simplestarの技術ブログ

目的を書いて、思想と試行、結果と考察、そして具体的な手段を記録します。

AWS:LambdaにAuthorizerを設置してからAPIGatewayを公開する

前書き

インスタンスを start するだけで docker コンテナが起動して、機能し始めるというのは用意できてます。
あとはこれを PlayFab から HTTP リクエストで呼び出すだけ、なのですが、ここで公開 API でありつつも
認可の仕組みありきという API Gateway と接続した Lambda のお仕事にするので、そのあたりの具体的な手順と概念について記録します。

API Gateway

Lambda の試験を終えて、外から呼び出そうとトリガーに API Gateway を追加します。(ウィザードで新規作成)
f:id:simplestar_tech:20200719123820p:plain

プロトコルを HTTP にして、Authorizer 作成をします。

発行者URLに AWS Cognito の次の形式の url を書いて渡します
https://cognito-idp.us-west-2.amazonaws.com/us-west-2_XXXXXXX

一緒に Client ID を求められるので、 AWS Cognito でアプリクライアントを新規登録して、それの ID を打ち込みます。

これで Authorizer 設定完了

AWS Cognito

作るだけです。
昔やった
simplestar-tech.hatenablog.com

で、ユーザーのサインアップを許可しますか? 管理者のみがユーザーを作成できます として、勝手にユーザー追加されないようにします。

で、管理者としてユーザーを追加した後
Amazon Cognito ドメイン名を空いている prefix で取得し、次の通り、リダイレクトURL付で URL を作って
docs.aws.amazon.com

ログインの画面を開きます

あー実は 有効な ID プロバイダを Cognito で作らないとダメ(次の画面の設定にする)

f:id:simplestar_tech:20200719124633p:plain

管理者が追加した e-mail ユーザーに初回パスワードが送られてくるので、このログイン画面で初回パスワードを入れログインし
すぐに新しいパスワードに切り替えて、認証成功させる

上記画像のリダイレクト url とリクエストする リダイレクト url が一致しないとエラー画面になるので、しっかり合わせること
一つでも間違えると、理由なきエラー画面なので、なんとかネット上のやりとりで、url に違いがあるとダメということにたどり着けた

これでログイン画面の表示と、正しい email と パスワードでログイン成功となる
成功すると、リダイレクト後の url に AuthorizationCode つまり code=~~のクエリ文字列があるので記録する

Authorization Code

さっきリダイレクト url で code= で得たものを
/login
と同様に
/oauth2/token
のcode=AUTHORIZATION_CODEに設定して、次の通り POST リクエストを送る

docs.aws.amazon.com

サンプルでいうところのこれ

POST https://mydomain.auth.us-east-1.amazoncognito.com/oauth2/token&
Content-Type='application/x-www-form-urlencoded'&
Authorization=Basic aSdxd892iujendek328uedj

grant_type=authorization_code&
client_id=djc98u3jiedmi283eu928&
code=AUTHORIZATION_CODE&
redirect_uri=com.myclientapp://myclient/redirect

ああ、 Basic の次に続くやつは Baes64 文字列で、変換元は "client_id:client_secret" これは Cognito の管理画面で手に入る

得られる json を見ると長ーい文字列で

"id_token" が得られるので、これを記録
あと、Cognito でトークンの有効期間が設定できて、最長で 10年設定できる(今日から10年に設定した)

API Gateway を呼ぶ

これで設定した Authorizer にマッチする管理ユーザーの 10年有効な id_token を得られたので、Authorizer 設定で指定した通り Authorization ヘッダーに仕込む

自分はこれで認可されて APIGateway の先の Lambda をたたくことができた

追記

実は id_token は 3600秒後にかならず有効期限が来て使えなくなります。
一緒に refresh token が得られていると思うので、

grant_type = refresh_token
refresh_token = それ

として oauth2/token API をたたきます

更新トークンは 30~最大設定で 3650 日、有効なので

また id_token を 1時間で有効期限付きで取得できます。

自動化したいなら更新トークンで id_token を得て、その id_token で API Gateway の Lambda を実行する、といった感じですね


参考:こちらはトークンオーソライザー使ってますね(過去の自分)
simplestar-tech.hatenablog.com

