simplestarの技術ブログ

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

AWS:お金のかからないElastiCache活用術2

simplestar-tech.hatenablog.com
のつづき

適当な値を redis に詰める

ローカルに立てた Redis 5.0.5 サーバー、適当な値を詰めてみます。
たとえば

key : value
9999 : 100
ffff : 100
abcd : efgh

これを前回導入した rdb-tools で json に出力してみましょう。

Redis のバックアップファイルの取得

前回 rdb-tools をインストールしているので
rdb コマンドはどこでも打てる様子
まずは redis コンテナ内に入って rdb ファイルを作りましょう
コンテナ内に入って rdb ファイルを保存するには

winpty docker exec -it redis bash
root@1faa380aff8b:/data# redis-cli dbsize
(integer) 4
root@1faa380aff8b:/data# redis-cli bgsave
Background saving started
root@1faa380aff8b:/data# ls -la
total 12
drwxr-xr-x 2 redis redis 4096 Sep 22 05:41 .
drwxr-xr-x 1 root  root  4096 Sep 22 03:23 ..
-rw-r--r-- 1 redis redis  161 Sep 22 05:41 dump.rdb
root@1faa380aff8b:/data# mv dump.rdb /redis/dump_20190922.rdb

前回 redis フォルダを volume マウントしてましたので、ホストマシンにて .rdb ファイルを確認することができると思います。

Redis のバックアップファイル json

解析コマンドの一つに json 形式で表示するものがありましたので、実行してみます。

rdb --command json -k ".*" dump_20190922.rdb
[{
"abcd":"efgh",
"ffff":"100",
"9999":"100",
"uuid":"4cf5cc323edd423bbf0078ce187acd21"}]

出ますね。
これをリダイレクト出力して .json ファイルを手に入れます。

[
    {
        "abcd": "efgh",
        "ffff": "100",
        "9999": "100",
        "uuid": "4cf5cc323edd423bbf0078ce187acd21"
    }
]

メモリレポートを確認

rdb -c memory dump_20190922.rdb -f memory.csv

memory.csv の内容は次の通り

database,type,key,size_in_bytes,encoding,num_elements,len_largest_element,expiry
0,string,abcd,56,string,4,4,
0,string,ffff,48,string,8,8,
0,string,9999,40,string,8,8,
0,string,uuid,88,string,32,32,

ほか、redis 本体に入って

redis-cli info

と打つと、次のように Memory について情報を確認することができます。

# Memory
used_memory:854496
used_memory_human:834.47K
used_memory_rss:5718016
used_memory_rss_human:5.45M
used_memory_peak:854496
used_memory_peak_human:834.47K
used_memory_peak_perc:100.03%
used_memory_overhead:842262
used_memory_startup:792264
used_memory_dataset:12234
used_memory_dataset_perc:19.66%
allocator_allocated:1058256
allocator_active:1306624
allocator_resident:8474624
total_system_memory:2096144384
total_system_memory_human:1.95G
used_memory_lua:37888
used_memory_lua_human:37.00K
used_memory_scripts:0
used_memory_scripts_human:0B
number_of_cached_scripts:0
maxmemory:0
maxmemory_human:0B
maxmemory_policy:noeviction
allocator_frag_ratio:1.23
allocator_frag_bytes:248368
allocator_rss_ratio:6.49
allocator_rss_bytes:7168000
rss_overhead_ratio:0.67
rss_overhead_bytes:-2756608
mem_fragmentation_ratio:7.22
mem_fragmentation_bytes:4926400
mem_not_counted_for_evict:0
mem_replication_backlog:0
mem_clients_slaves:0
mem_clients_normal:49694
mem_aof_buffer:0
mem_allocator:jemalloc-5.1.0
active_defrag_running:0
lazyfree_pending_objects:0

CubeWalk 世界の 1/4096 を格納すると何メモリになるか

ローカルで調べます。
世界ブロック数は 1 chunk 16 x 16 x 16 block
256 chunk x 256 chunk x 256 chunk です。
これを 16 chunk x 16 chunk x 16 chunk に切り取って 4096 分の1の世界とすると

16 x 16 x 16 blocks x 16 x 16 x 16 chunk となり、全16,777,216 block であり
横方向 縦方向、奥行き方向それぞれに 256 段階の解像度が求められます

文字で 16 進数を使うと
FF で 256 段階を表す事ができるため
000000 から FFFFFF までのキーを使って直感的に 4096 分の1の世界のブロックを特定できます。

キーに付随する値は 4byte の表現が求められ、数値として入力できない以上 00000000 から FFFFFFFF までの値を入れることにします。

一度、8 件ほどデータを詰めてみて、ダンプ結果を解析してみましょう。

一旦 Redis をクリアする方法はこちら

redis-cli FLUSHDB

CubeWalk データを一括で詰める

試験的に次の 8 ブロック分のデータを詰めることを行ってみた

[
    {
        "FFFFFF": "FFFFFFFF",
        "FFFFFD": "FFFFFFFD",
        "FFFFFC": "FFFFFFFC",
        "FFFFFE": "FFFFFFFE",
        "000002": "00000002",
        "000000": "00000000",
        "000001": "00000001",
        "000003": "00000003"
    }
]

rdb-tools で一括インポートするための protocol は次のコマンドで得ることができる

rdb -c protocol dump_20190922.rdb > data.txt

得られた data.txt の内容は次の通り

*2
$6
SELECT
$1
0
*3
$3
SET
$6
FFFFFF
$8
FFFFFFFF
*3
$3
SET
$6
FFFFFD
$8
FFFFFFFD
*3
$3
SET
$6
FFFFFC
$8
FFFFFFFC
*3
$3
SET
$6
FFFFFE
$8
FFFFFFFE
*3
$3
SET
$6
000002
$8
00000002
*3
$3
SET
$6
000000
$8
00000000
*3
$3
SET
$6
000001
$8
00000001
*3
$3
SET
$6
000003
$8
00000003

読んでいけば db 0 を選んだ後、SET key value を延々と行っているルールが見て取れる
ローカル環境を本番さながらに構築するにはこの protocol を作って次のコマンドで流し込む

cat data.txt | redis-cli --pipe

のが Redis の最善の方法と公式ドキュメントに示されている。

Redis Mass Insertion
redis.io

あとはアプリケーションの話なので、独自に protocol を出力するプログラムを作ればいい

AWS:お金のかからないElastiCache活用術1

まえがき

CubeWalk ゲームは…キューブを置く処理に不正がないようサーバーで実行されます。
世界の情報はたった一つのサーバーであるため、負荷分散を考えて DB は同時アクセスに耐えられる DynamoDB などを考えてみましたが
世界データを格納するだけで 600 万円ほど費用がかかることがわかったので、DynamoDB は使わないことにします。

代わりにパフォーマンス面で注目していたのが ElastiCache の Redis です。
ユーザーが100人一斉に同じ場所にブロックを置こうとしたときに同時書き込みが発生したところで
たった一人のブロックだけが置かれることを保証する(原子的なのでアトミックな処理と呼ばれる)ためインメモリデータベースである Redis の原子性を活用することにします。

同時アクセスに耐えられるか?
知りません。10000 人から一斉アクセスで動かなかったらそれまでということで諦めます。
その不具合に見舞われる時、きっと私は億万長者ですので、笑いながら DynamoDB に移行します(夢物語)

Redis をオンラインに置いて活用するなら AWS の ElastiCache の Redis モードを使うのが簡単ですが、データを一括で更新したり
取得したりする通信量や処理時間は避けなければなりません。

そこで、バックアップで得られる rdb ファイルをダウンロードして解析できること
ローカルで編集した内容を一括でインポートできることなどを確かめて、これの手段を記録します。

ローカル環境で redis を触れるようにする

とにもかくにも docker コンテナで redis 環境を構築して ElastiCache と同じサーバーをローカルに立てます。
すべてはそこからです。

手順としては Docker for Windows を起動して redis:5.0.5 を pull します。
images で確認したときに、redis があれば OK

