如何使用ExpressJS和Typescript构建GraphQL APIs
GraphQL是一种API规范,类似于REST和SOAP规范。在GraphQL中,所有的请求都是通过一个单一的URL端点访问相同的HTTP方法。
在这篇文章中,你将学习如何使用Express.js构建一个GraphQL API。此外,你还将学习如何用Mongoose作为包装器将你的API连接到MongoDB这样的数据库系统。最后,我们将研究如何将代码库转换为Typescript。

现在你已经看到了GraphQL的作用,你可能会想,为什么会有这么多关于它的噪音?为什么我们不能坚持使用REST或SOAP APIs?
GraphQL如何比REST或SOAP更好?
下面的原因并不意味着REST或SOAP规范已经过时了。相反,它们只是强调了GraphQL的优势领域。
只用一个端点访问API
假设你有一个图书集。
客户端将使用相同的URL来访问书籍集合中的所有书籍、关于一本书的作者信息,以及用一个端点访问更多的书籍。
这使得用一个请求获得多种资源成为可能。这很酷,对吗?
GraphQL根据查询来获取数据
假设我们想在一个标准的REST API中获取一个用户创建的所有书籍。我们可以通过访问api/v1/users/books/ ,来获取一个图书数组。
但是,在我们只需要每个书的书title 的情况下,API给我们的东西比我们需要的多。
我们不希望开始请求我们已经拥有的东西。相反,我们应该请求我们没有的东西,比如书的_id 。
但是通过GraphQL,我们可以查询所有的书,并且只获得每本书的title 。因此,我们可以做一些像下面这样的事情,服务器将返回一个只有title 字段的图书数组。
{
books {
title
}
}
关于GraphQL的更多信息
GraphQL查询
GraphQL查询是用来在服务器上执行 "读取 "操作的。你通常会用GET 请求来进行这些操作。

GraphQL突变
GraphQL突变通常用于在服务器上执行 "写 "操作,我们通常会在REST架构中使用POST 、PUT 、DELETE 。

GraphQL订阅
如果你熟悉WebSockets,那么你会喜欢GraphQL订阅。GraphQL订阅是用来创建实时事件和连接的。

GraphQL模式
模式用于定义GraphQL API期望的数据种类以及它反馈给客户端的数据种类。
它也可以作为使用我们API的人的文档。因此,我们甚至可能不需要再为我们的API写文档。
这也有助于数据验证,因为GraphQL解析了请求,如果客户端的数据与服务器的期望值不一致,就会返回一个错误。

构建GraphQL API
现在我们知道GraphQL是什么了,让我们讨论一下如何使用这个规范来构建一个简单的API。
我假设你对Express.js有一个基本的了解。如果你没有,你可以看看这篇文章。
开始--安装Deps和设置项目
首先,我们将创建一个图书API,用于创建、更新、删除、获取所有和获取一本图书。
我们将不得不创建我们的项目并进行设置。
让我们把这个API命名为bookrr 。创建一个新的npm 项目并将其命名为bookrr 。你的项目结构应该看起来像我们下面的样子。

接下来,将start 脚本添加到你的package.json 文件中。
"start": "node server.js"
最后,我们需要安装我们的依赖项。
npm i express express-graphql dotenv graphql
我们需要express来运行服务器和管理路由,需要express-graphql来创建模式和路由的处理程序。
GraphQL是我们用来建立模式的,dotenv是用来访问环境变量的。
创建模板代码
让我们开始编写代码。
在你的server.js 文件中添加以下应用设置。
const express = require('express');
const { graphqlHTTP } = require('express-graphql');
const { schema, resolver } = require('./api/schema');
const envs = require('./envs');
const app = express();
app.use(express.json());
app.use(
envs.graphqlPath,
graphqlHTTP((request, response, graphQLParams) => ({
schema,
rootValue: resolver,
graphiql: true,
context: {
request,
response,
},
}))
);
app.listen(envs.port, () => {
console.log(`Server is running at http://localhost:${envs.port} ${envs.graphqlPath}`);
});
前四行是我们为这个文件导入所有的依赖关系的地方。
- 我们从导入
express开始,然后从express-graphql导入graphqlHTTP对象,它涉及到使处理程序成为GraphQL处理程序。 - 它接收了一个带有三个参数的回调函数:
request,response, 和graphQLParams,并返回一个带有API的schema的对象。 root是API根据查询或突变返回的东西,graphiql,它告诉GraphQL使我们的API有一个网络客户端,我们可以在浏览器上测试它,最后是context对象。
建议创建一个envs.js 文件,封装我们将在应用程序中使用的所有环境变量。
现在,我们已经添加了PORT 和GRAPHQL_PATH 。
envs.js 文件将看起来像这样。
const { config } = require( 'dotenv');
config();
module.exports = {
port: process.env.PORT || 3000,
graphqlPath: process.env.GRAPHQL_PATH || '/graphql',
}
Lines 9 - 20 是大部分魔法发生的地方。
我们已经为GraphQL代码创建了一个路由,其中graphqlHTTP 为该路由创建了一个处理器。然而,如果我们现在运行这个应用程序,我们会得到错误,因为我们没有定义schema 和root 。
API模式
在根文件夹下创建一个名为schema 的文件夹,其中包含以下文件:schema.js,mutation.js,query.js, 和index.js 。
types.js 这是我们将为响应和输入编写应用程序模式的地方。在 ,我们写出突变的解析器, 导入这些文件并从 导出 。mutation.js index.js types.js schema
在你的schema.js ,添加以下内容。
const { buildSchema } = require('graphql');
const schema = buildSchema(`
type Query {
books(limit: Int): [Book]
book(id: ID!): Book
}
type Mutation {
addBook(title: String!, author: String!, description: String!): BookResponse
updateBook(id: ID!, title: String, author: String, description: String): BookResponse
deleteBook(id: ID!): BookResponse
}
type Book {
id: ID!
title: String!
author: String!
description: String!
}
type Books {
books: [Book]
}
type BookResponse {
data: Book
error: String
ok: Boolean
}
`);
module.exports = schema;

