11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

GoAdvent Calendar 2019

Day 20

RelayにのっとったGraphQLを、GAE/Goで扱ってみた

Last updated at Posted at 2019-12-20

この記事は Go Advent Calendar 2019 20日目の記事です。

はじめに

gRPCやProtocol Buffersを扱っている、またRESTから置き換えているという話を様々な記事やイベントの発表でよく聞きます。
しかし、日本でGraphQLを導入したという話は、Webフロント(特にReactを使っているところ)のイベントなどのスライドを見ると出てくる程度で、プロダクトで使ってるとか、なかなか言及される記事もポツポツとしか存在しないので、圧倒的に情報量が少ない、そもそもGraphQLを使ってみる障壁が高い(海外の記事は多く上がっているので、そっちを見ればできなくはない)ので、僕もまだまだ始めたばかりですが、この記事でGAE/GoとGraphQLを触ってみたい人が増えると嬉しいです。

GraphQLは何がいいの?

GraphQLについてはここでは詳しい説明はしませんが、APIからデータを取得するためのクエリ言語です。

利点は以下のようなものがよく挙げられます。

  • クライアントが欲しいデータのみを取得できる
  • 一回のクエリで様々な種類のデータを取得することが可能
  • サーバーの都合とクライアントの都合が分離できる

GraphQLの記事などを少し漁ると同じような利点が書かれていると思いますが、使ってみるとよくわかります。
個人的には最もメリットだと感じているのは、三つ目のサーバーの都合とクライアントの都合が分離できる
です。
なぜなら、これまでサーバーを開発する上で、フロント側の都合を考慮して、ビジネスロジックとしては関係ないが、実装面からの観点で、データ構造を非正規化が必要だったり、中間値をとるためのAPIを作成しなくてはならなくなり、それに伴って、サーバーのコードだけ見て、なぜこうしたのかが分からないものが多々存在しました。
しかし、GraphQLを使用すると、サーバーのアプリケーション開発者はそれぞれのデータを返すことを考えるだけで、基本的には大丈夫です。なぜなら、フロントアプリケーション開発者がqueryでデータフォーマットを決めて、それに従った形に、詰め直す処理はGraphQL側がやってくれるからです。

しかし、これだけ聞くとなかなか導入や試すのが難しいように感じるかもしれませんが、
GraphQLは何か特殊なデータフォーマットでレスポンスが返ってくるわけでは無く、JSON で返ってきます。
つまり、現行のサービスで、JSONで通信をしているところはもしも、GraphQLに置き換えたいとなった場合、はじめは最小の変更から始められることができる可能性が高いということです。

(Google trendsでgRPCとGraphQLを検索してみると以下のような結果になり、やはり、日本ではあまり人気はないのかもしれないです、、)
スクリーンショット 2019-12-20 10.30.18.png
スクリーンショット 2019-12-20 10.30.09.png

そのほか、なぜGraphQLができたのかとかは公式のドキュメントやFacebookが出しているこちらの記事をご覧ください!

今年のアドベントカレンダーもあるので、GraphQLそのものをもっと詳しく知りたい方はこちらをご覧ください!
GraphQL Advent Calendar 2019

Go x GraphQL

Goで扱うためのライブラリもちろんはいくつか存在しています。

現在開発がここ最近でも行われているものはこの4つです。今回使うのは4つ目のgqlgenです!

おそらく、他の多くの記事でもこちらを使っているとは思いますが、僕の中での理由はこんな感じです

  • GraphQLスキーマからクエリ解釈して、それに応じたメソッド(リゾルバー)を呼ぶところまでを自動生成してくれる
  • 他のライブラリと違って、スキーマファーストなライブラリとなっている
  • 開発が最も盛んで、ドキュメントも揃っている

コードを自動生成してくれるので、GraphQLをあまり理解していなくても、Goを知っている人ならば、用意されたinterfaceの実装を用意してあげれば動くので、シュッと試すことを可能にしてくれます。

gqlgenが、この四つのライブラリの機能比較表を書いている(gqlgenが書いているので、少し偏っている可能性はありますが)参考にして、ベストなライブラリを選択してください。

