通过观察频谱来学习如何构建和测试 GraphQL 服务器
通过观察频谱来学习如何构建和测试 GraphQL 服务器
GraphQL文件夹结构
模式优先开发
测试 GraphQL 查询
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
通过观察频谱来学习如何构建和测试 GraphQL 服务器
最近我一直很感兴趣的是如何更好地构建和测试 JavaScript 应用程序,特别是那些使用 GraphQL 的应用程序。
假设我有一个用Node.js编写的GraphQL服务器,我应该如何组织我的文件夹结构?我应该把模式和解析器放在哪里?我的类型定义应该和它们各自的解析器放在一起吗?
测试我的/graphql端点所有不同查询和变更操作 的好方法是什么?
最近,spectrum.chat将其整个技术栈开源了。这意味着你我都可以访问他们的代码仓库,研究他们的源代码。我的计划是观察他们如何构建 JavaScript 应用程序,并从中汲取一些灵感应用到我自己的应用程序中。希望我们能够解答我上面提出的一些问题。
通过深入学习这个开源课堂,你可以像专业人士一样使用这些技术(以下内容直接摘自他们的自述文件):
- RethinkDB:数据存储
- Redis:后台任务和缓存
- GraphQL:API,由整个 Apollo 工具链提供支持
- Flowtype:类型安全的 JavaScript
- PassportJS:身份验证
- React:前端和移动应用
- Expo:移动应用(React Native)
- DraftJS:在网络上提供所见即所得的写作体验
今天,我们将首先了解一下他们的 GraphQL API 的布局方式。
GraphQL文件夹结构
我们首先要了解的是Spectrum的文件夹结构是如何运作的。
server/
├── loaders
├── migrations
├── models
├── mutations
├── queries
├── routes
├── subscriptions
├── test
├── types
│ └── scalars.js
├── README.md
├── index.js # Runs the actual servers
└── schema.js
首先需要注意的是,应用程序中已有文档描述了每个部分的功能。您还可以从文档中了解到他们所有后端服务所使用的奇特的希腊字母命名规则。
-
加载器为 Spectrum 的每个资源都实现了Facebook 的 DataLoader,以便进行批量处理和缓存。这涉及到优化方面的内容,但我们才刚刚起步,所以暂时不用担心。
-
迁移功能允许开发者预先填充数据以测试应用程序。它包含大量静态默认数据,但也使用了faker库,允许您伪造各种数据,例如用户、频道和消息线程。
-
模型描述了 API 如何与数据库交互;对于每个资源(用户、频道等),都有一组函数可用于查询或修改数据库中的数据。
-
查询包含解析器函数,用于描述如何获取数据、获取哪些项目、字段以及如何对它们进行分页。
-
Mutations包含解析器函数,用于描述如何创建新数据、删除或更新现有数据。
解析器是一种简洁的方式,用于描述调用相应服务以获取客户端所需数据的函数。例如,考虑以下查询:
query GetChannelsByUser {
user(id: "some-user-id") {
channels {
members
}
}
}
这条查询语句通过 ID 获取单个用户,同时获取该用户所属的所有频道及其成员。至于如何实现这一点,就需要解析器函数的帮助了。
在这种情况下,有三个解析函数:一个用于获取用户,一个用于获取该用户的频道,还有一个用于获取每个频道的所有成员。最后一个解析函数甚至可能针对每个频道运行 n 次。
您可能会注意到,这个查询可能会非常耗费资源。如果多个频道有成千上万的成员呢?这时加载器就派上用场了。不过我们今天暂且不讨论这个问题。
-
订阅功能允许服务器使用 WebSocket 服务器向移动或 Web 客户端上的用户推送消息和通知。
-
测试包含对查询和变更本身的测试,方法是针对实际数据库执行查询。我们稍后会介绍几个例子。
-
类型指的是 GraphQL 模式类型、可用于查询的字段以及它们之间的关系。服务器启动时,会将这些类型合并在一起创建模式。
-
路由包含路由处理程序和用于更传统的 RESTful Webhook 的中间件。例如,Slack 集成和电子邮件退订。
与这些文件夹处于同一级别的是一个schema.js文件,该文件将所有类型定义和解析器合并到一个可用的 GraphQL schema 中。
最后,还有一个文件index.js会启动我们的后端 API 以及用于处理订阅的 WebSocket 服务器。最后一个文件对我来说不太重要;我已经知道如何使用中间件搭建 Node.js 服务器了。
模式优先开发
Facebook 建议,在编写任何业务逻辑之前,应该先构建好用户模式。如果用户模式构建得当,执行业务逻辑时就会更有信心。
扩展根类型
我们来看一下根 schema.js 文件,所有查询、变更和类型定义都从这里导入到项目中。我想特别指出根查询的结构:
type Query {
dummy: String
}
type Mutation {
dummy: String
}
type Subscription {
dummy: String
}
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
项目所有者的评论中提到,他们在定义类型时只是简单地扩展了根查询!这太棒了,因为在我看到这个项目之前,我一直都是这样做的:
type Query {
contents(offset: Int = 0, limit: Int = 10): [Content]
tags(offset: Int = 0, limit: Int = 10): [Tag]
users(offset: Int = 0, limit: Int = 20, field: String): [User]
# And many more queries...
}
type Mutation {
createContent(text: String): Content
updateContent(id: ID!, text: String): Content
deleteContent(id: ID!): Content
createUser(username: String!): User
updateUser(id: ID!, username: String!): User
# I don't want to write all of these here...
}
虽然我很喜欢意大利面条式结构,但这种模式在大型应用程序中肯定会变得难以管理。Spectrum 就是这样扩展查询的,你也可以通过阅读文档了解这一点。
extend type Query {
channel(id: ID, channelSlug: String, communitySlug: String): Channel @cost(complexity: 1)
}
extend type Mutation {
createChannel(input: CreateChannelInput!): Channel
editChannel(input: EditChannelInput!): Channel
deleteChannel(channelId: ID!): Boolean
# ...more Channel mutations
}
定义输入类型
你可能还会注意到,上面的要点中,它们的输入类型并没有列出它们所需的每一个字段(就像我上面那样😮)。
相反,它们为每种需要除 ID 之外的其他参数的不同 mutation 创建了特定的类型。这些类型在 GraphQL schema 中定义为输入类型。
input CreateChannelInput {
name: String!
slug: String!
description: String
communityId: ID!
isPrivate: Boolean
isDefault: Boolean
}
input EditChannelInput {
name: String
slug: String
description: String
isPrivate: Boolean
channelId: ID!
}
果不其然,如果我真的把所有文档都读了,或许就能看到了。我在编写 GraphQL API 时,觉得有些部分很可笑,“为什么我非得在这里写这么多输入字段!”我当时想。
当你多次用蛮力去做某件事,然后再找出正确的方法时,这真的能帮助你学习。
这适用于软件开发领域内外的许多方面。这就好比你发现自己的乒乓球击球姿势一直都是错的,尽管它之前帮你赢了几局。好吧,我的击球姿势仍然是错的,但至少我现在意识到了。😅
连接和边缘
优秀的 GraphQL API 通常会为其数据集中的项目提供一个接口,以便在获取数据时使用游标或分页。例如,假设我们想要获取特定频道中的所有成员:
type Channel {
id: ID!
createdAt: Date!
modifiedAt: Date
name: String!
description: String!
slug: String!
memberConnection(first: Int = 10, after: String): ChannelMembersConnection! @cost(complexity: 1, multiplier: "first")
memberCount: Int!
# other fields omitted for brevity
}
通过指定成员类型为连接, API 的使用者就会知道他们正在处理自定义的非原始类型,这种类型符合其游标的工作方式。
在频谱 API 中,他们使用参数first来after处理光标。
first只是一个数字,用于告诉查询要获取多少项;有些 API 使用 limit 来实现这一点。after是一个用作偏移量的字符串,也就是说,如果我指定字符串“some-item-id”,它将获取该项之后的前n个项。基本上就是这样,只不过 Spectrum API 实际上将其编码为 base64。
字体ChannelMembersConnection如下所示:
type ChannelMembersConnection {
pageInfo: PageInfo!
edges: [ChannelMemberEdge!]
}
type ChannelMemberEdge {
cursor: String!
node: User!
}
当我们在 GraphQL 中定义的某个类型引用另一个自定义类型时,例如我们的 ` Channel<object>` 引用一个成员(它只是一个 `<object>` User),我们可以像这样定义类型以便与这些其他类型交互。我们可能关心的数据位于node`<edge>` 字段中,其中 `<edge>` 只是对已获取项的一种特殊称呼。
该连接会pageInfo返回一些元数据,说明数据集中是否存在下一页或上一页。现在让我们看看 membersConnection 是如何工作的。
示例查询:membersConnection
export default (
{ id }: DBChannel,
{ first, after }: PaginationOptions,
{ loaders }: GraphQLContext
) => {
const cursor = decode(after);
const lastDigits = cursor.match(/-(\d+)$/);
const lastUserIndex = lastDigits && lastDigits.length > 0 && parseInt(lastDigits[1], 10);
return getMembersInChannel(id, { first, after: lastUserIndex })
.then(users => loaders.user.loadMany(users))
.then(result => ({
pageInfo: {
hasNextPage: result && result.length >= first,
},
edges: result.filter(Boolean).map((user, index) => ({
cursor: encode(`${user.id}-${lastUserIndex + index + 1}`),
node: user,
})),
}));
};
当我们发出查询以获取Channel并请求时membersConnection,服务器将执行此解析器函数。
你会注意到函数顶部的参数语法有些奇怪。不用担心,它们使用的是FlowType。
该函数首先通过对 after 参数进行编码来创建一个游标,然后在编码后的字符串中查找最后几位数字。它利用这些数字来确定查询的起始位置。
然后,它会调用处理数据库交互层的函数。当数据库查询执行完毕后,该函数会获取查询结果并构建pageInfo我们edges之前提到的模型。
你还可以了解游标的编码方式;边缘会根据项目的 ID 和它在查询结果中出现的索引生成一个字符串。这样,当解码游标时,它就能知道它正在查找的类型和索引。
测试 GraphQL 查询
最近我一直在思考如何测试我的 GraphQL 服务器?我应该只对解析器函数进行单元测试吗?参考 Spectrum 的做法,他们实际上是通过直接调用测试数据库来测试查询的。据他们的团队说,当运行单元测试套件时,
在运行测试之前,它会在本地创建一个名为“testing”的 RethinkDB 数据库。它会对该数据库运行迁移,然后插入一些虚拟数据。这一点很重要,因为我们使用真实数据库测试 GraphQL API,不使用任何模拟数据,以确保一切 100% 正常运行。
完成此操作后,他们可以使用请求实用函数作为路由处理程序,处理原本会发送到 API/graphql路由的请求。
// @flow
import { graphql } from 'graphql';
import createLoaders from '../loaders';
import schema from '../schema';
type Options = {
context?: {
user?: ?Object,
},
variables?: ?Object,
};
// Nice little helper function for tests
export const request = (query: mixed, { context, variables }: Options = {}) =>
graphql(
schema,
query,
undefined,
{ loaders: createLoaders(), ...context },
variables
);
借助此工具,我们现在可以针对服务器执行自动化测试查询。以下是一个示例查询,可用于测试membersConnection我们之前检查过的查询。
import { request } from '../../utils';
import { SPECTRUM_GENERAL_CHANNEL_ID } from '../../../migrations/seed/default/constants';
it('should fetch a channels member connection', async () => {
const query = /* GraphQL */ `
{
channel(id: "${SPECTRUM_GENERAL_CHANNEL_ID}") {
id
memberConnection(after: null) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
cursor
node {
id
name
contextPermissions {
communityId
reputation
}
}
}
}
}
}
`;
expect.assertions(1);
const result = await request(query);
expect(result).toMatchSnapshot();
});
假设每次执行的测试数据相同,我们实际上可以利用快照功能!我认为这是一个非常巧妙的用例;给定一个默认数据集,你总是会期望查询返回特定格式的数据。
如果与该查询相关的解析器函数之一发生更改,Jest 会提醒我们快照中的差异。
这不是很棒吗?
对我来说,这就是全部内容了。通过仔细研究 Spectrum 的 API,我确实学到了很多关于构建更好的 GraphQL 服务器的知识。
还有一些内容我没有真正讲解,例如订阅、指令或身份验证。
如果你对这些主题感兴趣,不妨看看以下链接:
- Max Stoiber 的文章《保护您的 GraphQL API 免受恶意查询攻击》
- Jonas Helfer 的《GraphQL 身份验证指南》
- Ben Newman 的《可重用的 GraphQL Schema 指令》
- “ Apollo Client 中的 GraphQL 订阅” 作者:Amanda Liu
想看更多文章或精彩评论?请在Medium、Github和Twitter上关注我!
文章来源:https://dev.to/iwilsonq/learn-to-architect-and-test-graphql-servers-by-observing-spectrum-5din