- IT Tips
READ MORE
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。画像はすべて元記事からの引用です。
FlexportのメインとなるバックエンドサービスはRuby on Railsモノリスです。弊社を立ち上げた頃はRailsのおかげでビジネスを素早く進めることができました。しかし、成長著しいスタートアップによくあることではありますが、チームが育つに連れて複雑さを管理するのが困難になってきました。
当初はRailsの利便性のおかげで生産性が向上しましたが、今やそのせいで何が起こっているかを理解するのも困難なありさまです。大量の双方向モデル関連付けやら、ActiveRecordで何でも読み書きできてしまっているとか、グローバルなapp/ディレクトリ構造やら暗黙の振る舞いやら、もうきりがありません。
複雑極まる状態になったアプリを解きほぐすために、私たちはRailsエンジンを使い始めました。Railsエンジンとは、あるRailsアプリの中に内蔵されるモジュールで、独自のディレクトリ構造や名前空間を備えています。しかしながら、Railsエンジンが提供するモジュラリティのほとんどは外面的なものにとどまります。エンジニアがエンジンの内部に直接手を突っ込んでごそごそやることを防いではくれません。
Flexportでは、Railsエンジンのデフォルトの振る舞いを拡張するために、エンジン内部に強めの制約をかけて分離する手法をいくつか編み出しました。本記事では弊社のアプローチとして、Railsエンジンに関連したRuboCopのcopを3つご紹介します。3つのcopはオープンソースとして公開していますので、コミュニティで広く共有いただけます。
オープンソースのステータス: rubocop-flexportgemおよびリポジトリを一般公開しています。
「Railsエンジンの分離」とは、エンジンの外部にあるコードからエンジン内部を自由に読み書きできないようにすること、そしてその逆に、エンジン内部からエンジン外部を自由に読み書きできないようにすることを指します。
まずは簡単なコード例から。デフォルトのapp/の他にoceanとtrucking(運輸)という2つのエンジンがあるとします。ディレクトリ構造は以下のとおりです。
| app/ | |
| models/ | |
| controllers/ | |
| ... | |
| engines/ | |
| ocean/ | |
| app/ | |
| models/ | |
| controllers/ | |
| ... | |
| trucking/ | |
| app/ | |
| models/ | |
| controllers/ | |
| ... | |
| ... | |
さて、oceanチームのエンジニアは、出荷されたコンテナが宛先の港に到着する日時を知りたいとします。そこでoceanエンジン内にこんなハンドラを1つ追加しました。
| module Ocean | |
| def handle_container_arrived_at_destination_port(container_id, date) | |
| ... | |
| end | |
| end |
| module Ocean | |
| def handle_container_arrived_at_destination_port(container_id, date) | |
| delivery = Trucking::Delivery.find_by(container_id: container_id) | |
| delivery.inbound_container_arrived_at = date | |
| delivery.save | |
| ... | |
| end | |
| end |
| module Ocean | |
| def handle_container_arrived_at_destination_port(container_id, date) | |
| delivery = Trucking::Delivery.find_by(container_id: container_id) | |
| # Probably the trucking engine doesn’t want others doing this. | |
| delivery.secret_private_field = false | |
| delivery.save | |
| ... | |
| end | |
| end |
弊社の主要な目標は、エンジン同士の関心の分離と高い凝縮度、そして疎結合を達成することです。エンジン強制分離を導入することで、開発中にこれらの原則に違反すれば機能を早くリリースするのに便利な場合であっても、開発者をこれらの原則に従わせます。エンジン強制分離によって、戦術上においても以下のようなさまざまなメリットを得られます。
Active Recordのモデルに直接アクセスすると、コードベースのどんな場所からでも.saveで自由気ままに書き込みできてしまいます。これでは書き込みパスをチームで一本化するのが難しくなり、コードもわかりにくくなります。
Active Recordのモデルに直接アクセスすると、関連付け(association)を無視して他のモデルから自由気ままにデータを読めてしまい、次のような結果になってしまいます。
includesを定義してメンテナンスし続ける必要がある: よそのチームのデータモデルが変更されれば、こちらのincludesも更新が必要になり、コードを同期するのがつらくなります。isolate_namespaceモジュラリティデフォルトのRailsは、ささやかながらエンジンの分離機能を提供しています。isolate_namespaceメソッドは以下のように使います。
| module MyEngine | |
| class Engine < ::Rails::Engine | |
| isolate_namespace MyEngine | |
| end | |
| end |
isolate_namespaceはコントローラ、モデル、ルーティングおよびその他のコードをエンジンの名前空間へと分離し、app/の下にある類似のコンポーネントから切り離します。
つまり、MyEngine内で定義されているMyModelにOtherEngineからアクセスするには、名前空間なしのMyModelではなくMyEngine::MyModelを用いる必要があります。しかしサービスやモデルへのアクセスを禁止するわけではないので、引き続き以下のようにコードベースの他の部分からは自由に読み書きできてしまいます。
| # Without isolate_namespace enabled. | |
| class OtherEngine::OtherService | |
| m = MyModel.find(1) # Prefix is NOT needed. | |
| end | |
| # With isolate_namespace enabled. | |
| class OtherEngine::OtherService | |
| m = MyEngine::MyModel.find(1) # Prefix is needed. | |
| end |
isolate_namespaceを適用すると、モデルの冒頭に必ずエンジンの名前空間を付けなければならなくなります。
これはこれで正しい手順ではありますが、弊社の経験ではほぼお飾りレベルの分離です。
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への直接アクセスを取り締まります。以下の違反例をご覧ください。
| class MyEngine::MyService | |
| m = FooGlobalModel.find(123) | |
| # ^^^^^^^^^^^^^^ Direct access of global model from within Rails Engine. | |
| m.any_random_attribute = "whatever i want" | |
| m.save | |
| end |
app/の下のengine_api/で定義されるファイルの集まりです。これで次のように、エンジンでメインappへの明確なインターフェイスを使えるようになります。
| class MyEngine::MyService | |
| MainApp::EngineApi.perform_a_supported_operation(123) | |
| end |
MainApp::EngineApiはcopに強制されるものではなく、弊社内部での集約に用いる定番の手法です。エンジンのコードでこのcopを有効にすると、技術的にはエンジンのコードからメインappにあるどの非モデルコードにもアクセスできるようになります。これは、この後説明するcopによる外から中へのアクセス取り締まりに比べれば厳しくありません。
GlobalModelAccessFromEngine copは、次のように関連付けも調べてくれます。
| class MyEngine::MyModel | |
| has_one :foo_global_model, | |
| class_name: "FooGlobalModel" | |
| # ^^^^^^^^^^^^^^^^ Direct access of global model from within Rails Engine. | |
| end |
弊社では、エンジンのデータモデリングを、ちょうどネットワーク越しのサービスであるかのように扱う傾向があります。この場合、エンジン同士の境界を乗り越えてモデルを参照する外部キーIDを持たせるのが自然ですが、厳密に強制された外部データベースキーを持たせたいのではなく、背後のモジュールにおける「関心の分離」を隠蔽するORM関連付けを用いたいわけでもありません。
EngineApiBoundaryで外から中へのアクセスを制限Flexport/EngineApiBoundary copは、エンジンの名前空間がエンジンディレクトリの外にある場合に警告します。以下の例は、MyEngineをOtherEngineから保護しています。
| class OtherEngine::OtherService | |
| m = MyEngine::MyModel.find(1) | |
| # ^^^^^^^^ Direct access of MyEngine engine. Only access engine via MyEngine::Api. | |
| m.any_random_attribute = "whatever i want" | |
| m.save | |
| end |
当然ながら、エンジン同士が何らかの形でやりとりする必要がしばしば生じます。このcopを用いて、エンジンの外のコードからエンジンとやりとりするためのAPIをエンジン作者が定義できます。
このAPIは、マイクロサービスが公開するネットワークAPIとある意味で似ていますが、エンジンのAPI呼び出しは、通常の同期的なRubyメソッド呼び出しであるのが普通です。以下のOtherEngineは、容認可能な方法でMyEngineを利用します。
| class OtherEngine::OtherService | |
| MyEngine::Api::MySupportedBusinessProcess.execute(1) | |
| end |
api/ディレクトリの下に定義します。定義方法は以下の2とおりです。
api/にファイルを追加する: これらのファイルに定義されたコードはエンジンの外からアクセスできるようになります。たとえばapi/foo.rbを追加すればエンジン外部のコードからMyEngine::Api::Foo.bar(baz)のように呼び出せるようになります。api/の下に_whitelist.rbファイルを作成する: このファイルに記載されているモジュールはエンジンの外部にあるコードにアクセスできるようになります。このファイルには以下の形式でモジュール名を記述する必要があります。| module Flight::Api::Whitelist | |
| PUBLIC_MODULES = [ | |
| Flight::Service, | |
| Flight::EmailParser::Service, | |
| Flight::ScheduleEntity, | |
| Flight::EquipmentEntity, | |
| ] | |
| end |
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を有効にしてレガシー依存性をひととおり与え、それから分離のためのリファクタリングをじわじわと進めるのです。
| module Trucking::Api::LegacyDependents | |
| FILES_WITH_DIRECT_ACCESS = [ | |
| "app/models/bill.rb", | |
| ] | |
| end |