现在,您已经学习了如何使用 React 创建组件和构建可工作的用户界面,在本章中,我们将花一些时间为数据集成后端服务器。
到目前为止,Express 和 Node.js 服务器提供的唯一资源是以index.html形式的静态内容。在本章中,除了静态 HTML 文件之外,我们将开始使用来自 Express 和 Node.js 服务器的 API 来获取和存储数据。这将取代浏览器内存中的硬编码问题数组。我们将对前端和后端代码进行更改,因为我们将实现和使用 API。
我们不会将数据保存在磁盘上;相反,我们将只使用服务器内存中的模拟数据库。我们将把真正的持久性留到下一章。
我在 Hello World 一章中简要地提到了 Express 以及如何使用 Express 来服务静态文件。但是 Express 可以做的不仅仅是服务静态文件。Express 是一个最小但灵活的 web 应用框架。从某种意义上说,Express 本身做得很少。它依赖于被称为中间件的其他模块来提供大多数应用需要的功能。
第一个概念是路由。Express 的核心是路由,它从本质上接受一个客户机请求,将它与任何存在的路由进行匹配,并执行与该路由相关联的处理程序功能。处理函数应该生成适当的响应。
路由规范由 HTTP 方法(GET、POST 等)组成。)、匹配请求 URI 的路径规范以及路由处理程序。处理程序在请求对象和响应对象中传递。可以检查请求对象以获得请求的各种细节,可以使用响应对象的方法将响应发送给客户端。所有这些可能看起来有点令人不知所措,所以让我们从一个简单的例子开始,并探索细节。
我们已经有了一个使用express()函数创建的 Express 应用。我们还安装了一个处理静态文件的中间件。中间件功能处理与路径规范匹配的任何请求,而不考虑 HTTP 方法。相反,路由可以用特定的 HTTP 方法匹配请求。因此,为了匹配 GET HTTP 方法,必须使用app.get()而不是app.use()。此外,处理函数(路由函数采用的第二个参数)可以将响应设置为发送回调用者,如下所示:
...
app.get('/hello', (req, res) => {
res.send('Hello World!');
});
...当收到一个请求时,Express 做的第一件事就是将请求与其中一个路由匹配。请求方法与路线的方法相匹配。在前面的例子中,路由的方法是get(),所以任何使用 GET 方法的 HTTP 请求都将匹配它。此外,请求 URL 与路径规范匹配,路径规范是路由中的第一个参数,即/ hello。当一个 HTTP 请求符合这个规范时,就会调用处理函数。在前面的例子中,我们只是用一条文本消息来响应。
路线的方法和路径不必是特定的。如果您想匹配所有的 HTTP 方法,您可以编写app.all()。如果需要匹配多个路径,可以传入一个路径数组,甚至像'/*.do'这样的正则表达式也可以匹配任何以扩展名.do结尾的请求。很少使用正则表达式,但是经常使用路由参数,所以我将对此进行更详细的讨论。
路由参数是路径规范中与 URL 的一部分匹配的命名段。如果出现匹配,URL 中该部分的值将作为请求对象中的变量提供。
它以下列形式使用:
app.get('/customers/:customerId', ...)URL /customers/1234将匹配路由规范,/customers/4567也是如此。在这两种情况下,客户 ID 将被捕获并作为请求的一部分提供给处理函数req.params,参数的名称作为关键字。因此,对于这些 URL 中的每一个,req.params.customerId将分别具有值1234或4567。
查询字符串不是路径规范的一部分,因此不能对查询字符串的不同参数或值使用不同的处理程序。
可以设置多个路由来匹配不同的 URL 和模式。路由不会尝试寻找最佳匹配;相反,它会尝试按照安装顺序匹配所有路由。使用第一个匹配项。因此,如果两个路由可能匹配一个请求,它将使用第一个定义的路由。因此,必须按照优先级顺序定义路线。
因此,如果您添加模式而不是非常具体的路径,您应该注意在具体路径之后添加更通用的模式,以防请求同时匹配两者。例如,如果您想要匹配/api/下的所有内容,也就是说,像/api/*这样的模式,您应该只在处理路径的所有更具体的路由之后添加这个路由,例如/api/issues。
一旦匹配了路由,就调用处理函数,在前面的例子中,它是提供给路由设置函数的匿名函数。传递给处理程序的参数是请求对象和响应对象。处理函数不应该返回任何值。但是它可以检查请求对象,并根据请求参数发送响应作为响应对象的一部分。
让我们简单看一下请求和响应对象的重要属性和方法。
使用请求对象的属性和方法可以检查请求的任何方面。下面列出了一些重要且有用的属性和方法:
-
req.params:这是一个包含映射到命名路由参数的属性的对象,正如您在使用:customerId的例子中看到的。属性的键将是路由参数的名称(在本例中是customerId),值将是作为 HTTP 请求的一部分发送的实际字符串。 -
req.query:保存解析后的查询字符串。它是一个以键作为查询字符串参数,以值作为查询字符串值的对象。多个同名的键被转换为数组,带有方括号符号的键导致嵌套对象(例如,order[status]=closed可以作为req.query.order.status访问)。 -
req.header, req.get(header):get方法可以访问请求中的任何头部。header 属性是一个对象,所有标题都存储为键值对。一些头被特殊处理(如 Accept ),并在请求对象中有专门的方法。这是因为依赖于这些标题的常见任务可以轻松处理。 -
req.path:这包含了 URL 的路径部分,也就是 everything up any?开始查询字符串。通常,路径是路由规范的一部分,但是如果路径是可以匹配不同 URL 的模式,则可以使用此属性来获取请求中接收到的实际路径。 -
req.url, req.originalURL:这些属性包含完整的 URL,包括查询字符串。注意,如果您有任何修改请求 URL 的中间件,originalURL将保存修改前收到的 URL。 -
req.body:包含请求体,对 POST、PUT 和 PATCH 请求有效。注意,主体是不可用的(req.body将是未定义的),除非安装了一个中间件来读取和选择性地解释或解析主体。
还有许多其他的方法和属性;完整的列表请参考 http://expressjs.com/en/api.html#req 的 Express 的请求文档以及 Node.js 的请求对象at https://nodejs.org/api/http.html#http_class_http_incomingmessage ,Express 请求是从该请求扩展而来的。
response 对象用于构造和发送响应。请注意,如果没有发送响应,客户端将一直等待。
-
res.send(body):你已经简单看过了res.send()方法,它用一个字符串来响应。这个方法也可以接受一个缓冲区(在这种情况下,内容类型被设置为application/octet-stream,而不是字符串情况下的text/html)。如果主体是一个对象或数组,它会自动转换为具有适当内容类型的 JSON 字符串。 -
res.status(code):设置响应状态码。如果未设置,则默认为200 OK。一种常见的发送错误的方式是将status()和send()方法组合在一个单独的调用中,就像res.status(403).send("Access Denied")一样。 -
res.json(object):这和res.send()一样,除了这个方法强制转换传入 JSON 的参数,而res.send()可能会不同地对待一些参数,比如null。它还使代码可读和明确,表明您确实在发送一个 JSON。 -
res.sendFile(path):以path的文件内容响应。使用文件的扩展名猜测响应的内容类型。
响应对象中还有许多其他方法和属性;在 http://expressjs.com/en/api.html#res 可以查看 Express 文档中的完整列表,在 https://nodejs.org/api/http.html#http_class_http_serverresponse 可以查看 HTTP 模块中 Node.js 的 Response 对象。但是对于最常见的使用,前面的方法应该足够了。
Express 是一个 web 框架,它本身的功能很少。Express 应用本质上是一系列中间件函数调用。其实路由本身无非就是一个中间件功能。区别在于,中间件通常对请求和/或需要为所有或大多数请求完成的事情进行一般处理,但不一定是发送响应的链中的最后一个。另一方面,路由旨在用于特定的路径+方法组合,并被期望发出响应。
中间件功能是那些可以访问请求对象(req)、响应对象(res)以及应用的请求-响应周期中的下一个中间件功能的功能。下一个中间件功能通常用一个名为next的变量来表示。我不会详细讨论如何编写自己的中间件函数,因为我们不会在应用中编写新的中间件。但是我们肯定会使用一些中间件,所以理解任何中间件如何在高层次上工作是很方便的。
在 Hello World 示例中,我们已经使用了一个名为express.static的中间件来服务静态文件。这是作为 Express 的一部分唯一可用的内置中间件(除了路由)。但是 Express 团队还支持其他非常有用的中间件,我们将在本章中使用 body-parser,尽管是间接使用。第三方中间件可通过 npm 获得。
中间件可以在应用级别(适用于所有请求)或特定的路径级别(适用于特定的请求路径模式)。在应用级别使用中间件的方法是简单地向应用提供功能,就像这样:
app.use(middlewareFunction);在使用static中间件的情况下,我们通过调用express.static()方法构建了一个中间件功能。这不仅返回了一个中间件函数,还配置它使用名为public的目录来查找静态文件。
为了将相同的中间件仅用于匹配某个 URL 路径的请求,比如说,/public,调用app.use()方法时必须使用两个参数,第一个参数是路径,如下所示:
app.use('/public', express.static('public'));这将使在路径/public上安装静态中间件,所有静态文件都必须用前缀/public访问,例如/public/index.html。
REST(表述性状态转移的缩写)是应用编程接口(API)的架构模式。还有其他更老的模式,比如 SOAP 和 XMLRPC,但是最近,REST 模式越来越流行。
由于 Issue Tracker 应用中的 API 仅供内部使用,我们可以使用任何 API 模式,甚至可以发明自己的模式。但是我们不要这样做,因为使用现有的模式会迫使您更好地思考和组织 API 和模式,并鼓励一些好的实践。
虽然我们不会使用 REST 模式,但我会简单地讨论一下,因为由于它的简单性和少量的构造,它是更受欢迎的选择之一。它会让你体会到我最终选择使用 GraphQL 的区别和逻辑。
API 是基于资源的(而不是基于动作的)。因此,像getSomething或saveSomething这样的 API 名称在 REST APIs 中并不常见。事实上,没有传统意义上的 API 名称,因为 API 是由资源和动作组合而成的。实际上只有资源名叫做端点。
基于统一资源标识符(URI,也称为端点)来访问资源。资源是名词(不是动词)。通常每个资源使用两个 URIs:一个用于集合(如/customers),一个用于单个对象(如/customers/1234),其中1234唯一地标识一个客户。
资源也可以形成层次结构。例如,客户的订单集合由/customers/1234/orders标识,该客户的订单由/customers/1234/orders/43标识。
要访问和操作资源,可以使用 HTTP 方法。资源是名词,而 HTTP 方法是操作它们的动词。它们映射到资源上的 CRUD(创建、读取、更新、删除)操作。表 5-1 显示了 CRUD 操作到 HTTP 方法和资源的常用映射。
表 5-1
HTTP 方法的 CRUD 映射
|操作
|
方法
|
资源
|
例子
|
备注
|
| --- | --- | --- | --- | --- |
| 阅读列表 | 得到 | 募捐 | GET /customers | 列出对象(附加查询字符串可用于过滤和排序) |
| 阅读 | 得到 | 目标 | GET /customers/1234 | 返回单个对象(查询字符串可用于指定哪些字段) |
| 创造 | 邮政 | 募捐 | POST /customers | 使用主体中指定的值创建对象 |
| 更新 | 放 | 目标 | PUT /customers/1234 | 用正文中指定的对象替换该对象 |
| 更新 | 修补 | 目标 | PATCH /customers/1234 | 按照主体中的指定,修改对象的某些属性 |
| 删除 | 删除 | 目标 | DELETE /customers/1234 | 删除对象 |
其他一些操作,比如 DELETE 和 PUT in 集合,也可以用来一次性删除和修改整个集合,但是这种用法并不常见。HEAD 和 OPTIONS 也是有效的动词,它们给出关于资源的信息,而不是实际的数据。它们主要用于向外公开并由许多不同客户端使用的 API。
尽管 HTTP 方法和操作映射被很好地映射和指定,REST 本身并没有为以下内容制定规则:
-
对对象列表进行过滤、排序和分页。查询字符串通常以特定于实现的方式来指定这些内容。
-
指定在读取操作中返回哪些字段。
-
如果有嵌入对象,指定在读取操作中要扩展哪些对象。
-
指定要在修补操作中修改的字段。
-
对象的表示。在读取和写入操作中,您可以自由地使用 JSON、XML 或任何其他对象表示。
鉴于不同的 API 集使用不同的方式来处理这些问题,大多数 REST API 实现更像 REST- 而不是严格的 REST。这影响了普遍采用,因此,缺少工具来帮助完成实现基于 REST 的 API 所需的许多常见工作。
尽管 REST 范式在使 API 可预测方面非常有用,但是前面讨论的缺点使得当不同的客户端访问同一组 API 时很难使用它。例如,一个对象在移动应用中的显示方式和在桌面浏览器中的显示方式可能会有很大不同,因此,更细粒度的控制以及不同资源的聚合可能会更好。
GraphQL 正是为了解决这些问题而开发的。因此,GraphQL 是一个更加精细的规范,具有以下显著特征。
与 REST APIss 不同,在 REST API 中,您很难控制服务器将什么作为对象的一部分返回,而在 GraphQL 中,必须指定需要返回的对象的属性。在 REST API 中,不指定对象的字段会返回整个对象。相反,在 GraphQL 查询中,不请求任何内容是无效的。
这使得客户端可以控制通过网络传输的数据量,从而提高效率,特别是对于移动应用等轻型前端。此外,添加新功能(字段或新 API)不需要您引入新版本的 API 集。给定一个查询,由于返回数据的形状是由其决定的,所以不管 API 如何变化,结果都是一样的。
不利的一面是 GraphQL 查询语言有一点学习曲线,任何 API 调用都必须使用它。幸运的是,这种语言的规范非常简单,很容易掌握。
REST APIs 是基于资源的,而 GraphQL 是基于图形的。这意味着对象之间的关系在 GraphQL APIs 中被自然地处理。
在问题跟踪器应用中,您可以认为问题和用户有关系:问题分配给用户,用户有一个或多个分配给他们的问题。当查询用户的属性时,GraphQL 可以很自然地查询与分配给他们的所有问题相关的一些属性。
GraphQL API 服务器有一个端点,而 REST 中每个资源只有一个端点。被访问的资源或字段的名称作为查询本身的一部分提供。
这使得使用单个查询来查询客户端所需的所有数据成为可能。由于查询基于图形的性质,所有相关对象都可以作为一个对象的查询的一部分来检索。不仅如此,甚至不相关的对象也可以在对 API 服务器的一次调用中被查询。这消除了对“聚合”服务的需求,聚合服务的工作是将多个 API 结果放在一个包中。
GraphQL 是一种强类型查询语言。所有字段和参数都有一个类型,可以根据该类型来验证查询和结果,并给出描述性的错误消息。除了类型之外,还可以指定哪些字段和参数是必需的,哪些是可选的。所有这些都是使用 GraphQL 模式语言完成的。
强类型系统的优点是它可以防止错误。考虑到 API 是由不同的团队编写和使用的,因此必然会有沟通上的差距,这是一件很棒的事情。
GraphQL 的类型系统有自己的语言来指定您希望在 API 中支持的类型的细节。它支持整数和字符串等基本标量类型、由这些基本数据类型组成的对象以及自定义标量类型和枚举。
可以向 GraphQL 服务器查询它所支持的类型。这为工具和客户端软件创建了一个强大的平台来构建这些信息。这包括静态类型语言中的代码生成工具和资源管理器,使开发人员能够快速测试和学习 API 集,而无需重新整理代码库或与 cURL 争论。
我们将使用一个这样的工具,叫做 Apollo Playground,来测试我们的 API,然后将它们集成到应用的 UI 中。
单独解析和处理类型系统语言(也称为 GraphQL 模式语言)以及查询语言是很困难的。幸运的是,大多数语言中都有用于此目的的工具和库。
对于后端的 JavaScript,有一个名为 GraphQL.js 的 GraphQL 参考实现。为了将它与 Express 联系起来,并使 HTTP 请求成为 API 调用的传输机制,有一个名为express-graphql的包。
但是这些都是非常基本的工具,缺乏一些高级支持,比如模块化模式和定制标量类型的无缝处理。包graphql-tools和相关的apollo-server构建在 GraphQL.js 之上,以添加这些高级特性。在本章中,我们将使用问题跟踪器应用的高级包。
我将只介绍应用所需的 GraphQL 特性。对于您在自己的特定应用中可能需要的高级功能,请参考位于 https://graphql.org 的 GraphQL 的完整文档和位于 https://www.apollographql.com/docs/graphql-tools/ 的工具。
让我们从一个简单的 API 开始,它返回一个字符串,叫做 About。在这一节中,我们将实现这个 API 以及另一个 API,它允许我们更改这个 API 返回的字符串。这将让您学习使用 GraphQL 进行简单读取和写入的基础知识。
在我们开始为它编写代码之前,我们需要用于graphql-tools、apollo-server的 npm 包,以及它们所依赖的基础包graphql。包graphql-tools是apollo-server-express的依赖项,所以我们不必明确指定,而graphql是对等依赖项,需要单独安装。以下是安装它们的命令:
$ npm install graphql@0 apollo-server-express@2现在,让我们定义我们需要支持的 API 的模式。GraphQL 模式语言要求我们使用关键字type定义每种类型,后跟类型的名称,再加上花括号中的规范。例如,要定义一个包含用户名字符串的User类型,这是模式语言中的规范:
...
type User {
name: String
}
...对于 About API,我们不需要任何特殊的类型,只要基本的数据类型String就足够好了。但是 GraphQL 模式有两个特殊的类型,它们是类型系统的入口点,称为Query和Mutation。所有其他 API 或字段都是在这两种类型下分层定义的,它们就像 API 的入口点。查询字段应该返回现有状态,而突变字段应该改变应用数据中的某些内容。
模式必须至少有Query类型。查询和变异类型之间的区别是概念上的:在一个查询或变异中没有什么是您在另一个查询或变异中不能做的。但是一个微妙的区别是,查询字段是并行执行的,而变异字段是串行执行的。所以,最好按照它们的本意来使用它们:在Query下实现读操作,在Mutation下实现修改系统的东西。
GraphQL 类型系统支持以下基本数据类型:
-
Int:有符号 32 位整数。 -
Float:有符号双精度浮点值。 -
String:UTF 8 字符序列。 -
Boolean:true或false。 -
ID:表示唯一的标识符,序列化为字符串。使用一个ID代替一个字符串表明它不适合人类阅读。
除了指定类型之外,模式语言还提供了一个指示值是可选的还是强制的条款。默认情况下,所有值都是可选的(也就是说,它们可以是 null),那些需要值的值是通过在类型后添加一个感叹号(!)来定义的。
在 About API 中,我们只需要在Query下有一个名为about的字段,这个字段是一个字符串,也是一个强制字段。注意,模式定义是 JavaScript 中的一个字符串。我们将使用模板字符串格式,这样我们可以在模式中平滑地添加新行。因此,可以查询的about字段的模式定义如下:
...
const typeDefs = `
type Query {
about: String!
}
`;
...我们将在初始化服务器时使用变量typeDefs,但在此之前,让我们定义另一个字段,让我们更改消息并将其称为setAboutMessage。但是这需要为我们将接收的新消息输入一个值。这种输入值的指定就像函数调用一样:使用括号。因此,为了表明这个字段需要一个名为message的强制字符串输入,我们需要编写:
...
setAboutMessage(message: String!)
...请注意,所有参数都必须命名。GraphQL 模式语言中没有位置参数。此外,所有字段都必须有一个类型,并且没有 void 或其他类型指示该字段不返回任何内容。为了克服这一点,我们可以使用任何数据类型,并使其可选,这样调用者就不需要一个值。
让我们使用字符串数据类型作为setAboutMessage字段的返回值,并将其添加到模式中的Mutation类型下。让我们将包含模式的变量命名为typeDefs,并在server.js中将它定义为一个字符串:
...
const typeDefs = `
type Query {
about: String!
}
type Mutation {
setAboutMessage(message: String!): String
}
`;
...注意,我不再调用这些 API,而是调用类似于setAboutMessage字段的东西。这是因为所有的 GraphQL 都只有字段,访问字段会有副作用,比如设置一些值。
下一步是拥有在访问这些字段时可以调用的处理程序或函数。这样的函数被称为解析器,因为它们将查询解析为具有实值的字段。虽然模式定义是用特殊的模式语言完成的,但是解析器的实现依赖于我们使用的编程语言。例如,如果您要用 Python 定义 About API 集,那么模式字符串将与 JavaScript 中的相同。但是处理程序看起来与我们用 JavaScript 编写的完全不同。
在 Apollo 服务器和graphql-tools中,解析器被指定为遵循模式结构的嵌套对象。在每个叶级别,需要使用与字段同名的函数来解析字段。因此,在最顶层,我们将在解析器中有两个名为Query和Mutation的属性。让我们开始定义它:
...
const resolvers = {
Query: {
},
Mutation: {
},
};
...在Query对象中,我们需要一个用于about的属性,这是一个返回 About 消息的函数。让我们首先将消息定义为文件顶部的一个变量。因为我们将在setAboutMessage字段中改变消息的值,所以我们需要使用let关键字而不是const。
...
let aboutMessage = "Issue Tracker API v1.0";
...现在,函数需要做的就是返回这个变量。一个简单的不带参数的箭头函数应该可以达到这个目的:
...
Query: {
about: () => aboutMessage,
},
...因为我们需要接收输入参数,所以setAboutMessage函数没有这么简单。所有解析器函数都有四个参数,如下所示:
fieldName(obj, args, context, info)参数描述如下:
-
obj:包含父字段解析器返回结果的对象。该参数启用了 GraphQL 查询的嵌套性质。 -
args:一个对象,其参数被传递到查询中的字段中。例如,如果用setAboutMessage(message: "New Message")调用字段,args对象将是:{ "message": "New Message" }。 -
context:这是一个由特定查询中的所有解析器共享的对象,用于包含每个请求的状态,包括身份验证信息、数据加载器实例以及解析查询时应该考虑的任何其他内容。 -
info:这个参数应该只在高级情况下使用,但是它包含了关于查询执行状态的信息。
返回值应该是架构中指定的类型。在字段setAboutMessage的情况下,由于返回值是可选的,所以它可以选择不返回任何内容。但是,返回某个值来指示该字段的成功执行是一个很好的实践,所以让我们只返回message输入值。在这种情况下,我们也不会使用父对象(Query)的任何属性,所以我们可以忽略第一个参数obj,只使用args中的属性。因此,setAboutMessage的函数定义如下:
...
function setAboutMessage(_, { message }) {
return aboutMessage = message;
}
...我们使用 ES2015 析构赋值特性来访问第二个参数args中的message属性。这相当于将参数命名为args,并将属性访问为args.message,而不是简单的message。
现在,我们可以将该函数指定为顶级字段Mutation中setAboutMessage的解析器,如下所示:
...
Mutation: {
setAboutMessage,
},
...我们使用 ES2015 对象属性简写来指定setAboutMessage属性的值。当属性名和赋给它的变量名相同时,变量名可以跳过。因此,{ setAboutMessage: setAboutMessage }可以简单地写成{ setAboutMessage }。
既然我们已经定义了模式以及相应的解析器,我们就可以初始化 GraphQL 服务器了。方法是构造一个在apollo-server-express包中定义的ApolloServer对象。构造函数接受一个至少有两个属性的对象——typeDefs和resolvers——并返回一个 GraphQL 服务器对象。下面是实现这一点的代码:
...
const { ApolloServer } = require('apollo-server-express');
...
const server = new ApolloServer({
typeDefs,
resolvers,
});
...最后,我们需要在 Express 中安装 Apollo 服务器作为中间件。我们需要一个路径(单个端点)来安装中间件。但是,阿波罗服务器不是一个单一的中间件;事实上,有一组中间件功能以不同的方式处理不同的 HTTP 方法。ApolloServer对象为我们提供了一个方便的方法来完成所有这些工作,这个方法叫做applyMiddleware。它接受一个配置对象作为它配置服务器的参数,其中两个重要的属性是app和path。因此,要在 Express 应用中安装中间件,让我们添加以下代码:
...
server.applyMiddleware({ app, path: '/graphql' });
...将所有这些放在一起,我们应该有一个工作的 API 服务器。清单 5-1 显示了server.js的新内容,其中包含了所有的代码片段。
const express = require('express');
const { ApolloServer } = require('apollo-server-express');
let aboutMessage = "Issue Tracker API v1.0";
const typeDefs = `
type Query {
about: String!
}
type Mutation {
setAboutMessage(message: String!): String
}
`;
const resolvers = {
Query: {
about: () => aboutMessage,
},
Mutation: {
setAboutMessage,
},
};
function setAboutMessage(_, { message }) {
return aboutMessage = message;
}
const server = new ApolloServer({
typeDefs,
resolvers,
});
const app = express();
app.use(express.static('public'));
server.applyMiddleware({ app, path: '/graphql' });
app.listen(3000, function () {
console.log('App started on port 3000');
});
Listing 5-1server.js: Implementing the About API Set虽然我们不遗余力地确保所有代码清单的准确性,但可能会有打字错误、格式错误(如引号类型),甚至是在出版前没有出现在书中的更正。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。
正如我前面指出的,GraphQL 模式和自省允许开发人员开发能够探索 API 的工具。默认情况下,名为 Playground 的工具是 Apollo 服务器的一部分,只需浏览 API 端点即可访问。因此,如果你在浏览器的地址栏中输入http://localhost:3000/graphql,你将会找到游乐场的用户界面。
游乐场的默认主题是黑色。使用设置功能(右上角的齿轮图标),我把它改成了浅色主题,同时把字体大小减小到了 12。如果您也进行这些更改,您可能会看到如图 5-1 所示的用户界面。
图 5-1
图 QL 操场
在我们测试 API 之前,最好使用 UI 右侧的绿色 schema 按钮来探索模式。这样,您会发现模式中描述了about和setAboutMessage字段。要进行查询,您可以在左侧窗口中键入查询,并在单击 Play 按钮后在右侧看到结果,如 UI 中所述。
必须使用查询语言来编写查询。该语言类似于 JSON,但不是 JSON。查询需要遵循模式的相同层次结构,并且与之相似。但是我们不指定字段的类型,只指定它们的名称。对于输入字段,我们指定名称和值,用冒号(:)分隔。因此,要访问about字段,必须使用顶级的query,它只包含我们需要检索的字段,即about。以下是完整的查询:
query {
about
}请注意,Playground 中有一个自动完成功能,在您键入时可能会派上用场。操场还使用红色下划线显示查询中的错误。这些特性使用模式来了解可用的字段、参数及其类型。Playground 从服务器查询模式,因此每当模式改变时,如果您依赖自动完成,您需要刷新浏览器以便从服务器检索改变的模式。
因为默认情况下所有的查询都是类型Query(与Mutation相反),我们可以跳过关键字query,只输入{ about }。但是为了清楚起见,让我们始终包含query关键字。单击播放按钮,您会在右侧的结果窗口中看到以下输出:
{
"data": {
"about": "Issue Tracker API v1.0"
}
}与遵循查询语言语法的查询不同,输出是一个常规的 JSON 对象。它还反映了查询的结构,以“data”作为结果中的根对象。
现在为了测试setAboutMessage字段,您可以用一个突变来替换查询,或者更好的方法是,您可以在 UI 中使用+符号打开一个新的选项卡,然后像这样输入突变查询:
mutation {
setAboutMessage(message: "Hello World!")
}运行此查询应该会返回与结果相同的消息,如下所示:
{
"data": {
"setAboutMessage": "Hello World!"
}
}现在,运行最初的about查询(在第一个选项卡中)应该会返回新消息,"Hello World!"以证明新消息已经在服务器中成功设置。为了确保操场没有变魔术,让我们在命令行中使用 cURL 对about字段进行查询。
一个快速的方法是使用操场上的 COPY CURL 按钮复制命令,并将其粘贴到命令 shell 中。(在 Windows 系统上,shell 不接受单引号,因此您必须手动编辑单引号并将其更改为双引号,然后使用反斜杠对查询中的双引号进行转义。)该命令及其输出如下所示:
$ curl 'http://localhost:3000/graphql' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'DNT: 1' -H 'Origin: http://localhost:3000' --data-binary '{"query":"query {\n about\n}\n"}' –compressed
{"data":{"about":"Hello World!"}}注意,cURL 查询是作为 JSON 发送的,实际的查询编码为属性query的字符串值。通过在浏览器中检查开发人员控制台的 Network 选项卡,您可以看到在使用 Playground 时会发生类似的事情。JSON 至少包含一个名为query的属性(如curl命令所示),以及可选的operationName和variables属性。JSON 对象看起来像这样:
{
"operationName":null,
"variables":{},
"query": "{\n about\n}\n"
}此外,如果您查看标题(或者理解curl命令),您还会发现对于setAboutMessage变异和about查询,使用的 HTTP 方法是相同的:POST。使用 POST 方法从服务器获取值可能会让人感到有些不安,所以如果您更喜欢 GET,可以使用它。GET URL 的查询字符串可以包含如下查询:
$ curl 'http://localhost:3000/graphql?query=query+\{+about+\}'注意,这不是一个 JSON 对象,就像 POST 操作一样。该查询作为一个普通的 URL 编码字符串发送。我们必须避开花括号,因为它们对 cURL 有特殊的意义,所以在浏览器的常规 Ajax 调用中,您不需要这样做。如果您执行此命令,您应该会看到与之前的 POST 命令相同的结果:
$ curl 'http://localhost:3000/graphql?query=query+\{+about+\}'
{"data":{"about":"Hello World!"}}-
对 cURL 在浏览器和命令行中使用相同的 URL。例如,键入
curl http://localhost:3000/graphql,这与我们在浏览器中调用操场时使用的 URL 相同。或者,复制粘贴我们用于对about字段进行 GET 请求的curl命令。你看到了什么?你能解释一下区别吗?提示:比较请求头。 -
对于只读 API 调用,使用 GET 与 POST 的优缺点是什么?
本章末尾有答案。
在上一节中,我们在 JavaScript 文件中指定了 GraphQL 模式。如果模式变得更大,将模式分离成自己的文件会很有用。这将有助于保持 JavaScript 源文件更小,ide 可能能够格式化这些文件并启用语法着色。
因此,让我们将模式定义移动到它自己的文件中,而不是源文件中的字符串。移动内容本身很简单;让我们创建一个名为schema.graphql的文件,并将字符串typeDefs的内容移入其中。新文件schema.graphql显示在清单 5-2 中。
type Query {
about: String!
}
type Mutation {
setAboutMessage(message: String!): String
}
Listing 5-2schema.graphql: New File for GraphQL Schema现在,要使用这个变量代替字符串变量,这个文件的内容必须读入一个字符串。让我们使用fs模块和readFileSync函数来读取文件。然后,在创建阿波罗服务器时,我们可以使用readFileSync返回的字符串作为属性typeDefs的值。server.js文件中的变化如清单 5-3 所示。
const fs = require('fs');
const express = require('express');
...
const typeDefs = `
type Query {
about: String!
}
type Mutation {
setAboutMessage(message: String!): String!
}
`;
...
const server = new ApolloServer({
typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
resolvers,
});
...
Listing 5-3server.js: Changes for Using the GraphQL Schema File还有一件事需要更改:默认情况下,在检测到文件更改时重启服务器的nodemon工具只查找扩展名为.js的文件的更改。为了让它监视其他扩展的变化,我们需要添加一个-e选项,指定它需要监视的所有扩展。因为我们添加了一个扩展名为.graphql的文件,所以让我们将js和graphql指定为该选项的两个扩展名。
清单 5-4 中显示了对package.json的更改。
...
"scripts": {
"start": "nodemon -w server -e js,graphql server/server.js",
"compile": "babel src --out-dir public",
...
Listing 5-4package.json: Changes to nodemon to Watch GraphQL Files如果您现在使用npm start重启服务器,您将能够使用 Playground 测试 API,并确保它们像以前一样运行。
现在您已经学习了 GraphQL 的基础知识,让我们利用这些知识在构建问题跟踪器应用方面取得一些进展。我们要做的下一件事是实现一个 API 来获取问题列表。我们将使用 Playground 测试它,在下一节中,我们将更改前端以集成这个新的 API。
让我们从修改模式开始,定义一个名为Issue的自定义类型。它应该包含我们到目前为止一直在使用的 issue 对象的所有字段。但是由于 GraphQL 中没有标量类型来表示日期,所以我们暂时使用 string 类型。我们将在本章后面实现自定义标量类型。因此,该类型将有整数和字符串,其中一些是可选的。下面是新类型的部分模式代码:
...
type Issue {
id: Int!
...
due: String
}
...现在,让我们在Query下添加一个新字段来返回问题列表。指定另一种类型的列表的 GraphQL 方法是用方括号将它括起来。我们可以使用[Issue]作为字段的类型,我们称之为issueList。但是我们需要说的是,不仅返回值是强制的,列表中的每个元素也不能为空。因此,我们必须在Issue和数组类型后面加上感叹号,就像在[Issue!]!中一样。
让我们使用注释将顶级的Query和Mutation定义与定制类型分开。在模式中添加注释的方法是在行首使用#字符。所有这些变化都列在清单 5-5 中。
type Issue {
id: Int!
title: String!
status: String!
owner: String
effort: Int
created: String!
due: String
}
##### Top level declarations
type Query {
about: String!
issueList: [Issue!]!
}
type Mutation {
setAboutMessage(message: String!): String
}
Listing 5-5schema.graphql: Changes to Include Field issueList and New Issue Type在服务器代码中,我们需要在新字段的Query下添加一个解析器,它指向一个函数。我们还会有一系列问题(我们在前端代码中的问题的副本),这些问题是数据库的替身。我们可以立即从解析器返回这个数组。该函数可以像对about字段那样就地使用,但是知道我们将扩展该函数来做不仅仅是返回一个硬编码的数组,让我们为它创建一个名为issueList的单独函数。
清单 5-6 显示了server.js中的这组变化。
...
let aboutMessage = "Issue Tracker API v1.0";
const issuesDB = [
{
id: 1, status: 'New', owner: 'Ravan', effort: 5,
created: new Date('2019-01-15'), due: undefined,
title: 'Error in console when clicking Add',
},
{
id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
created: new Date('2019-01-16'), due: new Date('2019-02-01'),
title: 'Missing bottom border on panel',
},
];
const resolvers = {
Query: {
about: () => aboutMessage,
issueList,
},
Mutation: {
setAboutMessage,
},
};
function setAboutMessage(_, { message }) {
return aboutMessage = message;
}
function issueList() {
return issuesDB;
}
...
Listing 5-6server.js: Changes for issueList Query Field为了在操场上测试这一点,您需要运行一个查询来指定带有子字段的issueList字段。但是首先,需要刷新浏览器,以便 Playground 拥有最新的模式,并且在您键入查询时不会显示错误。
数组本身不需要在查询中展开。这是隐式的(由于模式规范),issueList返回一个数组,因此字段的子字段在数组中自动展开。
下面是一个这样的查询,您可以运行它来测试issueList字段:
query {
issueList {
id
title
created
}
}该查询将产生如下输出:
{
"data": {
"issueList": [
{
"id": 1,
"title": "Error in console when clicking Add",
"created": "Tue Jan 15 2019 05:30:00 GMT+0530 (India Standard Time)"
},
{
"id": 2,
"title": "Missing bottom border on panel",
"created": "Wed Jan 16 2019 05:30:00 GMT+0530 (India Standard Time)"
}
]
}
}如果在查询中添加更多的子字段,也将返回它们的值。如果您查看日期字段,您会看到它们已经使用Date JavaScript 对象的toString()方法从Date对象转换为字符串。
-
试着不为
issueList字段指定子字段,比如query { issueList },就像我们为about字段所做的那样,然后单击 Play 按钮。你观察到的结果是什么?尝试使用查询{ issueList { } }指定一个空字段列表,并播放请求。你现在看到了什么?你能解释一下区别吗? -
在
issueList下的查询中添加一个无效的子字段(比如,test)。当您单击播放按钮时,会出现什么错误?特别是,Playground 会将请求发送到服务器吗?在开发人员控制台打开的情况下,在操场上尝试一下。 -
一个包括问题列表和
about字段的聚合查询会是什么样子?
本章末尾有答案。
现在我们已经有了列表 API,让我们把它集成到 UI 中。在这一节中,我们将把 I ssueList React 组件中的loadData()方法的实现替换为从服务器获取数据的东西。
为了使用 API,我们需要进行异步 API 调用,或者 Ajax 调用。流行的库 jQuery 是使用$.ajax()函数的一种简单方法,但是仅仅为了这个目的而包含整个 jQuery 库似乎有些矫枉过正。幸运的是,有许多库提供这种功能。更好的是,现代浏览器通过 Fetch API 本地支持 Ajax 调用。对于 Internet Explorer 等较老的浏览器,可以从whatwg-fetch获得 Fetch API 的 polyfill。让我们直接从 CDN 中使用这个 polyfill,并将它包含在index.html中。为此,我们将使用之前使用的相同 CDN,unpkg.com。这些变化如清单 5-7 所示
...
<script src="https://unpkg.com/@babel/polyfill@7/dist/polyfill.min.js"></script>
<script src="https://unpkg.com/whatwg-fetch@3.0.0/dist/fetch.umd.js"></script>
<style>
...
Listing 5-7index.html: Changes for Including whatwg-fetch Polyfill只有 Internet Explorer 和其他浏览器的旧版本才需要 polyfill。所有最新版本的流行浏览器——如 Chrome、Firefox、Safari、Edge 和 Opera——都原生支持fetch()。
接下来,在loadData()方法中,我们需要构造一个 GraphQL 查询。这是一个简单的字符串,类似于我们在操场上用来测试issueList GraphQL 字段的字符串。但是我们必须确保我们查询的是问题的所有子字段,因此下面的查询可以获取所有问题和所有子字段:
...
const query = `query {
issueList {
id title status owner
created effort due
}
}`;
...我们将这个查询字符串作为 JSON 中的query属性值发送,作为fetch请求主体的一部分。我们将使用的方法是 POST,我们将添加一个头,表明内容类型是 JSON。下面是完整的fetch请求:
...
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify({ query })
});
...我们使用了await关键字来处理异步调用。这是 ES2017 规范的一部分,受除 Internet Explorer 之外的所有浏览器的最新版本支持。它是由旧浏览器的 Babel transforms 自动处理的。另外,await只能在标有async的函数中使用。我们将不得不在loadData()函数中添加async关键字。如果不熟悉async/await构造,可以在 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function 了解一下。
一旦响应到达,我们就可以通过使用response.json()方法将 JSON 数据转换成 JavaScript 对象。最后,我们需要调用一个setState()来为名为issues的状态变量提供问题列表,如下所示:
...
const result = await response.json();
this.setState({ issues: result.data.issueList });
...我们还需要为loadData()的函数定义添加关键字async,因为我们已经在这个函数中使用了await s。
此时,您将能够在浏览器中刷新问题跟踪器应用,但会看到一个错误。这是因为我们使用了一个字符串而不是Date对象,并且在IssueRow组件的render()方法中使用toDateString()将日期转换为字符串的调用抛出了一个错误。让我们删除转换,按原样使用字符串:
...
<td>{issue.created}</td>
...
<td>{issue.due}</td>
...我们现在也可以删除全局变量initialIssues,因为我们不再需要它在loadData()中。清单 5-8 显示了App.jsx中的一整套变更。
const initialIssues = [
{
id: 1, status: 'New', owner: 'Ravan', effort: 5,
created: new Date('2018-08-15'), due: undefined,
title: 'Error in console when clicking Add',
},
{
id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
created: new Date('2018-08-16'), due: new Date('2018-08-30'),
title: 'Missing bottom border on panel',
},
];
function IssueRow(props) {
const issue = props.issue;
return (
<tr>
...
<td>{issue.created.toDateString()}</td>
<td>{issue.effort}</td>
<td>{issue.due ? issue.due.toDateString() : ''}</td>
...
);
}
...
async loadData() {
setTimeout(() => {
this.setState({ issues: initialIssues });
}, 500);
const query = `query {
issueList {
id title status owner
created effort due
}
}`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify({ query })
});
const result = await response.json();
this.setState({ issues: result.data.issueList });
}
Listing 5-8App.jsx: Changes for Integrating the List API这就完成了集成 List API 所需的更改。现在,如果您通过刷新浏览器来测试应用,您会发现一个类似于图 5-2 所示的屏幕截图。你会注意到日期又长又难看,但除此之外,屏幕看起来和前一章结束时一样。添加操作将不起作用,因为它在添加新问题时使用Date对象而不是字符串。我们将在下一节讨论这两个问题。
图 5-2
列表 API 集成后
将日期存储为字符串在大多数情况下似乎可行,但并非总是如此。首先,对日期进行排序和过滤变得更加困难,因为每次都必须将字符串转换成Date类型。此外,无论服务器在哪里,日期都应该以用户的时区和地区显示。不同的用户可能基于他们在哪里而不同地看到相同的日期,甚至看到“2 天前”等形式的日期。
为了实现这一切,我们需要将日期存储为 JavaScript 的原生Date对象。理想情况下,应该在仅向用户显示时将其转换为特定于地区的字符串。但不幸的是,JSON 没有Date类型,因此,在 API 调用中使用 JSON 传输数据也必须将日期与字符串相互转换。
在 JSON 中传输Date对象的推荐字符串格式是 ISO 8601 格式。它简明扼要,广为接受。这也是 JavaScript 的Date的toJSON()方法使用的相同格式。在这种格式中,诸如 2019 年 1 月 26 日下午 2:30 UTC 这样的日期将被写成2019-01-26T14:30:00.000Z。使用Date的toJSON()或toISOString()方法将日期转换成这个字符串,以及使用new Date(dateString)将它转换回日期,都是简单明了的。
尽管 GraphQL 本身不支持日期,但它支持自定义标量类型,这可用于创建自定义标量类型日期。为了能够使用自定义标量类型,必须完成以下工作:
-
在模式中使用
scalar关键字而不是type关键字定义标量的类型。 -
为所有标量类型添加一个顶级解析器,它通过类方法处理序列化(在输出时)和解析(在输入时)。
之后,新类型可以像任何本地标量类型一样使用,比如String和Int。让我们称这个新的标量类型为GraphQLDate。标量类型必须在模式中使用关键字scalar定义,后跟自定义类型的名称。让我们把它放在文件的开头:
...
scalar GraphQLDate
...现在,我们可以用created替换String类型关联,用GraphQLDate替换due字段。清单 5-9 显示了标量定义的变化和日期字段的新数据类型。
scalar GraphQLDate
type Issue {
id: Int!
...
created: StringGraphQLDate!
due: StringGraphQLDate
}
...
Listing 5-9schema.graphql: Changes in Schema for Scalar Date标量类型解析器需要是包graphql-tools中定义的类GraphQLScalarType的对象。我们先在server.js导入这个类:
...
const { GraphQLScalarType } = require('graphql');
...GraphQLScalarType的构造函数接受一个具有各种属性的对象。我们可以通过调用类型上的new()来创建这个解析器,如下所示:
...
const GraphQLDate = new GraphQLScalarType({ ... });
...初始化器的两个属性——name和description——在自省中使用,所以让我们将它们设置为适当的值:
...
name: 'GraphQLDate',
description: 'A Date() type in GraphQL as a scalar',
...将调用类方法serialize()将日期值转换为字符串。此方法将值作为参数,并期望返回一个字符串。因此,我们所要做的就是对值调用toISOString()并返回它。下面是serialize()的方法:
...
serialize(value) {
return value.toISOString();
},
...需要另外两个方法parseValue()和parseLiteral()来将字符串解析回日期。让我们把这种解析留到稍后阶段,当它确实需要接受输入值时,因为这些是可选的方法。
最后,我们需要将这个解析器设置为与Query和Mutation(在顶层)相同的级别,作为标量类型GraphQLDate的值。清单 5-10 显示了server.js中的整套变化。
...
const { ApolloServer } = require('apollo-server-express');
const { GraphQLScalarType } = require('graphql');
...
const GraphQLDate = new GraphQLScalarType({
name: 'GraphQLDate',
description: 'A Date() type in GraphQL as a scalar',
serialize(value) {
return value.toISOString();
},
});
const resolvers = {
Query: {
...
},
Mutation: {
...
},
GraphQLDate,
};
...
Listing 5-10server.js: Changes for Adding a Resolver for GraphQLDate此时,如果您切换到操场并刷新浏览器(由于模式更改),然后测试 List API。您将看到日期作为 ISO 字符串的等价物返回,而不是以前使用的特定于地区的长字符串。这里有一个在操场上测试的查询:
query {
issueList {
title
created
due
}
}以下是该查询的结果:
{
"data": {
"issueList": [
{
"title": "Error in console when clicking Add",
"created": "2019-01-15T00:00:00.000Z",
"due": null
},
{
"title": "Missing bottom border on panel",
"created": "2019-01-16T00:00:00.000Z",
"due": "2019-02-01T00:00:00.000Z"
}
]
}
}现在,在App.jsx中,我们可以将字符串转换为原生的Date类型。实现这一点的一种方法是在从服务器获取问题后遍历这些问题,并用它们的日期等价物替换字段due和created。更好的方法是将一个 reviver 函数传递给 JSON parse()函数。reviver 函数是一个被调用来解析所有值的函数,JSON 解析器给它一个机会来修改默认解析器要做的事情。
因此,让我们创建这样一个函数,它在输入中寻找类似日期的模式,并将所有这样的值转换为日期。我们将使用一个正则表达式来检测这种模式,并使用new Date()进行简单的转换。下面是 reviver 的代码:
...
const dateRegex = new RegExp('^\\d\\d\\d\\d-\\d\\d-\\d\\d');
function jsonDateReviver(key, value) {
if (dateRegex.test(value)) return new Date(value);
return value;
}
...转换函数response.json()不能让我们指定一个 reviver,所以我们必须使用response.text()获取正文的文本,并通过传入 reviver 使用JSON.parse()自己解析它,就像这样:
...
const body = await response.text();
const result = JSON.parse(body, jsonDateReviver);
...现在,我们可以恢复我们的更改,将日期显示为之前的状态:使用toDateString()在IssueRow中呈现日期。包括这一变化,在App.jsx中使用Date标量类型的一整套变化如清单 5-11 所示。
const dateRegex = new RegExp('^\\d\\d\\d\\d-\\d\\d-\\d\\d');
function jsonDateReviver(key, value) {
if (dateRegex.test(value)) return new Date(value);
return value;
}
function IssueRow(props) {
const issue = props.issue;
return (
<tr>
...
<td>{issue.created.toDateString()}</td>
<td>{issue.effort}</td>
<td>{issue.due ? issue.due.toDateString() : ' '}</td>
...
);
}
...
class IssueList extends React.Component {
async loadData() {
...
const result = await response.json();
const body = await response.text();
const result = JSON.parse(body, jsonDateReviver);
this.setState({ issues: result.data.issueList });
}
...
}
...
Listing 5-11App.jsx: Changes for Receiving ISO Formatted Dates经过这一系列的修改,应用应该像以前一样出现在上一章的末尾。日期的格式看起来会很好。即使添加一个问题也应该可以,但是在刷新浏览器时,添加的问题将会消失。这是因为我们没有将问题保存在服务器中,我们所做的只是在浏览器中更改了问题列表的本地状态,这将在刷新时重置为初始问题集。
-
在
server.js中,删除将类型GraphQLDate关联到解析器对象的解析器。发出调用issueList的 API 请求。输出有区别吗?你认为如何解释这种差异或缺乏差异? -
你如何确定标量类型解析器确实被使用了?
本章末尾有答案。
在本节中,我们将实现一个 API,用于在服务器中创建一个新问题,该问题将被附加到服务器内存中的问题列表中。
为此,我们必须首先在模式中的Mutation下定义一个名为issueAdd的字段。这个字段应该接受参数,就像setAboutMessage字段一样。但是这一次,我们需要多个参数,每个参数对应于要添加的问题的一个属性。或者,我们可以将一个新类型定义为一个对象,该对象具有我们输入所需的字段。这不能与Issue类型相同,因为它有一些必填字段(id和created)不是输入的一部分。这些值仅由服务器设置。此外,GraphQL 在输入类型方面需要不同的规范。我们必须使用input关键字,而不是使用type关键字。
让我们首先在模式中定义这个名为IssueInputs的新输入类型:
...
input IssueInputs {
# ... fields of Issue
}
...我们讨论了如何在模式中添加注释。但是这些注释不是类型或子字段的正式描述。对于显示在 schema explorer 中的真实文档,需要在字段上方添加一个字符串。当向开发人员展示模式时,这些描述将作为有用的提示出现。因此,让我们为IssueInputs以及属性status添加一个描述,假设如果不提供,它将默认为值'New':
...
"Toned down Issue, used as inputs, without server generated values."
input IssueInputs {
...
"Optional, if not supplied, will be set to 'New'"
status: String
...
}
...现在,我们可以使用类型IssueInputs作为Mutation下新的issueAdd字段的参数类型。该字段的返回值可以是任何值。返回在服务器上生成的值,通常是新对象的 ID,这是一种很好的做法。在这种情况下,因为 ID 和创建日期都是在服务器上设置的,所以让我们返回创建的整个问题对象。
清单 5-12 显示了对模式的一整套更改。
...
"Toned down Issue, used as inputs, without server generated values."
input IssueInputs {
title: String!
"Optional, if not supplied, will be set to 'New'"
status: String
owner: String
effort: Int
due: GraphQLDate
}
##### Top level declarations
...
type Mutation {
setAboutMessage(message: String!): String
issueAdd(issue: IssueInputs!): Issue!
}
Listing 5-12schema.graphql: Changes for New Type IssueInputs and New Field issueAdd接下来,我们需要一个用于issueAdd的解析器,它接受一个IssueInput类型并在内存数据库中创建一个新问题。就像我们对setAboutMessage所做的一样,我们可以忽略第一个参数,使用一个析构赋值来访问问题对象,即输入:
...
function issueAdd(_, { issue }) {
...
}在函数中,让我们像在浏览器中一样设置 ID 和创建日期:
...
issue.created = new Date();
issue.id = issuesDB.length + 1;
...此外,如果没有提供状态(因为我们没有将其声明为必需的子字段),我们也将状态默认为值'New':
...
if (issue.status == undefined) issue.status = 'New';
...最后,我们可以将问题附加到全局变量issuesDB中,并按原样返回问题对象:
...
issuesDB.push(issue);
return issue;
...该函数现在可以设置为Mutation下issueAdd字段的解析器:
...
Mutation: {
setAboutMessage,
issueAdd,
},
...我们推迟了实现自定义标量类型GraphQLDate的解析器,因为那时我们不需要它。但是现在,因为类型IssueInputs有一个GraphQLDate类型,我们必须实现解析器来接收日期值。在GraphQLDate解析器中需要实现两种方法:parseValue和parseLiteral。
方法parseLiteral在正常情况下被调用,其中字段在查询中被就地指定。解析器用一个参数ast调用这个方法,这个参数包含一个kind属性和一个value属性。kind属性表示解析器找到的令牌的类型,可以是浮点、整数或字符串。对于GraphQLDate,我们唯一需要支持的令牌类型是字符串。我们可以使用graphql/language中的Kind包中定义的常量来检查这一点。如果令牌的类型是 string,我们将解析该值并返回一个日期。否则,我们就返回undefined。下面是parseLiteral的实现:
...
parseLiteral(ast) {
return (ast.kind == Kind.STRING) ? new Date(ast.value) : undefined;
},
...返回值undefined向 GraphQL 库表明该类型不能被转换,它将被视为一个错误。
如果输入作为变量提供,将调用方法parseValue。我将在本章后面的部分讨论查询输入中的变量,但是现在,把它看作 JavaScript 对象形式的输入,一个预先解析的 JSON 值。该方法的参数将直接是值,没有类型规范,所以我们需要做的就是用它构造一个日期,并像这样返回它:
...
parseValue(value) {
return new Date(value);
},
...清单 5-13 中显示了对server.js的一整套更改。
...
const { GraphQLScalarType } = require('graphql');
const { Kind } = require('graphql/language');
...
const GraphQLDate = new GraphQLScalarType({
...
parseValue(value) {
return new Date(value);
},
parseLiteral(ast) {
return (ast.kind == Kind.STRING) ? new Date(ast.value) : undefined;
},
});
...
const resolvers = {
...
Mutation: {
setAboutMessage,
issueAdd,
},
GraphQLDate,
};
...
function issueAdd(_, { issue }) {
issue.created = new Date();
issue.id = issuesDB.length + 1;
if (issue.status == undefined) issue.status = 'New';
issuesDB.push(issue);
return issue;
}
...
Listing 5-13server.js: Changes for the Create API现在,我们准备使用操场测试 Create API。如果您在操场上浏览模式(可能需要刷新浏览器)并深入到IssueInputs的status字段,您会发现我们在模式中提供的描述。其截图如图 5-3 所示。
图 5-3
显示问题输入和状态描述的模式
要测试新问题的添加,您可以在 Playground 中使用以下查询:
mutation {
issueAdd(issue:{
title: "Completion date should be optional",
owner: "Pieta",
due: "2018-12-13",
}) {
id
due
created
status
}
}运行这个查询应该会在操场的结果窗口中给出以下结果:
{
"data": {
"issueAdd": {
"id": 4,
"due": "2018-12-13T00:00:00.000Z",
"created": "2018-10-03T14:48:10.551Z",
"status": "New"
}
}
}这表明已经正确解析和转换了到期日期。状态字段也被默认为'New'。您还可以通过在操场上运行对issueList的查询并检查结果来确认问题已经被创建。
-
我们使用了一个
input复杂类型来为issueAdd提供值。与单独通过每个字段相比,像issueAdd(title: String!, owner: String ...)。每种方法的优缺点是什么? -
尝试为该字段传递一个有效的整数,如
due: 2018,而不是有效的日期字符串。你认为在parseLiteral中ast.kind的值会是多少?在parseLiteral中添加console.log信息并确认。你认为ast.kind还有哪些值是可能的? -
传递一个字符串,但传递一个无效的日期,比如为
due字段传递"abcdef"。会发生什么?如何解决这个问题? -
有没有另一种方法来指定
status字段的默认值?提示:在http://graphql.github.io/learn/schema/#arguments阅读 GraphQL 模式文档中的传递参数。
本章末尾有答案。
让我们开始集成 Create API,在 UI 中对新问题的默认设置做一点小小的改变。让我们取消将状态设置为'New'的操作,并将截止日期设置为当前日期的 10 天后。这种改变可以在App.jsx中的IssueAdd组件的handleSubmit()方法中完成,就像这样:
...
const issue = {
owner: form.owner.value, title: form.title.value, status: 'New',
due: new Date(new Date().getTime() + 1000*60*60*24*10),
}
...在进行 API 调用之前,我们需要一个填充了字段值的查询。让我们使用一个模板字符串在IssueList的createIssue()方法中生成这样一个查询。我们可以使用传入的问题对象的标题和所有者属性,但是对于日期字段due,我们必须按照 ISO 格式将其显式转换为字符串,因为这是我们决定用于传递日期的格式。
在返回路径上,我们将不需要任何新问题的值,但是因为子字段不能为空,所以让我们只指定id字段。因此,让我们按如下方式形成查询字符串:
...
const query = `mutation {
issueAdd(issue:{
title: "${issue.title}",
owner: "${issue.owner}",
due: "${issue.due.toISOString()}",
}) {
id
}
}`;
...现在,让我们使用这个查询来异步执行fetch,就像我们对问题列表调用所做的那样:
...
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify({ query })
});
...我们可以使用返回的完整问题对象,并像以前一样将其添加到状态数组中,但是更简单的方法是(尽管性能较差)在将新问题发送到服务器后调用loadData()来刷新问题列表。它也更准确,以防出现错误而无法添加问题,或者其他用户同时添加了更多问题。
...
this.loadData();
...清单 5-14 显示了集成 Create API 的一整套更改。
...
class IssueAdd extends React.Component {
...
handleSubmit(e) {
...
const issue = {
owner: form.owner.value, title: form.title.value, status: 'New',
due: new Date(new Date().getTime() + 1000*60*60*24*10),
}
...
}
...
}
...
async createIssue(issue) {
issue.id = this.state.issues.length + 1;
issue.created = new Date();
const newIssueList = this.state.issues.slice();
newIssueList.push(issue);
this.setState({ issues: newIssueList });
const query = `mutation {
issueAdd(issue:{
title: "${issue.title}",
owner: "${issue.owner}",
due: "${issue.due.toISOString()}",
}) {
id
}
}`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify({ query })
});
this.loadData();
}
Listing 5-14App.jsx: Changes for Integrating the Create API在通过使用 UI 添加新问题来测试这组更改时,您会发现截止日期被设置为从当前日期起 10 天后。此外,如果刷新浏览器,您会发现添加的问题仍然存在,因为新问题现在已保存在服务器上。
- 添加标题中带有引号的新问题,例如,
Unable to create issue with status "New"。会发生什么?检查控制台以及浏览器的开发人员控制台中的请求和响应。你认为如何解决这个问题?
本章末尾有答案。
对于这两个变异调用,我们都在查询字符串中指定了字段的参数。当在操场上测试一个 API 时,就像我们对setAboutMessage所做的那样,这非常有效。但是在大多数应用中,参数是动态的,基于用户输入。这正是issueAdd所发生的,我们必须使用字符串模板来构造查询字符串。
这不是一个好主意,首先是因为将模板转换成实际字符串的开销很小。一个更重要的原因是需要对引号和花括号等特殊字符进行转义。这很容易出错,也很容易被忽略。由于我们没有进行任何转义,如果您在此时通过添加一个在标题中有双引号的问题来测试问题跟踪器应用,您会发现它不能正常工作。
GraphQL 有一流的方法从查询中提取动态值,并将其作为单独的字典传递。这些值被称为*变量。*这种传递动态值的方式非常类似于 SQL 查询中的预处理语句。
要使用变量,我们必须先命名操作。这是通过在query或mutation字段说明后指定一个名称来实现的。例如,要给一个setAboutMessage突变命名,必须完成以下工作:
mutation setNewMessage { setAboutMessage(message: "New About Message") }接下来,必须用变量名替换输入值。变量名以$字符开始。让我们调用变量$message,并用这个变量替换字符串“New About Message”。最后,为了接受变量,我们需要将它声明为操作名的参数。因此,新的查询将是:
mutation setNewMessage($message: String!) { setAboutMessage(message: $message) }现在,为了提供变量值,我们需要在一个 JSON 对象中发送它,这个 JSON 对象是与查询字符串分开的*。在游乐场中,右下角有一个名为查询变量的选项卡。点击这个按钮将会打开请求窗口,并允许您在下半部分输入查询变量。我们需要将变量作为一个 JSON 对象发送,将变量名(不带$)作为属性,变量值作为属性值。*
*操场截图如图 5-4 ,消息值为"Hello World!"。
图 5-4
带有查询变量的操场
如果您在开发人员控制台中检查请求数据,您会发现请求 JSON 有三个属性— operationName、variables和query。虽然到目前为止我们只使用了query,但是为了利用变量,我们不得不同时使用另外两个。
GraphQL 规范允许多个操作出现在同一个查询字符串中。但是在一次调用中只能执行其中的一个。operationName的值指定需要执行那些操作中的哪一个。
我们现在准备在查询中用常规字符串替换模板字符串,使用操作名和变量规范格式。新的查询字符串将如下所示:
...
const query = `mutation issueAdd($issue: IssueInputs!) {
issueAdd(issue: $issue) {
id
}
}`;
...然后,在构造fetch()请求的主体时,除了query属性之外,我们还要指定variables属性,它将包含一个变量:issue。清单 5-15 显示了App.jsx中的一整套变化,包括这一点。
...
async createIssue(issue) {
const query = `mutation {
issueAdd(issue:{
title: "${issue.title}",
owner: "${issue.owner}",
due: "${issue.due.toISOString()}",
}) {
id
}
}`;
const query = `mutation issueAdd($issue: IssueInputs!) {
issueAdd(issue: $issue) {
id
}
}`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify({ query, variables: { issue } })
});
this.loadData();
}
...
Listing 5-15App.jsx: Changes for Using Query Variables在 Issue Tracker 应用中测试这些变化时,您会发现添加新问题的工作方式和以前一样。此外,您应该能够在新增加的问题的标题中使用双引号,而不会导致任何错误。
- 在自定义标量类型
GraphQLDate中,现在我们在使用变量,你觉得会调用哪一种解析方法?会是parseLiteral还是parseValue?在这两个函数中添加一个临时的console.log语句,确认你的答案。
本章末尾有答案。
到目前为止,我们已经忽略了验证。但是所有的应用都需要一些典型的验证,不仅是为了防止来自 UI 的无效输入,也是为了防止来自直接 API 调用的无效输入。在本节中,我们将添加一些对大多数应用来说很典型的验证。
一种常见的验证是限制允许值的集合,可以在下拉列表中显示。问题跟踪器应用中的status字段就是这样一个字段。实现这种验证的一种方法是在issueAdd解析器中添加对允许值数组的检查。但是 GraphQL 模式本身通过枚举类型或枚举为我们提供了一种自动的方式。模式中的一个enum定义如下:
...
enum Color {
Red
Green
Blue
}
...请注意,虽然该定义可以翻译成其他语言中的实际枚举类型,但由于 JavaScript 没有枚举类型,因此在客户端和服务器端都将它们作为字符串处理。让我们为名为StatusType的状态添加这个枚举类型:
...
enum StatusType {
New
Assigned
Fixed
Closed
}
...现在,我们可以用Issue类型中的StatusType替换String类型:
...
type Issue {
...
status: StatusType!
...
}同样可以在IssueInput类型中完成。但是 GraphQL 模式的一个显著特性是,它允许我们在输入没有给定参数值的情况下提供默认值。这可以通过在类型说明后添加一个=符号和默认值来实现,比如owner: String = "Self"。在status的情况下,缺省值是一个 enum,所以可以不用引号来指定它,如下所示:
...
status: StatusType = New
...现在,我们可以移除server.js中issueAdd解析器内issue.status到'New'的默认值。清单 5-16 显示了对schema.graphql文件的所有更改。
scalar GraphQLDate
enum StatusType {
New
Assigned
Fixed
Closed
}
type Issue {
...
status: StringStatusType!
...
}
...
input IssueInputs {
...
status: StringStatusType = New
owner: String
effort: Int
due: GraphQLDate
}
...
Listing 5-16schema.graphql: Changes for Using Enums and Default Values至于编程验证,我们必须在server.js中保存新问题之前进行。我们将在一个名为validateIssue()的独立函数中实现这一点。让我们首先创建一个数组来保存验证失败的错误消息。当我们发现多个验证失败时,数组中的每个验证失败消息都有一个字符串。
...
function validateIssue(_, { issue }) {
const errors = [];
...接下来,让我们为该期的标题添加一个最小长度。如果检查失败,我们将把一条消息推入到errors数组中。
...
if (issue.title.length < 3) {
errors.push('Field "title" must be at least 3 characters long.')
}
...让我们添加一个有条件的强制验证,当状态设置为Assigned时,检查所有者是否是必需的。UI 在这个阶段无法设置 status 字段,因此为了测试这一点,我们将使用 Playground。
...
if (issue.status == 'Assigned' && !issue.owner) {
errors.push('Field "owner" is required when status is "Assigned"');
}
...我们可以添加更多的验证,但是对于演示编程验证来说,这已经足够了。在检查结束时,如果我们发现 errors 数组不为空,我们将抛出一个错误。Apollo 服务器建议使用UserInputError类来生成用户错误。让我们用它来构造一个要抛出的错误:
...
if (errors.length > 0) {
throw new UserInputError('Invalid input(s)', { errors });
}
...现在,让我们再添加一个我们之前没有做的验证:在解析值的过程中捕获无效的日期字符串。当日期字符串无效时,new Date()构造函数不会抛出任何错误。相反,它创建一个 date 对象,但该对象包含一个无效的日期。检测输入错误的一种方法是检查构造的日期对象是否是有效值。在构建日期后,可以使用检查isNaN(date)来完成。让我们在parseValue和parseLiteral实施这项检查:
...
parseValue(value) {
const dateValue = new Date(value);
return isNaN(dateValue) ? undefined : dateValue;
},
parseLiteral(ast) {
if (ast.kind == Kind.STRING) {
const value = new Date(ast.value);
return isNaN(value) ? undefined : value;
}
},
...注意,返回undefined被库视为错误。如果提供的文字不是字符串,函数将不返回任何内容,这与返回undefined相同。
最后,您会发现,尽管所有的错误都被发送到客户机并显示给用户,但是没有办法在服务器上捕获这些错误以供以后分析。此外,如果能监控服务器的控制台,甚至在开发过程中就能看到这些错误,那就太好了。Apollo 服务器有一个名为formatError的配置选项,可以用来更改将错误发送回调用者的方式。我们也可以使用此选项在控制台上打印出错误:
...
formatError: error => {
console.log(error);
return error;
}
...在清单 5-17 中显示了server.js中添加GraphQLDate类型的编程验证和适当验证的所有变化。
...
const { ApolloServer, UserInputError } = require('apollo-server-express');
...
const GraphQLDate = new GraphQLScalarType({
...
parseValue(value) {
return new Date(value);
const dateValue = new Date(value);
return isNaN(dateValue) ? undefined : dateValue;
},
parseLiteral(ast) {
return (ast.kind == Kind.STRING) ? new Date(ast.value) : undefined;
if (ast.kind == Kind.STRING) {
const value = new Date(ast.value);
return isNaN(value) ? undefined : value;
}
},
});
...
function validateIssue(_, { issue }) {
const errors = [];
if (issue.title.length < 3) {
errors.push('Field "title" must be at least 3 characters long.')
}
if (issue.status == 'Assigned' && !issue.owner) {
errors.push('Field "owner" is required when status is "Assigned"');
}
if (errors.length > 0) {
throw new UserInputError('Invalid input(s)', { errors });
}
}
function issueAdd(_, { issue }) {
issueValidate(issue);
issue.created = new Date();
issue.id = issuesDB.length + 1;
if (issue.status == undefined) issue.status = 'New';
issuesDB.push(issue);
return issue;
}
...
const server = new ApolloServer({
typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
resolvers,
formatError: error => {
console.log(error);
return error;
},
});
Listing 5-17server.js: Programmatic Validations and Date Validations使用应用测试这些更改将会很困难,需要临时更改代码,所以您可以使用 Playground 来测试验证。注意,由于status现在是一个枚举,该值应该作为一个文字提供,也就是说,在操场上没有引号。对issueAdd的有效调用如下所示:
mutation {
issueAdd(issue:{
title: "Completion date should be optional",
status: New,
}) {
id
status
}
}运行这段代码时,操场结果应该显示添加了以下新问题:
{
"data": {
"issueAdd": {
"id": 5,
"status": "New"
}
}
}如果您将状态更改为像Unknown这样的无效枚举,您应该会得到如下错误:
{
"error": {
"errors": [
{
"message": "Expected type StatusType, found Unknown.",
...如果您使用字符串"New"来代替,它应该会显示如下有用的错误消息:
{
"error": {
"errors": [
{
"message": "Expected type StatusType, found \"New\"; Did you mean the enum value New?",
...最后,如果您完全删除状态,您会发现它确实将值默认为New,如结果窗口所示。
为了测试编程验证,您可以尝试创建一个两个检查都失败的问题。以下查询应该有所帮助:
mutation {
issueAdd(issue:{
title: "Co",
status: Assigned,
}) {
id
status
}
}运行此查询时,将返回以下错误,其中两条消息都列在 exception 部分下。
{
"data": null,
"errors": [
{
"message": "Invalid input(s)",
...
"extensions": {
"code": "BAD_USER_INPUT",
"exception": {
"errors": [
"Field \"title\" must be at least 3 characters long.",
"Field \"owner\" is required when status is \"Assigned\""
],
...为了测试日期验证,您需要使用文字和查询变量进行测试。对于文字测试,可以使用以下查询:
mutation {
issueAdd(issue:{
title: "Completion data should be optional",
due: "not-a-date"
}) {
id
}
}将返回以下错误:
{
"error": {
"errors": [
{
"message": "Expected type GraphQLDate, found \"not-a-date\".",
...
"extensions": {
"code": "GRAPHQL_VALIDATION_FAILED",至于基于查询变量的测试,下面是可以使用的查询:
mutation issueAddOperation($issue: IssueInputs!) {
issueAdd(issue: $issue) {
id
status
due
}
}这是查询变量:
{"issue":{"title":"test", "due":"not-a-date"}}运行此命令时,您应该会在结果窗口中看到以下错误:
{
"error": {
"errors": [
{
"message": "Variable \"$issue\" got invalid value {\"title\":\"test\",\"due\":\"not-a-date\"}; Expected type GraphQLDate at value.due.",
...
"extensions": {
"code": "INTERNAL_SERVER_ERROR",在本节中,我们将修改用户界面,以便向用户显示任何错误消息。我们将处理由于网络连接问题以及无效用户输入导致的传输错误。对于用户来说,通常不应该出现服务器错误和其他错误(这些很可能是 bug),如果出现了,让我们只显示收到的代码和消息。
这是创建一个公共实用函数来处理所有 API 调用和报告错误的好机会。我们可以用这个公共函数替换实际处理程序中的fetch调用,并将任何错误作为 API 调用的一部分显示给用户。我们称这个函数为graphQLFetch。这将是一个异步函数,因为我们将使用await调用fetch()。让我们让函数将query和变量作为两个参数:
...
async function graphQLFetch(query, variables = {}) {
...我们使用 ES2015 默认函数参数将{}分配给参数variables,以防调用者没有传入它。点击 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters 了解更多此功能。
所有的传输错误都将从对fetch()的调用中抛出,所以让我们将对fetch()的调用和随后对主体的检索包装起来,并在try-catch块中解析它。让我们使用catch块中的alert来显示错误:
...
try {
const response = await fetch('/graphql', {
...
});
...
} catch (e) {
alert(`Error in sending data to server: ${e.message}`);
}
...fetch操作与最初在issueAdd中执行的操作相同。一旦fetch完成,我们将在result.errors中寻找错误。
...
if (result.errors) {
const error = result.errors[0];
...错误代码可以在error.extensions.code中找到。让我们使用这段代码,以不同的方式处理我们预期的每种类型的错误。对于BAD_USER_INPUT,我们需要将所有的验证错误结合在一起,并显示给用户:
...
if (error.extensions.code == 'BAD_USER_INPUT') {
const details = error.extensions.exception.errors.join('\n ');
alert(`${error.message}:\n ${details}`);
...对于所有其他错误代码,我们将显示收到的代码和消息。
...
} else {
alert(`${error.extensions.code}: ${error.message}`);
}
...最后,在这个新的效用函数中,让我们返回result.data。调用者可以检查是否有数据返回,如果有,就使用它。IssueList中的方法loadData()是第一个调用者。构建完查询后,所有获取数据的代码都可以替换为使用查询对graphQLFetch的简单调用。因为它是一个异步函数,我们可以使用await关键字,并将结果直接接收到一个名为data的变量中。如果数据为非空,我们可以用它来设置状态,如下所示:
...
async loadData() {
...
const data = await graphQLFetch(query);
if (data) {
this.setState({ issues: data.issueList });
}
}
...让我们对同一个类中的createIssue方法进行类似的更改。在这里,我们还需要传递第二个参数 variables,它是一个包含变量issues的对象。在返回路径上,如果数据有效,我们知道操作成功了,因此我们可以调用this.loadData()。除了知道操作成功之外,我们不使用数据的返回值。
...
const data = await graphQLFetch(query, { issue });
if (data) {
this.loadData();
}
...清单 5-18 中显示了App.jsx中显示错误的一整套更改。
...
async function graphQLFetch(query, variables = {}) {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify({ query, variables })
});
const body = await response.text();
const result = JSON.parse(body, jsonDateReviver);
if (result.errors) {
const error = result.errors[0];
if (error.extensions.code == 'BAD_USER_INPUT') {
const details = error.extensions.exception.errors.join('\n ');
alert(`${error.message}:\n ${details}`);
} else {
alert(`${error.extensions.code}: ${error.message}`);
}
}
return result.data;
} catch (e) {
alert(`Error in sending data to server: ${e.message}`);
}
}
...
class IssueList extends React.Component {
...
async loadData() {
const query = `query {
..
}`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify({ query })
});
const body = await response.text();
const result = JSON.parse(body, jsonDateReviver);
this.setState({ issues: result.data.issueList });
const data = await graphQLFetch(query);
if (data) {
this.setState({ issues: data.issueList });
}
}
async createIssue(issue) {
const query = `mutation issueAdd($issue: IssueInputs!) {
issueAdd(issue: $issue) {
id
}
}`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify({ query, variables: { issue } })
});
this.loadData();
const data = await graphQLFetch(query, { issue });
if (data) {
this.loadData();
}
}
Listing 5-18App.jsx: Changes for Displaying Errors要测试传输错误,您可以在刷新浏览器后停止服务器,然后尝试添加新问题。如果这样做,您将会发现如图 5-5 中的屏幕截图所示的错误消息。
图 5-5
传输错误消息
至于其他消息,可以通过在用户输入中键入一个小标题来测试标题的长度。其他验证只能通过临时更改代码来测试,例如,将status设置为所需的值,将due字段设置为无效的日期字符串等。在IssueAdd组件的handleSubmit()方法中。
在本章中,我们比较了两个 API 标准:REST 和 GraphQL。尽管 REST 被广泛使用,但考虑到它的特性和易于实现,我们选择了 GraphQL,因为有工具和库可以帮助我们构建 API。
GraphQL 是一个非常结构化的 API 标准,并且非常广泛。我只介绍了 GraphQL 的基础知识,其中只包括问题跟踪应用在这个阶段所需的特性。我鼓励你在 https://graphql.org/ 阅读更多关于 GraphQL 的文章。还有一些高级特性,比如指令和片段,这些特性有助于重用代码来构建查询。这些在大型项目中可能非常方便,但我不会在本书中涉及这些,因为它们对于问题跟踪器应用来说并不是真正必需的。
在本章中,您已经看到了如何使用 GraphQL 构建 CRUD 的 C 和 R 部分。您还看到了一些验证是如何容易实现的,以及 GraphQL 的强类型系统如何帮助避免错误并使 API 自文档化。我们将在后面的章节中处理 CRUD 的 U 和 D 部分,当我们构建这些特性时。
同时,看看如何持久化数据将是一个好主意。我们将一系列问题从浏览器内存转移到服务器内存。在下一章,我们将进一步把它转移到一个真正的数据库,MongoDB。
-
浏览器中的相同 URL 和 cURL 命令行会导致不同的结果。在浏览器中,返回操场 UI,而在命令行中,执行 API。Apollo 服务器通过查看
Accept头来进行区分。如果它找到了"text/html"(这是浏览器发送的),它返回操场 UI。您可以通过在 cURL 命令行中添加--header "Accept: text/html"并执行它来检查这一点。 -
浏览器可以缓存 GET 请求,并从缓存本身返回响应。不同的浏览器行为不同,很难预测正确的行为。通常,您会希望 API 结果永远不被缓存,而是总是从服务器获取。在这种情况下,使用 POST 是安全的,因为浏览器不会缓存 POST 的结果。
但是如果您真的希望浏览器尽可能缓存某些 API 响应,因为结果很大并且不会改变(例如,图像),GET 是唯一的选择。或者,您可以使用 POST,但自己处理缓存(例如,通过使用本地存储),而不是让浏览器来处理。
-
在第一种情况下,查询具有有效的语法,但是不符合模式。游乐场发送了请求,服务器对此响应了一个错误。
在第二种情况下,Playground 没有将查询发送到服务器(您可以在控制台日志中看到使用开发人员控制台的错误),因为它发现查询没有有效的语法:花括号中应该有一个子字段名称。
在这两种情况下,Playground 都在查询中将错误显示为红色下划线。将光标悬停在红色下划线上将显示实际的错误消息,而不管它是语法错误还是模式错误。
-
添加无效的子字段不会使查询在语法上无效。请求被发送到服务器,服务器验证查询并返回一个错误,指出子字段无效。
-
查询可以像
query { about issueList { id title created } }一样。在结果中,您可以看到about和issueList都作为data对象的属性返回。
-
无论是否使用标量类型的解析器,输出都是相同的。将
GraphQLDate定义为标量类型的模式使得Date对象的默认解析器使用toJSON()而不是toString()。 -
可以在 serialize 函数中添加一条
console.log()消息。或者,如果您临时更改转换以使用Date.toString()而不是Date.toISOString(),您可以看到转换正在以不同的方式进行。
-
就详细程度而言,两种方法是相同的,所有的公共属性都必须在
Issue和IssueInputs或参数列表之间重复。如果属性列表发生变化,例如,如果我们添加一个名为severity的新字段,那么必须在两个地方进行更改:在Issue类型中,在IssueInputs类型或中,参数列表指向issueAdd。定义输入类型的一个优点是可以重用相同的类型。例如,如果创建和更新操作可以接受相同的输入类型,这就很方便了。
-
传递一个整数会将
ast.kind设置为Kind.INT(T1 被设置为字符串'IntValue',如控制台日志所示)。其他可能的值有Kind.FLOAT、Kind.BOOLEAN和Kind.ENUM。 -
传递一个有效的字符串但传递一个无效的日期不会在创建问题的过程中抛出任何错误,但是问题将与一个无效的
Date对象一起保存,这是new Date()使用无效的日期字符串的结果。当问题被返回时,将会看到这样的效果;会有错误显示date对象不能被转换成字符串。我们将在本章后面添加验证。 -
可以在模式中通过在类型规范后添加一个
=符号和缺省值来指定缺省值,比如status: String = "New"。我们将在本章的后面切换到这种方法。
-
问题没有产生,控制台将出现一个错误,指示请求不正确。您会发现请求的格式不正确,因为引号结束了字符串,这是标题的值,GraphQL 查询解析器无法识别其后的所有内容。
解决这个问题的一种方法是在字符串值中查找引号,并使用反斜杠()字符对它们进行转义。但是正如您将在下一节中看到的,有一种更好的方法可以做到这一点。
- 由于这些值不是作为查询字符串中的文字传递的,现在将调用的是
parseValue。*




