Go 之 GraphQL 的踩坑指南

Go 之 GraphQL 的踩坑指南

1.REST vs GraphQL

REST 是一个很流行的前后端交互形式的约定,在这种约定下,前端专注于页面,同时与后端进行数据交互;而后端则专注于提供 API 接口。 RESTful API 开发中遇到的问题:

  • 扩展性 :随着 API 数量的不断增加,RESTful API 的接口会变得越来臃肿。
  • 无法按需获取 :一个返回 id、name、age、 city、 addr、 email 的接口,如果仅获取部分信息,如 name、age,却必须返回接口的全部信息,然后从中提取自己需要的。坏处是不仅会增加网络传输量,而且不便于 client 处理数据
  • RESTful API 不好处理的问题 : 比如确保 client 提供的参数是类型安全的,如何从代码生成 API 的文档等。
  • 一个请求无法获取所需全部资源 :例如 client 需要显示一篇文章的内容,同时要显示评论,作者信息,那么就需要调用文章、评论、用户的接口。坏处是造成服务的的维护困难,以及响应时间变长
    RESTful API 通常由多个端点组成,每个端点代表一种资源。所以当 client 需要多个资源是,它需要向 RESTful API 发起多个请求,才能获取到所需要的数据。

Facebook 开源的 GraphQL ,在 Twitter、GitHub 等大公司已做实践,GraphQL 是一种数据查询语言,提供以下的性质:

  • 请求你的数据不多不少 :GraphQL 查询总是能准确获得你想要的数据,不多不少,所以返回的结果是可预测的。
  • 获取多个资源只用一个请求 :GraphQL 查询不仅能够获得资源的属性,还能沿着资源间进一步查询,所以 GraphQL 可以通过一次请求就获取你应用所需的所有数据。
  • 描述所有的可能类型系统: GraphQL API 基于类型和字段的方式进行组成,使用类型来保证应用只请求可能的类型,同时提供了清晰的辅助性错误信息。
  • 使用你现有的数据和代码: GraphQL 让你的整个应用共享一套 API,通过 GraphQL API 能够更好的利用你的现有数据和代码。GraphQL 引擎已经有多种语言实现,GraphQL 不限于某一特定数据库,可以使用已经存在的数据、代码、甚至可以连接第三方的 APIs。
  • API 演进无需划分版本: 给 GraphQL API 添加字段和类型而无需影响现有查询。老旧字段可以废弃,从工具中隐藏。

2. GraphQL 介绍

官网给出的定义:「 GraphQL 既是一种用于 API 的查询语言 也是一个满足你数据查询的运行时 。GraphQL 对你的 API 中的数据提供了一套易于理解的完整描述 ,使得客户端能够准确地获得它需要的数据 ,而且没有任何冗余,也让 API 更容易地随着时间推移而演进,还能用于构建强大的开发者工具」。

  • API 不是用来调用的吗?是的,这正是 GraphQL 的强大之处,引用官方文档的一句话ask exactly what you want
  • 本质上来说 GraphQL 是一种查询语言。
  • 上述的定义比较抽象很难理解,实践过 GraphQL 的使用后能够更加深刻的理解。
  • 数据在本质上是分层的,同时也是关系图。GraphQL 的核心目标就是表达这种关系图。

在 GraphQL 中,通过定义 Schema 和声明 Type 来达到上述描述的功能,需要学习:

  • 对于数据模型的抽象是通过 Type 来描述的 ,那么如何定义 Type?
  • 对于接口获取数据的逻辑是通过 schema 来描述的 ,那么如何定义 schema?

2.1 如何定义 Type

对于数据模型的抽象是通过 Type 来描述的,每一个 Type 有若干 Field 组成,每个 Field 又分别指向某个 Type。

GraphQL 的 Type 简单可以分为两种,一种是 scalar type(标量类型) ,另一种是 object type(对象类型)。

2.1.1 scalar type

GraphQL 中的内建的标量包含 String、Int、Float、Boolean、Enum,标量是 GraphQL 类型系统中最小的颗粒。

2.1.2 object type

仅有标量是不够抽象一些复杂的数据模型,需要使用对象类型。通过对象类型来构建 GraphQL 中关于一个数据模型的形状,同时还可以声明各个模型之间的内在关联(一对多,一对一或多对多)。

