Upgrade to Pro — share decks privately, control downloads, hide ads and more …

GraphQLとDDDの再考

 GraphQLとDDDの再考

Seong Yong-ju

December 01, 2023
Tweet

More Decks by Seong Yong-ju

Other Decks in Programming

Transcript

  1. GraphQLクラスの実装例 data class User( val id: Int, val name: String,

    val posts: List<Post>, ) data class Post( val id: Int, val message: String, )
  2. GraphQLクエリResolverの実装例 class UserQueryResolver(private val findUserByIdUsecase: FindUserByIdUsecase) { fun user(id: ID):

    User? { val request = FindUserByIdUsecase.Request(id = id.toDomainId()) return findUserByIdUsecase.execute(request) } }
  3. GraphQLレスポンスの例 { "data": { "user": { "id": 1, "name": "John",

    "posts": [ { "id": 1, "message": "Hello, world!" }, { "id": 2, "message": "Goodbye, world!" } ] } } }
  4. GraphQLのデメリット 一般的にGraphQLはWeb APIより通信量が多くなりがちである。 なぜならGraphQLがWeb APIの上に構築されるため。 APQ (Advanced Persistent Queries) というRPC機能で解決できるが、GraphQLで

    はなくFW依存の機能である。 単一のHTTPリクエストで複数のクエリをバッチ実行できてしまうため、BE負荷の制御 が難しい。 REST APIでは1リクエスト毎に1クエリであるため、L3でもリクエストレートを制 限することで負荷を抑えることができる。 GraphQLではリクエストボディに含まれるクエリを解析しなければ負荷量が分から ないため、L7で負荷を制御する必要がある。(後述)
  5. フィールドの指定 { "data": { "user": { "id": 1, "name": "John",

    // posts が取得される "posts": [ { "id": 1, "message": "Hello, world!" }, { "id": 2, "message": "Goodbye, world!" } ] } } }
  6. クエリのネスト GraphQLはクエリをネストすることができる。 data class User( val id: ID, val name:

    String, ) { fun posts(dfe: DataFetchingEnvironment, limit: Int): CompletableFuture<List<Post>> { // posts の取得処理 } }
  7. エラー処理 { "errors": [ { "message": "Syntax Error: Expected Name,

    found <EOF>.", "locations": [ { "line": 1, "column": 26 } ] } ] }
  8. エラー処理 静的型でHaskellの Either , あるいはRustの Result のようなtyped errorを実装して 使うのがよい。 しかし、投げうるドメイン例外全てをUnionで列挙するのは非常に面倒。

    最低限のエラーメッセージをラップしただけのtyped errorでも、無意識にドメイン 例外が捨てられる事態を軽減できる (はず)。
  9. エラー処理 data class Error(val message: String) data class Result<out T>(val

    value: T?, val error: Error?) { companion object { fun <T> success(value: T): Result<T> = Result(value, null) fun <T> failure(error: Error): Result<T> = Result(null, error) } } Result.success(user) Result<User>.failure(Error("User not found"))
  10. BEでモデルを結合 one to manyで結合する場合はほぼBEでモデルを結合することになる。 実装するとすればドメイン層 or ユースケース層 ドメイン操作に関係のない、ただプレゼンテーション層に流したいだけのデータの 結合処理がドメイン層 or

    ユースケース層に入り込むことになり冗長に。 あるいは取得〜結合までをやってくれる Service が乱立し、モデルベースから遠 ざかってゆく。 N+1問題を意識して書く必要がある
  11. BEでモデルを結合 data class User( val id: Int, val name: String,

    ) data class Profile( val id: Int, val userId: Int, val bio: String, )
  12. 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) { " ユーザーのプロフィールが存在しません" } // 何らかの処理 }
  13. 結合パターンごとにエンティティクラスを使い分ける 結合パターン数に依ってはエンティティクラスの数が組み合わせ爆発を起こす。 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<Post>) data class UserWithProfileAndPosts(val user: User, val profile: Profile, val posts: List<Post>) // ...
  14. 全ての結合パターンに対応できるエンティティクラスを定義する 1. 常に全てのテーブル結合を行う。 ユースケースに依らず全てのテーブル結合を行うため、パフォーマンスは最悪。 Repository の更新系メソッドを呼び出すときには全てのテーブルを更新する必要 がある。 読み込み系と更新系でモデルを分ける方法 (CQRS) もあるが、銀の弾丸ではな

    い。(後述) 2. テーブル結合を行わずに取得しなかったフィールドを null とする 論理設計レベルでnullableであるフィールドと区別しにくい。 静的なnull安全でないため、使う側のコードで null チェックやエラー処理などの 防御的プログラミングが必要になり、コードが冗長に。
  15. 取得方法の比較 取得方法 モデルベ ース パフォーマ ンス コードの簡 潔さ BEでモデルを結合 ◦

    ◦ ✗ テーブル結合パターンごとにエンティティクラス を定義 ✗ ◎ ◦ 常に全てのテーブル結合を行う ◎ ✗ ◦ テーブル結合を行わずに取得しなかったフィール ドを null とする ◦ ◎ ✗ CQRSによる読み込み系/更新系でモデルの分離 ✗ ◎ ◦
  16. Data Loaderを呼び出すフィールドの実装例 data class User( val id: ID, val name:

    String, ) { fun posts(dfe: DataFetchEnvironment): CompletableFuture<List<Post>> { return dfe.getValueFromDataLoader("PostsByUserIdDataLoader", id) } }
  17. Data Loaderの実装例 User#posts では単一のユーザーIDをData Loaderに渡しているのに対し、Data Loaderは複数のユーザーIDを処理しているのに注目。 Data Loaderのスケジューラーが遅延バッチ処理により複数のユーザーIDをまとめ て処理しているため。これによりN+1を意識せずに直感的に書ける。 そのため、IDからエンティティの取得を行う

    Repository はバッチ取得に対応さ せておくのがおすすめ。 class PostsByUserIdDataLoader(private val postRepository: PostRepository) : KotlinDataLoader<ID, List<Post>> { override val dataLoaderName = "PostsByUserIdDataLoader" override suspend fun getDataLoader() = DataLoader<ID, List<Post>> { userIds -> CompletableFuture.supplyAsync { val postsByUserId = postRepository.findByUserIds(userIds).associateBy { it.userId } userIds.map { userId -> postsByUserId[userId] ?: listOf() } } } }
  18. Data Loaderを呼び出す導出フィールドの実装例 非同期処理をチェーンするだけで導出フィールドを実装できる。 posts と postCount の両方を取得しても、 posts の取得処理は1回しか実行されな い。

    data class User( // ... ) { fun posts(dfe: DataFetchEnvironment): CompletableFuture<List<Post>> { return dfe.getValueFromDataLoader("PostsByUserIdDataLoader", id) } fun postCount(dfe: DataFetchEnvironment): CompletableFuture<Int> { return posts.thenApply { it.size } } }
  19. End