概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Isolating Rails Engines with RuboCop - Flexport Engineering
- 原文公開日: 2019/11/10
- 著者: Max Heinritz
- サイト: Flexport Engineering -- 流通系のシステムを手掛けている開発会社です
日本語タイトルは内容に即したものにしました。画像はすべて元記事からの引用です。
RailsエンジンをRuboCopで徹底的に分離する:前編(翻訳)
FlexportのメインとなるバックエンドサービスはRuby on Railsモノリスです。弊社を立ち上げた頃はRailsのおかげでビジネスを素早く進めることができました。しかし、成長著しいスタートアップによくあることではありますが、チームが育つに連れて複雑さを管理するのが困難になってきました。
当初はRailsの利便性のおかげで生産性が向上しましたが、今やそのせいで何が起こっているかを理解するのも困難なありさまです。大量の双方向モデル関連付けやら、ActiveRecordで何でも読み書きできてしまっているとか、グローバルなapp/
ディレクトリ構造やら暗黙の振る舞いやら、もうきりがありません。
複雑極まる状態になったアプリを解きほぐすために、私たちはRailsエンジンを使い始めました。Railsエンジンとは、あるRailsアプリの中に内蔵されるモジュールで、独自のディレクトリ構造や名前空間を備えています。しかしながら、Railsエンジンが提供するモジュラリティのほとんどは外面的なものにとどまります。エンジニアがエンジンの内部に直接手を突っ込んでごそごそやることを防いではくれません。
Flexportでは、Railsエンジンのデフォルトの振る舞いを拡張するために、エンジン内部に強めの制約をかけて分離する手法をいくつか編み出しました。本記事では弊社のアプローチとして、Railsエンジンに関連したRuboCopのcopを3つご紹介します。3つのcopはオープンソースとして公開していますので、コミュニティで広く共有いただけます。
オープンソースのステータス: rubocop-flexport
gemおよびリポジトリを一般公開しています。
Railsエンジンの分離について
「Railsエンジンの分離」とは、エンジンの外部にあるコードからエンジン内部を自由に読み書きできないようにすること、そしてその逆に、エンジン内部からエンジン外部を自由に読み書きできないようにすることを指します。
まずは簡単なコード例から。デフォルトのapp/
の他にoceanとtrucking(運輸)という2つのエンジンがあるとします。ディレクトリ構造は以下のとおりです。
さて、oceanチームのエンジニアは、出荷されたコンテナが宛先の港に到着する日時を知りたいとします。そこでoceanエンジン内にこんなハンドラを1つ追加しました。
エンジン強制分離のメリット
弊社の主要な目標は、エンジン同士の関心の分離と高い凝縮度、そして疎結合を達成することです。エンジン強制分離を導入することで、開発中にこれらの原則に違反すれば機能を早くリリースするのに便利な場合であっても、開発者をこれらの原則に従わせます。エンジン強制分離によって、戦術上においても以下のようなさまざまなメリットを得られます。
1. 自由な書き込みを防止する
Active Recordのモデルに直接アクセスすると、コードベースのどんな場所からでも.save
で自由気ままに書き込みできてしまいます。これでは書き込みパスをチームで一本化するのが難しくなり、コードもわかりにくくなります。
- 「この配送に用いる運搬車両を変更する」といった単純なビジネスプロセスですら、バリデーションやコールバックや外部コードがあちこちに散らばってしまう可能性がある。
- モデル自身にもモデルをまたがる重要なバリデーションを含めなければならなくなる: これによってバリデーションで関連付けが読み込まれるときのパフォーマンスが低下し、さらにN + 1クエリ問題につながることもあります。
- メール送信やサードパーティシステムとの同期、イベント発火といった副作用があちこちでトリガされ、デバッグが困難になる可能性がある。
2. 自由な読み取りを防止する
Active Recordのモデルに直接アクセスすると、関連付け(association)を無視して他のモデルから自由気ままにデータを読めてしまい、次のような結果になってしまいます。
- チームのモデルがコードベースの他の部分でどう使われているかが見通しにくくなる: それによって明確なインターフェースや所有権の境界を確定することも困難になり、当然リファクタリングも製品の改修も困難になります。
- エンジニアがよそのチームのデータモデルから一部を読み込む場合、N+1問題を回避するために自分たちの
includes
を定義してメンテナンスし続ける必要がある: よそのチームのデータモデルが変更されれば、こちらのincludes
も更新が必要になり、コードを同期するのがつらくなります。
すぐに使えるisolate_namespace
モジュラリティ
デフォルトのRailsは、ささやかながらエンジンの分離機能を提供しています。isolate_namespace
メソッドは以下のように使います。
isolate_namespace
はコントローラ、モデル、ルーティングおよびその他のコードをエンジンの名前空間へと分離し、app/
の下にある類似のコンポーネントから切り離します。
つまり、MyEngine
内で定義されているMyModel
にOtherEngine
からアクセスするには、名前空間なしのMyModel
ではなくMyEngine::MyModel
を用いる必要があります。しかしサービスやモデルへのアクセスを禁止するわけではないので、引き続き以下のようにコードベースの他の部分からは自由に読み書きできてしまいます。
isolate_namespace
を適用すると、モデルの冒頭に必ずエンジンの名前空間を付けなければならなくなります。
これはこれで正しい手順ではありますが、弊社の経験ではほぼお飾りレベルの分離です。
Railsエンジン分離を強制するcopたち
Railsエンジンのデフォルトの分離の振る舞いを拡張するため、弊社はRuboCop用のcopを新たに作りました。RuboCopは弊社で愛用されていて、昨年公開したいくつかのcopも含め、社内で30個以上のcopをこしらえました。copに違反すると、ローカルのpre-commitフックでも社内CIパイプラインでも落ちるようになっています。
Railsエンジン分離で必要な保護は、主に次の2種類です。
- 外から中へのアクセス: エンジン外部のコードからエンジン内部のコードに手を突っ込む行為
- 中から外へのアクセス: エンジン内部のコードからエンジンの外のコードにちょっかいを出す行為
自分たちのすべてのコードが保護されたエンジン内に収まっていれば、1.は満たされるでしょう。しかし弊社の既存app/
ディレクトリにも対応が必要なものがどっさり詰まっています。一般にエンジンの作者は両方向の結合に目を光らせなくてはなりません。弊社の頼もしいcopたちはこの2種類のアクセスを取り締まり、メインのapp/
ではなくエンジンを使うよう後押しします。
NewGlobalModel
でエンジン利用を促進
Flexport/NewGlobalModel copは、新しいモデルがメインのapp/models
に追加されたときに違反キップを切ります。弊社ではこのディレクトリに置かれるモデルを「グローバルモデル」と呼ぶ慣習があり、モデルがメインappに置かれたことがこれでわかります。エンジニアは新しいモデルをメインappではなくRailsエンジンに追加することが奨励されます。
GlobalModelAccessFromEngine
で外部へのアクセスを制限
Flexport/GlobalModelAccessFromEngine copは、エンジン内からメインappへの直接アクセスを取り締まります。以下の違反例をご覧ください。
app/
の下のengine_api/
で定義されるファイルの集まりです。これで次のように、エンジンでメインappへの明確なインターフェイスを使えるようになります。
MainApp::EngineApi
はcopに強制されるものではなく、弊社内部での集約に用いる定番の手法です。エンジンのコードでこのcopを有効にすると、技術的にはエンジンのコードからメインappにあるどの非モデルコードにもアクセスできるようになります。これは、この後説明するcopによる外から中へのアクセス取り締まりに比べれば厳しくありません。
GlobalModelAccessFromEngine
copは、次のように関連付けも調べてくれます。
弊社では、エンジンのデータモデリングを、ちょうどネットワーク越しのサービスであるかのように扱う傾向があります。この場合、エンジン同士の境界を乗り越えてモデルを参照する外部キーIDを持たせるのが自然ですが、厳密に強制された外部データベースキーを持たせたいのではなく、背後のモジュールにおける「関心の分離」を隠蔽するORM関連付けを用いたいわけでもありません。
EngineApiBoundary
で外から中へのアクセスを制限
Flexport/EngineApiBoundary copは、エンジンの名前空間がエンジンディレクトリの外にある場合に警告します。以下の例は、MyEngine
をOtherEngine
から保護しています。
Railsエンジンのpublic Ruby API
当然ながら、エンジン同士が何らかの形でやりとりする必要がしばしば生じます。このcopを用いて、エンジンの外のコードからエンジンとやりとりするためのAPIをエンジン作者が定義できます。
このAPIは、マイクロサービスが公開するネットワークAPIとある意味で似ていますが、エンジンのAPI呼び出しは、通常の同期的なRubyメソッド呼び出しであるのが普通です。以下のOtherEngine
は、容認可能な方法でMyEngine
を利用します。
api/
ディレクトリの下に定義します。定義方法は以下の2とおりです。
api/
にファイルを追加する: これらのファイルに定義されたコードはエンジンの外からアクセスできるようになります。たとえばapi/foo.rb
を追加すればエンジン外部のコードからMyEngine::Api::Foo.bar(baz)
のように呼び出せるようになります。api/
の下に_whitelist.rb
ファイルを作成する: このファイルに記載されているモジュールはエンジンの外部にあるコードにアクセスできるようになります。このファイルには以下の形式でモジュール名を記述する必要があります。
api/
ディレクトリ内で定義されているコードにもアクセスできます。
エンジンAPIのベストプラクティス
弊社のエンジニアは、エンジン間で値を交換するAPIとして、Active Recordではなく、PORO(Plain Old Ruby Object)またはDry::Structの値を用いることが推奨されています(現在は強制ではありませんが)。あるエンジンが他のエンジンのモデルを欲しがってActive Recordオブジェクトへの参照を取得すると、関連付けや.save
を用いて自由に読み書きできるようになってしまいます。
また、エンジニアはSorbetシグネチャでAPIを型付けすることも推奨されています。弊社では以下を確実に実行するためのcopを書くことを検討しました。(1)エンジンのAPIファイルにSorbetシグネチャがあること、(2)シグネチャにActive Recordモデルの型が含まれていないこと。
レガシー依存性
API以外にも、エンジンの作者はこのcopで「レガシー依存性リスト」ファイルを定義できます。これは、(理由を問わず)エンジンにこっそり直接アクセスすることを許されているファイルのバックログです。このレガシー依存性ファイルは、既存のコードをエンジンに移行するうえで素晴らしく有用であることがわかってきました。このcopを有効にしてレガシー依存性をひととおり与え、それから分離のためのリファクタリングをじわじわと進めるのです。
おたより発掘
モノリシックなRailsアプリをモジュラモノリスに移行する手法として、これ結構良さそうに見える。
Railsエンジン単体だと制約が緩いので、rubocop-flexportで強めの制約をかけて境界を跨るアクセスを防止してる。https://t.co/ROeu2Wu0mr— きなこ棒 (@kinakob0) January 9, 2020