Node.js 蚕食计划(七)—— Koa + GraphQL + MongoDB + Vue 初体验
首先需要搭建一个简单的应用
前端部分不多赘述,如果确实没接触过 Vue 项目,可以参考我的《Vue 爬坑之路》系列
后端服务可以参考之前的文章《Node.js 蚕食计划(六)—— MongoDB + Koa 入门》
完整的项目地址:https://github.com/wisewrong/bolg-demo-app/tree/main/test-graphql-app,结合项目食用本文更香哦~
一、Mongoose
在上一篇文章《Node.js 蚕食计划(六)》里,直接使用了 mongodb 中间件来连接数据库,并尝试着操作数据库
但我们一般不会直接用 MongoDB 的原生函数来操作数据库,Mongoose 就是一套操作 MongoDB 数据库的接口
1. Schema 与 Model
Schema 是 Mongoose 的基础,用来定义集合的数据模型,也就是传统意义上的表结构
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
// 影片信息
const MovieSchema = new Schema({
name: String, // 影片名称
years: Number, // 上映年代
director: String, // 导演
category: [String], // 影片类型
comments: [ // 影评
{
author: String,
createdAt: {
type: Date,
default: Date.now(),
},
updatedAt: {
type: Date,
default: Date.now()
}
}
],
});
module.exports = mongoose.model('Movie', MovieSchema);
上面的最后一行代码,是基于定义好的 Schema 生成 Model,我们可以通过 Model 来操作数据库
mongoose.model('ModelName', SchemaObj)
这里的 model() 方法可以接收两个参数,第二个参数是创建好的 Schema 实例
第一个参数 ModelName 是数据库中集合 (collection) 名称的单数形式,Mongoose 会查找名称为 ModelName 复数形式的集合
对于上例,Movie 这个 model 就对应数据库中 movies 这个 collection,如果数据库没有对应的集合会自动创建
2. Model 的增删改查
在 mongoose 中是通过操作 Model 来实现数据库的增删改查
< 新增 >
Model.create(data, callback)
< 查询 >
// 返回所有符合查询条件 conditions 的数据
Model.find(conditions, callback);
// 返回找到的第一个文档
Model.findOne(conditions, callback);
// 只针对主键 _id 查询
Model.findById('_id', callback);
< 修改 >
// 批量修改符合条件 conditions 的数据
Model.updateMany(conditions, update, options, callback)
// 修改指定 id 的数据
Model.findByIdAndUpdate(id, update, options , callback)
// 修改第一个符合查询条件的数据
Model.updateOne(conditions, update, options , callback)
// 替换第一个符合查询条件的数据
Model.replaceOne(conditions, update, options , callback)
< 删除 >
// 删除符合条件的所有数据
Model.remove(conditions, callback);
// 删除指定 id 的数据
Model.findByIdAndRemove(id, options, callback);
比如封装一个插入数据的方法:
const Movie = require('../mongodb/models/movie');
// 新建电影
const createMovie = (req) => {
return Movie.create(req);
}
// 更新电影信息
const updateMovie = (req) => {
return Movie.findByIdAndUpdate(req._id, req, {
new: true,
});
}
// 保存电影
const saveMovie = async (ctx, next) => {
const req = ctx.request.body;
// 校验必填
if (!req.name) {
return { message: '影片名称不能为空' }
}
const data = req._id
? await updateMovie(req)
: await createMovie(req);
return { data };
};
module.exports = {
saveMovie,
};
mongoose 也有更规范的查询条件,可以参考官网的 Query 配置
3. 连接数据库
使用 mongoose.connect 连接数据库,可以在 connect 方法中传入第二个参数作为回调
也可以通过 mongoose.connection.on 来监听相应的事件
/* /mongodb/index.js */
const mongoose = require("mongoose");
const { dbUrl } = require("../config");
// const dbUrl = 'mongodb://127.0.0.1:27017/Movie'; // 数据库地址
const connect = () => {
// mongoose.set('debug', true)
mongoose.connect(dbUrl);
mongoose.connection.on("disconnected", () => {
mongoose.connect(dbUrl);
});
mongoose.connection.on("error", (err) => {
console.error('Connect Failed: ', err);
});
mongoose.connection.on("open", async () => {
console.log('🚀 Connecting MongoDB Successfully 🚀');
});
};
4. 接口实现
基于这些 API,我们就可以搭建一个相对规范的传统后端服务
首先创建 model,然后创建 controller,在 controller 中引入 model,并使用 model 来操作数据库
然后还可以通过 koa-router 来实现传统接口
/* /router/api/movie.js */
const router = require('koa-router')();
const { apiPrefix } = require('../../config');
// const apiPrefix = '/api';
const movieController = require('../../controllers/movie');
router.prefix(apiPrefix);
router.post('/movie/save', movieController.saveMovie);
router.get('/movie/list', movieController.getMovie);
router.delete('/movie/delete/:id', movieController.deleteMovie);
module.exports = router;
最后只要在 app.js 中引入相应模块,一个简单的传统服务就搭建好了
// app.js
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const api = require('./router/api');
// 连接数据库
require('./mongodb');
const app = new Koa();
app.use(bodyParser());
// 注册 API
for (const key in api) {
const router = api[key];
app.use(router.routes()).use(router.allowedMethods());
}
app.listen({port: 3200});
但这样的传统服务,接口的出参都是由后端决定的
如果业务调整,接口出参需要新增一个字段,就需要后端和前端同时迭代
而如果使用 GraphQL 的话,这种改动就不用后端的小伙伴参与了
二、GraphQL
GraphQL 是一种新的 API 定义和查询语言,它使前端能够声明式地获取数据,从一定程度上自定义接口出参
像上图这样,接口的响应会按照入参的结构返回出参。概念性的优点就不多赘述,实际感受之后才能明白它的优势
先在项目中引入 koa-graphql 和 GraphQL.js 备用
npm install graphql koa-graphql --save
在 GraphQL 中,Schema 是定义整个查询语言的入口
schema {
query: Query
mutation: Mutation
}
Schema 有一个必须定义的 query 类型,用来执行查询操作;还有一个可选的 mutation,处理增删改操作
这两种类型其实都是 graphql.GraphQLObjectType 类型
构建一个 Schema 可以使用 graphql.buildSchema 或者构建类型 graphql.GraphQLSchema,先介绍一下 buildSchema
const Schema = buildSchema(`
type Query {
getList: [Movie]
getDetail: [Movie]
}
type Mutation {
add(post: input): [Movie],
}
`)
这里的 type Query 就是定义上面提到的 schema 中必须包含的 query 类型
需要注意的是,在类型下定义的字段,并不是像 mongoose 中 schema 定义的文档结构
这个字段只是声明一种类型,而类型的值取决于对应的 resolve 处理函数,所以将这个字段当作查询指令更便于理解
上面 Query.getList: [Movie] 表示通过 getList 指令能够返回一个数组,数组的每个元素是一个 Movie 类型,这个 Movie 是我们需要定义的另一个类型
/* /graphql/schema.js - 使用 buildSchema 创建的 GraphQL Schema */
const { buildSchema } = require('graphql');
const Schema = buildSchema(`
type Query {
getAllMovie: [Movie]
}
type Movie {
_id: String,
name: String,
years: String,
director: String,
}
`)
// 暂时不用 Mutation
module.exports = Schema;
这样就定义了一个包含 name、years 等四个字段的 Movie 类型,一个简单的 Schema 就定义好了
然后来改造 controllers,引入 koa-graphql、刚才定义的 Schema,以及之前用 mongoose 生成的 Model
/* /controllers/movie.js */
const graphqlHTTP = require('koa-graphql');
const MovieSchema = require('../graphql/schema');
const Movie = require('../mongodb/models/movie');
// GraphQL 类型处理函数
const root = {
getAllMovie: async () => {
return Movie.find({});
}
}
// 查询所有电影
const getMovie = graphqlHTTP({
schema: MovieSchema,
rootValue: root,
graphiql: true
});
module.exports = {
getMovie,
};
用 koa-graphql 提供的 graphqlHTTP 方法作为接口的 handler 函数,并传入定义好的 schema
这里有一个 rootValue 对象,用来配置 schema 类型的具体操作函数,比如上面就定义了 getAllMovie 的操作函数
然后接口路径还是按之前的方式配置:
/* /router/api/movie.js */
const router = require('koa-router')();
const movieController = require('../../controllers/movie');
router.all('/movie/list', movieController.getMovie);
module.exports = router;
一个简单的 GraphQL 服务就完成了,接下来处理前端的请求
请求的时候需要携带 JSON 格式的参数,所以通常使用 post 请求
最主要的是,需要设置请求头 'Content-Type': 'application/json'
然后按照 schema 的格式设置入参,比如查询 schema 中 query 类型下的 getAllMovie:
request.post('/api/movie/list', {
query: `{
getAllMovie {
_id,
name,
}
}`
});
可以看到响应的结果为:
我们在 GraphQL 中定义的 Movie 类型有 name 等四个字段,但入参中只设置了 name 和 _id,所以出参也只有 name 和 _id
如果把入参也改为四个字段:
后端逻辑不用调整,请求结果就会变成:
Cool~
三、GraphQL 构建类型
上面的 Schema 是使用 buildSchema 定义的,但 buildSchema 接收的类型参数只能是一整个字符串
如果我们复用某些自定义类型就不太方便,而且字段的处理函数需要写在 rootValue 里面,不方便模块化管理
所以更推荐使用 GraphQLSchema 构建类型
const { GraphQLSchema, GraphQLObjectType } = require('graphql');
const schema = new GraphQLSchema({
query: new GraphQLObjectType(),
mutation: new GraphQLObjectType(),
});
GraphQLObjectType 是构建 Schema 类型的基本方法,包括 query 和 mutation 在内的所有类型都需要通过该构造函数构建
我们先尝试用构建类型的方式,来改写将上面 buildSchema 定义的 Schema
/* /graphql/schema.js - 构建类型 */
const { GraphQLSchema, GraphQLObjectType } = require('graphql');
const getAllMovie = require('./query/movie.js');
const RootQuery = new GraphQLObjectType({
name: 'RootQueryType',
fields: {
getAllMovie,
}
});
module.exports = new GraphQLSchema({
query: RootQuery,
// mutation: RootMutation,
});
这里定义了一个 RootQuery 类型,对应的是之前的:
这里的 getAllMovie 是由 Movie 类型组成的数组,需要另外构建:
/* /graphql/types/movies.js - 定义 Movie 类型 */
const graphql = require('graphql');
const {
GraphQLObjectType,
GraphQLList,
GraphQLString,
GraphQLInt,
} = graphql;
const MovieType = new GraphQLObjectType({
name: 'Movie',
fields: () => ({
_id: { type: GraphQLString }, // String
name: { type: GraphQLString },
years: { type: GraphQLInt }, // Int
poster: { type: GraphQLString },
director: { type: GraphQLString },
category: { type: new GraphQLList(GraphQLString) }, // [String]
})
});
module.exports = MovieType;
在定义 Movie 类型下的具体字段 fields 的时候,需要通过对象的形式规定类型 type
这里的 type 不能像之前那样直接写 String、Boolean,而是使用 graphql 中提供的类型对象
现在定义好了 Movie 类型,但是 getAllMovie 返回的是 Movie 类型组成的数组,还有一个对应的处理函数,所以我们要单独维护一个 getAllMovie 对象
/* /graphql/query/movies.js - 定义 getAllMovie 字段 */
const { GraphQLList } = require('graphql');
const movieGraphQLType = require('../types/movie.js');
const Movie = require('../../mongodb/models/movie.js');
module.exports = {
type: new GraphQLList(movieGraphQLType),
args: {},
resolve() {
return Movie.find({})
}
}
注意我们导出的对象包含 type、args、resolve 三个字段,而我们刚才定义 Movie 类型的时候,fields 字段对象也包含一个 type 字段
没错,这里导出的对象其实就一个 field,而每个 field 都可以包含 type、args、resolve
其中 type 不用再提,resolve 就是该字段对应的处理函数,对应上面 buildSchema 小节中 rootValue 中的字段
args 用来描述 resolve 方法接收的参数,在后面介绍 mutation 的时候会介绍
由于每个 filed 都可以是一个独立的类型,而每个类型可以配置自己的 resolve 处理函数,所以在 GraphQL 可以很方便的执行复杂查询
只要在响应的类型中配置好 resolve,前端只需要调一次接口就能获取到多个文档的数据
到此为止,我们已经完成了从 buildSchema 到构建类型的改造,由于在 field 字段中定义了 resolve,所以就可以不用定义 rootValue 了
/* /controllers/movie.js */
const graphqlHTTP = require('koa-graphql');
const MovieSchema = require('../graphql/schema');// 查询所有电影
const getMovie = graphqlHTTP({
schema: MovieSchema,
graphiql: true
});
module.exports = {
getMovie,
};
四、使用 mutation 执行增删改
上面提到了 args,它用来描述 resolve 方法的参数
首先来看一下前端怎么在 resolve 方法中传参
看起来就和我们平时用的 function 一样,但这里面大有玄机
首先如果参数是一个 String,就需要手动添加双引号,而且只能是双引号
如果用单引号会报错(主要是为了避免文本中带有单引号的情况 desc: "I'm Wise" )
Syntax Error: Unexpected single quote character ('), did you mean to use a double quote (")?
如果参数是 Int 类型,就不能添加引号 years: ${data.years},
如果参数是数组类型,需要用 JSON.stringify 转换
由于对参数类型的处理较为复杂,可以封装一个处理参数的工具函数来统一处理
// 这只是我简单尝试之后的感想,如果小伙伴有更好的处理思路,一定要在评论区留言,感谢 🤝
知道了怎么向 resolve 方法传参(不只是 mutation,query 也可以传参),再来说说 args:
它可以像定义 fields 一样定义接收的参数,如果 args 里只写了一个参数,而接口入参传了入了多个,接口会返回错误
如果入参传的参数少了是可以的,只要必填项 GraphQLNonNull 没落下
然后可以从 resolve 的第二个参数中获取到前端传过来的参数,再通过 Mongoose 生成的 Model 来操作数据
需要注意的是,前端在发送 mutation 请求的时候,要在 query 中声明 mutation
定义好了 mutation,按照之前构建 RootQuery 对象的方式构建 RootMutation,并赋值给 schema,一个具有基本功能的 GraphQL 服务就完成了
如果对项目结构还不太清晰,可以看一下项目仓库:https://github.com/wisewrong/Test-GraphQL-App
再回头捋一下,其实后端服务只定义了一个接口,而具体的操作都是在前端分工
这样虽然增加了前端的工作量,但也增加了前端的灵活性,让后端的小伙伴能专注于数据库的设计和优化
其实 GraphQL 早在 2015 年就发布了,却一直没有推广开,当时尤大大还做了一波分析
但时至今日,GraphQL 已经得到了广泛认可,有许多大厂已经开始广泛使用(比如 TX 的 CSIG)
特别是对于有全栈发展兴趣的小伙伴,学一下 GraphQL 是很有必要的,这样我就不至于只能看国外的文章来学 GraphQL 了
参考文章: