你真的需要 GraphQL 吗

465 阅读10分钟

笔者在 2019 年开始在云原生业务里使用 GraphQL 作为 BFF 层, 之后公司十几个项目也陆续使用了 GraphQL。

2024 年, 笔者所在的业务组已经有很多项目逐渐弃用 GraphQL 甚至去 BFF 化, 改为纯前端 + 共用 nodejs proxy 架构, 中间发生了什么事情, 我们真的需要 GraphQL 吗, 请跟随笔者的脚步来给 GraphQL 完成 "祛魅"。

Why GraphQL

REST API 的问题

  • 一次 REST 查询全量返回数据, 存在前端过度获取的情况
  • 页面需要多接口拼接, REST 需要针对每个接口发一次请求
  • 嵌套查询实现复杂
  • 无运行时类型约束

GraphQL 如何解决上述问题?

一个 GraphQL 操作可以是一个查询(query)、修改(mutation)以及订阅(subscription), 其规范如下, 我们举例说明:

例子 1

前端请求格式:

{
  user {
    name
}

服务端应该返回格式:

{
  user: {
    name: "张三"
}

例子 2

前端请求格式:

{
  user {
    name
    age
   }
}

服务端应该返回格式:

{
  user: {
    name: "张三",
    age: 18
   }
}

例子 3

前端请求格式:

{
  user {
    name
    age
    friends {
      name
      age
    }
  }
}

服务端应该返回格式:

{
  user: {
    name: "张三",
    age: 18,
    friends: [
      {
        name: "李四",
        age: 18
      },
      {
        name: "王五",
        age: 18
      }
    ]
  }
}

小结

  • 前端按需获取字段: GraphQL 将视角转移到前端,由前端决定它需要的数据, 而不是服务器, 这是最初发明 GraphQL 的主要原因
  • 前端聚合多查询为一次 HTTP 请求
  • 更好的嵌套查询支持
  • 严格定义的数据类型可减少前端与服务器之间的通信错误

GraphQL 的主要组件

实际上,GraphQL API 使用了 3 个主要的组件:

  • 前端查询: 前端发出的请求, 包括 query、mutation 以及 subscription
  • 服务端 GraphQL Server Schema: Schema 描述了 GraphQL 服务器可以提供的功能(供前端获取的数据结构和字段类型等信息)
  • 服务端 GraphQL Server 解析器 resolver: 除非我们告诉 GraphQL 服务器该做什么,不然它不知道如何处理它得到的前端查询。 这个工作是用解析器 resolver 来完成的。简单地说,resolver 告诉 GraphQL Server 如何(及从何处)获取字段对应的数据。你可以在 resolver 里查询数据库或者转发 http 请求等方式来获取数据源。

接下来我们看下这 3 个组件如何实现一个完整的 api 查询

本文使用 React + Apollo Client + Apollo Server 的方案

Apollo 是一个实现了 GraphQL 协议的开源框架, 支持多种主流编程语言

Apollo Server 服务端: 链接

const { ApolloServer, gql } = require("apollo-server");
const { userDB, favorateDB, placeDB } = require("./mockDB");

// 构造 GraphQL schema
const typeDefs = gql`
  type Query {
    user(id: ID!): User
  }
  type User {
    id: ID
    name: String
    age: Int
    friends: [User]
    favorate: [Project]
  }
  type Project {
    projectName: String
    places: [Place]
  }
  type Place {
  
    location: String
    price: Float
  }
`;

// 构造 GraphQL resolver, 代表 schema 里字段的数据来源
const resolvers = {
  Query: {
    user: (root, args) => {
      console.log("user resolver", root, args);
      return userDB.find(({ id }) => id === args.id);
    }
  },
  User: {
    friends: (root, args) => {
      console.log("friends resolver", root, args);
      const friendIDs = root.friendIDs || [];
      return userDB.filter(({ id }) => friendIDs.includes(id));
    },
    favorate: (root, args) => {
      console.log("favorate resolver", root, args);
      const _userId = root.id;
      return favorateDB.find(({ userId }) => userId === _userId).favorates;
    }
  },
  Project: {
    places: (root, args) => {
      console.log("Project resolver", root, args);
      const projectName = root.projectName;
      return placeDB.find((place) => place.projectName === projectName).places;
    }
  }
};

const server = new ApolloServer({
  typeDefs,
  resolvers
});

server.listen().then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});

