こんにちは。
Part 7も「リゾルバの実装 - 応用編」についてです。
今回はリゾルバ分割の実装と動作確認について説明いたします。
#リゾルバ分割の実装
リゾルバを分割していない今の状況ではどのような不具合があるのかを確認できたところで、いよいよ分割実装をしていきましょう。
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
の中に以下のコードが増えていることが確認できるかと思います。
分割された子リゾルバ部分のコードは以下の通りです。
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
メソッドの中身を実装していきましょう。
// 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
テーブルにアクセスするサービス層メソッドを作成し、それをリゾルバの中から呼び出すようにしてあげましょう。
// 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
テーブルにアクセスするサービス層の実装
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},
}
}
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
型が与えられており、その中には取得対象となったレポジトリの情報(ID
やcreatedAt
など)が含まれています。
そのため、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
メソッドが呼ばれる
// 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) {
// (ユーザー実装部分、略)
}
そのため、
- リゾルバ
*queryResolver
型のRepository
メソッドが呼ばれて、その過程でrepository
テーブルから取得対象のレポジトリの情報を取得→*model.Repository
型に格納 - 1で得た情報を引数にして、リゾルバ
*repositoryResolver
型のIssue
メソッドを呼ぶ
という処理フローを作り上げることができるのです。
その2 - Owner
メソッド
Issue
メソッドと同様の考え方で、Owner
メソッドも作っていきましょう。
func (r *repositoryResolver) Owner(ctx context.Context, obj *model.Repository) (*model.User, error) {
panic(fmt.Errorf("not implemented: Owner - owner"))
}
メソッド内の処理
Owner
メソッド内で実装するべき内容は「とあるレポジトリのオーナーとなっているユーザー情報を取得する」というものです。
オーナーとなっているユーザーIDは第二引数のobj.Repository
のOwner.ID
フィールドに格納されているため、それを利用してusers
テーブル内をselect
すればOKです。
// 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メソッドの実装
type UserService interface {
+ GetUserByID(ctx context.Context, id string) (*model.User, error)
GetUserByName(ctx context.Context, name string) (*model.User, error)
}
+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
メソッドの実装の際にも解説した通り、分割されたリゾルバは以下のようにネストが浅い順に呼ばれていきます。
- ルートリゾルバ
*Resolver
型のQuery
メソッドが呼ばれる - リゾルバ
*queryResolver
型のRepository
メソッドが呼ばれる - リゾルバ
*repositoryResolver
型のOwner
メソッドが呼ばれる
ステップ2のRepository
メソッドで作成し、戻り値としている*model.Repository
型が、そのまま後続ステップ3のOwner
メソッドの引数となります。
つまり、リゾルバの戻り値というのは、単純にクライアントに返却するレスポンスを作るという以外にも、後続の子リゾルバに渡す引数を作っているという役割・側面があるのです。
// ステップ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オブジェクト関連のリゾルバを分割していく際に、同様の処理が必要になります。
models:
Repository:
fields:
owner:
resolver: true
+ ProjectV2:
+ fields:
+ owner:
+ resolver: true
// 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問題」とその解決方法をご紹介したいと思います。
今日は以上です。
ありがとうございました。
よろしくお願いいたします。