Azit CTOの十亀(@Pocket7878)です。
DeliveryXでは、バックエンドのRuby on Railsでの開発をモジュラーモノリス構成で行っており、
本記事では、弊社のモジュラーモノリス構成へのRBSの導入にあたってどのような観点から判断を行ったか、
今後行いたい技術的な改善項目などをご紹介いたします。
弊社のDeliveryXでは、物流業界のDXという事業ドメインに向き合うソフトウェアの開発をおこなっており、
WoltやUber Directといった3PLとのAPI連携や、自社の配送網をすでに保有しているクライアント企業内の配送オペレーションの改善のための機能群など
一つのプロダクトのなかでも、ユースケースや利用タイミングが異なっていたり、同じ単語であっても業務フローが異なっている場面が存在するなど、
ソフトウェアとしてもロジックをいかに疎結合でわかりやすい形に保ち続けるかが開発効率の要の一つであり、顧客への迅速なアウトカムの提供のために重要です。
テストのカバレッジを高く保つことを意識したり、ドメイン駆動設計の概念などを取り入れてビジネス観点とソフトウェア内部の距離を近くするなどの活動も行っておりますが
本記事では、 RBSを利用して型チェックの観点から保守性を高めるための活動をご紹介いたします。
モジュラーモノリスの分割方針とdry-structの活用
DeliveryXでは、ルートディレクトリに packages
というディレクトリを切り、
その下に marketplace
(3PLネットワークとの接続) であったり、delivery
(配達員アサイン後の配送進捗管理)などといった独立したディレクトリを作成し、
各ディレクトリの間はできるかぎり疎結合にできるように努めて構成をしています。
そのなかでの方針の一つとして、パッケージからは usecase
という外部公開の用の限定されたインタフェースを腐敗防止層として定義し、
またUseCaseへの引数としては types
というネームスペース以下に定義したイミュータブルなオブジェクトおよびIntegerやStringなどに限るようにしています。
そのようなイミュータブルなオブジェクトは例えば、以下のようにdry-structとdry-typesを利用して定義しています:
module Marketplace
module Types
class FooBarConfiguration < StructBase
attribute :foo_bar_id, Types::Integer
attribute :maximum_amount, Types::Integer
attribute :maximum_delivery_minutes_limit, Types::Integer
attribute :foo_prioritize_criteria, Types::Symbol.enum(:price_priority, :time_priority)
attribute :bars, Types::Array.of(Bar)
end
end
end
(実際には、StrictやParamsやCoercibleなども適宜使い分けます)
このように定義しておくことで、このパラメータとして受け取ったあとの処理は、このオブジェクトから取り出したフィールドはIntegerやStringなどであることは実行時に保証されているとして実装をすることができます。
また、ユースケースの返値についても同様にイミュータブルで明確なオブジェクトにしておくことで、ユースケースの返値を受け取る側の処理に対しても安定したインタフェースとして依存することができるようになります。
RBS導入の狙いと課題
上記のように進めているdry-structやdry-typesでは主に実行時の型の保証と、インタフェースの明確化によるコードのドキュメントとしての効果を狙って行っています。
そこに加えてRBSを導入することで、静的にチェックできる領域を増やすことでテストのカバレッジの維持と同様にシステム全体の安全性を向上させるとともに、追加の機能開発の際に静的な型検査による影響範囲の検知なども狙いたいと思い、RBS CollectionやSteep、RubyMineでのRBSサポートがより成長してきたことなどを踏まえて正式に積極的に導入をすすようと判断をしました。
また、AzitではGithub CopilotやChatGPT Team、ChatGPT Proの試験導入や、Devinの試験導入などAIによる開発生産性の向上にも会社全体で取り組んでいます。
そのような観点からもAIにとって扱いやすい形として、主要なデータ型やinterfaceを明確な形で与えておきたい・設計したいという意図もあります。
dry-structとRBSの併用
当然のことながら、すでに各パッケージのインタフェースとして定義しているdry-structはそのまま活用でき、引き続き実行時の保証という面では非常に役に立っています。
RBSによる静的型検査では、以下のようにuntypedを経由することで、実際には間違った型の値を外部から渡されてしまう可能性が存在するため、
引き続きdry-struct等による実行時の型検査は必要となります。
たとえば、以下のようなコードが書かれてしまっているとします。
class Foo
def greeting_message
return "Hello, World!"
end
end
class Bar
def add_two(number)
return number + 2
end
end
if __FILE__ == $0
foo = Foo.new
puts foo.greeting_message
bar = Bar.new
puts bar.add_two(foo.greeting_message)
end
このとき、Bar側の型シグネチャとして
class Bar
def add_two: (Integer number) -> Integer
end
と、Integerを期待していることを明確にしていたとします。
このとき、Fooの型シグネチャが
class Foo
def greeting_message: () -> String
end
と定義されていれば、 steepによる検査時に
app/sample.rb:18:19: [error] Cannot pass a value of type `::String` as an argument of type `::Integer`
│ ::String <: ::Integer
│ ::Object <: ::Integer
│ ::BasicObject <: ::Integer
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└ puts bar.add_two(foo.greeting_message)
~~~~~~~~~~~~~~~~~~~~
と、正しくミスが指摘されますが、 greeting_message
の返値が untyped
と宣言されてしまっている場合は
実行時までこのミスに気がつくことはできません、プログラムが段々と大きくなっていくにつれてどのコードがどこで呼び出されているかを
記憶しておいて人間がミスに気がつくというのは段々と難しくなります、またテストのカバレッジを十分に高く保っていたとしても
テストケースに万一の漏れが発生してしまう可能性はありますので、異なるタイプの検査で検知できるのであればそれに超したことはありません。
また今回の場合は、すぐにパラメータがbarメソッド内で利用されているので実行時にも値を渡しているところのすぐ近くでエラーが発生しますが、
もし、barメソッドがもらった引数をまた別の関数に渡して、その関数が..と呼び出しの連鎖が長くなってくると、
実行時のエラーを検知した際に、どこからそのような値が侵入してきてしまったかを把握することは困難になってしまいます。
このような場合にもdry-structで適切にインタフェースを宣言してあれば、想定外の値を渡してしまっていることはユースケースを呼び出すための
パラメータを作成した段階で実行エラーとして検出されるようになり、パッケージ内部ではその心配をする必要性が減ります。
このように実行時の動的な型検査によって入り口の段階で型を一定保証することによって、パッケージ内部の型シグネチャではユースケースから連鎖的に呼び出される処理の間で渡される型について静的に型検査を行っておけば内部の呼び出し関係の型にある程度自信を持てるようになります。
このように実際の運用では、実行時の動的な型検査と、静的な型検査の役割の違いはきちんと意識して使い分ける必要があります。
RBS-Inlineへの段階的移行とそのメリット
上記のようなメリットもあり、RBSの導入を進めていますが、さらにRBSでの普段の開発をスムーズにするために
rbs-inlineについても導入をすすめています。
もともと、チームでRBSを導入をすすめるなかで
- コードレビューの際に、RBSとRubyコードの一致を確認することが難しい
- 手作業による型シグネチャの記述の場合に、RBS側にだけ残ってしまっている場合に検査で発見できない
など型シグネチャとRubyコードの両方を同時に編集するなかでのミスも発生していました。
前者についてはRubyMineでの型シグネチャへのジャンプ機能なども進歩してきたためスムーズに行えるようになりました
後者については、リファクタリングの過程でのミスなどによって削除したはずのメソッドを引き続き呼び出してしまっている
処理が存在するものの、型シグネチャの方からの記述の削除漏れも同時に発生してしまうことによって型検査でも検出されなかったりする場面もありました。最終的にはCIでのテストによって発見されるものの、もっと開発者がコードでの機能開発に集中しながら型での恩恵も受けやすくしたいと思っていました。
そんななかで、rbs-inlineを導入することでRubyコード側のコメントと完全に同期した形でRBSが生成することが可能になり
開発者も開発中にわかりやすく、またコードレビューでも無意味に浮いたコメントであれば気がつきやすいのでより導入がしやすくなりました。
dry-structにも型をつけておく
上記でも触れましたがdry-structで定義してあるオブジェクトにも静的型検査用の型シグネチャをrbs-inlineのコメントとしてつけておく必要があります。
以下では、いくつかのパターンに触れて、紹介しておきます。
まずは、メインであるattribute関連の型付けです
# frozen_string_literal: true
# rbs_inline: enabled
module Optimization
module Types
class FooConfiguration < StructBase
# @rbs! attr_reader optimization_duration_in_seconds: Integer
attribute :optimization_duration_in_seconds, Types::Coercible::Integer
# @rbs! attr_reader vehicle_speed_km_per_hour: Float
attribute :vehicle_speed_km_per_hour, Types::Coercible::Float
# @rbs! attr_reader clustering_equal_coordinates_service_jobs: bool
attribute :clustering_equal_coordinates_service_jobs, Types::Bool
# @rbs! attr_reader ttl: Time | nil
attribute? :ttl, Types::Params::Time
end
end
end
たとえば、このようにdry-structが独自で提供している attribute
は当然のことながらrbs-inlineでは認識されないので、 @rbs!
という埋め込み構文をコメントとしてつけておくことで、このオブジェクトからInteger型などでアットリビュートを取り出すことができることを表現しています。
また、すこしトリッキーな例としては、
# frozen_string_literal: true
# rbs_inline: enabled
module Optimization
module Types
# @rbs!
# type batchAssignmentServiceJobsStatus = "IN_PROGRESS" | "COMPLETED"
# @rbs skip
BatchAssignmentServiceJobsStatus = Types::String.enum('IN_PROGRESS', 'COMPLETED')
end
end
このように、dry-struct側では、独自の型を表現として大文字から始まる形で宣言ができるのにたいして、同等の型宣言はRBSでは小文字からしか開始できないことなどの制限もあるため
適宜、もとの記述を @rbs skip
でスキップしてもらい、先程と同様 @rbs!
による埋め込みで同様のリテラル型による等価な型シグネチャを出力するなどの工夫も必要になります。
rbs_railsを併用した運用方針
また、本記事では紹介しませんでしたが、rbs_rails
も併せて導入済みのためRailsの自動生成してくれるアソシエーション系のメソッドなども
適切に型がつくため、より一層開発中にIDEでメソッドの自動補完が適切に行われたり、カラムが空の場合を考慮してメソッドの命名や設計意図をどのように設定するかなど
静的な型検査によってより意識的に設計を考える良い機会になっています。
rbs-inline, rbs_railsを併用して、以下のような配置でRBSファイルを管理しています。
対象 | 対応方針 | 配置先 |
---|---|---|
ActiveRecordのメソッド | rbs_rails に一本化 | sig/rbs_rails/ 以下 |
ActiveRecordのカラムへのアクセサ | rbs_rails に一本化 | sig/rbs_rails/ 以下 |
rbs_inline をつけたファイルの sig | rbs_inline に一本化し、適宜 @rbs! を使う | 各パッケージの sig 以下、 および app/sig 以下 |
その他 | 可能であれば rbs_inline に段階的に移行していく | 各パッケージの sig 以下、 および app/sig 以下 |
今後の課題
本記事では、現在DeliveryXの開発で進めているRBSの導入を紹介いたしました。
テストによる試験などと同様に、静的な型チェックを進めることによってよりプロダクトの開発を自信をもって進めることが可能になり、
また、型という目線からプログラムやメソッドの設計を考える機会にもなり、よい設計の指針にもなっています。
今後は steep stats
を定期的に報告する仕組みなどを作成し、プロジェクト全体へのRBSの導入率などをチェックできるようにしたいと思っております。
Azitではサーバーサイドエンジニアを募集中です。ご興味をお持ちいただけましたら以下の採用サイトからご応募下さい!