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

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

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 にブラッシュアップされた」と考えれば、悪いことではなさそうです。

まとめ

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