在公司将Facebook转移到开源类别之后的过去几年中,GraphQL技术已变得非常流行。 该材料的作者(我们今天将其翻译发表)表示,他尝试在Node.js中使用GraphQL,并且根据他自己的经验,他坚信该技术由于其卓越的功能和简单性而不会引起如此多的关注。 最近,在从事一个新项目时,他从Node.js切换到Golang。 然后,他决定测试Golang和GraphQL的协作。

初步资料
您可以从官方的GraphQL定义中了解到,这是API的查询语言,也是对现有数据执行此类查询的运行时。 GraphQL提供了特定API中数据的完整且易于理解的描述,使客户可以准确地请求他们所需的信息,仅此而已,就可以随着时间的推移简化API的开发,并为开发人员提供强大的工具。
用于Golang的GraphQL库不多。 特别是,我尝试了诸如
Thunder ,
graphql ,
graphql-go和
gqlgen之类的库 。 我应该注意,我尝试过的最好的方法是gqlgen库。
gqlgen库仍处于beta版本中,在撰写本文时,版本为
0.7.2 。 图书馆正在迅速发展。
在这里,您可以找到有关其开发计划的信息。 现在gqlgen的官方赞助商是
99designs项目,这意味着该库很可能会比以前更快地发展。 该库的主要开发人员是
vektah和
neelance ,而neelance另外可以在graphql-go库上工作。
让我们基于您已经具备GraphQL基本知识的假设来讨论gqlgen库。
Gqlgen功能
在gqlgen描述中,您可以找到我们之前拥有的库,用于在Golang中快速创建严格类型的GraphQL服务器。 在我看来,这句话很有希望,因为这意味着在使用该库时,我不会遇到诸如
map[string]interface{}
,因为此处使用了基于严格类型的方法。
另外,该库使用基于数据模式的方法。 这意味着将使用GraphQL
架构定义语言来描述API。 该语言具有自己强大的代码生成工具,可自动创建GraphQL代码。 在这种情况下,程序员只能实现相应接口方法的基本逻辑。
本文分为两个部分。 第一个致力于基本的工作方法,第二个致力于高级方法。
主要工作方法:设置,接收和更改数据的请求,订阅
作为实验性应用程序,我们将使用一个网站,用户可以在该网站上发布视频,添加屏幕截图和评论,搜索视频以及查看与其他记录相关的记录列表。 让我们开始这个项目:
mkdir -p $GOPATH/src/github.com/ridhamtarpara/go-graphql-demo/
在项目的根目录中创建以下数据模式文件(
schema.graphql
):
type User { id: ID! name: String! email: String! } type Video { id: ID! name: String! description: String! user: User! url: String! createdAt: Timestamp! screenshots: [Screenshot] related(limit: Int = 25, offset: Int = 0): [Video!]! } type Screenshot { id: ID! videoId: ID! url: String! } input NewVideo { name: String! description: String! userId: ID! url: String! } type Mutation { createVideo(input: NewVideo!): Video! } type Query { Videos(limit: Int = 25, offset: Int = 0): [Video!]! } scalar Timestamp
它描述了基本的数据模型,一个突变(
Mutation
,对数据更改的请求的描述)用于在站点上发布新的视频文件,以及一个查询(
Query
)以获取所有视频文件的列表。
在此处阅读有关GraphQL模式的更多信息。 另外,这里我们声明了自己的标量数据类型之一。 我们对GraphQL中的5种标准标量数据
类型 (
Int
,
Float
,
String
,
Boolean
和
ID
)不满意。
如果需要使用自己的类型,则可以在
schema.graphql
声明它们(在我们的示例中,此类型为
Timestamp
)并在代码中提供它们的定义。 使用gqlgen库时,需要提供用于所有自己的标量类型的封送处理和解封送处理的方法,并使用
gqlgen.yml
配置映射。
应当指出,在该库的最新版本中,有一项重要更改。 即,从中删除了对已编译二进制文件的依赖性。 因此,应将
scripts/gqlgen.go
文件添加到项目中,
scripts/gqlgen.go
以下内容:
之后,您需要初始化
dep
:
dep init
现在是时候利用库的代码生成功能了。 它们使您可以创建所有无聊的样板代码,但是,不能将它们称为完全无趣的代码。 要启动自动代码生成机制,请执行以下命令:
go run scripts/gqlgen.go init
执行后,将创建以下文件:
gqlgen.yml
:用于管理代码生成的配置文件。
generated.go
:生成的代码。
models_gen.go
:提供的架构的所有模型和数据类型。
resolver.go
:这是程序员创建的代码。
server/server.go
:带有http.Handler
入口点,以启动GraphQL服务器。
看一下
Video
类型的生成模型(文件
generated_video.go
):
type Video struct { ID string `json:"id"` Name string `json:"name"` User User `json:"user"` URL string `json:"url"` CreatedAt string `json:"createdAt"` Screenshots []*Screenshot `json:"screenshots"` Related []Video `json:"related"` }
在这里,您可以看到
ID
是一个字符串,
CreatedAt
也是一个字符串。 相应地配置其他相关模型。 但是,在实际应用中,这不是必需的。 如果使用的是任何类型的SQL数据,则例如,根据所使用的数据库,您需要
ID
字段为
int
或
int64
。
例如,我在此演示应用程序中使用PostgreSQL,因此,当然,我需要
ID
字段为
int
类型,而
CreatedAt
字段为
CreatedAt
类型。 这导致一个事实,我们需要定义自己的模型,并告诉gqlgen我们需要使用我们的模型而不是生成新模型。 这是
models.go
文件的内容:
type Video struct { ID int `json:"id"` Name string `json:"name"` Description string `json:"description"` User User `json:"user"` URL string `json:"url"` CreatedAt time.Time `json:"createdAt"` Related []Video }
我们告诉图书馆它应该使用以下模型(
gqlgen.yml
文件):
schema: - schema.graphql exec: filename: generated.go model: filename: models_gen.go resolver: filename: resolver.go type: Resolver models: Video: model: github.com/ridhamtarpara/go-graphql-demo/api.Video ID: model: github.com/ridhamtarpara/go-graphql-demo/api.ID Timestamp: model: github.com/ridhamtarpara/go-graphql-demo/api.Timestamp
所有这些的关键在于,我们现在拥有自己的
ID
和
Timestamp
定义,以及用于编组
gqlgen.yml
并将它们映射到
gqlgen.yml
文件中的方法。 现在,用户已将字符串作为
ID
,
UnmarshalID()
方法将该字符串转换为整数。 发送响应时,
MarshalID()
方法将数字转换为字符串。
Timestamp
或程序员声明的任何其他标量类型
Timestamp
发生相同的情况。
现在该实现应用程序逻辑了。 打开
resolver.go
文件,并向其中添加突变和查询的描述。 已经有一个自动生成的样板代码,我们需要填充其含义。 这是此文件的代码:
func (r *mutationResolver) CreateVideo(ctx context.Context, input NewVideo) (api.Video, error) { newVideo := api.Video{ URL: input.URL, Name: input.Name, CreatedAt: time.Now().UTC(), } rows, err := dal.LogAndQuery(r.db, "INSERT INTO videos (name, url, user_id, created_at) VALUES($1, $2, $3, $4) RETURNING id", input.Name, input.URL, input.UserID, newVideo.CreatedAt) defer rows.Close() if err != nil || !rows.Next() { return api.Video{}, err } if err := rows.Scan(&newVideo.ID); err != nil { errors.DebugPrintf(err) if errors.IsForeignKeyError(err) { return api.Video{}, errors.UserNotExist } return api.Video{}, errors.InternalServerError } return newVideo, nil } func (r *queryResolver) Videos(ctx context.Context, limit *int, offset *int) ([]api.Video, error) { var video api.Video var videos []api.Video rows, err := dal.LogAndQuery(r.db, "SELECT id, name, url, created_at, user_id FROM videos ORDER BY created_at desc limit $1 offset $2", limit, offset) defer rows.Close(); if err != nil { errors.DebugPrintf(err) return nil, errors.InternalServerError } for rows.Next() { if err := rows.Scan(&video.ID, &video.Name, &video.URL, &video.CreatedAt, &video.UserID); err != nil { errors.DebugPrintf(err) return nil, errors.InternalServerError } videos = append(videos, video) } return videos, nil }
现在让我们测试一下突变。
变异createVideo有效! 但是,为什么用户信息(
user
对象)中没有任何内容? 当使用GraphQL时,适用于类似于“惰性”(lazy)和“贪婪”(渴望)加载的概念。 由于该系统是可扩展的,因此您需要指定哪些字段需要“贪婪地”填写,哪些字段需要“懒惰”。
我向我所在的组织的团队建议了以下与gqlgen一起使用的“黄金法则”:“不要在模型中包括仅在客户要求时才需要加载的字段。”
在我们的情况下,仅当客户端请求这些字段时,我才需要下载有关视频片段的数据(甚至包括用户信息)。 但是由于我们在模型中包括了这些字段,因此gqlgen假定我们通过接收有关视频的信息来提供此数据。 结果,现在我们得到了空结构。
有时,每次都需要某种类型的数据,因此使用单独的请求下载数据是不切实际的。 为此,为了提高性能,可以使用诸如SQL连接之类的方法。 一次(但是,这不适用于此处考虑的示例),我需要将其元数据与视频一起上传。 这些实体存储在不同的位置。 结果,如果我的系统收到了下载视频的请求,则我不得不提出另一个请求以获取元数据。 但是,由于我知道此要求(也就是说,我知道客户端始终需要客户端和视频及其元数据),因此我更喜欢使用贪婪的加载技术来提高性能。
让我们重写模型并再次生成gqlgen代码。 为了不使故事复杂化,我们只为
user
字段编写方法(文件
models.go
):
type Video struct { ID int `json:"id"` Name string `json:"name"` Description string `json:"description"` UserID int `json:"-"` URL string `json:"url"` CreatedAt time.Time `json:"createdAt"` }
我们添加了一个
UserID
并删除了
User
结构。 现在重新生成代码:
go run scripts/gqlgen.go -v
由于此命令,将创建以下接口方法来解析未定义的结构。 此外,您将需要在解析器(
generated.go
文件)中确定以下内容:
type VideoResolver interface { User(ctx context.Context, obj *api.Video) (api.User, error) Screenshots(ctx context.Context, obj *api.Video) ([]*api.Screenshot, error) Related(ctx context.Context, obj *api.Video, limit *int, offset *int) ([]api.Video, error) }
这是定义(
resolver.go
文件):
func (r *videoResolver) User(ctx context.Context, obj *api.Video) (api.User, error) { rows, _ := dal.LogAndQuery(r.db,"SELECT id, name, email FROM users where id = $1", obj.UserID) defer rows.Close() if !rows.Next() { return api.User{}, nil } var user api.User if err := rows.Scan(&user.ID, &user.Name, &user.Email); err != nil { errors.DebugPrintf(err) return api.User{}, errors.InternalServerError } return user, nil }
现在,突变测试结果将如下所示。
变异createVideo我们刚刚讨论的是GraphQL的基础知识,掌握了它之后,您已经可以编写自己的东西。 但是,在您尝试使用GraphQL和Golang进行实验之前,讨论与我们在这里进行的工作直接相关的订阅将很有用。
▍订阅
GraphQL提供了订阅实时发生的数据更改的功能。 gqlgen库允许使用Web套接字实时处理订阅事件。
订阅需要在
schema.graphql
文件中描述。 以下是订阅视频发布事件的说明:
type Subscription { videoPublished: Video! }
现在,再次运行自动代码生成:
go run scripts/gqlgen.go -v
如前所述,在
generated.go
文件中自动创建代码的过程中,创建了必须在识别器中实现的接口。 在我们的例子中,它看起来像这样(
resolver.go
文件):
var videoPublishedChannel map[string]chan api.Video func init() { videoPublishedChannel = map[string]chan api.Video{} } type subscriptionResolver struct{ *Resolver } func (r *subscriptionResolver) VideoPublished(ctx context.Context) (<-chan api.Video, error) { id := randx.String(8) videoEvent := make(chan api.Video, 1) go func() { <-ctx.Done() }() videoPublishedChannel[id] = videoEvent return videoEvent, nil } func (r *mutationResolver) CreateVideo(ctx context.Context, input NewVideo) (api.Video, error) {
现在,在创建新视频时,您需要触发一个事件。 在我们的示例中,这是
for _, observer := range videoPublishedChannel
的行完成的。
现在是时候检查您的订阅了。
验证订阅GraphQL当然具有某些有价值的功能,但是正如他们所说,并不是所有闪闪发光的都是金子。 即,我们正在谈论一个事实,即使用GraphQL的人需要照顾授权,请求的复杂性,缓存,N + 1个请求的问题,查询执行速度的限制以及其他一些事情。 否则,使用GraphQL开发的系统可能会严重降低性能。
先进技术:身份验证,数据加载器,查询复杂性
每次阅读这样的手册时,我都会感觉到,掌握它们后,我会学到某种技术所需的一切知识,并具有解决各种复杂问题的能力。
但是,当我开始自己的项目时,通常会遇到无法预料的情况,例如服务器错误或运行了很长时间的请求,或其他一些死锁情况。 因此,为了做到这一点,我必须更好地研究最近似乎完全可以理解的内容。 我希望在同一本手册中可以避免这种情况。 因此,在本节中,我们将介绍一些使用GraphQL的高级技术。
▍认证
使用REST API时,使用特定端点时,我们具有身份验证系统和标准授权工具。 但是,当使用GraphQL时,仅使用一个端点,因此,可以使用架构指令解决身份验证任务。 如下编辑
schema.graphql
文件:
type Mutation { createVideo(input: NewVideo!): Video! @isAuthenticated } directive @isAuthenticated on FIELD_DEFINITION
我们创建了
isAuthenticated
指令,并将其应用于
createVideo
订阅。 在下一个自动代码生成会话之后,您需要为此指令定义一个定义。 现在,指令以结构方法的形式而不是接口的形式实现,因此我们需要对其进行描述。 我编辑了位于
server.go
文件中的自动生成的代码,并创建了一种返回
server.go
文件的GraphQL配置的方法。 这是
resolver.go
文件:
func NewRootResolvers(db *sql.DB) Config { c := Config{ Resolvers: &Resolver{ db: db, }, }
这是
server.go
文件:
rootHandler:= dataloaders.DataloaderMiddleware( db, handler.GraphQL( go_graphql_demo.NewExecutableSchema(go_graphql_demo.NewRootResolvers(db) ) ) http.Handle("/query", auth.AuthMiddleware(rootHandler))
我们从上下文中读取用户
ID
。 你不觉得这个奇怪吗? 这个含义是如何进入上下文的,为什么它甚至出现在上下文中? 事实是gqlgen仅在实现级别提供请求上下文,因此我们无法读取识别器或指令中的任何HTTP请求数据,例如标头或cookie。 结果,您需要向系统添加自己的中间机制,接收此数据并将其置于上下文中。
现在,我们需要描述我们自己的中间认证机制,以从请求中获取认证数据并进行验证。
这里没有定义逻辑。 取而代之的是,出于授权目的,出于演示目的,仅在此处传递用户
ID
。 然后,该机制在
server.go
与新的配置加载方法结合在一起。
现在,该指令的说明才有意义。 我们不会在中间件代码中处理未经授权的用户请求,因为此类请求将由指令处理。 这是它的外观。
与未经授权的用户一起工作与授权用户一起工作使用模式指令时,甚至可以传递参数:
directive @hasRole(role: Role!) on FIELD_DEFINITION enum Role { ADMIN USER }
▍数据加载器
在我看来,所有这些看起来都很有趣。 您可以在需要时下载数据。 客户端具有管理数据的能力;所需的正是从存储中获取的。 但是,一切都有代价。
为这些机会付出的代价是什么? 查看所有视频的下载日志。 即,我们谈论的是我们有8个视频和5个用户的事实。
query{ Videos(limit: 10){ name user{ name } } }
视频下载详细信息 Query: Videos : SELECT id, name, description, url, created_at, user_id FROM videos ORDER BY created_at desc limit $1 offset $2 Resolver: User : SELECT id, name, email FROM users where id = $1 Resolver: User : SELECT id, name, email FROM users where id = $1 Resolver: User : SELECT id, name, email FROM users where id = $1 Resolver: User : SELECT id, name, email FROM users where id = $1 Resolver: User : SELECT id, name, email FROM users where id = $1 Resolver: User : SELECT id, name, email FROM users where id = $1 Resolver: User : SELECT id, name, email FROM users where id = $1 Resolver: User : SELECT id, name, email FROM users where id = $1
这是怎么回事 为什么会有9个请求(其中1个请求与视频表相关联,而8个与用户表相关联)? 看起来糟透了。 当我以为必须用这个现有的API代替现有的API时,我的心几乎停了下来。确实,数据加载器可以完全解决这个问题。
这就是N + 1问题,我们正在谈论这样一个事实,即有一个查询可获取所有数据,并且对于每条数据(N),还将有一个查询到数据库。
当涉及到性能和资源时,这是一个非常严重的问题:尽管这些请求是并行的,但它们会消耗系统资源。
为了解决这个问题,我们将使用
gqlgen库作者的dataloaden库。 该库允许您生成Go代码。 首先,为
User
实体生成一个数据加载器:
go get github.com/vektah/dataloaden dataloaden github.com/ridhamtarpara/go-graphql-demo/api.User
我们可以使用文件
userloader_gen.go
,该文件具有
Fetch
,
userloader_gen.go
和
Prime
。
现在,为了获得一般结果,我们需要定义
Fetch
方法(
dataloader.go
文件):
func DataloaderMiddleware(db *sql.DB, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userloader := UserLoader{ wait : 1 * time.Millisecond, maxBatch: 100, fetch: func(ids []int) ([]*api.User, []error) { var sqlQuery string if len(ids) == 1 { sqlQuery = "SELECT id, name, email from users WHERE id = ?" } else { sqlQuery = "SELECT id, name, email from users WHERE id IN (?)" } sqlQuery, arguments, err := sqlx.In(sqlQuery, ids) if err != nil { log.Println(err) } sqlQuery = sqlx.Rebind(sqlx.DOLLAR, sqlQuery) rows, err := dal.LogAndQuery(db, sqlQuery, arguments...) defer rows.Close(); if err != nil { log.Println(err) } userById := map[int]*api.User{} for rows.Next() { user:= api.User{} if err := rows.Scan(&user.ID, &user.Name, &user.Email); err != nil { errors.DebugPrintf(err) return nil, []error{errors.InternalServerError} } userById[user.ID] = &user } users := make([]*api.User, len(ids)) for i, id := range ids { users[i] = userById[id] i++ } return users, nil }, } ctx := context.WithValue(r.Context(), CtxKey, &userloader) r = r.WithContext(ctx) next.ServeHTTP(w, r) }) }
在这里,我们等待1毫秒。 在执行请求之前,将请求收集到最多100个请求的程序包中。 现在,装入程序将在访问数据库之前等待指定的时间,而不是分别为每个用户执行请求。 接下来,您需要通过使用使用数据加载器的请求(
resolver.go
文件)重新配置识别器逻辑来更改识别器逻辑:
func (r *videoResolver) User(ctx context.Context, obj *api.Video) (api.User, error) { user, err := ctx.Value(dataloaders.CtxKey).(*dataloaders.UserLoader).Load(obj.UserID) return *user, err }
这是在与上述情况类似的情况下日志的处理方式:
Query: Videos : SELECT id, name, description, url, created_at, user_id FROM videos ORDER BY created_at desc limit $1 offset $2 Dataloader: User : SELECT id, name, email from users WHERE id IN ($1, $2, $3, $4, $5)
此处仅执行两个数据库查询,因此,每个人现在都很高兴。 有趣的是,尽管请求了8个视频的数据,但仅向该请求发送了5个用户标识符。 这表明数据加载器将删除重复的记录。
▍
GraphQL API , . , API DOS-.
, .
Video
, . GraphQL
Video
. . — .
, — :
{ Videos(limit: 10, offset: 0){ name url related(limit: 10, offset: 0){ name url related(limit: 10, offset: 0){ name url related(limit: 100, offset: 0){ name url } } } } }
100, . (, , ) , .
gqlgen , . , (
handler.ComplexityLimit(300)
) GraphQL (300 ). , (
server.go
):
rootHandler:= dataloaders.DataloaderMiddleware( db, handler.GraphQL( go_graphql_demo.NewExecutableSchema(go_graphql_demo.NewRootResolvers(db)), handler.ComplexityLimit(300) ), )
, , . 12. , , , ( , , , , ).
resolver.go
:
func NewRootResolvers(db *sql.DB) Config { c := Config{ Resolvers: &Resolver{ db: db, }, }
, , .
, ,
related
. , , , , .
总结
, ,
GitHub . . , , .
亲爱的读者们! GraphQL , Go?
