Tech Racho エンジニアの「?」を「!」に。
  • 開発

RailsエンジンをRuboCopで徹底的に分離する:前編(翻訳)

概要

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

日本語タイトルは内容に即したものにしました。画像はすべて元記事からの引用です。

RailsエンジンをRuboCopで徹底的に分離する:前編(翻訳)

FlexportのメインとなるバックエンドサービスはRuby on Railsモノリスです。弊社を立ち上げた頃はRailsのおかげでビジネスを素早く進めることができました。しかし、成長著しいスタートアップによくあることではありますが、チームが育つに連れて複雑さを管理するのが困難になってきました。

当初はRailsの利便性のおかげで生産性が向上しましたが、今やそのせいで何が起こっているかを理解するのも困難なありさまです。大量の双方向モデル関連付けやら、ActiveRecordで何でも読み書きできてしまっているとか、グローバルなapp/ディレクトリ構造やら暗黙の振る舞いやら、もうきりがありません。

複雑極まる状態になったアプリを解きほぐすために、私たちはRailsエンジンを使い始めました。Railsエンジンとは、あるRailsアプリの中に内蔵されるモジュールで、独自のディレクトリ構造や名前空間を備えています。しかしながら、Railsエンジンが提供するモジュラリティのほとんどは外面的なものにとどまります。エンジニアがエンジンの内部に直接手を突っ込んでごそごそやることを防いではくれません。

Flexportでは、Railsエンジンのデフォルトの振る舞いを拡張するために、エンジン内部に強めの制約をかけて分離する手法をいくつか編み出しました。本記事では弊社のアプローチとして、Railsエンジンに関連したRuboCopのcopを3つご紹介します。3つのcopはオープンソースとして公開していますので、コミュニティで広く共有いただけます。

オープンソースのステータスrubocop-flexportgemおよびリポジトリを一般公開しています。

Railsエンジンの分離について

「Railsエンジンの分離」とは、エンジンの外部にあるコードからエンジン内部を自由に読み書きできないようにすること、そしてその逆に、エンジン内部からエンジン外部を自由に読み書きできないようにすることを指します。

まずは簡単なコード例から。デフォルトのapp/の他にoceanとtrucking(運輸)という2つのエンジンがあるとします。ディレクトリ構造は以下のとおりです。

メインのRailsアプリと2つのエンジンのディレクトリ構造
分離されたRailsサブディレクトリには、それぞれにmodels/やcontrollers/やservices/といったディレクトリがあります。

さて、oceanチームのエンジニアは、出荷されたコンテナが宛先の港に到着する日時を知りたいとします。そこでoceanエンジン内にこんなハンドラを1つ追加しました。

oceanエンジン内の、到着するコンテナのハンドラ
その後、truckingチームもコンテナがいつ到着するのか知りたくなったとしましょう。truckingチームのエンジニアはoceanのハンドラに少々コードを追加して、truckingエンジン内に手を突っ込んでモデルを更新するようにしました。

oceanエンジンの内部からtruckingエンジンのモデルに直接アクセスしている
このやり方は便利ですが、エンジン間の越境は「関心の分離」に違反しています。oceanエンジンはtruckingエンジン内部のモデルについてもビジネスプロセスについても知識を持ってしまいました。oceanエンジンにはActive Recordモデルへの参照があるので、ocean側のコードがtrucking側のフィールドを勝手に設定してしまう可能性があります。

oceanエンジンはtruckingモデルに直接アクセスしてtruckingの内部に書き込めてしまう
「エンジンの強制分離」は、この種のエンジン間の越境をプログラム的に防止することです。

エンジン強制分離のメリット

弊社の主要な目標は、エンジン同士の関心の分離高い凝縮度、そして疎結合を達成することです。エンジン強制分離を導入することで、開発中にこれらの原則に違反すれば機能を早くリリースするのに便利な場合であっても、開発者をこれらの原則に従わせます。エンジン強制分離によって、戦術上においても以下のようなさまざまなメリットを得られます。

