Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

モバイルで動くShaderでの流体表現


この記事は Akatsuki Advent Calendar 2020の25日目の記事です.
前回の記事は脆弱性診断時のAndroidのプロキシ設定を行うコマンドラインツールを作った話+その他内製ツールの紹介でした。

 

 

はじめに


新卒研修で流体を用いたスマホゲームをリリースする機会がありましたので,実装を紹介します.

具体的には,ミラーケーキ(ケーキの一種)を作成するゲーム内で,
ケーキに溶けたチョコをかけてコーティングする作業(グラサージュ)の実装です.

(実際のアプリ: iOS, Android

実際の動画


実際のゲームプレイ動画がこちらになります.この部分の実装方法を以下で説明していきます.

f:id:AxI:20201223125005g:plain
ちなみに実際のミラーケーキはこんな感じです.

実装

概要:ケーキの柄が変わってるだけ

モバイルで三次元流体表現を行うことはスペック上難しいので,二次元で液体挙動の近似計算を行い,影で立体感をつけることで上記の挙動を実現しています.
具体的には,ケーキオブジェクトのテクスチャをリアルタイムで変化させることで,ケーキの表面に液体が流れているかのように見せています.

f:id:AxI:20201224123839p:plain
ぱっと見,ケーキになにか物体が覆いかぶさっていくように見えますが,実はケーキの柄が変わっているだけです.

実装は主に3つのパートに分かれています.以下の処理はUnity×Shaderで実装されています.
1.液体の広がりを表現
2.液体の流れを表現
3.液体の立体感を表現

液体の広がり


液体の広がりでは,ミラーケーキでのグラサージュの特徴である,注いだ点からじわーっと広がる挙動を表現しています.

具体的には二次元上で液量を定義し,”ぼかし”(平滑化)を行うことで液体が多いところから少ないところへ広がる動きを計算しています.毎フレームShader上で周囲のピクセルの液量の平均をそのピクセルの次の値とすることで,下図のように注いだ点から周囲に広がり,付近の液量と影響しあう挙動となります.

f:id:AxI:20201223133432p:plain

上図において,液量2以上など,閾値以上の部分のみ液体が存在すると定義し,液体が存在するところは別のテクスチャを参照するとすることで下図のようになります.

f:id:AxI:20201223133450p:plain

液体の流れ


液体の流れでは,液体が流れていく方向へ色も移動する処理を行います.

先ほどの液体の広がりで用いた図を見ると,液体範囲の縁は閾値に近くなることがわかります.よって,閾値に近い部分に黄色をつけるとするならば,黄色は常に外側,つまり液体が流れていく方向へ移動することになります.
このように液量が似ているピクセルは似た色を参照するという仕組みにすることで,流れていく方向に色が移動しているような挙動になります.

f:id:AxI:20201223133505p:plain
具体的には,液体範囲が液体テクスチャを参照する際,テクスチャの参照座標を液量分ずらすことで,任意のテクスチャにおいて液量に伴い変動するマーブル模様が生成され.流体っぽい挙動を実現しています.(時間が経つと戻っていく など,実際の流体挙動とは大きく異なる部分があります)

f:id:AxI:20201223133516p:plain

液体の立体感


液体の立体感は,液体の広がりで計算された,液体が存在する範囲の周囲に影をつけることで表現されています.
具体的には,テクスチャ上で光の方向を定義し,液体が存在しない かつ 光の逆向きに隣接するピクセルに液体が存在するピクセルを黒くします.

f:id:AxI:20201223133532p:plain

f:id:AxI:20201223133542p:plain

影をつけることで一気に立体感が出たと思います.影と反対側にハイライトをつけることでより立体感,艶感がでます.
上記の三つの要素を合体させることでモバイルで動く流体表現を実装しました.

おわりに


実際のアプリリンクです.楽しんでいただけたら幸いです.

Mirror cakes - Apps on Google Play

Mirror cakes on the App Store

 

限定的な状況下の実装ですがみなさまの発想の助けになれば幸いです.
来年も,おもしろいがたくさん生まれますように,メリークリスマス.

 

【LT会】Akatsuki Geek Live 2020-2021 開催レポート!Vol.1

こんにちは!アカツキ採用担当のこさだです。

12/14(月)に、本年度1回目となる「Akatsuki Geek Live2020-2021 Vol.1」を開催しました!この状況下ですので、初の完全オンライン実施となりましたが、50名以上の方にご参加をいただき、とても良い場になりました!今回は、そのイベント当日の様子をご紹介したいと思います!

 

「Akatsuki Geek Live」とは?

エンジニアを志す学生さんと、アカツキエンジニアが登壇するLT会です。フリーテーマで7分間のLTを実施、その後懇親会を実施し、交流の場を設けています。楽しみながらアカツキのことを知れる場、そして学生さん同士の横のつながりも作れる場として実施しております。

 

▼イベント概要はこちらから

aktsk.connpass.com

 

 #当日の様子

今回は完全オンラインでの開催となりました。昨年まではアカツキオフィスに実際にお越しいただいての開催だったため、少し不安もありましたが、当日は50名を超える方にご参加いただきました!

はじめに採用担当のまえだからスタートし、アカツキのエンジニア3名、来春入社予定の内定者2名、一般学生1名の計7名が登壇。
登壇者から資料掲載の承諾をいただきましたので、下記にスライドをご紹介します!

 

▼@まえださん(アカツキ/採用担当)

なるほどよくわかる!アカツキ理解のヒント 

トップバッターはエンジニアの採用を担当しているまえださんから。
アカツキを知るにはどうしたらいいか?や学生のみなさんがなかなか聞きづらいポイントを解説しました!

 【登壇資料】

 

▼@になさん(アカツキ/17年度新卒入社)

Unity の CI 環境をGithub Actions で構築した話

エンジニアトップバッターはになさん。
なぜやるか?「やってみたかったから」というのが正直でいいですよね!
問題意識をもったこと、やりたいと思う方法をやってみる。「ワクワクして働く」ということを体現している一つの例かもしれませんね。

【登壇資料】

 

▼@Bapliscaさん(一般学生)

「ボイストラベラー」サークルで新感覚ボイスゲームを作った話
学祭で制作した音声認識でUFOを操るゲームについて発表していただきました。
「あ〜〜〜⤴︎⤴︎あ〜⤵︎⤵︎ ・・・ビーム!!!!」とデモも披露していただき、笑いが巻き起こり、チャットも盛り上がる時間でした(個人的にも画面越しにめちゃくちゃ笑いました)
アセットを使わずに制作したとのことで、様々な工夫が素晴らしかったです。
「子供が操作しやすいゲームを作りたい」という目的が達成されていて、とても素晴らしいと思いました!遊んでみたい!(888888)

【登壇資料】

 

▼@なかじさん(学生/21年度新卒内定)

DOTweenはいいぞ

来年、アカツキへの入社を決めてくれている21卒内定者のなかじさんです。
ゲームを開発されている学生の皆さんから「DOTween使ってみたい!」という声がチャットから溢れてきていました!
【登壇資料】

 

▼@いたみんさん(アカツキ/19年度新卒入社)

研修でゲームをリリースした話

入社後の研修で作ったゲームの話でした。毎年少しずつ研修内容は変わるのですが、入社直後の研修で作ったゲームをすぐリリース!というのはなかなか実施されている企業は多くないのではないでしょうか。作ってみて初めてわかる難しさ、「感覚的に楽しくできる」にたどり着くまでは色々な苦労があるんですよね。

【登壇資料】

 

▼@すぎやんさん(学生/21年度新卒内定)

競プロと業プロの関係性について

なかじさんと同じく、内定者のすぎやんさん。
就職活動で感じた違和感と、何から初めていいかわからないという思いから、「競プロ」を始めてよかったぞ!という話をしてくれました。
面接での自己アピールの伝え方を変えてみることや、自分でもサービスを作ってみることで自分にも変化があったとのこと。視点を変えて考えてみるというのは本当に大事!

【登壇資料】

 

▼@ちょうさん(アカツキ/19年度新卒入社)

RailsのN+1あるある

アカツキで実際の業務で使っているRuby on Railsdでの例をもとに、わかりやすく解説してくれました!当日司会をしていた島村と同じチームということもあり、先輩後輩による掛け合いも面白かったですねw

【登壇資料】

 

 

#LTを経て懇親会!
オンラインでの開催でしたが、チャットで盛り上げてくださる学生の皆さんのおかげもあり、発表者も楽しくLT登壇を終えられました。その空気感のままオンライン懇親会へ。
なんだかまるでラジオの公開収録をみているような雰囲気でしたね!皆さんからチャットで投稿される質問に答えていきながら、アカツキの業務やコロナ禍リモートでの業務について、登壇者へ向けての質問など様々な話題が時間いっぱいまで活発に飛び交っていました。アカツキの雰囲気も伝わる有意義な時間になったのではないでしょうか?
オフィスで直接みなさんにお会いできなかったのは残念でしたが、ご来社いただける状況が戻ってきた時には、ぜひ遊びに来てくださいね^^
 

改めて、ご参加いただいた皆さま本当にありがとうございました!この時間ががみなさんのエンジニアとしての気付きや新たな1歩、また良き出会いの場になっていれば嬉しいです!

 

最後にオフショットを★(広〜い空間で、こんな風にお届けしておりました)

f:id:megumikosada:20201221093503j:plain

次回は2月頃に開催できたらいいな、と思っています!ぜひまた会いましょう〜!

メリークリスマス!!

脆弱性診断時のAndroidのプロキシ設定を行うコマンドラインツールを作った話+その他内製ツールの紹介

こんにちは、セキュリティエンジニアの小竹 泰一(aka tkmru)です。 アカツキでは、Webアプリケーション、ゲームアプリに対する脆弱性診断や社内ネットワークに対するペネトレーションテスト、ツール開発/検証などを担当しています。

今日は、先日作成したAndroidのプロキシ設定をコマンドラインから行うツールの紹介と、その他にもある脆弱性診断に使える内製ツールの紹介をします。 この記事は、Akatsuki Advent Calendar 2020の24日目の記事です。

脆弱性診断に使うプロキシツールとは

脆弱性を発見する方法のひとつに、対象となるWebアプリ、スマホアプリの通信内容をプロキシツール上で確認し、リクエストやレスポンスを編集するという方法があります。 脆弱性診断時に用いるプロキシツールは、Burp SuiteやPacketProxyなどが知られています。

f:id:TAKEmaru:20201217044534p:plain
中間者攻撃を行う様子

スマホアプリの通信をプロキシツールへと向ける方法

スマホアプリの通信をプロキシツール上で確認するには、端末のネットワーク設定を変更しプロキシツールへと通信を曲げる必要があります。 ここではよく用いられる2つの方法を紹介します。

プロキシのIPアドレス、ポートを指定

プロキシツールが動作しているPCのIPアドレス、ポートを端末のネットワーク設定画面で指定することでプロキシツールへと通信を向けることができます。 これは最も簡単な方法です。

Androidでは、Wi-Fiの詳細設定から、プロキシのIPアドレス、ポートを指定可能です。iOSを使用している場合も設定する項目は同じです。

Android上でプロキシを設定する画面

DNSサーバーを追加し、レコードを偽装する

PacketProxyには自由にDNSレコードを変更できるDNSサーバーが組み込まれています。 このDNSサーバー上でアプリの通信先のドメインを指定し、プロキシツールを経由して通信するようにDNSレコードを偽装することで、通信内容を取得できます。 この方法は、指定したドメインとの通信のみを取得できる点や、SSL Pinningが施されていても通信先のドメインを確認することができる点で便利です。

Androidでは、プロキシのIPアドレスを指定する場合と同じくWi-Fiの詳細設定画面から、DNSの設定を変更可能です。 ここで指定するDNSサーバーのIPアドレスはPacketProxyが動作しているPCのアドレスです。 Androidの場合は、次の画像のように端末のIPアドレス、ゲートウェイのIPアドレス、ネットワークプレフィックス長も指定する必要があります。 iOSの場合はDNSのアドレスを差し替えるだけでOKです。

Android上でDNSサーバーを設定する画面

コマンドラインからプロキシの設定を行えるツールを作成した

プロキシの設定をGUIを操作して行うのは少し手間がかかります。 特にAndroidでDNSサーバーを指定するには、DHCPをオフにして端末のIPアドレス等も指定しないといけないのでめんどくさいです。 そのため、コマンドラインからプロキシの設定を簡単に行えるよう、aprox を作成しました。

github.com

インストール方法

aproxはPythonで作成されているため、pipでインストール可能です。

$ pip install git+ssh://git@github.com/aktsk/aprox.git

使い方

サブコマンドを指定することで、機能を呼び出せます。

プロキシを指定する

proxyコマンドでプロキシが待ち受けているIPアドレス、ポートを設定できます。

$ aprox proxy 192.168.100.10:8080
Local proxy has been set up

DNSサーバーを指定する(root化端末限定)

dnsコマンドでDNSサーバーのIPアドレスを指定できます。 この機能はroot権限が必要なndcコマンドを使っているため、root化端末をPCにつないでいる場合しか使えません。

$ aprox dns 192.168.100.10
200 0 Resolver command succeeded

設定をクリアする

clearコマンドでプロキシ、DNSサーバの設定をクリアできます。

$ aprox clear
Local proxy is not configured...
Cleared local DNS settings!!

開発時に知ったデバッグテクニック

ndcコマンドに関してはあまりドキュメントがなく、扱いに苦戦しました。そのため、DNSのリクエストが本当に端末から出ているのか確かめたくなりました。 ちなみにaproxの内部では次のようにndcコマンドを使って、DNSサーバーを追加しています。

$ su
# dumpsys netd # 現在の使用しているネットワークIDを確認
# ndc resolver setnetdns <ネットワークID> '' <IP アドレス> 

DNSのログは次のように確認できました。 iptablesコマンドを使い、ログの先頭にDNS_QUERIESという文字列を追加し、dmesgコマンドでログを確認しています。

3|sargo:/ $ su
sargo:/ # iptables -I OUTPUT -m udp -p udp --dport 53 -j LOG --log-prefix 'DNS_QUERIES '
sargo:/ # dmesg -w | grep 'DNS_QUERIES'
[54672.362220] DNS_QUERIES IN= OUT=wlan0 SRC=192.168.100.20 DST=192.168.100.15 LEN=59 TOS=0x00 PREC=0x00 TTL=64 ID=40850 DF PROTO=UDP SPT=41015 DPT=53 LEN=39 MARK=0xf0068 
[54672.525921] DNS_QUERIES IN= OUT=wlan0 SRC=192.168.100.20 DST=106.187.2.33 LEN=59 TOS=0x00 PREC=0x00 TTL=64 ID=50180 DF PROTO=UDP SPT=55042 DPT=53 LEN=39 MARK=0xc0068 
[54672.526507] DNS_QUERIES IN= OUT=wlan0 SRC=192.168.100.20 DST=106.187.2.33 LEN=59 TOS=0x00 PREC=0x00 TTL=64 ID=50181 DF PROTO=UDP SPT=26049 DPT=53 LEN=39 MARK=0xc0068 
[54672.589872] DNS_QUERIES IN= OUT=wlan0 SRC=192.168.100.20 DST=192.168.100.15 LEN=59 TOS=0x00 PREC=0x00 TTL=64 ID=40865 DF PROTO=UDP SPT=42598 DPT=53 LEN=39 MARK=0xf0068 
[54672.847426] DNS_QUERIES IN= OUT=wlan0 SRC=192.168.100.20 DST=106.187.2.33 LEN=67 TOS=0x00 PREC=0x00 TTL=64 ID=50221 DF PROTO=UDP SPT=33378 DPT=53 LEN=47 MARK=0xc0068 
[54672.848117] DNS_QUERIES IN= OUT=wlan0 SRC=192.168.100.20 DST=106.187.2.33 LEN=67 TOS=0x00 PREC=0x00 TTL=64 ID=50222 DF PROTO=UDP SPT=31970 DPT=53 LEN=47 MARK=0xc0068 
[54672.849920] DNS_QUERIES IN= OUT=wlan0 SRC=192.168.100.20 DST=106.187.2.33 LEN=63 TOS=0x00 PREC=0x00 TTL=64 ID=50223 DF PROTO=UDP SPT=50412 DPT=53 LEN=43 MARK=0xc0068

また、このようにログを確認することで、アプリから出るDNSのリクエストの宛先は指定したものに変更されているが、ブラウザから出るDNSのリクエストの宛先はデフォルトのままであることが分かりました。 アプリの脆弱性診断では現状のままで困らないので、このまま公開しています。

他の内製診断ツール

アカツキでは、他にも脆弱性診断時に使えるツールを作成し、OSSとして公開しています。

apk-medit

apk-meditは、root権限を必要としないメモリ改ざんツールです。 メモリ改ざんはスマホゲームの脆弱性診断時に見る項目のひとつです。 このツールに関しては、Black Hat USA Arsenalや、CODE BLUE Blueboxなどで発表しました。

github.com

以前にも社のブログに紹介記事を書いたので、興味があればこちらも読んで見てください!

hackerslab.aktsk.jp

apkutil

apkutilは、APKファイルをデコードする際にandroidmanifest.xmlをパースし様々な情報を表示したり、脆弱性診断に必要なパッチを自動で当てたりしてくれるツールです。

github.com

次のようにコマンドを実行することで、debuggable属性をtrueにしたり、networkSecurityConfigの設定をしたりしつつ、再度ビルドしてくれます。

$ apkutil all sample.apk
Decoding APK by Apktool...
I: Using Apktool 2.4.1 on sample.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
...

Potentially Sensitive Files:
sample/README.md
sample/hoge.sh

Checking AndroidManifest.xml...
Permission:
android.permission.INTERNET

Debuggable:
False

AllowBackup:
False

Custom schemas:
None

Set debuggable attribute to true in AndroidManifest!

Set networkSecurityConfig attribute to true in AndroidManifest!

Building APK by Apktool...
I: Using Apktool 2.4.1
I: Checking whether sources has changed...
...

Signing APK by apksigner...
Signed

Output: sample.patched.apk

上で挙げたapk-meditはdebuggable属性がtrueになっているアプリを前提に作られているので、apk-meditを使う際にはとても便利です。

ipautil

ipautilは、IPAファイルをデコードする際にInfo.plistをパースし様々な情報を表示したり、センシティブなファイルがないか確認したりしてくれるツールです。

github.com

次のようにコマンドを実行することで、IPAファイルをデコードできます。

$ ipautil decode sample.ipa
Decoding IPA...
Archive: sample.ipa
   creating: Payload/
   creating: Payload/demo-client iOS develop.app/
   creating: Payload/demo-client iOS develop.app/_CodeSignature/
  inflating: Payload/demo-client iOS develop.app/_CodeSignature/CodeResources  
  inflating: Payload/demo-client iOS develop.app/demo-client iOS develop  
...
  inflating: Payload/demo-client iOS develop.app/Info.plist  

CFBundleName:
demo-client iOS develop

CFBundleDisplayName:
demo-dev

Checking AppTransportSecurity...
True

NSExceptionDomains:
facebook.com

Custom schemas (CFBundleURLSchemes):
None

Potentially Sensitive Files:
./Payload/demo-client iOS develop.app/README.md
./Payload/demo-client iOS develop.app/hoge.sh

Output: ./Payload

また、IPAファイルはパッチ当てた後、リザインしないとインストールできませんが、リザインは結構手間です。 ~/ipautil.jsonに証明書の情報を書いておくことで、コマンド1発でリザインする機能もあります。

$ ipautil sign Payload/
Signing IPA by codesign...
Payload/demo-client iOS develop.app/Frameworks/Hoge.framework: replacing existing signature
Payload/demo-client iOS develop.app/Frameworks/Fuga.framework: replacing existing signature
Payload/demo-client iOS develop.app: replacing existing signature
Signed

NWPentestUtils

NWPentestUtilsは、内部ネットワークでのペネトレーションテスト業務で使用しているスクリプト集です。

github.com

DBサーバーを指定したネットワークレンジから見つけるfind-db.shやブルートフォース攻撃を行うbruteforce-postgres.shbruteforce-mysql.shbruteforce-redis.shあたりは特にお気に入りです。

$ ./find-db.sh target-ip.txt 
Target: 192.168.100.0/24
172.16.0.0/12
Now Launching: nmap -p3306 -v -oX results/20201221/192.168.100.0_24_mysql_20201221_140057.xml 192.168.100.0/24
Starting Nmap 7.91 ( https://nmap.org ) at 2020-12-21 14:00 JST
Initiating Ping Scan at 14:00
Scanning 512 hosts [2 ports/host]
...
Read data files from: /usr/local/bin/../share/nmap
Nmap done: 512 IP addresses (9 hosts up) scanned in 34.01 seconds
Now Launching: nmap -p5432 -v -oX results/20201221/192.168.100.0_24_postgre_20201221_140131.xml 192.168.100.0/24
Starting Nmap 7.91 ( https://nmap.org ) at 2020-12-21 14:01 JST
Initiating Ping Scan at 14:01
Scanning 512 hosts [2 ports/host]
Completed Ping Scan at 14:01, 14.59s elapsed (512 total hosts)
Initiating Parallel DNS resolution of 9 hosts. at 14:01
Completed Parallel DNS resolution of 9 hosts. at 14:01, 11.03s elapsed
...

DBサーバーにログインできた場合、RCEにまで持ち込めるケースがあります。

hackerslab.aktsk.jp

また、Nmapが出力したXMLファイルをスプレッドシートにコピーしやすいようCSV形式に変換するnmap-xml2csv.rbは地味に便利です。

$ ruby nmap-xml2csv.rb ./results/20201204/192.168.1.0_24_20201204_072122.xml
[+] parse these xml files:
./results/20201204/192.168.1.0_24_20201204_072122.xml
-----------------------------------------------------
192.168.1.1 53(domain), 80(http)
192.168.1.17    22(ssh)
-----------------------------------------------------
[+] Output: portscan-result.csv

おわりに

aproxという最近作成したツールの紹介と、今までに作成したツールの紹介をしました。 アカツキのセキュリティチームでは脆弱性診断をただ単に行うだけではなく、効率よく行うためのツールの作成も積極的に行い、OSSとして公開することで業界に貢献していく姿勢をとっています。

一緒に脆弱性診断やツール開発を行いたい方は、ぜひ採用ページから応募してください。お待ちしています。

hrmos.co

Ruby on Rails 6.1 の水平シャーディング対応 & Octopusからの移行事例

この記事は Akatsuki Advent Calendar 2020 の20日目の記事です。

はじめに

ゲームサーバでは大量のユーザーデータなどを取り扱うため、データベースの負荷分散のために水平シャーディング(水平分割)が行われることがあります。 アカツキでも、これまで Ruby on Rails や Elixir 等でゲームサーバを開発する中で、それぞれの方法で水平分割を行ってきています。

さて、先日リリースされた Ruby on Rails 6.1 では、待望の水平シャーディング機能が標準でサポートされました。

早速使っていきたいところですが、これまで別の方法で水平シャーディングを実現していたアプリケーションを移行するにあたってはいくつか課題があるため、 それをどう解決するかの一例をご紹介したいと思います。

また、その解決の一環で利用した Ruby の BasicObject クラスを用いた Proxy パターンについても説明します。

Ruby on Rails 6.1 の水平シャーディング機能

Ruby on Rails 6.1 の水平シャーディング機能を利用するには以下のようにします。

まず、 database.yml に複数のDB接続を定義します。 *1

development:
  user01:
    <<: *default
    database: user01
  user02:
    <<: *default
    database: user02
  user01_readonly:
    <<: *default
    host: user01-ro....
    database: user01
    replica: true
  ...

レプリカへの接続には replica: true という設定をすることで、マイグレーションや db:create などDB操作の対象外となります。

次に、shard・role (シャード名とwrite/read等の役割)とDB接続のマッピングを、 ApplicationRecord 等モデルの親となるクラスにて connects_to メソッドで設定します。

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to shards: {
    user01: { writing: :user01, reading: :user01_readonly },
    user02: { writing: :user02, reading: :user02_readonly },
    ...
  }
end

class User < ApplicationRecord
end

あとは、レコードを取得するときに、 ActiveRecord::Base.connected_to メソッドを使って shard, role を指定します。

ActiveRecord::Base.connected_to(role: :reading, shard: :user01) do
  User.find(user_id)
end

これによって、指定したDB接続に対してクエリが発行されるようになります。

取得したレコードを書き換えて save するときなど、クエリが発行される際には必ずこのブロック内で行う必要があります。そうしないと別のシャードに対してクエリが発行されてしまいかねません。

ActiveRecord::Base.connected_to(role: :writing, shard: :user01) do
  user.save!
end

※ なお上記はあくまでメソッドの使用例であり、トランザクションを全く考慮していません。 多くの場合、API単位などで一貫して connected_to ブロック内で処理することを想定しているものと思われます。

Octopusからの移行

さて、あるアプリケーションでは、これまで Octopus という外部gemを使って水平シャーディングを実現していました。 Octopusでは、上記の User の例だと以下のようにアプリケーションを記述します。 *2

user = User.using(:user01).first
...
user.save!

Octopus では各レコードがどのシャードに属すかを記憶しており、 save! などの各種メソッドを呼び出した時には、自動的にそのシャードに対してクエリを発行してくれるようになっています。

リレーションも同様で、例えば user が has_many リレーション items を持っているとき、単に user.items と書けば自動的に user の属するシャードから items の検索を行ってくれます。

user.items.where.not(expired: true)... といったようにリレーションに対して where 等をメソッドチェインでつなげた場合も、最終的にクエリが発行されるところまでシャード指定が伝わります。 これをシャードトラッキングと呼んでいます。

これにより、間違ったシャードにクエリを発行してしまう心配をしなくてすみますし、特に異なるシャードから取得した複数のレコードをまとめて処理するような時には記述が簡単になるという利点があります。

しかし、 Octopus は Rails 6.1 に対応しておらず、シャードの記述や管理の方法も異なるために、このままでは Rails 6.1 に移行するためにはアプリケーションを大幅に書き直す必要が生じてしまいます。

そこで、Octopus ライクなシャードの指定方法はそのままで、水平シャーディングのDB接続ハンドリングには Rails 6.1 のネイティヴ機能を使用する Octoball という gem を開発しました。

github.com

これを使えば、アプリケーションをほとんど書き換えることなく Rails 6.1 に移行することが可能となっています。

ただし、DB接続の管理は Rails 6.1 に任せるため、接続の定義は Octopus 独自の設定ファイルから上記の database.yml に移す必要があります。 using() の引数に指定するのは connected_to に渡す shard に相当するシンボルになります。

Proxy パターンによるシャードトラッキングの実現方法

Octoball でシャードトラッキングを実現するにあたっては、いわゆる Proxy パターンを利用しています。 *3

user.items.where ... といった記述をした場合、各メソッドは ActiveRecord::Relation の様々な子クラス等を返しており、最終的にこの結果のレコード取得が必要になった段階でクエリが構築・発行されます。

各レコードや ActiveRecord::Relation は多様なメソッドを提供しているので、これらに逐一シャードトラッキング機能を入れていくのは現実的ではありません。

そこで、最初にこれらをラップする Octoball::RelationProxy というクラスのインスタンスを生成して返すようにします。

これに対して何かメソッドが呼ばれたときには、必要に応じてシャードを ActiveRecord::Base.connected_to で切り替えてから、ラップしているオブジェクトのメソッドを呼び出します。 そして、返り値が ActiveRecord::Relation などシャードトラッキングの対象であれば、再びそれをラップして呼び出し元に返します。

これが各メソッドチェインごとに繰り返されることで、最終的にクエリが発行されるところまでシャード指定が伝搬されてきいます。

Ruby には、まさにこうした Proxy パターンを実現するために、 BasicObject というクラスが用意されています。以下ではこれについて少し紹介します。

BasicObject による Proxy パターンの実装

BasicObject クラスは、通常の Object の親である Kernel のさらに親クラスにあたり、ほとんど何もメソッドを持っていないクラスです。 従って、これに対して何かしらのメソッドを呼び出そうとすると、基本的には method_missing が呼ばれることになります。 Proxy パターンでは、 method_missing で何かしらの処理をしてからラップしているオブジェクトのメソッドを呼び出すことで、そのオブジェクトの機能を拡張します。

例えば、ナイーブな Octoball::RelationProxy の実装だと、以下のように method_missing で元のオブジェクトのメソッド呼び出し前にシャードを切り替える動作を入れます:

class Octoball::RelationProxy < BasicObject
  def initialize(rel, shard)
    @rel = rel  # ラップしているオブジェクト
    @current_shard = shard
  end

  def respond_to?(method, include_all = false)
    @rel.respond_to?(method, include_all)
  end

  def method_missing(method, *args, &block)
    ret = nil

    # シャードを切り替えてから @rel に対してメソッドを呼び出す
    ::ActiveRecord::Base.connected_to(
        role: ::Octoball.current_role, shard: @current_shard) do
      ret = @rel.public_send(method, *args, &block)

      # connected_to のブロックから ActiveRecord::Relation を
      # 返すと自動的にその場でクエリを発行してしまうので、
      # nilを返してこれを抑制する
      nil  
    end

    # retがラップする必要のあるオブジェクトでなければそのまま返却
    return ret unless ret.is_a?(::ActiveRecord::Relation) || ret.is_a?...

    # retをRelationProxyでラップして返却
    ::Octoball::RelationProxy.new(ret, @current_shard)
  end

  # Ruby3でのキーワード引数デリゲーション対応
  ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
end

BasicObject を使わない場合、例えば ActiveRecord のコード中で

if x.is_a?(ActiveRecord::Relation)
  ...

などとクラスの判別をしている箇所に、 ActiveRecord::Relation をラップしている RelationProxy が渡った場合、 RelationProxy#is_a? が呼ばれることになり、ラップしたことで結果が変わってしまいます。 一方、 BasicObject なら is_a? メソッドすら持っていませんので、 method_missing 経由で @rel.is_a? に転送され、ラップされたオブジェクトのクラスに基づいた動作になってくれるというわけです。

ちなみに最後の、ruby2_keywords の件は、Ruby2.7以前でもRuby3以降でも **kwargs をつけずにキーワード引数のデリゲーションを可能にするために付加してあります。

なお、実際の Octoball では、メソッドに渡されたブロック内でのデフォルトシャードの振る舞いを Octopus に合わせたりするためにもっと複雑なことになっています。 また、 method_missing を毎回呼ぶのは遅そうなので、最初の呼び出し時に同名の Proxy メソッドを動的に定義してしまうという方法でいくらか高速化を狙っていたりもします。

まとめ

本記事では、 Ruby on Rails 6.1 で対応した水平シャーディング機能と、Octopus を使ったアプリケーションをそこに乗せるために開発した Octoball gem について紹介しました。 また、Octoball でシャードトラッキングを実現するのに用いた Proxy パターンと、その実装のための BasicObject についても説明しました。

Octoball 自体は割とニッチな gem だと思いますが、Proxy パターンなどのテクニックが何かの参考になれば幸いです。

*1:複数DB接続の定義自体はrails6から可能になっていた機能です。

*2: この using は ActiveRecord::Base に追加されるメソッドで、 Ruby の Refinement で使われる Module#using とは関係ありません。

*3: 元になった Octopus においても Proxy パターンが用いられています。

1人開発のコードにオニオンアーキテクチャを導入した経験談

はじめに

この記事は Akatsuki Advent Calendar 2020 の 16 日目の記事です。

s-capybara です。普段は Elixir でモバイルゲームサーバーの機能開発をしていることが多いですが、時々 Go でツール開発も行っています。ほぼ1人で開発していてコードベースを自由に変更できるということもあり、このツールにオニオンアーキテクチャを導入してみました。色々あった困りごとが解消できたのですが、実際に導入することで新たに悩みが生まれたこともあり、その経験を共有できればと思います。

オニオンアーキテクチャとは

オニオンアーキテクチャ は Jeffrey Palermo 氏が提唱したアーキテクチャです。コードをいくつかのレイヤに分解して、依存性の逆転により DB やファイルにアクセスする処理を外に追い出し、アプリケーションの中心であるドメインロジックがこれらに依存しないようにするというものです。ドメインロジックのテストがやりやすくなったり、DB を他の機構に乗り換えやすくなったりといったメリットがあります。

依存性を逆転させてドメインを中心に置くというのが大事なポイントなので、ヘキサゴナルアーキテクチャやクリーンアーキテクチャと本質的には変わりません。個人的には、シンプルでレイヤのネーミングが分かりやすく他に比べて好みです。

作っているもの

今回オニオンアーキテクチャを導入した対象について少し説明します。

モバイルゲーム開発には大量のマスタデータを作成する仕組みが必要です。ゲームにおけるマスタデータというのは、例えば「A という名前のクエストがあって、その中には B という敵がいて、クリアすると C という報酬が貰える」など、ゲームの進行方法を指示するデータのことです。ゲームクライアントやゲームサーバーはこれを読み込んで、動的に処理を決定していきます。

今回取り上げるのはこのマスタデータ作成に使うツールで、Go で書いています。Excel にマスタデータの内容を記述し、事前に YAML で定義しておいたスキーマ(型情報)をもとに、JSON などゲームが利用しやすい形式のファイルに出力します。単に出力するだけでなく、データレコードをリリース日別に出力したり、海外版のために翻訳データを適用したりといった機能もサポートしています。このツールはデータを作成する人のローカル環境で実行するのですが、Go なのでバイナリの作成・配布が簡単で、あるゲームタイトルで使っている大量のデータでも1分ほどで実行が完了するぐらい高速です。

テーブルごとに以下のように YAML でスキーマを定義し、Excel の中身を解釈しています。

properties:
  id:
    description: "ID"
    type: "integer"
  name:
    description: "クエスト名"
    type: "string"
  stamina:
    description: "消費スタミナ"
    type: "integer"

f:id:s-capybara:20201216193234p:plain
サンプルの Excel

オニオンアーキテクチャの導入による改善

導入前に困っていたこと

最初はシンプルな実装だったのですが、開発を進める中で機能の数が増えていき、当初の想像以上に複雑なアプリケーションになっていきました。以下のように困りごとの数も増えていき、開発がやりにくくなってしまいました。

  • テストが入力データのファイル形式に依存している。
    • 複雑なロジックの途中に YAML を読む処理があるため、細かいテストのために逐一 YAML でテストデータを作る必要がある。
    • Excel で日時を表現するデータ形式が特殊なため、自分でテストデータを作ることが難しく、日時に関するテストをする際に Excel でテストデータを作る必要がある。
  • ファイル形式やライブラリの都合がコード全体に漏れ出ている。
    • YAML のデータ構造と実装に便利なデータ構造が微妙に異なるため、YAML にデータ構造を合わせるとそれを利用する側のコードが書きにくくなる。
    • YAML ライブラリの都合上、struct のフィールドを public にする必要がある。(そのため、関数でラップして上の問題を解決するということも難しい)
  • 新しい出力形式のサポートが難しい。
    • ツールを利用するゲームタイトルによって出力形式を変えられるようにしようと思うと、各形式を ON/OFF する分岐処理が煩雑になる。
    • 各形式で細かく設定変更できるようにするのも難しい。

改善後の構造

これらの問題を解決するために、オニオンアーキテクチャを導入しました。 以下のようにリポジトリのパッケージ(ディレクトリ)構成を変更し、依存関係が domain <- application <- infrastructure となるようにしました。

  • domain
    • Excel や YAML といった特定の形式に依存しない、ツールにとって最も重要なロジックを書く。
    • 外側のレイヤが使う interface を定義する。
  • application
    • domain のオブジェクトや、domain の interface を満たす infrastructure のオブジェクトを組み合わせて、ツールのユーザーが所望する単位の処理の流れを形成する。
    • コード量は非常に少ない。
  • infrastructure
    • Excel や YAML, JSON といった特定のファイル形式に依存したコードを書く。
    • domain で定義された interface を実装する。

内側で interface を定義することで依存性を逆転させ、ドメインロジックが特定のファイル形式に依存せずに済むようになりました。

オニオンアーキテクチャではドメインモデルとドメインサービスがはっきり分かれていますが、今回は、区別しつつも同じディレクトリに入れることにしました。ドメイン駆動開発(を今回きっちりやっているわけではないですが)において、これらを別の場所に置くと「ドメインモデル貧血症」を起こしやすくなると ヴァーン・ヴァーノン著『実践ドメイン駆動設計』 に書かれています。また、ドメインサービスが大きくなりすぎないように、かつドメインモデルがドメインサービスに依存しないようにしています(注意しなくても自然とそうなりました)。

改善されたこと

オニオンアーキテクチャのメリットそのものですが、以下のようにメンテナンス性が向上しました。

  • ドメインロジックがファイル形式に依存しなくなり、テストしやすくなった。
  • ファイル形式やライブラリに合わせるためのコードを一箇所に集約できるようになった。
  • 新しい種類のファイル形式に対応しやすくなった。

例として3つ目を少し詳しく紹介します。機能追加をしようとしていて、ツールを利用するゲームタイトルごとにファイルの出力形式を変更できるようにし、またそれぞれの形式について細かいオプションを付けたいという事情がありました。以前は決め打ちで特定の形式を使うようになっていたのですが、この箇所の柔軟性が上がり、設定ファイルを通じて任意の形式を選べるようになりました。また、同じ形式を異なるオプションで複数回利用するということもできるようになりました。

marshalers:
  # キーは出力先のディレクトリ名
  diff_check:
    type: json
    options:
      time_zone: JST
  server:
    type: message_pack
  client:
    type: message_pack
    options:
      hide_secret: true # ゲームクライアントが使わないテーブルやカラムを削除する

導入後に悩んだこと

各レイヤのテスト

どのレイヤにどの程度テストを書けば良いのか、というのは悩んだポイントでした。domain については、もともとの動機でもあるのでユニットテストを細かく書いています。一方で、application についてはとても薄いレイヤになり、分岐があるわけでもないので全くテストしないことにしました。代わりに、Excel や YAML でシンプルなテストデータを少しだけ作って、infrastructure を含めた E2E テストを書くことで application のテストを内包することにしました。手動での動作確認を頻繁に繰り返す手間を省くため、ツール全体が正しく動作することを保証したい事情もありました。

しかし、この対応だけでは不充分で、アップデートしたツールを利用者(マスタデータ作成者)に配布したところ、何度かトラブルが発生してしまいました。domain にバグが含まれるケースはなく、毎度 infrastructure に原因がありました。infrastructure が外部のファイルに依存していることを理由にあまりテストを書いていなかったのですが、冷静に考えてみると、いくつかの工程に分解できることに気づきました。

  1. テキストファイルを読み込む
  2. ライブラリを使ってパースする
  3. domain に合う形に変換する

2 の YAML のキー名を指定する場所で typo が発生しがちで、度々 0 や空文字列などのデフォルト値になってしまっていました。また、3の工程も意外に複雑でした。しかし、細かい機能を追加するたびに E2E テストを書いていては、テストの影響範囲が大きくメンテナンスコストが高すぎると感じました。

そこで、1 とそれ以外を別々の関数にし、2 の結果がライブラリ独自の複雑なデータ構造であり自分で作ることが難しいことも考慮して、テストコードの中にヒアドキュメントとして YAML などを書き、2 と 3 のみまとめてテストすることにしました。ライブラリや domain に強く依存しているので若干脆いテストにはなるのですが、特段メンテナンスしにくいということもなく、バグの発生を抑えられるようになりました。

巨大な domain の整理

オニオンアーキテクチャでは、ドメインモデルやドメインサービスの中身をどう構成するかは特に言及されていません。今回の実装では domain/schema, domain/record といったいくつかのパッケージに分割し、domain/schema <- domain/record などと横向きの依存関係を作りました。複数の domain パッケージから参照されるものもあれば、他の domain パッケージからは参照されないものもあります。今後は domain パッケージの階層を増やす可能性もあります。

domain パッケージの間には interface を挟まず、直接参照するようにしました。最初は interface を逐一定義しようと考えたのですが、ドメインモデルの全てのメソッドを interface 化しようとするとかなりの手間だったので、1人開発では割に合わないと思い、断念しました。

interface を定義しないとなると、スタブを使うことができず他のパッケージ (schema) を参照しているコード (record) のテストが難しくなります。そこで、厳密さよりもテストデータの作りやすさを優先して参照先のコード (schema) を変更しました。具体的には、schema オブジェクト生成時にオブジェクトが正当であることの保証をせず、少しずつ組み立てて、最後にバリデーションする形にしました。

// domain/schema/schema.go
package schema

func NewProperty(name string, propertyType PropertyType) *Property {
    // ...
    return property
}

func (schema *Schema) AddProperty(property *Property) *Schema {
    schema.properties = append(schema.properties, property)
    return schema
}

func (schema *Schema) Validate() error {
    // プロパティ名のバリデーションなど
}
// domain/record/record_test.go
package record

func TestRecord(t *testing.T) {
    sch := schema.NewSchema().
        AddProperty(schema.NewProperty("id", schema.Integer)).
        AddProperty(schema.NewProperty("name", schema.String).SetNullable(true))

    // ...
}

この例では、例えば schema.NewProperty の第1引数であるプロパティ名にはアルファベットや数値以外指定できないのですが、作る際は一旦気にせず、後でバリデーションしています。そのため、バリデーションを実行し忘れると不正なスキーマで処理が続行してしまう危険性があります。interface 定義の手間を減らしてテストをしやすくするためにバグのリスクを取っているということになり、これがベストな方法なのか、今でも迷いがあります。

もはやオニオンアーキテクチャの管轄外の問題ではありますが、ある意味、オニオンアーキテクチャの導入によって問題が明瞭になったと言えるのかもしれません。以前は他の問題が多すぎて、ドメインロジックとそのテストをどう整理していくかということに意識をあまり向けられていませんでした。

infrastructure 都合で domain 実装が変わることもある

「内側のレイヤは外側のレイヤの変更の影響を受けない」と思っていましたが、そうでない場面もありました。パフォーマンスチューニングをするにあたって、例えば DB を使う場面で、複数レコードをまとめて INSERT することで DB との通信回数を減らすなど、ドメインが DB の事情に全く無関心というわけにはいきません。「内側で interface を定義して外側で実装する」というのは「外側の変更の影響を受けない」とはイコールではありませんでした。

今回のツールでは、当初 Excel のシートを読む際に 360EntSecGroup-Skylar/excelizefunc (*File) GetRows という関数を使い文字列の二次元配列 [][]string として利用していましたが、大きいファイルを扱うにはパフォーマンス上不向きだったため、func (*File) Rows というストリーミングで読み込む方式の関数に乗り換えました。これに従ってドメインの interface も変更する必要がありました。

外側の事情で内側を変更することに抵抗を感じましたが、読み込み済みの [][]string データをストリーミング方式の interface で利用することもやろうと思えば可能ではあるので、「より汎用的な interface にブラッシュアップされた」と考えれば、悪いことではなさそうです。

まとめ

オニオンアーキテクチャを使うと、ドメインロジックが特定のファイル形式などに依存しないようになります。導入することで新たに悩むポイントもいくつか発生しましたが、テストがしづらいなどの様々な問題を改善できました。参考になる部分があれば幸いです。