
构建便携式Apollo服务器配置
Apollo Server的优点之一是有很多不同的Node.js中间件库集成,因此有很多不同的地方可以运行它。然而,有时在生产中运行Apollo Server的最佳方式并不一定是在本地开发或测试中运行它的最佳方式。
例如,AWS的Lambda函数是在生产中运行Apollo服务器的绝佳场所,因为你的大部分部署和扩展问题都为你处理了。然而,使用Lambda函数与本地模拟器(如Serverless或AWS SAM框架提供的模拟器)进行开发和测试可能会很慢,而且设置起来很麻烦。
另一方面,Express很适合做本地开发,因为它是轻量级的,可嵌入的,并且容易与其他工具(例如nodemon、ts-node、Jest等)一起使用。然而,如果你想在生产中运行它,你需要用很多支持性的基础设施来围绕它。
因此,如果你能用Express完成大部分的本地开发,然后部署到AWS的Lambda函数,而不必重复任何代码,那不是很好吗?在这篇文章中,我将向你展示如何做到这一点,同时还要考虑到环境特定的变量和集成特定的请求处理代码。
介绍config
让我们从基础知识开始。如果你以前使用过Apollo服务器,你会知道,要想开始使用,你首先要安装一个特定于你的首选集成的包。例如,这里有一个脚本,显示了该 [apollo-server](https://www.npmjs.com/package/apollo-server#installation-standalone)包是如何轻松地启动一个在Express中运行的服务器,并让你查询一个单一的greeting 字段。
const { ApolloServer } = require("apollo-server");
const server = new ApolloServer({
typeDefs: `
type Query {
greeting: String!
}
`,
resolvers: {
Query: {
greeting: () => "Hello!",
},
},
});
server.listen();
要在AWS Lambda函数中运行同样的代码,我们可以使用 [apollo-server-lambda](https://www.npmjs.com/package/apollo-server-lambda)包来编写一个模块,导出一个处理程序。比如说:
const { ApolloServer } = require("apollo-server-lambda");
const server = new ApolloServer({
typeDefs: `
type Query {
greeting: String!
}
`,
resolvers: {
Query: {
greeting: () => "Hello!",
},
},
});
exports.handler = server.createHandler();
我们可以看到,我们提供给每个类构造函数的typeDefs 和resolvers 选项是完全一样的。我们怎样才能重构这种重复呢?乍一看,你可能会认为ApolloServer 类可能是一个很好的开始,但请注意,在每个例子中,这个类实际上来自不同的包,有不同的方法。
然而,事实证明,不同的ApolloServer 类构造函数都接受同一个参数。我们将这个参数称为 "配置对象"。一个配置对象可以指定很多东西。事实上,它可以封装关于一个特定服务器的所有东西,这些东西可以跨集成共享。
这意味着你可以共享所有与模式相关的代码、相同的解析器代码(其中可以包括你拥有的任何自定义标量和枚举类型)、相同的数据源、相同的自定义指令和相同的上下文创建代码(尽管最后一项需要一点额外的工作,我将在后面讲到)。
你唯一不能共享的是那些特定的集成类型的东西。例如,你不能在lambda函数中运行订阅,所以试图在一个配置对象中描述它们是没有意义的,你想把它们放到lambda函数中。
所以综上所述,当我谈到 "在任何地方运行Apollo服务器 "时,我实际上是指 "在任何地方运行Apollo服务器配置对象"。在本篇文章的剩余部分,我将重构我们的代码,使其可以在Express和AWS Lambda中运行,没有重复,然后扩展它以支持几个更常见的现实世界的开发场景。
typeDefs 和resolvers
让我们从重构我们两个集成之间已经重复的东西开始,把它放在一个模块中,我们称之为config 。
exports.config = {
typeDefs: `
type Query {
greeting: String!
}
`,
resolvers: {
Query: {
greeting: () => "Hello!",
},
}
};
如果你使用TypeScript,请注意,配置对象有一个类型定义,可以从 [apollo-server](https://www.npmjs.com/package/apollo-server#installation-standalone)包中导入。
import { Config } from "apollo-server"
export const config: Config = {
typeDefs: `
type Query {
greeting: String!
}
`,
resolvers: {
Query: {
greeting: () => "Hello!",
},
}
}
虽然我强烈建议在Apollo服务器中使用TypeScript,但为了简洁起见,我将在这篇文章的其余部分中使用vanilla JavaScript。
dataSources
在这一点上,我们的配置对象并没有做什么。但是如果我们想让我们的 "Hello "字符串来自另一个REST服务器呢?我们会用一个数据源来实现。
让我们先定义一个简单的MessageDataSource 模块,使用 [apollo-](https://www.apollographql.com/docs/apollo-server/data/data-sources/)[datasource](https://www.npmjs.com/package/apollo-datasource-rest)[-rest](https://www.apollographql.com/docs/apollo-server/data/data-sources/)包。
import { RESTDataSource } from "apollo-datasource-rest"
exports.MessageDataSource = class extends RESTDataSource {
constructor() {
super()
this.baseURL = "https://localhost:8882"
}
async getMessage() {
return this.get("/")
}
}
然后我们可以把这个数据源导入我们的config 模块,并在我们的解析器中使用它。
const { MessageDataSource } = require("./MessageDataSource");
...
exports.config = {
...
dataSources: {
message: new MessageDataSource()
},
resolvers: {
Query: {
greeting: async function(source, args, context) {
return `${await context.dataSources.message.getMessage()}!`
}
},
...
}
}
使用config
现在让我们来看看我们如何将这个配置对象放入几个不同的集成中。让我们从Express开始。
const { ApolloServer } = require('apollo-server');
const { config } = require('./config');
const server = new ApolloServer(config);
server.listen()
然后是一个lambda函数:
const { ApolloServer } = require('apollo-server-lambda');
const config = require('./config');
const server = new ApolloServer(config);
exports.handler = server.createHandler();
但是等一下:你可能记得message 数据源的baseURL 是硬编码为 [http://localhost:8882](http://localhost:8882/).这在lambda环境中是行不通的!
为了使我们的配置真正具有可移植性,我们需要对不同环境下的变化进行参数化。我们首先要调整我们的MessageDataSource ,这样它就可以拥有传递给它的baseURL 的值。
...
exports.MessageDataSource = class extends RESTDataSource {
constructor(baseURL) {
super()
this.baseURL = baseURL
}
...
}
接下来,我们将使config 模块现在导出一个函数而不是一个常量。该函数将接受一个包含特定环境值的参数,并返回一个新的配置对象。
...
exports.createConfig = function(env) {
return {
...
dataSources: {
message: new MessageDataSource(env.messageServerUrl)
},
}
}
然后,我们将调整lambda函数,将其传递给 process.env到createConfig 。
const { ApolloServer } = require('apollo-server-lambda');
const createConfig = require('./config');
const server = new ApolloServer(createConfig(process.env));
exports.handler = server.createHandler();
现在我们可以使用Lambda环境变量,将messageServerUrl 的值设置为我们想要的任何值。我们甚至可以为不同的部署环境传入不同的值。例如,messageServerUrl 可能在我们的生产部署环境中有一个值,而在我们的测试部署环境中有一个值。
虽然我们也可以在本地使用Express的process.env ,但我不太喜欢使用环境变量,除非你别无选择,因为它们基本上是全局的。相反,当我进行本地开发时,我更喜欢从一个文件中加载我的特定环境值。这样我就可以很容易地通过符号链接到不同的文件,在不同的环境中切换。所以我们现在就为我们的Express服务器做这件事。
const { ApolloServer } = require('apollo-server');
const { createConfig } = require('./config');
const env = require('./env.json');
const server = new ApolloServer(createConfig(env));
app.listen()
注意我们是如何从文件系统中加载env.json ,然后把它的值放入createConfig 。对于针对本地服务器的开发,env.json 可能看起来像。
{ messageServerUrl: "http://localhost:8882" }
另外,我们也可以有其他版本的文件,指向我们的生产或测试部署环境。
context
我在前面提到,也可以在集成之间共享上下文相关的代码,但设置起来要复杂一些。现在我们将以将用户信息放在上下文中的常见场景为例,研究如何做到这一点。具体来说,我们将扩展message 解析器,以便在其响应中包括当前用户的名字。
在这个例子中,我们假设用户的名字已经被编码为JWT令牌,并被放置在一个名为Authorization 的HTTP头中。我们将使用 [jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken)包来进行解码(注意,在这个例子中,我们不会对令牌进行验证)。
const jwt = require('jsonwebtoken');
...
exports.createConfig = function(env) {
return {
resolvers: {
Query: {
message: async (source, args, context) =>
`${await context.dataSources.message.getMessage()}, ${
context.userName
}!`,
}
},
...
context: function(integrationContext) {
const authHeader = integrationContext.event.headers["Authorization"]
const payload = jwt.decode(authHeader)
return {
userName: payload.name
}
},
}
}
注意message 解析器现在能够从上下文中获得userName 。此外,为了把这个值放到上下文中,我们在我们的配置对象中添加了一个context 函数。这个函数获取头文件,对其进行解码,并从结果中获取用户名。
还要注意的是,尽管有这样的命名,但函数的integrationContext 参数不应该与GraphQL上下文相混淆。它是一个特定的集成对象,我们可以从中提取关于我们正在运行的特定集成的信息,包括当前的请求。
问题是,我们刚刚写的代码只能在AWS lambda函数中工作。如果我们想用Express代替,integrationContext 参数会有不同的形状,我们必须以稍微不同的方式编写代码。
...
exports.createConfig = function(env) {
return {
...
context: function(integrationContext) {
const authHeader = integrationContext.req.header("Authorization")
const payload = jwt.decode(authHeader)
return {
userName: payload.name
}
},
}
}
这里只有一行不同,其他都是一样的。我们如何处理这个重复的问题呢?让我们试着给createConfig 一个额外的参数--一个能从integrationContext 的特定头中获取的函数。
...
exports.createConfig = function(env, getHeader) {
return {
...
context: function(integrationContext) {
const authHeader = getHeader(integrationContext, "Authorization")
const payload = jwt.decode(authHeader)
return {
userName: payload.name
}
},
}
}
所以现在我们的lambda代码可以是这样的:
const { ApolloServer } = require('apollo-server-lambda');
const createConfig = require('./config');
const server = new ApolloServer(createConfig(
process.env,
(integrationContext, headerName) => integrationContext.event.headers[headerName]
));
exports.handler = server.createHandler();
而我们的Express服务器代码可以看起来像:
const { ApolloServer } = require('apollo-server');
const { createConfig } = require('./config');
const env = require('./env.json');
const server = new ApolloServer(createConfig(
env,
(integrationContext, headerName) => integrationContext.req.header(headerName)
));
server.listen();
我们已经做到了!我们已经将所有的共享代码封装在一个地方,而所有的集成特定代码则封装在另一个地方。一些读者可能会发现这种技术让人联想到依赖性注入,因为我们正在将集成特定的代码注入到配置中。依赖注入模式也可用于组成更复杂的数据源,但这是另一篇博客的主题 🙂
让我们来总结一下
Apollo Server的配置对象让我们把模式、解析器、数据源和上下文相关的逻辑打包在一个单元中。此外,我们可以在运行时组装配置对象,使其在不同的环境和集成中可以移植。这意味着你可以使用像Express这样的东西进行大部分的本地开发和测试,然后只在真正需要的时候使用AWS Lambda函数。
我已经在几个大型项目中成功使用了这种方法。还可以捆绑更多的高级功能,如自定义标量类型和自定义指令。除了订阅(不能进入Lambda函数)之外,我还没有发现其他不能被移植的东西。
如果你有兴趣,我已经为这篇文章中的代码创建了一个Github仓库。这个仓库还包括一个Jest测试套件,它将相同的配置对象放入一个嵌入式Express实例,并将其连接到一个存根的Message服务器。这证明了Apollo服务器的设计,只要稍加思考,我们就可以把这么多的代码部署到不同的环境中。