Relayのスキーマ仕様

GraphQLは非常に表現力が高いので、やろうと思えば様々なことができてしまいます。しかしするとqueryやschemaが複雑化してしまい、秩序のないものになってしまいます。そのため、ある一定のルールにしたがってそれ元にして、各サービスに適用させていくのが良いと思います。そして、
RelayはFacebookが開発しているReactでGraphQLを扱うためのクライアントライブラリです。その中に、RelayでGraphQLをよりうまく活用するためのスキーマの仕様が定義されています。これにまずは従って書いていき、各サービスで必要なものを適用し足りないものを追加する方法が良いです。

  • interface Node を定義しエンティティはidにって引けるようにする
  • リストを返す場合はCursor Connectionsに従う
  • Mutationの名前は動詞、引数の型名にInputサフィックス、返り値の型名にPayloadサフィックスをつける
  • Mutationの入力と出力を紐付けて調整するために使用されるクライアント変換識別子が必要になる場合がある
    (これはv7.1.0まではMutationの引数と返り値に識別子clientMutationIdが必要と書いてあるが、v8.0.0ではその記載は消えている)

Relay x GAE/Go

以下での話はGAE/Goを扱う話なので、datastoreを使った話になります。

interface Node を定義しエンティティはidにって引けるようにする

まず一つ目のinterface Node を定義しエンティティはidにって引けるようにする
GraphQLではidは全てのエンティティの中で固有でなければなりません。
つまりどういうことかというと、UserとItemがあってそれぞれ、idを持っている時、

User.ID = 1
Item.ID = 1

これは許されなません。それぞれ固有でなければならないので、

User.ID = "User:1"
Item.ID = "Item:1"

などのように必ずそのエンティティが一意に確定しなくてはなりません

そして、Relayではinterface Nodeを定義して、idによって各エンティティを引っ張ってこれるようにしなくてはいけないのです。

つまり、一意に確定するのはUUIDなどを使えば、IDとエンティティは一対一になりますが、それではIDだけを使って引いてくることはできません。なぜなら、そのIDがどの型のIDなのかを判別する術がないからです。

なので、GAEを使う場合、datastoreを使うことを想定した場合はIDはKind名と各Kindの識別子(idかname)を持った文字列にするといいと思います。
そうすれば、どのKindで引くのかまで明確にわかるので、idだけでもentityを引っ張ることが可能になります。

リストを返す場合はCursor Connectionsに従う

Relayでは、あるオブジェクトをリストで返したい場合に、ある一定のルールを設けて返すことによって、ページングや一覧を扱う処置をやりやすくしてくれています。その時に必要となる型は次の通りです。

  • XXXEdge型: CursorとXXX型を持つ型
  • PageInfo: ページ情報を持つ型
  • XXXConnection型: PageInfoとXXXEdgeのリストとその他必要なフィールドを持つ型

これらの型を使ってリストを以下の手順でqueryのschemaを定義する

  • リストを返すところをXXXConnectionを返すようにする
  • queryのフィールドに引数に{first: Int, after: String, last: Int, before: String}

first/after、last/beforeのセットで扱います。

  • first/last: after/before(cursor)から何個のオブジェクトを取ってくるか
  • after: cursorを受け、リストを取得しはじめるオブジェクトの開始位置を示す
  • before: cursorを受ける、リストを取得しはじめるオブジェクトの開始位置を示す。そして、afterをは逆方向
interface Node {
    id: ID!
}

type User implements Node {
    id: ID!
}

type PageInfo {
    hasNextPage: Boolean!
    hasPreviousPage: Boolean!
    startCursor: String
    endCursor: String
}

type UserEdge {
    cursor: String!
    node: User
}

type UserConnection {
    edges: [UserEdge]
    pageInfo: PageInfo!
}

type Query {
  users(first: Int, after: String, last: Int, before: String)
}

ここで登場するCursorをdatastoreのCursorを使用することができます。
しかし、datastoreのCursorを扱う場合気を付けなければならない点があります。

  • datastoreのCursorは同じクエリでないと使うことができない
  • datastoreのCursorは暗号化をデコードして結果のエンティティに関する情報を取得できる
  • データの更新

