packwerkに入門してみた
はじめに
packwerkは、Shopifyがオープンソースで提供しているモジュラモノリスを支援するgemです。
Shpifyがモジュラモノリスに移行した理由やpackwerkが作成された背景については、下記の記事で紹介されています。
packwerkのUSAGE.mdを読むだけでは中々理解が進まなかったのですが、下記の「Gradual Modularization for Ruby and Rails」という本が非常に参考になりました。
この記事では、packwerkの基本的な使い方について確認します。
簡易なサンプルアプリケーションをモジュール化する、という流れでイメージを掴んでいきたいと思います。
この記事で作成したコードは下記のリポジトリで確認することができます。
事前準備
scaffoldを用いて、簡易なサンプルアプリケーションを作成します。
rails new sample_packwerk
rails g scaffold User name:string room_id:integer
rails g scaffold Room name:string user_id:integer
rails db:migrate
以下が作成されたアプリケーションのディレクトリ構成です。
→ tree
.
├── assets
│ ├── config
│ │ └── manifest.js
│ ├── images
│ └── stylesheets
│ ├── application.css
│ ├── rooms.scss
│ ├── scaffolds.scss
│ └── users.scss
├── channels
│ └── application_cable
│ ├── channel.rb
│ └── connection.rb
├── controllers
│ ├── application_controller.rb
│ ├── concerns
│ ├── rooms_controller.rb
│ └── users_controller.rb
├── helpers
│ ├── application_helper.rb
│ ├── rooms_helper.rb
│ └── users_helper.rb
├── javascript
│ ├── channels
│ │ ├── consumer.js
│ │ └── index.js
│ └── packs
│ └── application.js
├── jobs
│ └── application_job.rb
├── mailers
│ └── application_mailer.rb
├── models
│ ├── application_record.rb
│ ├── concerns
│ ├── room.rb
│ └── user.rb
└── views
├── layouts
│ ├── application.html.erb
│ ├── mailer.html.erb
│ └── mailer.text.erb
├── rooms
│ ├── _form.html.erb
│ ├── _room.json.jbuilder
│ ├── edit.html.erb
│ ├── index.html.erb
│ ├── index.json.jbuilder
│ ├── new.html.erb
│ ├── show.html.erb
│ └── show.json.jbuilder
└── users
├── _form.html.erb
├── _user.json.jbuilder
├── edit.html.erb
├── index.html.erb
├── index.json.jbuilder
├── new.html.erb
├── show.html.erb
└── show.json.jbuilder
上記の構成をpackwerkを用いて、モジュール化していきます。
packwerkの導入
以下の手順でpackwerkを導入します。
# Gemfile
gem 'packwerk' # versionは、3.0.1を使用
bundle install
bundle exec packwerk init
パッケージの作成
ディレクトリ構成を変更します。
新たにpackage
というディレクトリを作成し、機能単位でファイルをまとめることにします。
各パッケージ(rails_shims、rooms、users)には、package.yml
というファイルを配置することで、それらはパッケージと定義されます。
→ tree
.
├── assets
│ ├── config
│ │ └── manifest.js
│ ├── images
│ └── stylesheets
│ ├── application.css
│ ├── rooms.scss
│ ├── scaffolds.scss
│ └── users.scss
├── javascript
│ ├── channels
│ │ ├── consumer.js
│ │ └── index.js
│ └── packs
│ └── application.js
├── packages
│ ├── rails_shims
│ │ ├── channels
│ │ │ └── application_cable
│ │ │ ├── channel.rb
│ │ │ └── connection.rb
│ │ ├── controllers
│ │ │ ├── application_controller.rb
│ │ │ └── concerns
│ │ ├── helpers
│ │ │ ├── application_helper.rb
│ │ │ ├── rooms_helper.rb
│ │ │ └── users_helper.rb
│ │ ├── jobs
│ │ │ └── application_job.rb
│ │ ├── mailers
│ │ │ └── application_mailer.rb
│ │ ├── models
│ │ │ ├── application_record.rb
│ │ │ └── concerns
│ │ └── package.yml 👈
│ ├── rooms
│ │ ├── controllers
│ │ │ └── rooms_controller.rb
│ │ ├── models
│ │ │ └── room.rb
│ │ ├── package.yml 👈
│ │ └── views
│ │ └── rooms
│ │ ├── _form.html.erb
│ │ ├── _room.json.jbuilder
│ │ ├── edit.html.erb
│ │ ├── index.html.erb
│ │ ├── index.json.jbuilder
│ │ ├── new.html.erb
│ │ ├── show.html.erb
│ │ └── show.json.jbuilder
│ └── users
│ ├── controllers
│ │ └── users_controller.rb
│ ├── models
│ │ └── user.rb
│ ├── package.yml 👈
│ └── views
│ └── users
│ ├── _form.html.erb
│ ├── _user.json.jbuilder
│ ├── edit.html.erb
│ ├── index.html.erb
│ ├── index.json.jbuilder
│ ├── new.html.erb
│ ├── show.html.erb
│ └── show.json.jbuilder
└── views
└── layouts
├── application.html.erb
├── mailer.html.erb
└── mailer.text.erb
package.yml
は、下記のように記述します。
# package.yml
enforce_dependencies: true
ディレクトリ構成を変更したので、各ファイルの読み込みパスを変更する必要があります。
# application.rb
module SamplePackwerk
class Application < Rails::Application
# -
config.paths.add 'app/packages', glob: '*/{*,*/concerns}', eager_load: true # 追記
end
end
# application_controller.rb
class ApplicationController < ActionController::Base
append_view_path(Dir.glob(Rails.root.join('app/packages/*/views'))) # 追記
end
違反を記録する
packwerkを用いることで、他のパッケージへの依存をチェックすることができます。
検証のため、以下のようにusers
がrooms
に依存するように処理を追加しておきます。
class UsersController < ApplicationController
# -
def index
@users = User.all
@room = Room.new # 依存を発生させるために追記。この処理自体に特に意味はない。
end
# -
end
以下のコマンドを実行します。
bundle exec packwerk update
すると、各パッケージにpackage_todo.yml
が生成されます。package_todo.yml
には、そのパッケージの依存関係が示されます。
# app/packages/users/package_todo.yml
app/packages/rails_shims:
"::ApplicationController":
violations:
- dependency
files:
- app/packages/users/controllers/users_controller.rb
"::ApplicationRecord":
violations:
- dependency
files:
- app/packages/users/models/user.rb
app/packages/rooms:
"::Room":
violations:
- dependency
files:
- app/packages/users/controllers/users_controller.rb
package_todo.yml
で示された依存関係は、大きく次のようなアプローチを取ることになります。
- 違反を記録する
- 違反を排除する
まずは違反を記録するというアプローチを確認します。
package.yml
を次のように変更します。
# app/packages/users/package.yml
dependencies:
- app/packages/rails_shims
- app/packages/rooms # 依存しているパッケージを記述する
再度bundle exec packwerk update
を実行すると、全ての依存が解決された(明示された)ので、package_todo.yml
が削除されました。
packwerkでパッケージの依存をチェックして、それを明示するのが1つ目のアプローチです。
依存関係の視覚化
packwerkのパッケージの依存関係をビジュアル化することもできます。
ここではpockyというgemを用いることにしました。
rake pocky:generate"[app/packages]"
を実行すると、packwerk.png
が生成されました。
モジュール化を進めるにあたっては、こうしたビジュル化を行うgemも便利なツールになりそうです。
違反を排除する
違反の記録ではなく、リファクタリングを行うことで実際に依存関係自体を解消するアプローチには様々な方法があると思います。
冒頭に紹介した「Gradual Modularization for Ruby and Rails」には、例えば以下のような手法などが紹介されています。
- Merge the two packages
- Split the violated package
- Move the code between packages
- Duplicate the functionality
- Abstract away the dependency
- Dependency injection
- Dependency Location - The service locator pattern
- Emit and listen to events
この記事の中で全てを扱うのは難しいので、1つだけその手法を確認したいと思います。
検証のため、room_ui
パッケージを追加して、rooms
パッケージから参照する(依存する)ことにします。
# app/packages/room_ui
→ tree
.
├── models
│ └── room_ui.rb
└── package.yml
# app/packages/rooms/rooms_controller.rb
class RoomsController < ApplicationController
# -
def index
@rooms = Room.all
@room_ui = RoomUi.new # 依存を発生させるために追記。この処理自体に特に意味はない。
end
end
# -
bundle exec packwerk update
を実行すると、rooms
パッケージに違反が見つかりました。
# app/packages/rooms/package_todo.yml
app/packages/room_ui:
"::RoomUi":
violations:
- dependency
files:
- app/packages/rooms/controllers/rooms_controller.rb
この違反をDependency injection(依存性の注入)によって解決していきます。
room
パッケージにService層を導入します。
# app/packages/rooms/services/room_service.rb
module RoomService
def self.configure(room_ui)
@room_ui = room_ui
freeze
end
def self.room_ui
@room_ui
end
end
そして、RoomUi
を初期化時にインスタンス化するために、config/initializers/configure_room_ui.rb
を作成します。
# config/initializers/configure_room_ui.rb
Rails.application.config.to_prepare do
RoomService.configure(RoomUi.new)
end
RoomsController
を変更します。
class RoomsController < ApplicationController
# -
def index
@rooms = Room.all
@room_ui = RoomService.room_ui
end
end
# -
再度bundle exec packwerk update
を実行すると、rooms
パッケージの違反が解消されていることが確認できました。
このようなリファクタリングを行うことで、違反自体を解消することが可能になります。
拡張機能
この記事で使用しているpackwerkのバージョンは3.0.1です。
2.x から 3.0へのアップグレード時にいくつか大きな変更がありました。
「Gradual Modularization for Ruby and Rails」は、2.x系の説明になっているため、読み替えが必要になります。
例えば、違反を記録するファイル名がdeprecated_references.yml
からpackage_todo.yml
に変更されています。
「privacy checking」という機能も、packwerk-extensionsという別のgemに分離されています。
プライバシーチェック
以下の手順でpackwerk-extensionsを導入します。
# Gemfile
gem 'packwerk-extensions'
bundle install
# packwerk.yml
require:
- packwerk/privacy/checker
rooms
パッケージのpackage.yml
にenforce_privacy: true
を追加します。
# app/packages/rooms/package_todo.yml
enforce_dependencies: true
enforce_privacy: true # 追加
dependencies:
- app/packages/rails_shims
bundle exec packwerk update
を実行すると、users
パッケージに違反が見つかりました。
# app/packages/users/package_todo.yml
app/packages/rooms:
"::Room":
violations:
- privacy # プライバシーチェックに違反
files:
- app/packages/users/controllers/users_controller.rb
users
パッケージは、以下のようにrooms
パッケージに依存しています。
# app/packages/rooms/rooms_controller.rb
class UsersController < ApplicationController
# -
def index
@users = User.all
@room = Room.new # 依存を発生させるために追記。この処理自体に特に意味はない。
end
# -
end
rooms
パッケージは、enforce_privacy: true
の追加によって、プライベートなパッケージと定義されることになります。
依存自体はdependencies
に明記しました。一方でプライバシーチェックにおいては、rooms
パッケージ自身がパブリックなインターフェースを公開するという制約が設定されます。
パッケージを公開するには、公開対象をpublic
ディレクトリに移動します。
.
├── controllers
│ └── rooms_controller.rb
├── package.yml
├── public
│ └── room.rb
├── services
│ └── room_service.rb
└── views
└── rooms
├── _form.html.erb
├── _room.json.jbuilder
├── edit.html.erb
├── index.html.erb
├── index.json.jbuilder
├── new.html.erb
├── show.html.erb
└── show.json.jbuilder
またpublic
ディレクトリを公開対象のディレクトリと定義するためにpackage.ymlに追記を行います。
# app/packages/rooms/package.yml
enforce_dependencies: true
enforce_privacy: true
public_path: public/
dependencies:
- app/packages/rails_shims
bundle exec packwerk update
を実行すると、違反が消えていることが確認できました。
おわりに
packwerkを用いたモジュール化の全体感を掴むことができました。しかし、実際の開発に導入していくとなると、パッケージをどのような単位で定義するか、依存関係を解消するためのリファクタリングなど、まだまだイメージできない部分が多くあります。
一方で業務で携わっているプロダクトにおいては、機能数も日々増えており、低凝集・密結合なコード、依存の方向性が複雑化しているなど、開発の難しさを感じる場面があります。
packwerkを用いたモジュラモノリスへの移行は、大規模なRailsアプリケーション開発で有用な手段になるのではないか、と感じているので引き続きウォッチしていきたいと思います。
Discussion