🍔

packs-rails + packwerkでファットモデルを安全に分割する

2024/01/11に公開

はじめに

こんにちは。リンクウェルクリニックDX支援システムチームの山本です。
皆さんが普段開発しているサービスの1stコミットはいつでしょうか?私が主に担当しているサービスは2018年から開発されており、今まで経験したRailsプロジェクトの中でも比較的大規模のものとなっております。
長期間開発が続けられているRailsのサービスでよくある問題として、ファットモデル・ファットコントローラーが挙げられます。その名の通り1つのファイルのサイズや役割が大きくなってしまう問題です。
我々のサービスでは診察の「予約」を担当するReservationモデルの肥大に悩まされています。この問題に対し、packs-railspackwerk というライブラリを利用して解決する方法を検証しました。今回はこのライブラリを利用し、モデルを適切に分割し、ドメインの境界を容易に検知する仕組みをご紹介します。

予約領域の問題点

我々が開発しているサービスは主に2つのドメインが存在します。ひとつは私が担当している「クリニックDX支援」です。(以降は対面診療と言います。)主にリアルに存在するクリニックが利用する機能を提供しています。もうひとつは「オンライン診療システム提供サービス」です。(以降はオンライン診療と言います。)その名の通りオンライン診療に関係する機能を提供しています。

このサービスは患者側が利用する予約機能、クリニック側が利用する予約一覧機能がひとつのリポジトリに含まれており、巨大なモノリシックな構造となっています。

さて、2つのドメインで共通する機能として「予約」が挙げられます。
この予約に関するデータは Reservation モデルで扱われていますが、対面診療とオンライン診療では「予約一覧」機能が分かれています。このため、片方の予約一覧でしか利用しない機能がひとつのモデル内に定義されており、肥大化の要因のひとつとなっています。

大きなファイルの問題点とその解決

Railsのモデルを例に上げて説明すると以下の問題が発生します。

  • 見通しが悪い

    • 予約領域に新たな機能を実装することを考えてみましょう。これから実装しようとする機能とほぼ一緒なのですが一部だけ処理の内容が異なるメソッドがすでに存在するとします。あなたならこの状況でどのように機能を追加するでしょうか?
      • メソッドの引数にオプションを追加して、特定の場合のみ動作を変更する。
      • 既存のメソッドを呼び出す別のメソッドを追加する。
      • 似たような機能だが新たにメソッドを追加する。

    どれがダメという話ではなく状況によって正解は変化すると思いますが、ひとつ言えることは単一のモデルの役割が増え、モデルの役割がぼやけてしまう点にあります。

  • 複雑な依存性

    • 特定のドメインでしか利用しないメソッドがひとつモデルに書かれています。プロジェクト発足直後や小規模なケースだと問題ありませんが、複数のチームがひとつのリポジトリを触っている場合はどうでしょうか?ある改修が思わぬ場所で障害を引き起こすかもしれません。
    プロジェクト序盤 現在

    ▲一箇所がコケるとすべてが崩れてしまう!

  • 難解なテスト

    • テスト容易性の観点から多くのビジネスロジックはモデルのメソッド内に格納されていると思います。モデルに対するテストファイルには大量のテストケースが書かれており、共通処理は一箇所でまとめられていることが多いです。(Rspecの場合、beforeやshared_context。)あるメソッドのテストを修正しようとすると、他のテストの準備の修正も必要となるケースがあります。

このような問題に対して、私はリファクタリングではなくリアーキテクチャという手段を選択しました。どちらも利用者側からの変化はありません。リファクタリングはメソッドやクラス単位など細かい粒度で調整を行いますが、リアーキテクチャはモジュールやコンポーネントといったより荒い粒度で修正を行います。

リアーキテクチャの指針とゴール

予約領域の問題点を踏まえ、次のような指針とゴールを設定しました。

  • [指針]Reservationという予約システムのコアモデルを一気に書き換えると障害発生時の影響が大きい。段階的な移行は必須となる。
    • [ゴール]packs-rails による段階的移行が可能な状態を作り出す。
  • [指針]対面診療およびオンライン診療の改修がもう一方に影響を及ぼさないようにする。
    • [ゴール]適切なドメイン境界を定める。モデルのスリム化。
  • [指針]少なくとも現状より依存関係が複雑化した際はCIで検出できる。
    • [ゴール]packwerk による静的解析ツールを利用するして検知する。

使用ライブラリ

今回のリアーキテクチャに利用するライブラリは以下の3つです。

  • packs-rails
    • Railsアプリケーションのパッケージ化を行うライブラリです。これを使わなくても、RailsのAutoloadの設定を修正すればパッケージ化は可能なのですが、Gemをインストールするだけでパッケージ化できるお手軽さに惹かれて利用しました。標準設定ではアプリケーションディレクトリ直下の packs ディレクトリがパッケージ置き場となります。
  • packwerk
    • Shopifyがメインで開発しているドメイン境界検知ツールです。ここで言うドメインとは packs-rails で設定したパッケージを指します。このツールを使うことで、パッケージ間の依存性を検知できます。なお、副次的な機能として、コードを静的解析するのでsyntax errorを見つけることができる点もメリットに感じています。
  • packwerk-extensions
    • packwerk を拡張するためのライブラリです。いくつか機能があるのですが、パッケージ内のPrivateな領域への参照を制限させる PrivacyChecker 機能を利用します。

対応例

準備

Gemfile

gem 'packs-rails'
gem 'packwerk'
gem 'packwerk-extensions'

3つのライブラリを Gemfile に定義して bundle install します。

初期化

% bundle exec packwerk init

必要なファイルが配置されます。

対面診療における予約領域のパッケージディレクトリを決める

packs/real_reservation ディレクトリを作成し、packwerkの設定ファイルである package.yml を設置します。
package.yml の内容は以下の通りです。

enforce_dependencies: true # packwerkによる境界検知を行います。
enforce_privacy: true # パッケージ内のPrivateな領域への参照を制限します。packwerk-extensionsの機能です。

予約処理を移動する

予約システムではフロントエンドとのインターフェイスにGraphQLを利用しています。予約取得用Mutationのファイルをpackageに移動します。
ここでは app/graphql/mutations/patients/create_reservation_mutation.rbpacks/real_reservation/mutations/patients/create_reservation_mutation.rb に移動します。
なんとファイルの定義は何も変更していないにもかかわらず、このタイミングでRailsアプリケーションを動かしても正常動作します! packs-rails とRails6から採用されている Zeitwerk というオートローダーのおかげです。この仕組みのお陰でファイルに手を加えず段階的にパッケージ化を行うことができます。

対面診療用のReservationモデルを作成する

今回のリアーキテクチャの目的のひとつとしてReservationモデルのスリム化があります。通常Railsのモデルは app/models 以下に置かれてDBのテーブルと1:1となります。今回は対面診療の予約取得に特化したモデルをパッケージ下に作成します。ディレクトリは packs/real_reservation/models/real/reservation.rb としました。
内容については元のモデルファイルから予約取得に関係する処理のみをコピーしました。

その他のファイルとディレクトリ構成

Reservationモデル以外にも予約取得時のメール処理をパッケージに切り出しました。また、予約取得用Mutationファイル内では Reservation モデルではなく対面診療に特化した Real:Reservation クラスを利用するように修正しました。