React + Apollo Client 客户端 : 链接

// index.tsx, 构造 GraphQL Client 客户端
import { React } from "react";
import * as ReactDOM from "react-dom/client";
import { ApolloClient, InMemoryCache, ApolloProvider } from "@apollo/client";
import App from "./App";

 // 此处 uri 需要是上面 apollo server 启动地址 
 const client = new ApolloClient({
  uri: "https://s6x0sp.sse.codesandbox.io/",
  cache: new InMemoryCache()
});

const root = ReactDOM.createRoot(document.getElementById("root"));

root.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>
);
// App.tsx
import { useQuery, gql } from "@apollo/client";

const getUser = gql`
  query getUser($id: ID!) {
    user(id: $id) {
      name
      age
      friends {
        name
        age
      }
      favorate {
        projectName
        places {
          location
          price
        }
      }
    }
  }
`;

function User() {
  const { loading, error, data } = useQuery(getUser, {
    variables: {
      id: "1"
    }
  });
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error :{error.message}</p>;
  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

export default function App() {
  return (
    <div>
      <h2>My first Apollo app</h2>
      <User />
    </div>
  );
}

GraphQL 前端工程化

上面基本演示了一个项目里如何接入 GraphQL, 但项目里仅仅这样做还不够, 首先没有 ts 类型提示, 写起来太累了, 前端缓存、稳定性方面也未考虑, 可谓是刀耕火种, 因此在项目里我们增加了如下的工程化设施来提高效率和稳定性。

开发阶段: 提升效率

使用 TS 提升开发体验

同时搭配前端 TS 技术栈,整体代码仓库在类型安全得到很大的提升。在前端开发中,如果接口入参和返回值的类型得到保证,则内部业务逻辑的各种组件和工具函数,一般就自然而然的具备了比较好的类型安全能力。从而在最大程度上保证了代码的类型安全,可有效降低前端页面实际运行中崩溃的概率。

借助一个 codegen 工具,尽量避免手写冗余代码,通过自动化和半自动化工具,我们可以将前端的一个查询语句自动生成一个开箱即用的 useQuery hook 函数,同时具备准确的类型提示和安全能力。

运行阶段: 如何做性能的优化

Apollo Client 缓存机制的合理利用

Apollo Client 自带非常完备的缓存机制,具体策略在此不详细展开,可参见官方文档

浏览器侧接口的编排和聚合

GraphQL 的灵活性之一就是浏览器侧可以自主指定查询字段,在此基础上,借助内置指令和一些 GraphQL server 侧的默认实现,在浏览器中也可以实现一些简单的编排和聚合操作。比如下面这两个例子:

mutation Create_A_B(
  $Var1: Var1Body!
  $Var2: Var2Body!
  $skipVar1: Boolean!
) {
  # mutation 里面 root action 是串行执行
  CreateA(body: $Var1) @skip(if: $skipVar1) {
    Id
  }
  CreateB(body: $Var2) {
    Ids
  }
}

query a_b($Var: String!) {
  # query 里面的 root action 是并发请求
  aList(body: { Var: [$Var] }) {
    Total
    Items {
      ...
    }
  }
  bList(body: { Var: [$Var] }) {
    Total
    Items {
      ...
    }
  }
}
减少 resolver 书写代码: 指令和中间件

当项目规模化地写起来之后,会发现很多 resolver 都是很相似的,一个一个全都写出来很麻烦还不好维护。那怎样使 resolver 代码保持 DRY(Don't Repeat Yourself) 的原则呢?可以使用自定义指令或 graphql 中间件。

  • 封装查询转发指令 (fetch)

我们知道,graphql 中每个字段的 resolution,归根结底都是靠一个 resolver 函数的调用,而指令的目的,就在于能够在项目启动阶段自动批量生成(或修改)字段的 resolver 函数。所以通过自定义 directive 的方式,可以把许多通用的字段解析或转换的逻辑给封装掉,然后简单直接地运用到需要的字段处即可。

BFF 层作为前端和后端的中继,resolver 最常见的逻辑就是向后端发请求。围绕这个场景我们设计了 fetch 指令。

fetch 指令是用于具体字段上的自定义指令,它支持传入 method,prefix,timeout 等多个参数,用以控制请求发送的多个方面,不过最常用的入参是:

path:访问的 url。

只要是对于 schema 中直接对接后端接口来获取值的字段(没有某些特殊逻辑处理的),都可以直接使用自定义的 fetch 指令,大大减少了开发过程中对接后端接口的成本。例:

type Query {
  aList(body: ABodyInput!): AList! @fetch(
    path: "/a",
  )
  bList(body: BBodyInput!): BList! @fetch(
    path: "/b",
  )
}
  • 封装 load 指令, 解决经典的 N + 1 的问题, 比如这个例子中:
// BFF 侧 schema SDL
type Query {
    aList: [ThingA!]! @fetch(path: "/a")
}
type ThingA {
    name: String!
    bList: ThingB! @fetch(path: "/b")
}
type ThingB{
    name: String!
}
// web 侧 query AST
query {
    aList {
        name
        bList {
            name
        }
    }
}

假设aList会返回 100 个ThingA对象,这 100 个都会再去 resolve 它的bList字段,所以会发出 100 个GetThingB的请求。这就是一个 n+1 的情况,n 在本例中是 100,1 是指 n 中的每一个又会触发 1 次额外请求。

为了把这 n 个额外请求合成一个(前提是批量查询的后端接口存在),我们引入了 DataLoader,并定义了 load 指令。针对每个需要解决 n+1 问题的场景,把相应的 batch 函数定义好,在 schema 中的具体字段上,就可以通过提供loader参数给 load 指令,来使该字段使用相应的 batch 函数来进行合并请求。例:

type Query {
    aList: [ThingA!]! @fetch(path: "/a")
}
type ThingA {
    name: String!
    bList: ThingB! @fetch(path: "/b") @load(
    loader: "BListLoader"
  )
}
type ThingB{
    name: String!
}
  • 中间件 (graphql-middleware)

大白话讲,graphql-middleware 就是给 resolver 打辅助的预处理/后处理逻辑,是围绕着 resolver 的洋葱模型(下图)。适用于通用的 resolver 辅助逻辑,如:

  • 数据转换:对请求或响应数据进行统一数据格式转换
  • 权限校验:对 GraphQL 提供的服务进行统一权限校验
  • 指标采集:采集 GraphQL 服务的一些指标或性能数据

它与自定义指令的不同之处在于,指令是 opt-in 的,你需要把它写到某个字段上才行;而 graphql-middleware 则是对所有字段的 resolution 都生效的,而且它也不能替代 resolver,只是预处理和后处理。

middleware 函数当然是支持异步的,因为如果在调 resolver 前后都有逻辑要跑,那当然得 await resolve(...).

import { applyMiddleware } from "graphql-middleware";

const resolver = {
    Query: {
    hello: (root, args, context, info) => {
      console.log(`3. resolver`)
    },
  },
}
// 只有预处理没有后处理
const middleware1 = (resolve, root, args, context, info) => {
    console.log('1. first middleware before resolver')
    return resolve(root, args, context, info);
}
// 既有预处理也有后处理
const middleware2 = async (resolve, root, args, context, info) => {
    console.log('2. second middleware before resolver')
    const res = await resolve(root, args, context, info)
    console.log('4. second middleware before resolver')
    return res;
}

const schema = makeExecutableSchema({typeDefs,resolvers,...})
const schemaWithMiddleware = applyMiddleware(schema,middleware1,middleware2)

middleware 函数接的参数在 resolver 的四个入参外还加了 resolver 本身。

查询深度限制

假设我们有这样的 GraphQL 定义

type Song {
  title
  album: Album
}

type Album {
  songs: [Song]
}

type Query {
  album(id: Int!): Album
  song(id: Int!): Song
}

这样会导致我们服务器有可能执行循环查询

query evil {
  album(id: 42) {
    songs {
      album {
        songs {
          album {
            songs {
              album {
                songs {
                  album {
                    songs {
                      album {
                        songs {
                          album {
                            # and so on...
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

如果查询深度为 10,000,我们的服务器将如何处理它? 这可能会成为一项非常昂贵的操作,在某些时候将耗尽服务器资源。 这是一个可能的 DOS 漏洞。 我们需要一种方法来验证传入查询的复杂性。

目前我们使用的是开源的 graphql-depth-limit

使用

import depthLimit from 'graphql-depth-limit'
import express from 'express'
import graphqlHTTP from 'express-graphql'
import schema from './schema'
 
const app = express()
 
app.use('/graphql', graphqlHTTP((req, res) => ({
  schema,
  validationRules: [ depthLimit(2) ]
})))

上面我们设置了 depthLimit(2), 那么下面超过 2 层深度的查询: deep3 在发起查询时则会被拒绝

# depth = 0
query deep0 {
  thing1
}

# depth = 1
query deep1 {
  viewer {
    name
  }
}
 
 
# depth = 2
query deep2 {
  viewer {
    albums {
      title
    }
  }
}
 
# depth = 3
query deep3 {
  viewer {
    albums {
      songs{
        {
          title
        }
      }
    }
  }
}

新的变化

由上可见, 想利用好 GraphQL 实际上要考虑的事情是很多的, 生产环境中我们做的也不止这么多, 包括开发运维、高可用、稳定性、可观测等。

2023 年开始, 有很多同事开始反思自己的业务是否真的需要 GraphQL

  • 很多团队是轻前端重后端的业务, BFF 实际上也就就是鉴权 + 转发给后端的定位, 也没有特别强的按需获取字段的需求, 这时候如如果继续用, 每个接口都要书写前端 查询语句和 GraphQL schema, 反而还多了很多流程出来, 变成了纯负担, 它存在的意义在哪里?

  • 其次 GraphQL 运行时的强类型校验, 意味着在发布时的不兼容风险, 我们的 web 页面和 GraphQL Server 发布是有时间差的, 如果 GraphQL schema 是新的, 并且新增了某个传参是必填项, 那么还没来得及获取最新前端 web 页面的请求就会报错. 如果只是普通的纯转发的 BFF 就不会存在这种风险

  • 单页面对接多版本服务端问题, 和上面的问题有点类似, 属于兼容性问题, 如果你的 web 页面有个下拉框, 选择的时候会向不同版本或地域的后端发请求, 后端里的 GraphQL Server 应用可能有不同的版本, 有可能某个地域今天发布了新版本, 其他的地域隔天依次发布, 这时候前端页面的查询语句需要对接 N 种不同的 GraphQL Server schema, 容易产生不兼容

  • 横向团队里其实存在一个公共的 nodejs proxy 组件, 定位就是鉴权 + 纯转发, 稳定性都是有专门团队负责的, 理论上比每个小团队去自己维护 GraphQL BFF 的成本更低, 质量更有保障, 自己维护就意味着指标、日志、限流、监控报警等设施都要付出单独的时间和心智

  • 组件或页面复用问题, 如果你的前端业务组件里使用了 GraphQL 技术栈, 那么想复用你的组件的其他项目必须有相匹配的 GraphQL Server 应用, 否则接口会报错

逐渐的去 GraphQL 和去 BFF 被正式提上日程, 很多业务逐步转变为纯 web 前端 + 公共 nodejs proxy 架构. 前端查询就是 REST 方式, 代码里接入了公司 API 平台自动生成 TS 的 sdk, 组件里引用 sdk 发起查询。

结语:

GraphQL 在按需获取的场景下是比较适用的, 比如一个后端 + 前端多端的场景, 不同的前端可以按需拼装自己需要的数据。

但新技术 ≠ 绝对好, 不可盲目追求技术上的 "高大上", 也不是所有业务和团队都适用, 否则 GraphQL 不会经历了十几年还不温不火, 以下场景建议谨慎考虑使用 GraphQL , 仅供参考

  • 你的团队只有 REST API 开发经验, 那么 GraphQL 还是有很高的学习成本的, 在踩坑的时候需要更多的时间成本去解决

  • 你的业务对 "接口聚合或者按需获取" 没那么强需求, 或者频次很低场景很少, REST API 就能满足绝大部分业务

  • 你的服务需要做 OPEN API, 由于 GraphQL 目前仍不是主流, 那么就要考虑你的 OPEN API 消费者的对接成本和习惯