
[cluster] 複数ワールドに拡張しやすい外部通信機能のエンドポイント設計 Python/CGI編
この記事は Cluster Script Advent Calendar 2024 の 9 日目の記事です。
昨日は むさし⭐︎蛮樽絵師 さんの「豆鉄砲ゲームキット制作手順書」でした。昨日まで開催されていた cluster のゲーム制作イベント「ゆるゲームジャム」に併せて、自作のアイテムを配布・解説されています。
私も「空飛ぶほうき」のスクリプトを公開し、多くの皆さんに使っていただきましたが、こうやって一人の知見がシェアされることで他の皆様に拡がっていくとコミュニティ全体として出来ることがどんどん増えていくので楽しいですね!
はじめに
こんにちは。普段はメタバースプラットフォーム cluster で遊んでいることが多いやまちゃんと申します。綺麗なワールドを見て回ったり、自分でワールドを作ったりするのが好きです。
以下では、cluster で凝ったワールドを作る際に役に立つかもしれない Tips をご紹介します。
外部通信機能について
cluster のワールド作成フレームワークの Cluster Creator Kit (CCK) には、cluster クライアントとインターネット上のサーバとで通信することでリアルタイムに cluster 外の情報のやり取りが行える「外部通信機能」があります。
外部のデータベースにワールドの進行状況を記録してランキングボードを作ったり、各種 Web API を用いてワールド内部の環境が実際の気象データに連動するワールドや、LLM を用いた高度な対話が行えるワールドが作れるなど活用範囲の広い機能となっています。
しかしながら、この外部通信機能は各アカウントでエンドポイント URI が一つしか指定できないため、複数のワールド【※】で外部通信機能を使うためには他のワールドの情報が混じらない・あるワールドの修正が別のワールドの機能に影響を出さないように工夫が必要になります。
【※】ワールドを跨って共通の機能を提供するケースもあり得るので正確な記述ではありません。「複数の要件」と言った方がより正確かもしれませんが、まあ言いたいことは分かるよね…?
そこで、まずは外部通信機能を用いるワールドを増やす際に既存ワールドに影響を与えずに拡張しやすくする設計方針をご紹介し、更にそれに基づく私の環境での実装例を簡単にご紹介します。
基本的な考え方
まずは概要を図でざっくりと。
この例では、プレイヤーのハイスコアをサーバ側で管理している「ゲームワールド」と、外部の Web API に問い合わせた現実の気象情報を用いる「天気連動ワールド」があるものとします。
上記のうち「天気連動ワールド」が、現在の東京の気温をサーバに問い合わせる際のリクエストの流れで説明します。

