GraphQL服务器的结构和实现(第一部分)
谈到GraphQL——一个首要的问题就是如何构建一个GraphQL服务器?由于GraphQL仅被发布为一个具体规范,您的GraphQL服务器真的可以用任何您喜欢的编程语言实现。
在开始构建您的服务器之前,GraphQL需要您设计一个schema,它用来定义服务器的API。在这篇文章中,我们希望理解schema的主要组件,阐明真正实现它的机制。在这个过程中,我们会用如GraphQL.js, graphql-tools 和 graphene-js 等库来帮助理解。
这篇文章只触及原生GraphQL的功能——没有关于用来定义服务器和客户端如何通信的网络层的概念,着重于“GraphQL执行引擎”的内在工作原理和查询过程。关于网络层,可以看下一篇文章。
GraphQL schema定义服务器API
如何定义schema:Schema定义语言
GraphQL有它自己的语言,它用来编写GraphQL的schema:Schema 定义语言(SDL). 举个最简单的例子,GraphQL SDL可以用来定义下面的类型:
type User {
id: ID!
name: String
}
User类型本身并不暴露任何功能给客户应用程序,它只是定义了一个用户模型。为了给API增加功能,您需要给GraphQL schema的根类型(Query,Mutation和Subscription)添加字段。这些根类型定义了GraphQL API的入口。
例如,看下面这个查询:
query {
user(id: "abc") {
id
name
}
}
该查询只有当对应的GraphQL schema定义了Query根类型并添加如下user字段才是有效的:
type Query {
user(id: ID!): User
}
所以,schema的根类型决定了可以被服务器接受的查询和变异(mutations)的形态。
GraphQL schema给客户端到服务端通信提供了一纸明确的契约。
GraphQLSchema 是GraphQL服务器的核心对象
GraphQL.js是脸书推荐的GraphQL实现,它也为其它库提供了基础,如graphql-tools 和 graphene-js。当您使用这些库中的任何一个,您的开发流程都在围绕着GraphQLSchema对象,这个对象包含下面两个主要组件:
- schema的定义
- 以分解器(resolver)函数的形式的真正实现
对于上面的例子,GraphQLSchema对象会像如下所示:
const UserType = new GraphQLObjectType({
name: 'User',
fields: {
id: { type: GraphQLID },
name: { type: GraphQLString },
},
})
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
user: {
type: UserType,
args: {
id: { type: GraphQLID },
},
},
},
}),
})
正如您所看到的,SDL版本的schema可以被直接翻译成用Javascript描述的GraphQLSchema类型。注意这个schema没有任何分解器——所以它不会允许您真正执行任何查询或变异。下一小节会更多介绍这些。
分解器实现API
GraphQL服务器: 结构 vs 行为
GraphQL对结构和行为有一个非常清晰的划分。GraphQL服务器的结构——正如我们刚刚讨论的——是它的schema, 一个对服务器所能做的事的抽象描述。这种结构通过确定服务器行为的具体实现,开始工作起来。实现的关键组件就是所谓的分解器函数。
GraphQL schema里的每一个字段都有一个分解器支撑。
在最基础的形式下,一个GraphQL服务器的schema里的每个字段都有一个分解器函数。每个分解器函数都知道如何去为该字段获取数据。因为一个GraphQL查询本质上只是一组字段的合集,GraphQL服务器为了获得被请求的数据,所有的真正需要做的是为查询中的具体字段调用分解器函数。(这也是为什么GraphQL总是被拿去和RPC风格的系统作对比,因为它本质上一个调用远程函数的语言)
剖析分解器函数
使用GraphQL.js,GraphQLSchema对象中的任一个类型的每个字段都可以附上一个resolve函数。让我们考虑上面的例子,尤其是Query类型上的user字段——我们可以添加一个resolve函数:
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
user: {
type: UserType,
args: {
id: { type: GraphQLID },
},
resolve: (root, args, context, info) => {
const { id } = args // the `id` argument for this field is declared above
return fetchUserById(id) // hit the database
},
},
},
}),
})
假设函数fetchUserById真的可用并且返回一个User实例(一个有id和name字段的JS对象),可以说resolve函数赋予了schema被执行的能力。
在我们深入讨论之前,让我们稍微花一点时间理解传给分解器函数的四个实参:
root(有时也被称为parent):记住我们前面说的GraphQL分解一个query,所需要做的只是调用查询字段的分解器函数。它通过广度优先(一层一层的)来做到这点,在每次分解器调用里的root实参只是上一次调用的的结果(初始值是null如果没有另外设置的话)args:该实参用来为查询携带形参,在这个例子中,User需要取的id。context一个穿过分解器链的对象,每个分解器都可以对它读写(基本上是一个用来给分解器之间交流和共享信息的工具)。info查询和变异的一个AST表现。您可以在这个系列文章的第三篇:GraphQL Server基础:解密GraphQL分解器的info参数中查看更多细节。
前面我们提到了GraphQL schema里的每一个字段都有一个分解器支撑。现在我们只有一个分解器,而我们的schema总共有三个字段:Query类型上的User字段,外加User类型上的id和name。剩下的两个字段仍然需要它们的分解器。正如您看到的,实现这些分解器是微不足道的事:
const UserType = new GraphQLObjectType({
name: 'User',
fields: {
id: {
type: GraphQLID,
resolve: (root, args, context, info) => {
return root.id
},
},
name: {
type: GraphQLString,
resolve: (root, args, context, info) => {
return root.name
},
},
},
})
查询的执行
考虑我们上面的查询,我们来理解它是怎样被执行并获得数据的。查询一共包含三个字段:user(根字段),id和name。这意味着当该查询到达服务器时,服务器需要调用三个分解器函数——每个字段对应一个。我们一起来看看执行流程吧:
- 查询到达服务器
- 服务器为根字段
user调用分解器——我们假设fetchUserById返回这个对象:{ "id": "abc", "name": "Sarah" } - 服务器为
User类型的id字段调用分解器。该分解器的输入实参root来自上次调用的返回值,所以它可以简单的返回root.id - 类似第三步,只是最后返回
root.name。(注意第三步和第四步可以并行) - 分解过程结束——最后结果用一个
data字段包裹,这也是遵循了GraphQL的具体规范
{
"data": {
"user": {
"id": "abc",
"name": "Sarah"
}
}
}
优化请求:DataLoader 模式
使用上面描述的执行方法,当客户端发送深层嵌套的查询时,我们很容易就会遇到性能问题。设想我们的API同样可以被请求带评论的文章并且允许下面的查询:
query {
user(id: "abc") {
name
article(title: "GraphQL is great") {
comments {
text
writtenBy {
name
}
}
}
}
}
注意我们是怎样从一个指定的user来请求一个特定的article,以及该文章下的comments和留下这些评论的用户们的name。
让我们假设这篇文章有五个评论,这些评论都是由同一个用户写的。这意味着我们命中(hit)了writtenBy的分解器五次,但是它每次都返回同样的结果。DataLoader可以让您优化这样的情景来避免N+1查询问题——主要观点是分解器的调用被分批处理了所以数据库(或其它数据源)仅被命中一次。
想了解更多DataLoader的内容,您可以观看这个精彩的视频DataLoader — Source code walkthrough (大约35分钟)
GraphQL.js vs graphql-tools
现在,让我们谈谈一些可用的库,这些库可帮助您在JavaScript中实现GraphQL服务器-主要是关于GraphQL.js和graphql-tools之间的区别。
GraphQL.js为graphql-tools提供了基础
首先要了解的是GraphQL.js为graphql-tools提供了基础。它通过定义所需的类型,实现schema构建以及查询验证和解析来完成所有繁重的工作。然后,graphql-tools在GraphQL.js之上提供了一个薄薄的便利层。
让我们快速浏览GraphQL.js提供的功能。 请注意,其功能主要围绕GraphQLSchema:
parse和buildASTSchema:对于给定的GraphQL shema(GraphQL SDL中定义为字符串),这两个函数将创建一个GraphQLSchema实例:const schema = buildASTSchema(parse(sdlString))。validate:给定一个GraphQLSchema实例和一个查询,validate可确保查询遵守该schema定义的API。execute:给定一个GraphQLSchema实例和一个查询,execute调用查询字段的分解器,并根据GraphQL规范创建响应。 当然,这仅在分解器是GraphQLSchema实例的一部分时才起作用(否则,它就像只有菜单没有厨房的餐厅)。printSchema:接收一个GraphQLSchema实例,并返回其在SDL中的定义(作为字符串)。
而GraphQL.js中最重要的功能是graphql,它接受一个GraphQLSchema实例和一个查询—然后调用validate和execute:
graphql(schema, query).then(result => console.log(result))
要了解所有这些函数,请查看这段简单直接的代码。
这里graphql函数针对一个schema执行了GraphQL查询,而该schema本身已包含了结构和行为。因此,graphql的主要作用是根据所提供查询的内容来组织分解器函数的调用并打包响应数据。在这方面,由graphql函数实现的功能也称为GraphQL引擎。
graphql-tools: 连接接口和实现的桥梁
使用GraphQL的好处之一是,您可以采用架构优先(schema-first)的开发流程,这意味着您首先构建的每个功能都会在GraphQL架构中体现出来,然后通过相应的分解器实现。这种方法有很多好处,例如,由于SDL的存在,它允许前端开发人员在由后端开发人员还未实现API之前就可以开始使用模拟的API。
GraphQL.js的最大缺点是,它不允许您在SDL中编写schema,然后轻松生成GraphQLSchema的可执行版本。
如上所述,您可以使用parse和buildASTSchema从SDL创建GraphQLSchema实例,但这缺少使执行成为可能的必需的resolve函数!使GraphQLSchema变得可执行(使用GraphQL.js)的唯一方法是手动将resolve函数添加到架构的字段中。
graphql-tools通过一项重要功能来填补这一空白:addResolveFunctionsToSchema。 这非常有用,因为它可用于提供更好的基于SDL的API来创建schema。而这正是graphql-tools通过makeExecutableSchema的做到的:
const { makeExecutableSchema } = require('graphql-tools')
const typeDefs = `
type Query {
user(id: ID!): User
}
type User {
id: ID!
name: String
}`
const resolvers = {
Query: {
user: (root, args, context, info) => {
return fetchUserById(args.id)
},
},
}
const schema = makeExecutableSchema({
typeDefs,
resolvers,
})
因此,使用graphql-tools的最大好处是其将声明式schema与分解器连接的漂亮的API!
什么时候不用graphql-tools
我们刚刚了解到graphql-tools的核心是在GraphQL.js之上提供了一个便利层,那么在哪些情况下它不是实现服务器的正确选择呢?
与大多数抽象一样,graphql-tools通过牺牲其他地方的灵活性使某些工作流程更容易。它提供了极好的“入门”体验,并在快速构建GraphQLSchema时避免了不必要的阻碍。但是,如果您的后端有更多自定义要求,例如动态构造和修改schema,则它的约束可能会太紧-在这种情况下,您可以退回使用GraphQL.js。
关于graphene-js的简要记录
graphene-js是一个新的GraphQL库,它遵循其Python同类产品的想法。 它还在背后使用了GraphQL.js,但不允许在SDL中进行schema声明。
graphene-js深深地融入了现代JavaScript语法,提供了直观的API,可以将查询和变异实现为JavaScript类。看到更多的GraphQL实施方案以新颖的思想丰富生态系统,真是令人兴奋!
总结
在这篇文章中,我们揭开了GraphQL执行引擎的内在工作原理。从用于定义服务器AIP的GraphQL schema开始,了解到schema也决定了哪些查询和变异会被接受,以及返回响应的格式应该是什么样的。接着我们深入分解器函数并总结了用于处理传入请求的GraphQL引擎的执行模式。最后我们对现有的,用来帮助实现GraphQL服务器的Javascript库进行了一个总览。
总的来说,知道GraphQL.js提供了所有您需要的用于构建GraphQL服务器的功能是很重要的——graphql-tools只是在最其之上实现了一个方便层,可以满足大多数用例,并提供了出色的“入门”经验。只有当您对构建GraphQL schema有更加高级的需求时,脱下手套直接使用GraphQL.js或许才有些道理。
下一篇文章中,我们会讨论网络层以及实现GraphQL服务器的不同的库,如express-graphql, apollo-server和graphql-yoga. 第三部分会包含GraphQL解析器中的info对象的结构和职能。