Chapter 06

リゾルバの実装 - 応用編

さき(H.Saki)
さき(H.Saki)
2023.03.07に更新

この章について

「リゾルバの実装 - 基本編」にて実装を行った際には、GraphQLのスキーマで定義されたクエリ・ミューテーション1つに対してリゾルバメソッド1つが紐づいている状態でした。

  • userクエリ: *queryResolver型のUserメソッドを実行
  • repositoryクエリ: *queryResolver型のRepositoryメソッドを実行
  • nodeクエリ: *queryResolver型のNodeメソッドを実行
  • addProjectV2ItemByIdミューテーション: *mutationResolver型のAddProjectV2ItemByIDsメソッドを実行

この章ではリゾルバを分割することによって、この1:1対応を解消していきます。

リゾルバを分割する前の状況確認

まずは、リゾルバ分割を行っていない状況ではどのような挙動をしているのか、もう一度確認してみましょう。
応用編では、repositoryクエリを例にとって説明していきたいと思います。

repositoryクエリと得られるレスポンス型

repositoryクエリは、レスポンスとしてRepositoryオブジェクトを返すように定義されています。

schema.graphqls
type Query {
  repository(
    name: String!
    owner: String!
  ): Repository
}

そのRepositoryオブジェクトの中には、スカラ型のフィールドが3つ、非スカラ型(オブジェクト型)のフィールドが5つずつ存在しています。

  • スカラ型
    • id: ID
    • name: string
    • createdAt: DateTime
  • オブジェクト型
    • owner: Userオブジェクト
    • issue: Issueオブジェクト
    • issues: IssueConnectionオブジェクト
    • pullRequest: PullRequestオブジェクト
    • pullRequests: PullRequestConnectionオブジェクト
(再掲)GraphQLスキーマに定義されたRepositoryオブジェクト
schema.graphqls
type Repository implements Node {
  id: ID!
  owner: User!
  name: String!
  createdAt: DateTime!
  issue(
    number: Int!
  ): Issue
  issues(
    after: String
    before: String
    first: Int
    last: Int
  ): IssueConnection!
  pullRequest(
    number: Int!
  ): PullRequest
  pullRequests(
    after: String
    baseRefName: String
    before: String
    first: Int
    headRefName: String
    last: Int
  ): PullRequestConnection!
}

リゾルバ分割前に得られるレスポンス

repositoryクエリを実行して、得られるレポジトリ情報を全て表示させてみようと思います。
そのようなクエリは以下のような形になります。

query {
  repository(name: "repo1", owner: "hsaki"){
    id
    name
    createdAt
    owner {
      name
    }
    issue(number:1) {
      url
    }
    issues(first: 2) {
      nodes{
        title
      }
    }
    pullRequest(number:1) {
      baseRefName
      closed
      headRefName
    }
    pullRequests(last:2) {
      nodes{
        url
        number
      }
    }
  }
}

しかし、基礎編の内容に従って実装していくと、以下のような少しおかしなレスポンスが得られるかと思います。

{
  "data": {
    "repository": {
      "id": "REPO_1",
      "name": "repo1",
      "createdAt": "2023-01-09T22:11:47Z",
      "owner": {
        "name": "",
      },
      "issue": null,
      "issues": null,
      "pullRequest": null,
      "pullRequests": null
    }
  }
}

おかしなポイントは以下2つです。

  • 取得したレポジトリのオーナー名はhsakiであるはずなのに、レスポンスではowner.nameフィールドが空文字列になっており取得できていない
  • オブジェクト型に対応したフィールド(issue(s)pullRequest(s))がnullになっておりデータ取得できていない

repositoryクエリに1:1対応づけされたリゾルバメソッドの実装

どうしてこのようなクエリ実行結果になってしまうのか、原因を確認します。

現在、repositoryクエリを実行した際に呼び出されるリゾルバは一つだけです。

graph/schema.resolvers.go
// Repository is the resolver for the repository field.
func (r *queryResolver) Repository(ctx context.Context, name string, owner string) (*model.Repository, error) {
	// 1. ユーザー名からユーザーIDを取得するサービス層のメソッドを呼ぶ
	user, err := r.Srv.GetUserByName(ctx, owner)
	if err != nil {
		return nil, err
	}
	// 2. ユーザーIDとレポジトリ名から、レポジトリ詳細情報を取得するサービス層のメソッドを呼ぶ
	return r.Srv.GetRepoByFullName(ctx, user.ID, name)
}

