本文最早行文于 2018 年中,那时 GraphQL 的生态尚未成熟,也缺乏社区总结的一些经验。
较早入了坑,回首百年身
2021 年中,复盘一下 GraphQL 的一些使用经验。
名词约定
年幼时看笑傲江湖,华山派两派居然为了剑宗和气宗争个你死我活。
觉得甚是幼稚。
年纪渐大之后发现社会处处充满着这种「谁好」的争论。
剑招是死的,人是活的。
当讨论问题的时候,下面两个问题意义可能并不是很大。
GraphQL 作为挑战者,下面几个问题是很有意义的。
RESTful 有很多接口上最佳实践,但生搬硬套就会使得接口比较诡异。
这里只说生搬硬套 RESTFul 的带来的缺点,对于大部分公司来说,这些缺点远远抵不上带来的优点。
比如,业务具备一定复杂性的时候,语义容易不明确。
预览订单和下单两个接口,如果遵循如下的标准
严格遵循 RESTful 的话,接口就会成这个样子
GET /shops/1/order { "products": [...] }
PUT /shops/1/order { "products": [...] }
反正我是看不出来这是一坨啥玩意的,至于如果你硬性规范 PUT order 就是创建订单,就会出现接下来两个接口完全不知道该怎么写
支付订单 (用 patch /shop/1/order)
取消订单 (用 delete 方法,也没有 delete 呀)
退款订单
部分退款订单
所以,采用的是 DELETE /machine/1
剑招是死的,人是活的。
不严格遵循 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 让问题变得复杂
Rest 时代一个有代表性的问题就是如何在 GET 方法里处理以下四个问题
为了解决这个问题,一个稍微复杂的查询接口就成了如下的样子
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 组合起来是交给前端来做的。 这意味着前端为了组合多个资源,则需要发起多次请求。
这首先是带来了的网络开销成本 (HTTP 2 解决了首部开销、多路复用等 HTTP 1.1 面临的问题,还做到了完全向后兼容。) 和响应内容拼装成本
其次,前端往往会抱怨
于是大家采用了 swagger/openapi 的配套解决方案。同时带来了,文档的缺失和滞后
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 的好处,自然是接口消费端写起来就是一个字,爽。
比如我们使用 Github 的 API 做一个简单的查询
地址如下 https://link.zhihu.com/?target=https%3A//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": "FullStack Pythonist In Shanghai",
"repositories": {
"nodes": [
{
"nameWithOwner": "twocucao/YaDjangoBlog",
"viewerHasStarred": true,
"watchers": {
"totalCount": 12
},
"stargazers": {
"totalCount": 326
}
},
{
"nameWithOwner": "twocucao/danmu.fm",
"viewerHasStarred": true,
"watchers": {
"totalCount": 23
},
"stargazers": {
"totalCount": 316
}
},
{
"nameWithOwner": "twocucao/YaVueBlog",
"viewerHasStarred": true,
"watchers": {
"totalCount": 8
},
"stargazers": {
"totalCount": 141
}
},
{
"nameWithOwner": "twocucao/danmu",
"viewerHasStarred": true,
"watchers": {
"totalCount": 5
},
"stargazers": {
"totalCount": 69
}
},
{
"nameWithOwner": "twocucao/silverhand",
"viewerHasStarred": true,
"watchers": {
"totalCount": 7
},
"stargazers": {
"totalCount": 60
}
},
{
"nameWithOwner": "twocucao/ChortHotKey",
"viewerHasStarred": true,
"watchers": {
"totalCount": 7
},
"stargazers": {
"totalCount": 32
}
}
// ......
],
"totalCount": 155
}
}
}
}
如果你说,我还要看看这个 repo 的主要语言,那么增加一行查询即可。如图。
是不是很方便?前端基本对着 Schemas 里面的 query 查询完毕就完毕了。
查询这么搞可以,增删改查之类的操作呢?
GraphQL 里面还有 Mutation 可以帮你解决这个问题,在后端定义完 Mutation , 前端在 Mutation 里面可以直接传参。
甚至,后端开出接口时,可以依据请求和响应生成对应的 typescript 代码,然后享受代码补全的快感。
极大利好前端
有人说, graphql 不就最后生成了 schema 嘛
可不要小瞧了生成全接口的 schema, 用它可以做很多东西
消费端(前端)爽了,是否意味着生产端(服务端)会爽呢?
并不是
下文我会以 graphene 这个 graphql python 库来说明带来的一些挑战
https://link.zhihu.com/?target=https%3A//github.com/graphql-python/graphene
有的挑战是和 graphql 相关的,有的挑战则是来自 graphene 库,或许其他库有更好的解决方案。
注意!痛点背后的原因,大多是 graphql 以及配套库玩的不熟。而不一定是 RESTFUL 不存在这些问题
大致有如下挑战
以简单的查询和创建两个接口为例。
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
为了提升输入速度,分别尝试了 code generator / snippet 。大量的输入的代价是代码阅读带来较多无效阅读,反而造成了更多的调试困难。
随着代码量上升,相同业务逻辑的物理距离变得比较长,query 和 mutation 需要拆分到两个文件或者一个文件的上部分和下部分。
我在 tifa 这个 fastapi 项目里尝试了使用这种组织 view 层代码的姿势
这种组织代码的方式可以用 restful 的开发体验,获得 graphql 的效果
router = GQLRouter()
class TPost(gr.ObjectType):
id = gr.Int(description="博客 ID")
name = gr.String(required=True, description="博客标题")
@router.item("ok", output=gr.Boolean)
def test_ok():
"""
做一个简单的 healthcheck
"""
return True
@router.item("test_exception", output=gr.Boolean)
def test_exception():
raise ApiException("raise an api exception")
@router.item("post", output=TPost)
async def post_by_id(id: gr.Int):
"""
文章详情
"""
return await Post.get(id)
@router.list("posts", output=TPost)
async def posts():
"""
文章列表
"""
return await Post.all()
class PPostPagination(gr.InputObjectType):
q = gr.String(description="标题,等等")
@router.pagination("posts2", output=TPost)
def posts_pagination(params: PPostPagination):
return {
"items": [
{
"id": i,
"name": "testName",
}
for i in range(10)
],
"per_page": 10,
"page": 1,
}
class ParamsCreatePost(gr.InputObjectType):
name = gr.String(required=True)
@router.mutation("create_post", output=TPost)
async def create_post(params: ParamsCreatePost):
post = await Post.add(
name=params.name
)
await db.session.commit()
return post
可以看出,这种组织方式有如下的优点
接口的正交性带来的是细粒度的控制,而聚合起来之后,权限则不是很好管控。
一个请求打过来包含了越权和非越权的请求。服务端需要如何判断?
保大保小?
在 Restful 里依据路由可以做简单粗暴的首次鉴权。并且程序设计之初就会考虑到权限问题。
在我 18 年研究 graphql 的时候,几乎就没有关于权限的最佳实践…
当然,graphql 已经流行了好久了,现在也有一些成熟的解决方案了。
随着业务的变更,往往需要新增 / 废弃一些接口。
在原先 RESTFul 的实践下
graphql 社区推荐的是不废弃接口,只做兼容。尝试遵循了 graphql 社区的意见。最后还是改回和 restful 类似的方案了。
还有另外一些痛点,以往想把一些性能敏感的接口拆出来的时候,一般可以
如果采用 graphql 之后,就只能用第二种方案了。
not required 表示这个字段不是必须的,可传可不传。
required 表示这个字段是必须的,但可为 null/none, 也可不为。
not nullable 表示这个字段是必须的,但是不能为 null/none
nullable 表示这个字段是必须的,但可为 null/none, 也可不为。
not blank 表示这个字段是可为空的
对于字符串,则必须要不为 ""
对于列表,则必须要存在至少一个 item
举个例子来说明,如果要表示如下的响应内容
订单没有支付状态,就没有支付方式,即不应该有支付方式这个字段。
# 未支付订单
{
"status": "NOT_PAID"
}
# 已支付订单
{
"status": "PAID",
"pay_method": "WECHAT"
}
这样的接口是无法表示, 如果有这个字段,就展示在界面上,没有这个字段就不展示在界面上的
graphql 没法做到未支付订单无 pay_method 这种情况,在响应体里 pay_method 总是为 null
# 未支付订单
{
"status": "NOT_PAID"
"pay_method": null
}
当然,如果硬是要做的话,只能借助 UNION[PaidOrder,NotPaidOrder] 来实现。使用 Union 本身就造成了序列化的成本急剧上升。
要查什么,往往不可知(无法预知查询语句),随前端来定。不知道该怎么缓存响应内容。
以往通过 URI 定位资源,Http 协议无状态,非常容易实现对应用层透明的缓存。
而 GraphQL 重新定义了资源的定位方式,设计缓存的时候往往不知道该怎么设计。
在 graphql 的场景下,重写 resolver 是一个非常的影响是未知的。
因为,你不知道有多少个 query 用了这个 field, 为了知道这个问题,你还得打点知道有多少个场景的 query 用到了这个字段。
现实生活中情况可能复杂一些。
这个例子旨在说明,存在一些改动的心智负担比较严重。
比如,前端变动,接口层次,所有商家级别的数据落到店铺级别。
这个例子正确的解法是联合前端一起升级到 V2
有的时候需要看情况来解决不同的 N+1 问题,也是比较迷惑的…
客户端不见得会按照你的预期来查询。
这就意味着,同一个结构,有的时候要依据情况 A 来重写查询,有的时候要依据情况 B 来重写查询。
项目写了半年,由于 graphene 玩的不熟,我(后端)一直在各种起名困难症中度过。
明明业务流程都差不多,但最后结果全是在改各种各样 ObjectType 的前缀。
GraphQL 这一套玩的熟悉的话,效率确实非常高。(前提是团队里面的人能玩的熟悉)
如果团队里面缺少全栈人才的话,在很多地方的比较难推进的动。graphql 用起来确实爽,特别是前端。
由于是较新的技术,引入进来算上折腾出最佳实践的成本和折腾过程中诞生的技术债,收益可能并没有想象的那么大。
restful 其实也并不是做不到不少 graphql 能做到的东西。schema 生成工具其实蛮多的.
不过,搞技术嘛,折腾不止 生命不息, 说不一定这次折腾收益就很高了呢?