如果你把鼠标悬停在buildSchema ,你应该看到我们上面的内容。
唯一需要的参数是一个字符串,它是我们应用程序的模式。
但是,如果我们有一个广泛的应用程序,有许多突变、查询和类型呢?那么,使用这个就不是一个好主意了。
在这种情况下,你总是可以使用apollo graphql,这已经超出了本文的范围。这里,模式包含Query 、Mutation ,以及一些其他类型,如Book 和Books 。
Query 包含books 和book ,表示所有的书和单一的书。另外,我们为书籍查询添加了一个可选的参数。 limit (一个数字),它限制了输出的范围。最后,book 查询包含ID ,它搜索所请求的书。
Mutation 包含addBook,updateBook, 和deleteBook 的声明及其参数。它们都有相同的响应类型,Book ,因为我们处理的是一本书。
Book 定义了单一书籍的结构,[Book] 将书籍定义为一个Book 的数组。
请注意,感叹号意味着该字段是必须的。
所有用于突变的输入都是必须的。现在我们已经设置了所有的模式,是时候编写解析器了。
GraphQL 查询解析器
在你的query.js 文件中,添加以下内容。
const booksData = require('./data')
const query = {
books: async ({limit}, context) => {
return limit ? booksData.slice(0, limit) : booksData;
},
book: async ({id}, context) => {
return booksData.find(book => book.id === id);
}
};
module.exports = query;
在query.js 文件中,我们导出一个包含两个函数的对象,books 和book ,这些函数是我们在模式中的Query 内定义的。每个函数都有两个参数。
根据模式,第一个是我们取消结构的参数。第二个参数是上下文对象,它包含我们请求的细节。
请记住,在我们的graphQLHttp 函数中,我们返回了context 和response 作为我们的上下文。
另外,由于我们是在用假数据工作,我们创建了一个data.js ,在那里我们将存储与书籍有关的细节。data.js 文件应该看起来像这样。
在与数据库整合后,我们将不会使用这个文件。
module.exports = [
{
id: "1",
title: "Building Data-Intensive Applications",
description: "The big ideas behind reliable, scalable and maintainable systems",
author: "Martin Kleppmann"
},
{
id: "2",
title: "Docker In Action",
description: "Docker in action teaches you everything you need to know in docker",
author: "Jeff KleNickoloffppmann"
},
{
id: "3",
title: "The Art of Unit Testing",
description: "The Art of Unit Testing teaches you everything you need to know in unit testing",
author: "Roy Osherove"
},
{
id: "4",
title: "Site Reliability Engineering",
description: "How Google runs production systems",
author: "Betsy Beyer"
}
]
GraphQL变异解析器
我们还需要创建我们的突变。在你的mutation.js 文件中,添加以下内容。
let books = require('./data')
const mutation = {
addBook: async ({ title, author, description }, context) => {
const book = { id: `${books.length+1}`, title, author, description }
books.push(book)
return {
data: book,
ok: true,
error: ''
};
},
updateBook: async ({ id, title, author, description }, context) => {
const book = books.find(book => book.id === id);
if (!book) {
return {
data: null,
ok: false,
error: 'Book not found'
};
}
if (author) book.author = author
if (title) book.title = title
if (description) book.description = description
books = books.map(b => b.id === id ? book : b)
return {
data: book,
ok: true,
error: ''
};
},
deleteBook: async ({ id }, context) => {
const book = books.find(book => book.id === id)
if (!book) {
return {
data: null,
ok: false,
error: 'Book not found'
};
}
books = books.filter(book => book.id !== id)
return {
data: book,
ok: true,
error: ''
};
}
};
module.exports = mutation
突变与查询类似。我们不是进行读操作,而是根据需要进行写操作。我们不只是返回'book'对象,而是指定我们将返回三个键。data,ok, 和error 在模式中。
在运行我们的应用程序之前,需要做的最后一件事是向我们的schema/index.js 添加一些数据。
在你的schema/index.js 文件中添加以下内容。
const schema = require('./schema.js');
const query = require('./query.js');
const mutation = require('./mutation.js');
const resolvers = {
...query, ...mutation,
};
module.exports.resolver = resolvers;
module.exports.schema = schema;
这里,我们从schema 文件中创建一个模式对象,从query 和mutation 文件中创建一个resolver 对象。
server.js 文件中我们的graphQLHttp 中的rootValue 对象希望有一个包含我们模式的所有解析器的单一对象。
目前,我们把它们放在两个不同的文件中,query 和mutation ,所以我们需要把它们分散(合并)到一个对象中,如上图所示。
现在,我们拥有运行我们的应用程序所需的一切。
用npm start 来运行你的应用程序。
在打开网址http://localhost:3000/graphql ,你应该看到以下内容。

