Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: モジュール化強制ツール"Packwerk"の導入を振り返る(翻訳)

概要

原著者Chris Salzbergさんの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。また、一部のパラグラフを分割しています。

CC BY-NC-SA 4.0 Deed | 表示 - 非営利 - 継承 4.0 国際 | Creative Commons

Rails: モジュール化強制ツールPackwerkの導入を振り返る(翻訳)

Shopify/packwerk - GitHub

2020年、ShopifyのチームはPackwerkという名前のRuby gemをリリースしました(関連記事)。Packwerkは、Railsアプリケーション内で境界を強制的に定めてモジュール化するツールです。Packwerkはリリース以来独自の存在として使われ続け、さまざまなブログ記事やカンファレンストーク、さらにgemエコシステム全体にまでインスピレーションを与えてきました。Packwerkが人気を得たことは、Railsコミュニティの空白が明らかに1つ埋まったことを示しています。

Packwerkは、RubocopSorbetといったツールに似た静的分析ツールです。Packwerkをコードベースに適用することで、定数参照を分析してコードの癒着を切り離し、明確に定義されたパッケージを編成するのに役立ちます。

しかしPackwerkは単なるツールではありません。Packwerkの長年にわたるモジュール化の手法は、コードの編成やコードの進化について明確な(時には互いに相容れない)視点をいくつも浮かび上がらせてくれます。Packwerkからのフィードバックは、類似の他のツールとは一線を画すレベルで、コードベースが進む道筋全体を大きく変えます。

今回の振り返り記事は、ShopifyでPackwerk開発チームとして携わった私たちが、Packwerkで得た学びやPackwerkを利用する場合の懸念点、そして将来の希望に光を当てる取り組みです。

🔗 Packwerkの起源

🔗 依存関係管理ツールとしてのPackwerk

「私はあなたが何者か(=どんなオブジェクトか)がわかっているので、あなたがどう振る舞うかもわかっています」こういう知識は依存関係であり、変更のコストは高くつきます。
Sandi Metz「Practical Object-Oriented Design in Ruby」

上のSandi Metzの引用は、Packwerkが誕生した精神を表現しています。Packwerkが前提としているものはシンプルで、使うときは最初に以下の2つを実行しなければなりません。

  1. パッケージのセットを、(おそらくネストした)ファイルディレクトリにキャプチャする形で定義する。

  2. 定義したパッケージ間に、循環のない依存関係セットを定義する。

以上が完了すれば、Packwerkのコマンドラインツールが利用可能になります。これで、あるパッケージの定数が、指定の依存関係グラフに違反する形で別パッケージの定数を参照している場所を教えてくれます。違反は、todoファイル(package_todo.yml)で一時的に"allowed"(許可済み)に設定できます。既存の違反についてはtodoファイルを生成する形で「破産宣告」すれば、それ以上新たな違反が侵入することをPackwerkで防げるようになります。

このように明確に定義された依存関係グラフを追求し続ければ、理論的にはアプリケーションのモジュール化が進み、癒着も減るはずです。アプリケーションであるセクションを移動する必要が生じたときも、そのセクションの依存関係が明らかになっていれば移動も簡単になります。逆に、循環依存が発生しているとコードはより複雑になり、理解もリファクタリングも難しくなってしまいます。

🔗 プライバシー強制者としてのPackwerk

アメとムチに例えるなら、プライバシーはアメです。プライバシーはわかりやすく、さまざまな場面で魅力を放ちますが、実は良いものとは限らないかもしれないのです。
Philip Müller(Packwerkのオリジナル作者)#219

Packwerkは、初期段階とはまったく異なる使われ方をするようになりました。その使われ方は「プライバシー」という形を取っており、上述の同じパッケージでpublic APIを静的に宣言することを可能にするものです。別の場所にあるpublicなディレクトリに置かれた定数は"public"なものとして扱われ、他のパッケージから参照可能になりました。それ以外の定数は"private"とみなされ、依存関係があろうとなかろうと、他のパッケージから参照されれば違反として扱われました。

