
[ClusterScript]AWS Lambdaとclusterscriptを連携させてみる?[Cluster Script Advent Calendar 2024]
おはこんばんちわ、かせーです。
clusterの公式企画であるCluster Scriptアドベンドカレンダーの24日です。
script本体に関しては、凄腕の方々の記事がいっぱいなので、私は変化球でせめたいと思います。(私の実力で直球勝負はむりむり~(そうじゃない))
Cluster Script Advent Calendar 2024
はじめに
今回の記事は、AWSのLambdaを使った記事になります。
AWSの機能を使うため、多少有料になることをご承知おきください。
(相当なアクセスがない限り、普通にやる分には月々数百円程度です。)
他のサービスでは、無料で使えるAPIもあるので、用途に応じて使い分けると良いと思います。
有料になる分、
AWS側のAPIの制限などはないため、やりたい放題(?)になります。
その反面、
公開サーバとなるため、今回の記事ではそこまで触れませんが、セキュリティなどを考慮する必要があるので、本格的にやってみたい方は、色々な情報があるため、これを機にそのあたりも調べてみるのもアリだと思います。
全体像
まずは今回の全体像は下記のようになります。
今回の例は、AWS側は、LambdaとS3を使用します。

大枠としては、
clusterに作成したワールドでボタンを押すと、
外部通信機能を使ってボタンに応じたコマンドがLambdaに送られます。
Lambdaはそのコマンドで指定された、S3にすでにアップしてあるファイルを読み、応答としてその内容を返します。
Lambda側から返ってきた内容は、cluster上では「押してみて」と書かれたところに表示されるようにします。

AWS側の設定
まずは、AWS側の設定を行っていきます。
今回は、直接Lambdaを公開APIとして作成します。
※通常は、VPC内で作成してAPI Gatewayを通して~とか、IP制限かけて~など、本来はセキュリティ周りを考慮してやるべきですが、今回は割愛します。
あと、下記設定内容は、「こんな風にやるんだ~」ぐらいに考えて頂ければ。
今回、AWSコンソール上ですべて設定をやっていきます。
・今回作成するLambdaの関数は、public公開とします。
(VPCは使用しません)
・AWSのストレージサービスであるS3上に今回使用するファイルの置き場(バケット)を作成して、そこにファイルをアップしておきます。
(「Test1.txt」、「Test2.txt」、「Test3.txt」)
・clusterに応答を返すためのtokenもファイル化してS3にアップします。
(「token.txt」)
・LambdaとS3バケット間で通信ができるようにして、
LambdaからS3のバケットにアップされたファイルが読み取るように
権限を設定します。(書き込みはできない。)
では、やっていきましょう。
※AWSのアカウントはすでにある前提で進めます。
Lambda関数の作成
まず、Lambda関数を用意します。
関数名は、「cluster-lambda-api」とします。
Javascript関数を作れるnode.jsの最新を使う形の関数にします。
設定内容はこんな感じで作ります。


デフォルトから下記を変更しています。
・アーキテクチャは、x86_64でなくarmを選択
・Additional Configrationsは、
-「関数URLを有効化」ー認証タイプをNONEへ
(アクセス頻度のない一般公開なのでこのようにしています)
-オリジン間リソース共有(CROSS)を設定
上記を設定したら、「関数の作成」を押して関数を作成します。
関数が作成されると下記のような画面に切り替わります。

これでLambda関数の準備ができました。
上記画像に書いてある、
「関数URL」(赤枠)というのが「外部通信(callExternal)接続先URL」に設定するURLになります。
Unityの画面からアップロードするワールドの設定にこのURLを登録して、
トークンを取得してください。
取得したトークンを「token.txt」というファイル名で保存しておいてください。(改行なしで保存してください。)
後ほど使います。

ここのindex.mjsと書かれていく部分を使用して編集していきます。
Lambdaはデフォルトだと、Javascriptの「ESModules」を使ったコーディングになります。
ただ今回は、あえて、「CommonJS」の方で作っていこうと思います。
そのため、新たに「index.js」を作成します。

作成できたら、「index.mjs」を選び、Deleteキーを押して削除します。

さて、下準備が整ったところで、コー-ディング。。。
っと行きたいところですが、、、、
今回、LambdaからS3のバケットへアクセスもしたいため、
Lambdaの権限にS3へのアクセスの付与とS3のバケット作成を行います。
Lambdaの権限にS3のアクセスを使え加えます。
また、タイムアウトなどの設定も行っていきます。
今見ている画面に、「設定」というところがあるので、そこを選択します。
そうすると何やら、設定項目みたいなものが出てきます。

ここで、いくつか設定を行います。
まず、「一般設定」にある「編集」を押します。
タイムアウトの設定があるので、「3秒」となっているところを「5秒」にします。
(Cluster ScriptのcallExternalの送信結果待ちは、
応答が5秒ないタイムアウトしてしまうため合わせています。)

次に「既存のロール」にS3の権限(読み込みのみ)を追加します。
一番下の所の赤枠部分にリンクがあるので、クリックしてロールの編集画面を開きます。

ロール編集画面(別タブで開きます)が出ると、下記のような画面になります。
赤枠の部分を押して、「ポリシーをアタッチ」を選択し権限を追加していきます。


ポリシーアタッチ画面が開いたら、「AmazonS3ReadOnlyAccess」を選択して、「許可を追加」を押してS3の読み取り許可をこのLambda関数に追加します。

追加されると、ロール編集画面に戻り、S3の読み権限が付与されます。

この状態になりましたら、
赤枠の部分の「arn:aws:iam::~~」で始まる値を
コピーしておいてください。
※この値は、S3の設定の時に使用します。

この状態になったら、ロールの編集は終了ですので、タブを閉じてかまいません。
Lambda側の設定は以上で終了になります。
「保存」を押してLambda関数の画面に戻ります。

関数が更新され、設定が反映されます。
次に、S3でバケットを作成していきます。
S3の画面に移動します。(別タブに開くと良いと思います。)
移りましたら、「バケットを作成」を押してバケットを作成します。

バケット名は、「cluster-lambda-api-read-bucket」とします。


設定は、上記の形でデフォルトのままになります。
バケット名を設定したら、「バケットを作成」を押してバケットを作成します。
先ほどの画面に切り替わり、「cluster-lambda-api-read-bucket」が追加されます。

追加されましたら、「cluster-lambda-api-read-bucket」を選択して
「cluster-lambda-api-read-bucket」の中に移動します。

次に、Lambda関数からアクセスを許可できるように設定していきます。
この画面の「アクセス許可」を押して、設定画面を開きます。
して、バケットポリシーの編集を押します。

編集画面が開いたら、下記の内容を書き込み、「変更の保存」を押して保存します。

