GraphQL和Golang

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



初步资料


您可以从官方的GraphQL定义中了解到,这是API的查询语言,也是对现有数据执行此类查询的运行时。 GraphQL提供了特定API中数据的完整且易于理解的描述,使客户可以准确地请求他们所需的信息,仅此而已,就可以随着时间的推移简化API的开发,并为开发人员提供强大的工具。

用于Golang的GraphQL库不多。 特别是,我尝试了诸如Thundergraphqlgraphql-gogqlgen之类的库 。 我应该注意,我尝试过的最好的方法是gqlgen库。

gqlgen库仍处于beta版本中,在撰写本文时,版本为0.7.2 。 图书馆正在迅速发展。 在这里,您可以找到有关其开发计划的信息。 现在gqlgen的官方赞助商是99designs项目,这意味着该库很可能会比以前更快地发展。 该库的主要开发人员是vektahneelance ,而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种标准标量数据类型IntFloatStringBooleanID )不满意。

如果需要使用自己的类型,则可以在schema.graphql声明它们(在我们的示例中,此类型为Timestamp )并在代码中提供它们的定义。 使用gqlgen库时,需要提供用于所有自己的标量类型的封送处理和解封送处理的方法,并使用gqlgen.yml配置映射。

应当指出,在该库的最新版本中,有一项重要更改。 即,从中删除了对已编译二进制文件的依赖性。 因此,应将scripts/gqlgen.go文件添加到项目中, scripts/gqlgen.go以下内容:

 // +build ignore package main import "github.com/99designs/gqlgen/cmd" func main() { cmd.Execute() } 

之后,您需要初始化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字段为intint64

例如,我在此演示应用程序中使用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 } //    int  ID func MarshalID(id int) graphql.Marshaler { return graphql.WriterFunc(func(w io.Writer) {   io.WriteString(w, strconv.Quote(fmt.Sprintf("%d", id))) }) } //        func UnmarshalID(v interface{}) (int, error) { id, ok := v.(string) if !ok {   return 0, fmt.Errorf("ids must be strings") } i, e := strconv.Atoi(id) return int(i), e } func MarshalTimestamp(t time.Time) graphql.Marshaler { timestamp := t.Unix() * 1000 return graphql.WriterFunc(func(w io.Writer) {   io.WriteString(w, strconv.FormatInt(timestamp, 10)) }) } func UnmarshalTimestamp(v interface{}) (time.Time, error) { if tmpStr, ok := v.(int); ok {   return time.Unix(int64(tmpStr), 0), nil } return time.Time{}, errors.TimeStampError } 

我们告诉图书馆它应该使用以下模型( 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 

所有这些的关键在于,我们现在拥有自己的IDTimestamp定义,以及用于编组gqlgen.yml并将它们映射到gqlgen.yml文件中的方法。 现在,用户已将字符串作为IDUnmarshalID()方法将该字符串转换为整数。 发送响应时, 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 {   observer <- newVideo } return newVideo, nil } 

现在,在创建新视频时,您需要触发一个事件。 在我们的示例中,这是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,   }, } //   c.Directives.IsAuthenticated = func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) {   ctxUserID := ctx.Value(UserIDCtxKey)   if ctxUserID != nil {     return next(ctx)   } else {     return nil, errors.UnauthorisedError   } } return c } 

这是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 ,该文件具有Fetchuserloader_gen.goPrime

现在,为了获得一般结果,我们需要定义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,   }, } //  countComplexity := func(childComplexity int, limit *int, offset *int) int {   return *limit * childComplexity } c.Complexity.Query.Videos = countComplexity c.Complexity.Video.Related = countComplexity //   c.Directives.IsAuthenticated = func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) {   ctxUserID := ctx.Value(UserIDCtxKey)   if ctxUserID != nil {     return next(ctx)   } else {     return nil, errors.UnauthorisedError   } } return c } 

, , .







, , related . , , , , .

总结


, , GitHub . . , , .

亲爱的读者们! GraphQL , Go?

Source: https://habr.com/ru/post/zh-CN444346/


All Articles