レスポンスを作る際のキーとなる部分は、サービス層のGetRepoByFullNameメソッドを実行している部分です。
しかしこのGetRepoByFullNameメソッドの中で取得しているのは、DBに用意されたrepositoriesテーブル中のデータのみにしています。

graph/services/repositories.go
func (r *repoService) GetRepoByFullName(ctx context.Context, owner, name string) (*model.Repository, error) {
	repo, err := db.Repositories(
		qm.Select(
			db.RepositoryColumns.ID,         // レポジトリID 
			db.RepositoryColumns.Name,       // レポジトリ名
			db.RepositoryColumns.Owner,      // レポジトリを所有しているユーザーのID
			db.RepositoryColumns.CreatedAt,  // 作成日時
		),
		db.RepositoryWhere.Owner.EQ(owner),
		db.RepositoryWhere.Name.EQ(name),
	).One(ctx, r.exec)
	if err != nil {
		return nil, err
	}
	return convertRepository(repo), nil
}

func convertRepository(repo *db.Repository) *model.Repository {
	return &model.Repository{
		ID:        repo.ID,
		Owner:     &model.User{ID: repo.Owner},
		Name:      repo.Name,
		CreatedAt: repo.CreatedAt,
	}
}
サービス層の全コード(GraphQLのuserクエリ・repositoryクエリ実行に必要な部分)
graph/services/service.go
type UserService interface {
	GetUserByID(ctx context.Context, id string) (*model.User, error)
	GetUserByName(ctx context.Context, name string) (*model.User, error)
}

type RepoService interface {
	GetRepoByFullName(ctx context.Context, owner, name string) (*model.Repository, error)
}

type services struct {
	*userService
	*repoService
}

func New(exec boil.ContextExecutor) Services {
	return &services{
		userService:        &userService{exec: exec},
		repoService:        &repoService{exec: exec},
	}
}
graph/services/users.go
type userService struct {
	exec boil.ContextExecutor
}

func (u *userService) GetUserByName(ctx context.Context, name string) (*model.User, error) {
	user, err := db.Users(
		qm.Select(db.UserTableColumns.ID, db.UserTableColumns.Name),
		db.UserWhere.Name.EQ(name),
		// qm.Where("name = ?", name),
	).One(ctx, u.exec)
	if err != nil {
		return nil, err
	}
	return convertUser(user), nil
}
graph/services/repositories.go
type repoService struct {
	exec boil.ContextExecutor
}

func (r *repoService) GetRepoByFullName(ctx context.Context, owner, name string) (*model.Repository, error) {
	repo, err := db.Repositories(
		qm.Select(
			db.RepositoryColumns.ID,
			db.RepositoryColumns.Name,
			db.RepositoryColumns.Owner,
			db.RepositoryColumns.CreatedAt,
		),
		db.RepositoryWhere.Owner.EQ(owner),
		db.RepositoryWhere.Name.EQ(name),
	).One(ctx, r.exec)
	if err != nil {
		return nil, err
	}
	return convertRepository(repo), nil
}

func convertRepository(repo *db.Repository) *model.Repository {
	return &model.Repository{
		ID:        repo.ID,
		Owner:     &model.User{ID: repo.Owner},
		Name:      repo.Name,
		CreatedAt: repo.CreatedAt,
	}
}

本来ならばテーブルのjoinなどを行って、レポジトリに紐づいたIssueやPRの情報を取得するべきなのですがそれを行っていないため、DBのrepositoriesテーブル内にある情報しかレスポンスに含めることができないのです。

GraphQLのRepositoryオブジェクトのフィールド サービス層で取得し紐付けているデータ
id repositoriesテーブルのid
name repositoriesテーブルのname
createdAt repositoriesテーブルのcreated_at
owner N/A(オーナーとなるユーザーIDはrepositoriesテーブルのowner列から取れているが、それだけでは不足している)
issue N/A
issues N/A
pullRequest N/A
pullRequests N/A

リゾルバ分割の実装

リゾルバを分割していない今の状況ではどのような不具合があるのかを確認できたところで、いよいよ分割実装をしていきましょう。

gqlgen.ymlに分割設定を記述

リゾルバを分割する設定は、gqlgen.ymlに記述します。

gqlgen.yml
models:
+  Repository:
+    fields:
+      owner:
+        resolver: true
+      issue:
+        resolver: true
+      issues:
+        resolver: true
+      pullRequest:
+        resolver: true
+      pullRequests:
+        resolver: true

