使用 GraphQL、Node.js 和 Sequelize 构建 API
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
什么是GraphQL?
与传统的 RESTful 架构不同,在 RESTful 架构中,端点会返回 API 指定的一组数据,无论你是否需要;而 GraphQL 允许你从 API 提供的数据中挑选你想要的确切数据。
假设您想访问仓库中的某些商品,有两种方法可以实现:方法 A和方法 B。两种方法都需要正确的访问密钥才能访问仓库中的商品。使用方法 A,您会收到仓库中所有商品的副本,需要从中选择您实际需要的商品。使用方法 B,您可以指定所需的商品,并且只会收到这些商品,无需获取整个仓库的副本。方法 A采用 RESTful 架构,而方法 B采用 GraphQL。
请访问GraphQL 网站了解更多关于 GraphQL 的信息。
本文将重点介绍如何使用 GraphQL、Node.js、Sequelize 和 PostgreSQL 构建 API。我们将构建一个简单的博客 API,包含用户、文章和评论功能。用户应该能够进行身份验证(注册、登录、创建文章、查看文章、发表评论和查看评论)。
目录
-
术语解释
- 模式和类型
- 解析器
- 查询和变异
- 语境
-
设置项目并安装依赖项
-
使用 Sequelize 创建数据库迁移和模型
-
为用户、帖子和评论创建 GraphQL 模式
- 用户模式
- 帖子架构
- 评论模式
-
创建解析器
- 用户解析器
- 请求身份验证
- 帖子解析器
- 评论解析器
- 用户解析器
-
结论
-
更多资源
-
接下来呢?
术语解释
- 模式和类型:模式定义了可查询数据的结构,而类型定义了数据的格式,例如我们已知的数据类型。点击此处了解更多关于模式和类型的信息。
-
解析器:GraphQL 服务器上的一个函数,负责获取单个字段或整个模式的数据。
-
查询和变更:这些是特殊的 GraphQL 类型。查询代表 REST API 中的 GET 请求,而变更代表 REST API 中的 POST、PUT 和 DELETE 请求。了解更多
-
上下文:上下文是 GraphQL 中的一个全局对象。上下文中可用的数据在所有解析器之间共享。
请记住这些解释,我们准备开始编写代码。
有很多库实现了 GraphQL,在本文中,我将使用Apollo GraphQL。
要跟随本文操作,请在此处克隆本文中使用的代码仓库。
设置项目并安装依赖项
- 打开终端,为项目创建一个文件夹。
$ mkdir graphql-node-sequelize && cd graphql-node-sequelize
$ npm init -y
- 安装依赖项
$ npm install express graphql apollo-server-express bcryptjs core jsonwebtoken pg pg-hstore sequelize dotenv
$ npm install -D nodemon
- 设置服务器
$ mkdir api graphql
$ cd graphql && mkdir resolvers schemas context
我们为服务器创建了一个名为 的文件夹api,并为住房创建了另一个名为 graphql 的文件夹resolvers,schemas以及context。
请注意,这种结构完全是我个人的意见,并不代表任何标准,您可以根据自己的喜好来构建项目。
index.js在 schemas 文件夹中,创建一个文件并将以下代码复制到该文件中,以创建根模式:
// graphql/schemas/index.js
const { gql } = require('apollo-server-express');
const rootType = gql`
type Query {
root: String
}
type Mutation {
root: String
}
`;
module.exports = [rootType];
index.js在 resolvers 文件夹中,创建一个文件并将以下代码复制到该文件中,从而创建根解析器:
// graphql/resolvers/index.js
module.exports = [];
- 在上下文文件夹中,创建一个
index.js文件并将以下代码复制到该文件中:
// graphql/context/index.js
module.exports = ({ req }) => {
return {};
};
- 创建服务器。在
api文件夹中创建一个server.js文件,并将以下代码复制到该文件中:
// api/server.js
const express = require('express');
const { createServer } = require('http');
const { ApolloServer } = require('apollo-server-express');
const cors = require('cors');
const typeDefs = require('../graphql/schemas');
const resolvers = require('../graphql/resolvers');
const context = require('../graphql/context');
const app = express();
app.use(cors());
const apolloServer = new ApolloServer({
typeDefs,
resolvers,
context,
introspection: true,
playground: {
settings: {
'schema.polling.enable': false,
},
},
});
apolloServer.applyMiddleware({ app, path: '/api' });
const server = createServer(app);
module.exports = server;
接下来,index.js在项目文件夹的根目录下创建一个文件,并将以下代码添加到该文件中:
// ./index.js
require('dotenv').config();
const server = require('./api/server');
const port = process.env.PORT || 3301;
process.on('uncaughtException', (err) => {
console.error(`${(new Date()).toUTCString()} uncaughtException:`, err);
process.exit(0);
});
process.on('unhandledRejection', (err) => {
console.error(`${(new Date()).toUTCString()} unhandledRejection:`, err);
});
server.listen({ port }, () => console.log(
`🚀 Server ready at http://localhost:${port}/api`,
));
让我们添加启动脚本到package.json
...
"scripts": {
"dev": "nodemon index.js",
"start": "node index.js"
},
...
现在,我们准备启动服务器。
$ nm run dev
🚀 Server ready at http://localhost:3301/api
你参观时http://localhost:3301/api会看到如下截图所示的游乐场:
该测试平台是一个用于测试 GraphQL API 的图形用户界面。它还包含 API 的文档和模式。
使用 Sequelize 创建数据库迁移和模型
数据库架构图如下所示:
如果您是 Sequelize 的新手,可以查看这篇关于 Sequelize 入门的文章。
- 创建
.sequelizerc文件“
$ touch .sequelizerc
- Copy the code below into the `.sequelizerc` file
// ./sequelizerc
const path = require('path');
module.exports = {
"config": path.resolve('./database/config', 'config.js'),
"models-path": path.resolve('./database/models'),
"seeders-path": path.resolve('./database/seeders'),
"migrations-path": path.resolve('./database/migrations')
};
Next up, run the command below:
$ npx sequelize-cli 初始化
The command above will create a `database` folder containing the migrations, models, seeds, and config folders.
We need to make a few changes to the `config/config.js` and `models/index.js` files as follows.
```js
// database/config/config.js
require('dotenv').config();
module.exports = {
development: {
username: 'root',
password: null,
database: 'database_development',
host: '127.0.0.1',
dialect: 'postgres',
use_env_variable: 'DEV_DATABASE_URL',
},
test: {
username: 'root',
password: null,
database: 'database_test',
host: '127.0.0.1',
dialect: 'postgres',
host: '127.0.0.1',
dialect: 'postgres',
use_env_variable: 'TEST_DATABASE_URL',
},
production: {
username: 'root',
password: null,
database: 'database_production',
host: '127.0.0.1',
dialect: 'postgres',
host: '127.0.0.1',
dialect: 'postgres',
use_env_variable: 'DATABASE_URL',
},
};
// database/models/index.js
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require('../config/config')[env];
const db = {};
let sequelize;
if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
sequelize = new Sequelize(
config.database,
config.username,
config.password,
config,
);
}
fs.readdirSync(__dirname)
.filter((file) => (
file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js'
))
.forEach((file) => {
const model = sequelize.import(path.join(__dirname, file));
db[model.name] = model;
});
Object.keys(db).forEach((modelName) => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db;
请记住将数据库名称替换为您的数据库名称。如果您使用的是 PostgreSQL 连接字符串,则可以将env连接字符串的名称传递给它use_env_variable。
接下来运行以下命令,为数据库模式生成迁移:
$ npx sequelize-cli model:generate --name User --attributes name:string,email:string,password:string
$ npx sequelize-cli model:generate --name Post --attributes title:string,content:text,userId:integer
$ npx sequelize-cli model:generate --name Comment --attributes content:text,userId:integer,postId:integer
userId让我们在postIdPosts 和 Comments 迁移中添加外键约束:
...
userId: {
type: Sequelize.INTEGER,
references: {
model: {
tableName: 'Users',
},
key: 'id',
},
},
...
...
postId: {
type: Sequelize.INTEGER,
references: {
model: {
tableName: 'Posts',
},
key: 'id',
},
},
...
接下来,我们来定义模型之间的关系,并按如下所示编辑模型:
// database/models/user.js
const bcrypt = require('bcryptjs');
module.exports = (sequelize, DataTypes) => {
const User = sequelize.define(
'User',
{
name: DataTypes.STRING,
email: DataTypes.STRING,
password: DataTypes.STRING,
},
{
defaultScope: {
rawAttributes: { exclude: ['password'] },
},
},
);
User.beforeCreate(async (user) => {
user.password = await user.generatePasswordHash();
});
User.prototype.generatePasswordHash = function () {
if (this.password) {
return bcrypt.hash(this.password, 10);
}
};
User.associate = function (models) {
// associations can be defined here
User.hasMany(models.Post, { foreignKey: 'userId', as: 'posts' });
};
return User;
};
你明白上面这段代码的运行原理吗?
User定义了用户和帖子之间的多对多关系Post。一个用户可以发布多条帖子。- 添加了
defaultScope选项,以确保在查询 User 模型时,密码不会作为 JSON 结果的一部分返回。 - 新增了
beforeCreate一个钩子,底层使用 bcrypt.js 自动对密码进行哈希处理。
// database/models/post.js
module.exports = (sequelize, DataTypes) => {
const Post = sequelize.define(
'Post',
{
title: DataTypes.STRING,
content: DataTypes.TEXT,
userId: DataTypes.INTEGER,
},
{},
);
Post.associate = function (models) {
// associations can be defined here
Post.belongsTo(models.User, { foreignKey: 'userId', as: 'author' });
Post.hasMany(models.Comment, { foreignKey: 'postId', as: 'comments' });
};
return Post;
};
// database/models/comment.js
module.exports = (sequelize, DataTypes) => {
const Comment = sequelize.define(
'Comment',
{
content: DataTypes.TEXT,
userId: DataTypes.INTEGER,
postId: DataTypes.INTEGER,
},
{},
);
Comment.associate = function (models) {
Comment.belongsTo(models.User, { foreignKey: 'userId', as: 'author' });
Comment.belongsTo(models.Post, { foreignKey: 'postId', as: 'post' });
};
return Comment;
};
现在我们已经定义了模型之间的关系,我们将能够使用 Sequelize mixins,例如post.getAuthor(),user.getPosts()等等。
如果您已经创建了数据库并提供了配置凭据,那么现在可以运行迁移了:
$ npx sequelize-cli db:migrate
为用户、帖子和评论创建 GraphQL 模式
用户模式
在 schemas 文件夹中创建一个名为 `.schemas` 的文件user.js,并将以下代码复制到该文件中:
// graphql/schema/user.js
const { gql } = require('apollo-server-express');
module.exports = gql`
type User {
id: Int!
name: String!
email: String!
password: String!
posts: [Post!]
}
extend type Mutation {
register(input: RegisterInput!): RegisterResponse
login(input: LoginInput!): LoginResponse
}
type RegisterResponse {
id: Int!
name: String!
email: String!
}
input RegisterInput {
name: String!
email: String!
password: String!
}
input LoginInput {
email: String!
password: String!
}
type LoginResponse {
id: Int!
name: String!
email: String!
token: String!
}
`;
上面的代码片段讲的是什么?
- 我们创建了一个
type用户,并使用感叹号将所有字段设置为必填项。! - 在“用户”类型中,我们添加了一个
posts返回“帖子”类型的字段,这样我们就可以查询用户创建的帖子。 [Post!]这意味着用户可以不发帖,但如果发帖,帖子类型必须是“帖子”。- 我们定义了两个变更操作:注册和登录,它们分别作为注册和登录的端点。
帖子架构
创建post.js文件schemas并将以下代码复制到该文件中:
// graphql/schemas/post.js
const { gql } = require('apollo-server-express');
module.exports = gql`
type Post {
id: Int!
title: String!
content: String!
author: User!
comments: [Comment!]
createdAt: String
}
extend type Query {
getAllPosts: [Post!]
getSinglePost(postId: Int!): Post
}
extend type Mutation {
createPost(title: String!, content: String!): CreatePostResponse
}
type CreatePostResponse {
id: Int!
title: String!
content: String!
createdAt: String!
}
`;
我们再次创建了 Post 类型。这次我们添加了两个查询,分别用于获取所有帖子和单个帖子,以及一个用于创建新帖子的 mutation。
评论模式
创建comment.js文件schemas并将以下代码复制到该文件中:
// graphql/schemas/comment.js
const { gql } = require('apollo-server-express');
module.exports = gql`
type Comment {
id: Int!
content: String!
author: User!
post: Post!
createdAt: String
}
extend type Mutation {
createComment(content: String!, postId: Int!): CreateCommentResponse
}
type CreateCommentResponse {
id: Int!
content: String!
createdAt: String!
}
`;
请注意,评论类型包含帖子和作者,分别返回帖子类型和用户类型。
最后,我们来更新根模式,schemas/index.js按如下所示进行更新:
// graphql/schemas/index.js
const { gql } = require('apollo-server-express');
const userType = require('./user')
const postType = require('./post')
const commentType = require('./comment')
const rootType = gql`
type Query {
root: String
}
type Mutation {
root: String
}
`;
module.exports = [rootType, userType, postType, commentType];
接下来,我们将为模式创建解析器。
创建解析器
首先,我们来看一下解析器函数的结构。
register(root, args, context, info) {
}
上面的代码片段定义了一个名为 `resolver` 的解析器register。解析器函数接受四个参数:
- 根:这是父解析器的结果。我们稍后会看到应用程序。
- args:GraphQL 查询提供的参数或数据。这可以看作是 REST API 中的请求负载。
- context:一个所有解析器均可访问的对象。任何需要对所有解析器全局访问的数据都会被放置在 context 中。例如,我们可以将 Sequelize 模型传递给 context。
- info:包含与正确查询相关的特定信息的对象。这仅在高级情况下有用。
现在我们已经了解了什么是解析器,让我们为 User 模式创建解析器。
用户解析器
让我们为 User 模式中的变更register和修改创建解析器。在 resolvers 文件夹中创建一个新文件,并将以下代码复制到其中:loginuser.js
// graphql/resolvers/user.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { AuthenticationError } = require('apollo-server-express');
const { User } = require('../../database/models');
module.exports = {
Mutation: {
async register(root, args, context) {
const { name, email, password } = args.input;
return User.create({ name, email, password });
},
async login(root, { input }, context) {
const { email, password } = input;
const user = await User.findOne({ where: { email } });
if (user && bcrypt.compareSync(password, user.password)) {
const token = jwt.sign({ id: user.id }, 'mySecret');
return { ...user.toJSON(), token };
}
throw new AuthenticationError('Invalid credentials');
},
},
};
在注册解析器中,我们从 args 对象中提取有效负载,并使用 User 模型创建新用户。在验证过程中login,我们对用户进行身份验证,如果提供了正确的凭据,则返回用户以及一个 JSON Web Token;否则,抛出身份验证错误。
注意:以上代码片段仅用于演示目的,不建议在生产环境中使用。在实际应用中,您需要验证输入、将 JWT 密钥存储在指定位置.env,并遵循其他安全最佳实践。
现在,当您在测试环境中测试这些变更(端点)时,您将获得与以下屏幕截图类似的结果:
上述屏幕截图对应的 graphQL 查询可以在文章存储库中包含的 query.graphql 文件中找到。
请求身份验证
在创建 POST 解析器之前,我们需要设计一种请求身份验证机制。我们希望添加一个函数来检查请求头中是否包含授权令牌。
按如下方式编辑graphql/context/index.js:
// graphql/context/index.js
const { User } = require('../../database/models');
const jwt = require('jsonwebtoken');
const { AuthenticationError } = require('apollo-server-express')
const verifyToken = async (token) => {
try {
if (!token) return null;
const { id } = await jwt.verify(token, 'mySecret');
const user = await User.findByPk(id);
return user;
} catch (error) {
throw new AuthenticationError(error.message);
}
};
module.exports = async ({ req }) => {
const token = (req.headers && req.headers.authorization) || '';
const user = await verifyToken(token)
return { user };
};
从上面的代码片段可以看出,我们创建了一个辅助函数verifyToken,用于验证令牌并返回令牌中包含的用户 ID。在上下文函数中,我们检查请求头中的授权信息,如果存在令牌,则对其进行解码,并将解码后的user对象传递给上下文。现在,我们可以检查user上下文中的对象,以确定请求是否已通过身份验证。
首先
,我们来创建createPost解析器。新建一个文件,post.js并将graphql/resolvers以下代码复制到其中:
// graphql/resolvers/post.js
const { Post } = require('../../database/models');
const { AuthenticationError } = require('apollo-server-express');
module.exports = {
Mutation: {
async createPost(_, { content, title }, { user = null }) {
if (!user) {
throw new AuthenticationError('You must login to create a post');
}
return Post.create({
userId: user.id,
content,
title,
});
},
},
};
我们解构了 args 对象以获取请求的内容和标题。同样地,我们解构了 context 对象以获取请求用户,如果 user 为 null,则表示请求未通过身份验证。下面的屏幕截图显示了在 Playground 上测试的结果。 仔细查看上面的屏幕截图,您可以看到授权是如何添加的。要在 GraphQL Playground 中添加授权标头,请转到 Playground 底部,单击并添加授权,如下所示:
HTTP HEADERS
{
"Authorization": "your-json-web-token"
}
您可以通过我们之前创建的 mutation 获取令牌login。
接下来,我们来创建 `and`getAllPosts和 ` getSinglePost.` 的解析器。按如下方式编辑 ` post.js.`:
// graphql/resolvers/post.js
const { Post } = require('../../database/models');
const { AuthenticationError } = require('apollo-server-express');
module.exports = {
Mutation: {
async createPost(_, { content, title }, { user = null }) {
if (!user) {
throw new AuthenticationError('You must login to create a post');
}
return Post.create({
userId: user.id,
content,
title,
});
},
},
Query: {
async getAllPosts(root, args, context) {
return Post.findAll();
},
async getSinglePost(_, { postId }, context) {
return Post.findByPk(postId);
},
},
Post: {
author(post) {
return post.getAuthor();
},
comments(post) {
return post.getComments();
},
},
};
请注意,我们为 Post 本身添加了一个解析器,在 Post schema 中,我们有 `post`author和 `post_comments` comments。这里我们使用了根对象,post在本例中是 `post`。GraphQL 会隐式地将 Post 解析为 Post 查询的结果,并将 post 对象作为根对象传递。然后,我们使用 Sequelize mixin来返回 Post 的相关作者和评论。
我们来测试一下这个getAllPosts查询。你可以在 Playground 中添加下面的示例查询。
query allPosts {
getAllPosts {
id
title
content
author {
id
name
}
comments {
id
content
}
}
}
上述查询的响应如下所示:
{
"data": {
"getAllPosts": [
{
"id": 1,
"title": "New post",
"content": "New post content",
"author": {
"id": 1,
"name": "test"
},
"comments": []
}
]
}
}
查看响应可知,查询返回了数据库中唯一的帖子以及帖子的作者,评论数组为空,因为该帖子目前还没有评论。
同样地,下面的查询展示了如何查询单个帖子。
query singlePost {
getSinglePost(postId: 1) {
id
title
content
author {
name
}
}
}
您可以根据自己的需求来定义查询语句。
评论解析器
在评论模式中,我们有一个createCommentmutation,让我们为其创建一个解析器。
将以下代码片段复制到graphql/resolvers/comment.js:
// graphql/resolvers/comment.js
const { Post } = require('../../database/models');
const { AuthenticationError, ApolloError } = require('apollo-server-express');
module.exports = {
Mutation: {
async createComment(_, { content, postId }, { user = null }) {
if (!user) {
throw new AuthenticationError('You must login to create a comment');
}
const post = await Post.findByPk(postId);
if (post) {
return post.createComment({ content, userId: user.id });
}
throw new ApolloError('Unable to create a comment');
},
},
Comment: {
author(comment) {
return comment.getAuthor();
},
post(comment) {
return comment.getPost();
},
},
};
与帖子解析器类似,我们确保只有经过身份验证的用户才能创建评论,并且我们还通过首先检索帖子,然后使用 Sequelize 提供的关系方法为给定的帖子创建评论来确保帖子存在。
下面展示了一个创建评论的示例查询,请将其复制并粘贴到 Playground 中以测试变更。请记住在授权标头中添加身份验证令牌。
mutation createComment {
createComment(content: "New post comment", postId: 1) {
id
content
createdAt
}
}
回复如下所示:
{
"data": {
"createComment": {
"id": 5,
"content": "New post comment",
"createdAt": "1593345238316"
}
}
}
现在,当您查询帖子时,您将能够获得该帖子的评论结果。
请记住将解析器导入到根解析器中graphql/resolvers/index.js。现在它应该看起来像下面这样:
// graphql/resolvers/index.js
const userResolvers = require('./user');
const postResolvers = require('./post');
const commentResolvers = require('./comment');
module.exports = [userResolvers, postResolvers, commentResolvers];
结论
如果你按照步骤操作到这里,你就已经成功地使用 GraphQL 开发了一个 API。值得注意的是,本文采用极简主义方法,侧重于最重要的内容,而非最佳实践。我们使用了 PostgreSQL 和 Sequelize ORM 作为数据源,但你可以根据应用程序的实际情况选择任何合适的数据源。
接下来呢?
接下来的文章中,我将介绍如何使用中间件改进应用程序,如何在 GraphQL API 上实现集成测试等等。敬请期待!
更多资源
- https://graphql.org/
- https://www.apollographql.com/docs/apollo-server/
- https://dev.to/nedsoft/getting-started-with-sequelize-and-postgres-emp
- https://sequelize.org/master/manual/
- https://www.howtographql.com/



