見出し画像

[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.js作成

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

index.mjs削除

さて、下準備が整ったところで、コー-ディング。。。
っと行きたいところですが、、、、
今回、LambdaからS3のバケットへアクセスもしたいため、
Lambdaの権限にS3へのアクセスの付与とS3のバケット作成を行います。

Lambdaの権限にS3のアクセスを使え加えます。
また、タイムアウトなどの設定も行っていきます。
今見ている画面に、「設定」というところがあるので、そこを選択します。
そうすると何やら、設定項目みたいなものが出てきます。

設定画面

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

タイムアウト変更(赤枠)

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

ロール編集へ(赤枠)

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

ロール編集画面

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

「AmazonS3ReadOnlyAccess」を追加

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

S3の読み込み権限が付与された状態

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

S3の設定の時に使用するARNの場所

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

保存を押して設定を反映

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

バケットを作成

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

設定は、上記の形でデフォルトのままになります。
バケット名を設定したら、「バケットを作成」を押してバケットを作成します。

先ほどの画面に切り替わり、「cluster-lambda-api-read-bucket」が追加されます。

「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のファイルだよ?

test1.txtの内容

[test2.txtの内容]
test2だよ

test2.txtの内容

[test3.txtの内容]
test3だべ~

test3.txtの内容

ファイルを投げ込むと下記のような画面になるので、「アップロード」を押してファイルをバケットにアップロードします。

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

アップロード終了後

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

token.txtもアップロード

Lambdaの画面に戻りスクリプトを書いていきます。(やっと本題)

clusterからキューブを押してcallExternalを通してLambdaにPOSTされるデータは、下記を想定して書いていきます。

{
"body": "{"request":"{\"type\":\"[パラメータ]\"}"}"
}

例)
{
"body": "{\"request\":\"{\\\"type\\\":\\\"test_random\\\"}\"}"
}

LambdaにPOSTされる想定データ

各コードは下記のとおりです。
※詳細は下記コメントを見てください。

//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関数として反映します。

デプロイ(Deploy)を押してを押して記入したLambda関数のコードを反映させる

エラーが出る場合は、何か間違えている可能性がありますので、
見直してみてください。

以上で、AWS側の設定は終了です。
次はUnityに戻り、clusterのワールドをアップロードします。

clusterワールド側のScript

clusterのワールドは、「MinimalSample」をベースに作っています。
Hierarchyは下記のようにしています。

Hierarchy

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

「ExntenedTest1」Transform
「ExntenedTest1」下の「TextRoot」
※「TextRoot」は、GameObjectに「TextView」を張り付けています。
※「ExntenedTest2」、「ExntenedTest3」、ExntenedTest_Random」の
「TextRoot」も同じ設定です。
「ExntenedTest2」Transform
「ExntenedTest3」Transform
「ExntenedTest_Random」Transform
配置した様子

配置が終わりましたら、
「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の設定がメインになっちゃってスマン。。。