はじめに
この記事は、「大規模ソフトウェアを手探る」の授業のレポートです。私たちは「Mattermost」というソフトウェアに翻訳機能を実装しました。
記事内では、機能実装の方法、またその過程で遭遇したエラーの対処法などについて記述しています。
1. Mattermostとは
Mattermostとは、利用者ごとにさまざまなカスタマイズを行うことができるSlackライクなアプリケーションです。自社サーバーでホスティングを行うことができるため、データの管理や保護が容易で、セキュリティ要件の厳しい組織に最適です。
2. ビルド方法
Mattermostには、Webアプリ、デスクトップアプリ、モバイルアプリの3つが存在します。今回は、実際の機能実装に用いたWebアプリのMacでのビルド方法について説明します。
1. 必要なツールのインストール
まずは、Mattermostをビルドするために必要な開発ツールインストールします。以下にインストールが必要なものとその方法を挙げます。
・Make
Xcodeのコマンドラインツール
$xcode-select --install
でインストールできる。
・Docker
公式サイトからDocker Desktopをダウンロードしてインストールします。
・Go
サーバーサイドの言語であるGoをインストールします。Homebrewを用いて、
$brew install go
でインストールできます。
・libpng
画像の読み込み、書き込みを行うためのライブラリ。Goと同様にHomebrewを用いてインストールします。
$brew install libpng
・Rosetta
M1~M3のチップを内蔵したMacの場合、インストールが必要。以下のコマンドでインストールします。
$softwareupdate --install-rosetta
・Node.js
Webアプリでは、Mattermost側で指定されたNode.jsのバージョン(ver18.10.0以上)が必要です。nvmやVoltaなどお気に入りのノードバージョンマネージャーを使ってダウンロードしてください。
2. サーバーの立ち上げ
「Mattermostとは」で述べたように、Mattermostはセルフホスティング形式です。そのため、自分でサーバーを立ち上げる必要があります。今回は開発のため、ローカルで立ち上げます。
まずは、以下のMattermostのGitHubのリポジトリをフォークしてローカルにクローンします。
$git clone https://github.com/YOUR_GITHUB_USERNAME/mattermost.git
クローンしたディレクトリ内のserverディレクトリに移動します。
$cd mattermost/server
serverディレクトリ内で以下のコマンドを実行します。
$make run-server
これでサーバーが立ち上がります。以下のコマンドを入力し、Jsonオブジェクトが返されれば、正常に動作しています。
$curl http://localhost:8065/api/v4/system/ping
# 返されるオブジェクト
{"AndroidLatestVersion":"","AndroidMinVersion":"","DesktopLatestVersion":"","DesktopMinVersion":"","IosLatestVersion":"","IosMinVersion":"","status":"OK"}
サーバーを停止したい場合は以下のコマンドを実行します。
$make stop-server
# 全てのDockerを停止したい時
$make stop-docker
3. Webアプリケーションの起動
ついにWebアプリを起動します。今度は、webappディレクトリに移動し、以下のコマンドを実行します。
make run
このコードを実行することでビルドが開始し、正常に完了すると、localhost:8065にアクセスすればアプリケーションが利用できます。
* 私が遭遇したエラー
以上の手順でビルドをしていると、make runを実行した後、roll-upの部分でエラーを吐く可能性があります。これは、ルートディレクトリをアプリケーションの最初ではなく、PCのルートディレクトリと呼んでいる可能性があります。その時は、mattermost/webapp/platform/components/rollup.config.js内のinput部分を、
export default [
{
input: 'src/index.tsx',
~~その他のコード~~
},
];
に書き換えてください。こうすることでしっかりと目当てのファイルが認識されるはずです。
3. APIの実装方針
今回実装した翻訳機能の概要を説明します。今回は、アプリケーションの中でメッセージを送る際に、自分のメッセージを英語に翻訳、そのまま送信できる機能を私は実装しました。方法としては、まずはユーザーが書いたメッセージをサーバーサイドに投げ、そこからGoogleのCloud Translation APIを使用して翻訳、その結果を再度フロントに投げて表示といった方法を用いました。Cloud Translation APIは一部有料で、APIKeyを使用するため、セキュリティの観点から、一度サーバーサイドを経由してからリクエストを投げる形となっています。
これから、主にこの機能を実装する上で1番ネックであったフロントとサーバーサイド間のAPIの実装方法を解説していきます。これにはいくつかの方法が存在するので、3章では方針のみを全て説明し、4章で実際に用いた方法の具体的な中身を説明します。
方法1 : Plugin機能を用いる
Mattermostには外部機能を追加するためにplugin機能が存在します。この方法は、公式にサポートされているプラグインフレームワークを使って追加するため、Mattermostのバージョンが上がっても比較的影響も受けにくく、セキュリティの面もコストが低くて済みます。
方法2 : 初期化の際に別のサーバーを立てる(Go言語のnet/httpライブラリの使用)
Mattermostがサーバーをたてる時のエントリーポイントとなる、main.goの中に、ポート番号の異なるサーバーを立てる方法です。この方法では、オリジン1の異なるサーバーにデータをアクセスすることになるので、CORS2の設定が必須です。また、MattermostではMakeファイルを使用してサーバーの立ち上げを行っているので、そのファイルも書き換える必要があります。うまく書き換えられないと、新たに立ち上げたサーバーが終了せず、ずっとポートが開いたままになる可能性があります。
方法3 : Docker Composeを用いる
Docker Composeとは、複数のコンテナを一度に作ったり、まとめて管理したりできるツールのことです。詳しくは以下のサイトを参考にしてみてください。この方法では、docker関連のファイルを書き換えれば良いのですが、Mattermost内のdocker関連のファイルは複数あり、私のようなDocker初心者には厳しかったです、、、
方法4 : APIに関連するコードを書き換える
この方法は、すでに実際にMattermostで利用されているAPIの動作を理解し、コードを加える方法です。当実験における「ソースを眺めて全容を把握できるわけがない程大きなソフトウェアをいかに扱い、必要なだけ動作を理解し、変更する」という趣旨に基づき、この方法が最もその趣旨に合っていると感じたので、今回はこの方法を採用しました。
4. 翻訳機能の実装
いよいよAPIの実装方法について、どうやってそのコードを見つけていったのか流れとともに説明していきます。
バックエンド側
バックエンドでは、APIエンドポイントの設定方法を理解する必要があります。APIに関するコードを見つけるために、エディタの検索機能を用いて"api"と検索をかけてみると、api.goというファイルが存在し、その中でapi.BaseRoutes.~やapi.Init~()といったコードが書かれていることがわかります。そのフォルダに移動すると、そのフォルダはserver/channels/api4というディレクトリの中に入っていることがわかり、他のファイルを見てもapiに関連してそうなものが多くあるので、バックエンドのAPI設定に関するコードはこのディレクトリ内で間違いなさそうです。
では、api.goの中身を見ていきましょう。api.goは最初に構造体の方が定義されており、その後にInit関数内でapi.BaseRoutes.~という引数にパスをとっている部分があり、最後にapi.Init~()といった関数が一気に書かれているといった中身になっています。エディタの機能の「Command+クリック」で実際にその関数が書かれている部分に飛ぶ方法を使うと、Init~()の中でもapi.BaseRoutes.~というものが書かれていることがわかります。api.BaseRoutes.~では、引数にパスのようなものがあることから、APIのエンドポイントの設定を行っていることが予想され、それはInit~()でも行われており、最後にapi.go内のInit関数でまとめて実行されていることが予想できます。
「Command+クリック」を使いまくって、詳細を見ていくと、mux.Routerが使用されていて、それはimportされていることがわかりました。実際にそのサイトを見てみると、使い方が載っており、さらにネットで検索すると、実際にAPIサーバーを作っている記事を見つけられました。
これらの記事に従って、私はapp.InitCustom()関数を作成し、エンドポイントを作成することができました。実際に作成したコードが以下になります。
<api.goの中身>
package api4
import (
"github.com/gorilla/mux"
~~
)
type ~~ struct {
//ここで構造体の型の定義
}
func Init(srv *app.Server) (*API, error) {
api := &API{
srv: srv,
BaseRoutes: &Routes{},
}
//ここからapi.BaseRoutes
api.BaseRoutes.Root = srv.Router
~~
api.BaseRoutes.Users = api.BaseRoutes.APIRoot.PathPrefix("/users").Subrouter()
~~
//ここからapi.Init~()
api.InitUser()
api.InitBot()
~~
//追加
api.InitCustom()
}
<custom.go(InitCustom()を定義した)の中身>
package api4
import (
"net/http"
"github.com/mattermost/mattermost/server/v8/channels/web"
"github.com/mattermost/mattermost/server/v8/pkg/handlers"
)
//APIエンドポイントの設定(/translate)
func (api *API) InitCustom() {
api.BaseRoutes.APIRoot.Handle("/translate",api.APISessionRequired(customSendMessageHandler)).Methods(http.MethodPost)
}
//設定したエンドポイントにアクセスされたときに実行される関数
func customSendMessageHandler(c *web.Context, w http.ResponseWriter, r *http.Request) {
handlers.SendMessageHandler(w, r)
}
このようにAPIエンドポイントをサーバー側で作成することができました。実際にCloud Translation APIを呼び出しているのは、SendMessageHandlerの中で、フロントから受け取った内容から、リクエストを作成し、外部のAPIのエンドポイントを叩く仕様になっています。
<SendMessageHandlerの中身>
package handlers
import (
"fmt"
"net/http"
"io"
"github.com/mattermost/mattermost/server/v8/pkg/services"
)
func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
//ここで外部APIを利用
response, err := services.CallExternalAPI(body)
if err != nil {
http.Error(w, "Failed to call external API", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(response))
}
<APIリクエストの中身>
package services
import (
"bytes"
"io/ioutil"
"net/http"
"os"
)
func CallExternalAPI(body []byte) (string, error) {
//環境変数からAPIキーを取得
apiKey := os.Getenv("API_KEY")
//Cloud TranslationのAPIエンドポイント
url := "https://translation.googleapis.com/language/translate/v2"
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-goog-api-key", apiKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
responseBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(responseBody), nil
}
フロントエンド
フロントエンドではバックエンドで実装した"/translateに入力されたメッセージをAPIリクエストの形で送信する必要があります。また、そのきっかけとして翻訳ボタンが押されたら現在のメッセージが翻訳されるという仕組みにしました。
まず、他のAPIがどのように叩かれているのかを調べるために、すでにあるAPIのエンドポイントをエディタで検索してみると、"/webapp/client/src/client4.ts"の中に書かれていることがわかりました。中身を見てみると、CLient4というクラスになっていて、各APIリクエストをメソッドとして実装しているようです。そこで、私も同じようにメッセージを受け取って、CLoud Translation APIにあるbodyの形でリクエストメソッドを実装しました。それが以下のコードです。
import ~~
export default class Client4 {
//定数の定義
logToConsole = false;
serverVersion = '';
clusterId = '';
token = '';
csrf = '';
url = '';
urlVersion = '/api/v4';
~~
//メソッドの実装
getUrl() {
return this.url;
}
~~
//例えばこれは、"/known"に対する、Getリクエストの関数
getKnownUsers = () => {
return this.doFetch<Array<UserProfile['id']>>(
`${this.getUsersRoute()}/known`,
{method: 'get'},
);
};
~~
//ここで"/translate"に対する、postリクエスト
reqTranslate = (message: string) => {
const body = {
q: message,
target: 'en',
};
return this.doFetch<any>(
`${this.getBaseRoute()}/translate`,
{method: 'post',
body: JSON.stringify(body),
},
);
};
こうすることでClient4クラスをインポートすれば、どこでも"Client4.reqTranslate(message)"とすることで翻訳結果を受け取ることができるようになりました。
実際に使用したところは、Chromeの既存の検証ツールと、拡張機能のReact Developer Toolsを使って、メッセージ欄のコンポーネントを見つけ出し、あとはコードを「コマンド+クリック」(書かれている変数や関数が使用されている部分に飛ぶ)や「コマンド+Fキー」(書かれている変数や関数をコード内で検索する)を使用して、機能実装に関連するコードの中身を読み解いていき、見つけ出しました。ボタンは既存のコンポーネントを使用し、modeごとに発火される関数が異なるという仕様であったので、translateモードを追加して先ほど作成したClient4.reqTranslateを使用しました。詳しい仕組みについては、他の班員の方が作成してくれた記事を参考にしてください。
友人のリンク
また、翻訳した結果をすぐにmessageに反映することなく、一度確認を入れて、採用後も日本語に戻せるようにしました。この中身については、今回最も伝えたいAPI実装の部分とは少しずれてしまうので、コードを載せておく程度にしておきます。また、実際に動いているデモ動画を最後に載せているので、参考にしてみてください。
//ボタンが押された時の処理
export async function applyMarkdown(options: ApplyMarkdownOptions): Promise<ApplyMarkdownReturnValue> {
const {selectionEnd, selectionStart, message, markdownMode} = options;
if (selectionStart === null || selectionEnd === null) {
/**
* in case we do not get the selectionStart or selectionEnd values
* from the textbox we simply set it to be at the end of the message
* string and return the message without changing it.
*
* This should never happen, so this just serves as an insurance fallback for very strange browser-bugs!
*/
return {
message,
selectionStart: message.length,
selectionEnd: message.length,
};
}
let delimiter: string;
/**
* all options that need to be handled in a ver specific way have their own applyMarkdown (sub-)functions.
* The rest just define their delimiters and return the basic applyMarkdownToSelection function.
*
* In a strange case where nothing works we throw an error.
*/
switch (markdownMode) {
case 'bold':
return applyBoldMarkdown({selectionEnd, selectionStart, message});
case 'italic':
return applyItalicMarkdown({selectionEnd, selectionStart, message});
case 'link':
return applyLinkMarkdown({selectionEnd, selectionStart, message});
case 'ol':
return applyOlMarkdown({selectionEnd, selectionStart, message});
~~
case 'math':
return applyMathMarkdown({selectionEnd, selectionStart, message});
case 'translate':
return applyTranslate({selectionEnd, selectionStart, message});
default:
throw Error('Unsupported markdown mode: ' + markdownMode);
}
}
//日本語かどうかを調べる
function containsJapanese(text: string) {
const japaneseRegex = /[\u3040-\u30FF\u4E00-\u9FFF]/;
return japaneseRegex.test(text);
}
const applyTranslate = async ({selectionEnd, selectionStart, message}: ApplySpecificMarkdownOptions) => {
try {
if (containsJapanese(message)) { //日本語の場合はAPIを使用しない
//作成したAPIを使用
const res = await Client4.reqTranslate(message);
const updateMessage: string = res.data.translations[0].translatedText;
return {selectionEnd, selectionStart, message, transMessage: updateMessage};
}
return {selectionEnd, selectionStart, message};
} catch (error) {
console.log('API error', error);
return {selectionEnd, selectionStart, message};
}
};
<UI部分>
const AdvancedTextEditor = ({
location,
channelId,
postId,
isThreadView = false,
placeholder,
afterSubmit,
}: Props) => {
const applyMarkdown = useCallback(async (params: ApplyMarkdownOptions) => {
~~ //メッセージを受け取る
const res = await applyMarkdownUtil(params);
if ((params.markdownMode === 'translate' && res.message)) {
//確認の表示
setShowConfirm(true);
if (res.transMessage) { //翻訳された時
setTransMessage(res.transMessage);
} else {
setTransMessage(pastMessage);
}
//過去のメッセージを保存
setPastMessage(res.message);
} else {
//他のモード
handleDraftChange({
...draft,
message: res.message,
});
}
~~
}
~~
//確認画面でボタンが押された時の処理
const handleShowConfirm = (useTransMessage: boolean) => {
if (useTransMessage) {
handleDraftChange({
...draft,
message: transMessage,
});
}
setTransMessage('');
setShowConfirm(false);
};
return (
<>
{showConfirm && (
<> //確認部分のコンポーネント
<Confirm
transMessage={transMessage}
onConfirm={() => handleShowConfirm(true)}
onCancel={() => handleShowConfirm(false)}
/>
</>
)}
~~
}
デモ動画
5. 得た知見・感想
今回、Mattermostを手探るにあたり、エディタの検索機能やジャンプ機能、ブラウザの検証機能に非常に助けられました。また、キータなどで先駆者たちの残してくれた記事が参考になりました。今時は、AIに聞いたらそれらしいことも返ってくるかもしれませんが、やはり不十分なところもあり、実際に人間が書いた記事を読み、自分の中に落とし込むという行為は重要だと感じました。これから、インターンなどで大規模なプロジェクトに参加することもあると思いますが、今回得た知見を活かしていきたいと思います。
-
オリジンとはポート番号までのURLのことを言います。
https://developer.mozilla.org/ja/docs/Glossary/Origin ↩ -
CORSとはオリジンの異なるサーバー内でデータのやり取りをできるようにする方法です。
https://qiita.com/Hirohana/items/9b5501c561954ad32be7 ↩