$ docker images
REPOSITORY                              TAG                 IMAGE ID            CREATED             SIZE
redis                                   5.0.5               63130206b0fa        9 days ago          98.2MB

docker-compose.yml テキストファイルを作ったら次のように記述し

version: '3'
services:
  redis:
    container_name: redis
    image: redis:5.0.5
    volumes:
      - ./redis:/redis
    ports:
      - "6379:6379"

同フォルダに up.sh ファイルを配置して次の通り実装

#!/bin/sh
cd `dirname $0`
docker-compose -f docker-compose.yml up -d

Git Bash プロンプトで sh ./up.sh なんて叩けば

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                      NAMES
1faa380aff8b        redis:5.0.5         "docker-entrypoint.s…"   18 minutes ago      Up 18 minutes       0.0.0.0:6379->6379/tcp     redis

docker redis ローカル環境のできあがり

後ほど volume マウントした redis フォルダを活用します。

ローカルから redis サーバーにアクセス

python 実行環境を Windows なら Anaconda Navigator 経由で用意します。
Anaconda プロンプトを開いて python 3 系が実行できる環境で redis-rdb-tools を用意します。

pip install rdbtools python-lzf

基本的な使い方はこちらを参照のこと
githubja.com

一応次の python の redis client を使った localhost 接続テストには合格

from __future__ import print_function
import redis
import uuid

#elasticache settings
r = redis.StrictRedis(host='localhost', port="6379", db=0)

def handler(event, context):
    """
    This function puts into memcache and get from it.
    Memcache is hosted using elasticache
    """

    #Create a random UUID... this will be the sample element we add to the cache.
    uuid_inserted = uuid.uuid4().hex
    #Put the UUID to the cache.
    r.set('uuid', uuid_inserted)
    #Get item (UUID) from the cache.
    uuid_obtained = r.get('uuid')
    if uuid_obtained.decode("utf-8") == uuid_inserted:
        # this print should go to the CloudWatch Logs and Lambda console.
        print ("Success: Fetched value %s from memcache" %(uuid_inserted))
    else:
        raise Exception("Value is not the same as we put :(. Expected %s got %s" %(uuid_inserted, uuid_obtained))

    return "Fetched value from memcache: " 

handler(None, None)

pythonデバッグの仕方が具体的じゃなかったので、整理して記録しました。
simplestar-tech.hatenablog.com

Python:VSCodeデバッグ環境構築

一回やっておくと、あとからどうでも良くなるけど、最初からないとつらい

Anaconda を Windows に入れて、新しい env を base から conda コマンドで作るのはいい

conda create -n py37 python=3.7

とかね

デバッグしたい python ファイルを VSCode で開いて
.vscode フォルダ内の settings.json

{
    "python.pythonPath": "C:\\Users\\simpl\\Anaconda3\\env\\py37",
}

とか書いておけば、py37 環境で Python のエクステンションによるデバッグ実行ができる

AWS:はじめてのAPI Gateway⇄Lambda

概要

前回の Lambda と ElastiCache は VPC 内に引きこもるため(そういうセキュリティグループに配置したので)、インターネットからアクセスすることはできません。
simplestar-tech.hatenablog.com
API Gateway からトークン認証付きで Lambda を呼べるようにして、高速かつ安全に ElastiCache 操作をインターネットから実現してみます。

API Gateway を作る

適当な REST API を作成すると、アクションメニューからリソース or メソッドを作成できるようになります。

f:id:simplestar_tech:20190916211231p:plain
アクションでリソースとメソッド作成を選択

リソースは階層を形成し、メソッドは HTTP メソッドに対応し GET や POST, PUT を意味します。
今回はリソースに cubewalkblocks を追加し、そこに GET メソッドを追加しました。GET メソッドを選択すると次のデザイナー表示が確認できます。

f:id:simplestar_tech:20190916211742p:plain
API Gateway のデザイナー
いや、もうすでに色々いじってしまっていますが…

Authorizer 機能を持つ Lambda を作る

目的は公開する API ですから、秘密の文字列を知る者だけが呼び出し可能となるようにするためです。
手順はこちら
docs.aws.amazon.com

