概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Isolating Rails Engines with RuboCop - Flexport Engineering
- 原文公開日: 2019/11/10
- 著者: Max Heinritz
- サイト: Flexport Engineering -- 流通系のシステムを手掛けている開発会社です
日本語タイトルは内容に即したものにしました。画像はすべて元記事からの引用です。
RailsエンジンをRuboCopで徹底的に分離する:後編(翻訳)
(前編の続き)
実際に使ってみる
弊社では2019年初頭からエンジン分離copを使い続けて成功を収めています。今やコードベースには40個のエンジンができあがり、Rubyコードの35%を締めています(cloc app
とcloc engines/**/app
の比較)。このcopは、既存コードのリファクタリングにも、新規プロジェクトの開始時にも有用であることが実証されています。
訳注: AlDanial/cloc:はさまざまなプログラミング言語を対象にソースコードの空行やコメント行や実際の行数をカウントするツールです。
インクリメンタルな分離
弊社のドメインモデルでは、Martin Fowlerの「Bounded Context」に基づいてインクリメンタルなコード切り離しを進めるときに以下の手順に従っています。
- 空のエンジンを1つ作成する(copは無効にしておく)
- コードをエンジンに移し替える
- エンジンでcopを有効にしてみる
- RubyCop違反の発生場所を確認することで依存性のありかを突き止める
- 「外から中」違反をひとつずつチェックして、そのファイルをエンジンに移動し、エンジンAPIを公開してファイルのユースケースをサポートする、もしくはそのファイルを
_legacy_dependents.rb
に登録する - 「中から外」違反をひとつずつチェックし、依存性を削除してメインのappエンジンAPIを作成する
新規プロジェクト
これらのcopがまっさらぴんのエンジン作成にも有用であることがわかってきました。2つのcopを最初から有効にしておけば、新しいエンジンのデータがモジュール化されてシステムの他の部分から切り離されます。このおかげでエンジンをモノリスで手軽に立ち上げられるようになり、(必要なら)後でネットワーク越しでアクセスする通常のサービスに切り出すのも楽にやれます。
copの特性
他のツールと同様、弊社のcopにも強みと制約があります。
強み
- 越境違反を(実行時ではなく)静的解析で検出できるので、修正サイクルを軽快に回せる
- チームのインクリメンタル分離作業が他から分離されて自分たちのペースで進められる
- まっさらエンジンで最初から2つのcopを有効にしておくことで分離が強化される
- copたちのおかげでインターフェイスファーストな開発が促進され、設計の質が高まるともっぱらの評判(個人の体験です)
制約
- 「エンジンAPI」の定義がユルい: エンジンがActive Modelモデルを返すことを禁止できていない(今のところは!)
- 「レガシー依存性」もユルい: 既存のレガシー依存性ファイルからエンジンへの直接アクセスを、警告なしに追加できてしまう
- copの取り締まりをすり抜けようと思えばすり抜けられる(生SQLアクセスやメタプロを使う、RuboCopを止めてしまう)
その他の分離手法
弊社では、これら新しいcop以外にもモノリスでモジュール化を強制する方法をいろいろ調べてきました。
リードオンリーActive Record
弊社エンジニアのKevin Millerは、Railsのモデルを以下のように拡張することを社内レベルで提案しています。
.api_association
: これは既存の.readonly
と同じだが、User.all.api_association.last.company.readonly? == true
のような関連付けチェインのみの形への強制も行う。これにより、あらゆる書き込みをサービスAPI経由のみでしか行えないように強制できそう。-
.with_whitelisted_methods
: 背後にあるモデルで、ホワイトリストに記載されていないメソッドをすべてエラーにし、背後の残念なモデルにかかわらずに特定のメソッドやカラムだけを公開できるようにする。
テスト時には明示的に定義された依存性だけを読み込む
スタートアップ企業Root社の良記事「The Modular Monolith: Rails Architecture」では、エンジン同士のモジュール分離を強制する方法について考察しています。テスト時に特定のエンジンだけをいくつか読み込むという方法です。
エンジンA、B、Cがあるとしよう。AはBに依存し、BはCに依存する。Cのテストを走らせるときは、エンジンCだけを読み込み、AやBは読み込まない。Bのテストを走らせるときは(BがCに依存するので)Cは読み込むがAは読み込まない。Aのテストを走らせるときはBやCを読み込む。
同記事より大意
この手法はまっさらエンジンではうまくいきそうですが、既存のコードベースをインクリメンタルにモジュール化するときはそれほどでもなさそうです。
Active Recordのsave
にフックをかける
ApplicationRecord
クラスにフックをかけ、そのモデルが定義されているエンジンの外からsave
やsave!
を呼び出そうとしたときにブロックするという手法です。
関連付けローダーにフックをかける
Active Recordの関連付けローダーにフックをかけて、エンジンを越境する関連付けの読み込みをブロックするという手法です。これは、あらゆる関連付けをcopで削除するという上述の手法と同等のように思われます。
ネットワークによる分離
エンジンを別アプリのインスタンスにデプロイして、やりとりをネットワーク越しに限定します。これは、ある意味で弊社で現在追求している手法と同じです。
実行時にメソッド呼び出しを計測する
aftersave
フックとメタプロを併用して、エンジンごと(またはモデルごと)にバックログ的なものを作成し、production環境でのエンジン内save
とエンジンの外からのsave
やコミットの割合を表示します。値が十分小さくなったら、Sentryのwarningとフルスタックトレースを併用します。
おまけ: オープンソースの哲学
ここで弊社のオープンソース哲学と、本記事でご紹介したcopたちのステータスについて簡単に述べておきます。
Frexportは、コミュニティに支えられている既存のリポジトリを利用可能なら、そこに置かせてもらうようにしています。しかし既存のリポジトリが要件に合わないこともあるので、その場合は弊社のコードを自社リポジトリに置く方が理にかなうと考えます。
RuboCopチームとの議論によれば、Railsエンジン分離copたちは後者に該当します。そこで弊社はそれらのcopを含む自社製copのリポジトリを新たに作成しましたが、他のupstream先は設けませんでした。
今後について
Railsエンジンは、弊社の複雑なモノリスの管理に役立つツールであることが実証されています。会社が成長し続けるにつれて、弊社の関心はバックエンドサービスをネットワークで分離して、GoogleのProtocol Bufferで定義された、より強固な「インターフェイスファーストAPI」を用いることに移りつつあります。弊社は今後も、コードの一部を新サービスに切り出す移行パスとしてRailsエンジンを使い続けるつもりです。そして弊社はRailsエンジンを移行中にも、新しいサービス内部のモジュールにも長期的に用いていくことを期待しています。
この方面についての皆さまの経験談についても知りたいと思います。ご意見などございましたらぜひ弊社までお知らせください。