我在GAE/Go中尝试使用符合Relay规范的GraphQL
这篇文章是2019年Go附歳末日历第20天的文章。
首先
在各种文章和活动的演讲中,我们经常听到关于处理 gRPC 和 Protocol Buffers、以及将其替代 REST 的讨论。
然而,在日本,关于引入 GraphQL 的讨论仅限于查看 Web 前端(特别是使用 React 的地方)的活动幻灯片上,并且很少提到在实际产品中使用,相关文章也只有零星存在,所以信息量明显较少。基本上,由于使用 GraphQL 尚存在一定的障碍(尽管海外有很多相关文章,如果查看那些就不完全没有可能),我自己也只是刚刚开始了解,但如果通过本文能够增加对 GAE/Go 和 GraphQL 感兴趣的人,我会非常高兴。
GraphQL有什么优点?
在这里不会对GraphQL进行详细说明,但它是一种用于从API获取数据的查询语言。
以下是经常被提及的优点。
-
- クライアントが欲しいデータのみを取得できる
-
- 一回のクエリで様々な種類のデータを取得することが可能
- サーバーの都合とクライアントの都合が分離できる
当阅读一些有关GraphQL的文章时,您可能会发现它们都写到了相同的优点,但只有当您亲自使用它时,您才能真正理解它的好处。
在我个人看来,我认为最大的优点是能够将服务器和客户端的考虑分离开来。
因为在以前开发服务器的过程中,我必须考虑前端的需求,这些需求与业务逻辑无关,但在实际实施中,我不得不对数据结构进行非正规化处理,创建中间值的API,并且在服务器代码中存在很多看不懂为什么要这样做的情况。
然而,使用GraphQL后,服务器应用程序开发人员只需考虑返回每个数据,基本上就行了。因为前端应用程序开发人员可以通过查询来确定数据格式,并且GraphQL会根据他们的需求进行数据的重组处理。
然而,听起来可能会觉得GraphQL的引入和尝试很困难,但实际上,GraphQL并没有使用什么特殊的数据格式来返回响应,而是返回JSON。换句话说,在当前的服务中,如果正在使用JSON进行通信,若要将其替换为GraphQL,可以从最小的更改开始,这种可能性非常高。