課金するとデータサーバーが起動して、お金が尽きたら勝手にデータサーバーを止める仕組みづくり

前書き

長いことはじまりの世界のデータサーバーは一つ
AWS Elastic Beanstalk で環境を用意して運用して半年~1年が過ぎようとしてます

ここでモチベーションがあがる機能追加案がありまして、世界を広げるという操作をプレイヤーが行えるようにする
具体的にはプレイヤーがお金を払い、サーバー運用費を越える額がたまった後、それをトリガーに一つとなりのワールドのデータサーバーが立ち上がり
これを編集できるようになるというものです。

ec2 インスタンスに docker 環境を作る

こちらの記事を参考に手を動かしてみます。
Amazon Linux2にDockerをインストールする - Qiita

AWS ec2 インスタンスを作る
ssh 接続はセキュリティーグループで自宅の IP アドレスからのアクセスからのみ許可
秘密鍵を自身の .ssh/aws フォルダに配置して、これを指定して ec2-user というディフォルトユーザー名で、出来上がった ec2 インスタンスDNS アドレスにたいして ssh 接続を成功させます。

この時点で、自宅と同じ IP アドレスであり、インスタンス作成時に作った自分しか知らない秘密鍵を持ち、これを指定した者だけが
この ec2 インスタンスに入って自由に操作できるようになります。

とりあえず最新状態にして docker エンジンのコマンドを打てるようにしてみましょう

sudo yum update

で y 押す

docker をインストール
sudo yum install -y docker
インストールした docker をスタート
sudo service docker start
sudo 打たなくて良いように ec2-user アカウントを docker グループに追加(※有効化されるのは次のログインからだから注意)
sudo usermod -a -G docker ec2-user
インスタンス再起動時に docker コマンドが打てるよう自動起動を有効
sudo systemctl enable docker
docker-compose 実行ファイルをダウンロードして、実行権限付与
sudo curl -L https://github.com/docker/compose/releases/download/1.26.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

動作確認
docker --version
docker-compose -v

どちらもバージョン番号が出力されれば ec2 インスタンスを起動して Amazon Linux 2 にて docker 環境整えるはクリア

docker 環境に ECR から pull してアプリの動作確認

まずローカルで docker image を作成します。

FROM golang:1.14 AS build
RUN apt-get update && apt-get install -y \
    python3-dev \
    python3-pip
RUN go get -u github.com/aws/aws-sdk-go
WORKDIR /app
COPY . .
RUN GOOS=linux CGO_ENABLED=0 go build -o /tmp/cubedataserver application.go

FROM alpine:3.12
COPY --from=build /tmp/cubedataserver /bin/cubedataserver
ENTRYPOINT ["cubedataserver"]

この Dockerfile には go build した成果物をビルドに必要なライブラリのないスリムな alpine image 上で実行 という
コンテナを小さくするアイディアが記載されています。

ecr にログインして、docker push して、IAM で ecr リソース指定アクセス権限ポリシーをつけておけば docker pull できました。
いざ、ローカルテストと同じように docker コンテナが機能し、セキュリティーグループで自宅から指定ポートのインバウンド設定すれば
自宅からキューブ情報の操作ができること確認しました。(アプリ層でトークンが正しいこと確認してます)

lambda でインスタンスを stop, start する

できれば ALB のルールもつけたり、消したりしてほしい

参考にした記事はこちら
dev.classmethod.jp

docker run するときに --restart=always オプション付けていれば、インスタンスの起動完了と同時にサーバーとして機能をはじめました。

IAM ロールで、指定リージョンの指定インスタンスIDに関してのみの start, stop ポリシーを付けたので、暴走する関数にはならない
lambda を実行すれば機能したので、これを PlayFab から呼び出せるので、すべての技術課題は解決した

完成はしてないが、あとは調査いらずの作業だけになったぞ

参考
PlayFab Cloud Script → API Gateway → Lambda(python) → EC2 start, stop

CubeWalkGame:AIが使う脳内コンテキスト

前書き

自作ゲームの開発ネタです。
考えていることを書くと、頭が整理されてコードに落とし込める気がして

最近の進捗絵はこんな感じ

f:id:simplestar_tech:20200616212308p:plain
木のぼり

