ビジネス開発部のバックエンドエンジニアの伊藤です。主にトクバイのビジネスサイドの開発を担当しています。
Shopify記事の影響もありしばらく前からモジュラモノリスが注目されるようになりました。 我々が開発しているトクバイではモノリシックに構築されており、各機能が密結合になりメンテナンスしづらくなっています。 今回、新機能プロジェクトを担当する上で各機能をモジュール分割・コンポーネント化することを試してみました。
Packwerk
PackwerkはShopifyが開発しているGemで、Railsアプリケーションをモジュール分割する手助けをしてくれます。 USAGEに書かれていることを要約すると、大規模なアプリケーションは境界を作り境界間の依存関係をコードレベルで最小にしようということだと考えています。 後述しますが幾つかのエコシステム導入する事で、よりモジュラモノリス化を促すことができます。
Packwerkを導入することで次のことができます。 - パッケージの定義 - パッケージの依存関係の管理 - 依存関係の検証
Packwerkではパッケージ化のベストプラクティスが記載されています。これについても少し触れます。
- 機能的凝集性の高いものをパッケージ化する
- パッケージは相互に緩い結合をする必要がある(疎結合)
※ 図はhttps://github.com/Shopify/packwerkより引用
パッケージ間の結合度を低くすることにより、モジュール・機能の依存度を下げることができそうです。 本来ならパッケージ設計の原則についてもしっかり触れたいところではありますが今回は省きます。
一緒に入れると便利なgem
packwerk-extensions
https://github.com/rubyatscale/packwerk-extensions
Packwerk単体ではパッケージの依存関係はチェックできるものの、それ以外のルールを課すことができません。 packwer-extensionsを導入することで以下のチェッカーを導入することができます。
Privacy Checker
Privacy Checkerではパッケージ内のモジュール・クラスの公開・非公開を明示的にし、依存関係を制限できます。
このチェッカーを導入した上でpublic
ディレクトリに配置した機能を公開し、それ以外を非公開にできます。
こうすることで不要な外部依存を検出することができます。
今回のプロジェクトでは有効にしました。
Visibility Checker
Visibility Checkerは他のパッケージのプライベート実装として定義することができます。 定義した依存関係外から直接参照があればエラーになるイメージです。 今回のプロジェクトでは利用していません。
Architecture Checker
Architecture Checkerではパッケージにアーキテクチャレイヤーを付与し、依存関係の順序を定義することができます。 Clean ArchitectureやDDDを意識するような依存チェックをしたい場合は有用そうです。 今回のプロジェクトでは利用していません。
packs-rails
https://github.com/rubyatscale/packs-rails
packs-railsを導入するとautoloadやテストなど分割した際に微妙に面倒になりそうな設定を省略することができます。 その代わり指定の構成にする必要が出てきますが大きな問題は無いかなと思います。
package.yml # root level pack app/ # Unpackaged code models/ ... packs/ my_domain/ package.yml # See the packwerk docs for more info deprecated_references.yml # See the packwerk docs for more info app/ public/ # Recommended location for public API my_domain.rb # creates the primary namespaces my_domain/ my_subdomain.rb services/ # Private services my_domain/ some_private_class.rb models/ # Private models some_other_non_namespaced_private_model.rb # this works too my_domain/ my_private_namespaced_model.rb controllers/ views/ config/ initializers/ # Initializers can live in packs and load as expected lib/ tasks/ spec/ # With packs-rails, specs for a pack live next to the pack public/ my_domain_spec.rb my_domain/ my_subdomain_spec.rb services/ my_domain/ some_private_class_spec.rb models/ some_other_non_namespaced_private_model_spec.rb my_domain/ my_private_namespaced_model_spec.rb factories/ # packs-rails will automatically load pack factories into FactoryBot my_domain/ my_private_namespaced_model_factory.rb my_other_domain/ ... # other packs have a similar structure my_other_other_domain/ ...
各パッケージはpacks
配下に設置し、各パッケージ内ではRailsアプリケーションと同様の構成を取ることができます。
前述したpackwerk-extensionsのPrivacy Checkerを導入した場合はpublic
ディレクトリも登場しますがそれ以外に大きな違いは無いと思います。
graphwerk
https://github.com/samuelgiles/graphwerk
パッケージの依存関係を図にすることができます。 依存関係を図にすることで直感的に機能のありかや複雑さが分かるようになります。
※ 図はhttps://github.com/samuelgiles/graphwerkより引用
導入
紹介したGemすべてを導入方法について説明します。 尚、Railsアプリケーションであることを前提とします。
# Gemfile gem 'packwerk' gem 'packwerk-extensions' gem 'packs-rails' gem 'graphwerk', group: :development
graphwerkはGraphvizを利用します。
macOSであればbrew install graphviz
でインストールしておくと良いでしょう。
コマンド実行を楽にする為にbinstubを生成してきます。
bundle binstub packwerk
packwerkの設定とパッケージ定義を生成します。
bin/packwerk init
bin/packwerk init
すると以下のファイルが生成されます。
- packwerk.yml
- package.yml
packwer.ymlはpackwerkの設定ファイルです。packwerk-extensionsで導入したいチェッカーがあればこのファイルに追記します。 package.ymlはパッケージを宣言するファイルです。packwerkではpackage.ymlが設置されたディレクトリ配下を1つのパッケージとして扱います。 ネストしている場合は単純にパッケージ境界として認識します。なのでサブパッケージのような扱いにはなりません。
工夫していること
名前空間を切る
当然と言えば当然ですがパッケージ毎に名前空間を切っています。
公開APIに関しては{Package}::Public
と言う形で切り出しています。
呼び出す時に少し冗長でわずわらしさもありますが、内部のAPIなのか外部のAPIなのかが意識しやすくなります。
各パッケージをなるべく小さく保つ
大きくなったモジュールを小く分離する方がコストが高いという考えで、 パッケージとして切り出しすぎかも?ぐらい小さく保つことを心掛けています。
循環依存関係を持たない
循環依存を持つと全体像が把握が難しくなりがちです。依存関係をツリー状にすることで循環依存を回避するよう心掛けています。
Architecture Checkerを導入してレイヤーの階層を作っても良かったのですが、初期段階では決め切れないと判断し導入しませんでした。
テスト
RSpecを使っている場合はpacks/rails/rspecをrequireすることで各パッケージのテストをワンコマンドで実行できるようになります。 .rspecに記載しておくと便利です。
--require spec_helper --require packs/rails/rspec
モデルを共有しない
同一のテーブルを参照する場合でも必要に応じてパッケージ毎にモデルを用意するようにしました。 パッケージに閉じ込めたビジネスロジックに直接参照するとパッケージ間の依存度が強くなるからです。
DDDでCreatePost
、UpdatePost
のようにエンティティを分けて実装する場合がありますが、この思想に似ています。
CI
パッケージの依存関係をCIで常にチェックし違反があった場合に検出できるようにしました。
感想
良かったこと
境界を意識できる
今から実装するもの・利用するものは公開APIなのか非公開APIなのかを意識するようになります。 それによって影響範囲が事前に把握しやすくなりました。 また、コメントを記述する際も特に公開APIであれば利用する際の注意点や副作用なども気にするようになりました。
他の言語では標準機能として搭載している場合もあるので良いなと思いました。
複雑度が下がる
これは依存関係をどう持たせるかによりますが、前に上げた「境界を意識できる」レベルで分解することが習慣化され、1つ1つのパッケージ・モジュールの複雑度が下げやすくなった印象があります。 各名前空間のクラス数など数えると明かに数が少なくなっているので効果が得られていそうです。
気になること
設定やルーティング
パッケージをmountable engineのように振る舞わせることができるのですが、その機能を活用してconfig配下をパッケージ毎に配置しています。 パッケージ内のコンテキストに絞って見ている内はいいのですがアプリケーション全体に影響するような設定や、どこで追加されたか分かりづらいroutes.rbが今後出てきそうです。
この辺は設定メソッドやルーティングメソッドをパッケージの公開APIとして用意し、アプリケーション側で呼び出すように変更した方が良さそうだなと思いました。
モデルの共有
モデルを共有していなことでパッケージ毎にモデルやファクトリの定義をしなくてはならず少し大変でした。 共通のバリデーションなどが出てきた場合にsharedパッケージのような微妙なパッケージが必要になりそうだなと思いました。
こういうシーンではインターフェイスのような概念がRubyにも実装されていると、パッケージ間の実装の齟齬などが発生を抑えらるなと実感しました。 型アノテーションも一緒に導入できれば良かったなと思いました。
まとめ
試験導入ということもありまだまだ勉強し工夫しなければならない段階だと思いますが、他のプロダクトにも導入したい感触がありました。 読者の皆さんも是非Packwerkでモジュラモノリスライフを始めてみてください。