目录

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 的带来的缺点,对于大部分公司来说,这些缺点远远抵不上带来的优点。

剑招是死的,人是活的。

语义不明确

比如,业务具备一定复杂性的时候,语义容易不明确。

预览订单和下单两个接口,如果遵循如下的标准

  1. 接口路径使用名词而非动词
  2. GET 不修改,POST/PUT/DELETE 修改状态
  3. 使用子字段来表述关系,查询订单 GET /shop/711/orders/

严格遵循 RESTful 的话,接口就会成这个样子

GET /shops/1/order

{
  "products": [...]
}

PUT /shops/1/order

{
  "products": [...]
}

反正我是看不出来这是一坨啥玩意的,至于如果你硬性规范 PUT order 就是创建订单,就会出现接下来两个接口完全不知道该怎么写

  1. 支付订单 (用 patch /shop/1/order)
  2. 取消订单 (用 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 方法里处理以下四个问题

  1. 过滤 filter
  2. 排序 sort
  3. 字段裁剪 field selections
  4. 分页 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 给生产端带来的挑战

消费端爽了,是否意味着生产端会爽呢?

并不是

  1. 对于前端来说,如果要消费 graphql api, 相对容易一些。 只需将原先的 api 层改为 graphqlfetch 即可
  2. 对于后端来说,如果要生产 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

  1. 减少接口的嵌套,尽量在 2~3 层,不超过 5 层。
  2. 使用 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 问题

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 初始化本文