動画はこちら

作りたいのはワールドロジックとその共用

世界がどうなるかの先行きを小さなロジックを使って取り扱いたくて
キューブのデータをある場所を中心に切り取ってコピーを受け取れる方法がこちら

        /// <summary>
        /// 対象のチャンク、チャンク内のキューブ位置インデックスと格納先配列を渡すと、データを埋める
        /// </summary>
        /// <param name="chunkInt3">ベースとなるチャンクの位置インデックス</param>
        /// <param name="chunkCubeInt3">ベースチャンク内での中心となるキューブの位置インデックス</param>
        /// <param name="radius">ppCubeデータの半径キューブ数</param>
        /// <param name="ppCubeData">埋めてもらうキューブデータ配列</param>
        static unsafe void FillAroundCubeData(Vector3Int chunkInt3, Vector3Int chunkCubeInt3, int radius, byte** ppCubeData)

radius * 2 + 1 の 3乗要素数の byte x 4 配列を確保して渡すと、指定した位置を中心にローカル最新データを埋めてくれるもの

うまくいきまして、AIが周囲のキューブの状態から未来予測を行い、木が育つことをサーバーに報告すると
サーバーが検算して合格したら、本当に木が育つようになりました!

最近の様子を記録したので載せておきますね

Python:コマンドの実行と標準出力文字列の取得+aws cli 呼び出し例

aws cli を呼べる環境なら、次の pthon で動作中の task arn を for 文で処理できるよ

import subprocess
import json
from typing import Union, List


def callCommand(commands: List[str]) -> str:
    """
    コマンド呼び出しと標準出力文字列を返す
    """
    cmd = []
    for command in commands:
        cmd.append(command)
    subprocess.call(cmd)
    output = subprocess.Popen(cmd, stdout=subprocess.PIPE)
    return output.communicate()[0]


def main():
    cmd = []
    cmd.append("aws")
    cmd.append("ecs")
    cmd.append("list-tasks")
    cmd.append("--region=ap-northeast-1")
    cmd.append("--cluster")
    cmd.append("clustername")
    cmd.append("--service-name")
    cmd.append("servicename")
    jsonS = callCommand(cmd)
    for taskArn in json.loads(jsonS)['taskArns']:
        print(taskArn)


if __name__ == "__main__":
    main()

CubeWalk:自分で遊んでダメ出し

ずっと未公開だったメモ記事
公開して完成時に当時を振り返ります。

前書き

最近のチート対策のサーバー側の複雑な CustomData 計算は本当はやりたくなかったんですけど、こまめに記録しながらなんとか動き始めました。
たとえるなら、まだごみが詰まった歯車が回りだした状態で、ちょっとならし回転させて、不純物を取り除いていこうと思います。

今回は機能追加ではなく、動かして気になるところをつぶしていく回です。
未来の自分に仕事をまかせて、とりあえず何でも書いてみようと思います。

ゲーム開始までのながれがない

いきなり機能追加となりましたが、確かに起動に時間を要するうえ、変に操作されるとすぐに壊れてしまうので
今一度ゲーム開始までのながれを確認してみようと思います。

アイテムの増減は一瞬で行われてほしい

これについては前々から気づいていて、サーバーの結果をもらってからアイテム数の増減には問題がありますよね。
そこで、ローカルでは一瞬で操作結果を反映しつつ、サーバー側にはインターバルを組んでアクションを送信してもらいたいものです。
ローカルではアクションをキューに詰め込んで、順にサーバーにリクエストするようにしてみます。

これなんですが、レスポンスが来るまで破壊アニメーションとしようとしてます。(きっとうまくいく)

そろそろ音が鳴ってほしい

UI操作をしているときに、ピンポンと音が鳴ってくれると嬉しいのですけど

わかる

番号のテクスチャつまらなくなってきた

キューブのマテリアルをそろそろ作りこんでほしいですね

こちらは
simplestar-tech.hatenablog.com
こちらのシェーダを作って対処しました。

オンライン通信でキューブアクションを同期

互いにアクションを交換し合い、世界の共有を頑張ってほしいです。

サーバー側の関数見直しで、一緒に考えてみます。

