关注“之家技术”,获取更多技术干货
总篇175篇 2022年第48篇
1、实践背景
随着业务的增长,页面涉及的业务线越来越多, 为了实现一个需求, 往往需要调用多个 RESTful 接口组合数据, 然后绑定到 UI 组件上,呈现给 C 端用户。这样会有几个缺点,最明显的是客户端会发送多次网络请求,浪费tcp的开销。
其次,多个接口的请求,数据返回顺序并不固定,很容易导致bug,另外根据不同的场景,也不是每个接口返回的所有字段都会用上, 浪费了网络流量,还容易泄露敏感字段。前端技术人员也频频吐槽为什么这些接口的活要前端干。
为了解决这些问题, 我们小组引入了 GraphQL 作为 BFF 层, 利用 GraphQL 支持以图的形式构建数据,可实现在单个请求中获取多个资源,以及提供强大的查询语法能力,减少网络请求次数、裁剪冗余数据,缩短需求交付周期,提升应用响应性能。在应用的过程中,我们积累了一些经验,借此机会分享给大家。
2、实践过程
我们在引入 GraphQL 时,采用了 Express 搭配 Apollo GraphQL 方式。
2.1
Express + Apollo GraphQL
►安装依赖
npm install apollo-server-express apollo-server-core express graphql
►启动代码
import { ApolloServer } from "apollo-server-express";
import { ApolloServerPluginDrainHttpServer } from "apollo-server-core";
import express from "express";
import http from "http";
async function startApolloServer(typeDefs, resolvers): Promise<void> {
// 创建expres实例
const app = express();
const http = http.createServer(app);
// 初始化 ApolloServer, 并添加 ApolloServerPluginDrainHttpServer 插件
// 该插件能让你的HTTP服务器优雅关闭
const server = new ApolloServer({
typeDefs,
resolvers,
cache: "bounded",
plugins: [ApolloServerPluginDrainHttpServer({ http })],
});
// 必须等待 Apollo Server启动完成之后, 再执行 server.applyMiddleware
await server.start();
server.applyMiddleware({
app: app,
// 设置 GraphQL 监听的URL路径
path: "/graphql",
});
// 监听端口启动服务
await new Promise<void>((resolve) => http.listen({ port: 4000 }, resolve));
console.log(`Server ready at http://localhost:4000/`);
}
Apollo Server 也可以单独使用, 也可以使用对应 Web 框架的集成包, ApolloServer 需要从对应的集成包里导入。
3.自动加载合并 schema
和 resolvers
随着项目越来越大, 如果 GraphQL 类型文件和对应的 resolver 都放在一个文件里会变的难以维护, 可以使用 @graphql-tools/merge 缓解该问题。
下面的配置代码, 会自动加载 src/schema/graphqls 目录下的所有后缀名为 .gql和 .graphql 的 schema 文件, 以及 src/schema/resolvers 下所有扩展名为 .ts和.js 的 JS 代码文件作为 resolver
import { loadFilesSync } from "@graphql-tools/load-files";
import { mergeTypeDefs, mergeResolvers } from "@graphql-tools/merge";
import { ApolloServer } from "apollo-server-express";
/**
* 这里会加载合并目录 src/schema/graphqls 及其子目录下的所有扩展名为 gql 和 graphql 的GraphQL文件
*/
const typeDefsArray = loadFilesSync("./schema/graphqls", {
recursive: true,
extensions: ["gql", "graphql"],
});
/**
* 这里会加载 src/schema/resolvers 目录及子目录下的所有扩展名为 ts 和 js 的文件作为 resolver
* 扩展名包含 ts 是为了方便本地开发, 实际部署之后不会有 ts 文件
* exportNames 表示 resolver 文件需要导出名为 resolvers 的对象
*/
const resolversArray = loadFilesSync("./schema/resolvers", {
recursive: true,
extensions: ["ts", "js"],
ignoreIndex: false,
exportNames: ["resolvers"],
});
const typeDefs = mergeTypeDefs(typeDefsArray);
const resolvers = mergeResolvers(resolversArray);
/**
* 下面就可以直接使用上面加载的 typeDefs 和 resolvers
*/
const server = new ApolloServer({
typeDefs,
resolvers,
// ... 其他选项
});
或者调用 @graphql-tools/schema 包里的 makeExecutableSchema 方法处理之后再传给 ApolloServer:
import { makeExecutableSchema } from "@graphql-tools/schema";
const server = new ApolloServer({
schema: makeExecutableSchema({
typeDefs: typeDefs,
resolvers: resolvers,
}),
// ... 其他选项
});
4.代理
本地开发调试,有时需要 Mock 某个后端接口,或者查看实际应用发起了哪些网络请求,可以通过配置 Node.js 应用代理来解决,或者应用部署之后使用代理访问受限资源。
虽然 Apollo Server 支持 Node.js 标准的代理配置:https.globalAgent 和 http.globalAgent, 但是推荐建议使用第三方类库 global-agent. gobal-agent 支持使用环境变量(HTTP_PROXY, NO_AGENT)设置代理(而 Node.js 是不支持的, 而且也许以后也不打算支持.).
4.1
安装
npm install global-agent
4.2
使用
import { ApolloServer, gql } from "apollo-server";
import { bootstrap } from "global-agent";
// 设置代理
bootstrap();
// 创建服务
const server = new ApolloServer({
typesDefs,
resolvers,
cache: "bounded",
});
然后启动项目时设置环境变量 GLOBAL_AGENT_HTTP_PROXY=http://localhost:3210 即可设置代理。
5.HTTP Cache
关于 HTTP 缓存有两个重要的概念:新鲜度和有效性。
服务器通过输出Cache-Control和Expires响应头来标识资源在多长时间内是新鲜的. 比如服务器端输出 Cache-Control: max-age=300 告诉客户端 5 分钟内资源都是新鲜的, 无需重复向服务器请求该资源. 对于不常变化的资源使用该方式是一种不错的选择. 然后 5 分钟之后, 客户端就会再次向服务器发送请求获取资源, 即便资源并没有发生变化。
为了避免客户端因为无法知晓资源是否新鲜而重新下载资源, 可以使用Last-Modified, ETag响应头标识资源的有效性.如果服务器设置了响应头Last-Modified, 客户端可以发送请求头 If-Modifies-Since 避免再次下载已经缓存但未发生变化的资源. 也可以使用响应头 ETag 标识资源版本来避免重复下载。
利用新鲜度和有效性相关的响应头可以有效控制浏览器端缓存和 CDN 缓存。
由于 Graph QL 服务默认使用 POST 请求, 而POST请求无法利用上述提到的 HTTP 响应头缓存资源, 导致 GraphQL 给人们的第一印象是缓存不友好。
虽然技术上, GraphQL 也支持 GET 请求, 但是由于客户端请求 GraphQL 服务的时候需要传递 query schema, 如果使用GET请求有可能导致 URL 长度超过浏览器或者服务器端的最大限制。
所以要想 GraphQL 服务使用 HTTP 缓存, 就必须解决 GET 请求的 URL 长度问题。
对于 GET 请求问题, GraphQL 社区提出了持久化查询的解决方案. 下面简单介绍一下我们基于此思路搭建的持久化查询实现:
► 客户端
1.开发人员直接在前端项目里编写 query schema 文件
2.通过命令行和集成工具自动将 query schema 文件生成一个文件名为内容 hash 的文件, 然后将 hash 文件名对应的文件上传到一个远端存储空间(可以是数据库、文件系统等), 同时脚本会生成一个包含 query schema 文件名和对应文件内容 hash 的配置文件
3.在实际业务代码里通过文件名引用 query schema, 封装的请求 GraphQL 服务的代码会将文件名替换成真正的 hash 文件名, 传给 GraphQL 服务, 实际发起的网络请求为: /graphql?query-hash=5d41402abc4b2a76b9719d911017c592&variables=xxxx
►服务器端
1.服务器端接收到 GET请求之后, 解析 query-hash 参数, 然后根据该参数值去存储空间获取真正的 query schema.
2.服务器端拿到 query schema 之后就可以响应资源了
流程图如下:
使用该方式即解决了 URL 长度问题, 也能保证 query schema 是可信的, 防止恶意用户查询嵌套层级很深的 schema。
由于GraphQL的一次查询请求, 可能会涉及多个后端接口, 而每个后端接口根据业务场景可缓存时长是不同的, 那么本次查询的可缓存时间取决于后端接口中最小的缓存时长。
虽然可以利用 GraphQL 的指令特性, 细粒度控制缓存响应头, 但是我们目前并没有这么做, 因为给每个 type 增加缓存指令, 是一件很繁琐的事情. 我们目前的解决方式是由前端请求 GraphQL 服务时通过 URL 参数指定本次查询的缓存时长, 比如 /graphql?query-hash=xxx&variables=xxx&_cache=300, 这里的URL参数 _cache=300 表示 GraphQL 响应数据的时候设置响应头 Cache-Control: max-age=300.
6.请求后端接口
GraphQL 服务器端调用后端接口时, 可以使用 RESTDataSource 和 DataLoader 优化网络请求次数.
6.1
RESTDataSource
RESTDataSource实例内部会自动缓存 GET 请求, URL 相同的 GET 请求会只会发起一次(具体实现代码参考RESTDataSource.ts#L273). 比如以下代码虽然请求了两次后端接口, 但是实际后端只会接收到一次请求:
const source = new RESTDataSource();
const user_1 = await source.get("/user", { id: 1 });
const user_2 = await source.get("/user", { id: 1 });
// 这里的 user_1 和 user_2 实际是同一个 Promise 对象
6.2
DataLoader
DataLoader 对于批量查询很有帮助, 考虑以下场景:
服务器端有以下 Schema 定义, 并支持商品列表查询:
type Query {
"商品列表"
goodsPagingList(pageSize: Int, pageIndex: Int): [Good]
}
"商品信息"
type Good {
skuId: ID
title: String
price: Float
seriesId: Int
"商品关联的车系信息"
series: SeriesInfo
}
"车系信息"
type SeriesInfo {
seriesId: Int
seriesName: String
seriesImage: String
}
export const resolvers = {
Query: {
goodsPagingList: (parent, args, context) => {
return context.dataSources.get("/goods", args);
},
},
Good: {
series: ({ seriesId }, args, context) => {
return context.dataSources.get("/series", { seriesId: seriesId });
},
},
};
如果列表有 10 条商品数据, 上述的 context.dataSources.series.getSeriesInfo 会调用 10 次接口, 加上商品列表接口, 总共向后端请求了 11 次接口. 利用 DataLoader 可以实现批量查询, 减少接口调用次数:
const loader = new DataLoader((seriesIds) => {
return this.get("/series", {
seriesIds: seriesIds.join(","),
}); // 假设 /series 接口支持批量查询, 多个车系id用逗号分割
});
然后上述 Good.series 的 resolver 就可以使用 loader.load(seriesId) 读取单个车系信息, DataLoader内部会维护一个队列, 使用 process.nextTick(Node 环境)延迟批量调用后端接口. 使用 DataLoader 之后, 上述实际只会调用 2 次后端接口.
使用 DataLoader 需要注意以下两点:
1. DataLoader 要求第一个参数 batchLoadFn 返回的数据顺序和入参的 id 顺序(即上述示例代码里的 seriesIds 参数)必须保持一致, 因为 DataLoader 内部利用这个顺序来和 id 对应起来, 用于 load/loadMany 方法返回正确的数据.
2.上述示例比较简单, 接口只需要 id 参数, 但是在实际业务场景中, 有可能除了 id 还有很多其他参数, 比如接口/series?seriesIds=1,2,3&cityId=110100&seriesLevel=5, 这个时候就需要重写 DataLoader 第二个参数里的 cacheKeyFn 函数, 默认情况下该参数使用 id 作为 key.
7.其他技巧
7.1
代码自动生成
为了提高开发效率, 可以借助 @graphql-codegen/cli 自动生成 GraphQL schema 文件中定义的类型, 以及 GraphQL 接口调用代码。
► 自动生成 ts 类型定义
为了方便开发, 可以借助 @graphql-codegen/cli 自动生成 GraphQL schema 文件中定义的类型。
7.2
集成测试
Apollo Server 提供了两种 e2e 测试方法:
1.使用 ApolloServer 的 executeOperation;
2.直接向 Apollo Server 发起 HTTP 请求。
下面是使用 ApolloServer.executeOperation 方法的测试示例:
import { ApolloServer, gql } from "apollo-server";
describe("sample", () => {
test("e2e demo", async () => {
const server = new ApolloServer({
typeDefs: gql`
type Query {
hello(name: String): String!
}
`,
resolvers: {
Query: {
hello: (_, { name }) => `Hello ${name}`
}
}
});
const response = await server.executeOperation({
query: `query Say($name: String) { hello(name: $name) }`,
variables: { name: "world" }
});
expect(response.errors).toBeUndefined();
expect(response.data?.hello).toBe("Hello world");
});
})
8.总结
本文主要是记录了经销商技术部为解决微服务技术发展过程中遇到的新问题而引入了 Graphql ,以及在引入 Graphql 后的一些实践探索和总结,包括 schema 和 resolver 的拆分,代理、缓存和集成测试等方面的设计思路,以及具体的实践中的其他技巧,希望对其他人也有所帮助。
参考文献
作者简介
韦明明
■ 经销商技术部-i车商技术团队。
■ 2016年3月加入汽车之家。任职于经销商技术部-i车商技术团队。目前主要参与C端交易类项目的开发工作。
阅读更多:
▼ 关注「之家技术」,获取更多技术干货 ▼