🛠

packwerk環境で扱いやすいモデルジェネレーターを作成する

2024/07/22に公開

こんにちは、Linc'wellでバックエンドエンジニアをしている西村です!
弊社で開発しているRailsプロジェクトではモジュラーモノリスアーキテクチャを採用しており、そのためのライブラリとしてpackwerkを利用しています。
今回このpackwerk環境下において特有(多分)の問題を解決した話をTIPSとして残そうと思い記事を書いてみました🖊

packwerkについて

packwerkは、アプリケーションのコードベースを分割し、各部分が他の部分にどのように依存しているかを明確にするためのツールです。具体的には以下に示すように、パッケージという概念を用いて一つのリポジトリの中で複数のRailsアプリケーションを構築するような形でコードを分割します。

app # ルートアプリ
├── controllers
│   ├── hoge_controller.rb
│   └── fuga_controller.rb
├── models
│   ├── hoge.rb
│   └── fuga.rb
├── etc...
└── packs
    └── new_service # 別サービスのコードをまとめたパッケージ
        └── app # パッケージごとに別のRailsアプリが構成されているようなイメージ
            ├── controllers
            │   ├── hoge_controller.rb
            │   └── fuga_controller.rb
            ├── models
            │   ├── hoge.rb
            │   └── fuga.rb
            └── etc...

詳細は割愛しますが、これによりサービスごとの文脈で使用したいモデルファイルを分割する、既存システムにある他のモデルとの依存関係を適切に管理するといったようなメリットが得られます。

packwerk下の問題点

通常Railsで新たにリソースファイルを追加する場合 rails generate model のようなコマンド(ジェネレーター)を用いて必要なファイルを一度に作成することが可能です。
特にモデルを作成する際はこのコマンドだけでマイグレーションファイルも同時に作成してくれるため、非常に便利で開発には欠かせない存在です。

しかしpackwerkのディレクトリ構造に従って開発を進める場合、このジェネレーターが上手く機能しないため新たにモデルファイルなどを追加するのが少し手間になってしまいます。
たとえばpackwerkで定義した別パッケージにモデルを追加する場合、上記の構成図にもあるようにパッケージ下の app/packs/new_service/app/models ディレクトリにファイルを作成したいところですが、ジェネレーターを用いると作成場所の指定などが出来ないためルートの app/models 配下に作られてしまい、ファイル作成後にディレクトリを移動するという手間が加わってしまいます。

bundle exec rails generate model Hoge

      invoke  active_record
      create    db/migrate/20240626052820_create_hoges.rb
      create    app/models/hoge.rb # 通常のジェネレーターだとルートパッケージにモデルファイルが作られる

せっかく依存関係を明確にするためのパッケージを作成しても、その為にファイルを手動で移動する手間が発生してしまうのはモヤモヤしてしまうので、この作業を自動化したいです。
今回はパッケージ用にカスタムジェネレーターを作成することを試してみました!

Railsジェネレーターの作成

Railsでは上で紹介したようなジェネレーターを自前で作成することができます。
参考: https://railsguides.jp/generators.html#最初のジェネレータを作成する

Railsに標準搭載されている「ジェネレーターを作るジェネレーター」を使ったり、Rails::Generators::Base といったクラスを継承して直接作成することも可能です(今回は後者で行いました)
今回はモデルを作成するジェネレーターを作りたいので、Rails::Generators::ModelGenerator クラスを継承し、以下のように記述してみました。

packs/new_service/lib/generators/new_service/model_generator.rb
require 'rails/generators/rails/model/model_generator'

class NewService::ModelGenerator < Rails::Generators::ModelGenerator
  desc "new_serviceパッケージ内にモデルファイルを作成するジェネレーター"
  source_root File.expand_path('packs/new_service/lib/generators/new_service/templates')

  def create_model_file
    template 'model.rb', "packs/new_service/app/models/new_service/#{file_name}.rb"
  end
end

このジェネレーターファイルに加えて、ジェネレーターが作成するファイルの雛形となるテンプレートファイルを別途用意します。
今回はパッケージ配下のモデルファイルを作りたいので、 NewService:: という名前空間を付与したモデルになるよう以下のようにテンプレートを作ってみました。 name 変数はジェネレーターが受け取るモデル名の引数を参照できるようで、標準のジェネレータークラスはこの辺りのヘルパーメソッドがかなり充実している印象です。

packs/new_service/lib/generators/new_service/templates/model.rb.tt
class NewService::<%= name %> < new_service::ApplicationRecord
end