请参阅官方文件或者Facebook发布的这篇文章,了解为什么产生了GraphQL等其他相关信息!
由於今年也有聖誕歲月記事,如果您想進一步了解GraphQL本身,請參閱此處!2019年GraphQL聖誕歲月記事
运行 x GraphQL
当然有几个库可用于在Go中处理。
-
- graphql-go/graphql
-
- samsarahq/thunder
-
- graph-gophers/graphql-go
- 99designs/gqlgen
现在最近一直进行开发的有以下四个部分。今次使用的是第四个部分,即gqlgen!
我认为可能还有许多其他的文章中也使用了这种方式,但是我个人的原因如下。
-
- GraphQLスキーマからクエリ解釈して、それに応じたメソッド(リゾルバー)を呼ぶところまでを自動生成してくれる
-
- 他のライブラリと違って、スキーマファーストなライブラリとなっている
- 開発が最も盛んで、ドキュメントも揃っている
由于它可以自动生成代码,即使对GraphQL不太了解,只要懂Go的人准备好实现提供的接口,它就可以运行,为我们提供了一个快速尝试的可能。
请参考gqlgen撰写的这四个库的功能比较表(因为是由gqlgen撰写的,可能会有些偏见),选择最佳的库。
Relay的架构规范
GraphQL非常具有表达能力,如果有需求,可以实现各种各样的功能。然而,这样做会让查询和模式变得复杂,变得无序。因此,我认为最好按照一定规则进行操作,并根据这个规则将其应用于各个服务。接下来,谈一谈Relay,它是Facebook开发的一个用于处理GraphQL的React客户端库。在其中,定义了用于更好地利用GraphQL的模式规范。首先,按照这个规范编写,然后在各个服务中应用所需的内容,并添加所缺少的部分。
-
- interface Node を定義しエンティティはidにって引けるようにする
-
- リストを返す場合はCursor Connectionsに従う
-
- Mutationの名前は動詞、引数の型名にInputサフィックス、返り値の型名にPayloadサフィックスをつける
-
- Mutationの入力と出力を紐付けて調整するために使用されるクライアント変換識別子が必要になる場合がある
- (これはv7.1.0まではMutationの引数と返り値に識別子clientMutationIdが必要と書いてあるが、v8.0.0ではその記載は消えている)
中继 x GAE/Go
由于下面的讨论涉及到GAE/Go,所以它将是关于使用datastore的讨论。
定义一个名为Node的接口,使其实体能够通过id进行访问。
首先定义一个名为Node的接口,使得实体能够通过id进行检索。
在GraphQL中,id必须在所有实体中是唯一的。
也就是说,当存在User和Item,并且它们分别具有id时,
User.ID = 1
Item.ID = 1
“这是不可被容忍的。每个人都必须是唯一的。”
User.ID = "User:1"
Item.ID = "Item:1"
如何以中文表达以下内容,仅需一个选项:
必须明确确保实体唯一。
然后,在Relay中定义了接口Node,必须通过id来获取每个实体。
简单来说,如果使用UUID等方式能够唯一确定一个ID和实体的一对一关系,但仅通过ID无法提取实体。这是因为无法判断该ID是什么类型的ID。
因此,如果使用GAE且假设使用datastore,我认为最好将ID设为包含Kind名称和每个Kind的标识符(id或name)的字符串。这样做的好处是,您可以清楚地知道要在哪个Kind中检索,因此即使只有ID,也可以提取实体。
如果返回一个列表,应按照Cursor Connections的规定。
在使用Relay时,如果希望返回一个对象列表,可以通过设定一定的规则来方便地处理分页和列表操作。下面是所需的类型。
-
- XXXEdge型: CursorとXXX型を持つ型
-
- PageInfo: ページ情報を持つ型
- XXXConnection型: PageInfoとXXXEdgeのリストとその他必要なフィールドを持つ型
使用这些类型,在以下步骤中定义查询的模式:
– 将返回列表的部分更改为返回XXXConnection
– 在查询字段中添加参数{first: Int, after: String, last: Int, before: String}
第一个/之后,最后一个/之前的组合方式进行处理。
– 第一个/最后一个:从之后/之前(光标)开始获取多少个对象
– 之后:接收光标,并指示列表中开始获取对象的位置
– 之前:接收光标,并指示列表中开始获取对象的位置。同时,将之后反向使用。
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)
}
在这里,您可以使用datastore的Cursor来处理Cursor的出现。但是,当处理datastore的Cursor时,有一些需要注意的地方。
-
- datastoreのCursorは同じクエリでないと使うことができない
-
- datastoreのCursorは暗号化をデコードして結果のエンティティに関する情報を取得できる
- データの更新
我会逐一进行解释。
在datastore中,只能使用相同的查询才能使用Cursor。
只有在以下情况下,才能处理相同的游标:数据存储游标的限制。
-
- 実行したプロジェクトと同じプロジェクトのCursor
-
- 開始カーソル、終了カーソル、オフセット、条件の更新
- 元クエリが”key”で最後に並び替えしている場合は、逆引きクエリで使用できる
基本上,除了這個以外,你基本上不能處理相同的游標。
(然而,根據我所確認的,對於逆向查詢,雖然不會出錯,但官方文件中指出游標不應該使用,所以最好不要使用。)
数据存储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
模式
由于本次数据存储的行为,在同一批次中实现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
模型
即使我们称其为模型,但是由于缺乏特定的业务逻辑,我们基本上是在结构体的定义中进行自定义而不是自动生成。
当查看以下用户时,可以看到模式中没有定义的”events”。
这是因为我想要定义一个用于获取”events”的解析器,所以没有写它。
这样做的好处是,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"`
}
数据存储
由于这个已经相当大了,所以我会去看看获取多个用户的部分的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
}
解决者
在 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項目中,並進行測試!
代码库 jì)
创建用户 (Chuang jian yong hu)




总结
尽管我还刚开始学习,但因为在日本很少有人谈论这个话题,所以我带着一丝期望写了这篇文章,希望如果我写了文章,会有人会帮忙。
个人认为GraphQL非常好,所以今后我还想用Go和GraphQL继续开发服务器应用。
请参阅
-
- https://github.com/vvakame/graphql-with-go-book
-
- https://www.oreilly.co.jp/books/9784873118932/
-
- https://relay.dev/
-
- https://facebook.github.io/relay/graphql/connections.htm
-
- https://graphql.org/
-
- https://cloud.google.com/datastore/docs/concepts/queries?hl=en
- https://qiita.com/wawoon/items/d00bd180dcac48a3068e