# packs/real_reservation/app/public/mutations/patients/create_reservation_mutation.rb
()
# ReservationからReal::Reservationに変更
reservation = Real::Reservation.new(
  clinic:,
  department:,
  user:,
  reserved_at:,
()

できあがったパッケージ内のディレクトリ構成は以下のとおりです。

📦packs
 ┗ 📂real_reservation
 ┃ ┣ 📂app
 ┃ ┃ ┣ 📂decorators
 ┃ ┃ ┃ ┗ 📂real
 ┃ ┃ ┃ ┃ ┗ 📜reservation_decorator.rb
 ┃ ┃ ┣ 📂mailers
 ┃ ┃ ┃ ┗ 📂real
 ┃ ┃ ┃ ┃ ┗ 📜user_mailer.rb # 予約取得に関係する処理のみ記載
 ┃ ┃ ┣ 📂models
 ┃ ┃ ┃ ┗ 📂real
 ┃ ┃ ┃ ┃ ┗ 📜reservation.rb # 予約取得に関係する処理のみ記載
 ┃ ┃ ┣ 📂public # 公開されているパッケージ。外部からはここのみアクセス可能。
 ┃ ┃ ┃ ┗ 📂mutations
 ┃ ┃ ┃ ┃ ┗ 📂patients
 ┃ ┃ ┃ ┃ ┃ ┗ 📜create_reservation_mutation.rb
 ┃ ┃ ┗ 📂views
 ┃ ┃ ┃ ┗ 📂real
 ┃ ┃ ┃ ┃ ┗ 📂user_mailer
 ┃ ┃ ┃ ┃ ┃ ┣ 📜create_new_reservation.html.slim
 ┃ ┃ ┃ ┃ ┃ ┗ 📜create_new_reservation.text.erb
 ┃ ┣ 📂spec
 ┃ ┃ ┗ 📂graphql
 ┃ ┃ ┃ ┗ 📂mutations
 ┃ ┃ ┃ ┃ ┗ 📂patients
 ┃ ┃ ┃ ┃ ┃ ┗ 📜create_reservation_mutation_spec.rb
 ┃ ┣ 📜package.yml
 ┃ ┗ 📜package_todo.yml

以下の通り、reservations テーブルはルートパッケージと real_reservation パッケージそれぞれのモデルで管理されます。

依存関係を記録する

ここで現在の依存関係を packwerk を使って記録してみましょう。

$ bundle exec packwerk update

ルートパッケージ側に package_todo.yml が作成されました。

packs/real_reservation:
  "::Mutations::Patients::CreateReservationMutation":
    violations:
    - dependency
    files:
    - app/graphql/types/mutation_type.rb

Rubocopを使ったことがあれば、package_todo.ymlrubocop_todo.yml と同じような存在であることが理解できると思います。
このTodoの意味はルートパッケージの app/graphql/types/mutation_type.rbreal_reservation パッケージの ::Mutations::Patients::CreateReservationMutation クラスに依存していることを表します。
パッケージへの移行はこの package_todo.yml を潰していく作業となります。

依存関係を設定する

ルートパッケージの package.yml に依存関係を記載します。

dependencies:
- packs/real_reservation

再び、 packwerk update を実行してみましょう。

$ bundle exec packwerk update

依存関係が package.yml に記載されたことで package_todo.yml が削除されたことが確認できました。

real_reservation パッケージ

real_reservation パッケージの package_todo.yml を確認してみましょう。

---
".":
  "::ApplicationHelper":
    violations:
    - dependency
    files:
    - packs/real_reservation/app/mailers/real/user_mailer.rb
  "::ApplicationMailer":
    violations:
    - dependency
    files:
    - packs/real_reservation/app/mailers/real/user_mailer.rb
  "::ApplicationRecord":
    violations:
    - dependency
    files:
    - packs/real_reservation/app/models/real/reservation.rb
(略)

real_reservation パッケージが多くのルートパッケージに依存していることが確認できるかと思います。現実的かはさておき、すべてのルートパッケージをパッケージ化して package_todo.yml を消し去ることが最終ゴールとなります。

依存関係のチェック

% bundle exec packwerk check

このコマンドを実行することで、設定を無視した依存関係を見つけることができます。

📦 Packwerk is inspecting 3454 files
................................................................................................
(略)
..............................................................................................
📦 Finished in 14.12 seconds

No offenses detected
No stale violations detected

GitHubにプッシュする前やCircleCIで実行できる環境を用意しておくとより良いでしょう。

現時点で感じる問題点

  • Rails Wayに沿ってない
    • 新たに追加された packs ディレクトリは packs-rails 独自の概念です。Railsは 設定より規約 (convention over configuration; CoC) というコンセプトを大切にしています。 packs-rails の導入によりRails Wayに沿っていない新たな規約が登場することとなり、新たにチームに参加したメンバーを戸惑わせることは容易に想像がつきます。こちらはオンボーディングやREADMEへの追記など、新たなサポートが必要だと感じています。
  • だれが境界違反を判断するのか?
    • packwerk による境界検知は少なからず開発者に制限を強いる仕組みだと考えています。新たな依存性が発生した場合、それを容認するのか既存パッケージを迂回してアクセスするべきなのかの判断は誰が行うのかまだ定まっていません。今のところはパッケージごとに管理者を置き、その人の判断に委ねるようにしようと考えています。

まとめ

  • packs-rails により段階的に依存関係を整理する基盤が整いました。
  • packwerk によりパッケージの依存関係違反を検知することができました。
  • 大きなモデルを安全に分割する方法が確立できました。

Railsは0→1のサービスを立ち上げるためのベストなフレームワークとして高い人気を誇っています。しかしアプリケーションサイズが大きくなるにつれ、適切な分割が難しくなるシーンが増えていく印象です。packs-rails + packwerk はその問題を打破するための、大きな力を与えてくれるライブラリだと言えるでしょう。

参考

https://zenn.dev/stmn_inc/articles/67f584ca002d4a
https://zenn.dev/kyoshida/articles/2ceaa5f998e8c79a4616

Linc'well, inc.

Discussion