React 和 GraphQL 全栈 Web 开发第二版(一)
原文:
zh.annas-archive.org/md5/218d260d064933ae511d6d90a02baf1c译者:飞龙
前言
在过去几年中,越来越多的 Web 开发者依赖 JavaScript 来构建他们的前端和后端。本书涵盖了 Apollo、Express.js、Node.js 和 React 的一些主要技术。我们将介绍如何设置 React 和 Apollo,以便在用 Node.js 和 Sequelize 构建的后端上运行 GraphQL 请求。在此基础上,我们还将介绍对所编写的组件或函数进行测试,并使用 CircleCI 在 AWS ECS 上自动化部署。到本书结束时,您将了解如何结合最新的前端和后端技术。
这本书面向的对象
这本书是为熟悉 React 和 GraphQL 的 Web 开发者编写的,他们希望提高自己的技能,并使用 React、Apollo、Node.js 和 SQL 等行业标准构建全栈应用程序,同时学习使用 GraphQL 解决复杂问题。
这本书涵盖的内容
第一章, 准备开发环境,通过介绍一些核心概念、完整流程以及准备一个可工作的 React 设置来解释应用程序的架构。我们将看到 React、Apollo Client 和 Express.js 是如何协同工作的,并介绍在 React 中工作时的一些良好实践。此外,我们还将向您展示如何使用 React Developer Tools 调试前端。
第二章, 使用 Express.js 设置 GraphQL,教您如何通过安装 Express.js 和 Apollo 通过 NPM 配置您的后端。Express.js 将用于 Web 服务器,它处理并将所有 GraphQL 请求传递给 Apollo。
第三章, 连接到数据库,讨论了 GraphQL 在数据修改和查询方面提供的机会。例如,我们将使用传统的 SQL 构建一个完整的应用程序。为了简化数据库代码,我们将使用 Sequelize,它允许我们使用普通的 JavaScript 对象查询我们的 SQL 服务器,并允许我们使用 MySQL、MSSQL、PostgresSQL 或仅仅是 SQLite 文件。我们将在 Apollo 和 Sequelize 中为用户和帖子构建模型和模式。
第四章, 将 Apollo 集成到 React 中,您将学习如何将 Apollo 集成到 React 中,并构建前端组件以发送 GraphQL 请求。这一章将解释 Apollo 特定的配置。
第五章, 可重用 React 组件和 React Hooks,在基本概念和获取及展示数据的流程清晰的情况下,将更深入地探讨编写更复杂的 React 组件以及在这些组件间共享数据。
第六章, 使用 Apollo 和 React 进行身份验证,将解释在 Web 和 GraphQL 中验证用户的常见方式。您将通过使用最佳实践来构建完整的身份验证工作流程。
第七章, 处理图像上传,是您将在 Apollo 之上构建一个工作认证和授权系统的点。继续前进,为了超越具有 JSON 响应的正常请求,就像 GraphQL 那样,我们现在将通过 Apollo 上传图像并将它们保存在单独的对象存储中,例如 AWS S3。
第八章, React 中的路由, 是您将实现一些进一步功能以构建面向最终用户的完整应用程序的地方,例如个人资料页面。我们将通过安装 React Router v5 来完成这项工作。
第九章, 实现服务器端渲染,涵盖了服务器端渲染。对于许多应用程序来说,这是必需的。这对于 SEO 很重要,但也可以对您的最终用户产生积极影响。本章将专注于将您当前的应用程序迁移到服务器端渲染设置。
第十章, 实时订阅,探讨了我们的应用程序是如何成为 WebSocket 和 Apollo 订阅的绝佳用例。我们日常使用的许多应用程序都有一个自我更新的通知栏。本章将专注于如何使用名为订阅的实验性 GraphQL 和 Apollo 功能构建此功能。
第十一章, 为 React 和 Node.js 编写测试,探讨了真实的生产就绪应用程序总是有一个自动化的测试环境。我们将使用 Mocha,一个 JavaScript 单元测试框架,以及 Enzyme,一个 React 测试工具,以确保我们应用程序的质量。本章将专注于测试 GraphQL 后端以及如何使用 Enzyme 正确测试 React 应用程序。
第十二章, 使用 CircleCI 和 AWS 进行持续部署,研究了部署。部署应用程序意味着不再需要通过 FTP 手动上传文件。如今,您可以在没有完整服务器运行的情况下在云中几乎运行您的应用程序。为了便于部署我们的应用程序,我们将使用 Docker。在部署我们的应用程序之前,我们将快速介绍一个基本的持续部署设置,这将让您轻松地部署所有新代码。本章将解释如何使用 Git、Docker、AWS 和 CircleCI 来部署您的应用程序。
要充分利用本书
要开始阅读本书并编写功能代码,您需要满足一些要求。关于操作系统,您几乎可以在所有操作系统上运行完整代码和其他依赖项。本书的主要依赖项将在本书中逐一解释。
本书涵盖的软件/硬件:
-
Node.js 14+
-
React 17+
-
Sequelize 6+
-
MySQL 5 或 8
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图和图表的彩色图像 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801077880_ColorImages.pdf
static.packt-cdn.com/downloads/9781801077880_ColorImages.pdf
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“Apollo Client 的最新版本附带useQuery钩子。”
代码块设置如下:
if (loading) return 'Loading...';
if (error) return 'Error! ${error.message}';
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
mkdir src/client/apollo touch src/client/apollo/index.js
任何命令行输入或输出都应如下编写:
mkdir src/client/components
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“在顶部栏中,您将找到Prettify按钮,它可以整理您的查询,使其更易于阅读。”
小贴士或重要提示
看起来是这样的。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问www.packtpub.com/support/err…并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用 GraphQL 和 React 第二版进行全栈 Web 开发》,我们非常期待听到您的想法!扫描下面的二维码直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。
第一部分:构建栈
每次旅程都是从第一步开始的。我们的第一步将是查看如何使用 Node.js、React、MySQL 和 GraphQL 来完成基本设置。了解如何自己构建这样的设置以及不同技术如何协同工作,对于理解本书后面更高级的主题非常重要。
在本节中,包含以下章节:
-
第一章, 准备你的开发环境
-
第二章, 使用 Express.js 设置 GraphQL
-
第三章, 连接到数据库
第一章:准备你的开发环境
在本书中,我们将构建一个简化版的 Facebook,称为Graphbook。我们将允许用户注册和登录,以阅读和撰写帖子并与朋友聊天,类似于我们在常见的社交网络上所能做的。
在开发应用程序时,做好充分的准备始终是一个要求。然而,在我们开始之前,我们需要将我们的栈组合起来。在本章中,我们将探讨我们的技术是否与我们的开发过程很好地配合,在开始之前我们需要什么,以及哪些工具可以帮助我们在构建软件时。
本章通过介绍核心概念、完整流程和准备一个可工作的 React 设置,解释了我们应用程序的架构。
本章涵盖了以下主题:
-
架构和技术
-
仔细思考如何构建栈的架构
-
构建 React 和 GraphQL 栈
-
安装和配置 Node.js
-
使用 webpack、Babel 和其他要求设置 React 开发环境
-
使用 Chrome DevTools 和 React Developer Tools 调试 React 应用程序
技术要求
本章的源代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition/tree/main/Chapter01。
理解应用程序架构
自从 2015 年首次发布以来,GraphQL 已经成为了标准 SOAP 和 REST API 的新替代品。GraphQL 是一个规范,就像 SOAP 和 REST 一样,你可以遵循它来构建你的应用程序和数据流。它之所以创新,是因为它允许你查询实体的特定字段,例如用户和帖子。这种功能使其非常适合同时针对多个平台。移动应用可能不需要在桌面计算机浏览器中显示的所有数据。你发送的查询由一个类似于 JSON 的对象组成,该对象定义了你的平台需要哪些信息。例如,一个针对post的查询可能看起来像这样:
post {
id
text
user {
user_id
name
}
}
GraphQL 根据你的查询对象中指定的正确实体和数据解决问题。GraphQL 中的每个字段都代表一个解析到值的函数。这些函数被称为解析函数。返回值可以是相应的数据库值,例如用户的姓名,也可以是一个日期,该日期在返回之前由你的服务器格式化。
GraphQL 完全与数据库无关,可以在任何编程语言中实现。为了跳过实现 GraphQL 库的步骤,我们将使用 Apollo,这是一个 Node.js 生态系统的 GraphQL 服务器。多亏了 Apollo 背后的团队,这使得它非常模块化。Apollo 与许多常见的 Node.js 框架一起工作,例如 Hapi、Koa 和 Express.js。
我们将使用 Express.js 作为我们的基础,因为它在 Node.js 和 GraphQL 社区中被广泛使用。GraphQL 可以与多个数据库系统和分布式系统一起使用,为所有服务提供一个简单的 API。它允许开发者统一现有系统并处理客户端应用程序的数据获取。
如何将你的数据库、外部系统和其他服务组合成一个服务器后端取决于你。在这本书中,我们将使用 Sequelize 通过 MySQL 服务器作为我们的数据存储。SQL 是最知名且最常用的数据库查询语言,而通过 Sequelize,我们有一个现代客户端库,用于我们的 Node.js 服务器连接到我们的 SQL 服务器。
HTTP 是访问 GraphQL API 的标准协议。它也适用于 Apollo 服务器。然而,GraphQL 并不固定于一种网络协议。我们之前提到的所有内容都是后端的重要部分。
当我们到达我们的Graphbook应用程序的前端时,我们将主要使用 React。React 是由 Facebook 发布的一个 JavaScript UI 框架,它引入了许多现在常用于在网络上以及原生环境中构建界面的技术。
使用 React 带来了一系列显著的优势。在构建 React 应用程序时,你总是将代码拆分成许多组件,以提高它们的效率和可重用性。当然,你可以在不使用 React 的情况下这样做,但 React 使这变得非常容易。此外,React 教你如何以响应式的方式更新应用程序状态以及 UI。你永远不会分别更新 UI 和数据。
React 通过使用虚拟 DOM,将虚拟 DOM 和实际 DOM 进行比较并相应地更新,从而使得重新渲染非常高效。只有当虚拟 DOM 和实际 DOM 之间存在差异时,React 才会应用这些更改。这种逻辑阻止了浏览器重新计算布局、层叠样式表(CSS)以及其他影响应用程序整体性能的计算。
在整本书中,我们将使用 Apollo 客户端库。它自然地与 React 和我们的 Apollo 服务器集成。
如果我们将所有这些放在一起,结果就是由 Node.js、Express.js、Apollo、SQL、Sequelize 和 React 组成的主堆栈。
基本设置
使应用程序工作的基本设置是逻辑请求流程,如下所示:
图 1.1 – 逻辑请求流程
这里是如何工作的逻辑请求流程:
-
客户端请求我们的网站。
-
Express.js 服务器处理这些请求并服务一个静态 HTML 文件。
-
客户端根据这个 HTML 文件下载所有必要的文件,这些文件还包括一个捆绑的 JavaScript 文件。
-
这个捆绑的 JavaScript 文件是我们的 React 应用程序。在执行完这个文件中的所有 JavaScript 代码后,所有必要的 Ajax 别名 GraphQL 请求都会发送到我们的 Apollo 服务器。
-
Express.js 接收请求并将它们传递给我们的 Apollo 端点。
-
Apollo 从所有可用的系统中查询所有请求的数据,例如我们的 SQL 服务器或第三方服务,合并数据,并以 JSON 格式发送回来。
-
React 可以将 JSON 数据渲染为 HTML。
这个工作流程是使应用程序工作的基本设置。在某些情况下,为我们的客户端提供服务器端渲染是有意义的。服务器需要在返回 HTML 给客户端之前自己渲染并发送所有的 XMLHttpRequests。如果服务器在初始加载时发送请求,用户将节省一次或多次往返。我们将在本书的后面部分关注这个主题,但这就是应用程序架构的精髓。考虑到这一点,让我们动手设置我们的开发环境。
安装和配置 Node.js
准备我们的项目的第一步是安装 Node.js。有两种方法可以做到这一点:
-
一种选项是安装 Node 版本管理器(NVM)。使用 NVM 的好处是您可以在几乎所有的 UNIX 基础系统(如 Linux 和 macOS)上轻松地并行运行多个 Node.js 版本,它为您处理安装过程。在这本书中,我们不需要在不同版本的 Node.js 之间切换的选项。
-
另一个选项是如果您使用 Linux,可以通过您发行版的包管理器安装 Node.js。官方的 PKG 文件适用于 Mac,而 MSI 文件适用于 Windows。我们将在这本书中使用常规的 Linux 包管理器,因为它是最简单的方法。
注意
您可以在以下链接找到 Node.js 的 下载 部分:
nodejs.org/en/download/.
我们将在这里使用第二个选项。它涵盖了常规的服务器配置,并且易于理解。我会尽量简短,并跳过所有其他选项,例如针对 Windows 的 Chocolatey 和针对 Mac 的 Brew,这些选项非常特定于那些操作系统。
我假设您使用基于 Debian 的系统,以便于使用这本书。它具有正常的 APT 包管理器和用于轻松安装 Node.js 和 MySQL 的仓库。如果您不是使用基于 Debian 的系统,您可以在 nodejs.org/en/download/package-manager/ 查找安装 Node.js 的匹配命令。
我们的项目将是新的,这样我们就可以使用 Node.js 14,这是当前的 LTS 版本:
-
首先,让我们通过运行以下命令添加我们的包管理器的正确仓库:
curl -fsSL https://deb.nodesource.com/setup_14.x | sudo bash - -
接下来,我们必须使用以下命令安装 Node.js 和用于原生模块的构建工具:
apt-get install -y nodejs build-essential -
最后,让我们打开一个终端并验证安装是否成功:
node --version注意
通过包管理器安装 Node.js 将自动安装 npm。
太好了!你现在可以运行 Node.js 的服务器端 JavaScript,并使用 npm 为你的项目安装 Node.js 模块。
我们项目所依赖的所有依赖项都可在npmjs.com找到,并可以使用 npm 或 Yarn 进行安装。我们将依赖 npm,因为它比 Yarn 更广泛地被使用。所以,让我们继续,并开始使用 npm 来设置我们的项目和其依赖项。
设置 React
我们项目的开发环境已经准备好了。在本节中,我们将安装和配置 React,这是本章的主要内容。让我们首先为我们的项目创建一个新的目录:
mkdir ~/graphbook
cd ~/graphbook
我们的项目将使用 Node.js 和许多 npm 包。我们将创建一个package.json文件来安装和管理我们项目的所有依赖项。它存储有关项目的信息,如版本号、名称、依赖项等。
只需运行npm init来创建一个空的package.json文件:
npm init
npm 会询问一些问题,例如询问包名,实际上就是项目名。输入Graphbook以在生成的package.json文件中插入你的应用程序名称。
我更喜欢从版本号 0.0.1 开始,因为 npm 提供的默认版本号 1.0.0 对我来说代表的是第一个稳定版本。然而,关于你在这里使用哪个版本,这是你的选择。
你可以通过按Enter键来跳过所有其他问题,以保存 npm 的默认值。其中大部分都不相关,因为它们只是提供信息,如描述或仓库链接。我们将在本书的工作过程中填写其他字段,如脚本。你可以在下面的屏幕截图中看到一个命令行的示例:
![Figure 1.2 – npm 项目设置
![Figure 1.2 – npm 项目设置
Figure 1.2 – npm 项目设置
这本书的第一个也是最重要的依赖项是 React。使用 npm 将 React 添加到我们的项目中:
npm install --save react react-dom
这个命令从npmjs.com安装了两个 npm 包到我们的项目文件夹下的node_modules。
由于我们提供了--save选项并添加了这些包的最新版本号,npm 自动编辑了我们的package.json文件。
你可能想知道为什么我们安装了两个包,尽管我们只需要 React。react包只提供 React 特定的方法。所有 React Hooks,如componentDidMount、useState,甚至 React 的组件类,都来自这个包。你需要这个包来编写任何 React 应用程序。
在大多数情况下,你甚至不会注意到你已经使用了react-dom。这个包提供了将浏览器实际 DOM 连接到你的 React 应用程序的所有功能。通常,你使用ReactDOM.render在 HTML 的特定位置渲染你的应用程序,并且只在你代码中渲染一次。我们将在本书的后面部分介绍如何渲染 React。
还有一个名为ReactDOM.findDOMNode的函数,它为你提供了对 DOMNode 的直接访问,但我强烈建议不要使用这个函数,因为 DOMNodes 上的任何更改在 React 本身中都是不可用的。我从未需要使用这个函数,所以如果可能的话,尽量避免使用它。现在,我们的 npm 项目已经设置好,两个主要依赖项也已经安装,我们需要准备一个环境来打包我们将要编写的所有 JavaScript 文件。我们将在下一节中关注这一点。
准备和配置 webpack
当我们的浏览器访问我们的应用程序时,它会请求一个index.html文件。它指定了运行我们的应用程序所需的所有文件。我们需要创建这个index.html文件,并将其作为我们应用程序的入口点:
-
为我们的
index.html文件创建一个单独的目录:mkdir public cd public touch index.html -
然后,将以下内容保存到
index.html中:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Graphbook</title> </head> <body> <div id="root"></div> </body> </html>
如你所见,这里没有加载任何 JavaScript。只有一个带有root ID 的div标签。这个div标签是ReactDOM将我们的应用程序渲染到的 DOMNode。
那么,我们如何使用这个index.html文件来启动 React 呢?
为了实现这一点,我们需要使用一个网络应用程序打包器,它将准备和打包我们所有的应用程序资源。所有必需的 JavaScript 文件和node_modules都被打包和压缩;SASS 和 SCSS 预处理器被转换为 CSS,并且也被合并和压缩。
有几个应用程序打包器包可用,包括 webpack、Parcel 和 Gulp。对于我们的用例,我们将使用 webpack。它是最常见的模块打包器,并且有一个庞大的社区。为了打包我们的 JavaScript 代码,我们需要安装 webpack 及其所有依赖项,如下所示:
npm install --save-dev @babel/core babel-loader @babel/preset-env @babel/preset-react clean-webpack-plugin css-loader file-loader html-webpack-plugin style-loader url-loader webpack webpack-cli webpack-dev-server
此命令将所有开发工具添加到package.json文件中的devDependencies。我们需要这些工具来打包我们的应用程序。它们仅在开发环境中安装,并在生产中跳过。
如果你还不知道,设置 webpack 可能有点麻烦。许多选项可能会相互干扰,并在你打包应用程序时导致问题。现在,让我们在项目的根目录中创建一个webpack.client.config.js文件。
输入以下代码:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const buildDirectory = 'dist';
const outputDirectory = buildDirectory + '/client';
module.exports = {
mode: 'development',
entry: './src/client/index.js',
output: {
path: path.join(__dirname, outputDirectory),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
devServer: {
port: 3000,
open: true
},
plugins: [
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: [path.join(__dirname,
buildDirectory)]
}),
new HtmlWebpackPlugin({
template: './public/index.html'
})
]
};
webpack 配置文件只是一个普通的 JavaScript 文件,你可以在这里 require node_modules和自定义 JavaScript 文件。这和 Node.js 内部的任何地方都一样。让我们快速浏览一下这个配置的所有主要属性。理解这些将使未来的自定义 webpack 配置变得容易得多。所有重要点都在这里解释:
-
HtmlWebpackPlugin:这会自动生成一个包含所有 webpack 打包的 HTML 文件。我们传递之前创建的index.html作为模板。 -
CleanWebpackPlugin:这会清空所有提供的目录以清理旧的构建文件。cleanOnceBeforeBuildPatterns属性指定了一个在构建过程开始之前被清理的文件夹数组。 -
entry字段告诉 webpack 我们应用程序的起点在哪里。这个文件需要我们创建。 -
output对象指定了我们的包是如何命名的以及它应该保存的位置。对我们来说,这是dist/client/bundle.js。 -
在
module.rules中,我们将我们的文件扩展名与正确的加载器匹配。所有 JavaScript 文件(除了位于node_modules中的文件)都由babel-loader转译,以便我们可以在代码中使用 ES6 特性。我们的 CSS 由style-loader和css-loader处理。还有许多其他用于 JavaScript、CSS 和其他文件扩展名的加载器可用。 -
webpack 的
devServer功能使我们能够直接运行 React 代码。这包括在浏览器中无需重新运行构建或刷新浏览器标签页的情况下热重载代码。注意
如果您需要 webpack 配置的更详细概述,请查看官方文档:
webpack.js.org/concepts/。
考虑到这一点,让我们继续前进。我们在 webpack 配置中缺少 src/client/index.js 文件,所以让我们创建它,如下所示:
mkdir -p src/client
cd src/client
touch index.js
您可以暂时留空这个文件。它可以在没有内容的情况下由 webpack 打包。我们将在本章的后面更改它。
为了启动我们的开发 webpack 服务器,我们将在 package.json 中添加一个命令,我们可以使用 npm 来运行。
将以下行添加到 package.json 中的 scripts 对象:
"client": "webpack serve --devtool inline-source-map --hot --config webpack.client.config.js"
现在,在您的控制台中执行 npm run client 并观察新浏览器窗口的打开。我们正在使用新创建的配置文件运行 webpack serve。
当然,浏览器仍然是空的,但如果你用 Chrome DevTools 检查 HTML,你会看到我们已经有了一个 bundle.js 文件,并且我们的 index.html 文件被用作模板。
有了这些,我们已经学会了如何将空的 index.js 文件包含在包中并服务于浏览器。接下来,我们将在模板 index.html 文件中渲染第一个 React 组件。
渲染第一个 React 组件
对于 React 来说,有许多最佳实践。其背后的核心哲学是在可能的情况下将我们的代码拆分为单独的组件。我们将在 第五章,可重用 React 组件和 React Hooks 中更详细地介绍这种方法。
我们的 index.js 文件是前端代码的主要起点,这就是它应该保持的样子。不要在这个文件中包含任何业务逻辑。相反,尽量保持它尽可能干净和精简。
index.js 文件应包含以下代码:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App/>, document.getElementById('root'));
ECMAScript 2015 的发布引入了 import 功能。我们可以使用它来加载我们的 npm 包——react 和 react-dom——以及我们的第一个自定义 React 组件,我们必须现在编写它。
当然,我们需要涵盖传统的 Hello World 程序。
在您的 index.js 文件旁边创建 App.js 文件,并确保它包含以下内容:
import React from 'react';
const App = () => {
return (
<div>Hello World!</div>
)
}
export default App
在这里,我们导出了一个名为 App 的单个无状态函数,然后由 index.js 文件导入。正如我们之前解释的,我们现在正在积极地在 index.js 文件中使用 ReactDOM.render。
ReactDOM.render 的第一个参数是我们想要渲染的组件或函数,这是显示 DOMNode 的导出函数,它应该在那里渲染。我们通过一个简单的 document.getElementById JavaScript 接收 DOMNode。
当我们创建 index.html 文件时,我们定义了我们的根元素。保存 App.js 文件后,webpack 将尝试重新构建一切。然而,它不应该能够做到这一点。webpack 在打包我们的 index.js 文件时将遇到问题,因为我们使用 ReactDOM.render 方法中的 <App /> 标签语法,它没有被转换为正常的 JavaScript 函数。
我们配置了 webpack 来加载 Babel 以处理我们的 JavaScript 文件,但没有告诉 Babel 要转换什么以及不要转换什么。
让我们在根目录中创建一个 .babelrc 文件,其中包含以下内容:
{
"presets": ["@babel/env","@babel/react"]
}
注意
你可能需要重新启动服务器,因为当文件被修改时,.babelrc 文件不会被重新加载。几分钟后,你应该能在浏览器中看到标准的 Hello World! 消息。
在这里,我们告诉 Babel 使用 @babel/preset-env 和 @babel/preset-react,这些预设与 webpack 一起安装。这些预设允许 Babel 转换特定的语法,例如 JSX。我们可以使用这些预设来创建所有浏览器都能理解的正常 JavaScript,并且 webpack 可以打包。
从 React 状态渲染数组
Hello World! 是每本好编程书的必备,但当我们使用 React 时,我们追求的并不是这个。
一个像 Facebook 或 Graphbook 这样的社交网络,我们现在正在编写,需要一个新闻源和发布新闻的输入。让我们来实现这个功能。
由于这是本书的第一章,我们将在 App.js 中完成这个任务。
由于我们还没有设置 GraphQL API,我们应该在这里使用一些假数据。我们可以在以后用真实数据替换它。
在导出为 default 的 App 函数上方定义一个新变量,如下所示:
const initialPosts = [
{
id: 2,
text: 'Lorem ipsum',
user: {
avatar: '/uploads/avatar1.png',
username: 'Test User'
}
},
{
id: 1,
text: 'Lorem ipsum',
user: {
avatar: '/uploads/avatar2.png',
username: 'Test User 2'
}
}
];
我们将使用 React 渲染这两个假帖子。为了准备这个,将 App.js 文件的第一个行更改为以下内容:
import React, { useState } from 'react';
这确保了 React 的 useState 函数被导入并且可以被我们的无状态函数访问。
将你的 App 函数当前内容替换为以下代码:
const [posts, setPosts] = useState(initialPosts);
return (
<div className="container">
<div className="feed">
{ initialPosts.map((post, i) =>
<div key={post.id} className="post">
<div className="header">
<img src={post.user.avatar} />
<h2>{post.user.username}</h2>
</div>
<p className="content">
{post.text}
</p>
</div>
)}
</div>
</div>
)
在这里,我们通过 React 的 useState 函数在函数内部初始化了一个 posts 数组。这允许我们拥有一个状态,而无需编写真正的 React 类;相反,它只依赖于原始函数。useState 函数期望一个参数,即状态变量的初始值。在这种情况下,这是常量 initialPosts 数组。这返回 posts 状态变量和一个 setPosts 函数,你可以使用它来更新本地状态。
然后,我们使用map函数遍历posts数组,这再次执行了内部回调函数,逐个传递数组项作为参数。第二个参数简单地称为i,代表我们正在处理的数组元素的索引。map函数返回的所有内容随后都由 React 渲染。
我们只是通过将每个帖子的数据放入 ES6 花括号中返回 HTML。这些花括号告诉 React 将它们内部的代码解释和评估为 JavaScript。
如前述代码所示,我们依赖于useState函数返回的帖子。这种数据流非常方便,因为我们可以在应用程序的任何位置更新状态,帖子将重新渲染。重要的是,这只能通过使用setPosts函数并将更新的数组传递给它来实现。在这种情况下,React 会注意到状态的改变并重新渲染函数。
前面的方法更简洁,我推荐这种方法以提高可读性。保存时,你应该能够看到渲染的帖子。它们应该看起来像这样:
![图 1.3 – 未加样式演示帖子]
图 1.3 – 未加样式演示帖子
我在这里使用的图片是免费可用的。如果路径与posts数组中的字符串匹配,你可以使用任何其他材料。这些图片可以在本书的官方 GitHub 仓库中找到。
使用 webpack 的 CSS
前面的图中的帖子尚未设计。我已经为返回的 HTML 组件添加了 CSS 类。
而不是使用 CSS 使我们的帖子看起来更好,另一种方法是使用 CSS-in-JS,例如使用 styled components 这样的包,这是一个 React 包。其他替代方案包括 Glamorous 和 Radium。使用这样的库有无数的理由和反对的理由。使用那些其他工具,你无法有效地使用 SASS、SCSS 或 LESS。我需要与其他人合作,例如屏幕和图形设计师,他们可以提供和使用 CSS,但不会编写样式组件。总是有一个原型或现有的 CSS 可以使用,那么我为什么要花时间将这转换为样式组件 CSS,而我可以继续使用标准 CSS 呢?
在这里没有正确或错误的选择;你可以自由地以任何你喜欢的任何方式实现样式。然而,在这本书中,我们将继续使用传统的 CSS。
在我们的webpack.client.config.js文件中,我们已经指定了一个 CSS 规则,如下面的代码片段所示:
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
style-loader将你的打包 CSS 直接注入到 DOM 中。css-loader将解析 CSS 代码中的所有import或url出现。
在/assets/css目录下创建一个style.css文件,并填写以下内容:
body {
background-color: #f6f7f9;
margin: 0;
font-family: 'Courier New', Courier, monospace
}
p {
margin-bottom: 0;
}
.container {
max-width: 500px;
margin: 70px auto 0 auto;
}
.feed {
background-color: #bbb;
padding: 3px;
margin-top: 20px;
}
.post {
background-color: #fff;
margin: 5px;
}
.post .header {
height: 60px;
}
.post .header > * {
display: inline-block;
vertical-align: middle;
}
.post .header img {
width: 50px;
height: 50px;
margin: 5px;
}
.post .header h2 {
color: #333;
font-size: 24px;
margin: 0 0 0 5px;
}
.post p.content {
margin: 5px;
padding: 5px;
min-height: 50px;
}
刷新浏览器将留下你之前相同的旧 HTML。
这个问题发生是因为 webpack 是一个模块打包器,它对 CSS 一无所知;它只知道 JavaScript。我们必须在我们的代码中某个地方导入 CSS 文件。
我们可以使用 webpack 和我们的 CSS 规则,而不是使用index.html并添加一个head标签,将 CSS 直接加载到App.js中。这个解决方案非常方便,因为我们的应用程序中所有需要的 CSS 都会被压缩和捆绑。Webpack 自动化了这个过程。
在你的App.js文件中,在 React import语句之后添加以下内容:
import '../../assets/css/style.css';
webpack 神奇地重建我们的包并刷新我们的浏览器标签。
这样,你就已经通过 React 渲染了假数据,并用 webpack 捆绑的 CSS 进行了样式化。它应该看起来像这样:
图 1.4 – 样式化演示帖子
输出看起来已经很好了。
React 的事件处理和状态更新
对于这个项目,有一个简单的textarea会很好,我们可以点击一个按钮,然后添加一个新的帖子到我们在App函数中编写的静态posts数组中。
在包含feed类的div标签上方添加以下代码:
<div className="postForm">
<form onSubmit={handleSubmit}>
<textarea value={postContent} onChange={(e) =>
setPostContent(e.target.value)}
placeholder="Write your custom post!"/>
<input type="submit" value="Submit" />
</form>
</div>
你可以在 React 中无任何问题地使用表单。React 可以通过给表单一个onSubmit属性来拦截请求的提交事件,这将是一个处理逻辑的函数。
我们将postContent变量传递给textarea的value属性,以获得所谓的受控输入。
使用useState函数创建一个空字符串变量来保存textarea的值:
const [postContent, setPostContent] = useState('');
postContent变量已经被用于我们的新textarea,因为我们已经在value属性中指定了它。此外,我们在帖子表单中直接实现了setPostContent函数。这用于onChange属性或任何在textarea内部输入时被调用的任何事件。setPostContent函数接收e.target.value变量,这是textarea值的 DOM 访问器,然后存储在 React 函数的状态中。
再次查看你的浏览器。表单在那里,但它看起来并不漂亮,所以添加以下 CSS:
form {
padding-bottom: 20px;
}
form textarea {
width: calc(100% - 20px);
padding: 10px;
border-color: #bbb;
}
form [type=submit] {
border: none;
background-color: #6ca6fd;
color: #fff;
padding: 10px;
border-radius: 5px;
font-size: 14px;
float: right;
}
最后一步是实现我们的表单的handleSubmit函数。直接在状态变量和return语句之后添加它:
const handleSubmit = (event) => {
event.preventDefault();
const newPost = {
id: posts.length + 1,
text: postContent,
user: {
avatar: '/uploads/avatar1.png',
username: 'Fake User'
}
};
setPosts([newPost, ...posts]);
setPostContent('');
};
之前的代码看起来比实际要复杂,但我将快速解释它。
我们需要运行event.preventDefault来阻止浏览器实际尝试提交表单并重新加载页面。大多数来自 jQuery 或其他 JavaScript 框架的人都会知道这一点。
接下来,我们将新帖子保存在newPost变量中,我们希望将其添加到我们的 feed 中。
我们在这里伪造了一些数据来模拟真实世界的应用。对于我们的测试用例,新的帖子 ID 是我们状态变量中的帖子数加一。React 希望我们给 ReactDOM 中的每个子元素一个唯一的 ID。通过计算posts.length中的帖子数,我们模拟了真实后端为我们帖子提供唯一 ID 的行为。
我们新帖子的文本来自postContent状态变量。
此外,我们目前还没有一个用户系统,我们的 GraphQL 服务器可以使用它来给我们提供最新的帖子,包括匹配的用户和他们的头像。我们可以通过为所有创建的新帖子创建一个静态用户对象来模拟这一点。
最后,我们再次更新了状态。我们通过使用 setPosts 函数并传递一个由新帖子以及当前 posts 数组通过解构赋值合并而成的数组来做到这一点。之后,我们通过将空字符串传递给 setPostContent 函数来清空 textarea。
现在,继续使用你的工作 React 表单。别忘了,你创建的所有帖子都不会持久化,因为它们只保存在浏览器的本地内存中,并没有保存到数据库中。因此,刷新会删除你的帖子。
使用 React Helmet 控制文档头
在开发 web 应用程序时,你必须控制你的文档头。你可能想根据你展示的内容更改标题或描述。
React Helmet 是一个优秀的包,它提供了这些功能,包括动态覆盖多个头信息和服务器端渲染。让我们看看我们如何做到这一点:
-
使用以下命令安装 React Helmet:
head tags inside your template. This has the advantage that, before React has been rendered, there is always the default document head. For our case, you can directly apply a title and description in App.js. -
在文件顶部导入
react-helmet:import { Helmet } from 'react-helmet'; -
在
postForm div上面直接添加Helmet:<Helmet> <title>Graphbook - Feed</title> <meta name="description" content="Newsfeed of all your friends on Graphbook" /> </Helmet>
如果你刷新浏览器并仔细观察浏览器标签栏上的标题,你会看到它从 Graphbook 变为 Graphbook - Feed。这种行为发生是因为我们在 index.html 中已经定义了一个标题。当 React 完成渲染后,新的文档头就会应用。
使用 webpack 进行生产构建
我们 React 设置的最后一步是进行生产构建。到目前为止,我们只使用了 webpack-dev-server,但这自然包括一个未优化的开发构建。此外,webpack 会自动启动一个 web 服务器。在下一章中,我们将介绍 Express.js 作为我们的 web 服务器,这样我们就不需要 webpack 来启动它了。
生产版本的包会合并所有 JavaScript 文件,但也会将所有 CSS 文件合并成两个单独的文件。这些文件可以直接在浏览器中使用。为了打包 CSS 文件,我们将依赖于另一个 webpack 插件,称为 MiniCss:
npm install --save-dev mini-css-extract-plugin
我们不想更改当前的 webpack.client.config.js 文件,因为它是为开发工作制作的。将以下命令添加到你的 package.json 文件的 scripts 对象中:
"client:build": "webpack --config webpack.client.build.config.js"
此命令使用单独的生产 webpack 配置文件运行 webpack。让我们创建这个文件。首先,克隆原始的 webpack.client.config.js 文件,并将其重命名为 webpack.client.build.config.js。
在新文件中更改以下内容:
-
mode需要设置为production,而不是development。 -
需要引入
MiniCss插件:const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -
替换当前的 CSS 规则:
{ test: /\.css$/, use: [{ loader: MiniCssExtractPlugin.loader, options: { publicPath: '../' } }, 'css-loader'], },我们不再使用
style-loader;相反,我们使用MiniCss插件。该插件遍历完整的 CSS 代码,将其合并到一个单独的文件中,并从我们并行生成的bundle.js文件中删除import语句。 -
最后,将插件添加到配置文件底部的插件中:
new MiniCssExtractPlugin({ filename: 'bundle.css', }) -
删除整个
devServer属性。
当您运行新的配置时,它不会启动服务器或浏览器窗口;它只会创建生产 JavaScript 和 CSS 包,并将它们包含在我们的index.html文件中。根据我们的webpack.client.build.config.js文件,这三个文件将被保存到dist/client文件夹中。
您可以通过执行npm run client:build来运行此命令。
如果您查看dist/client文件夹,您将看到三个文件。您可以在浏览器中打开index.html文件。遗憾的是,图片已损坏,因为图片 URL 不再正确。我们必须暂时接受这一点,因为当我们的后端工作正常时,它将自动修复。
这样,我们就完成了 React 的基本设置。
有用的开发工具
当您使用 React 时,您想知道为什么您的应用程序以这种方式渲染。您需要知道组件接收了哪些属性以及它们当前的状态看起来如何。由于这些信息在 DOM 或 Chrome DevTools 的任何其他地方都没有显示,您需要一个单独的插件。
幸运的是,Facebook 已经为您解决了这个问题。访问chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi并安装 React Developer Tools。此插件允许您检查 React 应用程序和组件。当您再次打开 Chrome DevTools 时,您将在行尾看到两个新的标签页 - 一个称为Components,另一个称为Profiler:
![Figure 1.5 – React developer tools]
![Figure_1.5_B17337.jpg]
Figure 1.5 – React developer tools
您只能在开发模式下运行 React 应用程序时才能看到这些标签页。如果 React 应用程序正在运行或以生产模式打包,这些扩展将不起作用。
注意
如果您看不到这个标签页,您可能需要完全重新启动 Chrome。您还可以在 Firefox 上找到 React Developer Tools。
第一个标签页允许您查看、搜索和编辑您的 ReactDOM 的所有组件。
左侧面板看起来与 Chrome DevTools 中的常规 DOM 树(Elements)非常相似,但您将看到所有组件都在树中,而不是显示 HTML 标记。ReactDOM 将此树渲染成真实的 HTML,如下所示:
![Figure 1.6 – React component tree]
![Figure_1.6_B17337.jpg]
Figure 1.6 – React component tree
Graphbook 当前版本的第一个组件应该是<App />。
通过点击一个组件,你的右侧面板将显示其属性、状态和上下文。你可以尝试使用 App 组件,这是唯一的真实 React 组件:
![图 1.7 – React 组件状态
图 1.7 – React 组件状态
App 函数是我们应用程序的第一个组件。这就是为什么它没有接收任何属性。子组件可以从父组件接收属性;没有父组件,就没有属性。
现在,测试 App 函数并尝试操作状态。你会看到状态的改变会重新渲染你的 ReactDOM 并更新 HTML。你可以编辑 postContent 变量,它会在 textarea 内插入新文本。正如你将看到的,所有的事件都会被抛出,并且你的处理器会运行。更新状态总是触发重新渲染,所以尽量减少状态更新,以尽可能少地使用计算资源。
摘要
在本章中,我们设置了一个可工作的 React 环境。这对于我们的前端来说是一个很好的起点,因为我们可以使用这个设置编写和构建静态网页。
下一章将主要关注我们的后端设置。我们将配置 Express.js 以接受我们的第一个请求并将所有 GraphQL 查询传递给 Apollo。此外,你还将学习如何使用 Postman 测试你的 API。
第二章:使用 Express.js 设置 GraphQL
我们的前端基本设置和原型现在已完成。现在,我们需要启动我们的 GraphQL 服务器,以便开始实现后端。我们将使用 Apollo 和 Express.js 来构建后端的基础。
本章将解释 Express.js 的安装过程以及我们的 GraphQL 端点的配置。我们将快速浏览 Express.js 的所有基本功能以及我们后端的调试工具。
本章涵盖了以下主题:
-
Express.js 的安装和说明
-
Express.js 中的路由
-
Express.js 中的中间件
-
将 Apollo 服务器绑定到 GraphQL 端点
-
发送我们的第一个 GraphQL 请求
-
后端调试和日志记录
技术要求
本章的源代码可在以下 GitHub 仓库中找到:
开始使用 Node.js 和 Express.js
本书的主要目标之一是设置一个 GraphQL API,然后由我们的 React 前端消费。为了接受网络请求——特别是 GraphQL 请求——我们将设置一个 Node.js 网络服务器。
在 Node.js 网络服务器领域,最显著的竞争对手是 Express.js、Koa 和 Hapi。在这本书中,我们将使用 Express.js。大多数关于 Apollo 的教程和文章都依赖于它。
Express.js 也是目前最常用的 Node.js 网络服务器,它将自己描述为一个 Node.js 网络框架,提供了构建 Web 应用程序所需的所有主要功能。
安装 Express.js 很简单。我们可以像上一章一样使用npm:
npm install --save express
此命令将 Express.js 的最新版本添加到package.json中。
在上一章中,我们直接在src/client文件夹中创建了所有 JavaScript 文件。现在,让我们为我们的服务器端代码创建一个单独的文件夹。这种分离给我们一个整洁的目录结构。我们可以使用以下命令创建此文件夹:
mkdir src/server
现在,我们可以继续配置 Express.js。
设置 Express.js
和往常一样,我们需要一个包含所有主要组件的根文件,以便将它们组合成一个真实的应用程序。
在server文件夹中创建一个index.js文件。此文件是后端的起点。以下是我们的操作方法:
-
首先,我们必须从
node_modules导入express,这是我们刚刚安装的:import express from 'express';我们可以在这里使用
import,因为我们的后端将被 Babel 转换。我们还将计划在第九章中设置 webpack 用于服务器端代码,实现服务器端渲染。 -
接下来,我们必须使用
express命令初始化服务器。结果存储在app变量中。我们后端所做的所有操作都是通过此对象执行的:const app = express(); -
然后,我们必须指定接受请求的路由。在这个简单的介绍中,我们使用
app.get方法接受所有匹配任何路径的 HTTPGET请求。其他 HTTP 方法可以用app.post和app.put捕获:app.get('*', (req, res) => res.send('Hello World!')); app.listen(8000, () => console.log('Listening on port 8000!'));
要匹配所有路径,你可以使用星号,它在编程领域中通常代表任何,正如我们在前面的app.get行中所做的那样。
所有app.METHOD函数的第一个参数是要匹配的路径。从这里,你可以提供无限数量的回调函数,它们将依次执行。我们将在使用 Express.js 进行路由部分中稍后查看此功能。
回调函数总是将客户端请求作为第一个参数接收,将响应作为第二个参数,这是服务器将要发送的。我们的第一个回调将使用send响应方法。
send函数仅仅发送 HTTP 响应。它将 HTTP 体设置为指定的内容。因此,在我们的例子中,体显示为Hello World!,而send函数则负责所有必要的标准 HTTP 头,例如Content-Length。
最后一步是告诉 Express.js 服务器应该在哪个端口上监听请求。在我们的代码中,我们使用app.listen的第一个参数8000。你可以将8000替换为你想要监听的任何端口或 URL。当 HTTP 服务器绑定到该端口并且可以接受请求时,将执行回调。
这是我们可以为 Express.js 设置的 simplest 配置。
在开发中运行 Express.js
为了启动我们的服务器,我们必须在我们的package.json文件中添加一个新的脚本。
让我们在package.json文件的scripts属性中添加以下行:
"server": "nodemon --exec babel-node --watch src/server src/server/index.js"
正如你所见,我们正在使用一个名为nodemon的命令。我们首先需要安装它:
npm install --save nodemon
nodemon是一个运行 Node.js 应用程序的优秀工具。当源代码发生变化时,它可以重新启动你的服务器。
例如,为了使前面的命令生效,请按照以下步骤操作:
-
首先,我们必须安装
@babel/node包,因为我们正在使用 Babel 通过--exec babel-node选项转译后端代码。这允许我们使用import语句:npm install --save-dev @babel/node -
当使用
nodemon跟踪路径或文件时,提供--watch选项将永久跟踪该文件或文件夹上的更改,并重新加载服务器以表示应用程序的最新状态。最后一个参数指的是实际文件,它是后端启动执行的起点。 -
启动服务器:
npm run server
现在,当你打开浏览器并输入http://localhost:8000时,你将看到来自我们的 Express.js 回调函数的文本Hello World!。
第三章,连接到数据库,详细介绍了 Express.js 的路由工作原理。
Express.js 中的路由
理解路由对于扩展我们的后端代码至关重要。在本节中,我们将通过一些简单的路由示例进行实践。
通常,路由处理应用程序如何以及在哪里响应特定的端点和方法。
在 Express.js 中,一个路径可以响应不同的 HTTP 方法,并且可以有多个处理函数。这些处理函数按照它们在代码中指定的顺序依次执行。路径可以是简单的字符串,也可以是复杂的正则表达式或模式。
当你使用多个处理函数时——无论是作为数组提供还是作为多个参数——确保将next传递给每个回调函数。当你调用next时,你将执行权从当前回调函数传递给行中的下一个函数。这些函数也可以是中间件。我们将在下一节中介绍这一点。
这里有一个简单的例子。用当前的app.get行替换它:
app.get('/', function (req, res, next) {
console.log('first function');
next();
}, function (req, res) {
console.log('second function');
res.send('Hello World!');
});
当你刷新浏览器时,查看终端中的服务器日志;你会看到first function和second function都被打印出来。如果你移除next的执行并尝试重新加载浏览器标签页,请求将超时,只有first function会被打印出来。这个问题发生是因为没有调用res.send、res.end或任何替代方法。当不运行next时,第二个处理函数永远不会执行。
如我们之前提到的,**Hello World!**消息很好,但不是我们能得到的最好的。在开发中,运行两个独立的服务器——一个用于前端,一个用于后端——是完全可行的。
提供我们的生产构建
我们可以通过 Express.js 提供我们的前端生产构建。这种方法对于开发目的来说不是很好,但对于测试构建过程和查看我们的实时应用程序将如何表现是有用的。
再次,用以下代码替换之前的路由示例:
import path from 'path';
const root = path.join(__dirname, '../../');
app.use('/', express.static(path.join(root, 'dist/client')));
app.use('/uploads', express.static(path.join(root,
'uploads')));
app.get('/', (req, res) => {
res.sendFile(path.join(root, '/dist/client/index.html'));
});
path模块提供了许多用于处理目录结构的函数。
我们使用全局的__dirname变量来获取我们的项目根目录。该变量包含当前文件的路径。使用path.join与../../和__dirname一起,我们可以得到我们项目的真实根目录。
Express.js 提供了use函数,当给定的路径匹配时,它会运行一系列命令。当不指定路径执行此函数时,它会对每个请求执行。
我们使用这个特性通过express.static来提供我们的静态文件(头像图像),包括bundle.js和bundle.css,这些文件是通过npm run client:build创建的。
在我们的情况下,首先,我们使用express.static跟随'/'。这样做的结果是,dist目录中的所有文件和文件夹都以'/'开头提供服务。app.use的第一个参数中的其他路径,例如'/example',会导致我们的bundle.js文件能够在'/example/bundle.js'下被下载。
例如,所有头像图像都在'/uploads/'下提供服务。
现在,我们已经准备好让客户端下载所有必要的文件。我们客户端的初始路由是 '/',如 app.get 所指定。对这个路径的响应是 index.html。我们运行 res.sendFile 和返回此文件的文件路径——这就是我们在这里要做的全部。
一定要先执行 npm run client:build。否则,你将收到一个错误消息,指出这些文件未找到。此外,当运行 npm run client 时,dist 文件夹将被删除,因此你必须重新运行构建过程。
现在刷新浏览器将显示来自 第一章,准备你的开发环境 的 后 文件和表单。
下一节将重点介绍 Express.js 中间件函数的强大功能。
使用 Express.js 中间件
Express.js 提供了编写高效后端的方法,无需重复代码。
每个中间件函数都会接收到一个请求、一个响应和 next。它需要运行 next 来将控制权传递给下一个处理函数。否则,你将收到一个超时。中间件允许我们预先或后处理请求或响应对象,执行自定义代码,等等。之前,我们已经介绍了 Express.js 中处理请求的简单示例。
Express.js 可以针对同一路径和 HTTP 方法拥有多个路由。中间件可以决定哪个函数应该被执行。
以下代码是一个简单的示例,展示了通常可以用 Express.js 完成的事情。你可以通过替换当前的 app.get 路由来测试它。
-
根路径
'/'用于捕获任何请求:app.get('/', function (req, res, next) { -
在这里,我们将使用
Math.random在 1 和 10 之间随机生成一个数字:var random = Math.random() * (10 -1) + 1; -
如果数字大于
5,我们将运行next('route')函数跳转到下一个具有相同路径的app.get:if (random > 5) next('route')这个路由将记录
'second'。 -
如果数字小于
0.5,我们将不带任何参数执行next函数,并转到下一个处理函数。这个处理函数将记录'first':else next() }, function (req, res, next) { res.send('first'); }) app.get('/', function (req, res, next) { res.send('second'); })
你不需要复制此代码,因为这只是一个解释示例。当涉及到特殊处理,如管理员用户和错误处理时,这个功能可能会很有用。
安装重要的中间件
对于我们的应用程序,我们已经在 Express.js 中使用了一个内置的中间件:express.static。在这本书中,我们将继续安装其他中间件:
npm install --save compression cors helmet
现在,在服务器的 index.js 文件中添加新包的 import 语句,以便在文件中提供所有依赖项:
import helmet from 'helmet';
import cors from 'cors';
import compress from 'compression';
让我们看看这些包的功能以及我们如何使用它们。
Express Helmet
Helmet 是一个工具,允许你设置各种 HTTP 头来保护你的应用程序。
我们可以在服务器的 index.js 文件中如下启用 Express.js Helmet 中间件。在 app 变量下方直接添加以下代码片段:
app.use(helmet());
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "*.amazonaws.com"]
}
}));
app.use(helmet.referrerPolicy({ policy: 'same-origin' }));
我们在这里同时做了很多事情。在前面的代码中,我们仅通过在第一行使用helmet()函数,就添加了一些X-Powered-By HTTP 头信息,以及一些其他有用的东西。
注意
你可以在github.com/helmetjs/helmet查找默认参数以及 Helmet 的其他功能。在实现安全功能时,始终保持警觉,并尽你所能验证你的攻击防护方法。
此外,为了确保没有人可以注入恶意代码,我们使用了Content-Security-Policy HTTP 头信息,简称 CSP。这个头信息阻止攻击者从外部 URL 加载资源。
如你所见,我们还指定了imgSrc字段,这告诉我们的客户端只有来自这些 URL 的图片应该被加载,包括亚马逊网络服务(AWS)。我们将在第七章“处理图片上传”中学习如何将其上传。
你可以在helmetjs.github.io/docs/csp/了解更多关于 CSP 以及它如何使你的平台更安全的信息。
最后的增强是设置Referrer HTTP 头信息,但仅在向同一主机发出请求时。例如,当我们从域名 A 转到域名 B 时,我们不包含引用者,即用户来自的 URL。这个增强阻止了任何内部路由或请求暴露给互联网。
在你的 Express 路由器中非常高地初始化 Helmet 非常重要,这样所有响应都会受到影响。
使用 Express.js 进行压缩
启用 Express.js 的压缩可以为你和你的用户节省带宽,而且这很容易做到。以下代码也必须添加到服务器的index.js文件中:
app.use(compress());
这个中间件压缩了所有通过它的响应。请记住,在你的路由顺序中将其添加得非常高,以便所有请求都受到影响。
注意
无论何时你都有这样的中间件或多个匹配相同路径的路由,你都需要检查初始化顺序。除非你运行next命令,否则只有第一个匹配的路由会被执行。之后定义的所有路由将不会被执行。
Express.js 中的 CORS
我们希望我们的 GraphQL API 可以从任何网站、应用或系统中访问。一个不错的想法可能是构建一个应用或向其他公司或开发者提供 API,以便他们可以使用它。当你通过 Ajax 使用 API 时,主要问题是 API 需要发送正确的Access-Control-Allow-Origin头信息。
例如,如果你构建了 API,在https://api.example.com下进行宣传,并且尝试在不设置正确头信息的情况下从https://example.com访问它,那么它将不会工作。API 需要在Access-Control-Allow-Origin头信息中至少设置example.com以允许此域名访问其资源。这看起来有点繁琐,但它使你的 API 能够接受跨站请求,这一点你应该始终注意。
允许 index.js 文件:
app.use(cors());
此命令一次性处理了我们通常在跨源请求中遇到的所有问题。它仅仅在 Access-Control-Allow-Origin 中设置了一个带有 * 的通配符,允许来自任何地方的任何人使用您的 API,至少在最初是这样。您始终可以通过提供 API 密钥或仅允许已登录用户访问来保护您的 API。启用 CORS 只允许请求网站接收响应。
此外,该命令还实现了整个应用程序的 OPTIONS 路由。
每次我们使用 CORS 时,都会进行 OPTIONS 方法或请求。这个动作被称为 OPTIONS 预检,实际的 POST 等方法根本不会被浏览器执行。
我们的应用程序现在已准备好适当地服务所有路由并响应正确的头信息。
现在,让我们设置一个 GraphQL 服务器。
结合 Express.js 和 Apollo
首先,我们需要安装 Apollo 和 GraphQL 依赖项:
npm install --save apollo-server-express graphql @graphql-tools/schema
Apollo 提供了一个特定于 Express.js 的包,该包将其自身集成到 web 服务器中。还有一个没有 Express.js 的独立版本。Apollo 允许您使用可用的 Express.js 中间件。在某些情况下,您可能需要为不实现 GraphQL 或无法理解 JSON 响应的专有客户端提供非 GraphQL 路由。仍然有一些原因要提供一些回退到 GraphQL。在这些情况下,您可以依赖 Express.js,因为您已经在使用它了。
为服务创建一个单独的文件夹。一个服务可以是 GraphQL 或其他路由:
mkdir src/server/services/
mkdir src/server/services/graphql
在 graphql 文件夹中创建一个 index.js 文件,作为我们 GraphQL 服务的起点。它必须处理初始化的多项任务。让我们逐个过一遍它们,并将它们添加到 index.js 文件中:
-
首先,我们必须导入
apollo-server-express和@graphql-tools/schema包:import { ApolloServer } from 'apollo-server-express'; import { makeExecutableSchema } from '@graphql-tools/schema'; -
接下来,我们必须将 GraphQL 模式与
resolver函数结合。我们必须从单独的文件中导入相应的模式和解析函数。GraphQL 模式是 API 的表示——即客户端可以请求或运行的数据和函数。解析函数是模式的实现。两者都需要匹配。您不能返回不在模式中的字段或运行突变:import Resolvers from './resolvers'; import Schema from './schema'; -
@graphql-tools/schema包中的makeExecutableSchema函数将 GraphQL 模式和解析函数合并,解析我们将要写入的数据。当您定义一个不在模式中的查询或突变时,makeExecutableSchema函数会抛出一个错误。生成的模式由我们的 GraphQL 服务器执行,解析数据或运行我们请求的突变:const executableSchema = makeExecutableSchema({ typeDefs: Schema, resolvers: Resolvers }); -
我们将其作为
schema参数传递给 Apollo 服务器。context属性包含 Express.js 的request对象。在我们的解析函数中,如果我们需要,可以访问请求:const server = new ApolloServer({ schema: executableSchema, context: ({ req }) => req }); -
此
index.js文件导出初始化的服务器对象,该对象处理所有 GraphQL 请求:export default server;
现在我们正在导出 Apollo Server,它需要在其他地方导入。我发现,在服务层有一个 index.js 文件很方便,这样我们只有在添加新服务时才依赖于这个文件。
在 services 文件夹中创建一个 index.js 文件,并输入以下代码:
import graphql from './graphql';
export default {
graphql,
};
上述代码需要从 graphql 文件夹中的 index.js 文件中导入,并将所有服务重新导出到一个大的对象中。如果我们需要,我们还可以在这里定义更多服务。
为了使我们的 GraphQL 服务器对客户端公开可用,我们将 Apollo Server 绑定到 /graphql 路径。
将 index.js 文件导入到 server/index.js 文件中,如下所示:
import services from './services';
services 对象只包含 graphql 的索引。现在,我们必须使用以下代码将 GraphQL 服务器绑定到 Express.js 网络服务器:
const serviceNames = Object.keys(services);
for (let i = 0; i < serviceNames.length; i += 1) {
const name = serviceNames[i];
if (name === 'graphql') {
(async () => {
await services[name].start();
services[name].applyMiddleware({ app });
})();
} else {
app.use('/${name}', services[name]);
}
}
为了方便,我们遍历 services 对象的所有索引,并使用索引作为服务将被绑定的路由的名称。对于 services 对象中的 example 索引,路径将是 /example。对于一个典型的服务,例如 REST 接口,我们依赖于 Express.js 的标准 app.use 方法。
由于 Apollo Server 有其特殊性,当将其绑定到 Express.js 时,我们需要运行由初始化的 Apollo Server 提供的 applyMiddleware 函数,并避免使用 Express.js 的 app.use 函数。Apollo 会自动将自己绑定到 /graphql 路径,因为这是默认选项。如果您希望它从自定义路由响应,也可以包含一个 path 参数。
Apollo Server 要求我们在应用中间件之前运行 start 命令。由于这是一个异步函数,我们将整个代码块包裹在一个包装的 async 函数中,以便我们可以使用 await 语句。
现在还缺少两件事:模式和解析器。一旦我们完成这些,我们将执行一些测试 GraphQL 请求。模式是我们待办事项列表中的下一个任务。
编写您的第一个 GraphQL 模式
让我们从在 graphql 文件夹内创建一个 schema.js 文件开始。您也可以将多个较小的模式缝合成一个较大的模式。这样做会更干净,当您的应用程序、类型和字段增长时,这会更有意义。对于这本书,一个文件就足够了,我们可以将以下代码插入到 schema.js 文件中:
const typeDefinitions = '
type Post {
id: Int
text: String
}
type RootQuery {
posts: [Post]
}
schema {
query: RootQuery
}
';
export default [typeDefinitions];
上述代码代表了一个基本的模式,它至少能够从 第一章,准备您的开发环境,排除用户,提供伪造的帖子数组。
首先,我们必须定义一个新的类型,称为 Post。Post 类型有一个 id 为 Int 和一个 text 值为 String。
对于我们的 GraphQL 服务器,我们需要一个名为RootQuery的类型。RootQuery类型封装了客户端可以运行的所有查询。它可以是从请求所有帖子到所有用户,仅一个用户的帖子等等。你可以将其与你在常见 REST API 中找到的所有GET请求进行比较。路径将是/posts、/users和/users/ID/posts,以表示 GraphQL API 作为 REST API。当使用 GraphQL 时,我们只有一个路由,并且我们以类似 JSON 的对象发送查询。
我们将要执行的第一个查询将返回所有我们拥有的帖子数组。
如果我们查询所有帖子并希望返回每个用户及其对应的帖子,这将是一个子查询,它不会在我们的RootQuery类型中表示,而是在Post类型本身中。你稍后会看到这是如何实现的。
在类似 JSON 的模式末尾,我们将RootQuery添加到schema属性中。此类型是 Apollo Server 的起点。
然后,我们将向模式添加 mutation 键,我们将实现一个RootMutation类型。它将服务于用户可以运行的所有操作。突变与 REST API 的POST、UPDATE、PATCH和DELETE请求相当。
在文件末尾,我们将模式作为一个数组导出。如果我们想的话,我们可以将其他模式推送到这个数组中合并它们。
这里缺少的最后一件事情是我们解析器的实现。
实现 GraphQL 解析器
现在模式已经准备好了,我们需要匹配的解析器函数。
在graphql文件夹中创建一个resolvers.js文件,如下所示:
const resolvers = {
RootQuery: {
posts(root, args, context) {
return [];
},
},
};
export default resolvers;
resolvers对象持有所有类型作为属性。在这里,我们设置了RootQuery,以与我们在模式中相同的方式持有posts查询。resolvers对象必须等于模式,但必须是递归合并的。如果你想查询子字段,例如帖子的用户,你必须通过包含user函数的Post对象扩展resolvers对象,放在RootQuery旁边。
如果我们发送查询所有帖子的请求,posts函数将被执行。在那里,你可以做任何你想做的事情,但你需要返回与模式匹配的东西。所以,如果你有一个posts数组作为RootQuery的响应类型,你不能返回不同的东西,比如只返回一个帖子对象而不是数组。在这种情况下,你会收到一个错误。
此外,GraphQL 检查每个属性的 数据类型。如果id被定义为Int,你不能返回一个常规的 MongoDB id,因为这些 ID 是String类型。GraphQL 也会抛出一个错误。
注意
如果值类型匹配,GraphQL 会为你解析或转换特定的数据类型。例如,一个值为2.1的string可以无问题地解析为Float。另一方面,一个空字符串不能转换为Float,并且会抛出一个错误。直接拥有正确的数据类型会更好,因为这可以节省你转换,并防止出现不希望的问题。
为了证明一切正常工作,我们将通过向我们的服务器执行实际的 GraphQL 请求来继续。我们的 posts 查询将返回一个空数组,这是 GraphQL 的正确响应。我们稍后会回到 resolver 函数。你应该能够重新启动服务器,这样我们就可以发送一个演示请求。
发送 GraphQL 查询
我们可以使用任何 HTTP 客户端来测试这个查询,例如 Postman、Insomnia 或你习惯使用的任何客户端。下一节将介绍 HTTP 客户端。如果你想要自己发送以下查询,那么你可以阅读下一节,然后回到这里。
当你将以下 JSON 作为 POST 请求发送到 http://localhost:8000/graphql 时,你可以测试我们的新函数:
{
"operationName": null,
"query": "{
posts {
id
text
}
}",
"variables": {}
}
operationName 字段不是运行查询所必需的,但它对于日志记录非常有用。
query 对象是我们想要执行的查询的类似 JSON 的表示。在这个例子中,我们运行 RootQuery 帖子并请求每个帖子的 id 和 text 字段。我们不需要指定 RootQuery,因为它是我们 GraphQL API 的最高层。
variables 属性可以存储我们想要通过它来过滤帖子的用户 ID 等参数。如果你想要使用变量,它们也需要通过它们的名称在查询中定义。
对于不习惯使用 Postman 等工具的开发者,还有一个选项可以在单独的浏览器标签页中打开 /graphql 端点。你将看到一个专为轻松发送查询而制作的 GraphQLi 实例。在这里,你可以插入 query 属性的内容,然后点击播放按钮。由于我们设置了 Helmet 来保护我们的应用程序,我们需要在开发中将其停用。否则,GraphQLi 实例将无法工作。只需将完整的 Helmet 初始化用以下花括号中的 if 语句包裹在 server/index.js 文件中:
if(process.env.NODE_ENV === 'production')
这个简短的条件只在开发环境中激活 Helmet。现在,你可以使用 GraphQLi 或任何 HTTP 客户端发送请求。
当与前面的主体结合时,POST 请求的响应应该如下所示:
{
"data": {
"posts": []
}
}
这里,我们收到了预期的空帖子数组。
进一步来说,我们想要以我们客户端中静态编写的假数据作为响应,使其看起来像是来自我们的后端。从上面的 App.js 中复制 initialPosts 数组到 resolvers 对象上方,但将其重命名为 posts。我们可以用这个填充的 posts 数组来响应 GraphQL 请求。
将 GraphQL resolvers 中的 posts 函数内容替换为以下内容:
return posts;
你可以重新运行 POST 请求并接收两个假帖子。响应不包括我们假数据中的用户对象,因此我们必须在我们的模式中的 post 类型上定义一个用户属性来解决这个问题。
在 GraphQL 模式中使用多个类型
让我们创建一个 User 类型并将其与我们的帖子一起使用。首先,将其添加到模式中:
type User {
avatar: String
username: String
}
现在我们有了 User 类型,我们需要在 Post 类型中使用它。按照以下方式将其添加到 Post 类型中:
user: User
user字段允许我们在我们的帖子中有一个子对象,以及帖子的作者信息。
我们用来测试这个功能的扩展查询看起来是这样的:
"query":"{
posts {
id
text
user {
avatar
username
}
}
}"
你不能仅仅指定用户作为查询的属性。相反,你需要提供一个字段的子选择。当你有多个 GraphQL 类型嵌套在一起时,这是必需的。然后,你需要选择结果应包含的字段。
执行更新后的查询会给我们假数据,这些数据我们已经在我们的前端代码中有了;只是posts数组原样。
我们在查询数据方面已经取得了良好的进展,但我们还希望能够添加和更改数据。
编写你的第一个 GraphQL 突变
我们的客户端已经提供的一项服务是暂时向假数据中添加新帖子。我们可以在后端通过使用 GraphQL 突变来实现这一点。
从架构开始,我们需要添加突变,以及输入类型,如下所示:
input PostInput {
text: String!
}
input UserInput {
username: String!
avatar: String!
}
type RootMutation {
addPost (
post: PostInput!
user: UserInput!
): Post
}
GraphQL 输入不外乎是类型。突变可以在请求内部使用它们作为参数。它们可能看起来很奇怪,因为我们的当前输出类型看起来几乎相同。然而,在PostInput上有一个id属性,例如,这是不正确的,因为后端选择 ID,客户端无法提供它。因此,为输入和输出类型保留单独的对象是有意义的。
接收我们两个新必需输入类型PostInput和UserInput的addPost函数是一个新功能。这些函数被称为突变,因为它们会改变应用程序的当前状态。对此突变的响应是一个普通的Post对象。当使用addPost突变创建新帖子时,我们将直接从后端获取创建的帖子作为响应。
架构中的感叹号告诉 GraphQL 该字段是一个必需的参数。
RootMutation类型对应于RootQuery类型,是一个包含所有 GraphQL 突变的对象。
最后一步是启用 Apollo Server 的架构中的突变,通过将RootMutation类型应用到schema对象:
schema {
query: RootQuery
mutation: RootMutation
}
注意
通常,客户端不会在突变中发送用户。这是因为用户在添加帖子之前先进行认证,通过这种方式,我们已经知道哪个用户发起了 Apollo 请求。然而,我们暂时忽略这一点,稍后在第六章中实现认证,使用 Apollo 和 React 进行认证。
现在,需要在我们名为resolvers.js的文件中实现addPost解析器函数。
将以下RootMutation对象添加到resolvers.js中的RootQuery:
RootMutation: {
addPost(root, { post, user }, context) {
const postObject = {
...post,
user,
id: posts.length + 1,
};
posts.push(postObject);
return postObject;
},
},
此解析器从突变参数中提取post和user对象,这些参数作为函数的第二个参数传入。然后,我们构建postObject变量。我们希望通过解构post输入并添加user对象来将我们的posts数组作为属性添加。id字段只是posts数组的长度加一。
现在,postObject变量看起来就像posts数组中的post。我们的实现与前端已经做的相同。我们的addPost函数的返回值是postObject。为了使其工作,您需要将posts数组的初始化从const更改为let。否则,数组将是静态的,不可更改。
您可以通过您喜欢的 HTTP 客户端运行此突变,如下所示:
{
"operationName": null,
"query": "mutation addPost($post : PostInput!,
$user: UserInput!) {
addPost(post : $post, user: $user) {
id
text
user {
username
avatar
}
}
}",
"variables": {
"post": {
"text": "You just added a post."
},
"user": {
"avatar": "/uploads/avatar3.png",
"username": "Fake User"
}
}
}
首先,我们将单词mutation和实际要运行的函数名——在这个例子中是addPost——包括在query属性内的响应字段选择,传递给用于帖子数据的常规数据查询。
其次,我们使用variables属性来发送我们想要插入后端的数据。我们需要将它们作为参数包含在query字符串中。我们可以在operation字符串中定义这两个参数,使用美元符号和期待的数据类型。带有美元符号的变量随后会被映射到我们希望在后端触发的实际操作。
当我们发送这个突变时,请求将包含一个data对象,包括一个addPost字段。addPost字段包含我们随请求发送的帖子。
如果您再次查询帖子,您将看到现在有三个帖子。太好了——它成功了!
就像我们的客户端一样,这只是一个临时的,直到我们重启服务器。我们将在第三章“连接到数据库”中介绍如何在 SQL 数据库中持久化数据。
接下来,我们将介绍您调试后端的各种方法。
后端调试和日志记录
调试有两个非常重要的事情。首先,我们需要为后端实现日志记录,以防我们收到用户的错误,其次,我们需要查看 Postman 来有效地调试 GraphQL API。
那么,让我们开始记录日志。
Node.js 中的日志记录
Node.js 中最受欢迎的日志包叫做winston。按照以下步骤安装和配置winston:
-
使用
npm安装winston:npm install --save winston -
接下来,为后端的所有辅助函数创建一个新的文件夹:
mkdir src/server/helpers -
然后,在新的文件夹中插入一个
logger.js文件,内容如下:import winston from 'winston'; let transports = [ new winston.transports.File({ filename: 'error.log', level: 'error', }), new winston.transports.File({ filename: 'combined.log', level: 'verbose', }), ]; if (process.env.NODE_ENV !== 'production') { transports.push(new winston.transports.Console()); } const logger = winston.createLogger({ level: 'info', format: winston.format.json(), transports, }); export default logger;
此文件可以在我们想要记录日志的任何地方导入。
在前面的代码中,我们为winston定义了标准的transports。传输不过是winston如何将不同的日志类型分开并保存到不同的文件中。
第一个transport生成一个error.log文件,其中只保存真实错误。
第二个传输是一个组合日志,我们将保存所有其他日志消息,例如警告或信息日志。
如果我们在开发环境中运行服务器,我们现在就是这样做的,我们必须添加第三个传输。同时,我们将在服务器开发期间直接将所有消息记录到控制台。
大多数习惯于 JavaScript 开发的人都知道console.log的困难。通过直接使用winston,我们可以在终端中看到所有消息,但我们也无需从console.log中清理代码,只要我们记录的内容有意义即可。
为了测试这一点,我们可以在唯一的变异中尝试winston记录器。
在resolvers.js文件顶部添加以下代码:
import logger from '../../helpers/logger';
现在,我们可以在return语句之前添加以下内容来扩展addPost函数:
logger.log({ level: 'info', message: 'Post was created' });
当您现在发送变异时,您将看到消息被记录到控制台。
此外,如果您查看项目的根目录,您将看到error.log和combined.log文件。combined.log文件应包含来自控制台的操作日志。
现在我们能够记录服务器上的所有操作,我们应该探索 Postman,以便我们可以舒适地发送请求。
使用 Postman 进行调试
Postman是现有最广泛使用的 HTTP 客户端之一。它不仅提供了原始 HTTP 客户端功能,还提供了团队和集合,并允许您同步在 Postman 中保存的所有请求。
您可以通过从www.postman.com/downloads/下载适当的文件来安装 Postman。
注意
许多其他 HTTP 客户端工具对调试您的应用程序很有用。您可以使用您选择的工具。我使用的其他一些优秀客户端包括 Insomnia、SoapUI 和 Stoplight,但还有很多。在我看来,本书我们将使用 Postman,因为它是最受欢迎的。
安装完成后,它应该看起来像这样:
图 2.1 – 安装 Book 集合后的 Postman 屏幕
如您所见,我已在左侧面板中创建了一个名为Book的集合。这个集合包括我们的两个请求:一个请求所有帖子,一个添加新帖子。
例如,以下截图显示了在 Postman 中添加帖子变异的外观:
图 2.2 – Postman 中的添加帖子变异
URL 是localhost,包括预期的端口8000。
请求体看起来与之前我们看到的基本相同。请确保在raw格式旁边选择Content-Type为application/json。
注意
在我的情况下,我需要将查询内联编写,因为 Postman 无法处理 JSON 中的多行文本。如果您的情况不是这样,请忽略它。
由于 Postman 的新版本发布,现在也有选择 GraphQL 而不是 JSON 的选项。如果您这样做,您可以在多行中编写 GraphQL 代码,并在单独的窗口中编写变量。结果应该看起来像这样:
图 2.3 – 选择 GraphQL 的 Postman
如果你添加了一个新的请求,你可以使用 Ctrl + S 快捷键来保存它。你需要选择一个集合和一个名称来保存。使用 Postman(至少在使用 GraphQL API 时)的一个主要缺点是我们只使用 POST。如果能有一种方式来表明我们在做什么那就太好了——例如,一个查询或一个变更。一旦我们实现了它,我们就会学习如何在 Postman 中使用授权。
Postman 还拥有其他一些出色的功能,例如自动化测试、监控和模拟假服务器。
在本书的后面部分,为所有请求配置 Postman 将变得更加复杂。在这种情况下,我喜欢使用 Apollo Client 开发者工具,它们完美地集成到前端并利用 Chrome 开发者工具。Apollo Client 开发者工具的伟大之处在于,它们使用我们在前端代码中配置的 Apollo Client,这意味着它们重用了我们嵌入到前端中的认证。
摘要
在本章中,我们使用 Express.js 设置了我们的 Node.js 服务器,并将 Apollo Server 绑定到响应 GraphQL 端点的请求。我们可以处理查询,返回假数据,并通过 GraphQL 变更来修改数据。
此外,我们可以在我们的 Node.js 服务器中记录每个进程。使用 Postman 调试应用程序会导致经过良好测试的 API,这可以在我们前端后续使用。
在下一章中,我们将学习如何在 SQL 服务器中持久化数据。我们还将实现 GraphQL 类型的模型,并涵盖数据库迁移。我们需要用 Sequelize 通过查询替换我们当前的 resolver 函数。
有很多工作要做,所以请继续阅读以获取更多信息!
第三章:连接到数据库
我们的后端和前端可以使用假数据来通信、创建新帖子,并响应所有帖子的列表。我们列表中的下一步将是使用数据库,如 SQL 服务器,作为数据存储。
我们希望使用 Sequelize 将后端数据持久化到我们的 SQL 数据库。我们的 Apollo 服务器应根据需要使用这些数据来进行查询和突变。为了实现这一点,我们必须为我们的 GraphQL 实体实现数据库模型。
本章将涵盖以下主题:
-
在 GraphQL 中使用数据库
-
在 Node.js 中使用 Sequelize
-
编写数据库模型
-
使用 Sequelize 种植数据
-
使用 Apollo 与 Sequelize
-
使用 Sequelize 执行数据库迁移
技术要求
本章的源代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition/tree/main/Chapter03。
在 GraphQL 中使用数据库
GraphQL 是一种用于发送和接收数据的协议。Apollo 是您可以使用来实现该协议的许多库之一。无论是 GraphQL(在其规范中)还是 Apollo,都不会直接在数据层上工作。您放入响应中的数据来源,以及您随请求发送的数据保存位置,由开发者决定。
这条逻辑表明,数据库和您使用的服务对 Apollo 来说并不重要,只要您响应的数据与 GraphQL 模式相匹配。
在本项目和书中,我们生活在 Node.js 生态系统之中,因此使用 MongoDB 是非常合适的。MongoDB 为 Node.js 提供了一个出色的客户端库,并且使用 JavaScript 作为其与交互和查询的原生语言选择。
MongoDB 这样的数据库系统的通用替代品是一个典型的 MySQL 服务器,它具有经过验证的稳定性和全球使用率。我经常遇到的一个案例是系统和应用程序依赖于较旧的代码库和数据库,需要进行升级。实现这一点的绝佳方法是获取一个 GraphQL API 层的叠加。在这种情况下,GraphQL 服务器接收所有请求,并且逐个替换 GraphQL 服务器所依赖的现有代码库。在这些情况下,GraphQL 的数据库无关性非常有帮助。
在本书中,我们将通过 Sequelize 使用 SQL 来查看现实世界用例中的此功能。为了未来的目的,这还将帮助您处理现有基于 SQL 的系统的问题。
为开发安装 MySQL
MySQL 是在开发职业生涯中起步的绝佳起点。它也非常适合在您的机器上进行本地开发,因为设置简单。
在您的机器上设置 MySQL 的方法取决于您的操作系统。正如我们在 第一章 中提到的,准备您的开发环境,我们假设您正在使用基于 Debian 的系统。为此,您可以使用以下说明。如果您已经为 MySQL 或 Apache 设置了工作环境,这些命令可能不起作用,或者可能根本不需要。
提示
对于其他操作系统,有优秀的预构建软件包。我建议所有 Windows 用户使用 XAMPP,Mac 用户使用 MAMP。这些提供了在 Linux 上手动执行的操作的简单安装过程。它们还实现了 MySQL、Apache 和 PHP,包括 phpMyAdmin。
重要提示
在设置用于公共和生产的真实 SQL 服务器时,不要遵循这些说明。专业的设置包括许多安全功能来保护您免受攻击。此安装仅应在开发环境中,在您的本地机器上使用。
执行以下步骤以启动 MySQL:
-
首先,您应该始终安装系统上可用的所有更新:
sudo apt-get update && sudo apt-get upgrade -y我们希望安装 MySQL 和一个 GUI 来查看我们数据库内部的内容。MySQL 服务器最常用的 GUI 是 phpMyAdmin。为此,您需要安装一个 Web 服务器和 PHP。我们将安装 Apache 作为我们的 Web 服务器。
重要提示
如果在过程中任何时刻收到一个错误,表明找不到该软件包,请确保您的系统是基于 Debian 的。在其他系统上的安装过程可能会有所不同。您可以在互联网上轻松搜索适合您系统的匹配软件包。
-
使用以下命令安装所有必要的依赖项:
sudo apt-get install apache2 mysql-server php php-pear php-mysql -
安装完成后,您需要在 root shell 中运行 MySQL 设置。您将需要输入 root 密码。或者,您可以运行
sudo -i:su - -
现在,您可以执行 MySQL 安装命令;按照提示步骤进行操作:
mysql_secure_installation您可以忽略这些步骤中的大部分以及安全设置,但在被要求输入您的 MySQL 实例的 root 密码时要小心。
-
我们必须为开发创建一个与 root 用户分开的单独用户。我们不鼓励您使用 root 用户。使用 root 用户登录我们的 MySQL 服务器以完成此操作:
mysql -u root -
现在,运行以下 SQL 命令。
PASSWORD string with the password that you want. This is the password that you will use for the database connection in your application, but also when logging into phpMyAdmin. This command creates a user called devuser, with root privileges that are acceptable for local development.NoteIf you are already using MySQL8, the command that you need execute is a little different. Just run the following lines:**CREATE USER 'devuser'@'%' IDENTIFIED BY 'PASSWORD';****GRANT ALL PRIVILEGES ON *.* TO 'devuser'@'%' WITH GRANT OPTION;****FLUSH PRIVILEGES;**The above commands will create a new user with the same permissions on your MySQL server. -
由于我们的 MySQL 服务器已经设置,您可以安装 phpMyAdmin。在执行以下命令时,您将被要求选择 Web 服务器。使用空格键选择
apache2,然后按 Tab 键导航到 ok。当被要求时,选择 phpMyAdmin 的自动设置方法。您不应手动进行此操作。此外,phpMyAdmin 将要求您输入密码。我建议您选择与 root 用户相同的密码:
sudo apt-get install phpmyadmin -
安装完成后,我们需要设置 Apache 以服务 phpMyAdmin。以下
ln命令在 Apache 公共HTML文件夹的根目录中创建了一个符号链接。现在,Apache 将服务 phpMyAdmin:cd /var/www/html/ sudo ln -s /usr/share/phpmyadmin
现在,我们可以在http://localhost/phpmyadmin下访问 phpMyAdmin,并使用新创建的用户登录。这应该看起来如下所示:
图 3.1 – phpMyAdmin
使用这种方式,我们已经为我们的开发环境安装了数据库。
phpMyAdmin 会根据您的环境选择语言,因此它可能与前一个截图显示的略有不同。
在 MySQL 中创建数据库
在我们开始实现后端之前,我们需要添加一个新的数据库,我们可以使用它。
您可以通过命令行或 phpMyAdmin 来做这件事。因为我们刚刚安装了 phpMyAdmin,我们将使用它。
您可以在 phpMyAdmin 的SQL标签页中运行原始 SQL 命令。创建新数据库的相应命令如下:
CREATE DATABASE graphbook_dev CHARACTER SET utf8 COLLATE utf8_general_ci;
否则,您可以按照下一组步骤使用图形方法。在左侧面板中,点击新建按钮。
您将看到一个如下所示的屏幕。它将显示所有数据库,包括它们的 MySQL 服务器的校对:
图 3.2 – phpMyAdmin 数据库
输入一个数据库名称,例如graphbook_dev,然后选择utf8_general_ci校对。完成这些操作后,点击创建。
您将看到一个页面,上面写着数据库中未找到表,这是正确的。在我们实现了数据库模型,如帖子(posts)和用户(users)之后,这将会改变。
在下一章中,我们将开始设置 Sequelize 在 Node.js 中的配置,并将其连接到我们的 SQL 服务器。
将 Sequelize 集成到我们的 Node.js 堆栈中
我们刚刚设置了一个 MySQL 数据库,我们想在我们的 Node.js 后端中使用它。有许多库可以连接和查询您的 MySQL 数据库。在这本书中,我们将使用 Sequelize。
替代对象关系映射器(ORM)
替代方案包括 Waterline ORM 和 js-data,它们提供了与 Sequelize 相同的功能。这些方案的好处是,它们不仅提供 SQL 方言,还提供了 MongoDB、Redis 等数据库适配器。因此,如果您需要替代方案,请查看它们。
Sequelize 是 Node.js 的 ORM。它支持 PostgreSQL、MySQL、SQLite 和 MSSQL 标准。
通过npm在您的项目中安装 Sequelize。我们还将安装第二个包,称为mysql2:
npm install --save sequelize mysql2
mysql2包允许 Sequelize 与我们的 MySQL 服务器通信。
Sequelize 是围绕不同数据库系统的各种库的包装器。它提供了直观模型使用的出色功能,以及创建和更新数据库结构以及插入开发数据的函数。
通常,您会在开始数据库连接或模型之前运行npx sequelize-cli init,但我更喜欢一种更定制的方法。从我的角度来看,这要干净一些。这也是我们为什么在额外的文件中设置数据库连接而不是依赖模板代码的原因。
传统设置 Sequelize
如果您想查看通常是如何做的,可以查看 Sequelize 文档中的官方教程。我们采取的方法和教程中的方法差异不大,但总是看到另一种做事的方式是好的。文档可以在sequelize.org/master/manual/migrations.html找到。
让我们从在后台设置 Sequelize 开始。
使用 Sequelize 连接到数据库
第一步是初始化 Sequelize 到我们的 MySQL 服务器的连接。为此,我们将创建一个新的文件夹和文件,如下所示:
mkdir src/server/database
touch src/server/database/index.js
在index.js数据库内部,我们将使用 Sequelize 与我们的数据库建立连接。内部,Sequelize 依赖于mysql2包,但我们自己并不使用它,这非常方便:
import Sequelize from 'sequelize';
const sequelize = new Sequelize('graphbook_dev', 'devuser', 'PASSWORD', {
host: 'localhost',
dialect: 'mysql',
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000,
},
});
export default sequelize;
如您所见,我们从node_modules加载 Sequelize,然后创建其实例。以下属性对 Sequelize 很重要:
-
我们将数据库名称作为第一个参数传递,这是我们刚刚创建的。
-
第二个和第三个参数是我们
devuser的凭证。用您为数据库输入的用户名和密码替换它们。devuser有权访问我们 MySQL 服务器中的所有数据库,这使得开发变得容易得多。 -
第四个参数是一个通用选项对象,可以包含更多属性。前面的对象是一个示例配置。
-
我们 MySQL 数据库的
host选项是我们的本地机器别名,localhost。如果不是这种情况,您也可以指定 MySQL 服务器的 IP 或 URL。 -
当然,
dialect是mysql。 -
使用
pool选项,您告诉 Sequelize 每个数据库连接的配置。前面的配置允许最小连接数为零,这意味着 Sequelize 不应该维护一个连接,而应该在其需要时创建一个新的连接。最大连接数为五个。此选项还与您的数据库系统拥有的副本集数量相关。pool选项中的idle字段指定了连接在关闭并被从活动连接池中移除之前可以多久未被使用。当尝试建立到我们的 MySQL 服务器的新的连接时,如果连接被中止,
acquire选项定义了超时时间。在无法创建连接的情况下,此选项有助于防止您的服务器冻结。
执行前面的代码将实例化 Sequelize 并成功创建到我们的 MySQL 服务器的连接。进一步来说,我们需要为我们的应用程序可以运行的每个环境(从开发到生产)处理多个数据库。你将在下一节中看到这一点。
使用 Sequelize 配置文件
我们之前使用 Sequelize 进行数据库连接的设置是可行的,但它并不是为后续部署而设计的。最佳选择是有一个独立的配置文件,该文件根据服务器运行的环境进行读取和使用。
为此,在database文件夹旁边(称为config)的单独文件夹中创建一个新的index.js文件:
mkdir src/server/config
touch src/server/config/index.js
如果你遵循了创建 MySQL 数据库的说明,你的样本配置应该如下代码所示。我们在这里所做的唯一一件事是将我们的当前配置复制到一个新的对象中,该对象以development或production环境为索引:
module.exports = {
"development": {
"username": "devuser",
"password": "PASSWORD",
"database": "graphbook_dev",
"host": "localhost",
"dialect": "mysql",
"pool": {
"max": 5,
"min": 0,
"acquire": 30000,
"idle": 10000
}
},
"production": {
"host": process.env.host,
"username": process.env.username,
"password": process.env.password,
"database": process.env.database,
"logging": false,
"dialect": "mysql",
"pool": {
"max": 5,
"min": 0,
"acquire": 30000,
"idle": 10000
}
}
}
Sequelize 默认期望在这个文件夹中有一个config.json文件,但这个设置将允许我们在后面的章节中采用更定制的方法。development环境直接存储数据库的凭证,而production配置使用环境变量来填充它们。
我们可以移除之前硬编码的配置,并将我们的database/index.js文件的内容替换为要求使用configFile。
它应该看起来如下:
import Sequelize from 'sequelize';
import configFile from '../config/';
const env = process.env.NODE_ENV || 'development';
const config = configFile[env];
const sequelize = new Sequelize(config.database,
config.username, config.password, config);
const db = {
sequelize,
};
export default db;
在前面的代码中,我们使用NODE_ENV环境变量来获取服务器正在运行的环境。我们读取config文件并将正确的配置传递给 Sequelize 实例。环境变量将允许我们在本书的稍后部分切换到新的环境,例如production。
然后,Sequelize 实例被导出以供我们整个应用程序使用。我们使用一个特殊的db对象来做这件事。你将在稍后看到我们为什么要这样做。
接下来,你将学习如何为应用程序将拥有的所有实体生成和编写模型和迁移。
编写数据库模型
通过 Sequelize 创建到我们的 MySQL 服务器的连接后,我们希望使用它。然而,我们的数据库缺少一个我们可以查询或操作的表或结构。创建这些是我们接下来需要做的事情。
目前,我们有两个 GraphQL 实体:User和Post。
Sequelize 允许我们为我们的每个 GraphQL 实体创建数据库模式。当我们在数据库中插入或更新行时,该模式会被验证。我们已经在schema.js文件中为 GraphQL 编写了一个模式,该文件由 Apollo Server 使用,但我们需要为我们的数据库创建第二个模式。字段类型以及字段本身可能在数据库和 GraphQL 模式之间有所不同。
GraphQL 模式可能比我们的数据库模型有更多的字段,或者相反。也许你不想通过 API 导出数据库中的所有数据,或者当你请求数据时,可能想动态地为你的 GraphQL API 生成数据。
让我们为我们的帖子创建第一个模型。在database文件夹旁边创建两个新文件夹(一个叫models,另一个叫migrations):
mkdir src/server/models
mkdir src/server/migrations
将每个模型分别放在单独的文件中,比所有模型放在一个大文件中要干净得多。
你的第一个数据库模型
我们将使用 Sequelize CLI 生成我们的第一个数据库模型。使用以下命令全局安装它:
npm install -g sequelize-cli
这让你能够在终端中运行sequelize命令。
Sequelize CLI 允许我们自动生成模型。这可以通过运行以下命令来完成:
sequelize model:generate --models-path src/server/models --migrations-path src/server/migrations --name Post --attributes text:text
Sequelize 期望我们在运行sequelize init的默认文件夹中运行命令。由于我们的文件结构不同,因为我们有两个src/server层,所以我们手动指定路径,即前两个参数;即--models-path和--migrations-path。
--name参数为我们的模型提供了一个名称,该名称可以用于使用。--attributes选项指定模型应包含的字段。
小贴士
如果你正在自定义你的设置,你可能想了解 CLI 提供的其他选项。你可以通过添加--help选项轻松查看每个命令的说明:sequelize model:generate --help。
此命令会在你的models文件夹中创建一个post.js模型文件,并在你的migrations文件夹中创建一个名为XXXXXXXXXXXXXX-create-post.js的数据库迁移文件。X图标表示你使用 CLI 生成文件时的日期和时间戳。你将在下一节中看到迁移是如何工作的。
以下是我们为我们创建的模型文件:
'use strict';
const {
Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Post extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The 'models/index' file will call this method
automatically.
*/
static associate(models) {
// define association here
}
};
Post.init({
text: DataTypes.TEXT
}, {
sequelize,
modelName: 'Post',
});
return Post;
};
在这里,我们正在创建Post类,并从 Sequelize 扩展Model类。然后,我们使用 Sequelize 的init函数创建数据库模型:
-
第一个参数是模型属性。
-
第二个参数是一个
option对象,其中包含了sequelize连接实例和模型名称。模型定制
Sequelize 为我们提供了许多其他选项来定制我们的数据库模型。如果你想查找哪些选项可用,可以在
sequelize.org/master/manual/model-basics.html找到它们。
一个post对象具有id、text和user属性。用户将是一个单独的模型,如 GraphQL 模式所示。因此,我们只需要将id和text配置为帖子的列。
id是我们数据库中唯一标识数据记录的键。在运行model:generate命令时,我们不指定它,因为 MySQL 会自动生成。
text 列只是一个允许我们写入长帖子的 MySQL TEXT 字段。作为替代,还有其他 MySQL 字段类型,如 MEDIUMTEXT、LONGTEXT 和 BLOB,可以保存更多字符。对于我们的用例,一个常规的 TEXT 列应该就足够了。
Sequelize CLI 创建了一个模型文件,导出一个函数,执行后返回实际的数据库模型。你很快就会看到为什么这是一种初始化我们模型的好方法。
让我们看看 CLI 也创建的迁移文件。
你的第一个数据库迁移
到目前为止,MySQL 还不知道我们在其中保存帖子计划。我们的数据库表和列需要被创建,这就是为什么需要创建迁移文件。
迁移文件具有多个优点,如下所示:
-
迁移使我们能够通过常规版本控制系统(如 Git 或 SVN)跟踪数据库更改。我们数据库结构的每次更改都应该包含在迁移文件中。
-
迁移文件使我们能够编写更新,这些更新可以自动应用于我们应用程序的新版本中的数据库更改。
我们的第一个迁移文件创建了一个 Posts 表,并添加了所有必需的列,如下所示:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('Posts', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
text: {
type: Sequelize.TEXT
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('Posts');
}
};
按照惯例,迁移中的模型名称是复数形式,但在模型定义中是单数形式。我们的表名也是复数形式。Sequelize 提供了更改此设置的选择。
迁移有两个属性,如下所示:
-
up属性说明了在运行迁移时应执行的内容。 -
down属性说明了在撤销迁移时执行的内容。
如我们之前提到的,创建了 id 和 text 列,以及两个额外的 datetime 列,用于保存创建和更新时间。
id 字段已将 autoIncrement 和 primaryKey 设置为 true。id 将为表中每个帖子向上计数,从一几乎无限大。这个 id 为我们唯一标识帖子。通过将 allowNull 设置为 false,禁用了此功能,这样我们就可以插入一个空字段值的行。
要执行此迁移,我们将再次使用 Sequelize CLI,如下所示:
sequelize db:migrate --migrations-path src/server/migrations --config src/server/config/index.js
在 phpMyAdmin 中查看。在这里,你会找到名为 posts 的新表。表的结构应该如下所示:
![Figure 3.3 – Posts table structure]
![img/Figure_3.03_B17337.jpg]
Figure 3.3 – Posts table structure
所有列都按照我们的期望创建。
此外,还创建了两个额外的字段 – createdAt 和 updatedAt。这两个字段告诉我们行是何时被创建或更新的。这些字段是由 Sequelize 自动创建的。如果你不希望这样,可以将模型中的 timestamps 属性设置为 false。
每次使用 Sequelize 和其迁移功能时,你将有一个名为 sequelizemeta 的额外表。该表的内容应该如下所示:
![Figure 3.4 – Migrations table]
![img/Figure_3.04_B17337.jpg]
![Figure 3.4 – Migrations table]
Sequelize 保存了所有已执行的迁移。如果我们开发或新发布周期中添加了更多字段,我们可以编写一个迁移来为我们运行所有表更改语句作为更新。Sequelize 跳过了保存在元表中的所有迁移。
一个主要步骤是将我们的模型绑定到 Sequelize。这个过程可以通过运行 sequelize init 自动化,但理解它将教会我们比依赖预制的样板命令多得多的东西。
使用 Sequelize 导入模型
我们希望一次性导入所有数据库模型到一个中央文件。然后,我们的数据库连接生成器将依赖于这个文件。
在 models 文件夹中创建一个 index.js 文件,并使用以下代码:
import Sequelize from 'sequelize';
if (process.env.NODE_ENV === 'development') {
require('babel-plugin-require-context-hook/register')()
}
export default (sequelize) => {
let db = {};
const context = require.context('.', true,
/^\.\/(?!index\.js).*\.js$/, 'sync')
context.keys().map(context).forEach(module => {
const model = module(sequelize, Sequelize);
db[model.name] = model;
});
Object.keys(db).forEach((modelName) => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
return db;
};
当运行 sequelize init 时,前面的逻辑也会生成,但这样,数据库连接是在一个单独的文件中设置的,而不是在加载模型时。通常,当使用 Sequelize 样板代码时,这会在一个文件中完成。此外,我们还引入了一些 webpack 特定的配置。
总结前面代码中发生的事情,我们搜索与当前文件相同的文件夹中所有以 .js 结尾的文件,并使用 require.context 语句加载它们。在开发中,我们必须执行 babel-plugin-require-context-hook/register 钩子来在顶部加载 require.context 函数。此包必须使用 npm 安装,以下命令:
npm install --save-dev babel-plugin-require-context-hook
我们需要在开发服务器的开始时加载 Babel 插件,因此,打开 package.json 文件并编辑 server 脚本,如下所示:
nodemon --exec babel-node --plugins require-context-hook --watch src/server src/server/index.js
当插件加载并运行 require('babel-plugin-require-context-hook/register')() 函数时,require.context 方法对我们可用。确保您将 NODE_ENV 变量设置为 development;否则,这不会工作。
在生产中,require.context 函数包含在 webpack 生成的包中。
加载的模型文件导出一个具有以下两个参数的函数:
-
在创建与我们的数据库连接后,我们的 Sequelize 实例
-
sequelize类本身,包括它提供的各种数据类型,如整数或文本
运行导出的函数导入实际的 Sequelize 模型。一旦所有模型都已导入,我们就遍历它们并检查它们是否有一个名为 associate 的函数。如果是这样,我们执行 associate 函数,并通过这种方式在多个模型之间建立关系。目前,我们还没有设置关联,但这一点将在本章的后面改变。
现在,我们想要使用我们的模型。回到 index.js 数据库文件,并通过我们刚刚创建的聚合 index.js 文件导入所有模型:
import models from '../models';
在文件末尾导出 db 对象之前,我们需要运行 models 包装器来读取所有模型 .js 文件。我们按照以下方式传递我们的 Sequelize 实例作为参数:
const db = {
models: models(sequelize),
sequelize,
};
前述命令中的新数据库对象有 sequelize 和 models 属性。在 models 下,你可以找到 Post 模型以及我们稍后将要添加的每个新模型。
数据库的 index.js 文件已经准备好,现在可以使用了。你应该只导入这个文件一次,因为当你创建多个 Sequelize 实例时,这可能会变得混乱。池功能将无法正常工作,我们最终会拥有比我们之前指定的五个最大连接数更多的连接。
我们必须在根服务器文件夹的 index.js 文件中创建全局数据库实例。添加以下代码:
import db from './database';
我们需要导入 database 文件夹以及该文件夹内的 index.js 文件。加载该文件将实例化 Sequelize 对象,包括所有数据库模型。
从现在开始,我们想要通过我们在 第二章 中实现的 GraphQL API 查询数据库中的某些数据,使用 Express.js 设置 GraphQL。
使用 Sequelize 种植数据
我们应该用我们的假数据填充空的 Posts 表。为了完成这个任务,我们将使用 Sequelize 的数据种植功能来向数据库中种植数据。
创建一个名为 seeders 的新文件夹:
mkdir src/server/seeders
现在,我们可以运行下一个 Sequelize CLI 命令来生成一个模板文件:
sequelize seed:generate --name fake-posts --seeders-path src/server/seeders
种子非常适合将测试数据导入数据库进行开发。我们的 seed 文件有时间和 fake-posts 这两个词,应该看起来如下:
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
/*
Add altering commands here.
Return a promise to correctly handle asynchronicity.
Example:
return queryInterface.bulkInsert('Person', [{
name: 'John Doe',
isBetaMember: false
}], {});
*/
},
down: (queryInterface, Sequelize) => {
/*
Add reverting commands here.
Return a promise to correctly handle asynchronicity.
Example:
return queryInterface.bulkDelete('Person', null, {});
*/
}
};
如前述代码片段所示,这里没有做任何事情。它只是一个空的模板文件。我们需要编辑这个文件来创建我们已经在后端拥有的假帖子。这个文件看起来就像我们上一节中的迁移。将文件内容替换为以下代码:
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('Posts', [{
text: 'Lorem ipsum 1',
createdAt: new Date(),
updatedAt: new Date(),
},
{
text: 'Lorem ipsum 2',
createdAt: new Date(),
updatedAt: new Date(),
}],
{});
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('Posts', null, {});
}
};
在 up 迁移中,我们通过 queryInterface 和它的 bulkInsert 命令批量插入两个帖子。为此,我们将传递一个帖子数组,不包括 id 属性和关联的用户。这个 id 将自动创建,用户稍后保存在单独的表中。Sequelize 的 queryInterface 是 Sequelize 用来与所有数据库通信的通用接口。
在我们的种子文件中,我们需要添加 createdAt 和 updatedAt 字段,因为 Sequelize 不会为 MySQL 中的时间戳列设置默认值。实际上,Sequelize 会自己处理这些字段的默认值,但在种植数据时不会。如果你不提供这些值,种子将失败,因为 createdAt 和 updatedAt 不允许为 NULL。
down 迁移会批量删除表中的所有行,因为这显然是 up 迁移的逆操作。
使用以下命令执行 seeders 文件夹中的所有种子:
sequelize db:seed:all --seeders-path src/server/seeders --config src/server/config/index.js
Sequelize 不会检查或保存是否已经运行了种子,因为我们使用前述命令进行操作。这意味着如果你想的话,可以多次运行种子。
下面的截图显示了填充后的 Posts 表:
图 3.5 – 带有种子数据的帖子表
示例帖子现在在我们的数据库中。
在下一节中,我们将介绍如何使用 Sequelize 与我们的 Apollo Server,以及如何添加用户与其帖子之间的关系。
使用 Sequelize 与 GraphQL
数据库对象在根目录下的 index.js 文件启动服务器时初始化。我们从全局位置将其传递到依赖数据库的位置。这样,我们不需要重复导入数据库文件,而有一个单独的实例为我们处理所有数据库查询。
我们想要通过 GraphQL API 公布的服务需要访问我们的 MySQL 数据库。第一步是在我们的 GraphQL API 中实现帖子。它应该响应我们刚刚插入的数据库中的假帖子。
全球数据库实例
要将数据库传递到我们的 GraphQL 解析器中,我们必须在服务器 index.js 文件中创建一个新的对象:
import db from './database';
const utils = {
db,
};
在这里,我们在 database 文件夹的 import 语句下直接创建了一个 utils 对象。
utils 对象包含了我们的服务可能需要访问的所有实用工具。这可以是从第三方工具到我们的 MySQL 服务器,或任何其他数据库,如前述代码所示。
替换导入 services 文件夹的行,如下所示:
import servicesLoader from './services';
const services = servicesLoader(utils);
前述代码可能看起来有些奇怪,但我们在这里执行的是 import 语句的结果函数,并将 utils 对象作为参数传递。由于 import 语法不允许在一行中完成,我们必须在两行中执行此操作;因此,我们必须首先将 services 文件夹中导出的函数导入到一个单独的变量中。
到目前为止,import 语句的返回值是一个简单的对象。我们必须将其更改以匹配我们的要求。
要做到这一点,请转到 services 文件夹中的 index.js 文件,并按照以下方式更改文件内容:
import graphql from './graphql';
export default utils => ({
graphql: graphql(utils),
});
我们将前述 services 对象包围在一个函数中,然后导出该函数。该函数只接受一个参数,即我们的 utils 对象。
然后,这个对象被传递给一个新的函数,称为 graphql。我们将要使用的每个服务都必须是一个接受此参数的函数。这允许我们将任何我们想要的属性传递到我们应用程序的最深处。
当执行前述导出函数时,结果是之前使用的常规 services 对象。我们只是将其包裹在一个函数中,以传递 utils 对象。
我们正在执行的 graphql 导入需要接受 utils 对象。
打开 graphql 文件夹中的 index.js 文件,并将除顶部的 require 语句之外的所有内容替换为以下代码:
export default (utils) => {
const server = new ApolloServer({
typeDefs: Schema,
resolvers: Resolvers.call(utils),
context: ({ req }) => req
});
return server;
};
再次,我们用接受 utils 对象的函数包围了一切。所有这些的目的都是为了在我们的 GraphQL 解析器中访问数据库,这些解析器被传递给 ApolloServer。
为了实现这一点,我们使用了 JavaScript 的 Resolvers.call 函数。这个函数允许我们设置导出的 Resolvers 函数的所有者对象。我们在这里所说的就是,Resolvers 的作用域是 utils 对象。
因此,在 Resolvers 函数中,现在访问 this 给我们的是 utils 对象作为作用域。目前,Resolvers 只是一个简单的对象,但因为我们使用了 call 方法,所以我们还必须从 resolvers.js 文件中返回一个函数。
在此文件中,将 resolvers 对象包裹在一个函数中,并从函数内部返回 resolvers 对象:
export default function resolver() {
...
return resolvers;
}
我们不能使用之前使用的箭头语法。ES6 箭头语法会自动获取作用域,但我们要让 call 函数在这里接管。
另一种方法是将 utils 对象作为参数传递。我认为我们选择的方法稍微干净一些,但你可以按你喜欢的方式处理。
执行第一次数据库查询
现在,我们可以开始使用数据库了。将以下代码添加到 export default function resolver 语句的顶部:
const { db } = this;
const { Post } = db.models;
如前所述,this 关键字是当前方法的拥有者,它包含 db 对象。我们从上一节中构建的 db 对象中提取了数据库模型。
模型的优点在于你不需要直接对数据库编写原始查询。通过创建模型,你已经告诉 Sequelize 可以使用哪些字段和表。在这个阶段,你可以使用 Sequelize 的方法在你的解析器中运行数据库查询。
我们可以通过 Sequelize 模型查询所有帖子,而不是返回之前的假帖子。将 RootQuery 中的 posts 属性替换为以下代码:
posts(root, args, context) {
return Post.findAll({order: [['createdAt', 'DESC']]});
},
在前面的代码中,我们搜索并选择了我们数据库中所有的帖子。我们使用了 Sequelize 的 findAll 方法,并返回了它的结果。返回值将是一个 JavaScript promise,一旦数据库收集完数据,它就会自动解决。
一个典型的新闻源,如 Twitter 或 Facebook,会根据创建日期对帖子进行排序。这样,最新的帖子在顶部,最旧的帖子在底部。Sequelize 期望我们将作为 findAll 方法的第一个参数传递的排序属性的参数为一个数组数组。结果将按创建日期排序。
重要提示
Sequelize 提供了许多其他方法。您可以查询单个实体,计数它们,找到它们,如果未找到则创建它们,等等。您可以在sequelize.org/master/manual/model-querying-basics.html查找 Sequelize 提供的方法。
您可以使用 npm run server 启动服务器,并再次从 第二章,使用 Express.js 设置 GraphQL,执行 GraphQL 帖子查询。输出将如下所示:
{
"data": {
"posts": [{
"id": 1,
"text": "Lorem ipsum 1",
"user": null
},
{
"id": 2,
"text": "Lorem ipsum 2",
"user": null
}]
}
}
id 和 text 字段看起来很好,但 user 对象是 null。这是因为我们没有定义用户模型或声明用户与帖子模型之间的关系。我们将在下一节中更改这一点。
Sequelize 中的一对一关系
我们需要将每个帖子与一个用户关联起来,以填补我们在 GraphQL 响应中创建的空白。帖子必须有一个作者。没有关联用户的帖子是没有意义的。
首先,我们将生成一个 User 模型和迁移。我们将再次使用 Sequelize CLI,如下所示:
sequelize model:generate --models-path src/server/models --migrations-path src/server/migrations --name User --attributes avatar:string,username:string
迁移文件创建了 Users 表并添加了 avatar 和 username 列。数据行看起来像我们假数据中的帖子,但它还包括一个自动生成的 ID 和两个时间戳,就像您之前看到的那样。
由于我们只创建了模型和迁移文件,用户与其特定帖子之间的关系仍然缺失。我们仍然需要添加帖子与用户之间的关系。这将在下一节中介绍。
每个帖子都需要一个额外的字段,称为 userId。此列作为外键,用于引用一个唯一的用户。然后,我们可以连接与每个帖子相关的用户。
注意
MySQL 为不习惯使用外键约束的人提供了很好的文档。如果您是其中之一,您应该阅读有关此主题的内容,请参阅dev.mysql.com/doc/refman/8.0/en/create-table-foreign-keys.html。
使用迁移更新表结构
我们必须编写第三个迁移,将 userId 列添加到我们的 Post 表中,并将其包括在我们的数据库 Post 模型中。
使用 Sequelize CLI 生成模板迁移文件非常容易:
sequelize migration:create --migrations-path src/server/migrations --name add-userId-to-post
您可以直接替换生成的迁移文件的内容,如下所示:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn('Posts',
'userId',
{
type: Sequelize.INTEGER,
}),
queryInterface.addConstraint('Posts', {
fields: ['userId'],
type: 'foreign key',
name: 'fk_user_id',
references: {
table: 'Users',
field: 'id',
},
onDelete: 'cascade',
onUpdate: 'cascade',
}),
]);
},
down: async (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn('Posts', 'userId'),
]);
}
};
此迁移稍微复杂一些,我将分步骤解释:
-
在
up迁移中,我们使用queryInterface将userId列添加到Posts表中。 -
接下来,我们使用
addConstraint函数添加外键约束。此约束表示用户和帖子实体之间的关系。这种关系存储在Post表的userId列中。 -
当我在没有使用
Promise.all运行迁移时遇到了一些问题,Promise.all确保数组中的所有承诺都得到解决。只返回数组并没有运行addColumn和addConstraint方法。 -
前面的
addConstraint函数接收一个foreign key字符串作为type,这意味着数据类型与Users表中相应的列相同。我们希望给我们的约束起一个自定义名称fk_user_id,以便以后识别。 -
然后,我们正在指定
userId列的references字段。Sequelize 需要一个表,即Users表,以及我们的外键关联的字段,即User表的id列。这些都是建立有效数据库关系所需的一切。 -
此外,我们还将
onUpdate和onDelete约束更改为cascade。这意味着,当用户被删除或其用户 ID 更新时,这种变化会反映在用户的帖子中。删除用户会导致删除该用户的所有帖子,而更新用户的 ID 会更新所有用户帖子的 ID。我们不需要在应用程序代码中处理所有这些,这将是不高效的。注意
在 Sequelize 文档中关于这个主题有更多内容。如果你想了解更多,可以在
sequelize.org/master/manual/query-interface.html找到更多信息。
重新运行迁移以查看发生了什么变化:
sequelize db:migrate --migrations-path src/server/migrations --config src/server/config/index.js
通过 Sequelize 运行迁移的好处是它会遍历 migrations 文件夹中所有可能的迁移。它排除了那些已经保存在 SequelizeMeta 表中的迁移,然后按时间顺序运行剩余的迁移。Sequelize 可以这样做,因为每个迁移的文件名中都包含了时间戳。
运行迁移后,应该会有一个 Users 表,并且 userId 列已经被添加到 Posts 表中。
在 phpMyAdmin 中查看 Posts 表的关系视图。你可以在 结构 视图中找到它,通过点击 关系视图:
图 3.6 – MySQL 外键
如你所见,我们有了外键约束。正确地取了名字,以及级联选项。
如果你运行迁移时收到错误,你可以轻松地撤销它们,如下所示:
sequelize db:migrate:undo --migrations-path src/server/migrations --config src/server/config/index.js
这个命令会撤销最近的迁移。始终要意识到你在做什么。如果你不确定一切是否正常工作,请保留备份。
你也可以一次性撤销所有迁移,或者只撤销到特定的迁移,这样你可以回到特定的日期和时间戳:
sequelize db:migrate:undo:all --to XXXXXXXXXXXXXX-create-posts.js --migrations-path src/server/migrations --config src/server/config/index.js
省略 --to 参数以撤销所有迁移。
这样,我们就建立了数据库关系,但 Sequelize 也必须知道这个关系。你将在下一节中学习如何做到这一点。
Sequelize 中的模型关联
现在我们已经通过外键配置了关系,它需要在我们的 Sequelize 模型内部进行配置。
返回到 Post 模型文件,并用以下代码替换 associate 函数:
static associate(models) {
this.belongsTo(models.User);
}
associate 函数在聚合的 index.js 文件中被评估,该文件中导入了所有模型文件。
我们在这里使用的是 belongsTo 函数,它告诉 Sequelize 每个帖子恰好属于一个用户。Sequelize 在 Post 模型上为我们提供了一个新函数,称为 getUser,用于检索关联的用户。这种命名是按照惯例进行的,正如你所看到的。Sequelize 会自动完成所有这些操作。
不要忘记将 userId 作为可查询字段添加到 Post 模型本身,如下所示:
userId: DataTypes.INTEGER,
User 模型也需要实现反向关联。将以下代码添加到 User 模型文件中:
static associate(models) {
this.hasMany(models.Post);
}
hasMany 函数与 belongsTo 函数正好相反。每个用户都可以在 Post 表中关联多个帖子。这可以是零个或多个帖子。
你可以将新的数据布局与前面的布局进行比较。到目前为止,我们有一个包含帖子和用户的对象大数组。现在,我们将每个对象拆分为两个表。两个表通过外键连接。每次运行 GraphQL 查询以获取所有帖子及其作者时,都需要这样做。
因此,我们必须扩展我们当前的 resolvers.js 文件。将 Post 属性添加到 resolvers 对象中,如下所示:
Post: {
user(post, args, context) {
return post.getUser();
},
},
RootQuery 和 RootMutation 是我们迄今为止拥有的两个主要属性。RootQuery 是所有 GraphQL 查询的起点。
在旧的演示帖子中,我们能够直接返回一个有效且完整的响应,因为我们需要的所有东西都在那里。现在,需要执行第二个查询或 JOIN 来收集完整响应所需的所有必要数据。
Post 实体被引入到我们的 resolvers 中,我们可以为 GraphQL 模式中的每个属性定义函数。响应中只缺少用户;其余都在那里。这就是为什么我们向解析器中添加了 user 函数。
函数的第一个参数是我们返回到 RootQuery 解析器中的 post 模型实例。
然后,我们使用 Sequelize 给我们的 getUser 函数。执行 getUser 函数将运行正确的 MySQL SELECT 查询,从 Users 表中获取正确的用户。它不会运行实际的 MySQL JOIN;它只在一个单独的 MySQL 命令中查询用户。稍后,在 GraphQL 中的聊天和消息 部分,你将了解另一种直接运行 JOIN 的方法,这更有效率。
然而,如果你通过 GraphQL API 查询所有帖子,用户仍然会是 null。我们还没有向数据库中添加任何用户,所以让我们接下来插入它们。
外键数据初始化
添加用户的挑战是我们已经向数据库中引入了外键约束。你可以按照以下说明来学习如何使其工作:
-
首先,我们必须使用 Sequelize CLI 生成一个空的
seeders文件,如下所示:sequelize seed:generate --name fake-users --seeders-path src/server/seeders -
填写以下代码以插入假用户:
'use strict'; module.exports = { up: async (queryInterface, Sequelize) => { return queryInterface.bulkInsert('Users', [{ avatar: '/uploads/avatar1.png', username: 'TestUser', createdAt: new Date(), updatedAt: new Date(), }, { avatar: '/uploads/avatar2.png', username: 'TestUser2', createdAt: new Date(), updatedAt: new Date(), }], {}); }, down: async (queryInterface, Sequelize) => { return queryInterface.bulkDelete('Users', null, {}); } };上述代码看起来像是帖子
seeders文件,但相反,我们现在正在使用正确的字段插入用户。每次插入用户时,我们的 MySQL 服务器都会为每个用户分配一个自动递增的 ID。 -
我们必须维护我们在数据库中配置的关系。调整
posts种子文件以反映这一点,并替换up迁移,以便为每个帖子插入正确的用户 ID:up: (queryInterface, Sequelize) => { // Get all existing users return queryInterface.sequelize.query( 'SELECT id from Users;', ).then((users) => { const usersRows = users[0]; return queryInterface.bulkInsert('Posts', [{ text: 'Lorem ipsum 1', userId: usersRows[0].id, createdAt: new Date(), updatedAt: new Date(), }, { text: 'Lorem ipsum 2', userId: usersRows[1].id, createdAt: new Date(), updatedAt: new Date(), }], {}); }); },
在这里,我们使用原始 MySQL 查询来获取所有用户及其 ID,以便我们可以与我们的帖子一起插入它们。这确保了我们有一个有效的、MySQL 允许我们插入的外键关系。
我们目前存储在表中的帖子没有接收 userId,我们不想为这些帖子编写单独的迁移或种子来修复它们。
这里有两种选择。你可以手动通过 phpMyAdmin 和 SQL 语句截断表,或者你可以使用 Sequelize CLI。使用 CLI 更容易,但无论如何结果都是一样的。以下命令将撤销所有种子:
sequelize db:seed:undo:all --seeders-path src/server/seeders --config src/server/config/index.js
在撤销种子时,表不会被截断,因此 autoIncrement 索引不会重置为 1;相反,它保持在当前索引。多次撤销种子会提高用户或帖子的 ID,这会阻止种子工作。我们通过使用在插入帖子之前检索当前用户 ID 的原始 MySQL 查询来解决这个问题。
在再次运行种子之前,我们遇到了一个问题:我们在 post 种子文件之后创建了 users 种子文件。这意味着帖子是在用户存在之前插入的,因为文件的时序。通常这不会是问题,但因为我们已经引入了外键约束,所以我们不能在底层用户不存在于我们的数据库中时插入带有 userId 的帖子。MySQL 禁止这样做。只需调整假用户种子文件的时序,使其早于帖子种子文件的时序,或者反之亦然。
在重命名文件后,再次运行所有种子,使用以下命令:
sequelize db:seed:all --seeders-path src/server/seeders --config src/server/config/index.js
如果你查看你的数据库,你应该会看到一个填充的 Posts 表,包括 userId。Users 表应如下所示:
图 3.7 – 用户表
现在,你可以重新运行 GraphQL 查询,你应该会看到用户和他们的帖子之间存在一个正常的工作关联,因为 user 字段已被填充。
到目前为止,我们已经取得了很多成就,因为我们可以通过匹配其模式通过 GraphQL API 从我们的数据库中提供数据。
注意
有一些方法可以自动化这个过程,通过额外的 npm 包。有一个包可以自动为您从数据库模型创建 GraphQL 模式。一如既往,当您不依赖于预配置的包时,您将更加灵活。您可以在www.npmjs.com/package/graphql-tools-sequelize找到这个包。
使用 Sequelize 突变数据
通过 GraphQL API 从我们的数据库请求数据是有效的。现在是困难的部分:将新帖子添加到Posts表中。
在我们开始之前,我们必须从我们的resolvers.js文件中导出函数顶部的db对象中提取新的数据库模型:
const { Post, User } = db.models;
目前,我们还没有身份验证来识别创建帖子的用户。我们将伪造这一步,直到身份验证被实现 第六章,使用 Apollo 和 React 进行身份验证。
我们必须编辑 GraphQL 解析器以添加新帖子。将旧的addPost函数替换为新的,如下代码片段所示:
addPost(root, { post }, context) {
return User.findAll().then((users) => {
const usersRow = users[0];
return Post.create({
...post,
}).then((newPost) => {
return Promise.all([
newPost.setUser(usersRow.id),
]).then(() => {
logger.log({
level: 'info',
message: 'Post was created',
});
return newPost;
});
});
});
},
总是如此,前面的突变返回一个承诺。这个承诺在最深层的查询成功执行后解决。执行顺序如下:
-
我们通过
User.findAll方法从数据库中检索所有用户。 -
我们使用 Sequelize 的
create函数将帖子插入到我们的数据库中。我们传递的唯一属性是从原始请求中的post对象,它只包含帖子的文本。MySQL 自动生成帖子的id属性。注意
Sequelize 还提供了一个
build函数,它可以为我们初始化模型实例。在这种情况下,我们必须运行save方法来手动插入模型。create函数为我们一次性完成所有这些。 -
帖子已创建,但
userId尚未设置。您也可以直接将用户 ID 添加到
Post.create函数中。问题在于,即使这在数据库中有所反映,我们也不会建立模型关联。如果我们不显式使用setUser在模型实例上返回创建的帖子模型,我们就无法使用getUser函数,该函数用于返回突变响应的用户。因此,为了解决这个问题,我们必须运行
create函数,解决承诺,然后单独运行setUser。作为setUser的参数,我们静态地取users数组中的第一个用户的 ID。我们通过使用包围在
Promise.all中的数组来解决setUser函数的承诺。这允许我们稍后添加更多的 Sequelize 方法。例如,您也可以为每个帖子添加一个类别。 -
一旦我们正确设置了
userId,返回的值是新建的帖子模型实例。
一切都已就绪。为了测试我们的 API,我们将再次使用 Postman。我们需要更改 addPost 请求。之前添加的 userInput 现在不再需要,因为后端静态地选择数据库中的第一个用户。你可以发送以下请求体:
{
"operationName": null,
"query": "mutation addPost($post : PostInput!) {
addPost(post : $post) {
id text user { username avatar }}}",
"variables":{
"post": {
"text": "You just added a post."
}
}
}
你的 GraphQL 模式必须反映这一变化,因此也要从那里删除 userInput:
addPost (
post: PostInput!
): Post
现在运行 addPost GraphQL 演变现在会将帖子添加到 Posts 表中,如下截图所示:
图 3.8 – 帖子已插入数据库表
由于我们不再使用演示 posts 数组,你可以将其从 resolvers.js 文件中删除。
这样,我们就重新构建了前一章的示例,但我们使用的是后端数据库。为了扩展我们的应用程序,我们将添加两个新的实体,分别称为 Chat 和 Message。
多对多关系
Facebook 为用户提供各种互动方式。目前,我们只有请求和插入帖子的机会。正如在 Facebook 上一样,我们想要与我们的朋友和同事进行聊天。我们将引入两个新的实体来覆盖这一点。
第一个实体称为 Chat,第二个实体称为 Message。
在开始实施之前,我们需要制定一个详细的计划,说明这些实体将使我们能够做什么。
一个用户可以有多个聊天,一个聊天也可以属于多个用户。这种关系使我们能够与多个用户进行群聊,以及仅限于两个用户之间的私密聊天。一条消息属于一个用户,但每条消息也属于一个聊天。
模型和迁移
在将其转换为实际代码时,我们必须生成 Chat 模型。这里的问题是用户和聊天之间存在多对多关系。在 MySQL 中,这种关系需要一个表来分别存储所有实体之间的关系。
这些表称为 user_chats。用户的 ID 和聊天的 ID 在这个表中相互关联。如果一个用户参与多个聊天,他们将在表中有多行,具有不同的聊天 ID。
聊天模型
让我们先创建 Chat 模型和迁移。聊天本身不存储任何数据;我们用它来分组特定用户的消息:
sequelize model:generate --models-path src/server/models --migrations-path src/server/migrations --name Chat --attributes firstName:string,lastName:string,email:string
生成我们的关联表的迁移,如下所示:
sequelize migration:create --migrations-path src/server/migrations --name create-user-chats
调整由 Sequelize CLI 生成的 users_chats 迁移。我们指定用户和聊天 ID 作为我们关系的属性。迁移内部的自引用会自动为我们创建外键约束。迁移文件应如下所示:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
return queryInterface.createTable('users_chats', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
userId: {
type: Sequelize.INTEGER,
references: {
model: 'Users',
key: 'id'
},
onDelete: 'cascade',
onUpdate: 'cascade',
},
chatId: {
type: Sequelize.INTEGER,
references: {
model: 'Chats',
key: 'id'
},
onDelete: 'cascade',
onUpdate: 'cascade',
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: async (queryInterface, Sequelize) => {
return queryInterface.dropTable('users_chats');
}
};
对于关联表不需要单独的模型文件,因为我们可以在需要关联的模型中依赖这个表。id 列可以省略,因为行只能由用户和聊天 ID 来识别。
通过新的关系表在User模型中将User模型与Chat模型关联起来,如下所示:
this.belongsToMany(models.Chat, { through: 'users_chats' });
对于Chat模型也做同样的操作,如下所示:
this.belongsToMany(models.User, { through: 'users_chats' });
through属性告诉 Sequelize 这两个模型通过users_chats表相关联。通常,当你不使用 Sequelize 并且尝试使用原始 SQL 选择所有合并的用户和聊天时,你需要手动维护这种关联并自己连接三个表。Sequelize 的查询和关联能力非常复杂,所以这一切都为你做好了。
重新运行迁移以使更改生效:
sequelize db:migrate --migrations-path src/server/migrations --config src/server/config/index.js
以下截图显示了你的数据库现在应该看起来是什么样子:
![Figure 3.9 – Database structure]
![Figure 3.09_B17337.jpg]
![Figure 3.9 – Database structure]
你应该在users_chats表的关系视图中看到两个外键约束。命名是自动完成的:
![Figure 3.10 – Foreign keys for the users_chats table]
![Figure 3.10_B17337.jpg]
图 3.10 – users_chats表的外键
这个设置是难点。接下来是消息实体,这是一个简单的一对一关系。一条消息属于一个用户和一个聊天。
消息模型
一条消息就像一个帖子,只不过它只能在聊天中读取,不对所有人公开。
使用 CLI 生成模型和迁移文件,如下所示:
sequelize model:generate --models-path src/server/models --migrations-path src/server/migrations --name Message --attributes text:string,userId:integer,chatId:integer
通过替换以下属性,将缺失的引用添加到创建的迁移文件中:
userId: {
type: Sequelize.INTEGER,
references: {
model: 'Users',
key: 'id'
},
onDelete: 'SET NULL',
onUpdate: 'cascade',
},
chatId: {
type: Sequelize.INTEGER,
references: {
model: 'Chats',
key: 'id'
},
onDelete: 'cascade',
onUpdate: 'cascade',
},
现在,我们可以再次运行迁移来创建Messages表,使用sequelize db:migrate终端命令。
这些引用也适用于我们的模型文件,在那里我们需要使用 Sequelize 的belongsTo函数来获取所有那些方便的模型方法供我们的解析器使用。将Message模型的associate函数替换为以下代码:
static associate(models) {
this.belongsTo(models.User);
this.belongsTo(models.Chat);
}
在前面的代码中,我们定义了每条消息都与恰好一个用户和一个聊天相关联。
另一方面,我们还必须将Chat模型与消息关联起来。将以下代码添加到Chat模型的associate函数中:
this.hasMany(models.Message);
下一步是调整我们的 GraphQL API 以提供聊天和消息。
GraphQL 中的聊天和消息
到目前为止,我们已经引入了一些带有消息和聊天的新的实体。让我们将这些包括到我们的 Apollo 模式中。在以下代码中,你可以看到我们 GraphQL 模式中更改的实体、字段和参数的摘录:
type User {
id: Int
avatar: String
username: String
}
type Post {
id: Int
text: String
user: User
}
type Message {
id: Int
text: String
chat: Chat
user: User
}
type Chat {
id: Int
messages: [Message]
users: [User]
}
type RootQuery {
posts: [Post]
chats: [Chat]
}
看看我们 GraphQL 模式的以下简短变更日志:
-
User类型由于我们的数据库而获得了id字段。 -
Message类型完全是新的。它有一个文本字段,就像典型的消息一样,还有用户和聊天字段,这些字段是从数据库模型中引用的表中请求的。 -
Chat类型也是新的。一个聊天包含一个消息列表,这些消息作为数组返回。这些可以通过聊天 ID 进行查询,该 ID 保存在消息表中。此外,一个聊天有一个未指定的用户数量。用户和聊天之间的关系保存在我们单独的连接表中。这里有趣的是,我们的模式对此表一无所知;它只是用于我们内部适当保存数据在我们的 MySQL 服务器上。 -
我还添加了一个新的
RootQuery,称为chats。此查询返回所有用户的聊天。
这些因素也应该在我们的解析器中实现。我们的解析器应该如下所示:
Message: {
user(message, args, context) {
return message.getUser();
},
chat(message, args, context) {
return message.getChat();
},
},
Chat: {
messages(chat, args, context) {
return chat.getMessages({ order: [['id', 'ASC']] });
},
users(chat, args, context) {
return chat.getUsers();
},
},
RootQuery: {
posts(root, args, context) {
return Post.findAll({order: [['createdAt', 'DESC']]});
},
chats(root, args, context) {
return User.findAll().then((users) => {
if (!users.length) {
return [];
}
const usersRow = users[0];
return Chat.findAll({
include: [{
model: User,
required: true,
through: { where: { userId: usersRow.id } },
},
{
model: Message,
}],
});
});
},
},
让我们逐个查看这些更改:
-
我们在我们的解析器中添加了
Message属性。 -
我们在
resolvers对象中添加了Chat属性。在那里,我们运行getMessages和getUsers函数,以检索所有关联的数据。所有消息都是按 ID 升序排序的(例如,以在聊天窗口底部显示最新消息)。 -
我添加了一个新的
RootQuery,称为chats,以返回所有字段,如我们的模式所示:a) 在我们得到有效的身份验证之前,我们将静态使用第一个用户来查询所有聊天。
b) 我们正在使用 Sequelize 的
findAll方法并连接任何返回的聊天中的用户。为此,我们在findAll方法中的User模型上使用 Sequelize 的include属性。它运行一个 MySQLJOIN,而不是第二个SELECT查询。c) 将
include语句设置为required会默认运行一个INNER JOIN,而不是LEFT OUTER JOIN。任何不匹配through属性中条件的聊天将被排除。在我们的例子中,条件是用户 ID 必须匹配。d) 最后,我们以相同的方式连接每个聊天中所有可用的消息,没有任何条件。
我们必须在这里使用新的模型。我们不应该忘记在resolver函数内部从db.models对象中提取它们。它必须如下所示:
const { Post, User, Chat, Message } = db.models;
你可以发送这个 GraphQL 请求来测试更改:
{
"operationName":null,
"query": "{ chats { id users { id } messages { id text
user { id username } } } }",
"variables":{}
}
响应应该给我们一个空的chats数组,如下所示:
{
"data": {
"chats": []
}
}
这个空数组被返回,因为我们数据库中没有聊天或消息。你将在下一节中学习如何用数据填充它。
种植多对多数据
测试我们的实现需要数据在我们的数据库中。我们有三个新的表,因此我们将创建三个新的种子文件来获取一些测试数据来工作。
让我们从聊天开始,如下所示:
sequelize seed:generate --name fake-chats --seeders-path src/server/seeders
现在,用以下代码替换新的种子文件。运行以下代码会在我们的数据库中创建一个聊天。我们不需要超过两个时间戳,因为聊天 ID 是自动生成的:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('Chats', [{
createdAt: new Date(),
updatedAt: new Date(),
}],
{});
},
down: async (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('Chats', null, {});
}
};
接下来,我们必须插入两个用户和新的聊天之间的关系。我们可以通过在users_chats表中创建两个条目来实现,其中引用它们。现在,生成以下模板种子文件:
sequelize seed:generate --name fake-chats-users-relations --seeders-path src/server/seeders
我们的种子应该看起来与之前的类似,如下所示:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const usersAndChats = Promise.all([
queryInterface.sequelize.query(
'SELECT id from Users;',
),
queryInterface.sequelize.query(
'SELECT id from Chats;',
),
]);
return usersAndChats.then((rows) => {
const users = rows[0][0];
const chats = rows[1][0];
return queryInterface.bulkInsert('users_chats', [{
userId: users[0].id,
chatId: chats[0].id,
createdAt: new Date(),
updatedAt: new Date(),
},
{
userId: users[1].id,
chatId: chats[0].id,
createdAt: new Date(),
updatedAt: new Date(),
}],
{});
});
},
down: async (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('users_chats', null, {});
}
};
在 up 迁移中,我们使用 Promise.all 解决所有用户和聊天。这确保了当承诺解决时,所有聊天和用户同时可用。为了测试聊天功能,我们选择数据库返回的第一个聊天和前两个用户。我们取他们的 ID 并将它们保存到我们的 users_chats 表中。这两个用户应该能够通过这个聊天互相交谈。
最后一个没有任何数据的表是 Messages 表。按照以下方式生成种子文件:
sequelize seed:generate --name fake-messages --seeders-path src/server/seeders
再次,按照以下方式替换生成的样板代码:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const usersAndChats = Promise.all([
queryInterface.sequelize.query(
'SELECT id from Users;',
),
queryInterface.sequelize.query(
'SELECT id from Chats;',
),
]);
return usersAndChats.then((rows) => {
const users = rows[0][0];
const chats = rows[1][0];
return queryInterface.bulkInsert('Messages', [{
userId: users[0].id,
chatId: chats[0].id,
text: 'This is a test message.',
createdAt: new Date(),
updatedAt: new Date(),
},
{
userId: users[1].id,
chatId: chats[0].id,
text: 'This is a second test message.',
createdAt: new Date(),
updatedAt: new Date(),
}],
{});
});
},
down: async (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('Messages', null, {});
}
};
现在,所有种子文件都应该准备好了。在运行种子之前清空所有表是有意义的,这样你就可以使用干净的数据。我喜欢不时地删除数据库中的所有表,并重新运行所有迁移和种子来从零开始测试它们。无论你是否这样做,你至少应该能够运行新的种子。
尝试再次运行 GraphQL 的 chats 查询。它应该看起来如下所示:
{
"data": {
"chats": [{
"id": 1,
"users": [
{
"id": 1
},
{
"id": 2
}
],
"messages": [
{
"id": 1,
"text": "This is a test message.",
"user": {
"id": 1,
"username": "Test User"
}
},
{
"id": 2,
"text": "This is a second test message.",
"user": {
"id": 2,
"username": "Test User 2"
}
}
]}
]
}
}
太好了!现在,我们可以请求用户参与的所有聊天,并获取所有引用的用户及其消息。
现在,我们也想为单个聊天做同样的事情。按照以下步骤操作:
-
添加一个接受
chatId参数的RootQuery聊天:chat(root, { chatId }, context) { return Chat.findByPk(chatId, { include: [{ model: User, required: true, }, { model: Message, }], }); },在这种实现中,我们遇到的问题是所有用户都可以向我们的 Apollo 服务器发送查询,并作为回报,获取完整的聊天历史,即使他们没有被引用在聊天中。我们只有在稍后实现身份验证后才能解决这个问题,如第六章中所述,使用 Apollo 和 React 进行身份验证。
-
将新的查询添加到 GraphQL 模式中的
RootQuery下:chat(chatId: Int): Chat -
按照以下方式发送 GraphQL 请求以测试实现:
{ "operationName":null, "query": "query($chatId: Int!){ chat(chatId: $chatId) { id users { id } messages { id text user { id username } } } }", "variables":{ "chatId": 1 } }
在这里,我们发送这个查询,包括 chatId 作为参数。要传递参数,你必须在查询中定义它及其 GraphQL 数据类型。然后,你可以在你正在执行的特定 GraphQL 查询中设置它,即 chat 查询。最后,你必须将参数的值插入到 GraphQL 请求的 variables 字段中。
你可能还记得上次的响应。新的响应将看起来与 chats 查询的结果非常相似,但我们将只有一个 chat 对象,而不是聊天数组。
我们缺少一个主要功能:发送新消息或创建新的聊天。我们将在下一节创建相应的模式及其解析器。
创建新的聊天
新用户希望与他们的朋友聊天,因此创建一个新的聊天是必不可少的。
最好的方法是接受用户 ID 的列表,这样我们也可以允许群聊。按照以下方式操作:
-
在
resolvers.js文件中添加addChat函数到RootMutation,如下所示:addChat(root, { chat }, context) { return Chat.create().then((newChat) => { return Promise.all([ newChat.setUsers(chat.users), ]).then(() => { logger.log({ level: 'info', message: 'Message was created', }); return newChat; }); }); },Sequelize 为聊天模型实例添加了
setUsers函数。这是由于在聊天模型中使用belongsToMany方法建立关联而添加的。在那里,我们可以直接提供一个用户 ID 数组,这些用户 ID 应与新的聊天相关联,通过users_chats表。 -
修改模式以便运行 GraphQL 变异体。我们必须添加新的输入类型和变异体,如下所示:
input ChatInput { users: [Int] } type RootMutation { addPost ( post: PostInput! ): Post addChat ( chat: ChatInput! ): Chat } -
测试新的 GraphQL
addChat变异体,将以下内容作为请求体:{ "operationName":null, "query": "mutation addChat($chat: ChatInput!) { addChat(chat: $chat) { id users { id } }}", "variables":{ "chat": { "users": [1, 2] } } }
你可以通过检查 chat 对象中返回的用户来验证一切是否正常工作。
创建新消息
我们可以将 addPost 变异体作为基础并对其进行扩展。结果接受一个 chatId 并使用我们数据库中的第一个用户。稍后,认证将成为用户 ID 的来源:
-
将
addMessage函数添加到resolvers.js文件中的RootMutation,如下所示:addMessage(root, { message }, context) { return User.findAll().then((users) => { const usersRow = users[0]; return Message.create({ ...message, }).then((newMessage) => { return Promise.all([ newMessage.setUser(usersRow.id), newMessage.setChat(message.chatId), ]).then(() => { logger.log({ level: 'info', message: 'Message was created', }); return newMessage; }); }); }); }, -
然后,将新的变异体添加到你的 GraphQL 模式。我们还有一个新的消息输入类型:
input MessageInput { text: String! chatId: Int! } type RootMutation { addPost ( post: PostInput! ): Post addChat ( chat: ChatInput! ): Chat addMessage ( message: MessageInput! ): Message } -
你可以像发送
addPost请求一样发送请求:{ "operationName":null, "query": "mutation addMessage($message : MessageInput!) { addMessage(message : $message) { id text }}", "variables":{ "message": { "text": "You just added a message.", "chatId": 1 } } }
现在,一切都已经设置好了。客户端现在可以请求所有帖子、聊天和消息。此外,用户可以创建新的帖子、创建新的聊天室,并发送聊天消息。
摘要
本章的目标是创建一个具有数据库作为存储的工作后端,我们做得相当不错。我们可以添加更多实体,并使用 Sequelize 进行迁移和初始化。当涉及到进入生产环境时,迁移我们的数据库更改对我们来说不会是问题。
在本章中,我们还介绍了 Sequelize 在使用其模型时为我们自动执行的操作,以及它与我们的 Apollo 服务器协同工作的出色表现。
在下一章中,我们将重点介绍如何使用 Apollo React 客户端库与我们的后端以及其背后的数据库。