Lambda を作ったら、その後は API Gateway の「オーソライザー」にて新しく Authorizer を作ります。

f:id:simplestar_tech:20190916224738p:plain
Authorizer の作成(Lambdaを選ぶ)
ここで注意したいのが Lambda を呼べるように API Gateway を許可すること
新規作成時に許可ダイアログが出るからいいけど、既存のものをコピーすると気づかず、API 呼び出しにてサーバー内エラー 500 ステータスコードが返るから気をつけて(ハマった)

ここでトークンのソースを token としているので、リクエストヘッダーには token を入れないといけなくなります。
また、正規表現でチェックしているので、まずはこれに引っかからない者は、Lambda 呼び出し前に弾かれることになります。(攻撃されてもお金かからなくて良い)
ここで指定した token の値が Lambda の var token = event.authorizationToken; の値として手に入るというワケ
いろいろと繋がりましたでしょうか?

作った Authorizer を次の通り API のメソッドリクエストから選べれば OK

f:id:simplestar_tech:20190916212558p:plain
Authorizer の選択

API Gateway と Lambda との接続

メソッドの実行を開いて、Lambda を選ぶだけで OK

f:id:simplestar_tech:20190916225600p:plain
統合
権限付与する話を進めれば、このとおり Lambda の方も勝手に API Gateway とつながったことになります。
f:id:simplestar_tech:20190916225737p:plain
API Gateway と Lambda の接続

Lambda 側の注意点として、レスポンスは status code 200 の json とすること
今回は python 実装だったので、こんな感じ

    return { "statusCode": 200, "body": "\"\"" }

理由は統合レスポンスを「パススルー」に設定しているから

f:id:simplestar_tech:20190916230028p:plain
統合レスポンスの設定
これで Lambda の戻り値をそのまま、呼び出し元に与えることができます。

デプロイ

ずっと作ってきた API ですが、デプロイしない限りは外部から呼び出すことができません。
アクション > API のデプロイ を選択してデプロイします。
デプロイ先は prod にするのが一般的みたい

デプロイに成功すると url が確定します。

f:id:simplestar_tech:20190916230341p:plain
url の確認

curl で呼び出しテスト

curlコマンドラインで http 通信を試せるツールですが(色々な環境にインストール可能なもので、 Windows なら git for windows 入れたら git bash のコンソールから叩ける)
さて、Authorizer の Lambda の実装に依存しますが、ここまで作ってきた API を外部から呼び出すと次の通り