今回の分割の方針は「repositoryクエリを実行して得られたRepositoryオブジェクトの中で、おかしなことになっていたフィールドを切り出す」というもので、models.Repository.fields直下に今回の対象としたいフィールド名(owner/issue(s)/pullRequest(s))を列挙しています。

分割したリゾルバコードの生成

gqlgen.ymlにリゾルバ分割の設定を記述したら、その内容にしたがってコードを再生成させます。

$ gqlgen generate

すると、graph/schema.resolvers.goの中に以下のコードが増えていることが確認できるかと思います。
分割された子リゾルバ部分のコードは以下の通りです。

graph/schema.resolvers.go
type repositoryResolver struct{ *Resolver }

// Owner is the resolver for the owner field.
func (r *repositoryResolver) Owner(ctx context.Context, obj *model.Repository) (*model.User, error) {
	panic(fmt.Errorf("not implemented: Owner - owner"))
}

// Issue is the resolver for the issue field.
func (r *repositoryResolver) Issue(ctx context.Context, obj *model.Repository, number int) (*model.Issue, error) {
	panic(fmt.Errorf("not implemented: Issue - issue"))
}

// Issues is the resolver for the issues field.
func (r *repositoryResolver) Issues(ctx context.Context, obj *model.Repository, after *string, before *string, first *int, last *int) (*model.IssueConnection, error) {
	panic(fmt.Errorf("not implemented: Issues - issues"))
}

// PullRequest is the resolver for the pullRequest field.
func (r *repositoryResolver) PullRequest(ctx context.Context, obj *model.Repository, number int) (*model.PullRequest, error) {
	panic(fmt.Errorf("not implemented: PullRequest - pullRequest"))
}

// PullRequests is the resolver for the pullRequests field.
func (r *repositoryResolver) PullRequests(ctx context.Context, obj *model.Repository, after *string, baseRefName *string, before *string, first *int, headRefName *string, last *int) (*model.PullRequestConnection, error) {
	panic(fmt.Errorf("not implemented: PullRequests - pullRequests"))
}

新しくrepositoryResolver構造体が定義されて、その構造体のメソッドとしてOwner, Issue……などができています。
次はこの新規生成されたメソッドの中身を実装していくことになります。

メソッドの実装

その1 - Issueメソッド

メソッド内の処理

まずはIssueメソッドの中身を実装していきましょう。

graph/schema.resolvers.go
// Issue is the resolver for the issue field.
func (r *repositoryResolver) Issue(ctx context.Context, obj *model.Repository, number int) (*model.Issue, error) {
	panic(fmt.Errorf("not implemented: Issue - issue"))
}

このメソッドは以下のように、repositoryクエリを使って取得するRepositoryオブジェクトのissueフィールドにアクセスされたされたときに呼び出されるものです。

query {
  repository(name: "repo1", owner: "hsaki"){
    issue(number:1) {
      // ()
    }
  }
}

そのため、メソッドの中に実装するべき処理は「とあるレポジトリに属する、とある番号のIssue情報をDBから探してきて返り値にする」というものになります。
DBのissueテーブルにアクセスするサービス層メソッドを作成し、それをリゾルバの中から呼び出すようにしてあげましょう。

graph/schema.resolvers.go
// Issue is the resolver for the issue field.
func (r *repositoryResolver) Issue(ctx context.Context, obj *model.Repository, number int) (*model.Issue, error) {
-	panic(fmt.Errorf("not implemented: Issue - issue"))
+	// とあるレポジトリに属する、とある番号のIssue情報を取得
+	return r.Srv.GetIssueByRepoAndNumber(ctx, obj.ID, number)
}
`issue`テーブルにアクセスするサービス層の実装
graph/services/service.go
type UserService interface {
	GetUserByID(ctx context.Context, id string) (*model.User, error)
	GetUserByName(ctx context.Context, name string) (*model.User, error)
}

type RepoService interface {
	GetRepoByFullName(ctx context.Context, owner, name string) (*model.Repository, error)
}

type IssueService interface {
	GetIssueByRepoAndNumber(ctx context.Context, repoID string, number int) (*model.Issue, error)
}

type services struct {
	*userService
	*repoService
	*issueService
}

func New(exec boil.ContextExecutor) Services {
	return &services{
		userService:        &userService{exec: exec},
		repoService:        &repoService{exec: exec},
		issueService:       &issueService{exec: exec},
	}
}
graph/services/issues.go
type issueService struct {
	exec boil.ContextExecutor
}

func (i *issueService) GetIssueByRepoAndNumber(ctx context.Context, repoID string, number int) (*model.Issue, error) {
	issue, err := db.Issues(
		qm.Select(
			db.IssueColumns.ID,
			db.IssueColumns.URL,
			db.IssueColumns.Title,
			db.IssueColumns.Closed,
			db.IssueColumns.Number,
			db.IssueColumns.Author,
			db.IssueColumns.Repository,
		),
		db.IssueWhere.Repository.EQ(repoID),
		db.IssueWhere.Number.EQ(int64(number)),
	).One(ctx, i.exec)
	if err != nil {
		return nil, err
	}
	return convertIssue(issue), nil
}

func convertIssue(issue *db.Issue) *model.Issue {
	issueURL, err := model.UnmarshalURI(issue.URL)
	if err != nil {
		log.Println("invalid URI", issue.URL)
	}

	return &model.Issue{
		ID:         issue.ID,
		URL:        issueURL,
		Title:      issue.Title,
		Closed:     (issue.Closed == 1),
		Number:     int(issue.Number),
		Author:     &model.User{ID: issue.Author},
		Repository: &model.Repository{ID: issue.Repository},
	}
}

メソッドの引数として与えられている*model.Repository型について

特筆するべき点としては、このIssueメソッドの引数として*model.Repository型が与えられており、その中には取得対象となったレポジトリの情報(IDcreatedAtなど)が含まれています。
そのため、Issueメソッドの中でobj.IDを参照することで「検索対象となったレポジトリのID」を入手することができるのです。

リゾルバの呼び出し順

どうしてIssueメソッドの*model.Repository型引数にあらかじめレポジトリの情報が格納されていたのか、それは分割されたリゾルバの実行順が関わっています。

今回のように「repositoryクエリを使って取得するRepositoryオブジェクトのissueフィールドにアクセスする」場合のクエリをよく観察してみます。

query {
  repository(name: "repo1", owner: "hsaki"){
    issue(number:1) {
      // ()
    }
  }
}

すると、以下のような構造になっていることがお分かりいただけるかと思います。

  1. queryというワードによって、クエリ・ミューテーションと数あるGraphQLの操作の中でクエリを行いたいということが確定する
  2. repositoryというワードによって、クエリの中でもrepositoryクエリを実行したいということが確定する
  3. issueというワードによって、Repositoryオブジェクトの中でのissueフィールドが欲しいということが確定する

これは、そのままリゾルバを呼び出す順番にもなっているのです。

  1. ルートリゾルバ*Resolver型のQueryメソッドが呼ばれる
  2. リゾルバ*queryResolver型のRepositoryメソッドが呼ばれる
  3. リゾルバ*repositoryResolver型のIssueメソッドが呼ばれる
graph/schema.resolvers.go
// 1. 
// Query returns internal.QueryResolver implementation.
func (r *Resolver) Query() internal.QueryResolver { return &queryResolver{r} }

// 2.
// Repository is the resolver for the repository field.
func (r *queryResolver) Repository(ctx context.Context, name string, owner string) (*model.Repository, error) {
	// (ユーザー実装部分、略)
}

// 3.
// Issue is the resolver for the issue field.
func (r *repositoryResolver) Issue(ctx context.Context, obj *model.Repository, number int) (*model.Issue, error) {
	// (ユーザー実装部分、略)
}

そのため、

  1. リゾルバ*queryResolver型のRepositoryメソッドが呼ばれて、その過程でrepositoryテーブルから取得対象のレポジトリの情報を取得→*model.Repository型に格納
  2. 1で得た情報を引数にして、リゾルバ*repositoryResolver型のIssueメソッドを呼ぶ

という処理フローを作り上げることができるのです。

その2 - Ownerメソッド

Issueメソッドと同様の考え方で、Ownerメソッドも作っていきましょう。

graph/schema.resolvers.go
func (r *repositoryResolver) Owner(ctx context.Context, obj *model.Repository) (*model.User, error) {
	panic(fmt.Errorf("not implemented: Owner - owner"))
}

メソッド内の処理

Ownerメソッド内で実装するべき内容は「とあるレポジトリのオーナーとなっているユーザー情報を取得する」というものです。
オーナーとなっているユーザーIDは第二引数のobj.RepositoryOwner.IDフィールドに格納されているため、それを利用してusersテーブル内をselectすればOKです。

