Slide 1

Slide 1 text

アジェンダ 前半: GraphQLの話 後半: GraphQLでDDDを再考する話

Slide 2

Slide 2 text

GraphQLとは スキーマとクエリを定義するための言語 従来のWeb APIに代わってバックエンドとの通信を実現するためのFW 厳密にはGraphQLはWeb API上で動作する。 GraphQLエンドポイントと呼ばれる単一のWeb APIに対してGraphQLで書かれたクエリ を送信する。

Slide 3

Slide 3 text

GraphQLクラスの実装例 data class User( val id: Int, val name: String, val posts: List, ) data class Post( val id: Int, val message: String, )

Slide 4

Slide 4 text

GraphQLクラスから生成されたGraphQLのスキーマの例 type User { id: ID! name: String! posts: [Post!]! } type Post { id: ID! message: String! }

Slide 5

Slide 5 text

GraphQLクエリResolverの実装例 class UserQueryResolver(private val findUserByIdUsecase: FindUserByIdUsecase) { fun user(id: ID): User? { val request = FindUserByIdUsecase.Request(id = id.toDomainId()) return findUserByIdUsecase.execute(request) } }

Slide 6

Slide 6 text

GraphQLクエリの例 ここでは取得するユーザーのIDを指定していない。 $id はSQLのプレースホルダー ? のようなもので、実行時に値を埋め込む。 query { user(id: $id) { id name posts { id message } } # 同じリクエストに複数のクエリを含めることもできる }

Slide 7

Slide 7 text

GraphQLリクエストの例 クエリとは別に $id に渡す値を指定している。 { "query": "query { user(id: $id) { id name posts { id title } } }", "variables": { "id": 1 } }

Slide 8

Slide 8 text

GraphQLレスポンスの例 { "data": { "user": { "id": 1, "name": "John", "posts": [ { "id": 1, "message": "Hello, world!" }, { "id": 2, "message": "Goodbye, world!" } ] } } }

Slide 9

Slide 9 text

GraphQLのメリット 静的型付け言語である。 スキーマとクエリからTypeScriptの型定義を生成できるため、BEとFEが静的型付け 言語であればBEからFEまで型安全に開発できる。 BEで定義したモデルからスキーマを生成し、スキーマとクエリからFEの型定義を生 成するのが一般的。 単一のHTTPリクエストで複数のクエリをバッチ実行できるため、HTTPリクエスト生成 のオーバーヘッドを抑えられる。 クエリごとに取得するフィールドを指定でき、オーバーフェッチを防げる。 クエリをネストすることができる。(後述) スキーマにドキュメントを記述できる。Swaggerのようにドキュメントツールを併用し なくてよい。

Slide 10

Slide 10 text

GraphQLのメリット Subscriptionを使ってBEとFE間で双方向通信ができる。(詳しくないので割愛) Tree-sitterパーサーやGraphQL Playground, LSP準拠のGraphQL言語サーバーなど、開 発環境が充実している。 Data Loader (後述)

Slide 11

Slide 11 text

GraphQLのデメリット 一般的にGraphQLはWeb APIより通信量が多くなりがちである。 なぜならGraphQLがWeb APIの上に構築されるため。 APQ (Advanced Persistent Queries) というRPC機能で解決できるが、GraphQLで はなくFW依存の機能である。 単一のHTTPリクエストで複数のクエリをバッチ実行できてしまうため、BE負荷の制御 が難しい。 REST APIでは1リクエスト毎に1クエリであるため、L3でもリクエストレートを制 限することで負荷を抑えることができる。 GraphQLではリクエストボディに含まれるクエリを解析しなければ負荷量が分から ないため、L7で負荷を制御する必要がある。(後述)

Slide 12

Slide 12 text

取得するフィールドの指定 GraphQLではクエリごとに取得するフィールドを指定できる。 type User { id: ID! name: String! posts: [Post!]! } type Post { id: ID! message: String! }

Slide 13

Slide 13 text

取得するフィールドの指定 query { user(id: 1) { id name } }

Slide 14

Slide 14 text

取得するフィールドの指定 { "data": { "user": { "id": 1, "name": "John" // posts は取得されない } } }

Slide 15

Slide 15 text

フィールドの指定 query { user(id: 1) { id name # レスポンスに posts を含める posts { id message } } }

Slide 16

Slide 16 text

フィールドの指定 { "data": { "user": { "id": 1, "name": "John", // posts が取得される "posts": [ { "id": 1, "message": "Hello, world!" }, { "id": 2, "message": "Goodbye, world!" } ] } } }

Slide 17

Slide 17 text

クエリのネスト GraphQLはクエリをネストすることができる。 data class User( val id: ID, val name: String, ) { fun posts(dfe: DataFetchingEnvironment, limit: Int): CompletableFuture> { // posts の取得処理 } }

Slide 18

Slide 18 text

クエリのネスト query { user(id: 1) { id name posts(limit: 10) { id message } } }

Slide 19

Slide 19 text

クエリのネスト GraphQLの構造体型が同じ型をフィールドにもつとクエリを無限にネストできる。 type Post { id: ID! content: String! replies: [Post!]! }

Slide 20

Slide 20 text

クエリのネスト curl などで直接GraphQLエンドポイントに深いネストを含むクエリを送信することで、ク ライアントは高負荷のHTTPリクエストを作成できてしまう。 query { post(id: $id) { id content replies { id content replies { id content replies { # ... } } } } }

Slide 21

Slide 21 text

エラー処理 クエリの実行中にエラーが発生してもGraphQLエンドポイントは 200 OK を返す。 なぜならGraphQLのクエリはバッチ実行するため、一部のクエリでエラーが発生し ても他のクエリは正常終了する可能性があるため。 エラーを返す場合は errors フィールドにエラー情報を格納する。 GraphQLクエリに構文エラーがあった場合も errors フィールドにエラー情報を格納 する。

Slide 22

Slide 22 text

エラー処理 { "errors": [ { "message": "Syntax Error: Expected Name, found .", "locations": [ { "line": 1, "column": 26 } ] } ] }

Slide 23

Slide 23 text

エラー処理 ドメイン例外の処理にGraphQL標準のエラー処理を使うのは好ましくないと考えている errors フィールドはランタイムエラーも格納されるため、FEで処理すべきエラー とその他を区別する必要がありコードが冗長になる。 ドメインエラーをランタイムエラーと同じ errors フィールドで返すと、FE開発 者にクエリがドメインエラーを吐きうるという情報が型情報から伝わりにくく、最 悪ドメインエラーが捨てられる。

Slide 24

Slide 24 text

エラー処理 静的型でHaskellの Either , あるいはRustの Result のようなtyped errorを実装して 使うのがよい。 しかし、投げうるドメイン例外全てをUnionで列挙するのは非常に面倒。 最低限のエラーメッセージをラップしただけのtyped errorでも、無意識にドメイン 例外が捨てられる事態を軽減できる (はず)。

Slide 25

Slide 25 text

エラー処理 data class Error(val message: String) data class Result(val value: T?, val error: Error?) { companion object { fun success(value: T): Result = Result(value, null) fun failure(error: Error): Result = Result(null, error) } } Result.success(user) Result.failure(Error("User not found"))

Slide 26

Slide 26 text

エラー処理 GraphQLスキーマにはジェネリクス型がないため、型引数ごとに型が定義される。 type Error { message: String! } type UserResult { value: User error: Error }

Slide 27

Slide 27 text

セキュリティ 攻撃者は次の方法でGraphQLリクエスト当たりのBEの負荷量を大きくできる。 バッチ実行するクエリを多くする ネストを深くする など... そのため、リクエストレートを制限するだけの負荷対策は不十分であり、次のような負荷対 策が必要である。 バッチ実行するクエリの数を制限する ネストの深さを制限する クエリの数とネストの深さから算出されるcomplexity (複雑性) を制限する (ハイブリッ ド)

Slide 28

Slide 28 text

Rethink DDD 基本的にGraphQLはプレゼンテーション層の関心事 プレゼンテーション層の関心事はドメインモデルに影響を与えない

Slide 29

Slide 29 text

Rethink DDD 「プレゼンテーション層の関心事はドメインモデルに影響を与えない」 そう思ってたいた時期が俺にもありました。 現実は異なる ドメイン層はデータフローの中間に位置するため、プレゼンテーション層のビュー モデル設計はドメイン層に影響を与えうる。 よく見るドメイン層が最上位だったり同心円の中心に位置する図はあくまで依 存の方向を表したもの。 抽象化とパフォーマンスのトレードオフ 例: 複数テーブルに跨るデータをどこで結合するか

Slide 30

Slide 30 text

複数テーブルに跨るデータの取得 1. BEでモデルを結合 2. SQLでテーブル結合 結合パターンごとにエンティティクラスを使い分ける 全ての結合パターンに対応できるエンティティクラスを定義する 常に全てのテーブル結合を行う テーブル結合を行わずに取得しなかったフィールドを null とする 3. CQRSによる読み込み系/更新系でモデルの分離

Slide 31

Slide 31 text

BEでモデルを結合 one to manyで結合する場合はほぼBEでモデルを結合することになる。 実装するとすればドメイン層 or ユースケース層 ドメイン操作に関係のない、ただプレゼンテーション層に流したいだけのデータの 結合処理がドメイン層 or ユースケース層に入り込むことになり冗長に。 あるいは取得〜結合までをやってくれる Service が乱立し、モデルベースから遠 ざかってゆく。 N+1問題を意識して書く必要がある

Slide 32

Slide 32 text

BEでモデルを結合 data class User( val id: Int, val name: String, ) data class Profile( val id: Int, val userId: Int, val bio: String, )

Slide 33

Slide 33 text

BEでモデルを結合 val users = userRepository.findByIds(userIds) val profileByUserId = profileRepository.findByUserIds(userIds).associateBy { it.userId } users.map { user -> // ハッシュマップからの取得は O(1) の償却コストで行える val profile = profileByUserId[user.id] checkNotNull(profile) { " ユーザーのプロフィールが存在しません" } // 何らかの処理 }

Slide 34

Slide 34 text

結合パターンごとにエンティティクラスを使い分ける 結合パターン数に依ってはエンティティクラスの数が組み合わせ爆発を起こす。 data class User(val id: Int, val name: String) data class Profile(val id: Int, val userId: Int, val bio: String) data class Post(val id: Int, val userId: Int, val message: String) data class UserWithProfile(val user: User, val profile: Profile) data class UserWithPosts(val user: User, val posts: List) data class UserWithProfileAndPosts(val user: User, val profile: Profile, val posts: List) // ...

Slide 35

Slide 35 text

全ての結合パターンに対応できるエンティティクラスを定義する 1. 常に全てのテーブル結合を行う。 ユースケースに依らず全てのテーブル結合を行うため、パフォーマンスは最悪。 Repository の更新系メソッドを呼び出すときには全てのテーブルを更新する必要 がある。 読み込み系と更新系でモデルを分ける方法 (CQRS) もあるが、銀の弾丸ではな い。(後述) 2. テーブル結合を行わずに取得しなかったフィールドを null とする 論理設計レベルでnullableであるフィールドと区別しにくい。 静的なnull安全でないため、使う側のコードで null チェックやエラー処理などの 防御的プログラミングが必要になり、コードが冗長に。

Slide 36

Slide 36 text

CQRSによる読み込み系/更新系でモデルの分離 参照系/更新系でモデルを分離するのはモデルベースであることを犠牲にしている。 もっとも、モデルベースで実装することが現実的に難しい処理は確実に存在する。 例: モデルベースでユーザーIDの重複チェックを実装するために全ユーザーを DBからフェッチするのか、など。 CQRSはモデルベースでの実装が難しい箇所に限定して使うのがよい。 CQRSパターンを採用したからと言って、全ての実装がCQRSである必要は全くな い。

Slide 37

Slide 37 text

取得方法の比較 取得方法 モデルベ ース パフォーマ ンス コードの簡 潔さ BEでモデルを結合 ○ ○ ✗ テーブル結合パターンごとにエンティティクラス を定義 ✗ ◎ ○ 常に全てのテーブル結合を行う ◎ ✗ ○ テーブル結合を行わずに取得しなかったフィール ドを null とする ○ ◎ ✗ CQRSによる読み込み系/更新系でモデルの分離 ✗ ◎ ○

Slide 38

Slide 38 text

抽象化とパフォーマンスはしばしばトレードオフ

Slide 39

Slide 39 text

Data Loader BEのプレゼンテーション層でデータを結合するためのGraphQLの機能 Data LoaderはCQRSにおけるQueryモデルをプレゼンテーション層に書くためのフ レームワークと言える。 スケジューラーによる遅延バッチ処理と並列処理 遅延バッチ処理によりN+1を意識せずに直感的に書ける。 並列処理によりレスポンスタイムの劣化を最小限に抑える。

Slide 40

Slide 40 text

Data Loader プレゼンテーション層から呼び出せるため、ビュー都合でドメイン層を汚染しない。 FEが必要としないデータはフィールドを省けばData Loaderは実行されない。 FEの型定義はスキーマとクエリから生成されるため、静的なnull安全が手に入る。

Slide 41

Slide 41 text

Data Loaderを呼び出すフィールドの実装例 data class User( val id: ID, val name: String, ) { fun posts(dfe: DataFetchEnvironment): CompletableFuture> { return dfe.getValueFromDataLoader("PostsByUserIdDataLoader", id) } }

Slide 42

Slide 42 text

Data Loaderの実装例 User#posts では単一のユーザーIDをData Loaderに渡しているのに対し、Data Loaderは複数のユーザーIDを処理しているのに注目。 Data Loaderのスケジューラーが遅延バッチ処理により複数のユーザーIDをまとめ て処理しているため。これによりN+1を意識せずに直感的に書ける。 そのため、IDからエンティティの取得を行う Repository はバッチ取得に対応さ せておくのがおすすめ。 class PostsByUserIdDataLoader(private val postRepository: PostRepository) : KotlinDataLoader> { override val dataLoaderName = "PostsByUserIdDataLoader" override suspend fun getDataLoader() = DataLoader> { userIds -> CompletableFuture.supplyAsync { val postsByUserId = postRepository.findByUserIds(userIds).associateBy { it.userId } userIds.map { userId -> postsByUserId[userId] ?: listOf() } } } }

Slide 43

Slide 43 text

Data Loaderを呼び出す導出フィールドの実装例 非同期処理をチェーンするだけで導出フィールドを実装できる。 posts と postCount の両方を取得しても、 posts の取得処理は1回しか実行されな い。 data class User( // ... ) { fun posts(dfe: DataFetchEnvironment): CompletableFuture> { return dfe.getValueFromDataLoader("PostsByUserIdDataLoader", id) } fun postCount(dfe: DataFetchEnvironment): CompletableFuture { return posts.thenApply { it.size } } }

Slide 44

Slide 44 text

Data Loaderもまた銀の弾丸ではない プレゼンテーション層でデータを結合するため、ドメインロジックでも結合データを参 照したい場合は使えない。 結局BE側でデータを結合していることに変わりはないため、SQLのテーブル結合のパフ ォーマンスには及ばない。(と予測)

Slide 45

Slide 45 text

Data Loaderを使う条件まとめ 以下の条件を全て満たす場合はData Loaderを使うのがよい。 結合したいデータはユースケースに依っては結合が不要である。 結合したいデータはドメインロジックで参照されない。

Slide 46

Slide 46 text

次回予告 GraphQL KotlinでRelay準拠のConnectionの実装をする話 NixとNixOSの原理 のいずれか。

Slide 47

Slide 47 text

End