自己紹介

Takumi Karibe氏:「Kotlin製の業務WebアプリケーションをRustでリプレイス」というテーマで発表します。先ほど「Rust製の業務WebアプリケーションをRustでリプレイス」という話と、そのどさくさに紛れて「フロントエンドをリプレイス」という話もありましたが、今回もどさくさに紛れてKotlin製のWebアプリケーションをRustでリプレイスした話をします。

自己紹介です。Karibeと申します。2021年の9月に入社して半年と少し経ちました。現在はバックエンドエンジニアとして、受発注管理システムとパートナー連携システムのチームリードを行っています。

社内のプロダクトの紹介と構成

社内には「Klein」と呼ばれている受発注管理プロダクトと「SPP」と呼ばれている製造パートナー連携プロダクトがあります。今回は、SPPのリプレイスの話です。

(スライドを示して)SPPは、キャディからサプライパートナーへの発注情報を受注済み案件の一覧として見られたり、見積もりの依頼に回答すると受発注管理プロダクトのKleinでデータが連携することができます。

(スライドを示して)現在の構成はこのようになっています。いろいろ省略していますが、Kotlin製のGraphQLサーバーがあり、これはSPP-APIと呼ばれています。このSPP-APIが受発注管理システムであるRust製のKleinにgRPCで通信をしています。データベースにはPostgreSQLを採用しています。今回は、このSPP-APIのリプレイスの話です。

なぜリプレイスするのか?

(スライドを示して)まず「なぜリプレイスするのか」という話です。組織的な問題として、SPP-APIの初期設計者や、Kotlinに慣れた人が退職したり異動したりしてチームからいなくなってしまったことによって、新規の機能開発や運用の効率が非常に悪くなってしまいました。

また、技術的な負債として、受発注管理システムのKleinとSPPでステータスを重複して管理している部分が、分散トランザクション化してバグの温床になっていました。

また、現在は受発注管理システムのKleinをリプレイスしていて、ドメインのモデルがけっこう変わってしまったんです。

そのため、既存のモデルと同居させると確実にバグが多く埋め込まれてしまうことが目に見えてわかっていました。なので、Kleinのリプレイスのために工数を確保して開発を行っているこのタイミングでSPP-APIも変えるしかないとPdMに打診して、なんとかリプレイスにこぎつけました。

リプレイスの要件

どさくさに紛れているので、リプレイスの要件がいくつかあります。Kleinの受発注管理システムのリプレイス計画を破綻させないように、必ず納期を遵守すること、またSPPはUIをリプレイスせずに、バックエンドだけで現在の機能が使えるようにする必要があること。

同時並行で開発するため、仕様が固まっていない部分やモデリング最中のドメインがありますが、仕様を妄想しながら、また、「こういうレスポンスが返ってくるだろう」と意識しながらAPIの開発を行っています。これは仕様やモデルが確定するのを待つよりも、一定の手戻りを許容した開発を行った方が最終的に完成が早いという判断からです。

リプレイス中の構成は、スライドのようになっています。先ほどSPPのKotlin製のAPIがありましたが、新SPP-APIとしてRustでGraphQLサーバーを実装しているところです。こKleinとはgRPCで通信しています。

リプレイス後の構成は、KotlinのGraphQLサーバーと古いリプレイス前のKleinのRust製のサーバーがなくなっていくので、徐々に新SPP-APIに移行していくかたちになります。

Rustを採用した理由

ここまでRustに変更する話でしたが、そもそもなぜRustなのかという話をしたいと思います。Rustを採用した理由として、社内に開発実績や慣れている人が多く、KotlinよりもRustで実装したほうが楽だという事情がありました。また、社内にRustで実装しているチームも多く、人員の流動性が確保しやすいので、リソース調整しやすいこともメリットとして考えていました。

また、非常に短い納期で開発しなければならないので、コンパイラに品質の担保を手伝ってほしいこともあり、Rustの採用を後押ししていました。

本来のモチベーションである機能開発や運用効率を高める意味においても、Rustでコンパイラがより堅牢なドメインモデルを表現できるメリットをしっかり活かしていくことで、負債の返済だけでなく、複利の効く資産形成として攻めのリプレイスを考えています。

具体的に利用しているライブラリとして、GraphQLではasync-graphqlを採用しています。Webアプリケーションフレームワークとしては、tokioが開発しているaxumを使っています。ORMにはdieselを使っていて、gRPCにはtonicを採用しています。

設計・開発の考慮1:シンプルな設計を作る

設計においては、ベストプラクティスとしてクリーンアーキテクチャを採用しており、それに倣いつつ、若干あえて外している部分もあり、シンプルな設計を目指しています。

クリーンアーキテクチャに特に変わったところはなく、コントローラーからユースケースを呼び出してユースケースがドメインやドメインサービスを呼んだり、インフラ層に接続しにいったりするものになっています。名前はクリーンアーキテクチャだったりDDD(Domain Driven Design)が混ざっていたりしますが、おおよそ「レイヤードアーキテクチャ」です。

一部教科書的ではありませんが、外部接続を抽象化した存在を置かないようにしています。本来ユースケースは、例えばインフラ層のデータベースからエンティティを取得するのか、外部サービスから、(今回でいうと)Kleinからデータを取得するのかを意識せずに、リポジトリからget_〇〇みたいにエンティティを取ってこられることが理想的かもしれません。

しかし、今回はそれも抽象化せずに具体的にデータベースから取ってくる、外部サービスから取ってくることを明示的に書くような設計にしています。

実際にSPPでデータを持っているのか、それとも依存しているサービスのKleinでデータを持っているのか、障害が発生した時にわかりづらくなることがありました。そのため、データの所在をわかりやすく、実装上シンプルにしたかったこと、また納期が短かったのでレビューのコストを下げたかったからです。

スライドの右下に簡単なスニペットを書いています。例えばHogeUseCaseが、DbAdapterProviderとAServiceClientProviderという外部接続クライアントのアダプターにユースケースが依存しているという定義において、3行目にself.provide_db();でデータベースのコネクションを取ってきたり、self.provide_a_service_client();でクライアントのインターフェースを取ってきたりして、ユースケースが具体的にどの外部に接続するのかを明示的に記述しています。

このように書いてあると、「このユースケースでは、ビジネスロジックはデータベースとこのサービスに依存している」ということが、斜め読みでもわかります。これによってGitHub上でプルリクエストを見られる時も、斜め読みで簡単にわかります。

例えばGitHub上で、「このユースケースではデータベースしかアクセスしないはずなのに、なぜこのサービスに接続する必要があるのか」といった議論が簡単にできるようなメリットを今回は採用しました。

コメントアウトされていて見づらいかもしれませんが、self.provide_b_service_client();というものは、今回このユースケースのTraitではデータベースとAサービスクライアントにしか依存していないので、こういった呼び出し方を実装すると、コンパイルエラーになってしまう設計になっています。

斜め読みした時にどこに依存しているかがわかりやすいこともありますが、そもそもこのユースケースでは「この外部とこの外部のモジュールに依存している」ということが型で定義できるので、コンパイル時にも楽で、コードを読む時にも非常に優しい設計にしています。

設計・開発の考慮2:データの置き場所を整理

設計・開発の考慮2として、データの置き場所を整理しています。先ほど「SPPとKleinのどちらにもデータが存在してしまっているため分散トランザクションが発生している」という話をしましたが、今回はそのデータを一部Klein側に寄せることによって、分散トランザクションをうまく行うのではなく、そもそも分散トランザクションを避けるような設計をしています。

また、基本的に可逆的な変更の意思決定は後回しにして、とにかくアプリケーションとしてものを作っていくことを優先しています。これについて不可逆な変更や、例えば他の選択肢を選び直した場合にはコストがかなり高くなってしまう場合を除いて、意思決定の速度を優先する。とにかく実装して、アプリケーションとしてどんどん機能を追加することを優先しています。

具体的には、先ほどのRepositoryの設計として抽象化層を置かないという意思決定がいい例です。設計当初は抽象化層を置くかどうかで議論になりましたが、後になって抽象化層を置くのは実装コストが小さいため、まずは置かずにやってみてから後で考えようという判断をしました。

また、何かあった時にUnknown*Unknownな状況にならないように、意思決定時には他の選択肢のpros/consの整理をしています。AとBという選択肢があり、「今回はAで行きましょう」という場合にも、なぜBではないのか、Bにするには何が必要なのかをあらかじめ整理して考えて開発しています。

設計・開発の考慮3:依存先の開発状況を意識しない

設計・開発の考慮3として、依存先の開発状況を意識しない開発を行っています。先ほど話したリプレイスの要件として、Kleinのリプレイスと今回のSPPのリプレイスの納期は同じなので、現在進行形で互いに開発が進んでいる状況です。そのため、インフラ層で新しいKleinのドメインのモデリングや仕様を隠ぺいして、とにかくドメインロジックを先にしてどんどん実装しています。

また、プルリクエストで議論をするのではなく、細かく同期的なレビューで開発着手からマージまでのリードタイムを短縮しています。これについては何千行、何万行と書いてもmainブランチにマージされなければ意味がないので、とにかく開発着手からマージまでのリードタイムを短縮して、どんどんmainブランチにコードをマージすることを意識しています。

また、Rust的にもっといい書き方がある場合でも、何かしらその旨のコメントを書いて、とにかくマージを優先することを意識しています。

開発向上のためにRustに変更して得られた学び

まとめです。今回は開発向上のためにKotlinからRustに変更しました。また、同時並行のリプレイスの場合は、今のようにある程度不確実性を受け入れた意思決定や、手戻りとの付き合い方が非常に大事だということが今回の学びです。以上です。