1. 自由な書き込みを防止する

Active Recordのモデルに直接アクセスすると、コードベースのどんな場所からでも.saveで自由気ままに書き込みできてしまいます。これでは書き込みパスをチームで一本化するのが難しくなり、コードもわかりにくくなります。

  • 「この配送に用いる運搬車両を変更する」といった単純なビジネスプロセスですら、バリデーションやコールバックや外部コードがあちこちに散らばってしまう可能性がある。
  • モデル自身にもモデルをまたがる重要なバリデーションを含めなければならなくなる: これによってバリデーションで関連付けが読み込まれるときのパフォーマンスが低下し、さらにN + 1クエリ問題につながることもあります。
  • メール送信やサードパーティシステムとの同期、イベント発火といった副作用があちこちでトリガされ、デバッグが困難になる可能性がある。

2. 自由な読み取りを防止する

Active Recordのモデルに直接アクセスすると、関連付け(association)を無視して他のモデルから自由気ままにデータを読めてしまい、次のような結果になってしまいます。

  • チームのモデルがコードベースの他の部分でどう使われているかが見通しにくくなる: それによって明確なインターフェースや所有権の境界を確定することも困難になり、当然リファクタリングも製品の改修も困難になります。
  • エンジニアがよそのチームのデータモデルから一部を読み込む場合、N+1問題を回避するために自分たちのincludesを定義してメンテナンスし続ける必要がある: よそのチームのデータモデルが変更されれば、こちらのincludesも更新が必要になり、コードを同期するのがつらくなります。

すぐに使えるisolate_namespaceモジュラリティ

デフォルトのRailsは、ささやかながらエンジンの分離機能を提供しています。isolate_namespaceメソッドは以下のように使います。

Railsエンジンでデフォルトで使える`isolate_namespace`
Railsエンジンのガイドによると、isolate_namespaceはコントローラ、モデル、ルーティングおよびその他のコードをエンジンの名前空間へと分離し、app/の下にある類似のコンポーネントから切り離します。

つまり、MyEngine内で定義されているMyModelOtherEngineからアクセスするには、名前空間なしのMyModelではなくMyEngine::MyModelを用いる必要があります。しかしサービスやモデルへのアクセスを禁止するわけではないので、引き続き以下のようにコードベースの他の部分からは自由に読み書きできてしまいます。

isolate_namespaceを適用すると、モデルの冒頭に必ずエンジンの名前空間を付けなければならなくなります。

これはこれで正しい手順ではありますが、弊社の経験ではほぼお飾りレベルの分離です。

Railsエンジン分離を強制するcopたち

Railsエンジンのデフォルトの分離の振る舞いを拡張するため、弊社はRuboCop用のcopを新たに作りました。RuboCopは弊社で愛用されていて、昨年公開したいくつかのcopも含め、社内で30個以上のcopをこしらえました。copに違反すると、ローカルのpre-commitフックでも社内CIパイプラインでも落ちるようになっています。

Railsエンジン分離で必要な保護は、主に次の2種類です。

  1. 外から中へのアクセス: エンジン外部のコードからエンジン内部のコードに手を突っ込む行為
  2. 中から外へのアクセス: エンジン内部のコードからエンジンの外のコードにちょっかいを出す行為

自分たちのすべてのコードが保護されたエンジン内に収まっていれば、1.は満たされるでしょう。しかし弊社の既存app/ディレクトリにも対応が必要なものがどっさり詰まっています。一般にエンジンの作者は両方向の結合に目を光らせなくてはなりません。弊社の頼もしいcopたちはこの2種類のアクセスを取り締まり、メインのapp/ではなくエンジンを使うよう後押しします。

NewGlobalModelでエンジン利用を促進

