こちらは Timee Product Advent Calendar2024 の18日目の記事です。前日は @ryopeko による「RubyWorld Conference 2024に参加してきた」でした。
こんにちは。タイミーでバックエンドのテックリードをしている @euglena1215 です。
タイミーではモノリスな Ruby on Rails アプリケーションに一定の規律を設けるために Packwerk を導入しています。
A Packwerk Retrospective であったように、Packwerk はあくまでツールであり鋭いナイフです。ツールは使い手が意図を持って扱わないとそれに振り回されて怪我をしてしまいます。
この記事では、それぞれのチェッカーがどんな目的を達成するために使えるものなのかを自分なりに整理してまとめてみます。
Packwerk 自体はあくまで依存グラフを作成するだけであり、どんな検査を行うかは決定しません。どんな検査を行うかを決定するのはパッケージごとのチェッカーの設定です。この記事を通して「Packwerk での議論が行われる際にはチェッカーの設定もセットで行われるようになるといいな」という微かな期待もあります。
前提
タイミーでは packwerk, packwerk-extensions で提供されているチェッカーのうち、Dependency Checker と Privacy Checker を利用しています。packwerk-extensions には他に Visibility Checker, Folder-Visibility Checker, Layer Checker が存在しますが、今回は省略させてください。
Dependency Checker
パッケージ間の依存関係を管理・検査するためのチェッカーです。これが唯一 Packwerk 本体が提供しているチェッカーになります。
機能の詳細は https://github.com/Shopify/packwerk/blob/main/USAGE.md#types-of-boundary-checks をご覧ください。
主な使い道
CI 上で実行するテストの枝刈りを行いテストの実行時間を削減する
Dependency Checker はパッケージ間の依存関係を管理するため、全てのパッケージの依存関係を厳密に管理すれば、ある変更に対して影響を受けた可能性のあるパッケージを特定できます。影響範囲外のパッケージはテストの実行を省略可能なのでテストの実行時間を削減することが可能です。
これは Shopify のブログにもモチベーションの1つとして記述されています。
Instead of running the test suite on the whole application, we can run it on the smaller subset of components affected by a change, making the test suite faster and more stable.
ref. https://shopify.engineering/shopify-monolith
この用途で使うためには、全てのパッケージの enforce_dependencies
を基本的には strict
に設定する必要があります。
# packs/foo/package.yml enforce_dependencies: strict dependencies: - bar # bar パッケージで変更があれば foo パッケージにも影響を与える - baz # baz パッケージで変更があれば foo パッケージにも影響を与える
しかし、この使い道で Dependency Checker を利用する際にはいくつかの注意点が存在します。
パッケージの循環依存をなくす必要がある
循環依存があると、全てのパッケージが影響を受ける可能性ありとしてマークされます。
例えば、A → B → C → D → A という依存があると、どのパッケージを変更しても全てのパッケージのテストを実行する必要があるため実行すべきテストを削減することができません。
そのため、どうやって循環依存を減らすかが重要です。
Shopify では、依存の方向を変え循環依存を減らすために ActiveSupport::Notifications
の pub/sub のような機構を導入したと2020年の記事には記述されていました。
Inversion of control means to invert a dependency in such a way that control flow and source code dependency are opposed. This can be done for example through a publish/subscribe mechanism like
ActiveSupport::Notifications
.
ref. https://shopify.engineering/shopify-monolith
が、2024年の記事では「結果としてコードが理解しにくくなったこともしばしばあった」と記述されていました。
We relied heavily on inversion of control, for example, to extract package references out of base layer code. These changes introduced indirection that, while resolving the violations, often made code harder to understand.
ref. https://shopify.engineering/a-packwerk-retrospective
ActiveSupport::Notifications
をアプリケーションロジックで多用している Rails アプリケーションに出会ったことがないのでどのような書き味・読み味になるのかは私には分かりません。
しかし、同じように依存方向を反転させる ActiveRecord の Callback は馴染み深いのではないでしょうか。ActiveRecord の Callback は適切に使えば便利ですが、使いすぎると苦しめられることは Rails エンジニアのみなさんはよくご存知だと思います。同様のことが起きていないといいのですが果たして…。
Sorbet の利用が実質的には必須
テストの実行を省略するためには厳密な依存チェックが行えている必要があります。feature branch では CI が通っていてマージをしたのに、実は失敗するテストケースが存在していたなんて考えなくもないですよね?
Packwerk はパッケージ間のクラス・定数の呼び出しを検出するツールです。そのため、引数として他パッケージで管理しているクラスのインスタンスが渡された場合、コード上にはクラスが登場しないので検知することができません。
# packs/foo/app/models/foo.rb class Foo # 引数 bar は bar パッケージで管理している Bar クラスのインスタンス def foo(bar) # bar.bar_method を呼びだすことで bar パッケージへの依存が発生しているが # packwerk は `Bar` が登場しない限り依存を検知できない! bar.bar_method end end
Sorbet のような引数のクラスを評価するような実装があることで初めて検知することができます。
# packs/foo/app/models/foo.rb class Foo # sorbet ではランタイムで `Bar` を評価するので packwerk が依存を検知できる! sig { params(bar: Bar).void } def foo(bar) bar.bar_method end end
これは Shopify の Rails アプリケーションでの Sorbet による型アノテーションのカバレッジが十分に高いことが前提になっているからだと思われます。
こちらに関しては実質的に Sorbet に依存せずとも厳密なチェックができるようにカスタマイズするための機能提案が行われていますが、1年以上前から動きがありません。
Shopify からすると今のままで困ってないわけですし、新たな機能を追加することで保守が大変になるので困るのは理解できます。fork して自分で保守を行う覚悟がないのであれば、この用途で使うためには Sorbet の利用が実質的に必須である状態は避けられなさそうです。
基盤パッケージが他パッケージに依存していないことを維持したい
全てのパッケージに関してパッケージ間の依存管理するのは諦めて、最低限基盤パッケージのような他パッケージに依存しないパッケージのみ検査を行うという使い道もあると思います。
この使い道で Dependency Checker を利用するなら以下のような設定になります。
# packs/no_dependency_package/package.yml # 他パッケージに依存しない基盤的なパッケージ enforce_dependencies: true | strict dependencies: [] # packs/normal/package.yml # 他パッケージに依存して動作する一般的なパッケージ enforce_dependencies: false dependencies: []
タイミーでは、 ApplicationRecord
や ApplicationController
など Rails が提供している基盤クラスを rails_shims パッケージとしてまとめています。このパッケージが具体の機能に紐づく module を include しているのは好ましくありません。
この使い方をすることで、明らかに依存してはいけない依存を検知することができます。
どの使い道が良いと考えているのか
完全に個人的な意見にはなりますが、Shopify の状況を見るに依存グラフを作成することによるテストの実行時間の削減は現実的ではないと考えています。
そのため、基盤パッケージの意図しない他パッケージへの依存の検出など本当にパッケージ間の依存を管理したい一部のパッケージのみ Dependency Checker を有効にし、一般的なその他パッケージは無効にしておくのが良いのではないかと考えています。
Privacy Checker
packwerk の文脈での private なクラス・定数への参照を管理・検査するためのチェッカーです。元々 packwerk 本体が提供するチェッカーでしたが、packwerk-extensions に切り出されました。
機能の詳細は https://github.com/rubyatscale/packwerk-extensions?tab=readme-ov-file#privacy-checker をご覧ください。
主な使い道
パッケージの利用方法を絞りたいとき
パッケージで扱っているロジックが複雑で「ここを変更した際は一緒にここも変更しないと不整合が発生する」といった暗黙的な依存が発生しているときは、安全な利用方法を Public API として提供しておくのが安心です。
この使い道で Privacy Checker を利用するなら以下のような設定になります。
# Public API を提供して利用方法を絞りたいパッケージ enforce_privacy: true | strict # パッケージ外からどのクラスを参照しても構わないパッケージ enforce_privacy: false
一方、packwerk の開発元である Shopify では Privacy Checker をうまく活用できなかったために packwerk 本体からは削除され、packwerk-extensions に切り出されたのも事実です。何が起きていたのかを分かる範囲でまとめておきたいと思います。
Under Deconstruction: The State of Shopify’s Monolith では「Privacy Checker を守ることだけを目的としたほとんど意味を持たない Public ラッパークラスが量産されていたことに気付いた」との記述がありました。
When we ignored the dependency graph, in large parts of the codebase the public interface turned out to just be an added layer of indirection in the existing control flows. This made it harder to refactor these control flows because it added additional pieces that needed to be changed. It also didn’t make it a lot easier to reason about parts of the system in isolation.
ref. https://shopify.engineering/shopify-monolith
また、Packwerk から Privacy Checker を削除し、packwerk-extensions に切り出すことが決まった GitHub discussion での議論では「Privacy Check に違反したコードはただ public ディレクトリに移動されるだけ、もしくは package_todo.yml に記録されて忘れ去られるだけ。これらを対処するためにより悪いコードが生み出される」との記述がありました。ここでの「悪いコード」とは上記のほとんど意味を持たない Public ラッパークラスだと予想しています。
So far at Shopify, we didn't see much value in the privacy checks. Most of our packages have this option disabled and when we do have them enabled, people mostly fix this kind of violations mostly by moving files around, without improving the APIs, or, even worse, people just record the deprecations and forget about them.
Those ways to solve this violation are actually creating worse code, instead of improving it. The public folders are full of different concepts mixed together, and people are getting annoyed with "Packwerk failing on me with something I can't fix" and just avoiding the tool all together.
ref. https://github.com/Shopify/packwerk/discussions/219
このような状況になった実際の原因は Shopify に直接聞かないと分かりませんが、自分なりに予想するに Public API を定義することに価値を感じていない開発者に対しても Public API の提供および運用を強制させてしまったことにあるのではないかと考えています。
投資対効果に対して効果を感じられていない場合は、投資を最小化させるのが最も効果的な行動です。
どの使い道が良いと考えているか
やはり他パッケージからの利用方法を絞りたいときに活用するのが良いと考えています。しかし、Shopify の事例を踏まえるとまずは Public API を提供することに価値があると感じられるパッケージでのみ有効にするのが良いのではないでしょうか。
新規で作ったパッケージに対して Privacy Checker を有効にし、Public API の定義を頑張ってみるのも良いと思います。
最初は全てを無効にした状態から始めてもいいじゃないか、気楽に行こう
個人的には最初は全てを無効にした状態で導入を行い、機能・ドメインごとでディレクトリが分かれているだけで他は特に何もしない Rails アプリケーションとして運用してみるのも悪くないと思います。
社内でこのような運用をしているパッケージが存在しますが、「触るファイルがまとまっているのでコードリーディングがしやすくなった」とのポジティブな声もありました。一方「普通の Rails のディレクトリ構造と違うのでギョッとする」という声もあります。
ただし、チェッカーを有効にする際は意図を持って有効にしましょう。繰り返しますが、Packerk はあくまでツールであり鋭いナイフです。ツールは利用者が意図を持って扱わないとツールに振り回されて怪我をしてしまいます。
全てを有効にした状態から始めると大量の package_todo.yml の荒波に飲み込まれてしまい、気付くと package_todo.yml を空っぽにすることが目的になることがあります。何度でも言いますが Packwerk はあくまでツールです。自分たちの開発スタイルを Packwerk に合わせるのではなく、Packwerk の設定を自分たちの開発スタイルに合わせましょう。
明日は yykamei の「Slack の Huddles を使ったプラクティスとその背後にある考え」です。お楽しみに!