上述のPhilip Müller氏の引用でも表明されているように、プライバシーチェックはPackwerkのメイン機能として意図されたものではまったくありませんでした。大規模かつ無秩序なコードベースを正しく定義するのは難しく、解決するのはさらに困難な場合もあります。一方、publicやprivateを宣言するのは楽で、Ruby独自のprivateメソッドやpublicメソッドの概念ともよく似ています。

publicディレクトリのレイアウト

残念なことに、Packwerkのプライバシーチェックはお手軽な代わりに、数々の問題点を持ち込みました。

問題点の一部は実装に関連するものでした。
プライバシーチェックのためにapp/public/ディレクトリを配置することが要求されました。ここに置かれたコードはpublic APIとなります。このやり方によって、app/の下にアーキテクチャの概念を表すフォルダではなく、プライバシーレベルを表すフォルダが導入されたため、Railsのファイルレイアウトの慣習を壊しました。ファイルをどこに置くべきかで混乱が生じたため、コントローラやジョブのサブディレクトリが新たに作られたり、それまでapp/の下に存在していたディレクトリが複製されたりしました。これらのサブディレクトリは、public APIとしてドキュメント化と検討を十分行うべきだったのですが、Packwerkは、このレベルではそうした作業を詳細に行うよう開発者に指示しませんでした。かくして、publicにすることが意図されていなかったコードが、ドキュメントもろくにないまま果てしなく作り出されるという結果になったのです。

しかし、もっと深刻な問題がありました。
すなわち、「Packwerkがプライバシーチェックを行うようになったことで、Packwerkが本来の意図を超えたAPI設計ツールに変わり果てた」のです。Packwerkは、パッケージ同士が「祝福を受けた」エントリポイントを介して通信するようにするためのツールとして使われるようになっていました。しかしPackwerkの本来の目的は、依存関係グラフを定義してそれを強制することなのです。

パッケージAがパッケージBに依存していない場合、パッケージAはパッケージBのコードを使うことは許されません(たとえpublic APIであっても!)が、ここで判明したのは、開発者たちがコード内の依存関係よりも、むしろAPI設計の方に目を向けていたということでした。すなわち、問題解決のために作ったPackwerkが、その問題から開発者たちの目を逸らしていたのです。

これらの問題を考慮した結果、Packwerk 3.0のリリースからはプライバシーチェックが削除されました。

🔗 Packwerkの弱点と盲点

私たちが見つけたPackwerkの最大の問題は、「Packwerkが認識しないもの」「Packwerkが知らないこと」「Packwerkが報告しないこと」については、人間の代わりにうまくやってくれたりしないということです。

Packwerkを使うときは、最初にパッケージを宣言します。パッケージでは、どのコードがどこに置かれているか、個別のコードセットが他のコードにどのように依存しているかを定義します。特に、歴史的な理由であらゆるものがグローバルだった大規模コードベースでは、パッケージとそれらの関係を適切に選択するのが恐ろしく難しくなることがあります。パッケージの定義を後から変更することは一応可能ではあるものの、これほどの規模の変更になると、コードを分離するための時間や労力というコストが莫大なものになる可能性があります。しかも分離したコードは最終的に一緒に動くのです。

Packwerkはこの点について何も指針を示しません。利用者がどの方法を選ぼうと何も文句を言わず、指定の目標を達成するためのToDoファイルを黙々と生成するだけです。しかし、これによって現状が改善されるかどうかはまったく別の話です。

ツールが、アプリケーションの依存関係グラフを作る責任を開発者に押し付けると、コード同士の結合について誤った仮定を立ててしまうことがよくあります。特に、大規模コードベースの1セクションだけを対象にする場合や、依存関係管理やコードアーキテクチャの理解が不十分の場合はなおさらです。

私たちが発見したのは、開発者がコードをグループ化してパッケージ化するときに、コードが実際にどこでどう実行されるかよりも、それとほぼ無関係な意味論の方に気を取られる傾向があることです。