一つ一つ説明していきます

datastoreのCursorは同じクエリでないと使うことができない

datastoreカーソル制限 より以下の状況のみ同じCursorを扱うことができます

  • 実行したプロジェクトと同じプロジェクトのCursor
  • 開始カーソル、終了カーソル、オフセット、条件の更新
  • 元クエリが"key"で最後に並び替えしている場合は、逆引きクエリで使用できる

これ以外のものでは基本的に同じカーソルを扱うことはできません
(しかし、逆引きクエリの場合エラーにならない場合が僕が確認した限りではあるのですが、公式ではカーソルは使用できなくなると書かれているので使わない方が良い)

datastoreのCursorは暗号化をデコードして結果のエンティティに関する情報を取得できる

これはCursorの中に以下の情報が含まれている

  • ProjectID
  • エンティティの種類
  • キー名または数値 ID
  • 祖先キー
  • プロパティ

など、つまり、そのタイミングのエンティティのデータが入ってます。なので、これらの情報がばれたくない場合は、何かしら暗号化したものなどをクライアントに返す必要があります。

データの更新

カーソルは最後の結果が返された後の結果のリスト内の位置を表してます。
これより以下のルールがあります

  • データが更新された場合カーソル位置より後の結果で発生した変化は検知する
  • カーソルより後の結果を取ってきても新しい結果は返さない

これらの特徴があるので、カーソルで取ってきてしまったものは変わらないですが、これからとるものに関しては変化してくれるので、それに関しては気にしなくても大丈夫です。

以上の制限を踏まえてGAE/GOで実装していく必要があります!

シンプル実装

試しにシンプルなGraphQL+GAE/GOの実装したものを見ていきます

僕は基本的にdatastoreとgoを使う時はgo.mercari.io/datastoreを使わせていただいています。
その都合で、UserとEventのstructのidにboomタグがついています。

※今回、以上で述べたこと以外のことや複雑なクエリが出てくるようなことはしていません(dataloadenの導入やCashや、親の親からデータを取ってきたり)

ディレクトリ構成


.
├── app.yaml
├── datastore
│   └── datastore.go
├── go.mod
├── go.sum
├── gqlgen
│   ├── generated.go
│   └── models_gen.go
├── gqlgen.yml
├── main.go
├── model
│   ├── event.go
│   ├── page.go
│   └── user.go
├── schema.graphql
└── server
    └── resolver_gen.go

スキーマ

今回datastoreの挙動上last/beforeを同じカーセルで実現するのが難しかったので、省略したものにしました。
そのため、HasPreviousPageも省略しています。


interface Node {
    id: ID!
}

type PageInfo {
    hasNextPage: Boolean!
    startCursor: String
    endCursor: String
}

type User implements Node {
    id: ID!
    name: String!

    events(first: Int!, after: String): EventConnection!
}


type Event implements Node {
    id: ID!
    userID: ID!
    description: String!
}

type UserEdge {
    cursor: String!
    node: User
}

type UserConnection {
    edges: [UserEdge]
    pageInfo: PageInfo!
}

type EventEdge {
    cursor: String!
    node: Event
}

type EventConnection {
    edges: [EventEdge]
    pageInfo: PageInfo!
}


input CreateUserInput {
    name: String!
}

type CreateUserPayload {
    user: User!
}

input CreateEventInput {
    userID: ID!
    description: String!
}

type CreateEventPayload {
    event: Event!
}

type Mutation {
    createUser(input: CreateUserInput!): CreateUserPayload
    createEvent(input: CreateEventInput!): CreateEventPayload
}

type Query {
    node(id: ID!): Node
    user(id: ID!): User
    users(first: Int!, after: String): UserConnection!
}

gqlgen設定

生成されるコードはgqlgenディレクトリ以下に入れて、このアプリケーション内で使い回す構造体をmodelsに入っているので、autobindを指定しておくと、その構造体はmodels_gen.goで生成されず、指定した方の構造体を使ってくれるようになります!

