# 也聊聊 RESTful vs GraphQL

# 0x00 前言

本文最早行文于 2018 年中,那时 GraphQL 的生态尚未成熟,也缺乏社区总结的一些经验。

较早入了坑,回首百年身

2021 年中,复盘一下 GraphQL 的一些使用经验。

名词约定

  • 接口生产端 / 服务端 下面统一称「生产端」
  • 接口消费端 / 客户端 下面统一称「消费端」

# 0x01 流水的技术方案,铁打的需求。

年幼时看笑傲江湖,华山派两派居然为了剑宗和气宗争个你死我活。

觉得甚是幼稚。

年纪渐大之后发现社会处处充满着这种「谁好」的争论。

  • 剑宗 or 气宗
  • 自然美 or 人造美
  • Java or Python or Golang
  • Editor or IDE
  • Rest or GraphQL
  • 单体应用 or 微服务

剑招是死的,人是活的。

当讨论问题的时候,下面两个问题意义可能并不是很大。

  • 用剑宗初学者和气宗高手比,或者相反,这个完全没有多大意义?
  • 用剑宗初学者和气宗初学者比孰强孰弱,这个有一点意义,但意义也不是很大。菜鸡互啄。

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 呀)
    退款订单
    部分退款订单
  • 产品上,用户删除了一台机器,那就是删除了一台机器?这里是软删。
  • 产品上,管理员删除了一台机器,那就是删除了一台机器?机器记录还在的。

所以,采用的是 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’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 组合起来是交给前端来做的。 这意味着前端为了组合多个资源,则需要发起多次请求。

这首先是带来了的网络开销成本 (HTTP 2 解决了首部开销、多路复用等 HTTP 1.1 面临的问题,还做到了完全向后兼容。) 和响应内容拼装成本

其次,前端往往会抱怨

  1. 服务端这个接口字段少了,后端往往会复用序列器,直接把太多前端用不到的数据一并返回。
  2. 服务端这个接口字段太多,需要慢慢找 diff, 于是会开始抱怨文档问题。

于是大家采用了 swagger/openapi 的配套解决方案。同时带来了,文档的缺失和滞后

# 0x03 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) 嘛。

# 0x04 GraphQL 给消费端带来的好处

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, 用它可以做很多东西

  1. 便捷的文档功能, 接口列表以及入参的初步生成。便于快速调接口,比如我写了一个接口,发布到测试环境,接着点击一个按扭直接同步到 insomnia 里。点击运行即可调试。「甚至可以做简单的接口自动化测试」
  2. 可以生成 typescript/javascript 代码, 让前端享受到直接在代码里补全的快感。
  3. 比对两个版本之间的 schema 可以直接得出接口 schema 上,后端有没有做好兼容。如果不行, 则 CI 无法跑过

# 0x05 GraphQL 给生产端带来的挑战

消费端(前端)爽了,是否意味着生产端(服务端)会爽呢?

并不是

  • 对于前端来说,如果要消费 graphql api, 相对容易一些。 只需将原先的 api 层改为 graphqlfetch 即可
  • 对于后端来说,如果要生产 graphql api, 则麻烦了不少。

下文我会以 graphene 这个 graphql python 库来说明带来的一些挑战

https://link.zhihu.com/?target=https%3A//github.com/graphql-python/graphene

有的挑战是和 graphql 相关的,有的挑战则是来自 graphene 库,或许其他库有更好的解决方案。

注意!痛点背后的原因,大多是 graphql 以及配套库玩的不熟。而不一定是 RESTFUL 不存在这些问题

大致有如下挑战

  • 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
  • 第一个痛点原先简单清晰的代码变成了非常冗长的代码。

为了提升输入速度,分别尝试了 code generator / snippet 。大量的输入的代价是代码阅读带来较多无效阅读,反而造成了更多的调试困难。

  • 第二个痛点校验层没了。而增加校验层变得比较麻烦。

随着代码量上升,相同业务逻辑的物理距离变得比较长,query 和 mutation 需要拆分到两个文件或者一个文件的上部分和下部分。

  • 假设你写的是一个商城,从上到下阅读代码会发现一会儿是商品基本信息,一会儿是分类,一会儿是订单,一会儿又是分类,order 的逻辑查看在一个文件的犄角旮旯,修改在另一个文件的犄角旮旯。代码复杂度 +1
  • 假设查询订单和修改订单两个接口查询,查看与修改所查询的业务逻辑是一样的。由于物理距离过于远,则大概率会写两遍这个代码。Same Logic, Write Everywhere

