ジョブグループを作成しました。どこかでユーザーはスピナーを見つめながら、「14言語に翻訳中…」という表示を見ています。間違ってはいませんが、それだけでは役に立ちません。いつまで経っても動かないからです。欲しいのは、目の前で件数が増えていく表示です。3件完了、次に4件、1つのロケールで失敗、そして完了——そんなふうに進捗が見えることです。
ジョブグループをポーリングしても実現はできますが、リクエストが増えますし、返ってくるのは毎回新しいスナップショットです。実際に何が変わったのかを知るには、前回との差分を取らなければなりません。WebSocketはその前提をひっくり返します。一度接続すれば、ロケールが1つ解決するたびにサーバーがイベントをプッシュします。しかもすべてのメッセージにグループ全体の完全な状態が入っています。つまり、差分を突き合わせるのではなく、スナップショットをそのまま描画すればいいということです。1フレーム落としても、再接続しても、タブを再読み込みしても、次のメッセージが再び完全な真実になります。
GET /jobs/localization/groups/:groupId/ws非同期ローカライゼーションが初めてなら、まず概要からご覧ください。ここでのgroupIdは、ジョブを作成したときに返ってきたものです。
このページの内容
メッセージタイプ#
ソケット上を流れるメッセージタイプは4種類あります。どのメッセージも、直前に何が起きたかを伝えると同時に、その時点でのグループ全体の状態を渡します。
| タイプ | タイミング | 主要フィールド |
|---|---|---|
snapshot | 初回接続時 | グループ全体の完全な状態 |
job.completed | ロケールが正常に完了したとき | jobId、locale、およびグループ全体の完全な状態 |
job.failed | ロケールが失敗したとき | jobId、locale、error、およびグループ全体の完全な状態 |
group.completed | すべてのジョブが解決したとき | groupId、status、およびグループ全体の完全な状態。このメッセージの後、サーバーは接続を閉じます。 |
すべてのメッセージには、現在のグループ状態を表すsnapshotオブジェクトが含まれます。そこにはtotalJobs、completedJobs、completedWithWarningsJobs、failedJobs、さらにジョブIDをキーにしたjobsマップがあり、各エントリにはそれぞれのlocaleとstatusが入っています。これらのカウントは、ジョブグループ endpointが返すものと同じです。つまり、ソケット経由のスナップショットでも、REST endpoint のポーリングでも、グループがどこまで進んだかは一致します。
スナップショットを描画する。差分は突き合わせない
どのイベントをすでに見たかを追跡したり、取りこぼしたメッセージを再生したり、部分更新をローカル状態にマージしたりする必要はありません。各メッセージでsnapshotを読み取り、それをそのままUIに描画してください。再接続すると、まずsnapshotが再送されるので、今参加したクライアントも、最初からずっと接続していたクライアントも、同じ状態に収束します。
メッセージペイロード#
以下は、サーバーが実際に送るフレームそのものです。IDの形式も実際と同じです(グループはljg_、各ジョブはljb_)。snapshotについては、すでに示した構造の繰り返し部分だけを"..."で省略しています。
接続すると、サーバーは現在の状態を送ります。
{
"type": "snapshot",
"snapshot": {
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"totalJobs": 3,
"completedJobs": 1,
"completedWithWarningsJobs": 0,
"failedJobs": 0,
"jobs": {
"ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
"ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "processing" },
"ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "queued" }
}
}
}各ロケールが完了するたびに、イベントには変化したロケール名が入り、更新後のスナップショットも含まれます。
{
"type": "job.completed",
"jobId": "ljb_B2c3D4e5F6g7H8i9",
"locale": "fr",
"snapshot": {
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"totalJobs": 3,
"completedJobs": 2,
"completedWithWarningsJobs": 0,
"failedJobs": 0,
"jobs": {
"ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
"ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "completed" },
"ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "processing" }
}
}
}失敗は接続切れではなく、通常のメッセージとして届きます。job.failedにはロケールとerrorが入り、同じ完全なスナップショットも一緒に送られます。失敗したロケールはjobsマップ内でstatus: "failed"になり、ほかのロケールはそのまま流れ続け、ソケットもgroup.completedまで動き続けます。
{
"type": "job.failed",
"jobId": "ljb_C3d4E5f6G7h8I9j0",
"locale": "ja",
"error": "Model timeout after 30 seconds",
"snapshot": { "...": "..." }
}すべてのジョブが解決すると、サーバーは最後のイベントを送り、接続を閉じます。
{
"type": "group.completed",
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"status": "completed",
"snapshot": { "...": "..." }
}最後のstatusは、すべてのロケールが成功した場合はcompleted、すべてのロケールで出力は生成されたものの、そのうち少なくとも1つで任意のpipelineステージが失敗した場合はcompleted_with_warnings、一部のロケールが成功して一部が失敗した場合はpartial、すべて失敗した場合はfailedです。これらがグループ全体として何を意味するのかは、ジョブグループを追跡するをご覧ください。
認識できないメッセージでもスナップショットから描画する
対応しているメッセージタイプで分岐し、それ以外はsnapshotから再描画するようにしておけば十分です。どのメッセージにも完全なスナップショットが入っているので、個別の分岐がないフレームでも、それを元に描画するクライアントなら正しい状態を保てます。
UIへの組み込み#
グループがそのまま進捗モデルになります。ジョブを作成したとき、202レスポンスでgroupIdとjobs配列が返ってきます。ロケールごとに1エントリずつです。そのレスポンスを元に進捗レコードを初期化すれば、ソケットが埋めていく形がそのまま手に入ります。つまり、カウント対象となる総数と、0から始まるカウンターです。
const { groupId, jobs } = await response.json();
await db.translationProgress.create({
contentId: content.id,
groupId,
totalLanguages: jobs.length,
completedLanguages: 0,
});次に、そのgroupIdに対してソケットを開き、各メッセージでsnapshotを読んで再描画します。ロケールが完了するたびにカウンターが増えていき、group.completedが届いたら止めます。
import WebSocket from "ws";
const groupId = "ljg_A1b2C3d4E5f6G7h8";
const ws = new WebSocket(
`wss://api.lingo.dev/jobs/localization/groups/${groupId}/ws`,
{ headers: { "X-API-Key": process.env.LINGO_API_KEY } }
);
ws.on("message", (raw) => {
const event = JSON.parse(raw);
const { snapshot } = event;
switch (event.type) {
case "snapshot":
console.log(`${snapshot.completedJobs}/${snapshot.totalJobs} complete`);
break;
case "job.completed":
console.log(`${event.locale} ready (${snapshot.completedJobs}/${snapshot.totalJobs})`);
break;
case "job.failed":
console.error(`${event.locale} failed: ${event.error}`);
break;
case "group.completed":
console.log(`All translations done: ${event.status}`);
ws.close();
break;
}
});3ロケールのグループで動かすと、進行中の様子は次のように出力されます。
1/3 complete
fr ready (2/3)
ja failed: Model timeout after 30 seconds
All translations done: partialカウンターは自然に進み、1つのロケールが失敗してもストリームは止まらず、partialが最終的な着地点を教えてくれます。つまり、ただのスピナーを本物の進捗バーに変えるのに必要な情報が、すべて揃っています。ここで大事なのは、このループが状態を蓄積しないことです。各分岐は、その時点のメッセージに入っているsnapshotを読むだけなので、初回接続時でも、更新時でも、再接続時でも、同じコードで正しく動きます。
APIキーはサーバー側に置く#
このソケットはAPIキーで認証します。REST endpoint と同じ、組織スコープのキーです。つまり、これを開く場所はブラウザではありません。クライアントJavaScriptにAPIキーを置けば、ソースを見た人なら誰でも、組織内のすべてのエンジンにアクセスできてしまいます。
接続元はブラウザではなくバックエンドにする
WebSocketは、すでにキーを保持しているサーバーから開き、その後イベントを自分で管理するチャネル経由でブラウザに中継してください。たとえば、WebSocket や server-sent events のストリームです。これならフロントエンドはリアルタイム進捗を受け取れ、キーがインフラの外に出ることもありません。
これはwebhookモデルと同じです。Lingo.dev に接続するのはサーバー側で、ユーザーに届くのは、自分のアプリが転送すると決めたものだけです。
この機能の位置づけ#
WebSocketはライブ表示のための仕組みです。1つのグループに紐づき、そのグループが完了すると接続は閉じます。タブを閉じても、あるいはデプロイをまたいでも失われない、耐久性のあるサーバー間配信が必要なら、webhooksと組み合わせてください。ソケットは画面上のUIをリアルタイムに動かし、webhookは各結果が届いた瞬間に記録します。同じcreate callから両方を配線すれば、ユーザーには進捗がリアルタイムで見え、バックエンドは誰が見ているかに関係なく出力を保持できます。
