STORES 予約 でエンジニアリングマネージャーをしている Natsume です。
STORES 予約 は10年モノの45万行、380テーブルある大きなモノリスの Rails アプリケーションです。
業種にとらわれない汎用的な予約システムであり、それらに対応するように複雑なコードベースになっています。また、ここ 1~2 年はプロダクト間連携を進めており、各基盤やアプリケーションともつなげていく開発を進めています。今後も新規プロダクトとの連携や機能開発を進めるには、少しでも認知負荷を上げずに開発しやすい状態を保ち続けるか、が重要だと感じました。
その課題感の中で、今回はモジュラモノリスを選択し導入をしましたので、そちらのお話をしたいと思います!
現状の課題感
私が入社した3年前から STORES 予約 の開発メンバーは3倍になり比較的新しいメンバーが多く、また古くからいるエンジニアも少数なため、仕様の経緯などがわからずコードが仕様となっているケースが多くありました。
そのため、コードを追って仕様整理しなければいけないが
- コードベースが大きくなったことと
- 歴史的経緯からさまざまなコードの書き方が混ざっている
となっており、関連したコードを探すのに非常に時間がかかっていました。
また、機能拡充する際も
- 影響範囲がわからない
- どこに書いてあるか探し出せない
のような課題感があり、リファクタするにしても調査に時間がかかるため、それらが少しずつプロダクト開発の足を引っ張っている肌感がありました。
モジュラモノリスを選んだ理由
倒したい課題
- 認知負荷を下げ、特定の機能に関連するコードを読みやすくしたい
- 技術負債化している特定のコードをリファクタしやすい状態にしたい
- これらを対応するために、大きな運用や工数はかけたくない
さほど重要ではない課題
- 機能ごとにチームを分けること
- テーマごとにチームで開発するため、特定の機能を担当するチームなどはない
- コンフリクトを避けること
- ロードマップ時点で同じ機能を並列開発しないように整理されており、あまりコンフリで困ったことはない
- 特定のサービスの完全な分離
- アーキテクチャの変更等。非機能要件が難しかったり横断で必要なサービスは基盤として作っており、別アプローチで対応済
それらの理由により、モノリスな状態からサクッと導入できるモジュラモノリスを試験導入して、課題が小さくなるか試してみることにしました。
Packwerk を選んだ理由
Packwerk は Shopify 製のモジュール間の依存関係を静的解析するツールです。 Rails でモジュラモノリスを始めるにあたってメジャーなツールであり、静的解析するだけなので導入も容易です。
STORES 予約 ではメタプログラミングはさほど利用されておらず、静的解析で追いやすいコードベースとなっていると判断し、Packwerk で進めることにしました。
名前空間などは変えずにディレクトリが変わるだけですので、モジュールを新たに作る・モジュールの境界を変更する際も容易であり、撤退することも簡単です。
モジュラモノリスの知見もなかったため、メジャーで情報量も多く、サクッと始められることを重要視しました。
※ 余談:こちらの記事にて Rust 製の依存関係を解析する packs というツールが紹介されています。時間がある際に試してみたいです。
Packwerk に関連する gem だが入れてないもの
packwerk-extensions(プライバシーチェック)
Shopify チームからプライバシーチェックは目的から外れてしまった、と伝えています。
しかし、より深刻な問題がありました。それは、プライバシーチェックが Packwerk を本来の目的とは異なるものに変えてしまったことです。Packwerk は、依存関係のグラフを定義し強制するためのものでしたが、実際には API 設計ツールとして使用されていました。パッケージ A がパッケージ B のコード(公開 API であっても)を使用することは、A が B に依存していない限り許可されませんが、開発者たちはコード内の依存関係よりも API の設計に注力していることがわかりました。これにより、ツールが解決するために作られた問題から注意が逸れていました。(日本語訳)
packs-rails
rails 向けの autoloader やテストの path などをよい感じにしてくれる設定ライブラリです。 明示的に設定したほうがわかりやすいのでいれてません。
導入
Packwerk の導入
公式の Installation を進めます
- Gemfile に packwerk を追加
bundle binstub packwerk
bin ファイルを作成bin/packwerk init
初期化するmkdir packs
packs ディレクトリを切る
packs 配下にモジュールをおいていきます。
autoloader に含める
- autoloader に packs 配下を追加
- i18n に packs 配下を追加
module App class Application < Rails::Application # ...略 # モジュラモノリスのディレクトリ構成に対応するため対象をautoload対象に追加 config.x.packs_directories = Dir.glob('packs/*') config.x.packs_directories.each do |dir| config.paths.add dir, glob: '{app/*,lib}', eager_load: true end config.i18n.load_path += Dir[Rails.root.join('packs/*/config/locales/**/*.{yml}')] end end
FactoryBot に含める
- FactoryBot に packs 配下を追加
Rails.application.configure do # ...略 # packsディレクトリのfactory_botも読み込むようにパスを指定する config.factory_bot.definition_file_paths = config.x.packs_directories.map { "#{_1}/spec/factories" } + ['spec/factories'] end
ApplicationMailer に含める
- ApplicationMailer の view ファイルに packs 配下を追加
class ApplicationMailer < ActionMailer::Base append_view_path(Rails.root.glob('packs/*/app/views')) end
rubocop に含める
rubocop-rails などで path が指定されている cop は packs 配下ファイルを見てくれません。いったん rubocop-rails から default.yml で path を指定している cop をすべて packs 配下も見るようにベタ書きしています。
ですが cop が追加された際に手動対応する必要があるので、勝手に packs 配下も適応されてほしい状態にしたいです。いい方法をご存知の方は教えていただけると助かります!
### packs 配下を参照するように include/exclude を override している # base の config はこちら # https://github.com/rubocop/rubocop-rails/blob/master/config/default.yml Rails/ActionOrder: Include: - app/controllers/**/*.rb - packs/*/app/controllers/**/*.rb Rails/ActiveRecordCallbacksOrder: Include: - app/models/**/*.rb - packs/*/app/models/**/*.rb # 続く...
RSpec に含める
CI(GitHub Actions) で実行している RSpec は独自スクリプトでファイルを分割し、parallel_rspec で実行しています。packs 配下のテストファイルも読み込むようにしました。
- name: Run RSpec run: | TEST_FILES="$(ruby spec/spec_splitter.rb --glob='spec/**/*_spec.rb,packs/**/*_spec.rb'" bundle exec parallel_rspec -n $PARALLEL_TESTS_CONCURRENCY -- $TEST_FILES
CI に含める
CI(GitHub Actions) で packwerk check
を実行することで、packwerk update
忘れを防ぎます。
packwerk: steps: - uses: actions/checkout@v4 # setup - name: check packwerk run: bin/packwerk check
rails stats に含める
rails stats に packs 配下のコードも計測対象にします。
もともと対象を追加した rake task があったため、そこに packs/*/app/*
も読み込むように追加しています。
task stats: 'custom_stats:statsetup' namespace :custom_stats do task statsetup: :environment do # 今まであったコード %w[ forms policies serializers services ].each { |dir| STATS_DIRECTORIES << [dir.capitalize, "app/#{dir}"] } # packs 配下を対象にする packs_dir = Dir.glob('packs/*/app/*') packs_dir.each { |pack_dir| STATS_DIRECTORIES << [pack_dir.capitalize, pack_dir] } STATS_DIRECTORIES.uniq! STATS_DIRECTORIES.sort! end end
マニュアルの用意
Packwerk を入れた意図と、モジュールの作り方をマニュアルで用意しました。
ディレクトリで切る候補
まず手軽に始められるよう、ざっと見繕ったモジュール名を用意しました。
やってない・今後やること
- Controller / View はモジュールに含めていない
- モジュール毎に API が切れているわけではなく、含めると逆に複雑になる可能性が高い
- 依存関係をキレイにすること
- まずはディレクトリを切って関係を整理することから始める
課題感が減ったか
ディレクトリを作ってファイルを移動するだけなので、移行は非常に簡単だと思いました!ディレクトリで切られているだけで、ドメインがディレクトリとしてまとまっている状態となり、ある程度コードを追いやすい状態になったかと思います!
また、モジュラモノリスとしてのベースができたことで、新しく機能を作る際に依存関係をどう作っていくのか、の議論も活性化したと思います!
一方で依存関係やコード自体の複雑性が改善されたわけではないので、どこまでモジュールを意識して作るのかなど、議論をしていきたいと思います。
こちらの記事でもありますが、パッケージを増やしたらいい、というわけではなさそうなので模索していきたいと思います!
まとめ
packs にファイルを移動して切っていくのは一種の整理整頓しているような気分で気持ちよかったです。どのファイルを移動するか、関連のコードを追うにあたって知らないコードベースにたどり着いて新しい発見もありました!
認知負荷を下げるための第一歩なので、引き続き今後も改善していこうと思います!