graph/schema.resolvers.go
// Owner is the resolver for the owner field.
func (r *repositoryResolver) Owner(ctx context.Context, obj *model.Repository) (*model.User, error) {
-	panic(fmt.Errorf("not implemented: Owner - owner"))
+	return r.Srv.GetUserByID(ctx, obj.Owner.ID)
}
サービス層のGetUserByIDメソッドの実装
graph/services/service.go
type UserService interface {
+	GetUserByID(ctx context.Context, id string) (*model.User, error)
	GetUserByName(ctx context.Context, name string) (*model.User, error)
}
graph/services/users.go
+func (u *userService) GetUserByID(ctx context.Context, id string) (*model.User, error) {
+	user, err := db.FindUser(ctx, u.exec, id,
+		db.UserTableColumns.ID, db.UserTableColumns.Name,
+	)
+	if err != nil {
+		return nil, err
+	}
+	return convertUser(user), nil
+}

レポジトリオーナーのユーザーIDが手に入った理由

さて、Ownerメソッドの中では、引数として与えられたobj.Repository型のOwner.IDフィールドを参照することでレポジトリオーナーのユーザーIDを得ることができました。
実はこれは、repositoryResolver.Ownerメソッドが呼ばれる前に実行されたqueryResolver.Repositoryメソッド、いわば親となるリゾルバの中できちんとそのような実装をしたことがキーになっています。

Issueメソッドの実装の際にも解説した通り、分割されたリゾルバは以下のようにネストが浅い順に呼ばれていきます。

  1. ルートリゾルバ*Resolver型のQueryメソッドが呼ばれる
  2. リゾルバ*queryResolver型のRepositoryメソッドが呼ばれる
  3. リゾルバ*repositoryResolver型のOwnerメソッドが呼ばれる

ステップ2のRepositoryメソッドで作成し、戻り値としている*model.Repository型が、そのまま後続ステップ3のOwnerメソッドの引数となります。
つまり、リゾルバの戻り値というのは、単純にクライアントに返却するレスポンスを作るという以外にも、後続の子リゾルバに渡す引数を作っているという役割・側面があるのです。

graph/schema.resolvers.go
// ステップ2での戻り値*model.Repository型が、
func (r *queryResolver) Repository(ctx context.Context, name string, owner string) (*model.Repository, error) {
	// (中略)
	return r.Srv.GetRepoByFullName(ctx, user.ID, name)
}

// ステップ3での引数になる
func (r *repositoryResolver) Owner(ctx context.Context, obj *model.Repository) (*model.User, error)

Repositoryメソッドの戻り値を作っているGetRepoByFullNameサービスでは、repositoryテーブルの4つの列をselectしてきていましたが、その場でユーザーレスポンスという形で生きたのはそのうちの3つだけでした。
しかし、その場では何の役割もなかったowner列の情報は、後続のリゾルバOwnerメソッドの中で「レポジトリオーナーのユーザーIDを入手する」という機能をしっかりと提供するのです。

GraphQLのRepositoryオブジェクトのフィールド サービス層で取得し紐付けているデータ
id repositoriesテーブルのid
name repositoriesテーブルのname
createdAt repositoriesテーブルのcreated_at
owner N/A(オーナーとなるユーザーIDはrepositoriesテーブルのowner列から取れているが、それだけでは不足している。ただし、後続の子リゾルバでは使える情報)
(再掲)Repositoryメソッドの戻り値を作っているGetRepoByFullNameメソッドの実装
graph/services/repositories.go
func (r *repoService) GetRepoByFullName(ctx context.Context, owner, name string) (*model.Repository, error) {
	repo, err := db.Repositories(
		qm.Select(
			db.RepositoryColumns.ID,         // レポジトリID 
			db.RepositoryColumns.Name,       // レポジトリ名
			db.RepositoryColumns.Owner,      // レポジトリを所有しているユーザーのID
			db.RepositoryColumns.CreatedAt,  // 作成日時
		),
		db.RepositoryWhere.Owner.EQ(owner),
		db.RepositoryWhere.Name.EQ(name),
	).One(ctx, r.exec)
	if err != nil {
		return nil, err
	}
	return convertRepository(repo), nil
}

func convertRepository(repo *db.Repository) *model.Repository {
	return &model.Repository{
		ID:        repo.ID,
		Owner:     &model.User{ID: repo.Owner},
		Name:      repo.Name,
		CreatedAt: repo.CreatedAt,
	}
}

このように、サービス層の中でテーブルデータをselectしてくるときは「テーブルjoinが必要にならない範囲で、できるだけ多くのデータを取得してモデル構造体に反映させる」ことで、後々リゾルバを分割したときに役に立つのです。

サービス層の再利用