ジェネレーターファイルの内容はシンプルで、source_root メソッドでテンプレートファイルのあるディレクトリ(packs/new_service/lib/generators/new_service/templates)を指定し、 create_model_file 内の template メソッドでテンプレート名(model.rb)とファイルの作成先を指定する、といった具合です。
template メソッドの処理を create_model_file に切り出しているのは、 file_name というヘルパーメソッドがトップレベルでは参照できないからです。
公式ドキュメントにもありますが、ジェネレーターは特に何かしらのメソッドをオーバーライドしたりする必要はなく、実行時にクラス内に定義されたパブリックメソッドを順に実行していくという仕様なのでこのように自由にメソッドを定義してOKなのです。

class HogeGenerator < Rails::Generators::Base
  # ジェネレータークラスのインスタンスメソッドが、ジェネレーター実行時に定義順に実行されていく(メソッド名は自由)
  def first_method
    # なにかしらの処理
  end

  def second_method
    # なにかしらの処理
  end
end

ただしRubyの仕様上、意図せずして親クラスのメソッドを上書きしてしまう可能性があるので、自由とは言いつつメソッド名はそれなりに具体的なものにしておくと良いかと思います!

カスタムジェネレーターの実行

これで当初の「パッケージ内にモデルを作成するジェネレーターを用意する」という目的は達成です!
Railsのgenerateコマンドは指定されたジェネレーター名でファイルを探索するという仕様なので、特に何も設定など必要なく、今回用意したジェネレーターファイルの名前に基づいた rails generate new_service:model {モデル名} ({カラム名:型} {カラム名:型}...) という形式のコマンドでこのジェネレーターを使うことができます。

bundle exec rails generate new_service:model Hoge
      invoke  active_record
      create    db/migrate/20240626052820_create_hoges.rb
      create    app/models/hoge.rb # ルートパッケージにも作られてしまう……
      create  packs/new_service/app/models/new_service/hoge.rb # 想定したパッケージにモデルファイルが作られる

ですが今の実装だと Rails::Generators::ModelGenerator の挙動でルートパッケージにもモデルファイルが作られてしまいます。
template メソッドの書き方によってこれを回避する方法がないか調べてみたのですが……なかなか情報が見つからず断念。なので今回は愚直に「ルートパッケージと子パッケージに同時にモデルを作った後、ルートパッケージのモデルファイルは削除する」という方法を取ることにしました。

ジェネレーターファイルの内容を以下のように書き換えます。

packs/new_service/lib/generators/new_service/model_generator.rb
require 'rails/generators/rails/model/model_generator'

class NewService::ModelGenerator < Rails::Generators::ModelGenerator
  desc "new_serviceパッケージ内にモデルファイルを作成するジェネレーター"
  source_root File.expand_path('packs/new_service/lib/generators/new_service/templates')

  # ModelGeneratorがルートアプリにモデルファイルを作ってしまうため、パッケージ用テンプレートで作り直した後にルートアプリ側のファイルを削除する
  def replace_model_file
    template 'model.rb', "packs/new_service/app/models/new_service/#{file_name}.rb"
    remove_file "app/models/#{file_name}.rb"
  end
end

先程の create_model_file メソッドに remove_file メソッドの処理を加えることで、重複したモデルファイルを削除します。
また先程も触れたようにジェネレータークラス内のメソッド名は自由なため、実態に合うようにメソッド名も replace_model_file に変更してみました。

この状態で再びコマンドを実行してみると、

bundle exec rails generate new_service:model Hoge
      invoke  active_record
      create    db/migrate/20240626052820_create_hoges.rb
      create    app/models/hoge.rb
      create  packs/new_service/app/models/new_service/hoge.rb
      remove  app/models/hoge.rb # ルートパッケージのモデルファイルが削除され、辻褄が合う

ログにこそ削除している様子が出てしまいますが、パッケージのみにモデルファイルを作成するジェネレーターとして完成しました!
ちなみに今回はマイグレーションファイルをルートパッケージに置くことは許容しているのですが、その辺りも十分カスタムすることが可能かと思います。

まとめ

ジェネレーター、調べてみるとめちゃくちゃ奥が深くまだまだ分からないこともたくさんあります(実際、今回も一部実装を妥協してます)
今回はpackwerk環境に起因した問題を解決するのに使いましたが、他にもさらに用途がありそうなので上手く活用していきたいところです!
またpackwerk自体は非常に有用なツールで日々の業務でもその便利さを実感しています。なのでpackwerk環境で役立つTIPSを今後も開発・発信していければと思います👍

Linc'well, inc.

Discussion