たとえば、私たちのモノリスには「ショップの請求設定」情報を持つモデル(ShopBillingSetting)があり、ここにはショップが不正(fraudulent)かどうかという情報も含まれます。このモデルは、その名前に引きずられて"billing"(請求)というパッケージに含められましたが、これは間違いでした。不正なショップの検出は、請求に限らず、ショップからのどんなリクエストを処理するときにも不可欠です。私たちの場合、"billing"という名前が意味するものを無視して依存関係グラフのベースに移動し、どのコントローラからもアクセス可能にすることで解決しました。

ショップの請求設定の違反

名前の意味に引きずられないようにしながら決定を下すのは、物事の名前を尊重する人間の直感に逆らうので、その分難しくなります。Packwerkは、人間が提供した高レベルの視点に基づいて、コードベースに対して完全に動作しますが、人間の視点は直感に大きく左右されがちです。Packwerkが認識した依存関係グラフが現実と食い違っていれば、開発者が依存関係を解決するために費やした努力がほとんど水の泡になってしまう可能性もあります。実際、そういう努力によってもたらされた間接化は、コードをむしろ悪化させ、コードがさらにこじれて理解が難しくなる可能性すらあります。

たとえ依存関係グラフが適切だったとしても、次は違反を「どんな方法で」解決するかという問題が生じます。Packwerkは解決方法については何も示さず、定数の参照と、それらが提供されたパッケージ同士とどう関連しているかということだけをチェックします。このため、依存関係の違反を修正しようとするときに、修正方法が適切かどうかを人間が判断するのが難しくなります。

他にも、こうした問題を悪化させる可能性のある盲点がいくつかあります。
他の静的解析ツールと同様、Packwerkも実行時に動的生成される定数を適切に推論できません。ただしPackwerkはZeitwerkのオートロードディレクトリに依存しているので、他の静的解析ツールよりもずっと強い制約があります。requireautoloadActiveSupport::Autoloadなどのメカニズムによって読み込まれた定数は、Packwerkではトラッキングされず、認識もされません。その結果、Packwerkに沿って綿密に定義されたパッケージに違反が1つも残っていなくても、実際にはコードを実行したときに名前エラーでクラッシュすることがあります。

Packwerkは全体像を把握していないというだけでなく、私たちのようにRailsエンジンを完全なパッケージとして利用していると、「ルーティング」「フィクスチャ」「イニシャライザ」などを再配置するときには役に立ちません。Packwerkにとって、定数で参照できないものはすべて認識できないものであり、暗黙の依存関係となります。このことによって、実行してみないとわからない問題がさらに多く発生することがよくあります。

🔗 違反ゼロのパッケージ

上述の盲点の問題が最も明らかになるのは、パッケージ化されたコードを「単独で」実際に実行してみたときです。「単独で」実行するとは、パッケージとその依存関係は読み込むが、それ以外のものは一切読み込まないという意味です。理論上は、パッケージに違反が1つもなく、依存コードにも違反がまったくなければ、他のコードを読み込まなくてもまったく普通に利用できるはずです。つまるところ、依存関係グラフはここが肝心なのです。

最近私たちは、実際にそういうパッケージを作成してPackwerkをテストすることにしました。話を簡単にするため、このテストの対象に選んだのは、私たちのモノリスにあるもののうち、定義上依存関係が存在しないただ1つの部分でした。そのコードは"Platform"という名前ですが、その実体は「ガラクタの詰まった引き出し(junk drawer)」で、他のパッケージから利用される低レベルのグルーコード(=互換性を得るためだけの接着剤的なコード)が詰まっています。このPlatformはモノリスの依存関係グラフのベース部分に居座っていたので、分離作業の手始めにPlatformを選んだのは当然の流れでした。