schema:
- schema.graphql
exec:
  filename: gqlgen/generated.go
model:
  filename: gqlgen/models_gen.go
resolver:
  filename: server/resolver_gen.go
  type: Resolver
autobind:
  - github.com/Yamashou/gae-relay/model

Model

モデルと言っても、ビジネスロジックも特にないので、structの定義で、基本的には自動生成させずに自分で描きたいものを定義します。

以下のUserを見てみるとスキーマの方に定義していた、eventsがありません。
これはeventsはeventsを取得するresolverを定義したいので、書いていません。
こうしておくと、gqlgenがインターフェースにEventsリゾルバーを追加してくれます


// user.go
package model

import (
	"fmt"
	"strings"

	"github.com/google/uuid"
)

type UserID string

func newUserID() UserID {
	return UserID(fmt.Sprintf("User:%s", uuid.New().String()))
}

func IsUserID(s string) bool {
	return strings.Contains(s, "User")
}

func NewUser(name string) *User {
	return &User{
		ID:   newUserID(),
		Name: name,
	}
}

type User struct {
	ID   UserID `boom:"id" json:"id"`
	Name string `json:"name"`
}

func (u *User) IsNode() {}

type UserConnection struct {
	Edges    []*UserEdge `json:"edges"`
	PageInfo *PageInfo   `json:"pageInfo"`
}

type UserEdge struct {
	Cursor string `json:"cursor"`
	Node   *User  `json:"node"`
}

// event.go

package model

import (
	"fmt"
	"strings"

	"github.com/google/uuid"
)

type EventID string

func newEventID() EventID {
	return EventID(fmt.Sprintf("Event:%s", uuid.New().String()))
}

func IsEventID(s string) bool {
	return strings.Contains(s, "Event")
}

func NewEvent(userID UserID, description string) *Event {
	return &Event{
		ID:          newEventID(),
		UserID:      userID,
		Description: description,
	}
}

type Event struct {
	ID          EventID `boom:"id" json:"id"`
	UserID      UserID  `json:"userID"`
	Description string  `json:"description"`
}

func (e *Event) IsNode() {}

type EventConnection struct {
	Edges    []*EventEdge `json:"edges"`
	PageInfo *PageInfo    `json:"pageInfo"`
}

type EventEdge struct {
	Cursor string `json:"cursor"`
	Node   *Event `json:"node"`
}

Datastore

これは結構大きくなってしまっているので複数取得する部分のGetUsersを見てみます。

package datastore

import (
	"context"

	"google.golang.org/api/iterator"

	"github.com/Yamashou/gae-relay/model"

	"golang.org/x/xerrors"

	"go.mercari.io/datastore/boom"

	"go.mercari.io/datastore"
)

type Client struct {
	client datastore.Client
}

...

func (c *Client) GetUsers(ctx context.Context, limit int, cursor string) (*model.UserConnection, error) {
	// 次のページが存在するか確認するため1件多く取得する
	limitPlusOne := limit + 1
	bm := boom.FromClient(ctx, c.client)
	q := bm.NewQuery(bm.Kind(model.User{})).
		Limit(limitPlusOne)
	if cursor != "" {
                // カーソルが空出ない時はそれをセットする
		cur, err := c.client.DecodeCursor(cursor)
		if err != nil {
			return nil, xerrors.Errorf(": %w", err)
		}
		q = q.Start(cur)
	}

	var edges []*model.UserEdge
	it := bm.Run(q)
	for {
		var user model.User
		_, err := it.Next(&user)
		if xerrors.Is(err, iterator.Done) {
			break
		}
		if err != nil {
			return nil, xerrors.Errorf(": %w", err)
		}

		cursor, err := it.Cursor()
		if err != nil {
			return nil, xerrors.Errorf(": %w", err)
		}

		edge := &model.UserEdge{
			Cursor: cursor.String(),
			Node:   &user,
		}

		edges = append(edges, edge)
	}

	// このページの最初と最後のCursorを返す
	var startCursor, endCursor string
	if len(edges) > 0 {
		startCursor = edges[0].Cursor
		endCursor = edges[len(edges)-1].Cursor
	}

	// 次のページが存在する場合
	var hasNextPage bool
	if len(edges) == limitPlusOne {
		hasNextPage = true
		// 最後の1件は次のページの存在確認用なので除外する
		edges = edges[:len(edges)-1]
	}

	conn := &model.UserConnection{
		Edges: edges,
		PageInfo: &model.PageInfo{
			StartCursor: &startCursor,
			EndCursor:   &endCursor,
			HasNextPage: hasNextPage,
		},
	}

	return conn, nil
}

