新規開発の技術選定
株式会社MICINのプラットフォームチームでアプリケーションエンジニアをしている北村 光毅です。プラットフォームチームは2021年末に立ち上がり、2022年から本格稼働した新しいチームです。認証・決済・ビデオ通話など、MICIN内のプロダクトで共通して利用する機能をサービスとして切り出し、継続的な開発・改善を行っています。
今年1月にプラットフォームチームに加入するまで、私はオンライン診療事業部でリードエンジニアを担当していました。その具体的な役割は開発スケジュールの管理、システムのアーキテクチャ設計、技術選定です。この経験から、新規事業を立ち上げるにあたっての技術選定について紹介します。
※執筆時点では信頼関係がある一部の医療機関様にのみプロダクトを導入してもらっている状態です。システム機能に関して一部抽象的な表現を使う場合があります。
· プロダクト開発の課題
∘ 1つ目の課題
∘ 2つ目の課題
· 新規プロダクトの要件整理
· システム設計
∘ システムの機能と構成
· 技術選定
∘ データベースの技術選定
∘ 通信・インターフェースの技術選定
∘ モノレポ
· バックエンドの技術選定
∘ アプリケーションサーバの技術選定
∘ リアルタイムサーバの技術選定
· フロントエンドの技術選定
∘ フレームワークの技術選定
∘ スタイリング方法の技術選定
∘ GraphQLクライアントの技術選定
· インフラ構成
· 課題感は解決できたのか
· 開発者体験
∘ ドメイン名の変更
∘ コードレビュー
∘ フロントとサーバの繋ぎこみ
· まとめ
プロダクト開発の課題
MICINにおける従来のプロダクト開発には2つの課題がありました。これらの課題を解決する、アーキテクチャ設計と技術選定を考えます。
1つ目の課題
1つ目の課題は、似たようなエンドポイントが存在していることです。例えば、診察に関する画面が2つあり、画面ごとで表示させたいデータに差分があるとします。この場合、レスポンスの形は異なるが、非常に似通ったエンドポイントが2つ必要です。1つのエンドポイントで全ての属性を返すこともできますが、使わない属性のためにレスポンスが遅くなります。
2つ目の課題
2つ目の課題は、プロダクトの根幹を担う機能改修の場合、複数のレポジトリでPR(Pull Request)作成とレビューの必要があることです。MICINではバックエンド・フロントエンドでレポジトリを分けることが一般的です。そのため単一のプロダクトに対して2つ以上のレポジトリが存在します。たとえば、オンライン診療クロンでは5つのレポジトリがあります。
- 患者(モバイルアプリ)
- 患者(Webアプリ)
- 医療機関(Webアプリ)
- 社内管理画面(Webアプリ)
- 共通アプリケーションサーバ
1つの機能改修で5つのPRを作成することは少なくありません。その結果、開発・レビュー・デプロイ全ての工程において作業が煩雑になります。
新規プロダクトの要件整理
今回開発したプロダクトの基本的な要件は、以下の4つです。
- 医師と患者が使用するシステム。
- 一部リアルタイム処理が必要。この機能のレイテンシをなるべく小さくしたい。
- アクセスは医療機関の営業時間に集中。ピーク時間帯は8:00~18:00。
- 導入件数は1万件と仮定。MICINの既存サービスは累計1万件の医療機関(及び薬局)に導入済。
まず、蓄積されていくデータ量を検討します。医療機関が1時間あたりに行う診察を3件と仮定します。医療機関への導入件数は1万件と仮定したので、1時間あたり診察数は3万件です。医療機関の営業時間を8時間と仮定すると、システムを10年間運用した場合に必要なストレージ量は438GBです。
30K (回/hour) x 8(hour/day) x 365 (day/year) x 10(year) x 0.5 (KB/回) = 438.0 (GB)
一診察あたりのデータ量は0.5KBと仮定しました。まとめると次のようになります。
このような要件を前提としてシステム設計と技術選定を行いました。
システム設計
システム構成は以下の図のようにしました。
システムの機能と構成
新規プロダクトでは、MICINの共通アカウントを使って認証を行います。そのため、認証認可は既存の認可サーバに任せます。多くの機能は図の中央にあるApplication Server(アプリケーションサーバ)に実装しました。一方で、リアルタイム性が求められる機能に関してはReal Time Server(リアルタイムサーバ)として切り出しました。図にあるサーバAとサーバBは他のチームがメンテナンスをしているので、技術選定の対象外とします。
技術選定
データベースの技術選定
データベースの選定は、データ量、パフォーマンス、整合性の観点から行いました。アプリケーションサーバのデータベースにはリレーショナルデータベース(RDB)を使いました。システムを10年間運用した場合に想定されるデータ量は数百GBです。そのため、1つのサーバのストレージ上でデータを保持できます。つまり、データ容量観点でデータベースのパーティションは必要ありません。AWS RDSであれば、最大64TBまで保存できます。
また、サービスの性質上SNSやソーシャルゲームのようにDAU(Daily Active Users)が数100万を超える可能性は低いので、膨大なトラフィックが発生しません。そのため、パフォーマンス観点でデータベースをスケールアウトさせる必要もありません。ユーザ数増加に対してスケールアップで対応可能なのでNoSQLは選択しません。RDBでは正規化、結合、参照整合性により、堅牢なシステムを作ることができます。
リアルタイムサーバのデータベースにはNoSQLを使います。リアルタイムサーバでは連続的にデータベースに書き込みをします。RDBでは書き込み処理をスケールするのは難しいです。そこで、NoSQLを使います。確かに、RDBも水平パーティショニングをすることで書き込み処理の負荷分散を行うことができます。レプリケーションでは読み込み処理の負荷分散はできますが、書き込み処理の負荷分散はできません。
リアルタイムサーバで書き込むデータの構造は、シンプルで、トランザクションも必要ありません。そのため、パーティションを前提に設計されてるNoSQLを使います。
通信・インターフェースの技術選定
アプリケーションサーバとクライアントの通信にはGraphQLを使いました。これによって1つ目の課題を解決します。
クライアントとサーバ間の通信方法はRESTful APIが一般的です。RESTful APIはプロダクト開発の初期においてはよく機能します。一方、プロダクトが成長していくにつれクライアントの画面が増えていき、提供しなければならないエンドポイントが増えていきます。こうなると、データの構造が異なるエンドポイントが増えて、保守の工数が大きくなります。これの大きな原因はエンドポイントのレスポンスのスキーマが静的であることです。一方、GraphQLではクライアントがレスポンスのスキーマを動的に決定することができます。そのため、RESTful APIで生じたこの課題が起きにくいです。また、型定義とAPIリクエスト周りのクライアントコードとを生成することも可能です。これもGraphQLを使う大きなメリットの1つです。
アプリケーションサーバとリアルタイムサーバ間の通信はRESTful APIを使いました。サービス間通信で最も利用されるのはgRPCでしょう。今回のサービス間通信は極めて限定的で、インタフェースが変更される頻度も少ないです。また、これらのサービスは同一チーム内で管理され、他のチームから参照されません。そのため、インターフェースの変更に当たって各チームの合意を取る必要がありません。これらの理由によりgRPCでスキーマ定義するのはややオーバーヘッドと判断し、チームメンバー全員が馴染みのあるRESTful APIを使います。
モノレポ
今回の開発ではモノレポを採用しました。モノレポとは複数のプロジェクトを1つのレポジトリで管理する手法のことです。
これによって2つ目の課題感を解決します。レポジトリのルートからバックエンド、フロントエンド、ドキュメントの3つに分けました。
.
├── backend
├── docs
└── frontend
また、Bazelのようなビルドツールを入れていません。ビルド時間が極端に長くなることも考えにくく、導入コストが掛かるからです。つまり、テストとビルドはそれぞれのプロジェクトで独立して行われます。
インフラはTerraformを使ってコードで管理しました。このコードはモノレポには含めていません。理由は、権限の切り分けです。MICINの組織体制では、アプリケーションエンジニアがインフラの設計から作成まで担当します。一方でインフラ構成のレビューはSREチームの役割です。TerraformのapplyはCI上で実行、SREチームがレビューとマージを行います。
モノレポでは複数のプロジェクトが1つのレポジトリで管理されているため、大量にPRが作られます。そのため、PR一覧の視認性が悪くなります。そこで、CI上でファイルの差分からラベルをPRに自動でつけるようにしました。labelerというActionを使いました。
モノレポは1つのレポジトリでスキーマ管理できるので、GraphQとも相性が良いです。クライアントとサーバでレポジトリが異なると、それぞれでスキーマを同期させる必要があります。
バックエンドの技術選定
アプリケーションサーバの技術選定
プログラミング言語は、開発しやすさ、チームとの親和性、パフォーマンスの観点から行いました。
アプリケーションサーバの言語はRubyを使いました。今回アプリケーションサーバにおいて、サービスの性質上高い処理能力は要求されません。そのため、表現力が高くデバッグしやすいスクリプト言語を使ました。チームメンバーにRubyを得意としているエンジニアが多いのも理由の1つです。
データベースにRDBを使うことはすでに決めました。MICINの多くのサービスではPostgreSQLを使っています。今回のサービスはPostgreSQLで実現できるのでPostgreSQLを使いました。
今回はスクリプト言語を使いますが、静的解析も行いたいです。具体的にはIDE上で型チェック、コード補完機能、定義ジャンプ、エラーレポートなどです。Rubyのバージョン3.0から型定義と静的解析が公式でサポートされました。
Rubyの型のアプローチはTypeScriptに似ています。TypeScriptではJavaScriptとTypeScriptによる型定義を別々に書くことができます。Rubyもこれと同じやり方です。RBSという型定義言語を使い、Rubyファイルとは別にRBSファイルを作成します。拡張子は.rbsです。
RBSは次のようなシンタックスになります。RBSのレポジトリのREADMEより抜粋しました。Rubyのように見えますがRubyではありません。これは型定義言語です。
module ChatApp
VERSION: String
class User
attr_reader login: String
attr_reader email: String
def initialize: (login: String, email: String) -> void
end
class Bot
attr_reader name: String
attr_reader email: String
attr_reader owner: User
def initialize: (name: String, owner: User) -> void
end
class Message
attr_reader id: String
attr_reader string: String
attr_reader from: User | Bot # `|` means union types: `#from` can be `User` or `Bot`
attr_reader reply_to: Message? # `?` means optional type: `#reply_to` can be `nil`
def initialize: (from: User | Bot, string: String) -> void
def reply: (from: User | Bot, string: String) -> Message
end
class Channel
attr_reader name: String
attr_reader messages: Array[Message]
attr_reader users: Array[User]
attr_reader bots: Array[Bot]
def initialize: (name: String) -> void
def each_member: () { (User | Bot) -> void } -> void # `{` and `}` means block.
| () -> Enumerator[User | Bot, void] # Method can be overloaded.
end
end
Ruby公式の静的解析機はTypeProfです。非公式ではありますが、有名な物としてはSteepとSorbetがあります。SteepとSorbetは型定義を開発者が手書きする前提で設計です。一方、TypeProfは型定義ファイルを書かずに解析する設計です。ここら辺の詳しい話はRubyKaigi 2021で語られています。また、Sorbetで使われている型定義言語はRBSではなくRBIです。そのため、今回はSorbetは静的解析機の候補から外れます。
静的解析はIDE上で行います。MICINでは多くのエンジニアが使っているエディタはVSCodeです。TypeProfとSteep共にVSCodeの拡張機能があります。
TypeProfはサードパーティGemの型定義を読み込めませんでした(2021/11)。TypeProfは2020年にリリースされたばかりです。現在も積極的に機能実装がなされています。
サードパーティGemの型定義はgem_rbs_collectionという公式レポジトリで管理されています。これはTypeScriptのDefinitelyTypedと似たような位置付けになります。
一方、SteepはRailsとRBSのバージョンの依存関係の問題でうまく動作しませんでした。Railsで動的に定義されているメソッドの型はrbs_railsから生成しました。
また、一部型定義がされていないメソッドもあります。そのため、エラーが発生してCI上で機械的にチェックすることができません。以上のことを踏まえて、型の導入は現時点では見送りました。
リアルタイムサーバの技術選定
リアルタイムサーバではNode.jsを使いました。リアルタイムサーバでは平行処理をする必要があります。並行処理によく使われるのはGolang、Node.js、Elixirなどです。GolangとElixirはスレッドを使って並行処理を行います。正確には、Golangはスレッドではなくゴルーチンと呼ばれる軽量のスレッドです。一方、Node.jsはシングルスレッドで、排他制御を考える必要がありません。また、async と awaitを使って逐次処理のように並行処理を実装することができます。これにより、コードの可読性が高くなります。
データベースにパーティションを前提で設計されてるNoSQLを使うことはすでに選定しました。リアルタイムサーバのデータベースでにはDynamoDBを使いました。リアルタイムサーバでは連続的に書き込み処理を行います。そのため、書き込み処理に対してスケーラビリティを持つデータベースを使います。また、スケールアウトさせる際のクラスタ管理は煩雑なのでマネージドサービスであることが望ましいです。MICINではパブリッククラウドとして主にAWSを使っています。以上を踏まえると、Amazon Keyspaces(Cassandra)とDynamoDBが候補に上がります。今回はMICINで採用実績のあるDynamoDBを使いました。
フロントエンドの技術選定
フレームワークの技術選定
フロントエンドのフレームワークはNext.jsを使いました。MICINの一部のプロダクトはAngularを使っています。それ以外のプロダクトで使っているのはReactです。Angularはフルスタックなフレームワークで非常に便利です。具体的にはルーティング、HttpClient、DIなどもサポートしています。さらに、Angularの非同期通信ではRxJSを使います。そのため、堅牢ではありますが学習コストが高いです。今回の開発は大規模ではないのでReactを使います。ReactはJSXを使っているので、TypeScriptと組み合わせるとコンポーネントのプロパティを静的解析することができます。
CRA(Create React App)ではなくNext.jsを使う理由は、速度表示に関する機能が標準で組み込まれているからです。具体的には、コード分割、画像最適化、プレレンダリングです。他にも、ルーティング機能、webpackのカスタマイズ機能などNext.jsは提供しています。
スタイリング方法の技術選定
スタイリングにはstyled-componentsとstyled-systemを使いました。スタイリングの方法は大きく分けると3つあります。
- CSS Module
- CSS in JS
- CSS Framework
それぞれの代表的なツールは次のようなものがあります。
css-loaderのモジュール機能は将来的に非推奨です。
Next.jsはcss-loaderに依存しておらず、CSS Module機能はPostCSSを使い自前で実装しています。
そのため、使っても問題ないです。CSS ModuleはCSSのスコープを提供するのみです。つまり、デザインシステムやユーティリティクラスのサポートはしていません。ゆえに、CSS in JS またはCSSフレームワークを使います。
styled-componentsとstyled-systemを使うと、コンポーネントのプロパティでスタイリングすることができます。そのため、TypeScriptと組み合わせることで静的解析ができます。
GraphQLクライアントの技術選定
GraphQLクライアントにはApollo Clientを使いました。GraphQLクライアントはApollo ClientとRelayが有名です。GraphQLクライアントを使う主な理由は2つあります。
- レスポンスのキャッシュ
- スキーマからフロントエンドのコード自動生成
これらはどちらのツールを使っても実現できます。シンプルかつドキュメントが充実しており、導入しやすいのはApollo Clientです。そのため、Apollo Clientを採用します。GraphQL Code Generatorと組み合わせるとスキーマとクエリからTypeScriptの型とReact Hooksのコードを生成することができます。
インフラ構成
AWSを用いたインフラ構成は次のようにしました。
MICINではパブリッククラウドとして基本的にAWSを使っています。リアルタイムサーバは他のプロダクトからもアクセスされるので別のAWSアカウントに分けました。
コンテナオーケストレータにはECSを使いました。実行環境はFargateを使っており、サーバレスです。今回のプロダクトでは何十個のマイクロサービスを運用しないので、ECSでの運用コストは高くありません。そのため、EKSを使いませんでした。
Next.jsはAmplifyでデプロイしました。Next.jsのデプロイ先としてまず選択肢に上がるのはVercelです。Next.jsと開発元が同じであるため、Next.jsの最新バージョンへのサポートが早いです。しかし、権限管理はAWSに集約したいためAmplifyを選択しました。Amplifyも十分Next.jsの新しいバージョンを追従しています。Next.jsのバージョン11は2021/6/16にリリースされ、Amplifyは2021/8/10にバージョン11をサポートしました。
Next.jsには静的ファイルとしてイクスポートした後、WebサーバやCDNから配信する方法もあります。この方法では画像最適化機能が使えなくなります。AmplifyやVercelはサーバレスではありますが、裏側ではNode.jsが動いています。
課題感は解決できたのか
1つ目の課題はGraphQLを採用することで解決しました。GraphQLではクライアントがレスポンス形式を指定するのでレスポンスが異なるエンドポイントを作成する必要がなくなります。
2つ目の課題はモノレポを採用することで解決しました。複数のフロントエンドとバックエンドを跨ぐ改修の場合もPRを1つにすることができます。デプロイも各レポジトリから行う必要もなくなりました。MICINではリリースブランチにマージすることでリリースのワークフローが実行されます。
開発者体験
ドメイン名の変更
プロダクト開発終盤で、開発初期につけたドメインの名前が違うのではないかとエンジニア間で話題に上がりました。議論の結果、変更した方が良いという結論になりました。データベースのスキーマを変更する必要があるため、リリース前に変更することにしました。全てのコードが1つのレポジトリに集約されているため、ドメイン名の変更を変更漏れなくスムーズに行うことができました。
コードレビュー
モノレポによりフロントエンドとバックエンドを跨ぐ変更に関しても、1つのPRにすることができます。そのため、複数のPRやレポジトリを行ったり来たりしてコードレビューする必要がなくなりました。
また、レポジトリが一つにより、軽微な修正の場合にバックエンドとフロントエンドお互いに自分の担当外であっても積極的にPRを送り合いました。これもモノレポの良い影響だと考えています。
フロントとサーバの繋ぎこみ
RESTful APIの開発では、フロントエンドでレスポンスのパース、型定義、 APIリクエストのReact Hooksをエンドポイント毎に実装していました。GraphQL Code Generatorではこれらをスキーマとクエリから生成することができます。クライアントとサーバの繋ぎこみがスムーズに行うことができました。
まとめ
私が初めて技術選定したときは様々なドキュメントを読み込んだり、サンプルコードを書いてみたり試行錯誤をしました。OSSのコードも読むこともありました。その過程でたくさんの知識がつき、開発における全体像が見えるようになりました。
この記事が、これから新規開発をするエンジニアの方々の助けになれば幸いです。