CubeWalk:ゲーム開始までの流れをどう実装するか案

これまた下書きのままずっと放置されていた記事
Unity のコンポーネントが Start や Awake で好き放題やるのは、引き継いだり後から見直したときに
全体で何しているかわからないので、例えばオンラインのログイン完了後に処理してほしいとかそういうの指示するとき困るよね
これをどうするか考えた記事

開始フロー

調べてみると、各コンポーネントがスタート時に自由に開始していました。

やりたいことはゲームのタイトル情報、プレイヤーの現在の状況を読み込んでから

タイトルメニューで情報表示
プレイヤーの現在の状況から再スタート

バージョンとIDの表示だが

まずプレイヤーIDとは?

なるほどGenericIDとして、これを使ってサーバーAPIでPlayFabIDを問い合わせることができる

GenericIDの実態はサービス名とIDのペア

これを見てひらめいたのは、ゲーム内のキューブにGenericIDを刻み込み
サービス名はワールド名とし、IDにはワールド内の位置インデックス0~1700万くらいFFFFFFとする
ぎりぎり
その位置とキャラクターを結ぶようにGenericIDを加える

そうすれば、キューブ内のデータに描画用の情報を詰めてもよいことになる。
これがプレイヤー復活のためのメモリーキューブで、開始時にプレイヤーはしゃがんだ状態でキューブと交換するようにして現れることになる

開始フローを決める前に、まだまだ、そういうことしたいと思っていて要素がそろいきっていないことがわかった。

結論

最終的に GameManager が各種イベント接続をするコードを担当
そこを見ればゲームの処理の流れが追えるようになりました。

あとからどういうゲーム実装だっけ?と思って実装を読んだときに GameManger だけ読めば漏れなくわかる
というのを目指します。

React(SemanticUIかな)覚書き

なんか下書きでずっと残ってたのですが
消すのももったいないので後で自分が引っ張れるように公開しておきます。

初の React カテゴリ記事

f:id:simplestar_tech:20200307141943p:plain
こんなのの見た目を作るのに

import React, { FC } from 'react';
import { Link } from 'react-router-dom';
import { List } from 'semantic-ui-react';

import './Home.css';

const companies = ['unity-technologies', 'UnitySamples'];

const Home: FC = () => (
  <>
    <List celled relaxed>
      {companies.map(companyName => (
        <List.Item className="list-item" key={companyName}>
          <List.Icon name="users" size="large" verticalAlign="middle" />
          <List.Content>
            <Link to={`/${companyName}/members`}>{companyName}</Link>
          </List.Content>
        </List.Item>
      ))}
    </List>
  </>
);

export default Home;

f:id:simplestar_tech:20200307142911p:plain
こういう見た目は

import React, { FC } from 'react';
import { Redirect, Route, Switch } from 'react-router';
import { Grid, Menu, Container } from 'semantic-ui-react';
import { Link } from 'react-router-dom';

import './App.css';
import './styles/semantic.min.css';

interface MenuItem {
  name: string;
  linkto: string;
}

const title = 'SimplestarGame';
const menuItems = [
  { name: 'SimplestarGame', linkto: '/UnitySamples/members' },
  { name: 'Home', linkto: '' },
  { name: 'About', linkto: '/UnitySamples/members' },
  { name: 'Contact', linkto: '/unity-technologies/members' },
];

const App: FC = () => (
  <>
    <Grid padded className="tablet computer only">
      <Menu borderless fluid inverted size="huge">
        <Container>
          {menuItems.map((item: MenuItem) => (
            <Menu.Item header as={Link} to={item.linkto}>
              {item.name}
            </Menu.Item>
          ))}
        </Container>
      </Menu>
    </Grid>
  </>
);

export default App;
const useTimer = (limitSec: number): [number, () => void] => {
  const [timeLeft, setTimeLeft] = useState(limitSec);

  const reset = () => {
    setTimeLeft(limitSec);
  };

  useEffect(() => {
    const tick = () => {
      setTimeLeft(prevTime => (prevTime === 0 ? limitSec : prevTime - 1));
    };
    const timerId = setInterval(tick, 1000);

    return () => clearInterval(timerId);
  }, [limitSec]);

  return [timeLeft, reset];
};