TypeScriptによるGraphQLバックエンド開発 ──TypeScriptの型システムとデータフローに着目した宣言的プログラミング
アーカイブ動画
きっかけは、フロントエンドとバックエンドの技術的関心事のギャップ
株式会社 一休 執行役員 CTO
伊藤 直也氏
今回のイベントで、「RESZAIKO」のバックエンドチームでチャレンジした開発手法について語ってくれたのは、株式会社一休のCTOを務める伊藤直也氏だ。伊藤氏はまず、今回のTypeScriptでのGraphQLバックエンドを開発するに至った背景を、「フロントエンドとバックエンドの技術的関心事と開発スタイルのギャップ」だと切り出す。
例えば、フロントエンドがReactで開発した場合、Reactは小さな関数を組み合わせて宣言的に薄く書くことができる。
一方のバックエンドは、従来的な手法では、クラスをたくさん書き、レイヤーをまたぐデータのやりとりに伴いDTOでデータを詰め替え、interfaceを作って依存性の逆転を行うといったやや重厚なやり方で進めることが多い。
この両者を行き来して開発しているとメンタルモデルのギャップやコンテキストスイッチの負担も大きくなる。
「フロントエンドはどんどん新しい開発手法を取り入れているので、バックエンドにも応用できないかと考えました」(伊藤氏)
フロントエンドが進化している理由を、UIの表現がリッチ化するに伴い、複雑化するアプリケーションの状態を管理するために宣言的プログラミングが適しているとわかってきたからではないか、と伊藤氏は指摘する。
「サーバーのアプリケーションの状態は、ドメインモデルの状態を管理することに主眼が置かれますが、同じ状態管理であればフロントエンドのパラダイムと同じように考えることができるのではないかと思ったのが、今回の取り組みのきっかけです」(伊藤氏)
GraphQLバックエンドと相性の良いCQRS、Prismaを選定
本題に入る前に、まずGraphQLバックエンドとCQRSについて補足された。CQRSとは、コマンドクエリ責務分離原則(Command-Query Responsibility Segregation)を指す。実はGraphQL Queryとドメインモデルの「集約」はかみ合わせが非常に悪いため、その解決策の一つとして、このCQRSを選択した。
一休では、GraphQL上のクエリ (※Query ・・・ READ にあたる) で複雑な業務ロジックが走ることは少なく、多くの場合はデータベースをそのままGraphQLオブジェクトにすることが多い。途中で複雑なドメインロジックの計算が参照系で入ることはほとんどないという。
「CQRSに限らず、GraphQLのバックエンドを作るにあたって、我々が重宝しているのがPrismaというライブラリです。PrismaはORM(Object Relational Mapping)ですが、クエリビルダー+ プレーンなオブジェクトを返すデータアクセスライブラリだと考えています」(伊藤氏)
GraphQLのサーバーで、Prismaを使いながらCQRSを実践する概念が以下の図である。参照系は基本的にデータベースからPrismaのデータを取得。多くの場合ドメインレイヤーをはさまずに、GraphQLにデータを渡す。
一方、GraphQLのMutation (※書き込み系の処理 CREATE/UPDATE/DELETE に当たる)は更新系の処理なので、ドメインモデルを用意し、更新対象の不変条件を整合・永続化させるために、ドメインレイヤーが入る。
フロントエンドの主流は「宣言的プログラミング」の時代
Webアプリケーションバックエンドの実装方法を話す前に、伊藤氏は宣言的プログラミングについて、mizchi氏の記事を引用しながら解説した。
mizchi氏は、「現在におけるフロントエンドの主流は宣言的プログラミングであり、その代表例がフロントエンドはReact。さらに、宣言的プログラミングとは、時系列に基づいた状態の宣言とフレームワーク側が状態遷移を処理する概念である」と書いており、伊藤氏も同意している。
また、宣言的プログラミングのアーキテクチャを表している例として、フロントエンドのフレームワークライブラリ「Elm」も紹介された。
「レイヤーはReactと近く、書き味はかなり関数型寄りで、シンタックスはHaskellによく似ています」(伊藤氏)
Elmのアーキテクチャは、モデルの状態を遷移させる関数がアプリケーションの中心にある。ランタイムがイベントループを回しながら、例えば、非同期処理が終わった、画面が描画された、ユーザーのクリックがあったなどのイベントが発生するたび、その状態遷移関数を呼び出し、モデルの状態を遷移させる。その後また、ランタイムに処理が移る。
外界の世界とのやり取りなど副作用のある処理はランタイムに任せて、アプリケーション側は宣言的に、純粋関数で状態遷移と、それぞれの状態に応じて画面をどう描画したいか、それを宣言的に記述するのが Elm アーキテクチャだ。
「イベントループを回しながら状態遷移をさせ、フレームワークに委ねながらアプリケーションを開発していくと、複雑な状態を比較的シンプルに管理できます」(伊藤氏)
その状態性の簡素化のイベントループを直線的に書いてみたのが、以下の図である。
React や Elm では時系列に基づいた状態遷移をフレームワークが調停してくれるので、その状態を宣言的に書き、フレームワークに渡すことでアプリケーションが成立する。このプログラミングパラダイムが現在のフロントエンドの考え方だ。
バックエンドもTypeScriptで宣言的プログラミング
この関数型の状態遷移の概念を、バックエンドでも応用できないかと考えた伊藤氏。バックエンドにおけるドメインモデルの状態、ドメインモデルを遷移させるドメインイベントに焦点を当てていったと振り返る。
「例えば、一休の宿泊予約を例にドメインモデルを考えるとき、データ構造やクラスの実装、予約画面など、スタティック(静的)な構造に視点を与えるのではなく、予約というドメインイベントや状態遷移に着目しました」(伊藤氏)
つまり、宿泊の予約モデルは、予約を開始して、入力が完了するなどのドメインイベント、あるいは予約完了後もカード決済やキャンセル・宿泊済みなど、ドメインモデルが状態遷移していくたびドメインイベントが発生していると考えられる。
ドメインモデルの状態遷移の前後には入出力 (IO) がある。ユーザーからの入力として IO があって、それをきっかけにドメインモデルの状態遷移が起こり、ある状態に至ったところで画面に出力したり、データベースに永続化するというIOが発生する。
「これをさらに抽象化すると、IOがあって、間にモデルを状態遷移する関数があって、またIOがある・・・ということになる。抽象度を上げていくとフロントエンド、例えば先にみた Elm アーキテクチャと概念的にはかなり近いことをやっていることに気づきます」(伊藤氏)
このパラダイムをバックエンドに応用させ、実際の実装を進めていく。実装については、2018年に発売された書籍『Domain Modeling Made Functional』を参考に開発していった。関数型的な考え方でDDDをやってみたらどうなるかについて解説された書籍だが、その中で、いわゆるオニオンアーキテクチャについての関数型プログラミングでの捉え方についても書かれている。
「この書籍にもI/OとI/Oで状態遷移の関数をサンドイッチする考え方についても書かれており、それはオニオンアーキテクチャと同型だということを言っています。今まで自分たちがやってきた概念 (オニオンアーキテクチャ) と大きくは外れずに、新しいアーキテクチャを考えることができそうだと感じました」(伊藤氏)
『Domain Modeling Made Functional』を参考に関数型プログラミング、宣言的プログラミングで実装を進めて行くと TypeScriptの言語機能の中でも使う機能とあまり使わない機能がはっきりしてくるようだ。
●よく使う機能
- type / interface
- タグ付きユニオン(直和型)
- Result型
- カリー化
- 型のブランド化 ∔ コンパニオンオブジェクト
簡単なユースケースとして、アプリケーション内で「タグづけ」の機能を実現するため、タグエンティティを作成するドメインロジックを考えてみる。これを先ほどの状態遷移に着目してモデリングすると、初期状態のTagモデルとなる。ここでclassは作らず、状態遷移ごとに型で定義する宣言プログラミングのコードが紹介された。
伊藤氏いわく、状態を遷移することに値を確定していくのを型によって定義・宣言できる ため、記述量は多少増えるものの、非常に堅牢となる。堅牢になる理由は、直和型 (ユニオン) を使って状態ごとに型を作るやり方をしているからだ。
直和型を使って型定義を行うと、仕様上必要のない値の組み合わせを作り出すことなしに、オブジェクトを定義することができる。つまり、より厳密なオブジェクトの定義が可能になる。
続いては、状態遷移させる関数の出番である。ここでは、UnvalidatedTag (入力が未検証の状態) を型として受け取ったら、Validated (検証) が終わった後にValidated (検証済み) だとして、状態遷移させる関数の記述が紹介された。
まずは、Unvalidated、Validated、Createdというタグモデルの状態遷移を型で宣言。状態遷移の関数はそれぞれ状態遷移毎に用意する。関数の引数とレスポンス、返り値がそれぞれこの状態の型を表現している。
「ここまでで関数の型とモデルの型によって、時系列に伴う状態遷移を宣言的に記述することができていると思います」(伊藤氏)
Result型を用いたデータフロープログラミング
次に、個別に定義した状態遷移の関数を繋げるわけだが、計算は途中で失敗する可能性がある。例えば、ドメインモデルの事前条件を満たさないエラー、Validationのエラー、あるいは作ったタグが上限を超えてしまうなど、いろんなエラーが発生するかもしれない。
そこで、途中で失敗することも型で宣言し、フロントエンドのような単方向のデータフローを作りたいという観点からResult型を使うことを決めた。Result型はエラーと成功を型で表現できるだけでなく、計算を繋ぐことも可能となる。
「RustやHaskellにはResult型 (Either型) があるのですが、残念ながらTypeScriptには組み込みのResult型が存在しないので、サードパーティのライブラリを用いることにしました」(伊藤氏)
Result型で状態遷移関数を繋いで、一つのフローを作る。このフローを『Domain Modeling Made Functional』ではWorkFlow(ワークフロー)と表現している。
業務フローが1本のデータフローとなり、型でモデルの状態が宣言できるようになった。次は入力と出力を繋ぐ必要があるが、これもResult型を繋ぐことが可能だ。Result型を用いて、本来成功と失敗で分岐の発生する計算を1本道に合成することで、認知負荷を下げることができるという特徴もある。
Result型で失敗を表現すると、型をコンパイラレベルで検知することができるため、かなり堅牢にすることが可能となるのだ。例外による大域脱出とは異なり、エラーを無視して実装を進めることができないし、型で表現していない想定外のエラーが起きないことを保証することができる。この一連の流れは、データフロープログラミングとも呼ばれている。
●ユースケース:既存のエンティティの更新
新規作成のエンティティの場合、入力がシンプルであるため、入力そのものがドメインオブジェクトとなり、状態遷移してエンティティに変わっていく。しかし、既存のエンティティを更新する場合、データベースからエンティティを復元するケースやそのエンティティとは別に入力があるケースが発生する。
先ほどの状態遷移モデルで考えると、「なんだかゴチャつく」と伊藤氏は指摘する。
そこで、入力とドメインオブジェクトを一つにまとめた「コマンド」という型を作り、ワークフローの入力にする。
すると以下の図のように、Unvalidated CommandやValidated Commandを登場させることによって、ワークフローがシンプルな形になる。
Unvalidated CommandやValidated Commandで入力を受け取り、Resultを返してValidatedし、アップデートするというワークフローになるのだ。
セッションでは、GraphQLからInputが来てDBからエンティティを呼び出すコードの例も紹介された。状態遷移のモデルは、より複雑な枠組みになっても構造は変わらないため、基本的に上から下に単方向に流れているだけで難しいところはないと伊藤氏は語る。
「エラーを含む状態遷移が全部型で守られているので、堅牢であることも特徴です。実際に書いてみると守られている安心感がありました」(伊藤氏)
●ユースケース:ドメインロジックの途中でIOが発生する場合
ドメインロジックの途中でIOが発生する場合は、どうしたらいいのか。ここはセオリー通り、DI(Dependency Injection)することによって、WorkFlowにIOにまつわる型の混入を防ぎ、WorkFlow自体はIOに依存しない純粋な関数であることを維持する。
このときDIは、関数型プログラミングの技法を使って行う。具体的にはカリー化によって部分的適用された関数を渡すことで実現する。これは、『Domain Modeling Made Functional』でも提案されている手法だ。
また、DIでは解決できない複雑な業務フローの場合は、IOとIOの間にWorkFlowを二つ作ってサンドイッチにすると、今までの構造が保てるとのことなので、参考にしてほしい。
新しい開発手法にチャレンジして得られたことは?
最後に、フロントエンドとバックエンドのメンタルモデルを近づけることを目標にチャレンジしたプロジェクトの総括が語られた。
●フロントエンドとの比較
時系列に基づく状態遷移を宣言的に記述するという考え方については、バックエンドの方がエンティティが複雑なことが多いため、記述の書き味は違うものの、考えていることは一緒になったという実感があった。
一方で、ドメインイベントで状態遷移をさせる場合、ワークフローを実装する記述にまだまだフロントエンドでの感触との間に距離感があると伊藤氏。フロントエンドには、ReactやElmなどのフレームワークがあるが、バックエンドはそのようなフレームワークがなく、自前でResult型を使ってイベントとイベントを接続する実装をしているのが現状だと言う。
●従来のアーキテクチャとの差異について
オニオンアーキテクチャや Clean Architecture と比較すると、実は今回のやり方でも UseCase相当のWorkFlow、Repositoryパターン、コアドメインモデル、集約やエンティティ、バリューオブジェクト概念などが変わらず登場するので、大枠のアーキテクチャはあまり変わっていない。
だが、型での業務の状態やフローの宣言など、コンポーネントの中の実装パラダイムが大きく異なっており、データフロープログラミングによる単方向のデータフローには特徴がある。
また、基本的にはデータを(手続きとセットになったオブジェクト指向のオブジェクトではなく) データのまま扱っているので、レイヤーをまたぐところで型を合わせるためにデータの詰め替えを行うなどのコードが不要になり、全体的には記述量は減らすことができ、認知負荷も低くなった。途中解説したとおり、直和型によって仕様上ない状態を作らずに済むため、堅牢に書くことができることも大きなメリットである。
最後に、伊藤氏は今後もフロントエンドとバックエンドのパラダイムギャップを減らし、バックエンド開発でも宣言的プログラミングを行うなど、新たな開発スタイルに挑戦していきたいと語り、セッションをまとめた。
【Q&A】視聴者から寄せられた質問を紹介
Q&Aタイムでは、一休の新規事業のテックリード兼エンジニアリングマネージャーを務める所澤氏がファシリテーターとなり、参加者から寄せられた質問に答えるセッションが行われた。その中からいくつか紹介する。
Q.DBのトランザクションやロールバックはどのように抽象化しているか
伊藤:トランザクションは基本的にPrismaの作法に則っています。Prismaは基本は、ロングトランザクションを使わずにクエリをなるべく Prisma クエリでアトミックに記述することを推奨していて、アトミックに書くことさえできれば、あとは Prisma 側でよしなにトランザクションを発行してくれます。
どうしてもアトミックに書けないところは、無理して一貫性を保つのではなく結果整合性で考えたほうがいい、というのも Prisma のスタンスです。一応、インタラクティブトランザクションという、ロングトランザクションの仕組みは Prisma でも提供されています。
今回のアーキテクチャでインタラクティブトランザクションを使う場合、ワークフローの入力の手前でトランザクションを開始して、ワークフローの出力を Repository で永続化し、終えたところでトランザクションを終える、というような実装になると思います。
Q.TypeScriptで関数的なコードを書く場合のライブラリの選定について
伊藤:neverthrowはResult型を提供するだけのライブラリです。Result型相当を提供するライブラリは他にもあって、代表的なものに fp-ts があります。fp-ts はResult型以外にも様々な関数型プログラミング向けの機能を提供しています。私たちの場合、今回は関数型プログラミングをTypeScriptでやりたかったわけではなく、「宣言的に書く」ことを行いたかったこともあり、 利用するのは neverthrow のみに留めました。
TypeScript の本来の記述から大きく離れることは避けたかったので。この選択が良かったかどうかは、もうすこし時間が経たないとまだなんとも言えませんが、いまのところ困っていることはないです。
Q.TypeScriptを採用した理由は?
伊藤:今回はバックエンドをフロントエンドと同じ言語にしたかったからです。実際使ってみると、エコシステムが強力で、Prismaなど、安定したデータアクセスのライブラリがあることなどのメリットを感じました。
Q.GraphQLとRESTで行う場合の違いは何か
伊藤:大きな違いはユースケースの組み立てをどちら側でやるかです。GraphQLの場合、ユースケースの主導権はクライアント側が握っているし、RESTの場合は基本的にサーバー側が決めたユースケースでデータを返します。
ユースケースをどちらが主体で決めるほうが望ましいかで、どちらの API 形式が良いか決まってくるでしょう。なお、GraphQL は API だけでなく、フロントエンド向けの GraphQL ライブラリ (Apollo Client、Relay、Urql など) とセットになって真価を発揮します。フロントエンドの作り込みが重要な場合は、一考に値すると思います。
Q. データフロープログラミングを行う上で工夫したポイントは?
伊藤:大枠は通常のドメイン駆動開発と同じですが、このときの状態をオブジェクトに閉じ込めるのではなく、状態遷移ごとに1個ずつ型を定義します。一方で、コアドメインを用意してそこに型やふるまい (関数) を寄せていくのは一緒です。
Q. Result型の導入によるフロントエンドとバックエンドのギャップ解決効果について
伊藤:今回のプロジェクトチームは5人くらいと少人数だったので、新しいパラダイムを試してもあまり混乱はありませんでした。記述量も結果的にそれほど変わっていないので、不満はありません。これまで通りPythonやGoでオブジェクト指向で書く場合と比較したら、今回のやり方であればコンテキストスイッチは比較的少ないだろうと感じています。いまはまだ過渡期ですが、これから5年、10年かけてバックエンドの世界でも関数型の考え方がある程度浸透していくのではないでしょうか。