1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Goで学ぶGraphQLサーバーサイド(7)ーリゾルバの実装(応用編)2

Posted at

こんにちは。

Part 7も「リゾルバの実装 - 応用編」についてです。
今回はリゾルバ分割の実装と動作確認について説明いたします。

#リゾルバ分割の実装

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

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) {
      // (略)
    }
  }
}

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

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

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

  • ルートリゾルバ*Resolver型のQueryメソッドが呼ばれる
  • リゾルバ*queryResolver型のRepositoryメソッドが呼ばれる
  • リゾルバ*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
列から取れているが、それだけでは不足している。ただし、後続の子リゾルバでは使える情報)

このように、サービス層の中でテーブルデータを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の利点である「欲しいフィールドのみを指定してデータ取得する」という機能を真に実装できたことになります。

クエリを簡潔に保つ

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

次章予告

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

今日は以上です。
ありがとうございました。
よろしくお願いいたします。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?