Skip to main content

GraphQL 项目搭建

· 14 min read
Alan

本文转摘自公众号文章: GraphQL实践

随着业务的增长,页面涉及的业务线越来越多, 为了实现一个需求, 需要调用多个接口组合数据, 然后绑定到 UI 组件上. 每个接口返回的数据会有很多字段是不会使用的, 浪费了网络流量. 为了解决该问题, 我们小组引入了 GraphQL 作为 BFF 层, 利用 GraphQL 服务减少网络请求次数, 以及数据裁减能力减少网络响应数据量.

下面简单介绍一下我们组的 GraphQL Node 服务端项目的搭建过程.

我们在搭建 GraphQL 项目时, 采用了 Express 搭配 Apollo GraphQL 方式.

GraphQL 社区的 Node 方案除了 Apollo GraphQL, 还有个Yoga 可以使用. 当然也可以直接使用 Facebook 的 GraphQL.js

Express + Apollo GraphQL

安装依赖

npm install apollo-server-express apollo-server-core express graphql

启动代码

server.ts
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",
});

// Modified server startup
await new Promise<void>((resolve) => http.listen({ port: 4000 }, resolve));
console.log(`Server ready at http://localhost:4000/`);
}

Apollo Server 也可以单独使用, 也可以使用对应 Web 框架的集成包, ApolloServer 需要从对应的集成包里导入.

自动加载合并 schema 和 resolvers

随着项目越来越大, 如果 GraphQL 类型文件和对应的 resolver 都放在一个文件里会变的难以维护, 可以使用 @graphql-tools/merge 缓解该问题.

下面的配置代码, 会自动加载 src/schema/graphqls 目录下的所有后缀名为 .gql.graphql 的schema文件, 以及 src/schema/resolvers 下所有扩展名为 .ts.js 的JS代码文件作为resolver:

src/schema/index.ts
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:

src/server.ts
import { makeExecutableSchema } from "@graphql-tools/schema";

const server = new ApolloServer({
schema: makeExecutableSchema({
typeDefs: typeDefs,
resolvers: resolvers,
}),
// ... 其他选项
});

代理

本地开发调试, 有时需要 Mock 某个后端接口, 或者查看实际应用发起了哪些网络请求, 可以通过配置 Node.js 应用代理来解决. 或者应用部署之后使用代理访问受限资源.

虽然 Apollo Server 支持 Node.js 标准的代理配置: https.globalAgenthttp.globalAgent, 不过推荐使用更加方便的第三方类库 global-agent, 该类库支持使用环境变量(HTTP_PROXY, NO_AGENT)设置代理(而 Node.js 是不支持的, 而且也许以后也不打算支持.).

安装

npm install global-agent

使用

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 即可设置代理.

HTTP Cache

关于 HTTP 缓存有两个重要的概念: 新鲜度 和 有效性. 服务器通过输出Cache-ControlExpires响应头来标识资源在多长时间内是新鲜的. 比如服务器端输出 Cache-Control: max-age=300 告诉客户端 5 分钟内资源都是新鲜的, 无需重复向服务器请求该资源. 对于不常变化的资源使用该方式是一种不错的选择. 然后 5 分钟之后, 客户端就会再次向服务器发送请求获取资源, 即便资源并没有发生变化.

为了避免客户端因为无法知晓资源是否新鲜而重新下载资源, 可以使用Last-Modified, ETag响应头标识资源的有效性. 如果服务器设置了响应头Last-Modified, 客户端可以发送请求头 If-Modifies-Since 避免再次下载已经缓存但未发生变化的资源. 也可以使用响应头 ETag 标识资源版本来避免重复下载.

利用新鲜度和有效性相关的响应头可以有效控制浏览器端缓存和 CDN 缓存.

由于 GraphQL 服务默认使用 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 之后就可以响应资源了

流程图如下(查看大图):

https://gitee.com/alanway/resources/raw/master/images/GraphQL-diagram-persist-query.png

使用该方式即解决了 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.

请求后端接口

GraphQL 服务器端调用后端接口时, 可以使用RESTDataSourceDataLoader优化网络请求次数.

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 对象

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
}

对应 resolver 实现如下:

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.

Apollo Server Plugin

借助 Apollo Server 插件系统, 可以很方便扩展一些功能

其他技巧

自动生成 TypeScript 类型定义代码

为了方便开发, 可以借助@graphql-codegen/cli自动生成GraphQL schema文件中定义的类型.

集成测试

Apollo Server 提供了两种e2e测试方法:

  • 使用 ApolloServerexecuteOperation
  • 直接向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");
});
})

References