如果你点击Docs ,你可以查看Query 和Mutation 的文档。
现在,让我们通过获取所有的书来测试我们的API。

正如你所看到的,我们没有包括响应数据的任何描述,所以它不是我们响应的一部分。
让我们查询一本书,然后我们会同时进行两次查询。

接下来,让我们测试一下突变的情况。
我们创建了一本新书,并从上面的图片中更新了一本现有的书。
如果书不存在,我们会得到一个 "没有找到书 "的错误。我们也可以看到,我们能够同时创建多个突变。
但是,我们唯一的限制是,我们不能同时创建一个查询和突变。
最后,让我们试着删除一本书。

如果我们传递无效的数据,请求将不会被处理,因为GraphQL在处理请求之前会验证它。
将数据库集成到API中
本节涉及到将MongoDB和Mongoose添加到我们的应用程序中。
在根文件夹下创建一个名为db 的文件夹,并创建以下文件:books.js,index.js,dbUtils.js, 和connect.js 。
在你的books.js 文件中,添加以下内容。
const { Schema, model } = require('mongoose');
const bookSchema = new Schema({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
author: {
type: String,
required: true,
},
});
module.exports = new model('Books', bookSchema);
在这里,我们创建了图书模型的模式并将其导出。
在你的connect.js 文件中,添加以下内容。
const mongoose = require('mongoose')
const envs = require('../envs')
mongoose.connect(envs.dbUrl, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
module.exports = mongoose.connection
我们使用mongoose.connect 函数来连接到数据库。
同时,我们要在我们的envs.js 文件中添加另一个包含数据库连接URL的值,如图所示。
const { config } = require('dotenv');
config();
module.exports = {
port: process.env.PORT || 3000,
graphqlPath: process.env.GRAPHQL_PATH || '/graphql',
dbUrl: process.env.DB_URL || 'mongodb://localhost:27017/graphql-starter',
}
最后,我们将db 对象和db 文件夹中的另外两个对象导出到db/index.js 。
const db = require('./connect');
const BookModel = require('./books');
const books = require('./dbUtils');
module.exports = {
db,
BookModel,
books
};
为了使用server.js 文件中的这个连接,我们使用db.once 和db.on 方法导入db 对象。
添加以下内容,就在你的server.js 文件的最后一次导入之后。
const { db } = require('./db');
db.once('open', () => {
console.log('Connected to MongoDB');
});
db.on('error', (err) => {
console.log('Error connecting to MongoDB', err);
process.exit(1);
});
我们需要添加辅助函数,执行所有的查询操作。这样做可以使查询和变异的逻辑更加整齐。
这将在db 目录下的dbUtils.js 文件中完成。例如,在这个文件内添加以下内容。
const BookModel = require('./books');
const getAllBooks = async (limit) => {
return await BookModel.find({}).limit(limit);
}
const getBookById = async (id) => {
return await BookModel.findById(id);
}
const createBook = async ({ title, description, author }) => {
return await BookModel.create({ title, description, author });
}
const updateBook = async (id, { title, description, author }) => {
const set = {};
if (title) set.title = title;
if (description) set.description = description;
if (author) set.author = author;
return await BookModel.findByIdAndUpdate(id, set);
}
const deleteBook = async (id) => {
return await BookModel.findByIdAndDelete(id);
}
module.exports = {
getAllBooks,
getBookById,
createBook,
updateBook,
deleteBook
}
这个文件很简单--我们有函数来获取所有的书,通过id获取书,创建一个新的书,更新一个书,以及删除一个书。
现在我们需要做的就是使用这些函数。
query.js 文件现在应该是这样的。
// const booksData = require('./data')
const { books} = require('../db/')
const query = {
books: async ({limit}, context) => {
// return limit ? booksData.slice(0, limit) : booksData;
return await books.getAllBooks(limit)
},
book: async ({id}, context) => {
// return booksData.find(book => book.id === id);
return await books.getBookById(id)
}
};
module.exports = query
同样地,更新mutation.js 文件,如图所示。
// let books = require('./data')
const { books } = require('../db')
const mutation = {
addBook: async ({ title, author, description }, context) => {
try {
const book = await books.createBook({ title, author, description })
// const book = { id: `${books.length+1}`, title, author, description }
// books.push(book)
return {
data: book,
ok: true,
error: ''
};
} catch (error) {
return {
data: null,
ok: false,
error: error.message
};
}
},
updateBook: async ({ id, title, author, description }, context) => {
// const book = books.find(book => book.id === id);
// if (!book) {
// return {
// data: null,
// ok: false,
// error: 'Book not found'
// };
// }
// if (author) book.author = author
// if (title) book.title = title
// if (description) book.description = description
// books = books.map(b => b.id === id ? book : b)
try {
const book = await books.updateBook(id, { title, author, description })
if (!book) {
return {
data: null,
ok: false,
error: 'Book not found'
};
}
return {
data: book,
ok: true,
error: ''
};
} catch (error) {
return {
data: null,
ok: false,
error: error.message
};
}
},
deleteBook: async ({ id }, context) => {
// const book = books.find(book => book.id === id)
// books = books.filter(book => book.id !== id)
try {
const book = await books.deleteBook(id)
if (!book) {
return {
data: null,
ok: false,
error: 'Book not found'
};
}
return {
data: book,
ok: true,
error: ''
};
}
catch (error) {
return {
data: null,
ok: false,
error: error.message
};
}
}
};
module.exports = mutation
现在,我们可以开始测试我们的应用程序了。


如果我们想多次创建同一个突变,我们需要使用一个别名。这是通过给每个突变一个独特的名字来实现的。
我们可以使用books 和book 查询,分别获得所有的书和单一的书。

最后,让我们测试一下updateBook 和deleteBook 的突变。

如果我们再运行一次这些突变,deleteBook 突变将返回 "未找到书籍错误"。
迁移到TypeScript
现在,让我们尝试将现有代码迁移到TypeScript,因为TypeScript允许我们指定数据类型,这有助于提高编译和执行时间。
如果你对Typescript不熟悉,你可以在这里查看官方文档。
我们首先将Typescript安装为一个开发依赖项。
然后,我们需要在我们的项目中设置Typescript,运行tsc --init ,生成一个tscconfig.json 文件。
在该文件中,将rootDir 的值设置为./ ,将outDir 的值设置为./dist 。由于我们不需要重新编译JavaScript文件,所以allowJs 行可以被注释。
rootDir 选项告诉Typescript找到你的源代码(Typescript)文件。outDir 选项告诉Typescript将编译后的文件放在哪里。
在你的package.json 文件中,在你的scripts 部分添加以下内容。
"build": "tsc",
"start": "node dist/server.js",
"build:watch": "tsc --watch",
"dev": "nodemon dist/server.js"
接下来,我们需要把所有的文件名从,比如说,xxx.js 改为xxx.ts 。

如果我们尝试编译我们的TypeScript代码,我们应该有几个错误,本文的剩余部分将涉及到修复这些类型错误。
我们还需要为我们的项目安装类型定义。这有助于TypeScript获得代码库中使用的函数和对象的数据类型。
npm i @types/express @types/mongoose @types/express-graphql @types/dotenv @types/graphql -D
我们还需要安装nodemon ,在开发模式下运行我们的应用程序。
npm i nodemon -D
我们需要将所有的require 转换成import 语句,将我们的module.exports 转换成export default 。
为了修复这些错误,我们必须在代码库中添加类型和接口,将所有require 语句改为import ,并为数据库模式和对象创建接口(或类型)。
为了运行应用程序,我们使用npm run build ,如果我们想在观察模式下运行代码,则使用npm run build:watch 。
然后,为了启动服务器,我们需要运行npm start 或npm run dev ,以便在观察模式下运行它。现在一切都应该按预期工作。
总结
这篇文章已经指导你建立了一个简单的支持数据库的graphQL API。
向前看,现在我们已经有了我们的API并在运行,我们接下来可能要做的是添加一个认证中间件,或者只是一个向我们的request 对象添加中间件的方法。
我们构建schema 的方式甚至适用于大型应用程序。让我们举个例子,一个有查询和突变的应用程序,users,books,authors, 等等。首先,我们必须在schema/index.js 文件内的resolver 对象中取消所有的突变和查询。