はじめに
こんにちは、イタンジ株式会社でエンジニアをしている中山です。物件連動チームに所属していて、外部のシステムから送られてくる物件情報を取り込む物件基盤の開発を担当しております。
物件連動チームでは、さまざまなCSV形式で送られてくる物件情報を取り込む処理や、それらの情報をさまざまな外部システム向けに出稿する処理を担当しています。これらの仕組みを総称して「物件連動システム」と呼んでいます。
この記事では、複数の物件連動システムを一つのRailsアプリ(物件連動統括システム)で管理するにあたって、packwerk および packwerk-extensions を導入した経緯と、各設定項目の意味を紹介します。なお、本記事ではpackwerkにおける packs/ 配下の各アプリケーション単位をパッケージと呼びます。
物件連動システムの概要
物件連動システムは外部からの物件取り込みや外部システムへの出稿を担う仕組みです。取り込み元や出稿先は1つではなく、異なるCSV形式や連携仕様を持つ複数の外部システムが存在します。それぞれの外部システムに対応する形で、取り込み用の import_system_* と出稿用の export_system_* が並列に稼働しています。
外部システムA(取り込み元) → import_system_a → イタンジ物件DB 外部システムB(取り込み元) → import_system_b → イタンジ物件DB 外部システムC(取り込み元) → import_system_c → イタンジ物件DB イタンジ物件DB → export_system_d → 外部システムD(出稿先) イタンジ物件DB → export_system_e → 外部システムE(出稿先) イタンジ物件DB → export_system_f → 外部システムF(出稿先)
それぞれの物件連動システムは独自のCSVフォーマットや業務ロジックを持ちつつも、共通する処理(バリデーション、データ変換、ストレージへの保存など)も多く存在します。
なぜモノリス(物件連動統括システム)を選んだか
以前は各物件連動システムが別々のワークフローエンジン上で動作しており、インフラの設定や運用コストがシステムごとに存在していました。デプロイパイプラインもそれぞれ独立していたため、横断的な変更のたびに複数のリポジトリを触る必要があり、開発効率も悪い状況でした。
こうした課題を解決すべく、現在は一つのワークフローエンジンで複数の物件連動システムを管理する物件連動統括システムへの統合を進めています。
ここで「各システムをコンテナで分けてAPI連携させるマイクロサービス構成にすれば良かったのでは?」という疑問が浮かぶかもしれません。しかし、それには以下のような問題があります。
- 共通ロジックをAPI越しに呼び出すのは思いのほか面倒です。チーム間のコミュニケーションコストや仕様の違いから連携が難しくなり、gemで切り出す方法もリリースサイクルが絡んで重くなります。
- 一度分割したものを後から集約しようとすると、インフラ変更やDNSのゾーン管理、通信設計のやり直しなど、変更コストが跳ね上がります。
そのため、一つのRailsアプリ(モノリス)として集約する選択をしました。
ただし、モノリスに集約すると今度は別の問題が生じます。複数の物件連動システムのコードが一つのアプリに同居するため、システム間の依存関係が意図せず混沌としやすくなります。「import_system_a の内部クラスを export_system_d から参照している」といった状況が気づかないうちに発生してしまいます。
この問題に対処するために、packwerkを導入することにしました。
packwerkとは
packwerkはShopify製のRuby/Rails向けgemです。Railsアプリ内をパッケージという単位で論理的に分割し、パッケージ間の依存関係・パッケージ間の公開範囲・ビジビリティ(パッケージ自体への参照制限)を「宣言型」で管理できます。
最大の特徴は、実行時ではなく静的解析として機能する点です。packwerk check コマンドを実行すると、宣言された制約に違反している依存関係が検出されます。このコマンドをCIに組み込むことで、PRのマージ前に違反を検知できます。
物件連動統括システムへのpackwerk導入
ディレクトリ構成
物件連動統括システムでは packs/ ディレクトリ以下にパッケージを配置しています。
物件連動統括システム/ ├── packs/ │ ├── shared/ │ │ └── package.yml │ ├── import_system_a/ │ │ └── package.yml │ ├── import_system_b/ │ │ └── package.yml │ ├── import_system_c/ │ │ └── package.yml │ ├── export_system_d/ │ │ └── package.yml │ ├── export_system_e/ │ │ └── package.yml │ └── export_system_f/ │ └── package.yml ├── package.yml └── packwerk.yml
packs/shared には、各システムが共通で利用するロジック(バリデーション処理、変換ユーティリティなど)を置いています。各 import_system_* および export_system_* は shared に依存することは許可しますが、互いに依存しない設計にしています。
各パッケージのルートに置かれた package.yml が、そのパッケージの依存・公開範囲などのルールを宣言するファイルになります。
package.ymlの設定例
enforce_dependencies と dependencies は packwerk 本体の機能です。それ以外の enforce_privacy、enforce_folder_privacy、enforce_visibility および visible_to は packwerk-extensions が提供するチェッカーです。
# packs/import_system_a/package.yml # packwerk enforce_dependencies: strict dependencies: - packs/shared # packwerk-extensions enforce_privacy: strict enforce_folder_privacy: true enforce_visibility: true visible_to: - packs/admin
各設定項目の意味と効果
enforce_dependencies と dependencies
他パッケージへの依存を dependencies: に明示的に宣言することを強制します。宣言されていないパッケージへの依存はすべて違反として検出されます。
strict と true の違いは、既知の違反をどう扱うかにあります。
true: 違反があってもpackage_todo.ymlに記録することで「一時的なホワイトリスト」として扱えます。既存の違反を段階的に解消しながら移行したい場合に有効です。strict:package_todo.ymlへの新規記録を禁止し、違反ゼロを強制します。package_todo.ymlに追加しようとしてもpackwerk checkが失敗します。
dependencies: には、参照を許可するパッケージを列挙します。
dependencies: - packs/shared
enforce_privacy
パッケージ内の定数はデフォルトでprivate扱いになります。外部のパッケージから参照できるのは、app/public/ ディレクトリ以下に配置したファイルの定数だけです。
例えば、packs/shared/app/models/property.rb という内部モデルがあっても、それを外部パッケージから直接参照しようとすると、packwerkが違反として検出します。外部に公開したい定数は packs/shared/app/public/ 以下に移動させます。
public パスは独自に設定することもできます。
public_path: my/custom/path/
例外的に特定の定数を外部へ公開したい場合は、pack_public: true を設定することで公開ファイルとして扱えます。
# pack_public: true module Foo class Update end end
enforce_folder_privacy
enforce_folder_privacy: true を設定すると、そのパッケージはフォルダ階層上の位置関係に基づいてアクセス制御されます。同じ親の兄弟パッケージと親・祖先パッケージからの参照だけが許可され、それ以外は違反扱いになります。
例えば、以下のようなパッケージ構成を考えます。
. (ルートパッケージ)
├── packs/a_system/
└── packs/b_system/
├── packs/d_system/
├── packs/e_system/ ← ここに enforce_folder_privacy: true を設定
│ └── packs/f_system/
└── packs/h_system/
packs/c_system/
packs/b_system/packs/e_system に enforce_folder_privacy: true を設定した場合の各パッケージからのアクセス可否は次のとおりです。
| 参照元 | 結果 | 理由 |
|---|---|---|
.(ルート) |
参照可 | 祖先パッケージ |
packs/a_system |
参照不可 | 無関係なパッケージ |
packs/b_system |
参照可 | 親パッケージ |
packs/b_system/packs/d_system |
参照可 | 兄弟パッケージ |
packs/b_system/packs/h_system |
参照可 | 兄弟パッケージ |
packs/b_system/packs/e_system/packs/f_system |
packs/b_system/packs/e_system 自身の設定に依存 |
子パッケージ |
packs/c_system |
参照不可 | 無関係なパッケージ |
enforce_visibility と visible_to
enforce_visibility: true を有効にすると、visible_to: に列挙されたパッケージだけがこのパッケージを参照できるようになります。上記の設定例では visible_to: に packs/admin を指定しているため、packs/admin 以外からの参照は違反になります。
導入して良かったこと
依存関係が一目でわかるようになったのは、大きかったです。各パッケージの package.yml の dependencies: を見るだけで、そのパッケージがどのパッケージに依存しているか一望できます。導入以前は「このクラスどこから使われているんだろう?」とgrepして調べる必要がありましたが、今は依存関係がコードに明示的に宣言されているため、新しくプロジェクトに参加したメンバーも、設定ファイルを読むだけでアーキテクチャの全体像がわかるようになりました。
CIで意図しない依存を即検知できるのも助かっています。意図しない依存関係の追加はランタイムエラーを起こさないため、コードレビューをすり抜けて積み重なっていくことがあります。packwerk check をCIに組み込んでいるため、新しいコードを追加したときに依存ルール違反があればPRのCIが落ちます。「後で直そうと思っていたら溜まっていた」という状況を防げています。静的解析なので実行時オーバーヘッドもありません。
意外だったのは、設計についての会話がコードレビューの場で自然に生まれるようになったことです。packwerkを導入してから、開発メンバーが新しい機能やクラスを追加するたびに「このコードはどのパッケージに属するか」「このパッケージが依存してよい相手は何か」を自然と考えるようになりました。「このクラスは shared に置くべきか、それともシステム固有のパッケージに置くべきか」を決める過程で、共有すべきロジックと各システム固有のロジックの境界線が整理されていきます。
おわりに
packwerkを導入してみて、依存関係の管理をレビューだけに頼らなくていいのは思いのほか気持ちいいものだと感じました。ルールをコードで宣言し、CIが自動で検知してくれるので、「なんとなく動いている」状態から抜け出せます。
複数の機能が同居するRailsアプリや、これから立ち上げる新規アプリであれば最初から入れておくと、後々ずっと楽になると思います。この記事が導入のきっかけになれば嬉しいです。
最後までお読みいただき、ありがとうございました。