构建便携式Apollo服务器配置教程

117 阅读7分钟

Photo of electrical socket adapters

构建便携式Apollo服务器配置

Apollo Server的优点之一是有很多不同的Node.js中间件库集成,因此有很多不同的地方可以运行它。然而,有时在生产中运行Apollo Server的最佳方式并不一定是在本地开发或测试中运行它的最佳方式。

例如,AWS的Lambda函数是在生产中运行Apollo服务器的绝佳场所,因为你的大部分部署和扩展问题都为你处理了。然而,使用Lambda函数与本地模拟器(如ServerlessAWS SAM框架提供的模拟器)进行开发和测试可能会很慢,而且设置起来很麻烦。

另一方面,Express很适合做本地开发,因为它是轻量级的,可嵌入的,并且容易与其他工具(例如nodemonts-nodeJest等)一起使用。然而,如果你想在生产中运行它,你需要用很多支持性的基础设施来围绕它。

因此,如果你能用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();

我们可以看到,我们提供给每个类构造函数的typeDefsresolvers 选项是完全一样的。我们怎样才能重构这种重复呢?乍一看,你可能会认为ApolloServer 类可能是一个很好的开始,但请注意,在每个例子中,这个类实际上来自不同的包,有不同的方法。

然而,事实证明,不同的ApolloServer 类构造函数都接受同一个参数。我们将这个参数称为 "配置对象"。一个配置对象可以指定很多东西。事实上,它可以封装关于一个特定服务器的所有东西,这些东西可以跨集成共享。

这意味着你可以共享所有与模式相关的代码、相同的解析器代码(其中可以包括你拥有的任何自定义标量和枚举类型)、相同的数据源、相同的自定义指令和相同的上下文创建代码(尽管最后一项需要一点额外的工作,我将在后面讲到)。

你唯一不能共享的是那些特定的集成类型的东西。例如,你不能在lambda函数中运行订阅,所以试图在一个配置对象中描述它们是没有意义的,你想把它们放到lambda函数中。

所以综上所述,当我谈到 "在任何地方运行Apollo服务器 "时,我实际上是指 "在任何地方运行Apollo服务器配置对象"。在本篇文章的剩余部分,我将重构我们的代码,使其可以在Express和AWS Lambda中运行,没有重复,然后扩展它以支持几个更常见的现实世界的开发场景。

typeDefsresolvers

让我们从重构我们两个集成之间已经重复的东西开始,把它放在一个模块中,我们称之为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.envcreateConfig

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服务器的设计,只要稍加思考,我们就可以把这么多的代码部署到不同的环境中。