今回Ownerメソッドを実装するにあたり「ユーザーIDから、userテーブル内のユーザーデータを取得する」という処理が必要になったため、それをサービス層のGetUserByIDメソッドとして実装しました。

先読みした話をすると、例えば今後ProjectV2オブジェクト関連のリゾルバを分割していく際に、同様の処理が必要になります。

gqlgen.yml
models:
  Repository:
    fields:
      owner:
        resolver: true
+  ProjectV2:
+    fields:
+      owner:
+        resolver: true
graph/schema.resolvers.go
// Owner is the resolver for the owner field.
func (r *projectV2Resolver) Owner(ctx context.Context, obj *model.ProjectV2) (*model.User, error) {
	return r.Srv.GetUserByID(ctx, obj.Owner.ID)
}

このとき、サービス層という形で処理を分離して実装したことによって、異なるリゾルバ間で同様の処理を使い回して楽をすることができるようになっていることに気づくかと思います。

基本編にて「なぜリゾルバメソッドの中に直接DBクエリ処理を書かず、わざわざサービス層に切り出したのだろう?」と思った方もいるかもしれませんが、リゾルバというのは適切に分割していくとどうしても似たような処理を複数箇所に記述するということになってしまいます。
そのため、ビジネスロジック自体は他のパッケージに切り出して、リゾルバからはそれらを呼び出すだけ、という形にすることでコードをスッキリさせることができます。

動作確認

ここまで実装できたところで、分割したリゾルバを実際に稼働させてみましょう。

サーバー稼働

サーバーを稼働させるために、エントリポイントであるserver.goを実行します。

$ go run server.go 
2023/01/22 20:04:24 connect to http://localhost:8080/ for GraphQL playground

リクエストクエリの記述

サーバーを稼働させたら、リクエストクエリを作ります。
今回は、新たに実装したOwnerメソッドとIssueメソッドが呼ばれるようにフィールドを選択してみました。

query {
  repository(name: "repo1", owner: "hsaki"){
    id
    name
    createdAt
    owner {
      name
    }
    issue(number:1) {
      url
    }
  }
}

レスポンスを確認

{
  "data": {
    "repository": {
      "id": "REPO_1",
      "name": "repo1",
      "createdAt": "2023-01-09T22:11:47Z",
      "owner": {
        "name": "hsaki"
      },
      "issue": {
        "url": "http://example.com/repo1/issue/1"
      }
    }
  }
}

リゾルバ分割前には得られなかったowner.nameフィールドとissueフィールドがnullにならず、きちんと取得できていることが確認できました。

リゾルバ分割の利点まとめ

リゾルバを分割したことで得られた利点を改めてまとめたいと思います。

  • オーバーフェッチを防ぐ
  • 発行されるSQLクエリを簡潔に保つ

オーバーフェッチを防ぐ

Repositoryオブジェクトを取得するためのリゾルバを分割したことによって、

  • ownerフィールドを取得するクエリを受け取ったときにはOwner小リゾルバを呼び、そうでないときは呼ばない
  • issueフィールドを取得するクエリを受け取ったときにはIssue小リゾルバを呼び、そうでないときは呼ばない
  • pullRequestフィールドを取得するクエリを受け取ったときにはPullRequest小リゾルバを呼び、そうでないときは呼ばない
  • (以下略)

といった処理フローを作ることができました。
これによりGraphQLの利点である「欲しいフィールドのみを指定してデータ取得する」という機能を真に実装できたことになります。

(再掲)リゾルバ分割の定義を記述したgqlgen.ymlの内容
gqlgen.yml
models:
  Repository:
    fields:
      owner:
        resolver: true
      issue:
        resolver: true
      issues:
        resolver: true
      pullRequest:
        resolver: true
      pullRequests:
        resolver: true

クエリを簡潔に保つ

リゾルバ分割によって「あるフィールドが呼ばれたときには、別のリゾルバを呼ぶ」仕組みを作り上げたことで、DBからデータを取得するためのSQLクエリをシンプルに保つことができるようになります。
複数のテーブルに跨るようなデータ取得を要求されたときに、1つのリゾルバの中でJOINを駆使して何とか一回の処理でレスポンスに必要なデータを読み出す必要はもうなく、別のリゾルバに処理を委譲すればよいのです。

次章予告

リゾルバを分割したことによって、「リクエストされたデータだけ読み出す・処理する」というGraphQLの肝となる部分をついに実現させることができました。
次章は、このリゾルバ分割をした副作用として生まれてしまった「N+1問題」とその解決方法をご紹介したいと思います。