[ポリシーの内容] ※意味は割愛します。
{
"Version": "2012-10-17",
"Id": "Policy1734958722446",
"Statement": [
{
"Sid": "cluster-lambda-api-to-cluster-lambda-api-read-bucket-readonly",
"Effect": "Allow",
"Principal": {
"AWS": "[cluster-lambda-apiのロールのARNを書く(arn:aws:iam::~というやつ)]"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::cluster-lambda-api-read-bucket/*"
}
]
}
「Principal」の
「"AWS": "[cluster-lambda-apiのロールのARNを書く(arn:aws:iam::~というやつ)]"」
という部分は、
Lambda関数のロールの編集の時にありました
---
赤枠の部分の「arn:aws:iam::~~」で始まる値を
コピーしておいてください。
---
と記載していた値をここに記入してください。
ポリシーの設定が終了しましたら、
作成したバケットにファイルをアップロードします。
「オブジェクト」に移動して、
3つのファイルをドラグアンドドロップしてアップロードします。
中身は何でもいいのですが、
改行なしの下記内容のものをアップします。
(ファイルフォーマットはutf-8にしています。)
[test1.txtの内容]
テスト1のファイルだよ?
[test2.txtの内容]
test2だよ
[test3.txtの内容]
test3だべ~
ファイルを投げ込むと下記のような画面になるので、「アップロード」を押してファイルをバケットにアップロードします。

アップローが成功すると下記の画面になります。
「閉じる」を押して、「オブジェクト」画面に戻ります。


さらに、先ほど作成した「token.txt」もS3にアップします。
アップが終/わりましたら、S3のバケット設定は以上で終了です。

Lambdaの画面に戻りスクリプトを書いていきます。(やっと本題)
clusterからキューブを押してcallExternalを通してLambdaにPOSTされるデータは、下記を想定して書いていきます。
{
"body": "{"request":"{\"type\":\"[パラメータ]\"}"}"
}
例)
{
"body": "{\"request\":\"{\\\"type\\\":\\\"test_random\\\"}\"}"
}
各コードは下記のとおりです。
※詳細は下記コメントを見てください。
//S3へのアクセスするためにAWSのSDKをロード
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
// S3クライアント作成
const s3 = new S3Client({});
exports.handler = async (event) => {
// TODO implement
let data_type = null;
let data_body = {};
// clusterからcallExternalでPOSTされたデータを取得し、変数「data_type」に代入
if (event != null && event.hasOwnProperty('body')) {
data_body = JSON.parse(event.body);0
if (data_body.hasOwnProperty('request')) {
data_type = JSON.parse(data_body.request);
}
}
// S3にアップされたトークンファイル「token.txt」を読み込む準備
// (あえてS3から読むようにして、トークンのデータを見えずらくしているだけです。)
const token_cmd = new GetObjectCommand({
Bucket: "cluster-lambda-api-read-bucket",
Key: "token.txt"
});
let ret_str = '{"status":400, "message":"400 Bad Request"}';
let token = "";
try {
// S3にアップされている「token.txt」を読み込みそのファイルの中身を取得。
// その内容を変数「token」に代入する。
const response = await s3.send(token_cmd);
token = await response.Body.transformToString();
console.log(token);
} catch (err) {
console.error(err);
ret_str = JSON.stringify(err);
token = "";
}
console.log("token : " + token);
// tokenが取得できたら、POSTされた内容(type属性)に応じて、返す応答を変化させる。
if (token != "" && data_type != null) {
if (data_type.hasOwnProperty('type')) {
// POSTされたJsonの「Type」の値を変数「target_type」に代入する。
target_type = data_type.type;
// 変数「target_type」の内容が、「test_random」なら、
// 「text1.txt」、「text2.txt」、「text3.txt」の中からランダムに設定する。
if (target_type == "test_random") {
const target_types = ["test1", "test2", "test3"];
const random_array_index = Math.floor(Math.random() * 3);
target_type = target_types[random_array_index];
}
target_data_text = target_type + ".txt";
// そのファイルの内容をS3から取得する。
const command = new GetObjectCommand({
Bucket: "cluster-lambda-api-read-bucket",
Key: target_data_text
});
try {
// 取得したファイルの内容を変数「ret_str」に代入する。
const response = await s3.send(command);
const str = await response.Body.transformToString();
console.log(str);
ret_str = str;
} catch (err) {
console.error(err);
ret_str = err;
}
}
}
// clusterに返答するデータを作成し、変数「ret_body」代入する。
ret_dat = {"status":200, "message":"OK", "datas":ret_str};
let ret_body = {
verify: token,
response: JSON.stringify(ret_dat)
};
// HTTP POSに対する返答を返す。
// この応答が「onExternalCallEnd」で受け取ることになる。
//(実際はret_bodyの内容がclusterで取得できる。)
// それ以外は、正常に応答を返すためのおまじないと考えてほしい。
const response = {
statusCode: 200,
headers: {
"Access-Control-Allow-Headers":"Origin, X-Requested-With, Content-Type, Accept",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST"
},
body: ret_body,
};
return response;
};
ここまでできればもう一息です。
※実際はここで、clusterから来るデータを想定してダミーデータを使ったテストが出来ますが、今回は割愛します。
コードを記入しおえたら、このコードをデプロイしてLambda関数として反映します。

エラーが出る場合は、何か間違えている可能性がありますので、
見直してみてください。
以上で、AWS側の設定は終了です。
次はUnityに戻り、clusterのワールドをアップロードします。
clusterワールド側のScript
clusterのワールドは、「MinimalSample」をベースに作っています。
Hierarchyは下記のようにしています。

つくりは簡単に、Cubeを作成し、その下にTextViewを付けたGameObjectを置を1セットとして、4つ配置します。
Cubeはそれぞれ「ExntenedTest1」、「ExntenedTest2」、「ExntenedTest3」、「ExntenedTest_Random」とします。
それぞれのCubeの下に置くGameObjectの名前は、
すべて「TextRoot」とします。


※「TextRoot」は、GameObjectに「TextView」を張り付けています。
※「ExntenedTest2」、「ExntenedTest3」、ExntenedTest_Random」の
「TextRoot」も同じ設定です。




配置が終わりましたら、
「ExntenedTest1」、「ExntenedTest2」、「ExntenedTest3」、「ExntenedTest_Random」にScriptable Itemを追加します。
そしてそれぞれに下記のScriptを結びつけます。
[test1.js]
※「ExntenedTest1」をインタラクトしたときに、
「callExternal」で、Lambdaに下記のデータを送ります。
(正確にはもう少しデータが飛びますが割愛)
{ "body": "{\"request\":\"{\\\"type\\\":\\\"test1\\\"}\"}"}
const _GetType_Signal = "test1";
let isKeyExists = (obj, key) => {
if (obj) {
return obj.hasOwnProperty(key);
}
return false;
}
let initialize = () => {
$.state.isInit = true;
}
// 初期化
$.onStart( () => {
initialize();
});
$.onInteract(player => {
// インタラクトしたら、「callExternal」で、Lambda関数にアクセスする
const send_cmd = {"type": _GetType_Signal};
$.callExternal(JSON.stringify(send_cmd), _GetType_Signal);
});
// Lambda関数んからの応答内容を
// subNodeを通して、「TextRoot」の「Textview」へ値を反映させている。
$.onExternalCallEnd((response, meta, errorReason) => {
if ($.state.isInit) {
const res = JSON.parse(response);
let target = $.subNode("TextRoot");
if (isKeyExists(res, "datas")) {
target.setText(res.datas);
}
else {
target.setText("Error: " + response);
}
}
});
$.onUpdate(deltaTime => {
if ($.state.isInit == undefined) {
initialize();
}
});

[test2.js]
※「ExntenedTest2」をインタラクトしたときに、
「callExternal」で、Lambdaに下記のデータを送ります。
(正確にはもう少しデータが飛びますが割愛)
{ "body": "{\"request\":\"{\\\"type\\\":\\\"test2\\\"}\"}"}
const _GetType_Signal = "test2";
let isKeyExists = (obj, key) => {
if (obj) {
return obj.hasOwnProperty(key);
}
return false;
}
let initialize = () => {
$.state.isInit = true;
}
// 初期化
$.onStart( () => {
initialize();
});
$.onInteract(player => {
// インタラクトしたら、「callExternal」で、Lambda関数にアクセスする
const send_cmd = {"type": _GetType_Signal};
$.callExternal(JSON.stringify(send_cmd), _GetType_Signal);
});
// Lambda関数んからの応答内容を
// subNodeを通して、「TextRoot」の「Textview」へ値を反映させている。
$.onExternalCallEnd((response, meta, errorReason) => {
if ($.state.isInit) {
const res = JSON.parse(response);
let target = $.subNode("TextRoot");
if (isKeyExists(res, "datas")) {
target.setText(res.datas);
}
else {
target.setText("Error: " + response);
}
}
});
$.onUpdate(deltaTime => {
if ($.state.isInit == undefined) {
initialize();
}
});

[test3.js]
※「ExntenedTest3」をインタラクトしたときに、
「callExternal」で、Lambdaに下記のデータを送ります。
(正確にはもう少しデータが飛びますが割愛)
{ "body": "{\"request\":\"{\\\"type\\\":\\\"test3\\\"}\"}"}
const _GetType_Signal = "test3";
let isKeyExists = (obj, key) => {
if (obj) {
return obj.hasOwnProperty(key);
}
return false;
}
let initialize = () => {
$.state.isInit = true;
}
// 初期化
$.onStart( () => {
initialize();
});
$.onInteract(player => {
// インタラクトしたら、「callExternal」で、Lambda関数にアクセスする
const send_cmd = {"type": _GetType_Signal};
$.callExternal(JSON.stringify(send_cmd), _GetType_Signal);
});
// Lambda関数んからの応答内容を
// subNodeを通して、「TextRoot」の「Textview」へ値を反映させている。
$.onExternalCallEnd((response, meta, errorReason) => {
if ($.state.isInit) {
const res = JSON.parse(response);
let target = $.subNode("TextRoot");
if (isKeyExists(res, "datas")) {
target.setText(res.datas);
}
else {
target.setText("Error: " + response);
}
}
});
$.onUpdate(deltaTime => {
if ($.state.isInit == undefined) {
initialize();
}
});

[test_random.js]
※「ExntenedTest_Random」をインタラクトしたときに、
「callExternal」で、Lambdaに下記のデータを送ります。
(正確にはもう少しデータが飛びますが割愛)
{ "body": "{\"request\":\"{\\\"type\\\":\\\"test_random\\\"}\"}"}
const _GetType_Signal = "test_random";
let isKeyExists = (obj, key) => {
if (obj) {
return obj.hasOwnProperty(key);
}
return false;
}
let initialize = () => {
$.state.isInit = true;
}
// 初期化
$.onStart( () => {
initialize();
});
$.onInteract(player => {
// インタラクトしたら、「callExternal」で、Lambda関数にアクセスする
const send_cmd = {"type": _GetType_Signal};
$.callExternal(JSON.stringify(send_cmd), _GetType_Signal);
});
// Lambda関数んからの応答内容を
// subNodeを通して、「TextRoot」の「Textview」へ値を反映させている。
$.onExternalCallEnd((response, meta, errorReason) => {
if ($.state.isInit) {
const res = JSON.parse(response);
let target = $.subNode("TextRoot");
if (isKeyExists(res, "datas")) {
target.setText(res.datas);
}
else {
target.setText("Error: " + response);
}
}
});
$.onUpdate(deltaTime => {
if ($.state.isInit == undefined) {
initialize();
}
});

見てのとおり、ほぼ同じコードです。
「_GetType_Signal 」の値を各スクリプトで変えているだけです。
それにより、Lambda側に渡すtypeを変更して返ってくる値を変化させています。(コード流用した方が、バグなどの修正もしやすい。)
Cubeの下に配置した、GameObjectを「TextRoot」に固定したのも、
同じコードでSubNodeのTextViewの内容を変更したかったためです。
これで、cluster側は終了です。
ワールドをアップロードしたら、下記のようになります。
以上で終了となります。
おつかれさまでした。
おわりに
今回の例は、外部連携を余りする意味のない例ですが、
外部連携+Cluster Scriptを使用することで、
独自で何かをすぐに集計したり、多くのユーザに対してユーザごとに異なる結果を返したり、また、その結果に合わせて演出を変化させたり、、、
などが、可能になります。
Cluster Scriptで出来ることもどんどん増えているので、
新しい世界を自分の手で広げていくと楽しくなると思います。
皆さんチャレンジしてみてください。
なお、
今回はLambdaを使った例ですが、
EC2のような仮想PCを使うことで、
1つのサービスで「API通信」、「DBなどの処理」をすべて行うこなうようにするも可能です。
アクセス数などに応じて設計することで、
安価に安定した運用が出来たりします。
実運用となると、
セキュリティも考慮が必要になるなどでてきますが、
まずは、
こういう使い方も出来るんだなとキッカケになれば幸いです。
でわでわ。。
(24日の記事なのに書き終えたのは、
25日1:00、、、24日25時なのでセーフだな!(何))
※なんかAWSの設定がメインになっちゃってスマン。。。