GraphQL 的一些项目经验
0x00 前言
本文最早行文于 2018 年中,那时 GraphQL 的生态尚未成熟,也缺乏社区总结的一些经验。
2020 年末,复盘一下 GraphQL 的一些使用经验。
名词约定
- 接口生产端 / 服务端 下面统一称「生产端」
- 接口消费端 / 客户端 下面统一称「消费端」
0x01 流水的技术方案,铁打的需求。
年幼时看笑傲江湖,华山派两派居然为了剑宗和气宗争个你死我活。
觉得甚是幼稚。
年纪渐大之后发现社会处处充满着这种荒谬的争论。
- 剑宗 or 气宗
- 自然美 or 人造美
- Java or Python
- Editor or IDE
- Rest or GraphQL
- 单体应用 or 微服务
剑招是死的,人是活的。
打个比方,当讨论问题的时候,下面两个问题意义可能并不是很大。
- 用剑宗初学者和气宗高手比,或者相反,这个完全没有多大意义?
- 用剑宗初学者和气宗初学者比,有一点意义,但意义也不是很大。
GraphQL 作为挑战者,下面几个问题是很有意义的。
GraphQL 相比于原先成熟稳定的方案,GraphQL
- 现有方案存在哪些问题?
- 新方案解决了哪些原先没有解决的问题。
- 新方案更低成本解决了哪些问题。
- 现有的痛点是否存在一些新老方案都无法解决的问题。
- 有无前车之鉴,有无社区最佳实践。
- 迁移成本和学习成本是多少。
- 回滚成本多少。
0x02 RESTful 的缺点
RESTful 有很多接口上最佳实践,但生搬硬套就会使得接口比较诡异。
这里只说生搬硬套 RESTFul 的带来的缺点,对于大部分公司来说,这些缺点远远抵不上带来的优点。
剑招是死的,人是活的。
语义不明确
比如,业务具备一定复杂性的时候,语义容易不明确。
预览订单和下单两个接口,如果遵循如下的标准
- 接口路径使用名词而非动词
- GET 不修改,POST/PUT/DELETE 修改状态
- 使用子字段来表述关系,查询订单 GET /shop/711/orders/
严格遵循 RESTful 的话,接口就会成这个样子
GET /shops/1/order
{
"products": [...]
}
PUT /shops/1/order
{
"products": [...]
}
反正我是看不出来这是一坨啥玩意的,至于如果你硬性规范 PUT order 就是创建订单,就会出现接下来两个接口完全不知道该怎么写
- 支付订单 (用 patch /shop/1/order)
- 取消订单 (用 delete 方法,也没有 delete 呀)
- 产品上,用户删除了一台机器,那就是删除了一台机器?这里是软删。
- 产品上,管理员删除了一台机器,那就是删除了一台机器?机器记录还在的。
剑招是死的,人是活的。
不严格遵循 RESTful 的时候可以这么写。
POST /preview_order
{
"shop_id": 123,
"products": [...]
}
POST /make_order
{
"shop_id": 123,
"products": [...]
}
POST /pay_order
{
"order_id": "202012001231237",
"balance": "20.20",
}
POST /cancel_order
{
"order_id": "202012001231237"
}
至于第三点的子字段表述关系,是一个典型的反面教材
引用 Python 之禅来表达
Flat is better than nested.
扁平优于嵌套
得到即是失去 - Get’s loss
GET 让问题变得复杂
Rest 时代一个有代表性的问题就是如何在 GET 方法里处理以下四个问题
- 过滤 filter
- 排序 sort
- 字段裁剪 field selections
- 分页 pagination
为了解决这个问题,一个稍微复杂的查询接口就成了如下的样子
GET /cars?seats<=2&sort=-manufactorer,+model&fields=manufacturer,model,id,color&page=1&per_page=10
可阅读性较差
而如果这么写,则前端无需把原先的 queryparams 转化成逗号结尾的字符串,后端也需要跟着解析并且转成自己想要的格式
如果按照如下的写法,后端则可以直接使用 json 标准库里的数据类型。
POST /cars
{
"filters": [
{
"seat__lt": 2,
}
]
"sort": ["-manufactorer", "+model"],
"fields": ["manufacturer","model","id","color"],
"pagination": {
"page": 1,
"per_page": 10
}
}
未很好解决的问题
细粒度和正交性
RESTFul 接口提倡的是细粒度、正交性。
这样的接口是为了更加方便的复用多个接口来组成页面,但是并非没有问题。 细粒度和正交性意味着接口是 building block, 而 building block 组合起来是交给前端来做的。 这意味着前端为了组合多个资源,则需要发起多次请求。
这带来了额外的网络开销成本(随着大家都使用上 http2 之后可能开销问题会极大的减少)和响应内容拼装成本
HTTP 2 解决了首部开销、多路复用等 HTTP 1.1 面临的问题,还做到了完全向后兼容。
请求多个接口带来的另一个问题是
- 服务端给的怎么这么少
- 服务端给的实在是太多了
文档的滞后性
接口开出来是没有文档的。而维护文档则会带来一定的成本,文档本身也具备一定的滞后性。
0x03 GraphQL 是新的颠覆者么?
GraphQL 是什么?
GraphQL 也不是什么革命性的产品,不过是一种新的开 Web 接口的方式罢了。
而请求接口提交的参数也变成了包含参数的一种查询语言。
官方宣传的 GraphQL 搞好了,只需如此:
# Step1. 描述你的数据
type Project {
name: String
tagline: String
contributors: [User]
}
# Step2. 请求所需数据。
{
project(name: "GraphQL") {
tagline
}
}
# Step3. 拿到所需数据。
{
"project": {
"tagline": "A query language for APIs"
}
}
嗯,是不是和 SQL 看起来有点像?当然咯,都是查询语言 (QL) 嘛。
GraphQL 给消费端带来的好处
GraphQL 的好处,自然是接口消费端写起来就是一个字,爽。
比如我们使用 Github 的 API 做一个简单的查询
地址如下 https://developer.github.com/v4/explorer/
查询如下
query {
viewer {
login
bio
repositories(first:10, orderBy : { field: STARGAZERS, direction: DESC} ) {
nodes {
nameWithOwner
viewerHasStarred
watchers {
totalCount
}
stargazers {
totalCount
}
}
totalCount
}
}
}
结果如下
{
"data": {
"viewer": {
"login": "twocucao",
"bio": "Pythonist In Shanghai.",
"repositories": {
"nodes": [
{
"nameWithOwner": "twocucao/danmu.fm",
"viewerHasStarred": true,
"watchers": {
"totalCount": 22
},
"stargazers": {
"totalCount": 282
}
},
{
"nameWithOwner": "twocucao/YaDjangoBlog",
"viewerHasStarred": true,
"watchers": {
"totalCount": 9
},
"stargazers": {
"totalCount": 120
}
},
{
"nameWithOwner": "twocucao/danmu",
"viewerHasStarred": true,
"watchers": {
"totalCount": 5
},
"stargazers": {
"totalCount": 64
}
}
],
"totalCount": 33
}
}
}
}
如果你说,我还要看看这个 repo 的主要语言,那么增加一行查询即可。
query {
viewer {
login
bio
repositories(first:3, orderBy : { field: STARGAZERS, direction: DESC} ) {
nodes {
nameWithOwner
viewerHasStarred
languages(first: 3) { # 在这里增加查询编程语言
nodes {
name
}
}
watchers {
totalCount
}
stargazers {
totalCount
}
}
totalCount
}
}
}
依照这个简单的查询,可以看出 GraphQL 的便捷之处。后端编写完毕之后,前端基本上就可以对着 Schemas 里面的 query 查询完毕了。
查询这么搞可以,增删改查之类的操作呢?
GraphQL 里面还有 Mutation 可以帮你解决这个问题,在后端定义完 Mutation , 前端在 Mutation 里面可以直接传参。
0x04 GraphQL 给生产端带来的挑战
消费端爽了,是否意味着生产端会爽呢?
并不是
- 对于前端来说,如果要消费 graphql api, 相对容易一些。 只需将原先的 api 层改为 graphqlfetch 即可
- 对于后端来说,如果要生产 graphql api, 则麻烦了不少。
下文我会以 graphene 这个 graphql python 库来说明带来的一些挑战
https://github.com/graphql-python/graphene
有的挑战是和 graphql 相关的,有的挑战则是来自 graphene 库,或许其他库有更好的解决方案。
大致有下面的挑战
- View 层代码组织剧变
- 权限问题
- 版本更迭
- required vs nullable vs blank
- 服务端缓存
- 嵌套式 API
- N+1 问题
- 为名所困
View 层代码组织剧变
import graphene
class PersonInput(graphene.InputObjectType):
name = graphene.String(required=True)
age = graphene.Int(required=True)
class CreatePerson(graphene.Mutation):
class Arguments:
person_data = PersonInput(required=True)
person = graphene.Field(Person)
def mutate(root, info, person_data=None):
person = Person(
name=person_data.name,
age=person_data.age
)
return CreatePerson(person=person)
class Person(graphene.ObjectType):
name = graphene.String()
age = graphene.Int()
class MyMutations(graphene.ObjectType):
create_person = CreatePerson.Field()
# We must define a query for our schema
class Query(graphene.ObjectType):
person = graphene.Field(Person)
schema = graphene.Schema(query=Query, mutation=MyMutations)
同样的逻辑用原先的方法只需如此即可。
@bp.get("/person")
def get_person():
# do get person
return data
class DataValidator(validator):
name: constr(min_length=2, max_length=10)
age: conint(gte=1, lte=200)
@bp.post("/person_create")
def person_create(data: DataValidator):
# do create person
return data
带来的挑战如下
1. 原先简单清晰的代码变成了非常冗长的代码。
- 为了提升输入速度,则需要使用 code generator 或者 snippet 。
- 提升了输入速度,但是由于写完之后代码阅读带来较多无效阅读,反而造成了诸多不便。
2. 校验层没了。而增加校验层变得比较麻烦。
3. 随着代码量上升,相同业务逻辑的物理距离变得比较长,query 和 mutation 需要拆分到两个文件或者一个文件的上部分和下部分。
- 假设你写的是一个商城,从上到下阅读代码会发现一会儿是商品基本信息,一会儿是分类,一会儿是订单,一会儿又是分类,或者 order 的逻辑查看在一个文件的犄角旮旯,修改在另一个文件的犄角旮旯。
- 假设查询订单和修改订单两个接口查询,查看与修改所查询的业务逻辑是一样的。由于物理距离过于远,则大概率会写两遍这个代码。Same Logic, Write Everywhere
权限问题
接口的正交性带来的是细粒度的控制,而聚合起来之后,权限则不是很好管控。
一个请求打过来包含了越权和非越权的请求。
服务端需要如何判断?
保大保小?
版本更迭
随着业务的变更,往往需要新增 / 废弃一些接口。
- 接口新增只需要考虑 v2 版本
- 接口移除通过监控接口的请求次数来解决这个问题。
graphql 社区推荐的是不废弃接口….
required vs nullable vs blank
- not required 表示这个字段不是必须的,可传可不传。
- required 表示这个字段是必须的,但可为 null/none, 也可不为。
- not nullable 表示这个字段是必须的,但是不能为 null/none
- nullable 表示这个字段是必须的,但可为 null/none, 也可不为。
- not blank 表示这个字段是可为空的
- 对于字符串,则必须要不为 ""
- 对于列表,则必须要存在至少一个 item
举个例子来说明,如果要表示如下的响应内容
订单没有支付状态,就没有支付方式,即不应该有支付方式这个字段。
# 未支付订单
{
"status": "NOT_PAID"
}
# 已支付订单
{
"status": "PAID",
"pay_method": "WECHAT"
}
graphene 没法做到未支付订单无 pay_method 这种情况,在响应体里 pay_method 总是为 null
# 未支付订单
{
"status": "NOT_PAID"
"pay_method": null
}
对于消费 API 的客户端,需要按照具体情况来判断 null 值的现实意义是否存在。
服务端缓存
要查什么,往往不可知(无法预知查询语句),随前端来定。
而通过 URI 定位资源,Http 协议无状态,非常容易实现对应用层透明的缓存。
而 GraphQL 重新定义了资源的定位方式,
于是乎,我在设计缓存的时候往往不知道该怎么设计。
嵌套式 API
因为过度讲究复用,node 和 node 之间连接关系较为复杂。
比如,设计应用的初期你有这么一个商品 api
product {
template {
id
name
price
category {
id
name
}
}
id
}
随着业务变得越来越复杂,product 很可能和 template 模板解耦,
在这种情况下,最好的做法当然是新增一个接口。
productV2 {
id
name
price
category {
id
name
}
}
但老接口总不能挂掉吧。那么为了解决这个问题,后端需要向后兼容代码重写 product->template 里面的 name、price
但在 graphql 的场景下,重写 resolver 是一个非常的影响是未知的。
因为,你不知道有多少个 query 用了这个 field, 为了知道这个问题,你还得打点知道有多少个场景的 query 嘛。
你以为打点就知道有多少个场景的 query 嘛?前端起名字都不通知你的。
现实生活中情况可能复杂一些。这个例子旨在说明,存在一些改动的心智负担比较严重。 比如,前端不便,所有商家级别的数据落到店铺级别。
这个例子正确的解法是联合前端一起升级到 V2
N+1 问题
有的时候需要看情况来解决不同的 N+1 问题,也是比较迷惑的….
因为客户端不见得会按照你的要求来查询。
为名所困
项目写了半年之后,我只记得我一直在各种起名困难症中度过。
明明业务流程都差不多,但最后结果全是在改各种各样的的名字前缀。
0x05 如何应对挑战
经过实践,我最后得出的结论是,对用户端的请求做一部分的裁剪,写成 restful like 的 graphql
- 减少接口的嵌套,尽量在 2~3 层,不超过 5 层。
- 使用 Pydantic 作为接口校验,并且自动生成 ObjectType 与 InputObjectType
顺应 restful like 的 view 层组织方式
@router.item("/person")
def get_person(id: int):
# do get person
return data
class AParams(Params):
name: constr(min_length=2, max_length=10)
age: conint(gte=1, lte=200)
@router.list("/persons")
def get_persons(params: AParams):
# do get persons
return data
class BParams(PageParams):
name: constr(min_length=2, max_length=10)
age: conint(gte=1, lte=200)
@router.paginate("/person_pagination")
def get_person_pagination(params: BParams):
# do get persons
return data
class CParams(validator):
name: constr(min_length=2, max_length=10)
age: conint(gte=1, lte=200)
@route.mutation("/person_create")
def person_create(params: CParams):
# do create person
return data
通过这种方式,可以保持原先的代码结构,减少原先从 Restful 过来的迁移成本。
版本更迭
版本照常更迭,V1/V2 也用上。
服务端缓存
直接序列化塞到 redis 里面
N+1 问题
-
https://yacine.org/2017/02/27/graphqlgraphene-sqlalchemy-and-the-n1-problem/
-
https://github.com/graphql-python/graphene-sqlalchemy/issues/35
required vs nullable vs blank
比如对于一个 Mutation 来说
{
"a": 1
}
表示的是设置 a 为 1
{
"a": null
}
表示的是设置 a 为 null
{
}
表示的是不设置 a
blank 交给 validator 来做
对于消费 API 的客户端,则需要按照具体情况来判断 null 值的现实意义是否存在。
结论
这样组织代码,代码量比原先少了很多。一番实践下来,手贱率导致的问题直线下降。
0x05 其他思考点
Rest Or GraphQL 无法解决的问题
业务逻辑的沟通问题
抛开前后端分离所带来的优点,前后端分离的一大问题就是,前后端人员的分离。
如果你的公司规模不是很大,开发的项目需要一定的背景知识,团队开发比较(经常)敏(调整)捷(需求), 沟通上就会有些问题,
具体表现为,后端理解了需求,还需要和前端讲清楚,而刚讲清楚没几天,产品又跑过来说,这个业务我们需要调整一下。几次折腾下来,就会出现一个尴尬的局面。
前后端有的时候对业务理解出现来偏差。在以前不区分前后端的时候,一个东西到实现基本上一个人就可以 Cover 掉,只要这个人理解了业务逻辑,就行了。现在前后端分离,就要求前后端对需求的理解
如果需求不明确,就会出现如下情况,前端一脸懵逼的问后端与产品『眼前的黑不是黑,你说的白是什么白』
当然,这个不属于技术问题,只是提一下。
0xEE 参考链接
ChangeLog:
- 2017-01-20 初始化本文