しかし、いざ作業を開始してみると、このPlatformは分離すらされていなかったことが判明しました。まっさらの白紙にすることが重要だったので、Platform自身に手を付ける代わりに、本当に重要なものだけを入れる新しいパッケージを作って、Platformにあるものをそこに切り出していきました。新しいパッケージの名前は"Platform Essentials"で、そこにApplicationControllerApplicationRecord、そしてモノリスの他の部分が行うほぼすべての処理に依存するインフラストラクチャコードを移動しました。なお、私たちのモノリスにある"Platform Essentials"は、ちょうどRailsにおけるActive Supportのような位置づけになります。

Railsの依存関係グラフ

このPlatformパッケージを分離する演習は、私たちにとって驚きの体験でした。「ベースパッケージを違反ゼロ、依存関係ゼロで切り離す」という目標は達成されましたが、そこに至る道のりは平坦ではなく、多くのトレードオフを強いられました。たとえば、ベースレイヤのコードからパッケージ参照を切り出すためにMartin Fowlerの言う「制御の逆転」に強く依存する形になることもありました。こうした変更によって間接化がもたらされて違反は解決したものの、コードがわかりにくくなることもしょっちゅうでした。

違反ゼロを達成した私たちをゴールで迎えてくれたのは、Packwerkの驚くべきバグの発見でした(#249)。Packwerkは、すべての違反が解決されたときにパッケージの古いToDoファイルをクリーンアップしていなかったのです。Packwerkがリリースされてからの数年間、私たちがパッチを当てるまで誰もこのバグに気づかなかったということは、パッケージのToDoファイルを完全に解決して消し去ったのはどうやら私たちが最初だった可能性が高いようです。このことから、Packwerkはユーザーの問題を実際に修正する(または問題の修正に関心を抱く)能力よりも、ユーザーの問題を発見する能力の方がずっと高いのではないかという疑問が裏付けられました。

ベースパッケージのPackwerk違反をすべて解決し終えてから、そのコードだけを読み込んでモノリスを起動する形で実際にパッケージの「実行」を試みました。上述した弱点のせいで、やはり動きませんでした。実際、イニシャライザや環境ファイルなど、これまで思いもよらなかった場所で、解決の必要な問題が他にも見つかったのです。Zeitwerkなしで読み込まれたコードにも対処しなければならず、これらは前述したようにPackwerkではトラッキングされていませんでした。これらの問題については、イニシャライザなどのアプリケーションセットアップをアプリケーションのエンジンに移動することで修正し、単独で起動したときはベースレイヤを読み込まないようになりました。

起動できるようになったので一歩先に進めるようになり、パッケージのコードのテストを分離して実行するためのCIステップを作成しました。すると今度は、Packwerkの静的解析や起動では起きなかった別の問題も表面化しました。最終的にテストがパスしたことで、Platform Essentialsがアプリケーションの他の部分から完全に切り離されたという妥当な信頼レベルに到達できました。

依存関係を持たないパッケージという比較的単純なケースですら、完全な分離を達成するために数か月も大変な作業を要しました。これは一方で、1個のパッケージで期待されるものを遥かに超えているということであり、モノリスで対処が必要な依存関係の問題が実は途方もない規模であるということでもあります。依存関係を解決した後にも必要な作業が山ほど残されたことで、Packwerkでできることの限界と、対応範囲のギャップを埋める追加ツールの必要性が示されました。

実を言うと、上述のPlatformパッケージを分離する演習の対象はPackwerkではありませんでした。実際は、分離そのものが演習の対象であり、このサイズのコードベースがあらゆるものへのグローバルアクセスを前提としている場合に分離可能かどうかが対象だったのです。演習はこの点については大成功でした。これまで一度も行われたことがなかった作業を、予定されていた終了日よりも前倒しで達成できたのです。せっかくの進捗が絶対に逆戻りしないよう、CIチェックも実装しました。私たちは誰の目にも明らかな進歩を達成し、Packwerkは(正しい文脈で用いられたことで)その進歩を現実にするうえで重要な役割を果たしました。

🔗 パッケージはドメイン単位か機能単位か

Shopifyでは、モノリスを"コンポーネント"と呼ばれるコード単位に編成しています。コンポーネントは何年も前に、数千ものファイルを数十の固まりに分類する形で作成され、コンポーネントごとに商取引上の独自の概念を表現していました。そういうわけで、Shopifyのモノリスは「配送(Delivery)」「オンラインストア(Online Store)」「マーチャンダイジング(Marchandising)」「チェックアウト(Checkouts)」といった名前のディレクトリに分割されて配置されました。当時は、この大規模な変更のおかげでチームの作業を分割・分担できるようになり、新しいコンポーネントがむやみに作成されないよう制限をかけられるようになり、数百万行ものコードを抱えるコードベースの治安を保つうえで優れた方法となっていました。

しかしほどなく、ドメインと、ドメイン間の境界は、Shopifyのコードの実際の流れや機能を反映していないことが判明したのです。Packwerkをこのコードベースで実行すると、この食い違いがたちまち露わになり、コンポーネントごとに大量の巨大ToDoファイルが生成されました。何か新機能を追加すると、そのたびにToDoファイルは膨れ上がりました。開発者たちは、Packwerkが報告した違反の一部については解決できたものの、多くの場合、修正を加えることは、コードが行おうとしている作業の本質に反していて、不自然で複雑になりすぎるように感じていました。

Platformコンポーネントと他のコンポーネントの関係

ただし、修正を加えても不自然にならない重要な例外が1つありました。モノリスに居座っていた前述のPlatformコンポーネントは、最初から純粋にシステムレベルのconcern1だったのです。このPlatformコンポーネントは"commerce"ドメインという鋳型にはどうやってもはまりようがありません(そうしたコンポーネントは他にも少しあります)。こうして、Platformコンポーネントはドメイン中心の世界観の中では浮き上がっていました。

しかし、単にコードの配置をあれこれ変えるのではなく、コードが実際に実行されるときの機能や流れの方に視線を向けると、純粋に機能を実行するというPlatformコンポーネントの本質が、たちまち大きな有用性を発揮したのです。Platformコンポーネントは、他のどのコンポーネントとも異なり、依存関係グラフ上にはっきりとした居場所がありました。Platformコンポーネントはあらゆるもののベースに配置されなければならず、依存関係はゼロでなければなりません。

視点を「コードの実行」の方に移すことで、モノリスの編成方法を見直すことが促されました。コンポーネントには、ドメインを表すコンポーネントもあれば、アプリケーションで機能を果たす役割を中心に設計されているコンポーネントもあり、コンポーネントをどちらに落とし込むべきかという二分法に直面することになります。

チェックアウトのフローは、顧客がチェックアウトを開始して注文内容の支払いを行ううえで必要なコードとして定義される関数です。しかしCheckoutコンポーネントには、このフローと無関係なconcernsがたくさん含まれています(販売者がチェックアウト設定を変更するためのコントローラコードやバックエンドコードなど)。このコードは「checkout」ドメインの一部ですが、チェックアウトフロー機能の一部ではありません。

実際にパッケージを切り離した形で実行するには、パッケージを機能ベースで厳密に定義することが要求されます。一方、私たちのコンポーネントは、ほとんどがドメイン中心に定義されています。

これに関する最近の私たちの解決方法は、コンポーネントを単一のコード単位として使うのではなく、複数のパッケージをグループ化するためのトップレベル編成ツールとして使うというものです。こうすることで、チームは引き続きドメインを所有できるようになり、しかも個別のパッケージは真のモジュラーコードの単位として機能するようになります。この解決方法は、「理解可能なメンタルモデルを求める人間側のニーズ」と「依存関係グラフ上で明確に定義されたコード単位を求める実行時のニーズ」の両方に対する妥協策です。

🔗 Packwerkは「よく切れる刃物」

既存の大規模コードベースをモジュール化しようとすると、コードはどう振る舞うべきかという「あるべき論」に取り憑かれてしまいがちです。Packwerkも、それ自体が開発者に「最終的に望ましい状態」を定義させて、その目標を達成できるように促すことで、この傾向を後押しします。パッケージのセットを決めるのはあくまで開発者であり、パッケージ間をつなぐ依存関係を決定するのも開発者なのです。そしてToDoファイルの指示に従って開発者が作業を進めていけば、お望みのコード編成を達成できるというわけです。

この方法の問題点は、具体的な成果につながるのかどうかが見えにくいことです。コードというものは、そのコード自身の機能が目指す方向に強く進みたがる性質があります。コードが進みたがる方向を人間のメンタルモデルに合わせて変えることは相当難しく、逆に、コードの実際の振る舞いや流れに合わせて人間側がメンタルモデルを変更する方がずっと容易です。

私たちはこの教訓を得るためにイバラの道を進みました。
当初は、モノリスを理想郷にするビジョンを夢見ていました。commerce(商取引)に関するさまざまなビジネスドメインを相互に関連付けて表現するコード単位がモジュール化されていて、依存関係も明確に定義されている、そんな世界です。そして私たちは、コードが目標の場所にたどりつくための道筋を示すツールとしてPackwerkを構築し、コードベースに適用しました。目標達成に必要な作業も明らかで、進むべき道筋も明確であるように思われました。

そして、いざ実際に作業が始まってみると、バラ色のはずだったものが色褪せ始めてきました。さんざん手こずりながら厄介なトレードオフを繰り返して得た結果は、たった1個のパッケージのToDoファイルを消し去ったことだけでした(最初に完了できそうなのはこのファイルぐらいしかなかったことが後に判明しました)。その時点のコードはまだ壊れていて単独では動かず、ほろ苦い結果となりました。夢見ていた理想郷はどこにも存在しておらず、そこにいざなってくれると思っていたツールによって迷子になりました。

この行き詰まった状況を好転させてくれたのは、どんなメトリクスよりも、コードを動かすことこそが真の進歩を示す最高の指標であるという気付きでした。Packwerkにはそのための使い道がありますが、あくまでコード品質を測定するさまざまなツールのひとつに過ぎません。私たちは現実に立ち返り、当初のこだわりを捨てて自分たちの視野を広げたことで、当初思いもよらなかったPackwerkの活用法を開拓し、小さいながらも重要な成果を得たのです。

Railsエコシステムにある他のツールと同様、Packwerkもまた鋭い刃物であり、慎重に取り扱わなければなりません。

  • Packwerkの使い方と、Packwerkで発生する違反の修正方法をあいまいなままにせず、意識的に考え抜くこと。
  • 違反が開発者レベルのエラーなのか、依存関係グラフレベルのエラーなのかを常に自問自答すること。
  • 違反が依存関係グラフレベルの場合は、コードの依存関係に沿ってパッケージの配置をより良い形に調整することを検討すること。

Shopifyでは、仮説をストレステストにかけて、過去の決定事項を再検討することがよくあります。Packwerkで生じるコストや、前述の弱点や盲点を考慮しながら、Packwerkを私たちのモノリスから削除すべきかどうかについて議論を重ねました。プライバシーチェックを導入したために背負い込んだ技術的負債を返済し終わるのは、だいぶ先の話になります。

ただし、Packwerkはアプリケーションの基本レイヤで依存関係が新たに発生するのを食い止める防衛線としての価値を提供してきたことも確かです。解決すべき違反リストは、たとえ不完全であっても、分離のために明確に定義された目標に進むうえで、作業を効果的に分割する方法なのです。

私たちがPackwerkを利用したことで得た学びは、大規模Railsアプリケーションをモジュール化するためのさらに大きな戦略に影響を与えました。つまり、「哲学的な理想を追い求めるのではなく、コードが実行できることと実行可能な結果を強力に目指す」という戦略です。Packwerkは以前ほど開発の中心に存在しているわけではありませんが、今もShopifyで役目を担っています。おそらく、今後何年もその役目を担い続けるでしょう。

関連記事

素のRailsは十分に豊かである(翻訳)


  1. 訳注: concernはRailsで使われるコード編成方法の1つですが、「懸念事項」「悩みのタネ」といったニュアンスもあります。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。