一对一模型展示:

type Article {
	id: ID
	text: String
	isPublished: Boolean
	author: User
}

上述代码,声明了一个 Article 类型,它有三个 Field,分别是 id(ID类型)、text(String类型)、isPublished(Boolean类型)以及 author(新建的对象类型User),User 类型的声明如下:

type User {
	id: ID
	name: String
}

2.1.3 Type Modifier

类型修饰符,当前的类型修饰符有两种,分别是 ListRequired ,语法分别为 [Type] 和 Type! ,两者可以组合使用:

  • [Type]! :列表本身为必填项,但内部元素可以为空
  • [Type!] :列表本身可以为空,但是其内部元素为必填
  • [Type!]! :列表本身和内部元素均为必填

2.2 如何定义 Schema

schema 用来描述对于接口获取数据逻辑 ,GraphQL 中使用 Query 来抽象数据的查询逻辑,分为三种,分别是 query(查询)、mutation(更改)、subscription(订阅) 。API 的接口概括起来有 CRUD(创建、获取、更改、删除)四类,query 可以覆盖R(获取)的功能,mutation 可以覆盖( CUD 创建、更改、删除)的功能。

注意: Query 特指 GraphQL 中的查询(包含三种类型),query 指 GraphQL 中的查询类型(仅指查询类型)。

2.2.1 Query

  • query(查询):当获取数据时,选择 query 类型
  • mutation(更改): 当尝试修改数据时,选择 mutation 类型
  • subscription(订阅):当希望数据更改时,可以进行消息推送,使用 subscription 类型(针对当前的日趋流行的 real-time 应用提出的)。

以 Article 为数据模型,分别以 REST 和 GraphQL 的角度,编写 CURD 的接口

  • Rest 接口
GET  /api/v1/articles/
GET /api/v1/article/:id/
POST /api/v1/article/
DELETE /api/v1/article/:id/
PATCH /api/v1/article/:id/
  • GraphQL Query
query  {
        articles():[Article!]!
        article(id: Int!): Article!
}
mutation {
        createArticle(): Article!
        updateArticle(id: Int): Article!
        deleteArticle(id: Int): Article!
}

注意:

  • GraphQL 是按照类型来划分职能的 query、mutation、subscription,同时必须明确声明返回的数据类型。

2.2.2 Resolver

上述的描述并未说明如何返回相关操作( query、mutation、subscription )的数据逻辑。所有此处引入一个更核心的概念 Resolver(解析函数)。

GraphQL 中默认有这样的约定,Query(包括 query、mutation、subscription )和与之对应的 Resolver 是同名的,比如关于articles(): [Articles!]!这个 query,它的 Resolver 的名字必然叫做 articles。

以已经声明的 articles 的 query 为例,解释下 GraphQL 的内部工作机制

Query {
	articles {
		id
		author {
			name
		}
		comments {
			id
			desc
			author
		}
	}
}

按照如下步骤进行解析:

  1. 首先进行第一次解析,当前的类型是 query 类型,同时Resolver的名字为 articles
  2. 下一步会使用 articles 的 Resolver 获取解析数据,第一层解析完毕。
  3. 下一步对第一层解析的返回值,进行第二层解析,当前 articles 包含三个子 query ,分别是 id、author 和 comments。
  • id 在 Author 类型中为标量类型,解析结束。
  • author 在 articles 类型中为对象类型 User,尝试使用 User 的 Resolver 获取数据,当前 field 解析完毕。
  • 下一步对第二层解析的返回值,进行第三层解析,当前 author 还包含一个 query,name 是标量类型,解析结束。
  • comments 解析同上。

概括总结 GraphQL 大体解析流程就是遇见一个 Query 之后,尝试使用它的 Resolver 取值,之后再对返回值进行解析,这个过程是递归的,直到所有解析 Field 类型是 Scalar Type(标量类型)为止。整个解析过程可以想象为一个很长的 Resolver Chain(解析链)。 GraphQL 在实际使用中常常作为中间层来使用,数据的获取通过 Resolver 来封装,内部数据获取的实现可能基于 RPC、REST、WS、SQL 等多种不同的方式。

3.GraphQL 例子

下面这部分将会展示一个用 graphql-go 库实现的用户管理的例子,包括获取全部用户信息、获取指定用户信息、修改用户名称、删除用户的功能,以及如何创建枚举类型的功能 「完整代码在这里」:

3.1 生成的 schema 文件内容如下:

//mutation 操作可完成C(创建)、U(更新)、D(删除)
type Mutation {
  """[用户管理] 修改用户名称""" //操作注释信息
  changeUserName(
    """用户ID""" //参数注释信息,必传一个Int类型的值
    userId: Int!

    """用户名称"""
    userName: String!
  ): Boolean

  """[用户管理] 创建用户"""
  createUser(
    """用户名称"""
    userName: String!

    """用户邮箱"""
    email: String!

    """用户密码"""
    pwd: String!

    """用户联系方式"""
    phone: Int
  ): Boolean
  
  """[用户管理] 删除用户"""
  deleteUser(
    """用户ID"""
    userId: Int!
  ): Boolean
}
//query 操作,可完成 R(查询)
type Query {
  """[用户管理] 获取指定用户的信息"""
  UserInfo(
    """用户ID"""
    userId: Int!
  ): userInfo

  """[用户管理] 获取全部用户的信息"""
  UserListInfo: [userInfo]!
}
//object type 说明
"""用户信息描述"""
type userInfo {
  """用户email"""
  email: String //字段说明

  """用户名称"""
  name: String

  """用户手机号"""
  phone: Int

  """用户密码"""
  pwd: String

  """用户状态"""
  status: UserStatusEnum

  """用户ID"""
  userID: Int
}
//枚举类型在schema文件中的展示
"""用户状态信息"""
enum UserStatusEnum {
  """用户可用"""
  EnableUser

  """用户不可用"""
  DisableUser
}

注意

  • GraphQL 基于 golang 实现的例子比较少 。
  • GraphQL 的 schema 可以自动生成,具体操作可查看 graphq-cli 文档,步骤大致包括 npm 包的安装、graphql-cli 工具的安装,配置文件的更改(此处需要指定服务对外暴露的地址) ,执行 graphql get-schema 命令。

3.2 GraphQL 的 object type 定义

type UserInfo struct {
    UserID uint64               `json:"userID"`
    Name   string               `json:"name"`
    Email  string               `json:"email"`
    Phone  int64                `json:"phone"`
    Pwd    string               `json:"pwd"`
    Status model.UserStatusType `json:"status"`
}
//这段内容是如何使用 GraphQL 定义枚举类型
var UserStatusEnumType = graphql.NewEnum(graphql.EnumConfig{
    Name:        "UserStatusEnum",
    Description: "用户状态信息",
    Values: graphql.EnumValueConfigMap{
        "EnableUser": &graphql.EnumValueConfig{
            Value:       model.EnableStatus,
            Description: "用户可用",
        },
        "DisableUser": &graphql.EnumValueConfig{
            Value:       model.DisableStatus,
            Description: "用户不可用",
        },
    },
})
//定义 object type, 前端可以按需获取该类型中包含的字段
var UserInfoType = graphql.NewObject(graphql.ObjectConfig{
    Name:        "userInfo",
    Description: "用户信息描述",
    Fields: graphql.Fields{
        "userID": &graphql.Field{
            Description: "用户ID",
            Type:        graphql.Int,
        },
        "name": &graphql.Field{
            Description: "用户名称",
            Type:        graphql.String,
        },
        "email": &graphql.Field{
            Description: "用户email",
            Type:        graphql.String,
        },
        "phone": &graphql.Field{
            Description: "用户手机号",
            Type:        graphql.Int,
        },
        "pwd": &graphql.Field{
            Description: "用户密码",
            Type:        graphql.String,
        },
        "status": &graphql.Field{
            Description: "用户状态",
            Type:        UserStatusEnumType,
        },
    },
})

3.3 query 与 mutation 的定义