要点は以下となります。
外部通信機能で送るリクエストデータは構造化データとする
リクエストデータに、どのワールドか分かるような識別名(上記例では "type")を入れることを決めておく
外部通信機能のエンドポイント側では、リクエストハンドラとワールドごとのビジネスロジックを処理を分け、更に構造分離する
現実的には ClusterScript が JavaScript ベースであるので、リクエストデータの構造化は JSON を用いることになると思います。
エンドポイント側は実装によりけりですが、昔ながらの CGI スクリプトによる実装であれば、*.cgi ではリクエストデータのうち識別名だけを見てどのワールドの要求なのかを判別して振り分けるに留めて、具体的なワールド毎の処理(ビジネスロジック)は別ファイルのスクリプトを呼び出して実行する…といった形式が考えられます。
WSGI?FaaS?なにそれおいしいの?
実装例
具体的な実装例を、私の作ったワールドのうち外部通信機能を本格的に使用した 2 つ目のワールドである『みんなのお絵描きさん - Stable Diffusion Interactive Demo』を用いてご紹介します。
なお、過去のしがらみ(外部通信機能を利用した 1 つ目のワールド設計時、まさに今回ご説明している内容を考慮していなかったツケ)により、上記で述べた内容と異なる記述になっている箇所があります。これは都度解説します。
また、セキュリティ上の理由(後述)により、一部抜粋・改変してのご説明となります。
リクエストデータの構築 @ ClusterScript
例として、cluster ワールド内から画像生成を実行するときの ClusterScript のリクエスト関数を示します。
function send_request_generate_image(player) {
// 構造化データのオブジェクトを構築する
let root = {};
root["type"] = "everybody_painter";
root["subtype"] = "generate_image";
root["idfc"] = player.idfc;
root["userId"] = player.userId;
root["userDisplayName"] = player.userDisplayName;
root["prompt"] = $.state.prompt;
root["negativePrompt"] = $.state.negativePrompt;
root["inferenceNum"] = $.state.inferenceNum;
// 中略。送りたいデータを色々詰め込む
// 構造化データを JSON 文字列にする
let request = JSON.stringify(root);
// 外部通信機能で送信する
$.callExternal(request, "");
}
冒頭にて構造化データに、呼び出し元を区別するためのキー type・値 everybody_painter の組を入れています。
「基本的な考え方」では、root 直下には type と body の 2 要素だけがあると書いていましたが、そうなっていません。これは以下の 2 つの理由によります。
1 つ目の外部通信機能を用いたワールドのリクエストが、root 直下に要素をベタ書きしていたため
通信データサイズがギリギリで、少しでも文字数を削りたいため
この差分により、エンドポイント側スクリプトにて、リクエストハンドラとビジネスロジックを完全に分離できない…という制約が生じています。後述します。
…というわけで type と同一レベルに平置きの形で、エンドポイント側のビジネスロジックに渡したいデータを列挙しています。
type の直後にある subtype ですが、『みんなのお絵描きさん』では複数のトリガで外部通信機能を用いているため、それらを区別するためビジネスロジック側で使用するサブ識別名となっています。
エンドポイント側のファイル構成
「基本的な考え方」にて、エンドポイント側ではリクエストハンドラとビジネスロジックを構造分離することを挙げました。
以下では、Python/CGI を用いて実装した私のエンドポイントのファイル・ディレクトリ構成を基に説明します。
こちらもセキュリティ上の理由(後述)により、一部改変してのご説明となります。
/var/www/cgi-bin/path/to/
|
+- everybody_painter/
| |
| +- __init__.py
| +- everybody_painter_main.py
| +- (以下略。固有のデータベースファイルやログファイルなど)
|
+- playground_ranking/
| |
| +- __init__.py
| +- playground_ranking_main.py
| +- (以下略。同上)
|
+- call_external.cgi
外部通信機能のエンドポイントとして指定する URI は https://(当該エンドポイントが存在するサーバのFQDN)/cgi-bin/path/to/call_external.cgi となります。いわゆる CGI スクリプトで、リクエストハンドラとなります。
各ワールドごとのビジネスロジックは個別のディレクトリに収められており、パッケージ化されています(この辺りの詳細は省略します。興味のある方は「Python パッケージ化」とかでググってください)。
ここでは、CGI スクリプトにリクエストハンドラ、個別のディレクトリ(パッケージ)にビジネスロジックを配置して、ファイル構造として分離しているんだなぁという理解で OK です。
以下では、それぞれの役割と具体的な設計について解説します。
リクエストデータの解釈 @ リクエストハンドラ
リクエストハンドラ(CGI スクリプト)は、外部通信機能によって cluster の中継サーバから送られてきた要求データを受け取り、仕様に沿った形で返答データを送り返すのが仕事です。
call_external.cgi のソースは以下のような感じになっています。
拡張子は .cgi ですが中身は Python です。先頭の #! で始まる行(shebang シェバン)により、Python スクリプトであると解釈されます。
#!/usr/bin/python3
import json
import datetime
import time
import sys
import os
import io
import playground_ranking
import everybody_painter
TOKEN = '00000000-0000-0000-0000-000000000000'
def main():
# for Japanese
sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8')
resp_root = {}
resp_root['type'] = 'invalid'
# external call API allow POST method only
if os.environ.get('REQUEST_METHOD') == 'POST':
if os.environ.get('CONTENT_LENGTH'):
recv = sys.stdin.read(int(os.environ.get('CONTENT_LENGTH')))
root = json.loads(recv)
if 'request' in root:
req_str = root['request']
# unescape nested JSON string
req_str = req_str.replace(r'\"', '"')
req_root = json.loads(req_str)
if 'type' in req_root:
if req_root['type'] == 'playground_ranking_rank_in':
resp_root = playground_ranking.on_rank_in(req_root)
elif req_root['type'] == 'playground_ranking_get_list':
resp_root = playground_ranking.on_get_list(req_root)
elif req_root['type'] == 'everybody_painter':
resp_root = everybody_painter.on_request(req_root)
# create response data
head = 'Content-type: application/json'
if resp_root['type'] == 'invalid':
resp_root['time'] = datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S.%f')
resp = json.dumps(resp_root, separators=(',', ':'))
# escape nested JSON string
resp = resp.replace('"', r'\"')
body = '{"verify":"%s","response":"%s"}' % (TOKEN, resp)
print(head)
print()
print(body)
if __name__ == '__main__':
main()
if 文のネストがちょっとビジーなのと、自力で CGI を扱っているのが今時アレですが、重要なのは
ビジネスロジックを import some_package の形で読み込むことで、各ワールドの固有処理の変更時に共通部分にケアレスミスによる不具合が生じる可能性を低減している
リクエストの内容(「基本的な考え方」の "body" に相当)をビジネスロジックに丸投げし、返答内容は中身を一切見ずにそのまま cluster に送り返している
という点です。
なお、「基本的な考え方」と異なり "type" とその他の要素とが同一レベルに平置きされているため、実際にはビジネスロジックに外部通信機能の知識である "type" が渡ってしまっています。
また、"playground_ranking" は "type" を 2 つ使用しています。これは外部通信機能を初めて使ったワールドだったためこの辺の考慮が漏れていたためです…。
これは先述の妥協点になります。
ぶっちゃけ、最初に外部通信機能のデータ構造を考慮してなかったせいで色々と妥協が生じてしまっているので、皆さんがそうならないように…というのがこの記事を書いている動機だったりします
各ワールド固有の処理 @ ビジネスロジック
次に、ビジネスロジック部(Python パッケージ部)について。
everybody_painter_main.py のコードから、先述のリクエストハンドラで呼んでいる everybody_painter.on_request() を見ていきます。
def on_request(req_root):
log_info('on_request() start')
resp_root = {}
subtype = 'invalid'
# request to generate images
if req_root['subtype'] == 'generate_image':
resp_root = generate_image(req_root)
subtype = req_root['subtype']
# get statuses of image generation system
elif req_root['subtype'] == 'get_statuses':
resp_root = get_statuses(req_root)
subtype = req_root['subtype']
# illegal subtype
else:
log_error('unknown subtype "%s" has been received!' % (req_root['subtype']))
current_time = datetime.datetime.now().strftime('%H:%M:%S.%f')[:12]
resp_root['current_time'] = current_time
resp_root['type'] = req_root['type']
resp_root['subtype'] = subtype
#log_warn('resp_root = ' + str(resp_root))
return resp_root
ここで、最初の ClusterScript で出てきていたキー "subtype" とその値 "generate_image" の組み合わせが登場しています。
これらのワールド固有の知識にリクエストハンドラが一切触れないようになっている、というのが今回の仕組みの肝になります。
ビジネスロジック内では、subtype の値に応じてさらに処理を呼び分けていますが、今回は説明を省略します。
まとめ
外部通信機能を使う時は、最初のワールドを作るときに二個目以降をどう追加するか考えてデータの持ち方を設計しましょう!
既にやらかしちゃった人は、私と一緒にうんうん唸りましょう。そしてバッドノウハウを共有してください (笑)。
Cluster Script Advent Calendar 2024、明日は かおも さんの「ClusterScriptでも型チェック&コード補完」です。
特に大規模なコードを実装しようとするとき、不具合を生みづらく・生産性を向上させる方法を学べそうなテーマですね!
補足
なぜ一部の記述を書き換えているのか
何らかの理由で外部通信機能のエンドポイントの URI が漏れた場合に、通信内容の書式が判明していると、悪意のある第三者によってエンドポイントを攻撃される可能性があるためです。
$ curl -X POST -H 'Content-type: application/json' \
-d '{"request":"{\"type\":\"shooting_lv01\",\"username\":\"attacker\",\"score\":1000000000}"}' \
https://example.com/cgi-bin/path/to/call_external.cgi
↑こういう感じで、正規の方法以外で(cluster の中継サーバ以外から)エンドポイントにアクセスされる恐れが生じる、ということです。
また、外部通信機能のベリファイトークンは絶対に公開しないでください。他者に通信を乗っ取られる可能性があります。
今回のコードサンプルでは nil UUID【注】に置き換えてあります。
全ての文字が 0 であるような UUID。UUID の書式として正しいが、決して有効な値とならないことが仕様で定められている、以下の文字列。
00000000-0000-0000-0000-000000000000
リクエストハンドラの実装もうちょっと何とかならないの
type の値とパッケージの名前を揃えて、importlib を使って動的にパッケージをロードし分けることで、新しいワールドの追加時にリクエストハンドラのコードを一切変更不要・かつ必要なパッケージだけをロードすることで省リソース化できる可能性があります。
ただ、私の場合は前述の通り最初に作ったワールドの API が複数ワールド対応の事を考えておらず、例外処理が必要になるか作り直しになるので、その手間と照らし合わせて現状維持としています…。もっと規模が大きくなったら考えます。