$ curl -X GET -H 'token:cubewalk-allow' https://xxxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/cubewalkblocks -v
{ [35 bytes data]
100    35  100    35    0     0     39      0 --:--:-- --:--:-- --:--:--    39{"statusCode": 200, "body": "\"\""}

OK

ちなみに token を間違えると。。。

$ curl -X GET -H 'token:arere' https://xxxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/cubewalkblocks -v
{ [26 bytes data]
100    26  100    26    0     0    101      0 --:--:-- --:--:-- --:--:--   101{"message":"Unauthorized"}

こんな感じで、認証エラーのメッセージが返ってきます。

まとめ

REST APIAPI Gateway から作るまでは簡単
・Authorizer の Lambda も手順通りなら作れるがAPI Gateway にて Authorizer を作り切るところまでが覚えゲートークンソースと Authorizer Lambda実装がつながればこっちのもの)
・Lambda 呼び出し権限の付与には注意(特に既存の API をコピーしたときは)
・レスポンスをパススルーするなら json で statusCode 200 も返すこと
・デプロイして初めて外部から呼び出せる API の url が確定する

ひとまずこれで、認証付き API から Lambda が呼び出せるようになりました。
前の記事で Lambda から ElastiCache の Redis の get/set してましたけど、今回の API Gateway からつないで実行することができました。

AWS:はじめてのLambda⇄ElastiCache

概要

PlayFab のバックエンド処理が AWS と連携できます。
具体的には同期 http 通信が行えるので、そこを AWSAPI Gateway → Lambda → ElastiCache とつなぎます。
前回の記事
simplestar-tech.hatenablog.com
で確認できた ElastiCache と Lambda の接続を見るために、Lambda の作り方をマスターしてみましょう。

VPCアクセスできるロールを作る

いきなり Lambda から離れるなと思うところですが、Lambda を作るにはロール、つまり役割が定義されてないといけません。
ということでサービス一覧の IAM からロールを選んで新規ロール作成で以下のように LambdaVPC で検索できるポリシーを持つロールを作ります。

f:id:simplestar_tech:20190916202333p:plain
AWSLambdaVPCAccessExecutionRole

作ったロールを、今度は Lambda 新規作成時に割り当てます。
Lambda の実装は後で zip ファイルをアップロードして完了させますので、一から作成など、適当に作ります。

f:id:simplestar_tech:20190916202631p:plain
Lambda に AWSLambdaVPCAccessExecutionRole のポリシーを持つロールを設定

作った Lambda はロールの内容からデザイナー上では次の通りにビジュアライズされます。

f:id:simplestar_tech:20190916202828p:plain
デザイナーの見た目(VPCアクセス可能を意味する)

ElastiCache と Lambda のセキュリティグループを合わせる

EC2 のサービス項目にセキュリティグループというものがありますが、そこにはディフォルトのセキュリティグループがあります。
それは、インバウンドのすべての許可ソースに自身のセキュリティグループという、VPC 内に限りすべての通信を行えるセキュリティグループとなっています。
※あくまで許可対象のソースはセキュリティグループなので、外部からの入力は受け付けないので、大丈夫(IPアドレス範囲だと全公開だから危険だよ)

対象の ElastiCache を選択して変更メニューからセキュリティグループを default に設定します。

f:id:simplestar_tech:20190916203343p:plain
ElastiCache のセキュリティグループ

同じように Lambda のネットワーク設定を調べて、セキュリティグループを default に設定します。

f:id:simplestar_tech:20190916203553p:plain
Lambda のネットワーク設定

これで ElastiCache と Lambda の間で互いに通信できるようになりました。(default セキュリティグループをつけると自身のグループ内で通信がすべて許可されているので)

Lambda で ElastiCache(Redisモード)にアクセス

今回は Lambda を python で実装します。
Windows 環境なら Anaconda で python コマンドを実装できるようにし、適当なフォルダをカレントに、まずは次のファイルを作成します。
lambda_function.py

from __future__ import print_function
import redis
import uuid

#elasticache settings
r = redis.StrictRedis(host='c4096-000-000-000.xxxxxx.xxxxxx.xxxxx.cache.amazonaws.com', port="6379", db=0)

def handler(event, context):
    """
    This function puts into memcache and get from it.
    Memcache is hosted using elasticache
    """

    #Create a random UUID... this will be the sample element we add to the cache.
    uuid_inserted = uuid.uuid4().hex
    #Put the UUID to the cache.
    r.set('uuid', uuid_inserted)
    #Get item (UUID) from the cache.
    uuid_obtained = r.get('uuid')
    if uuid_obtained.decode("utf-8") == uuid_inserted:
        # this print should go to the CloudWatch Logs and Lambda console.
        print ("Success: Fetched value %s from memcache" %(uuid_inserted))
    else:
        raise Exception("Value is not the same as we put :(. Expected %s got %s" %(uuid_inserted, uuid_obtained))

    return "Fetched value from memcache: " + uuid_obtained.decode("utf-8")

このままだと redis が不足しているので、そのフォルダで

pip install redis -t . 

を実行します。-t . コマンドはカレントフォルダにパッケージを配置するというオプションです。
そのまま実行しても、接続エラーになります。

lambda_function.py が置かれているフォルダのすべてのファイル・フォルダを選択して zip 圧縮し、これを Lambda の zip としてアップロードの操作で選択します。
保存ボタンを押すと次の通り、右側の関数の指定では、ファイル名.関数名 を記述します。

f:id:simplestar_tech:20190916204554p:plain
関数の指定

あとはテストボタンを押せば…ご覧の通り redis モードの ElastiCache に uuid を詰めて、出して、値が一致していることが確認できました。

f:id:simplestar_tech:20190916204715p:plain
Lambda テスト実行結果

やりました。

まとめ

今回の勘所は次の通り
・ElastiCache にアクセスしたければ Lambda のロールには AWSLambdaVPCAccessExecutionRole ポリシーだけあればいい
・ElastiCache と Lambda は default セキュリティグループを当てる(よりキビしく TCP 6379 接続だけ許すセキュリティグループでもいいかもね)
python 実装では redis を import して使う
・Lambda で pip install が必要なときはローカルで -t . オプションで全部入りを作って zip 圧縮してアップロード

Lambda だけではなく IAMロール、セキュリティグループについても経験値がたまる入門記事になりました。

AWS:はじめてのElastiCache

まずはチュートリアルに従って動かしてみましょう。
docs.aws.amazon.com

ステップ 1: クラスターを起動する

気にするべきは、新しいセキュリティグループを作成しておくことです。
インバウンドポートは 6379 でソースIPは別途用意した EC2 インスタンスの内部 IP アドレスの CIDR 表記とします。(後でこの EC2 から接続するため)
一番簡素で安い構成で作ってみました。
f:id:simplestar_tech:20190916152152p:plain

ステップ 2: アクセスを許可する

あ、もうセキュリティグループの設定が完了しているので作業なしです。

ステップ 3: クラスターのノードに接続する

ホームディレクトリの ec2-user の下に redis-stable のフォルダ作っちゃった…これ本当は/usr/local/src に置くのが一般的
エンドポイントはこちらの欄に書かれていて

f:id:simplestar_tech:20190916160502p:plain
エンドポイント

EC2 インスタンス内で、先程作った redis-stable フォルダ内の src/redis-cli のコマンドを叩けば

 src/redis-cli -c -h c4096-000-000-000.xxxx.xxxxx.cache.amazonaws.com -p 6379

get, set でキーバリュー操作ができることを確認しました。

ステップ 4: クラスターの削除 (追加料金の発生を防ぐため)

特に問題ないウィザードでした。

ここまで難しいことなく、ElastiCache を使うことができました。

感想

削除する時にバックアップを作るか聞かれましたし、クラスター作成時にもバックアップを指定して開始できますとありました。
定期的にバックアップを取る仕組みもあるので、これはなかなかいい感じですね。

VRoidHubにログインしないでゲームを続けられる実装調査

ログインしないときの動作を策定するにあたり、現状を整理し、作業ログを残します。

簡単に今のフローをおさらい

VRoid Hub のログインは VRoid SDK のサンプルに任せっきり
ゲームを起動すると

f:id:simplestar_tech:20190915150327p:plain
起動直後
「VRoid Hub に接続」ボタンを表示、同時に「☓」の閉じるボタンがある
接続ボタンを押すと、次のとおり Web ブラウザが起動し、連携許諾の画面の後、認証コードを表示する画面が出てくる
f:id:simplestar_tech:20190915150745p:plain
連携します→認証コード入力画面
一応毎回認証コード違うので、もうこの値を入れても有効にはなりません…モザイクもかけない

で、ゲーム画面に戻ると

f:id:simplestar_tech:20190915150944p:plain
認証コード入れて
このような画面になっているので、先程のコードを入力する
f:id:simplestar_tech:20190915151034p:plain
キャラクター選択画面
キャラクター選択ができるが、このときも「☓」ボタンでダイアログを閉じることが可能

キャラを選択すると「利用する」「キャンセル」ボタンがある

f:id:simplestar_tech:20190915151314p:plain
選択した後

キャンセルすると、一つ前のキャラクター選択画面に戻る

「利用する」を選ぶと、次の通りキャラクターがゲームに登場する

f:id:simplestar_tech:20190915151655p:plain
ゲーム画面

ログインをやめたとき

「次のディフォルトキャラを利用しますか?」
「利用する」「キャンセル」

f:id:simplestar_tech:20190915151314p:plain
選択した後
を出してみようと思う

この画面は CharacterLicenseScrollView といって

まぁ、調べると複雑なのでユーザーは VRoidHubController の SetOnCancelHandler ですべてのキャンセルをハンドリングします。
ここで CharacterLicenseScrollView をアクティブ化するロジックをハンドラに追加してみましょう。
期待では、キャラ情報のない画面が出る

具体的にはこのように書き

        this.controller.SetOnCancelHandler(() => {
            this.licensePanel.SetActive(true);
            this.menuCanvas.SetActive(true);
            this.controller.gameObject.SetActive(true);
        });

結果は次の通り

f:id:simplestar_tech:20190915155525p:plain
キャラ情報のない画面が出る

ボタンとして「利用する」「キャンセル」があるが
「利用する」を押した場合は、独自の VRM ロードロジックに付け替える
「キャンセル」を押した場合は、元のログイン画面の表示につけかえる
想定できる問題に、キャンセルした後から普通のフローをたどると、「利用する」ボタンのロジックが切り替わったままになるので
キャンセル時にすべてを元に戻すことをしなければならない

では、まず、ロジック付替えるテストコード
動的に永続イベントを無効化して付け替えるには、このように書くしかなさそう…いい知見を得ました。

    private void OnCancelButtonClicked()
    {
        // 固定キャラクター利用を拒否されたので、最初のログイン画面に切り替え
        this.loginCanvas.SetActive(true);
        this.mainPanel.SetActive(true);
        this.menuCanvas.SetActive(false);
        this.licensePanel.SetActive(false);
        // 切り替えたボタンのロジックを元に戻す
        this.acceptButton.onClick.RemoveAllListeners();
        this.acceptButton.onClick.SetPersistentListenerState(0, UnityEventCallState.EditorAndRuntime);
        this.retryButton.onClick.RemoveAllListeners();
        this.retryButton.onClick.SetPersistentListenerState(0, UnityEventCallState.EditorAndRuntime);
        this.cancelButton.onClick.RemoveAllListeners();
        this.cancelButton.onClick.SetPersistentListenerState(0, UnityEventCallState.EditorAndRuntime);
    }

    private void OnAcceptButtonClicked()
    {
        // streamingAssets から vrm をインスタンス化
        var vrmFilePath = Path.Combine(Application.streamingAssetsPath, "VRM/defaultvrm.vrm");
        var context = new VRMImporterContext();
        context.Parse(vrmFilePath);
        context.LoadAsync(() => {
            context.ShowMeshes();
            var vrmModel = context.Root;
            vrmModel.name = "defaultvrm";

            // プレイヤーとしてコンポーネントを追加
            ComponentUtil.DeleteAllChildren(this.transform);
            int layerMask = LayerMask.NameToLayer(LayerMask_Player);
            vrmModel.SetLayerRecursively(layerMask);
            vrmModel.SetTagRecursively(Tag_Player);
            // ただし characterModelId はディフォルト値とする
            this.SetModel("defaultvrm", vrmModel, isUserPlayerFlag: true);

            // VRoid Hub メニューを閉じる
            this.controller.Close();
        }, (exception) => { Debug.LogError($"defaultvrm load error: {exception.Message}"); });
    }

    private void Start()
    {
        this.controller.Open();
        this.controller.SetOnLoadHandler((characterModelId, vrmModel) =>
        {
            ComponentUtil.DeleteAllChildren(this.transform);
            int layerMask = LayerMask.NameToLayer(LayerMask_Player);
            vrmModel.SetLayerRecursively(layerMask);
            vrmModel.SetTagRecursively(Tag_Player);
            this.SetModel(characterModelId, vrmModel, isUserPlayerFlag:true);
        });
        this.controller.SetOnCancelHandler(() => {
            // キャラクター利用画面を表示
            this.licensePanel.SetActive(true);
            this.menuCanvas.SetActive(true);
            this.mainPanel.SetActive(false);
            this.loginCanvas.SetActive(false);
            this.controller.gameObject.SetActive(true);
            // 既存の「利用する」ボタンと「キャンセル」ボタンの動作を無効化
            this.acceptButton.onClick.SetPersistentListenerState(0, UnityEventCallState.Off);
            this.acceptButton.onClick.RemoveAllListeners();
            this.retryButton.onClick.SetPersistentListenerState(0, UnityEventCallState.Off);
            this.retryButton.onClick.RemoveAllListeners();
            this.cancelButton.onClick.SetPersistentListenerState(0, UnityEventCallState.Off);
            this.cancelButton.onClick.RemoveAllListeners();
            // 新しいロジックに付け替え
            this.acceptButton.onClick.AddListener(this.OnAcceptButtonClicked);
            this.cancelButton.onClick.AddListener(this.OnCancelButtonClicked);

            // streamingAssets から vrm 情報を取得
            var characterLicensePanel = this.licensePanel.GetComponent<CharacterLicensePanel>();
            characterLicensePanel.Init(new VRoidSDK.CharacterModel
            {
                portrait_image = new VRoidSDK.PortraitImage { original = new VRoidSDK.WebImage { url = "" } },
                name = @"<b><size=28>千駄ヶ谷 篠</size></b>
<size=24> Welcome School Uniform 2019 </size>
<size=24> 作者:<b> VRoidプロジェクト </b></size>",
                license = new VRoidSDK.CharacterLicense {
                    characterization_allowed_user = "everyone",
                    violent_expression = "allow",
                    sexual_expression = "allow",
                    corporate_commercial_use = "allow",
                    personal_commercial_use = "profit",
                    modification = "allow",
                    redistribution = "allow",
                    credit = "unnecessary"
                }
            });
            this.caracterImage.texture = this.defaultvrmIcon;
        });
    }

キャンセルして元に戻すことができているので、あとは利用するボタンを押した時に、リソースから VRM を読み出そうと思います。
Prefab のインスタンス化で良いかな?→惜しいところで、vrmAvatar が内蔵されないので、キャラクターを動かす時に不都合を確認
ファイルから直接読み込むように StreamingAssets から直接読み込むようにしました。

色々修正して上の実装の通り

現在、VRoid Hub の接続を拒むと、次の画面が出て、利用するボタンを押すと、この子が使えるようになってます。

f:id:simplestar_tech:20190915210658p:plain
VRoid Hub の接続を拒むと出てくる画面

あとは、通信時にキャラクターIDを正しく相手に送る必要があるので、正しい ID とやらを確認します。
648876553405728395 でした。

そして、VRoid Hub への接続を拒んだクライアントでは、常にキャラクターをこの子とする実装が求められます。
それもテストしてみましょう。

あれ、方やログインしてないのに動いちゃった
そういうものなのかな

ああ、キャッシュをクリアしたらやっぱり認証でエラーが確認できました。
そういうときは、ディフォルトの vrm を読み込むようにしましょう。

これで行けると思うんだけど…

    IEnumerator CoWaitEmptyMapAndLoadAsync(ContentUrlResponse response)
    {
        // ロードは複数同時に走らせない
        while (this.isVRoidLoading)
        {
            yield return null;
        }
        this.isVRoidLoading = true;
        this.lastUrlResponse = response;
        if (Authentication.Instance.IsAuthorized())
        {
            // ログインできている場合は API 経由でキャラデータをロード
            var characterModel = new CharacterModel();
            characterModel.id = response.URL;
            HubModelDeserializer.Instance.LoadCharacterAsync(characterModel, OnLoadComplete, OnDownloadProgress, OnError);
        }
        else
        {
            // ログインしていない場合は、ディフォルト VRM で代用
            var vrmFilePath = Path.Combine(Application.streamingAssetsPath, "VRM/defaultvrm.vrm");
            var context = new VRM.VRMImporterContext();
            context.Parse(vrmFilePath);
            context.LoadAsync(() => {
                context.ShowMeshes();
                var vrmModel = context.Root;
                vrmModel.name = response.UserUniqueId.ToString();
                this.OnLoadComplete(vrmModel);
            }, (exception) => { Debug.LogError($"defaultvrm load error: {exception.Message}"); });
        }
    }

できました!

f:id:simplestar_tech:20190915220315p:plain
VRoid Hub にログインせずにオンラインチャット

色んなユーザーがルームに入ってきても、全部ディフォルトのキャラとして映ります…
これにて機能追加完了