var MutationType = graphql.NewObject(graphql.ObjectConfig{
    Name: "Mutation",
    Fields: graphql.Fields{
        "createUser": &graphql.Field{
            Type:        graphql.Boolean,
            Description: "[用户管理] 创建用户",
            Args: graphql.FieldConfigArgument{
                "userName": &graphql.ArgumentConfig{
                    Description: "用户名称",
                    Type:        graphql.NewNonNull(graphql.String),
                },
                "email": &graphql.ArgumentConfig{
                    Description: "用户邮箱",
                    Type:        graphql.NewNonNull(graphql.String),
                },
                "pwd": &graphql.ArgumentConfig{
                    Description: "用户密码",
                    Type:        graphql.NewNonNull(graphql.String),
                },
                "phone": &graphql.ArgumentConfig{
                    Description: "用户联系方式",
                    Type:        graphql.Int,
                },
            },
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                userId, _ := strconv.Atoi(GenerateID())
                user := &model.User{
                  //展示如何解析传入的参数,传入参数必须符合断言
                    Name: p.Args["userName"].(string),
                    Email: sql.NullString{
                        String: p.Args["email"].(string),
                        Valid:  true,
                    },
                    Pwd:    p.Args["pwd"].(string),
                    Phone:  int64(p.Args["phone"].(int)),
                    UserID: uint64(userId),
                    Status: int64(model.EnableStatus),
                }
                ......
                return true, nil

            },
        },
    
    },
})

var QueryType = graphql.NewObject(graphql.ObjectConfig{
    Name: "Query",
    Fields: graphql.Fields{
        "UserListInfo": &graphql.Field{
            Description: "[用户管理] 获取指定用户的信息",
            //定义了非空的 list 类型
            Type:        graphql.NewNonNull(graphql.NewList(UserInfoType)),
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                users, err := model.GetUsers()
                if err != nil {
                    log.WithError(err).Error("[query.UserInfo] invoke InserUser() failed")
                    return false, err
                }
                usersList := make([]*UserInfo, 0)
                for _, v := range users {
                    userInfo := new(UserInfo)
                    userInfo.Name = v.Name
                    userInfo.Email = v.Email.String
                    userInfo.Phone = v.Phone
                    userInfo.Pwd = v.Pwd
                    userInfo.Status = model.UserStatusType(v.Status)
                    usersList = append(usersList, userInfo)

                }
                return usersList, nil

            },
        },
    },
})

注意

  • 此处仅展示了部分例子。
  • 此处笔者仅列举了 query、mutation 类型的定义。

3.4 如何定义服务 main 函数


func main() {
    ......
    //new graphql schema
    schema, err := graphql.NewSchema(
        graphql.SchemaConfig{
            Query:    object.QueryType,
            Mutation: object.MutationType,
        },
    )

    //此次从 http 请求的 header中获取 user_id 的值,然后通过 context 向后续操作传递
    http.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) {
        ctx := context.Background()
        //read user_id from gateway
        userIDStr := r.Header.Get("user_id")
        if len(userIDStr) > 0 {
            userID, err := strconv.Atoi(userIDStr)
            if err != nil {
                w.WriteHeader(http.StatusBadRequest)
                w.Write([]byte(err.Error()))
                return
            }
            ctx = context.WithValue(ctx, "ContextUserIDKey", userID)
        }
        h.ContextHandler(ctx, w, r)

    })
    log.Fatal(http.ListenAndServe(svrCfg.Addr, nil))
}

4.总结

笔者在实践 GraphQL 的过程中,发现存在以下问题:

  • 除了 Facebook 官方的 Node.js 版本支持的比较好,其他版本的文档和实践都比较少。
  • graphql-go 库亲测存在n + 1 问题,建议使用 graph-gophers 下的 graphql-go 的库,因为 Features 里明确写着parallel execution of resolvers
  • Rest 和 GraphQL 都是服务端承载的系统对外的接口,二者是可以共存的。
  • GraphQL 更容易造成拒绝服务攻击,在使用时要特别小心。
  • GraphQL 的利好主要是在于前端的开发效率,但落地却需要服务端的全力配合。

如果是一家没有技术包袱的小公司,根据接口变动频繁等的业务的特性,考虑选择使用 GraphQL 完全可以理解;但如果是一个大公司,对外已经全部使用 RESTful API ,基于人力成本的考虑不使用 GraphQL 也是合理的。无论 GraphQL 还是 RESTful API ,因地制宜选择适合的才是好的。虽然 GraphQL 对于 Facebook 这样的公司是合适的,但不可能其他所有公司的业务需求都跟 Facebook 相同。

笔者初次接触 GraphQL ,不免有理解有误的地方,欢迎指出。

5.参考资料

编辑于 2018-07-04

文章被以下专栏收录