Resolver

server/resolver_gen.goにこのようなコードが生成されます。
基本的に、この中を実装していけば、GraphQLのapiを作ることができます


package server

import (
	"context"

	"github.com/Yamashou/gae-relay/gqlgen"
	"github.com/Yamashou/gae-relay/model"
)

// THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.

type Resolver struct{}

func (r *Resolver) Event() gqlgen.EventResolver {
	return &eventResolver{r}
}
func (r *Resolver) Mutation() gqlgen.MutationResolver {
	return &mutationResolver{r}
}
func (r *Resolver) Query() gqlgen.QueryResolver {
	return &queryResolver{r}
}
func (r *Resolver) User() gqlgen.UserResolver {
	return &userResolver{r}
}

type eventResolver struct{ *Resolver }

func (r *eventResolver) ID(ctx context.Context, obj *model.Event) (string, error) {
	panic("not implemented")
}
func (r *eventResolver) UserID(ctx context.Context, obj *model.Event) (string, error) {
	panic("not implemented")
}

type mutationResolver struct{ *Resolver }

func (r *mutationResolver) CreateUser(ctx context.Context, input gqlgen.CreateUserInput) (*gqlgen.CreateUserPayload, error) {
	panic("not implemented")
}
func (r *mutationResolver) CreateEvent(ctx context.Context, input gqlgen.CreateEventInput) (*gqlgen.CreateEventPayload, error) {
	panic("not implemented")
}

type queryResolver struct{ *Resolver }

func (r *queryResolver) Node(ctx context.Context, id string) (gqlgen.Node, error) {
	panic("not implemented")
}
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
	panic("not implemented")
}
func (r *queryResolver) Users(ctx context.Context, first int, after *string) (*model.UserConnection, error) {
	panic("not implemented")
}

type userResolver struct{ *Resolver }

func (r *userResolver) ID(ctx context.Context, obj *model.User) (string, error) {
	panic("not implemented")
}
func (r *userResolver) Events(ctx context.Context, obj *model.User, first int, after *string) (*model.EventConnection, error) {
	panic("not implemented")
}

先ほど作った、GetUsersを使用して、Usersリゾルバーを実装は以下のようにします


type Resolver struct {
	DatastoreClient *datastore.Client
}

...

func (r *queryResolver) Users(ctx context.Context, first int, after *string) (*model.UserConnection, error) {
	var cursor string
	if after != nil {
		cursor = *after
	}
	userConnection, err := r.DatastoreClient.GetUsers(ctx, first, cursor)
	if err != nil {
		return nil, xerrors.Errorf(": %w", err)
	}

	return userConnection, nil
}

動作

以下に動作を確認したgifを載せてありますが、自分で確認したい方は、実装をクローンして、自身のGCPのプロジェクトにデプロイしていただいて、試してください!

リポジトリ

CreateUser

create_user.gif

CreateEvent
create_event.gif

id指定でNodeを取得
create_node.gif

UsersクエリでUserを複数取得する
get_users.gif

まとめ

まだまだ、学び始めたばかりですが、単純に日本でなかなかこの辺んお話出てこないので、もしかしたら自分が記事を書いたら、誰かやってくれる人が出てくるんじゃないかという淡い期待のを持ちつつ書きました。
個人的にはとてもGraphQLはいいんじゃないかなと思っているのでこれからも、GoとGraphQLでサーバーアプリケーションを開発していきたいと思います。

参考

11
6
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
11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?