1.介绍

什么是gqlgen?

gqlgen是用于构建GraphQL服务器的Go库。

  • gqlgen is based on a Schema first approach-您可以使用GraphQL模式定义语言来定义API。

  • gqlgen优先考虑类型安全性-您在此永远都不会看到map [string] interface {}。

  • gqlgen启用了Codegen-我们生成了无聊的代码,因此您可以专注于快速构建应用程序。

仍不足以说服使用gqlgen?将gqlgen与其他Go graphql实现进行比较

入门

  • 要安装gqlgen,请运行以下命令:在项目目录中运行go get github.com/99designs/gqlgen
  • 您可以通过运行以下命令来初始化新项目:go run github.com/99designs/gqlgen init

您可以在这里找到更全面的指南来帮助您入门。我们还有两个真实的示例,展示了如何使用gqlgen无缝实现GraphQL应用程序。您可以在此处查看这些示例或访问godoc

报告问题

如果您认为已发现错误,或某些行为不符合您的预期,请在GitHub上提出问题

贡献

我们欢迎您的贡献,请阅读我们的贡献准则,以了解有关为gqlgen做出贡献的更多信息

经常问的问题

如何防止获取可能不使用的子对象?
当您像这样嵌套或递归架构时:

1
2
3
4
5
type User {
id: ID!
name: String!
friends: [User!]!
}

您需要告诉gqlgen,它仅应在用户请求时获取friends。有两种方法可以做到这一点;

  • 使用自定义模型

编写忽略friends 字段的自定义模型:

1
2
3
4
type User struct {
ID int
Name string
}

并在gqlgen.yml中引用该模型:

1
2
3
4
# gqlgen.yml
models:
User:
model: github.com/you/pkg/model.User # go import path to the User struct above
  • 使用显式解析器

如果要继续使用生成的模型,请在gqlgen.yml中将字段明确标记为需要解析器,如下所示:

1
2
3
4
5
6
# gqlgen.yml
models:
User:
fields:
friends:
resolver: true # force a resolver to be generated

完成以上任一操作并运行generate之后,我们需要为friends提供一个解析器:

1
2
3
4
func (r *userResolver) Friends(ctx context.Context, obj *User) ([]*User, error) {
// select * from user where friendid = obj.ID
return friends, nil
}

我可以将ID的类型从String更改为Int类型吗?
是!您可以通过如下所示在config中重新映射它:

1
2
3
4
5
models:
ID: # The GraphQL type ID is backed by
model:
- github.com/99designs/gqlgen/graphql.IntID # An go integer
- github.com/99designs/gqlgen/graphql.ID # or a go string

这意味着gqlgen将能够自动绑定到您自己编写的模型的字符串或整数,但是此列表中的第一个模型用作默认类型,并且在以下情况下将始终使用:

  • 基于schema生成模型
  • 作为解析器中的参数

对此没有任何办法,gqlgen无法在给定的上下文中知道您想要什么。

2.入门

在Golang中建立GraphQL服务器

本教程将指导您完成使用gqlgen构建GraphQL服务器的过程,该服务器可以:

  • 返回待办事项清单
  • 创建新的待办事项
  • 标记待办事项待办事项

您可以在这里找到本教程的完成代码

创建项目

为您的项目创建一个目录,并将其初始化为Go模块:

1
2
3
4
$ mkdir gqlgen-todos
$ cd gqlgen-todos
$ go mod init github.com/[username]/gqlgen-todos
$ go get github.com/99designs/gqlgen

构建服务器

创建项目框架

1
$ go run github.com/99designs/gqlgen init

这将创建我们建议的项目布局。 如果需要,可以在gqlgen.yml中修改这些路径。

1
2
3
4
5
6
7
8
9
10
11
12
├── go.mod
├── go.sum
├── gqlgen.yml - The gqlgen config file, knobs for controlling the generated code.
├── graph
│ ├── generated - A package that only contains the generated runtime
│ │ └── generated.go
│ ├── model - A package for all your graph models, generated or otherwise
│ │ └── models_gen.go
│ ├── resolver.go - The root graph resolver type. This file wont get regenerated
│ ├── schema.graphqls - Some schema. You can split the schema into as many graphql files as you like
│ └── schema.resolvers.go - the resolver implementation for schema.graphql
└── server.go - The entry point to your app. Customize it however you see fit

定义您的schema
gqlgen是一个架构优先的库-在编写代码之前,您使用GraphQL架构定义语言描述您的API。 默认情况下,它进入一个名为schema.graphql的文件中,但是您可以根据需要将其分解为多个不同的文件。

为我们生成的schema是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}

type User {
id: ID!
name: String!
}

type Query {
todos: [Todo!]!
}

input NewTodo {
text: String!
userId: String!
}

type Mutation {
createTodo(input: NewTodo!): Todo!
}

