笔者在 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 消费者的对接成本和习惯