使用Go语言创建GraphQL服务器
我们将以实践的方式介绍使用Go构建GraphQL服务器的基础知识。完整版本已经发布在GitHub上。
GraphQL的概述
GraphQL是什么?
GraphQL是Facebook创建的API格式。以下是从官方网页引用的内容。
GraphQL是一种用于API的查询语言,它是用服务器端运行环境来执行查询的,通过定义的类型系统来对数据进行查询。
GraphQL的使用方法
假设有下面的GraphQL模式。
type Query {
me: User
}
type User {
id: ID
name: String
profileUrl: String
}
可以执行以下类型的GraphQL查询。该查询可以是HTTP的POST或GET请求。通常情况下,我们会使用GraphQL客户端库,如Apollo或Relay,而不是原始的HTTP请求来执行请求。
查询
{
me {
id
name
profileUrl
}
}
回应
{
"me": {
"id": "user:123456"
"name": "田中太郎"
"profileUrl": "https://xxx.yyy.zzz/..."
}
}
作为GraphQL的特点之一,您可以筛选响应中的字段。
根据响应字段缩小查询范围。
{
me {
name
}
}
GraphQL的优点
-
- 由于可以指定响应字段,可以避免过度获取或不足获取。
-
- 有类型。
-
- 像Apollo这样的GraphQL客户端可以帮助我们将查询结果很好地缓存到本地。
- 可以在一个请求中包含多个查询或更新操作。
个人而言,我认为能够指定响应字段是一个很好的点。在拥有移动应用和Web前端的情况下,无需为每个场景都准备API或创建独特的字段指定机制,我们可以在两种用例中返回恰到好处的响应。
使用Go语言构建GraphQL服务器
从这里开始,我们将创建Go的GraphQL服务器。
使用的库:gqlgen
我们要使用一个名为gqlgen的库。
GraphQL服务器的实现库可以根据代码优先和模式优先来进行大致划分。gqlgen被归类为模式优先。使用gqlgen的原因是因为它是Go中最受欢迎的模式优先的GraphQL库,并且符合我个人的特点。
使用gqlgen开发GraphQL的流程如下。
- 生成的解析器模板进行实现
运行GraphQL服务器
首先,请按照gqlgen的“入门指南”进行操作。之后的说明都是基于已完成“入门指南”的前提进行的。
N+1問題及其解決方法
完成gqlgen的入门后,应该能够执行以下查询,以获取Todo的列表。
query findTodos {
todos {
text
done
user {
name
}
}
}
当前情况下,此查询将在获取待办事项列表后,针对每个待办事项获取登记者的信息。如果待办事项列表的数量为5个,则待办事项列表处理将执行1次,用户获取处理将执行5次。在用户获取处理中,执行RDB的SELECT操作或调用其他服务的Get API,会出现所谓的N+1问题。
数据加载器
为了解决GraphQL的N+1问题,我们可以使用dataloader。dataloader是一个可以将多个数据请求合并为一个请求的库。
有名的Go製数据加载器有两个选项。
graph-gophers/dataloader
コード生成をしない方式です
データを取得するときに対象のデータを一意に識別するkeyを指定し、結果はResult[V any]に格納して返します。
keyには、intやstringなどのcomparableな型が使えます
vektah/dataloaden
gqlgenと同じ作者です
Modelごとにdataloaderのコードを生成する方式です
データを取得するときに対象のデータを一意に識別するkeyを指定し、取得結果は取得対象のデータの型の変数に格納して返します
keyには、intやstringなどのcomparableな型が使えます
我認為前者更容易使用。在這篇文章中,我也會使用前者。
使用数据加载器实现数据获取处理
图/数据加载器.go
请创建一个名为graph/dataloaders.go的文件,并写下以下代码。
package graph
import (
"context"
"fmt"
"github.com/graph-gophers/dataloader/v7"
"github.com/kamikazezirou/gql-example/graph/model"
"net/http"
)
type Loaders struct {
UserById *dataloader.Loader[string, *model.User]
}
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), loadersKey, &Loaders{
UserById: dataloader.NewBatchedLoader(func(ctx context.Context, userIds []string) []*dataloader.Result[*model.User] {
fmt.Println("batch get users:", userIds)
// ユーザIDのリストからユーザ情報を取得する
// サンプル実装なので適当な値を返していますが、プロダクト実装では以下のようにしてください。
// - "SELECT * FROM users WHERE id IN (id1, id2, id3, ...)"のようなSQLでDBからユーザ情報を一括取得する
// - 他のサービスのBatch Read APIを呼ぶ
// それでN+1問題を回避することができます。
results := make([]*dataloader.Result[*model.User], len(userIds))
for i, id := range userIds {
results[i] = &dataloader.Result[*model.User]{
Data: &model.User{ID: id, Name: "user " + id},
Error: nil,
}
}
return results
}),
})
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
type contextKey int
var loadersKey contextKey
func ctxLoaders(ctx context.Context) *Loaders {
return ctx.Value(loadersKey).(*Loaders)
}
以下是实施的重点。
-
- dataloaderを保持するためのstruct Loadersを定義します
サンプルでは1つしかdataloaderがありませんが、プロダクトでは複数のdataloaderを持つことになるので、集約するstructを作っています
net/httpのhttp.Handlerのinterceptorを作り、この中でLoadersを作成してhttp.RequestのContextに埋め込みます
GraphQLは通信プロトコルは普通のhttpなので、サーバもnet/httpで実現されています。リゾルバの処理の前にdataloaderを作成したいので、interceptorとして実装します。
Loadersを作る際、NewBatchedLoaderでユーザ情報のdataloader Loaderを作ります
NewBatchedLoaderの引数は関数になっていますが、その関数の中でDBからのReadなり、他のサービスのBach Read APIを実行するなりします。
contextからLoadersを取り出せるようにしておきます
最后,请在编写完代码后执行以下命令。
go mod tidy
服务器.go
请将设置此文件的http.Handler的部分修改如下。
// http.Handle("/query", srv)
http.Handle("/query", graph.Middleware(srv))
我已将先前创建的拦截器设置在GraphQL处理代码的前面。
图/模式解析器。go
请以以下方式实现todoResolver#User()。
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
thunk := ctxLoaders(ctx).UserById.Load(ctx, obj.UserID)
item, err := thunk()
if err != nil {
return nil, err
} else {
return item, nil
}
}
通过Middleware将Dataloader嵌入到上下文中并获取它,然后通过它获取用户信息。在调用Dataloader时,将唯一标识数据的值作为调用参数传递。
观察dataloader的操作
请先运行 server.go,然后在 Web 浏览器中打开 http://localhost:8080/ 并显示 Playground。
请执行以下的GraphQL查询来注册Todo数据。正如先前提到的一样,GraphQL与REST不同,可以在一个请求中包含多个操作。
mutation createTodo {
first: createTodo(input: { text: "todo1", userId: "1" }) {
user {
id
}
text
done
}
second: createTodo(input: { text: "todo2", userId: "2" }) {
user {
id
}
text
done
}
third: createTodo(input: { text: "todo3", userId: "1" }) {
user {
id
}
text
done
}
}
请在最后执行以下的GraphQL查询,以获取待办事项数据的列表。
query findTodos {
todos {
text
done
user {
name
}
}
}
在引入dataloader之前,需要执行3次用户信息的获取操作,但是在引入dataloader之后,可以将获取多个用户信息的操作合并为一次。
关于dataloader的补充说明
合并重复请求的键
请查看GraphQL服务器的标准输出。应该以以下方式输出。
batch get users: [1 2]
“批量获取用户:[1 2 1]”不正确。数据加载器会将重复的请求键值进行合并,所以会变成这样。这样可以避免不必要的数据获取。
2. 能够缓存响应的。
假设按照以下流程进行处理。
-
- 接收请求
-
- 使用dataloader检索ID为1的用户
-
- 耗时操作
-
- 使用dataloader检索ID为1的用户
- 返回响应
在这种情况下,当进行第二次用户获取时,将从内存缓存返回值。值得注意的是,可以通过选项切换缓存的操作。
3. 关于将多个请求合并的行为
数据加载器会将一段时间内的数据获取请求合并成一个请求。默认情况下,它会以16毫秒的间隔合并数据获取请求。当然,可以更改这个间隔。此外,还可以指定要合并的请求的最大数量,默认为1000个。
Schema design patterns: 架构设计模式
从这里开始,我们将介绍3种GraphQL模式设计的模式。
-
- 查看者查询:获取登录用户的查询
-
- 节点查询:从ID中获取已实现节点类型的查询
- 连接模式:用于分页的设计模式。
在这里介绍的模式也被应用在GitHub的GraphQL API中。
获取查看者登录用户
习惯上,用于获取登录用户信息的查询应该被命名为“viewer”。让我们试着实现它。
首先,请将架构的变更如下所示。
type Query {
todos: [Todo!]!
viewer: User! # ここを追加
}
请重新生成代码。
go run github.com/99designs/gqlgen
请在以下代码中实现,graph/schema.resolvers.go已更新并已添加了Viewer方法。
func (r *queryResolver) Viewer(ctx context.Context) (*model.User, error) {
// プロダクトコードでは、ユーザを認証して、そのユーザの情報を返すようにしてください。
return &model.User{
ID: "user:1",
Name: "user1",
}, nil
}
我们已经完成了实施。让我们看看它的运行情况。
请运行server.go文件,并在Web浏览器中打开http://localhost:8080/,显示Playground。
请您实际运行viewer并试试看。以下是具体的查询示例。
{
viewer {
id
name
}
}
如果正确实施,响应将如下所示。
{
"data": {
"viewer": {
"id": "1",
"name": "user1"
}
}
}
获取实现了Node类型的ID的节点。
我会为获取到ID并实现了Node的类型准备一个查询node。这样我们就不需要为每种类型定义获取数据的查询了,查询的可读性会更好。
定义架构和代码生成
更改模式,实现Node接口以实现Todo类型,并进一步添加node查询。
interface Node {
id: ID!
}
type Todo implements Node {
id: ID!
text: String!
done: Boolean!
user: User!
}
type Query {
# 他のクエリは省略
node(id: ID!): Node
}
接下来,在Go的model文件中添加以下方法。
func (t *Todo) IsNode() {}
func (t *Todo) GetID() string {
return t.ID
}
最后,请重新生成代码。 , .)
go run github.com/99designs/gqlgen
让Todo的ID成为全局唯一且具有类型可识别的。
实现了Node接口的类型的ID需要是全局唯一的,并且从该值可以判断类型。因为node查询的返回类型取决于ID。虽然本次我们没有让User实现Node接口,但假设让它实现。在这种情况下,node(id: “Todo的ID”)应返回Todo,node(id:”User的ID”)应返回User。如果ID不包含类型信息,GraphQL服务器无法判断应该返回Todo还是User。
以下是ID的一个具体示例。我们在GitHub的GraphQL API上展示了一个GitHub仓库的ID进行了base64解码。可以看到值中包含了类型信息。
010:Repository12345678
到目前为止,实现应该是以” T123456 “这样的形式来表示Todo的ID。虽然我们可以从ID的首个字符“T”判断出它是一个Todo类型,但是这样的表示方式比较难以理解,所以让我们将其改为”todo:”吧。
func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
todo := &model.Todo{
Text: input.Text,
ID: fmt.Sprintf("todo:%d", rand.Int()), // ここを変更
UserID: input.UserID,
}
r.todos = append(r.todos, todo)
return todo, nil
}
实现node查询的解析器。
接下来,我们将实现节点查询的解析器。
func (r *queryResolver) Node(ctx context.Context, id string) (model.Node, error) {
s := strings.Split(id, ":")
t := s[0]
switch t {
case "todo":
for _, todo := range r.todos {
if todo.ID == id {
return todo, nil
}
}
return nil, errors.New("not found")
default:
return nil, fmt.Errorf("unknwon type:%s", t)
}
}
尝试执行node查询
请在重启服务器后,通过GraphQL Playground执行以下查询来注册Todo。请记下结果的id。
mutation createTodo {
first: createTodo(input: { text: "todo1", userId: "1" }) {
id
user {
id
}
text
done
}
}
请执行节点查询。应该能够获取到Todo的信息。
{
node(id: "<先程登録したTodoのID>") {
id
...on Todo {
text
user {
id
name
}
}
}
}
…关于 “Todo { }” 的语法是,因为 node 的返回值是 interface Node,所以这是用来将其转换为 Todo 的工具。
关于ID的补充说明
让API客户端无需关注节点ID的格式。如果以可读性良好的形式,比如实现一个”todo:1″形式的返回给API客户端,可能会使得我们在实现时意识到节点ID的格式。因此,最好是将节点ID进行Base64编码(因为它不是机密信息,所以并不需要完全隐藏)。
然而,使用易读的ID还有一个好处是能够更轻松地在GraphQL Playground中测试API。如果不是面向大众用户的API,并且可以教育API用户,且不需要在ID值中包含二进制,我认为不进行Base64编码也是一种选择。
连接模式 (分页)
连接模式是用于分页的模式设计方案。由于动手实践可以更快地理解,让我们动手试试看吧。
想要了解此模式的详细信息的人,请参考Facebook开发的GraphQL客户端js库Relay的文档。
取得查询定义
首先,我们将根据Connections模式修改findTodos函数的定义,以便获取所有Todo的列表。
type Query {
todos(
after: String
before: String
first: Int
last: Int
) : TodoConnection!
}
以下是参数的含义。
(The meaning of the argument is as follows.)
-
- after:データ取得開始位置(cursor)を指定します
このcursorで指し示される項目より後の項目が取得対象になります
before:データ取得終了位置(cursor)を指定します
このcursorで指し示される項目より前の項目が取得対象になります
first:先頭からの何件取得するか
last:末尾から何件取得するか
在中文中有多种表达方式,下面是一种选项:
不推荐同时指定”first”和”last”。如果两者都同时指定,应该报错。此外,建议将”first”或”last”中的一个设为必填项。GitHub的GraphQL API遵循这种行为。
補足2
无论是使用”first/after”还是”last/before”,查询结果中的项目顺序应该保持一致。举个例子,假设按照ID升序排列,有item1、item2、item3。那么,无论在获取列表查询参数中指定”first=3″还是指定”last=3″,结果的排序都应该是[item1, item2, item3]。
連結/頁面信息/邊緣的定義
接下来,要向模式中添加三个类型定义。
type TodoEdge {
cursor: String!
node: Todo!
}
type PageInfo {
endCursor: String
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
}
type TodoConnection {
edges: [TodoEdge!]!
pageInfo: PageInfo!
}
-
- Connection
EdgeのスライスとPageInfoを持ちます
一覧取得クエリのレスポンスになります
Edge/PageInfo以外にtotalCountなどの付属情報を持たせるのも良いです
PageInfo
startCursor/endCursor:取得した一覧の先頭と末尾の項目を指し示すcursorです
hasNextPage:一覧取得範囲の後に項目があるならtrue
先頭から後ろの方向に一覧を取得した場合は1、必須です
後ろから先頭の方向に一覧を取得した場合は2、オプションです
hasPreviousPage:一覧取得範囲の後に項目があるならtrue
先頭から後ろの方向に一覧を取得した場合は、オプションです
後ろから先頭の方向に一覧を取得した場合は、必須です
Edge
nodeと一覧の中での位置を示すcursorを持ちます
一覧がIDの昇順 or 降順だとしたら、cursorにはIDを含めておくイメージです
参考:GitHubのGraphQL APIでリポジトリ一覧を取得することができますが、各Edgeのcursorは、バイナリデータをbase64でエンコードしたもので、バイナリの下4バイトはリポジトリのIDになっています
nodeは項目のデータ本体で、その型にはNode interfaceを実装させます
实施
我们将省略介绍实现部分。
实现方法可以很容易地想象出来,但示例实现相当繁琐。
连接模式的好处
-
- cursorベースのページングなので、offsetベースのページングよりパフォーマンスが良いし、途中でデータを追加されたりしても影響を受けづらい
-
- このパターンに従っていると、Relay/ApolloなどのGraphQLクライアントライブラリが気を利かせてくれる
mutation(データ更新)のときに、クライアント側のキャッシュを更新してくれる
クライアント側のページングの実装が楽になる
一覧取得クエリの仕様がある程度統一される
请参考以下资料。
这里有一些可以参考的资料。
有关参考资料,请您阅读以下内容。
供参考,以下是一些相关资料。
GraphQLの公式ドキュメント
文法だけでなくベストプラクティスもあります
ShopifyのGraphQL設計チュートリアル
設計ガイドラインとしてどうぞ
日本語翻訳もあります
GitHubのGraphQL Exploer
GraphQLを試すのに良いです
スキーマをダウンロードできるので、設計の参考にできます
gqlgenの公式ドキュメント
この記事で触れていない認証やエラーハンドリングのやり方が書かれています
Production Ready GraphQL
GraphQLスキーマの設計、複雑すぎるクエリへの対処といったプラクティスがまとまっています。GraphQLスキーマの設計の考え方はとても参考になりました
手前味噌ですが、本書のスキーマ設計の主張の概要を別記事で紹介しています
最后
我已经分享了使用Go构建GraphQL服务器所需的最基本的知识。希望对别人有所帮助。