实现解析器
gqlgen generate将模式文件(graph/schema.graphqls)与模型graph/model/*进行比较,并且它将在任何可能的地方直接绑定到模型。

如果我们查看graph/schema.resolvers.go,我们将看到gqlgen无法匹配它们的所有情况。 对我们来说是两次:

1
2
3
4
5
6
7
func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
panic(fmt.Errorf("not implemented"))
}

func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
panic(fmt.Errorf("not implemented"))
}

我们只需要实现以下两种方法即可使服务器工作:

首先,我们需要一个跟踪状态的地方,将其放入graph/resolver.go

1
2
3
type Resolver struct{
todos []*model.Todo
}

这是我们在其中声明应用程序(如数据库)的任何依赖关系的地方,当我们创建graph时,它将在server.go中初始化一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
todo := &model.Todo{
Text: input.Text,
ID: fmt.Sprintf("T%d", rand.Int()),
User: &model.User{ID: input.UserID, Name: "user " + input.UserID},
}
r.todos = append(r.todos, todo)
return todo, nil
}

func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
return r.todos, nil
}

现在,我们有一个正常工作的服务器来启动它:

1
go run server.go

然后在浏览器中打开http://localhost:8080。这是一些查询尝试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mutation createTodo {
createTodo(input:{text:"todo", userId:"1"}) {
user {
id
}
text
done
}
}

query findTodos {
todos {
text
done
user {
name
}
}
}

不要急于吸引用户
这个例子很棒,但是在现实世界中,获取大多数对象很多余。 除非用户实际要求,否则我们不希望将用户加载到待办事项上。 因此,让我们将生成的Todo模型替换为更实际的东西。

创建一个名为graph/model/todo.go的新文件

1
2
3
4
5
6
7
8
package model

type Todo struct {
ID string `json:"id"`
Text string `json:"text"`
Done bool `json:"done"`
UserID string `json:"user"`
}

注意

默认情况下,gqlgen将使用模型目录中名称匹配的任何模型,可以在gqlgen.yml中进行配置。

然后运行github.com/99designs/gqlgen generate

现在,如果我们在graph/schema.resolvers.go中查看,我们可以看到一个新的解析器,实现它并修复CreateTodo。

1
2
3
4
5
6
7
8
9
10
11
12
13
func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
todo := &model.Todo{
Text: input.Text,
ID: fmt.Sprintf("T%d", rand.Int()),
UserID: input.UserID, // fix this line
}
r.todos = append(r.todos, todo)
return todo, nil
}

func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
return &model.User{ID: obj.UserID, Name: "user " + obj.UserID}, nil
}

画龙点睛

在我们的resolver.go的顶部,在packageimport之间,添加以下行:

1
//go:generate go run github.com/99designs/gqlgen

这个神奇的注释告诉go generate当我们想重新生成代码时要运行什么命令。 要在整个项目中递归运行go generate,请使用以下命令:

1
go generate ./...

3.配置

如何使用gqlgen.yml配置gqlgen

可以使用gqlgen.yml文件配置gqlgen,默认情况下将从当前目录或任何父目录中加载它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# Where are all the schema files located? globs are supported eg  src/**/*.graphqls
schema:
- graph/*.graphqls

# Where should the generated server code go?
exec:
filename: graph/generated/generated.go
package: generated

# Enable Apollo federation support
federation:
filename: graph/generated/federation.go
package: generated

# Where should any generated models go?
model:
filename: graph/model/models_gen.go
package: model

# Where should the resolver implementations go?
resolver:
layout: follow-schema
dir: graph
package: graph
filename_template: "{name}.resolvers.go"

# Optional: turn on use ` + "`" + `gqlgen:"fieldName"` + "`" + ` tags in your models
# struct_tag: json

# Optional: turn on to use []Thing instead of []*Thing
# omit_slice_element_pointers: false

# Optional: set to speed up generation time by not performing a final validation pass.
# skip_validation: true

# gqlgen will search for any type names in the schema in these go packages
# if they match it will use them, otherwise it will generate them.
autobind:
- "github.com/your/app/graph/model"

# This section declares type mapping between the GraphQL and go type systems
#
# The first line in each type will be used as defaults for resolver arguments and
# modelgen, the others will be allowed when binding to fields. Configure them to
# your liking
models:
ID:
model:
- github.com/99designs/gqlgen/graphql.ID
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32
Int:
model:
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int3

一切都有默认值,因此可以根据需要添加。

带指令的内联配置

gqlgen附带了一些内置指令,这使得管理接线更加容易。

要开始使用它们,您首先需要定义它们:

1
2
3
4
5
6
7
8
9
directive @goModel(model: String, models: [String!]) on OBJECT
| INPUT_OBJECT
| SCALAR
| ENUM
| INTERFACE
| UNION

directive @goField(forceResolver: Boolean, name: String) on INPUT_FIELD_DEFINITION
| FIELD_DEFINITION

gqlgen当前不支持SCALAR,ENUM,INTERFACE或UNION的用户可配置指令。 这仅适用于内部指令。 您可以在此处跟踪进度

现在,您可以在schema中定义类型时使用以下指令:

1
2
3
4
type User @goModel(model: "github.com/my/app/models.User") {
id: ID! @goField(name: "todoId")
name: String! @goField(forceResolver: true)
}

4.参考

4.1 APQ

自动持久查询

默认情况下,当您使用GraphQL时,查询将随每个请求一起传输。 这会浪费大量带宽。 为避免这种情况,您可以使用自动持久查询(APQ)。

使用APQ,您仅将查询哈希发送到服务器。 如果在服务器上未找到哈希,则客户端会再次请求在服务器上向原始查询注册查询哈希。

用法

为了启用自动持久查询,您需要更改客户端。 有关更多信息,请参见自动持久查询链接文档。

对于服务器,您需要实现PersistedQueryCache接口并将实例传递给handler.EnablePersistedQueryCache选项。

请参阅以下使用go-redis软件包的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import (
"context"
"time"

"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/go-redis/redis"
)

type Cache struct {
client redis.UniversalClient
ttl time.Duration
}

const apqPrefix = "apq:"

func NewCache(redisAddress string, password string, ttl time.Duration) (*Cache, error) {
client := redis.NewClient(&redis.Options{
Addr: redisAddress,
})

err := client.Ping().Err()
if err != nil {
return nil, fmt.Errorf("could not create cache: %w", err)
}

return &Cache{client: client, ttl: ttl}, nil
}

func (c *Cache) Add(ctx context.Context, key string, value interface{}) {
c.client.Set(apqPrefix+key, value, c.ttl)
}

func (c *Cache) Get(ctx context.Context, key string) (interface{}, bool) {
s, err := c.client.Get(apqPrefix + key).Result()
if err != nil {
return struct{}{}, false
}
return s, true
}

func main() {
cache, err := NewCache(cfg.RedisAddress, 24*time.Hour)
if err != nil {
log.Fatalf("cannot create APQ redis cache: %v", err)
}

c := Config{ Resolvers: &resolvers{} }
gqlHandler := handler.New(
generated.NewExecutableSchema(c),
)
gqlHandler.AddTransport(transport.POST{})
gqlHandler.Use(extension.AutomaticPersistedQuery{Cache: cache})
http.Handle("/query", gqlHandler)
}

4.2 变更集

使用地图作为变更集

有时,您需要将状态与nil(未定义和空)区分开。 在gqlgen中,我们使用地图进行此操作:

1
2
3
4
5
6
7
8
type Query {
updateUser(id: ID!, changes: UserChanges!): User
}

type UserChanges {
name: String
email: String
}

然后在配置中将类型设置为map [string] interface {}

1
2
3
models:
UserChanges:
model: "map[string]interface{}"

运行go generate之后,您应该得到一个看起来像这样的解析器:

1
2
3
4
5
6
func (r *queryResolver) UpdateUser(ctx context.Context, id int, changes map[string]interface{}) (*User, error) {
u := fetchFromDb(id)
/// apply the changes
saveToDb(u)
return u, nil
}

我们经常使用mapstructure库通过反射将这些变更集直接直接应用于对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func ApplyChanges(changes map[string]interface{}, to interface{}) error {
dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
ErrorUnused: true,
TagName: "json",
Result: to,
ZeroFields: true,
// This is needed to get mapstructure to call the gqlgen unmarshaler func for custom scalars (eg Date)
DecodeHook: func(a reflect.Type, b reflect.Type, v interface{}) (interface{}, error) {
if reflect.PtrTo(b).Implements(reflect.TypeOf((*graphql.Unmarshaler)(nil)).Elem()) {
resultType := reflect.New(b)
result := resultType.MethodByName("UnmarshalGQL").Call([]reflect.Value{reflect.ValueOf(v)})
err, _ := result[0].Interface().(error)
return resultType.Elem().Interface(), err
}

return v, nil
},
})

if err != nil {
return err
}

return dec.Decode(changes)
}

4.3 数据加载器

使用数据加载器优化N + 1数据库查询

您是否注意到某些GraphQL查询端可以进行数百个数据库查询,这些查询通常包含重复的数据? 让我们来看看为什么以及如何修复它。

查询解析

想象一下,如果您有一个简单的查询,例如:

1
query { todos { users { name } } }

我们的todo.user解析器如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
func (r *todoResolver) UserRaw(ctx context.Context, obj *model.Todo) (*model.User, error) {
res := db.LogAndQuery(r.Conn, "SELECT id, name FROM dataloader_example.user WHERE id = ?", obj.UserID)
defer res.Close()

if !res.Next() {
return nil, nil
}
var user model.User
if err := res.Scan(&user.ID, &user.Name); err != nil {
panic(err)
}
return &user, nil
}

注意:我将在此处使用go的低级sql.DB。 所有这一切都将与您喜欢的ORM一起使用。

查询执行程序将调用Query.Todos解析器,该解析器select * from todo,并返回N个todos。 然后,对于每个待办事项,同时调用Todo_user解析程序,SELECT * from USER where id = todo.user_id

eg:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SELECT id, todo, user_id FROM todo
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?
SELECT id, name FROM user WHERE id = ?

更糟的是? 这些待办事项大多数都由同一用户拥有! 我们可以做得更好。

4.4 Dataloader

我们需要的是一种对所有并发请求进行分组,取出所有重复项并进行存储的方式,以备日后在请求中需要时使用。 数据加载器就是这样,它是一种由Facebook普及的请求范围的批处理和缓存解决方案。

我们将使用dataloaden构建数据加载器。 在具有泛型的语言中,我们可能只是创建一个DataLoader,但golang没有泛型。 相反,我们为实例手动生成代码。

1
2
3
4
go get github.com/vektah/dataloaden
mkdir dataloader
cd dataloader
go run github.com/vektah/dataloaden UserLoader int *gqlgen-tutorials/dataloader/graph/model.User

接下来,我们需要创建新数据加载器的实例,并告诉您如何获取数据。 由于数据加载器是请求范围的,因此非常适合上下文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const loadersKey = "dataloaders"

type Loaders struct {
UserById UserLoader
}

func Middleware(conn *sql.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), loadersKey, &Loaders{
UserById: UserLoader{
maxBatch: 100,
wait: 1 * time.Millisecond,
fetch: func(ids []int) ([]*model.User, []error) {
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i := 0; i < len(ids); i++ {
placeholders[i] = "?"
args[i] = i
}

res := db.LogAndQuery(conn,
"SELECT id, name from dataloader_example.user WHERE id IN ("+strings.Join(placeholders, ",")+")",
args...,
)
defer res.Close()

userById := map[int]*model.User{}
for res.Next() {
user := model.User{}
err := res.Scan(&user.ID, &user.Name)
if err != nil {
panic(err)
}
userById[user.ID] = &user
}

users := make([]*model.User, len(ids))
for i, id := range ids {
users[i] = userById[id]
}

return users, nil
},
},
})
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}

func For(ctx context.Context) *Loaders {
return ctx.Value(loadersKey).(*Loaders)
}

该数据加载器将等待1毫秒以获取100个唯一请求,然后调用提取函数。 这个函数有点丑陋,但是其中一半只是构建SQL!

现在,让我们更新解析器以调用数据加载器:

1
2
3
func (r *todoResolver) UserLoader(ctx context.Context, obj *model.Todo) (*model.User, error) {
return dataloader.For(ctx).UserById.Load(obj.UserID)
}

最终结果?仅2个查询!

1
2
SELECT id, todo, user_id FROM todo
SELECT id, name from user WHERE id IN (?,?,?,?,?)

生成的UserLoader上还有其他一些有用的方法:

  • LoadAll(keys):如果您预先知道,则需要一堆用户
  • Prime(key,user):用于在类似的加载器(usersById,usersByNote)之间同步状态

您可以在此处查看完整的工作示例

4.5 字段收集

确定查询请求哪些字段

知道在解析器中查询哪些字段通常很有用。 拥有此信息可以使解析程序仅从数据源获取所需的字段集,而不是过度获取所有内容并允许gqlgen进行其余操作。

此过程称为字段收集gqlgen自动执行此操作,以便知道哪些字段应成为响应有效负载的一部分。 但是,收集的字段集确实取决于要解析的类型。 查询可以包含片段,而解析程序可以返回接口和联合,因此在知道解析对象的类型之前,无法完全确定所收集字段的集合。

在解析程序中,有几种API方法可用于查询所选字段。

CollectAllFields

CollectAllFields是获取查询字段集的最简单方法。 它将从查询中返回字段名称的字符串片段。 这将是一组唯一的字段,并且将返回所有片段字段,而忽略片段类型条件。

给定以下示例查询:

1
2
3
4
5
6
7
8
9
10
11
query {
foo {
fieldA
... on Bar {
fieldB
}
... on Baz {
fieldC
}
}
}

从解析器调用CollectAllFields将产生一个包含fieldA,fieldB和fieldC的字符串切片。

CollectFieldsCtx

如果需要更多有关匹配的信息,或者收集的字段集应与解析类型的片段类型条件匹配,则CollectFieldsCtx很有用。CollectFieldsCtx带有一个令人满意的参数,该参数应该是解析类型将满足的类型的字符串的一部分。

例如,给定以下架构:

1
2
3
4
5
6
7
8
interface Shape {
area: Float
}
type Circle implements Shape {
radius: Float
area: Float
}
union Shapes = Circle

Circle类型将满足CircleShapeShapes的类型-这些值应传递给CollectFieldsCtx以获取已解析的Circle对象的一组收集字段。

注意

CollectFieldsCtx只是CollectFields的便利包装,它使用解析器上下文中自动传递的选择集调用后面的内容。

实际例子

我们有以下GraphQL查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
query {
flowBlocks {
id
block {
id
title
type
choices {
id
title
description
slug
}
}
}
}

我们不想超载我们的数据库,所以我们想知道请求哪个字段。 这是一个示例,该示例将所有请求的字段都作为方便的字符串片段,可以方便地进行检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func GetPreloads(ctx context.Context) []string {
return GetNestedPreloads(
graphql.GetOperationContext(ctx),
graphql.CollectFieldsCtx(ctx, nil),
"",
)
}

func GetNestedPreloads(ctx *graphql.OperationContext, fields []graphql.CollectedField, prefix string) (preloads []string) {
for _, column := range fields {
prefixColumn := GetPreloadString(prefix, column.Name)
preloads = append(preloads, prefixColumn)
preloads = append(preloads, GetNestedPreloads(ctx, graphql.CollectFields(ctx, column.Selections, nil), prefixColumn)...)
}
return
}

func GetPreloadString(prefix, name string) string {
if len(prefix) > 0 {
return prefix + "." + name
}
return name
}

因此,如果我们在解析器中调用这些助手:

1
2
func (r *queryResolver) FlowBlocks(ctx context.Context) ([]*FlowBlock, error) {
preloads := getPreloads(ctx)

它将导致以下字符串切片:

1
["id", "block", "block.id", "block.title", "block.type", "block.choices", "block.choices.id", "block.choices.title", "block.choices.description", "block.choices.slug"]

4.6 上传文件

Graphql服务器具有内置的Upload标量,可使用分段请求上传文件。
它实现了以下规范https://github.com/jaydenseric/graphql-multipart-request-spec,该规范定义了用于GraphQL请求的可互操作的多部分表单字段结构,供各种文件上传客户端实现使用。

要使用它,您需要在架构中添加Upload标量,它将自动将编组行为添加到Go类型。

配置

可以配置两个用于上传文件的特定选项:

  • uploadMaxSize
    此选项指定用于将请求正文解析为multipart/form-data的最大字节数。
  • uploadMaxMemory
    此选项指定用于将请求正文解析为内存中的multipart/form-data的最大字节数,其余部分存储在磁盘上的临时文件中。

实例

单文件上传

对于此用例,架构可能如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
"The `UploadFile, // b.txt` scalar type represents a multipart file upload."
scalar Upload

"The `Query` type, represents all of the entry points into our object graph."
type Query {
...
}

"The `Mutation` type, represents all updates we can make to our data."
type Mutation {
singleUpload(file: Upload!): Bool!
}

可以使用cURL进行查询,如下所示:

1
2
3
4
curl localhost:4000/graphql \
-F operations='{ "query": "mutation ($file: Upload!) { singleUpload(file: $file) { id } }", "variables": { "file": null } }' \
-F map='{ "0": ["variables.file"] }' \
-F 0=@a.txt

这将调用以下操作:

1
2
3
4
5
6
7
8
9
10
11
12
{
query: `
mutation($file: Upload!) {
singleUpload(file: $file) {
id
}
}
`,
variables: {
file: File // a.txt
}
}

多文件上传

对于此用例,架构可能如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
"The `Upload` scalar type represents a multipart file upload."
scalar Upload

"The `File` type, represents the response of uploading a file."
type File {
id: Int!
name: String!
content: String!
}

"The `UploadFile` type, represents the request for uploading a file with a certain payload."
input UploadFile {
id: Int!
file: Upload!
}

"The `Query` type, represents all of the entry points into our object graph."
type Query {
...
}

"The `Mutation` type, represents all updates we can make to our data."
type Mutation {
multipleUpload(req: [UploadFile!]!): [File!]!
}

可以使用cURL进行查询,如下所示:

1
2
3
4
5
curl localhost:4000/query \
-F operations='{ "query": "mutation($req: [UploadFile!]!) { multipleUpload(req: $req) { id, name, content } }", "variables": { "req": [ { "id": 1, "file": null }, { "id": 2, "file": null } ] } }' \
-F map='{ "0": ["variables.req.0.file"], "1": ["variables.req.1.file"] }' \
-F 0=@b.txt \
-F 1=@c.txt

这将调用以下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
query: `
mutation($req: [UploadFile!]!)
multipleUpload(req: $req) {
id,
name,
content
}
}
`,
variables: {
req: [
{
id: 1,
File, // b.txt
},
{
id: 2,
File, // c.txt
}
]
}
}

有关更多示例,请参见example/fileupload软件包。

Apollo的用法

需要安装apollo-upload-client才能使文件上传与Apollo一起使用:

1
2
3
4
5
6
7
import ApolloClient from "apollo-client";
import { createUploadLink } from "apollo-upload-client";

const client = new ApolloClient({
cache: new InMemoryCache(),
link: createUploadLink({ uri: "/graphql" })
});

然后可以将File对象作为变量传递到您的mutation中:

1
2
3
4
5
6
7
8
9
10
11
12
{
query: `
mutation($file: Upload!) {
singleUpload(file: $file) {
id
}
}
`,
variables: {
file: new File(...)
}
}

4.7 处理错误

在graphql响应中发送自定义错误数据

返回错误

所有解析器仅返回错误并发送给用户。 假定此处返回的任何错误消息都适合最终用户。 如果某些消息不安全,请自定义错误提示器。

多重错误
要返回多个错误,您可以像这样调用graphql.Error函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package foo

import (
"context"

"github.com/vektah/gqlparser/v2/gqlerror"
"github.com/99designs/gqlgen/graphql"
)

func (r Query) DoThings(ctx context.Context) (bool, error) {
// Print a formatted string
graphql.AddErrorf(ctx, "Error %d", 1)

// Pass an existing error out
graphql.AddError(ctx, gqlerror.Errorf("zzzzzt"))

// Or fully customize the error
graphql.AddError(ctx, &gqlerror.Error{
Path: graphql.GetPath(ctx),
Message: "A descriptive error message",
Extensions: map[string]interface{}{
"code": "10-4",
},
})

// And you can still return an error if you need
return false, gqlerror.Errorf("BOOM! Headshot")
}

它们将在响应中以相同顺序返回,例如:

1
2
3
4
5
6
7
8
9
10
11
{
"data": {
"todo": null
},
"errors": [
{ "message": "Error 1", "path": [ "todo" ] },
{ "message": "zzzzzt", "path": [ "todo" ] },
{ "message": "A descriptive error message", "path": [ "todo" ], "extensions": { "code": "10-4" } },
{ "message": "BOOM! Headshot", "path": [ "todo" ] }
]
}

钩子

错误提示器
解析程序返回或从验证返回的所有错误在显示给用户之前都要经过一个钩子。 该挂钩使您能够自定义错误,但是在您的应用中有意义。

默认错误提示器将捕获解析器路径,并在响应中使用Error()消息。

您在创建服务器时更改此设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package bar

import (
"context"
"errors"

"github.com/vektah/gqlparser/v2/gqlerror"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
)

func main() {
server := handler.NewDefaultServer(MakeExecutableSchema(resolvers))
server.SetErrorPresenter(func(ctx context.Context, e error) *gqlerror.Error {
err := graphql.DefaultErrorPresenter(ctx, e)

var myErr *MyError
if errors.As(e, &myErr) {
err.Message = "Eeek!"
}

return err
})
}

将使用与生成该函数相同的解析器上下文来调用此函数,因此您可以提取当前的解析器路径以及可能要通知客户端的其他任何状态。

紧急处理程序
还有一个恐慌处理程序,每当发生恐慌以在停止解析之前向用户正常返回消息时,就会调用该处理程序。 这是通知您的错误跟踪器并将自定义消息发送给用户的好地方。 从这里返回的任何错误也将通过错误提示器。

您在创建服务器时更改此设置:

1
2
3
4
5
6
server := handler.NewDefaultServer(MakeExecutableSchema(resolvers)
server.SetRecoverFunc(func(ctx context.Context, err interface{}) error {
// notify bug tracker...

return errors.New("Internal server error!")
})

4.8 内省

GraphQL的最佳功能之一是强大的发现能力,但有时您不想让其他人探索您的端点。

禁用服务器的自省

要在运行时打开和关闭自省功能,请在启动服务器时传递IntrospectionEnabled处理程序选项:

1
2
3
4
5
6
srv := httptest.NewServer(
handler.GraphQL(
NewExecutableSchema(Config{Resolvers: resolvers}),
handler.IntrospectionEnabled(false),
),
)

禁用身份验证的自省

也可以基于每个请求上下文启用自省。 例如,您可以在基于用户身份验证的中间件中对其进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
srv := httptest.NewServer(
handler.GraphQL(
NewExecutableSchema(Config{Resolvers: resolvers}),
handler.RequestMiddleware(func(ctx context.Context, next func(ctx context.Context) []byte) []byte {
if !userForContext(ctx).IsAdmin {
graphql.GetOperationContext(ctx).DisableIntrospection = true
}

return next(ctx)
}),
),
)

4.9 插件

如何为gqlgen编写插件

插件提供了一种进入gqlgen代码生成生命周期的方式。 为了使用默认插件以外的任何东西,您将需要创建自己的入口点:

使用插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// +build ignore

package main

import (
"flag"
"fmt"
"io/ioutil"
"log"
"os"
"time"

"github.com/99designs/gqlgen/api"
"github.com/99designs/gqlgen/codegen/config"
"github.com/99designs/gqlgen/plugin/stubgen"
)

func main() {
cfg, err := config.LoadConfigFromDefaultLocations()
if err != nil {
fmt.Fprintln(os.Stderr, "failed to load config", err.Error())
os.Exit(2)
}


err = api.Generate(cfg,
api.AddPlugin(yourplugin.New()), // This is the magic line
)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(3)
}
}

编写插件

当前只有两个钩子:

  • MutateConfig:允许插件在代码生成开始之前对配置进行更改。 这使插件可以添加自定义指令,定义类型并实现解析器。 参见modelgen的例子
  • GenerateCode:允许插件生成新的输出文件,有关示例,请参见stubgen

请查看plugin.go以获取可用钩子的完整列表。 这些可能随每个发行版而改变。

4.10 查询复杂度

防止过于复杂的查询
GraphQL提供了一种强大的查询数据的方法,但是将强大的功能掌握在API客户端上也使您面临拒绝服务攻击的风险。 您可以通过限制允许的查询的复杂性,使用gqlgen减轻这种风险。

代价高的查询

考虑一种允许列出博客文章的架构。 每个博客帖子也与其他帖子相关。

1
2
3
4
5
6
7
8
9
type Query {
posts(count: Int = 10): [Post!]!
}

type Post {
title: String!
text: String!
related(count: Int = 10): [Post!]!
}

It’s not too hard to craft a query that will cause a very large response:

1
2
3
4
5
6
7
8
9
10
11
{
posts(count: 100) {
related(count: 100) {
related(count: 100) {
related(count: 100) {
title
}
}
}
}
}

响应的大小随相关字段的每个附加级别呈指数增长。 幸运的是,gqlgen的http.Handler包含了一种防止此类查询的方法。

限制查询复杂度

限制查询复杂度就像使用提供的扩展包进行指定一样简单。

1
2
3
4
5
6
func main() {
c := Config{ Resolvers: &resolvers{} }
srv := handler.NewDefaultServer(blog.NewExecutableSchema(c))
srv.Use(extension.FixedComplexityLimit(5)) // This line is key
r.Handle("/query", srv)
}

现在,任何复杂度大于5的查询都会被API拒绝。 默认情况下,每个字段和深度级别都会使整体查询复杂性加一。 您还可以使用extension.ComplexityLimit动态配置每个请求的复杂度限制。

这有帮助,但是我们仍然有一个问题:返回数组的postsrelated字段比标量titletext字段要昂贵得多。 但是,默认复杂度计算对它们进行平均加权。 将较高的成本应用于数组字段会更有意义。

自定义复杂度计算

为了将更高的成本应用于某些领域,我们可以使用自定义复杂度函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
c := Config{ Resolvers: &resolvers{} }

countComplexity := func(childComplexity, count int) int {
return count * childComplexity
}
c.Complexity.Query.Posts = countComplexity
c.Complexity.Post.Related = countComplexity

srv := handler.NewDefaultServer(blog.NewExecutableSchema(c))
srv.Use(extension.FixedComplexityLimit(5))
http.Handle("/query", gqlHandler)
}

当我们将函数分配给适当的“Complexity”字段时,该函数将用于复杂度计算。 在这里,posts及其related字段根据其count参数的值进行加权。 这意味着客户请求发布的内容越多,查询的复杂度就越高。 就像响应的大小在原始查询中以指数方式增加一样,复杂性也将以指数方式增加,因此任何尝试滥用API的客户端都会很快遇到限制。

通过应用查询复杂性限制并在正确的位置指定自定义复杂性功能,可以轻松地防止客户端使用过多的资源并破坏您的服务。

4.11 解析器

解决graphQL请求
graphQL类型可以通过多种方式绑定到允许许多用例的Go结构。

直接绑定到结构字段名称

这是最常见的用例,其中Go结构上的字段名称与graphQL类型的字段名称匹配。 如果未导出Go struct字段,则不会将其绑定到graphQL类型。

1
2
3
4
5
6
type Car struct {
Make string
Model string
Color string
OdometerReading int
}

然后在您的graphQL模式中:

1
2
3
4
5
6
type Car {
make: String!
model: String!
color: String!
odometerReading: Int!
}

并在gqlgen配置文件中:

1
2
3
models:
Car:
model: github.com/my/app/models.Car

在这种情况下,graphQL类型的每个字段都将绑定到go结构上的相应字段,而忽略字段的大小写

绑定到方法名称

这也是我们要将graphQL字段绑定到Go struct方法的非常常见的用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Person struct {
Name string
}

type Car struct {
Make string
Model string
Color string
OwnerID *string
OdometerReading int
}

func (c *Car) Owner() (*Person) {
// get the car owner
//....
return owner
}

然后在您的graphQL模式中:

1
2
3
4
5
6
7
type Car {
make: String!
model: String!
color: String!
odometerReading: Int!
owner: Person
}

并在gqlgen配置文件中:

1
2
3
4
5
models:
Car:
model: github.com/my/app/models.Car
Person:
model: github.com/my/app/models.Person

在这里,我们看到在Car上有一个名为Owner的方法,因此,如果graphQL请求包含要解析的字段,则将调用Owner函数。

模型方法可以选择将上下文作为其第一个参数。 如果需要上下文,则模型方法也将并行运行。

字段名称不匹配时绑定

当Go结构和graphQL类型不匹配时,可以通过两种方式绑定到字段。

第一种方法是可以将解析器绑定到基于struct标记的结构,如下所示:

1
2
3
4
5
6
7
8
type Car struct {
Make string
ShortState string
LongState string `gqlgen:"state"`
Model string
Color string
OdometerReading int
}

然后在您的graphQL模式中:

1
2
3
4
5
6
7
type Car {
make: String!
model: String!
state: String!
color: String!
odometerReading: Int!
}

然后在gqlgen配置文件中添加以下行:

1
2
3
4
5
struct_tag: gqlgen

models:
Car:
model: github.com/my/app/models.Car

在这里,即使graphQL类型和Go结构具有不同的字段名称,在longState上也会有一个Go结构标签字段匹配,因此state将绑定到LongState。

绑定字段的第二种方法是在配置文件中添加一行,例如:

1
2
3
4
5
6
7
8
type Car struct {
Make string
ShortState string
LongState string
Model string
Color string
OdometerReading int
}

然后在您的graphQL模式中:

1
2
3
4
5
6
7
type Car {
make: String!
model: String!
state: String!
color: String!
odometerReading: Int!
}

然后在gqlgen配置文件中添加以下行:

1
2
3
4
5
6
models:
Car:
model: github.com/my/app/models.Car
fields:
state:
fieldName: LongState

绑定到匿名或嵌入式结构

上面的所有规则都适用于具有嵌入式结构的结构。 这是一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Truck struct {
Car

Is4x4 bool
}

type Car struct {
Make string
ShortState string
LongState string
Model string
Color string
OdometerReading int
}

然后在您的graphQL模式中:

1
2
3
4
5
6
7
8
type Truck {
make: String!
model: String!
state: String!
color: String!
odometerReading: Int!
is4x4: Bool!
}

在这里,来自Go struct Car的所有字段仍将绑定到graphQL模式中与之匹配的各个字段

嵌入式结构是围绕数据访问类型创建精简包装的好方法,示例如下:

1
2
3
4
5
6
7
8
9
type Cat struct {
db.Cat
//...
}

func (c *Cat) ID() string {
// return a custom id based on the db shard and the cat's id
return fmt.Sprintf("%d:%d", c.Shard, c.Id)
}

它将与以下的gqlgen配置文件相关:

1
2
3
models:
Cat:
model: github.com/my/app/models.Cat

绑定优先

如果存在struct_tags配置,则结构标记绑定的优先级高于所有其他类型的绑定。 在所有其他情况下,找到的第一个与graphQL type字段匹配的Go struct字段将是绑定的字段。

4.12 标量

将GraphQL标量类型映射到Go类型

内置助手

gqlgen附带了一些内置的帮助程序,用于常见的自定义标量用例,时间,任意,上载和映射。 将任何这些添加到架构中将自动将编组行为添加到Go类型。

Time

1
scalar Time

将Time GraphQL标量映射到Go time.Time结构。

Map

1
scalar Map

将任意GraphQL值映射到map [string] interface {} Go类型。

Upload

1
scalar Upload

将Upload GraphQL标量映射到graphql.Upload结构,定义如下:

1
2
3
4
5
6
type Upload struct {
File io.Reader
Filename string
Size int64
ContentType string
}

Any

1
scalar Any

将任意GraphQL值映射到interface {} Go类型。

具有用户定义类型的自定义标量

对于用户定义的类型,您可以实现graphql.Marshaler和graphql.Unmarshaler接口,它们将被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package mypkg

import (
"fmt"
"io"
)

type YesNo bool

// UnmarshalGQL implements the graphql.Unmarshaler interface
func (y *YesNo) UnmarshalGQL(v interface{}) error {
yes, ok := v.(string)
if !ok {
return fmt.Errorf("YesNo must be a string")
}

if yes == "yes" {
*y = true
} else {
*y = false
}
return nil
}

// MarshalGQL implements the graphql.Marshaler interface
func (y YesNo) MarshalGQL(w io.Writer) {
if y {
w.Write([]byte(`"yes"`))
} else {
w.Write([]byte(`"no"`))
}
}

然后在.gqlgen.yml中或通过像正常的指令连接类型:

1
2
3
models:
YesNo:
model: github.com/me/mypkg.YesNo

具有第三方类型的自定义标量

有时您无法为类型添加add方法-也许您不拥有该类型,或者它是标准库的一部分(例如,字符串或time.Time)。 为此,我们可以构建一个外部封送处理程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package mypkg

import (
"fmt"
"io"
"strings"

"github.com/99designs/gqlgen/graphql"
)


func MarshalMyCustomBooleanScalar(b bool) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
if b {
w.Write([]byte("true"))
} else {
w.Write([]byte("false"))
}
})
}

func UnmarshalMyCustomBooleanScalar(v interface{}) (bool, error) {
switch v := v.(type) {
case string:
return "true" == strings.ToLower(v), nil
case int:
return v != 0, nil
case bool:
return v, nil
default:
return false, fmt.Errorf("%T is not a bool", v)
}
}

然后在.gqlgen.yml中指向前面没有Marshal | Unmarshal的名称:

1
2
3
models:
MyCustomBooleanScalar:
model: github.com/me/mypkg.MyCustomBooleanScalar

注意:您也可以通过这种方法取消/编组指针类型,只需在Marshal ... func中接受一个指针,然后在Unmarshal ... func中返回一个指针。

有关更多示例,请参见example/scalars软件包。

解析错误

作为自定义标量解组的一部分而发生的错误将返回该字段的完整路径。 例如,给定以下架构...

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extend type Mutation{
updateUser(userInput: UserInput!): User!
}

input UserInput {
name: String!
primaryContactDetails: ContactDetailsInput!
secondaryContactDetails: ContactDetailsInput!
}

scalar Email
input ContactDetailsInput {
email: Email!
}

…以及以下变量:

1
2
3
4
5
6
7
8
9
10
11
{
"userInput": {
"name": "George",
"primaryContactDetails": {
"email": "not-an-email"
},
"secondaryContactDetails": {
"email": "george@gmail.com"
}
}
}

…以及取消整理功能,如果电子邮件无效,该功能将返回错误。 变异将返回包含完整路径的错误:

1
2
3
4
5
6
7
8
9
{
"message": "email invalid",
"path": [
"updateUser",
"userInput",
"primaryContactDetails",
"email"
]
}

4.13 模式指令

使用架构指令实施权限检查

指令有点像其他任何语言的注释。 它们为您提供了一种无需直接绑定到实现即可指定某些行为的方式。 这对于诸如权限检查之类的跨领域问题非常有用。

注意:当前的指令实现仍然相当有限,旨在涵盖最常见的“现场中间件”情况。

在schema中声明

指令与所有其他类型一起在您的模式中声明。 让我们定义一个@hasRole指令:

1
2
3
4
5
6
directive @hasRole(role: Role!) on FIELD_DEFINITION

enum Role {
ADMIN
USER
}

下次运行go generate时,gqlgen会将此指令添加到DirectiveRoot中

1
2
3
type DirectiveRoot struct {
HasRole func(ctx context.Context, obj interface{}, next graphql.Resolver, role Role) (res interface{}, err error)
}

参数为:

  • ctx:父上下文
  • obj:包含要应用的值的对象,例如:
    • 对于字段定义指令,包含字段的对象/输入对象
    • 对于参数指令,包含所有参数的映射
  • next:指令链中的下一个指令,或字段解析器。 应该调用它以获取字段/参数/任何值。 您可以通过不调用下一步进行权限检查等来阻止对该字段的访问。
  • …args:指令的所有args也将被传入。

在模式中使用它

我们现在可以在任何字段定义上调用它:

1
2
3
type Mutation {
deleteUser(userID: ID!): Bool @hasRole(role: ADMIN)
}

实现指令

最后,我们需要实现指令,并在启动服务器时将其传递给它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

func main() {
c := Config{ Resolvers: &resolvers{} }
c.Directives.HasRole = func(ctx context.Context, obj interface{}, next graphql.Resolver, role Role) (interface{}, error) {
if !getCurrentUser(ctx).HasRole(role) {
// block calling the next resolver
return nil, fmt.Errorf("Access denied")
}

// or let it pass through
return next(ctx)
}

http.Handle("/query", handler.GraphQL(todo.NewExecutableSchema(c), ))
log.Fatal(http.ListenAndServe(":8081", nil))
}

5.秘笈

5.1 Apollo

在此快速指南中,我们将在gqlgen中实现示例Apollo Federation服务器。 您可以在示例目录中找到完成的结果。

启用联盟

在您的gqlgen.yml中取消注释federation配置

1
2
3
4
# Uncomment to enable federation
federation:
filename: graph/generated/federation.go
package: generated

创建联合服务器

对于要联合的每个服务器,我们将创建一个新的gqlgen项目。

1
go run github.com/99designs/gqlgen

更新schema反应federated示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Review {
body: String
author: User @provides(fields: "username")
product: Product
}

extend type User @key(fields: "id") {
id: ID! @external
reviews: [Review]
}

extend type Product @key(fields: "upc") {
upc: String! @external
reviews: [Review]
}

生成

1
go run github.com/99designs/gqlgen

然后实现解析器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// These two methods are required for gqlgen to resolve the internal id-only wrapper structs.
// This boilerplate might be removed in a future version of gqlgen that can no-op id only nodes.
func (r *entityResolver) FindProductByUpc(ctx context.Context, upc string) (*model.Product, error) {
return &model.Product{
Upc: upc,
}, nil
}

func (r *entityResolver) FindUserByID(ctx context.Context, id string) (*model.User, error) {
return &model.User{
ID: id,
}, nil
}

// Here we implement the stitched part of this service, returning reviews for a product. Of course normally you would
// go back to the database, but we are just making some data up here.
func (r *productResolver) Reviews(ctx context.Context, obj *model.Product) ([]*model.Review, error) {
switch obj.Upc {
case "top-1":
return []*model.Review{{
Body: "A highly effective form of birth control.",
}}, nil

case "top-2":
return []*model.Review{{
Body: "Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.",
}}, nil

case "top-3":
return []*model.Review{{
Body: "This is the last straw. Hat you will wear. 11/10",
}}, nil

}
return nil, nil
}

func (r *userResolver) Reviews(ctx context.Context, obj *model.User) ([]*model.Review, error) {
if obj.ID == "1234" {
return []*model.Review{{
Body: "Has an odd fascination with hats.",
}}, nil
}
return nil, nil
}

创建federation网关

1
npm install --save @apollo/gateway apollo-server graphql
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { ApolloServer } = require('apollo-server');
const { ApolloGateway } = require("@apollo/gateway");

const gateway = new ApolloGateway({
serviceList: [
{ name: 'accounts', url: 'http://localhost:4001/query' },
{ name: 'products', url: 'http://localhost:4002/query' },
{ name: 'reviews', url: 'http://localhost:4003/query' }
],
});

const server = new ApolloServer({
gateway,

subscriptions: false,
});

server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});

启动所有服务

在单独的终端中:

1
2
3
4
go run accounts/server.go
go run products/server.go
go run reviews/server.go
node gateway/index.js

查询federated网关
apollo文档中的示例都应该起作用,例如

1
2
3
4
5
6
7
8
9
10
11
12
query {
me {
username
reviews {
body
product {
name
upc
}
}
}
}

返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"data": {
"me": {
"username": "Me",
"reviews": [
{
"body": "A highly effective form of birth control.",
"product": {
"name": "Trilby",
"upc": "top-1"
}
},
{
"body": "Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.",
"product": {
"name": "Trilby",
"upc": "top-1"
}
}
]
}
}
}

5.2 认证方式

通过上下文提供身份验证详细信息

我们有一个应用程序,使用HTTP请求中的cookie对用户进行身份验证,我们想在图形中的某个位置检查此身份验证状态。 由于GraphQL与传输无关,我们无法假设甚至会有HTTP请求,因此我们需要使用中间件将这些身份验证详细信息公开给我们的graph。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package auth

import (
"database/sql"
"net/http"
"context"
)

// A private key for context that only this package can access. This is important
// to prevent collisions between different context uses
var userCtxKey = &contextKey{"user"}
type contextKey struct {
name string
}

// A stand-in for our database backed user object
type User struct {
Name string
IsAdmin bool
}

// Middleware decodes the share session cookie and packs the session into context
func Middleware(db *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("auth-cookie")

// Allow unauthenticated users in
if err != nil || c == nil {
next.ServeHTTP(w, r)
return
}

userId, err := validateAndGetUserID(c)
if err != nil {
http.Error(w, "Invalid cookie", http.StatusForbidden)
return
}

// get the user from the database
user := getUserByID(db, userId)

// put it in context
ctx := context.WithValue(r.Context(), userCtxKey, user)

// and call the next with our new context
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
}

// ForContext finds the user from the context. REQUIRES Middleware to have run.
func ForContext(ctx context.Context) *User {
raw, _ := ctx.Value(userCtxKey).(*User)
return raw
}

注意getUserByIDvalidateAndGetUserID已留给用户实现。

现在,当我们创建服务器时,应该将其包装在身份验证中间件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"net/http"

"github.com/99designs/gqlgen/example/starwars"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/go-chi/chi"
)

func main() {
router := chi.NewRouter()

router.Use(auth.Middleware(db))

srv := handler.NewDefaultServer(starwars.NewExecutableSchema(starwars.NewResolver()))
router.Handle("/", playground.Handler("Starwars", "/query"))
router.Handle("/query", srv)

err := http.ListenAndServe(":8080", router)
if err != nil {
panic(err)
}
}

在我们的解析器(或指令)中,我们可以调用ForContext来取回数据:

1
2
3
4
5
6
7
8
9
10
func (r *queryResolver) Hero(ctx context.Context, episode Episode) (Character, error) {
if user := auth.ForContext(ctx) ; user == nil || !user.IsAdmin {
return Character{}, fmt.Errorf("Access denied")
}

if episode == EpisodeEmpire {
return r.humans["1000"], nil
}
return r.droid["2001"], nil
}

Websockets

如果您需要访问websocket初始化有效负载,我们可以使用WebsocketInitFunc执行相同的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func main() {
router := chi.NewRouter()

router.Use(auth.Middleware(db))

router.Handle("/", handler.Playground("Starwars", "/query"))
router.Handle("/query",
handler.GraphQL(starwars.NewExecutableSchema(starwars.NewResolver())),
WebsocketInitFunc(func(ctx context.Context, initPayload InitPayload) (context.Context, error) {
userId, err := validateAndGetUserID(payload["token"])
if err != nil {
return nil, err
}

// get the user from the database
user := getUserByID(db, userId)

// put it in context
userCtx := context.WithValue(r.Context(), userCtxKey, user)

// and return it so the resolvers can see it
return userCtx, nil
}))
)

err := http.ListenAndServe(":8080", router)
if err != nil {
panic(err)
}
}

注意

订阅的寿命很长,如果您的令牌可能超时或需要刷新,则您也应该将该令牌保留在上下文中,并验证它在auth.ForContext中仍然有效。

5.3 CORS

使用rs/cors为gqlgen设置CORS标头

当您的graphql服务器与提供客户端代码的域位于不同的域时,需要跨域资源共享(CORS)标头。 您可以在MDN文档中阅读有关CORS的更多信息。

rs/cors

gqlgen不包含CORS实施,但可以与所有标准的http中间件一起使用。 在这里,我们将使用奇妙的chirs/cors来构建服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
"net/http"

"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/example/starwars"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/go-chi/chi"
"github.com/rs/cors"
)

func main() {
router := chi.NewRouter()

// Add CORS middleware around every request
// See https://github.com/rs/cors for full option listing
router.Use(cors.New(cors.Options{
AllowedOrigins: []string{"http://localhost:8080"},
AllowCredentials: true,
Debug: true,
}).Handler)


srv := handler.NewDefaultServer(starwars.NewExecutableSchema(starwars.NewResolver()))
srv.AddTransport(&transport.Websocket{
Upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// Check against your desired domains here
return r.Host == "example.org"
},
ReadBufferSize: 1024,
WriteBufferSize: 1024,
},
})

router.Handle("/", handler.Playground("Starwars", "/query"))
router.Handle("/query", srv)

err := http.ListenAndServe(":8080", router)
if err != nil {
panic(err)
}
}

5.4 gin

使用Gin设置HTTP处理程序
Gin是net/http路由器的绝佳替代品。 在他们的官方GitHub页面上

Gin是用Go(Golang)编写的Web框架。 它具有类似于martini的API,性能更高,由于使用了httprouter,速度提高了40倍。 如果您需要性能和良好的生产力,您会喜欢Gin的。

以下是一起设置Gin和gqlgen的步骤:

安装gin:

1
$ go get github.com/gin-gonic/gin

在路由器文件中,以两种不同的方法定义GraphQL和Playground端点的处理程序,然后在Gin路由器中进行绑定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import (
"github.com/gin-gonic/gin"

"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
)

// Defining the Graphql handler
func graphqlHandler() gin.HandlerFunc {
// NewExecutableSchema and Config are in the generated.go file
// Resolver is in the resolver.go file
h := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}}))

return func(c *gin.Context) {
h.ServeHTTP(c.Writer, c.Request)
}
}

// Defining the Playground handler
func playgroundHandler() gin.HandlerFunc {
h := playground.Handler("GraphQL", "/query")

return func(c *gin.Context) {
h.ServeHTTP(c.Writer, c.Request)
}
}

func main() {
// Setting up Gin
r := gin.Default()
r.POST("/query", graphqlHandler())
r.GET("/", playgroundHandler())
r.Run()
}

访问gin.Context

在解析程序级别,gqlgen使您可以访问context.Context对象。 访问gin.Context的一种方法是将其添加到上下文中,然后再次检索它。

首先,创建一个gin中间件以将其上下文添加到上下文中。

1
2
3
4
5
6
7
func GinContextToContextMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), "GinContextKey", c)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}

在路由器定义中,使用中间件:

1
r.Use(GinContextToContextMiddleware())

定义一个函数以从context.Context结构中恢复gin.Context:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func GinContextFromContext(ctx context.Context) (*gin.Context, error) {
ginContext := ctx.Value("GinContextKey")
if ginContext == nil {
err := fmt.Errorf("could not retrieve gin.Context")
return nil, err
}

gc, ok := ginContext.(*gin.Context)
if !ok {
err := fmt.Errorf("gin.Context has wrong type")
return nil, err
}
return gc, nil
}

最后,在解析器中,使用先前定义的函数检索gin.Context:

1
2
3
4
5
6
7
8
func (r *resolver) Todo(ctx context.Context) (*Todo, error) {
gc, err := GinContextFromContext(ctx)
if err != nil {
return nil, err
}

// ...
}