packwerk環境で扱いやすいモデルジェネレーターを作成する
こんにちは、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
クラスを継承し、以下のように記述してみました。
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
変数はジェネレーターが受け取るモデル名の引数を参照できるようで、標準のジェネレータークラスはこの辺りのヘルパーメソッドがかなり充実している印象です。
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
メソッドの書き方によってこれを回避する方法がないか調べてみたのですが……なかなか情報が見つからず断念。なので今回は愚直に「ルートパッケージと子パッケージに同時にモデルを作った後、ルートパッケージのモデルファイルは削除する」という方法を取ることにしました。
ジェネレーターファイルの内容を以下のように書き換えます。
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を今後も開発・発信していければと思います👍
Discussion