Flexport/NewGlobalModel copは、新しいモデルがメインのapp/modelsに追加されたときに違反キップを切ります。弊社ではこのディレクトリに置かれるモデルを「グローバルモデル」と呼ぶ慣習があり、モデルがメインappに置かれたことがこれでわかります。エンジニアは新しいモデルをメインappではなくRailsエンジンに追加することが奨励されます。

GlobalModelAccessFromEngineで外部へのアクセスを制限

Flexport/GlobalModelAccessFromEngine copは、エンジン内からメインappへの直接アクセスを取り締まります。以下の違反例をご覧ください。

エンジン内からグローバルモデルへの直接アクセスによる違反
ベストプラクティスは、モデルへの直接アクセスではなく、「ビジネスモデル中心の」serviceクラスを「メインappエンジンAPI」と私たちが呼んでいるところに追加することです。「メインappエンジンAPI」とは、app/の下のengine_api/で定義されるファイルの集まりです。これで次のように、エンジンでメインappへの明確なインターフェイスを使えるようになります。

グローバルモデルにアクセスするのではなく、正しく定義されたインターフェイスを用いる
MainApp::EngineApiはcopに強制されるものではなく、弊社内部での集約に用いる定番の手法です。エンジンのコードでこのcopを有効にすると、技術的にはエンジンのコードからメインappにあるどの非モデルコードにもアクセスできるようになります。これは、この後説明するcopによる外から中へのアクセス取り締まりに比べれば厳しくありません。

GlobalModelAccessFromEngine copは、次のように関連付けも調べてくれます。

エンジンで直接関連付けを用いた場合のRuboCop違反
エンジン内のモデルからグローバルモデルに関連付けを直接設定すると、本来分離されるべきモジュール同士がうっかり癒着してしまう可能性があります。

弊社では、エンジンのデータモデリングを、ちょうどネットワーク越しのサービスであるかのように扱う傾向があります。この場合、エンジン同士の境界を乗り越えてモデルを参照する外部キーIDを持たせるのが自然ですが、厳密に強制された外部データベースキーを持たせたいのではなく、背後のモジュールにおける「関心の分離」を隠蔽するORM関連付けを用いたいわけでもありません。

EngineApiBoundaryで外から中へのアクセスを制限

Flexport/EngineApiBoundary copは、エンジンの名前空間がエンジンディレクトリの外にある場合に警告します。以下の例は、MyEngineOtherEngineから保護しています。

MyEngineの外にあるコードからMyEngine内部にアクセスした場合のRuboCop違反
このcopは、次のように関連付けも調べてくれます。

Railsエンジンのpublic Ruby API

当然ながら、エンジン同士が何らかの形でやりとりする必要がしばしば生じます。このcopを用いて、エンジンの外のコードからエンジンとやりとりするためのAPIをエンジン作者が定義できます。

このAPIは、マイクロサービスが公開するネットワークAPIとある意味で似ていますが、エンジンのAPI呼び出しは、通常の同期的なRubyメソッド呼び出しであるのが普通です。以下のOtherEngineは、容認可能な方法でMyEngineを利用します。

外部のコードは、正しく定義されたAPI経由でMyEngineとやりとりすべき
エンジンの作者は、自分のエンジンのAPIをそのエンジン内部にあるapi/ディレクトリの下に定義します。定義方法は以下の2とおりです。

  1. api/にファイルを追加する: これらのファイルに定義されたコードはエンジンの外からアクセスできるようになります。たとえばapi/foo.rbを追加すればエンジン外部のコードからMyEngine::Api::Foo.bar(baz)のように呼び出せるようになります。
  2. api/の下に_whitelist.rbファイルを作成する: このファイルに記載されているモジュールはエンジンの外部にあるコードにアクセスできるようになります。このファイルには以下の形式でモジュール名を記述する必要があります。

エンジン内部での外部アクセスを許可するホワイトリスト_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エンジンをRuboCopで徹底的に分離する:後編(翻訳)

おたより発掘

関連記事

RuboCop作者がRubyコードフォーマッタを比較してみた: 前編(翻訳)

CONTACT

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