我在 tifa 这个 fastapi 项目里尝试了使用这种组织 view 层代码的姿势

这种组织代码的方式可以用 restful 的开发体验,获得 graphql 的效果

https://link.zhihu.com/?target=https%3A//github.com/twocucao/tifa/blob/master/tifa/apps/admin/graphql.py

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

可以看出,这种组织方式有如下的优点

  1. depth 为 1 的 query / mutation 字段为一个 route
  2. 简单粗暴的权限可以做在 route 上
  3. 与 restful 完全一致的代码组织方式。RESTFul 代码几乎无缝迁移。

# 权限问题

接口的正交性带来的是细粒度的控制,而聚合起来之后,权限则不是很好管控。

一个请求打过来包含了越权和非越权的请求。服务端需要如何判断?

保大保小?

在 Restful 里依据路由可以做简单粗暴的首次鉴权。并且程序设计之初就会考虑到权限问题。

在我 18 年研究 graphql 的时候,几乎就没有关于权限的最佳实践…

当然,graphql 已经流行了好久了,现在也有一些成熟的解决方案了。

# 版本更迭

随着业务的变更,往往需要新增 / 废弃一些接口。

在原先 RESTFul 的实践下

  1. 接口新增只需要考虑 v2 版本
  2. 接口移除通过监控接口的请求次数来解决这个问题。

graphql 社区推荐的是不废弃接口,只做兼容。尝试遵循了 graphql 社区的意见。最后还是改回和 restful 类似的方案了。

还有另外一些痛点,以往想把一些性能敏感的接口拆出来的时候,一般可以

  1. 运维手段:直接起另一个服务,然后在网关那边直接转发请求即可。
  2. 非运维手段:请求依旧由现在的服务接收,然后由再次发起请求到新服务上。

如果采用 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"
}

这样的接口是无法表示, 如果有这个字段,就展示在界面上,没有这个字段就不展示在界面上的

graphql 没法做到未支付订单无 pay_method 这种情况,在响应体里 pay_method 总是为 null

# 未支付订单
{
  "status": "NOT_PAID"
  "pay_method": null
}

当然,如果硬是要做的话,只能借助 UNION[PaidOrder,NotPaidOrder] 来实现。使用 Union 本身就造成了序列化的成本急剧上升。

# 服务端缓存

要查什么,往往不可知(无法预知查询语句),随前端来定。不知道该怎么缓存响应内容。

以往通过 URI 定位资源,Http 协议无状态,非常容易实现对应用层透明的缓存。

而 GraphQL 重新定义了资源的定位方式,设计缓存的时候往往不知道该怎么设计。

# 嵌套式 API

在 graphql 的场景下,重写 resolver 是一个非常的影响是未知的。

因为,你不知道有多少个 query 用了这个 field, 为了知道这个问题,你还得打点知道有多少个场景的 query 用到了这个字段。

现实生活中情况可能复杂一些。

这个例子旨在说明,存在一些改动的心智负担比较严重。

比如,前端变动,接口层次,所有商家级别的数据落到店铺级别。

这个例子正确的解法是联合前端一起升级到 V2

# N+1 问题

有的时候需要看情况来解决不同的 N+1 问题,也是比较迷惑的…

客户端不见得会按照你的预期来查询。

这就意味着,同一个结构,有的时候要依据情况 A 来重写查询,有的时候要依据情况 B 来重写查询。

# 为名所困

项目写了半年,由于 graphene 玩的不熟,我(后端)一直在各种起名困难症中度过。

明明业务流程都差不多,但最后结果全是在改各种各样 ObjectType 的前缀。

# 0x06 结论

GraphQL 这一套玩的熟悉的话,效率确实非常高。(前提是团队里面的人能玩的熟悉)

如果团队里面缺少全栈人才的话,在很多地方的比较难推进的动。graphql 用起来确实爽,特别是前端。

由于是较新的技术,引入进来算上折腾出最佳实践的成本和折腾过程中诞生的技术债,收益可能并没有想象的那么大。

restful 其实也并不是做不到不少 graphql 能做到的东西。schema 生成工具其实蛮多的.

不过,搞技术嘛,折腾不止 生命不息, 说不一定这次折腾收益就很高了呢?

# 0xEE 参考链接