课程地址: https://coding.imooc.com/learn/list/354.html
跟着GitHub认识RESTful API
REST是什么?
- 万维网软件架构风格
- 用来创建网络服务的
为何叫REST?
- Representational State Transfer
- Representational:数据的表现形式(json、xml...)
- State:当前状态或者数据
- Transfer:数据传输
通过REST的6个限制详细了解他
1. 客户端-服务器(Client-Server)
- 关注点分离
- 服务端专注于数据存储,提升了简单性
- 前端专注于用户界面,提升了可移植性
2. 无状态
- 所有用户会话信息都保存在客户端
- 每次请求必须包括所有信息,不能依赖上下文信息
- 服务端不用保存会话,提升了简单性、可靠性、可见性
3. 缓存(Cache)
- 所有服务端相应都要被标为可缓存或不可缓存
- 减少前后端的交互,提升了性能
4. 统一接口(Uniform Interface)
- 接口设计尽可能统一通用,提升了简单性、可见性
- 接口与实现解耦,使前后端可以独立开发迭代
5. 分层系统(Layered System)
- 每层只知道相邻的一层,后面隐藏的就不知道了
- 客户端不知道是和代理还是真实服务器通信
- 其他层、负载均衡、缓存层等
6. 按需代码(Code-OnDemand 可选)
- 客户端可以下载运行服务端传来的代码(比如JS)
- 通过减少一些功能,简化了客户端
统一接口的限制
1. 资源的标识
- 资源是任何可以命名的事物,比如用户、评论等
- 每个资源可以通过URI被唯一地标识
- https://api.github.com/users
- https://api.github.com/users/lewis617
2. 通过表述来操作资源
- 表述就是Representation,比如JSON、XML等
- 客户端不能直接操作(如sql)服务端资源
- 客户端应该通过表述(如json)来操作资源
自描述信息
- 每个消息(请求或相应)必须提供足够的信息让接受者理解
- 媒体类型(application/json、application/xml)
- HTTP方法:GET(查)、POST(增)、DELETE(删)
- 是否缓存:(Cache-Control)
超媒体作为应用状态引擎
- 超媒体: 带文字的链接
- 应用状态: 一个网页
- 引擎:驱动、跳转
- 合起来:点击链接跳转到另一个网页
RESTful API简介
什么是RESTful API?
- 符合REST架构风格的API
RESTful API具体什么样子?
- 基本的URI,如: https://api.github.com/users
- 标准HTTP方法,如: GET,POST,PUT,PATCH,DELETE
- 传输的数据媒体类型,如JSON,XML
显示举例
- GET /users 获取user列表
- GET /users/12 查看某个具体的user
- POST /users 新建一个user
- put
- delete
RESTful API设计最佳实践
请求设计规范
- URI使用名词,尽量用复数,如:/users
- URI使用嵌套表示关联关系,如: /users/12/repos/5
- 使用正确的HTTP方法,如:GET/POST/PUT/DELETE
- 不符合CRUD的情况:POST /action/子资源
响应设计规范
- 查询
- 分页
- 字段过滤
- 状态码
- 错误处理
安全
- HTTPS
- 鉴权
- 限流
开发者友好
- 文档
- 超媒体
用Koa 说 Hello World
Koala 简介
一句话简介
- 基于Node.js:Node.js 模块
- 下一代:蚕食第一代Web框架Express的市场
- Web框架: 不是命令行工具,不是算法
官网简介
- 由 Express 幕后的原班人马打造
- Web应用和API开发领域
- 更小、更富有表现力、更健壮
- 利用 async 函数,丢弃回调函数
- 增强错误处理, try catch
- 没有捆绑任何中间件
安装搭建第一个Koa程序
- 初始化项目
- 安装Koa
- 编写 Hello World
- 学习自动重启
安装nodemon包,可以自动重启服务
nodemon index.js
Koa 中间件与洋葱模型
操作步骤
- 学习 async await
- 学习编写 Koa 中间件
- 学习洋葱模型
路由简介
路由是什么?
- 决定了不同的URL是如何被不同地执行的
- 在 Koa 中,是一个中间件
- 如果没有路由,会怎么样?
- 路由存在的意义
如果没有路由
- 所有的请求都做了相同的事
- 所有的请求都会返回相同的值
路由存在的意义
- 处理不同的URL
- 处理不同的HTTP方法
- 解析URL上的参数
自己编写 Koa 路由中间件
操作步骤
- 处理不同的URL
- 处理不同的HTTP方法
- 解析URL上的参数
app.use(async (ctx, next) => {
if (ctx.url === '/') {
ctx.body = '这是主页
'
} else if (ctx.url === '/users') {
if (ctx.method === 'GET') {
ctx.body = '这是用户列表页'
} else if (ctx.method === 'POST') {
ctx.body = '创建用户'
} else {
ctx.status = 405
}
} else if (ctx.url.match(/\/users\/\w+/)) {
const userId = ctx.url.match(/\/users\/(\w+)/)[1]
ctx.body = `这是用户${userId}`
} else {
ctx.status = 404
}
})
使用 koa-router 实现路由
操作步骤
- 更优雅地实现路由基本功能
- 演示一些高级的路由功能, 如前缀、多中间件
const usersRouter = new Router({ prefix: '/users' })
const auth = (ctx, next) => {
if (ctx.url === '/users') {
ctx.throw(401)
}
next()
}
router.get('/', auth, (ctx) => {
ctx.body = '这是主页
'
})
usersRouter.get('/', auth, ctx => {
ctx.body = '这是用户列表页'
})
usersRouter.post('/', auth, ctx => {
ctx.body = '创建用户'
})
usersRouter.get('/:id', auth, ctx => {
ctx.body = `这是用户${ctx.params.id}`
})
app.use(router.routes())
app.use(usersRouter.routes())
HTTP options 方法的作用是什么?
为何要了解 options 方法的作用?
- 这是一道面试题
- 帮助理解 koa-router 的 allowedMethods 的作用
HTTP options 方法的作用是什么?
- 检测服务器支持的请求方法
- CORS 中的预检请求
allowedMethods 的作用
- 响应 options 方法,告诉它所支持的请求方法
- 相应地返回405(不允许)和501(没实现)
app.use(usersRouter.allowedMethods())
RESTful API 最佳实践——增删改查应该返回什么响应
操作步骤
- 实现增删改查
- 返回正确的响应
控制器简介
什么是控制器?
- 拿到路由分配的任务,并执行
- 在 Koa 中,是一个中间件
为什么要用控制器
- 获取HTTP请求参数
- 处理业务逻辑
- 处理 HTTP 响应
获取HTTP请求参数
- Query String,如: ?q=keyword
- Router Params, 如: /users/:id
- Body, 如: { name: "lilei" }
- Header, 如:Accept、Cookie
发送HTTP请求
- 发送 Status, 如: 200/400 等
- 发送Body, 如: { name: "123" }
- 发送Header, 如: Allow、Content-Type
编写控制器最佳实践
- 每个资源的控制器放在不同的文件里
- 尽量使用类+类方法的形式编写控制器
- 严谨的错误处理
获取HTTP请求参数
操作步骤
- 学习断点调试
- 获取query // ctx.query
- 获取rotuer params
- 获取 body
- 获取header
发送HTTP响应
操作步骤
- 发送status
- 发送body
- 发送header
- 实现用户的增删改查
更合理的目录结构
操作步骤
- 将路由单独放在一个目录
- 将控制器单独放在一个目录
- 使用 类+类方法 的方式组织控制器
错误处理简介
什么是错误处理?
- 编程语言或计算机硬件里的一种机制
- 处理软件或信息系统中出现的异常状况
异常状况有哪些?
- 运行时错误,都返回 500
- 逻辑错误,如找不到(404)、先决条件失败(412)、如法处理的实体(参数格式不对, 422)等
为什么要用错误处理?
- 防止程序挂掉
- 告诉用户错误信息
- 便于开发者调试
Koa 自带的错误处理
操作步骤
- 制造 404/412/500 三种错误
- 了解 Koa 自导的错误处理做了什么
自己编写错误处理中间件
操作步骤
- 自己编写错误处理中间件
- 制造 404/412/500 三种错误来测试
使用 koa-json-error 进行错误处理
操作步骤
- 安装 koa-json-error
- 使用 koa-json-error 的默认配置处理错误
- 修改配置使其在生产环境下禁用错误堆栈的返回
const error = require('koa-json-error')
app.use(error({
postFormat: (e, { stack, ...rest }) => {
process.env.NODE_ENV === 'production' ? rest : { stack, rest }
}
}))
使用 koa-parameter 校验参数
操作步骤
- 安装 koa-parameter
- 使用 koa-parameter 校验参数
- 制造 422 错误来测试校验结果
第一批用户入库啦~~
NoSQL 简介
什么是 NoSQL?
- 对不同于传统的关系型数据库的数据库管理系统的统称
NoSQL 数据库的分类
- 列存储(HBase)
- 文档存储(MongoDB)
- Key-value 存储(Redis)
- 图存储(FlockDB)
- 对象存储(db4o)
- XML 存储(BaseX)
为什么要用NoSQL?
- 简单(没有原子性、一致性、隔离性等复杂规范)
- 便于横向拓展
- 适合超大规模数据的存储
- 灵活地存储复杂结构的数据(Schema Free)
MongoDB 简介
什么是MongoDB?
- 来自于英文单词 “Humongous”, 中文含义为“庞大”
- 面向文档存储的开源数据库
- 由 C++ 编写而成
为什么要用MongoDB?
- 性能好(内存计算)
- 大规模数据存储(可拓展性)
- 可靠安全(本地复制、自动故障转移)
- 方便存储复杂数据结构(Schema Free)
MongoDB 下载
- 官网下载
- 支持常见平台(Windows、Linux、OSX)
云 MongoDB
- 阿里云、腾讯云(收费)
- MongoDB 官方的 MongoDB Atlas(免费+收费)
云数据库——MongoDB Atlas
操作步骤
- 注册用户
- 创建集群
- 添加数据库用户
- 设置IP地址白名单
- 获取连接地址
使用 Mongoose 连接 MongoDB
操作步骤
- 安装 Mongoose
- 用 Mongoose 连接 MongoDB
设计用户模块的 Schema
操作步骤
- 分析用户模块的属性
- 编写用户模块的 Schema
- 使用 Schema 生成用户 Model
const mongoose = require('mongoose')
const { Schema, model } = mongoose;
const userSchema = new Schema({
name: {
type: String,
required: true
}
})
module.exports = model('User', userSchema)
用MongoDB 实现用户的增删改查
操作步骤
- 用 Mongoose 实现增删改查接口
- 用 Postman 测试增删改查接口
JWT 在 Koa 框架中实现用户的认证与授权
Session 简介
工作原理
Session 的优势
- 相比JWT,最大的优势就在于可以主动清除session
- session 保存在服务器端,相对较为安全
- 结合 cookie 使用,较为灵活,兼容性好
Session 的劣势
- cookie + session 在跨域场景表现并不好
- 如果是分布式部署,需要做多机共享 session 机制
- 基于 cookie 的机制很容易被 CSRF
- 查询 session 信息可能会有数据库查询操作
Session 相关的概念介绍
- session:主要存放在服务器端,相对安全
- cookie:主要存放在客户端,并且不是很安全
- sessionStorage:仅在当前会话有效,关闭页面或浏览器后被清除
- localStorage:除非被清除,否则永久保存
JWT 简介
什么是 JWT?
- JSON Web Token 是一个开放标准(RFC 7519)
- 定义了一种紧凑且独立的方式,可以将各方之间的信息作为JSON对象进行安全传输
- 该信息可以验证和信任,因为是经过数字签名的
JWT 的构成
- 头部(Header)
- 有效载荷(Payload)
- 签名(Signature)
JWT的例子
Header
- typ:token的类型,这里固定为JWT
- alg:使用的hash算法,例如: HMAC SHA256或者RSA
Header 编码前后
base64 编码
Payload
- 存储需要传递的信息,如用户ID、用户名等
- 还包含元数据,如过期时间、发布人等
- 与Header不同,Payload可以加密
Payload 编码前后
Signature
- 对 Header 和 Payload 部分进行签名
- 保证 Token 在传输的过程中没有被篡改或损坏
JWT vs. Session
- 可拓展性
- 安全性
- RESTful API
- 性能,以空间换时间
- 时效性, 比session差
在Node.js中使用JWT
- jsonwebtoken
- 签名
- 验证
实现用户注册
操作步骤
- 设计用户 Schema
select:false 不查询某个字段 - 编写保证唯一性的逻辑
实现登陆并获取 Token
操作步骤
- 登陆接口设计
- 使用 jsonwebtoken 生成 token
自己编写 Koa 中间件实现用户认证与授权
操作步骤
- 认证:验证token,并获取用户信息
- 授权:使用中间件保护接口
用 koa-jwt 中间件实现用户认证与授权
操作步骤
- 安装 koa-jwt
- 使用中间件保护接口
- 使用中间件获取用户信息
项目实战之上传图片模块
上传图片的需求场景
- 用户头像
- 封面图片
- 问题和回答中的图片
- 话题图片
- ......
上传图片的功能点
- 基础功能:上传图片、生成图片链接
- 附加功能:限制上传图片的大小与类型、生成高中低三种分辨率的图片链接、生成CDN
上传图片的技术方案
- 阿里云 OSS 等云服务,推荐生产环境下使用
- 直接上传到服务器,不推荐在生产环境下使用
使用 koa-body 中间件获取上传的文件
操作步骤
- 安装 koa-body,替换 koa-bodyparser
- 设置图片上传的目录
- 使用 Postman 上传文件
使用 koa-static 中间件生成图片链接
操作步骤
- 安装 koa-static
- 设置静态文件目录
- 生成图片链接
编写前端页面上传文件
操作步骤
- 编写上传文件的前端页面
- 与后端接口联调测试
项目实战之个人资料模块 —— 学习处理复杂数据类型
个人资料需求分析
个人资料功能点
- 不同类型(如字符串、数组)的属性
- 字段过滤
个人资料的 schema 设计
操作步骤
- 分析个人资料的数据结构
- 设计个人资料的 schema
个人资料的参数校验
操作步骤
- 分析个人资料的数据结构
- 编写代码校验个人资料的参数
- 使用 Postman 测试
RESTful API 最佳实践——字段过滤
操作步骤
- 设计 schema 默认隐藏部分字段
- 通过查询字符串显示隐藏字段
- 使用 Postman 测试
关注与粉丝需求分析
浏览知乎的关注与粉丝功能
细化关注与粉丝功能点
- 关注、取消关注
- 获取关注人、粉丝列表(用户-用户多对多关系)
关注与粉丝的 schema 设计
操作步骤
- 分析关注与粉丝的数据结构
- 设计关注与粉丝 schema
following: {
type: [{ type: Schema.Types.ObjectId, ref: 'User' }],
select: false
}
RESTful 风格的关注与粉丝接口
操作步骤
- 实现获取关注人和粉丝列表接口
- 实现关注和取消关注接口
- 使用 Postman 测试
async listFollowing (ctx) {
const user = await User.findById(ctx.params.id).select('+following').populate('following')
if (!user) {
ctx.throw(404)
}
ctx.body = user.following
}
async listFollowers (ctx) {
const users = await User.find({ following: ctx.params.id })
ctx.body = users
}
async follow (ctx) {
const me = await User.findById(ctx.state.user._id).select('+following')
if (!me.following.map(id => id.toString()).includes(ctx.params.id)) {
me.following.push(ctx.params.id)
me.save()
}
ctx.status = 204
}
async unfollow (ctx) {
const me = await User.findById(ctx.state.user._id).select('+following')
const index = me.following.map(id => id.toString()).indexOf(ctx.params.id)
if (index > -1) {
me.following.splice(index, 1)
me.save()
}
ctx.status = 204
}
编写校验用户存在与否的中间件
操作步骤
- 编写校验用户是否存在的中间件
- 使用 Postman 测试
// controllers/users.js
async checkUserExist (ctx, next) {
const user = await User.findById(ctx.params.id)
if (!user) {
ctx.throw(404, '用户不存在!')
}
await next()
}
项目实战之话题模块(足够完整!!)
话题模块需求分析
浏览知乎的话题模块功能
话题模块功能点
- 话题的增改查
- 分页、模糊搜索
- 用户属性中的话题引用
- 关注/取消关注话题、用户关注的话题列表
RESTful 风格的话题增改查接口
操作步骤
- 设计 Schema
- 实现 RESTful 风格的增改查接口
- 使用 Postman 测试
// models/topics.js
const mongoose = require('mongoose')
const { Schema, model } = mongoose;
const topicSchema = new Schema({
__v: { type: Number, select: false },
name: { type: String, required: true },
avatar_url: { type: String },
introduction: { type: String, select: false }
})
module.exports = model('Topic', topicSchema)
// controllers/topics.js
const Topic = require('../models/topics')
class TopicsCtl {
async find (ctx) {
ctx.body = await Topic.find()
}
async findById (ctx) {
const { fields = '' } = ctx.query
const selectFields = fields.split(';').filter(f => f).map(f => '+' + f).join('')
const topic = await Topic.findById(ctx.params.id).select(selectFields)
ctx.body = topic
}
async create (ctx) {
ctx.verifyParams({
name: { type: 'string', required: true },
avatar_url: { type: 'string', required: false },
introduction: { type: 'string', required: false }
})
const topic = await new Topic(ctx.request.body).save()
ctx.body = topic
}
async update (ctx) {
ctx.verifyParams({
name: { type: 'string', required: false },
avatar_url: { type: 'string', required: false },
introduction: { type: 'string', required: false }
})
const topic = await Topic.findByIdAndUpdate(ctx.params.id, ctx.request.body)
ctx.body = topic
}
}
module.exports = new TopicsCtl()
// routes/topics.js
const jwt = require('koa-jwt')
const Router = require('koa-router')
const router = new Router({ prefix: '/topics' })
const { find, findById, create, update } = require('../controllers/topics')
const { secret } = require('../config')
const auth = jwt({ secret })
router.get('/', find)
router.post('/', auth, create)
router.get('/:id', findById)
router.patch('/:id', auth, update)
module.exports = router
RESTful API 最佳实践——分页
操作步骤
- 实现分页逻辑
- 使用 Postman 测试
const page = Math.max((ctx.query.page || 1) * 1, 1) - 1
const perPage = Math.max((ctx.query.per_page || 1) * 1, 1)
ctx.body = await User.find().limit(perPage).skip(page * perPage)
RESTful API 最佳实践——模糊搜索
操作步骤
- 实现模糊搜索逻辑
- 使用 Postman 测试
// 在find查询条件中加上正则表达式
ctx.body = await Topic.find({ name: new RegExp(ctx.query.q) })
12-6 用户属性中的话题引用
操作步骤
- 使用话题引用替代部分用户属性
- 使用Postman测试
- 将locations、business、employments.company、employments.job、educations.school、educations.major等字段设置为Schema.Types.ObjectID类型绑定为Topic
async findById (ctx) {
const { fields = '' } = ctx.query
+ const populateStr = fields.split(';').filter(f => f).map(f => {
+ if (f === 'employments') {
+ return 'employments.company employments.job'
+ }
+ if (f === 'educations') {
+ return 'educations.school educations.major'
+ }
+ return f
+ }).join(' ');
const user = await User.findById(ctx.params.id).select('+' + fields.split(';').filter(f => f).join('+'))
+ .populate(populateStr);
if (!user) {
ctx.throw(404, '用户不存在!')
}
ctx.body = user
}
12-7 RESTful 风格的关注话题接口
操作步骤
- 实现关注话题逻辑(用户-话题多对多关系)
- 使用Postman 测试
async listFollowingTopics (ctx) {
const user = await User.findById(ctx.params.id).select('+followingTopics').populate('followingTopics')
if (!user) {
ctx.throw(404, '用户不存在')
}
ctx.body = user.followingTopics
}
async followTopic (ctx) {
const me = await User.findById(ctx.state.user._id).select('+followingTopics')
if (!me.followingTopics.map(id => id.toString()).includes(ctx.params.id)) {
me.followingTopics.push(ctx.params.id)
me.save()
}
ctx.status = 204
}
async unfollowTopic (ctx) {
const me = await User.findById(ctx.state.user._id).select('+followingTopics')
const index = me.followingTopics.map(id => id.toString()).indexOf(ctx.params.id)
if (index > -1) {
me.followingTopics.splice(index, 1)
me.save()
}
ctx.status = 204
}
第13章 项目实战之问题模块——复杂的数据库设计
问题模块需求分析
浏览知乎的问题模块功能
问题模块功能点
- 问题的增删改查
- 用户的问题列表
- 话题的问题列表+问题的话题列表(话题-问题多读多关系)
- 关注、取消关注问题
用户-问题一对多关系设计与实现
操作步骤
- 实现增删改查接口
- 实现用户的问题列表接口
- 使用Postman测试
// controller/questions.js
const Question = require('../models/questions')
class QuestionsCtl {
async find (ctx) {
const page = Math.max((ctx.query.page || 1) * 1, 1) - 1
const perPage = Math.max((ctx.query.per_page || 2) * 1, 1)
const q = new RegExp(ctx.query.q)
ctx.body = await Question.find({ $or: [{ title: q }, { description: q }] }).limit(perPage).skip(page * perPage)
}
async checkQuestionExist (ctx, next) {
const question = await Question.findById(ctx.params.id).select('+questioner')
if (!question) {
ctx.throw(404, '问题不存在!')
}
ctx.state.question = question
await next()
}
async findById (ctx) {
const { fields = '' } = ctx.query
const selectFields = fields.split(';').filter(f => f).map(f => '+' + f).join('')
const question = await Question.findById(ctx.params.id).select(selectFields).populate('questioner')
ctx.body = question
}
async create (ctx) {
ctx.verifyParams({
title: { type: 'string', required: true },
description: { type: 'string', required: false },
introduction: { type: 'string', required: false }
})
const question = await new Question({ ...ctx.request.body, questioner: ctx.state.user._id }).save()
ctx.body = question
}
async update (ctx) {
ctx.verifyParams({
title: { type: 'string', required: false },
description: { type: 'string', required: false }
})
await ctx.state.question.update(ctx.request.body)
ctx.body = ctx.state.question
}
async checkQuestioner (ctx, next) {
const { question } = ctx.state
if (question.questioner.toString() !== ctx.state.user._id) { ctx.throw(403, '没有权限') }
await next()
}
async delete (ctx) {
await Question.findByIdAndRemove(ctx.params.id)
ctx.status = 204
}
}
module.exports = new QuestionsCtl()
// model/questions.js
const mongoose = require('mongoose')
const { Schema, model } = mongoose;
const questionSchema = new Schema({
__v: { type: Number, select: false },
title: { type: String, required: true },
description: { type: String },
questioner: { type: Schema.Types.ObjectId, ref: 'User', required: true, select: false }
})
module.exports = model('Question', questionSchema)
// router/question.js
const jwt = require('koa-jwt')
const Router = require('koa-router')
const router = new Router({ prefix: '/questions' })
const { find, findById, create, update, delete: del, checkQuestionExist, checkQuestioner } = require('../controllers/questions')
const { secret } = require('../config')
const auth = jwt({ secret })
router.get('/', find)
router.post('/', auth, create)
router.get('/:id', checkQuestionExist, findById)
router.patch('/:id', auth, checkQuestionExist, checkQuestioner, update)
router.delete('/:id', auth, checkQuestionExist, checkQuestioner, del)
module.exports = router
// router/users.js 中新增一个列出问题列表的接口
13-3 话题-问题多对多关系设计与实现
操作步骤
- 实现问题的话题列表接口
- 实现话题的问题列表接口
- 使用 Postman 测试
// model
topics: {
type: [{ type: Schema.Types.ObjectId, ref: 'Topic' }],
select: false
}
// controller
async listQuestions (ctx) {
const questions = await Question.find({ topics: ctx.params.id })
ctx.body = questions
}
// router
router.get('/:id/questions', checkTopicExist, listQuestions)
答案模块需求分析
浏览知乎的答案模块功能
- 答案的增删改查
- 问题-答案/用户-答案一对多
- 赞/踩答案
- 收藏答案
问题-答案模块二级嵌套的增删改查接口
操作步骤
- 设计数据库的Schema
- 实现增删改查接口
- 使用 Postman 测试
// model
const mongoose = require('mongoose')
const { Schema, model } = mongoose;
const answerSchema = new Schema({
__v: { type: Number, select: false },
content: { type: String, required: true },
answerer: { type: Schema.Types.ObjectId, ref: 'User', required: true, select: false },
questionId: { type: String, required: true }
})
module.exports = model('Answer', answerSchema)
// controller
const Answer = require('../models/answers')
class AnswersCtl {
async find (ctx) {
const page = Math.max((ctx.query.page || 1) * 1, 1) - 1
const perPage = Math.max((ctx.query.per_page || 2) * 1, 1)
const q = new RegExp(ctx.query.q)
ctx.body = await Answer.find({ content: q, questionId: ctx.params.questionId }).limit(perPage).skip(page * perPage)
}
async checkAnswerExist (ctx, next) {
const answer = await Answer.findById(ctx.params.id).select('+answerer')
if (!answer) {
ctx.throw(404, '答案不存在!')
}
if (answer.questionId !== ctx.params.questionId) {
ctx.throw(404, '该问题下没有此答案')
}
ctx.state.answer = answer
await next()
}
async findById (ctx) {
const { fields = '' } = ctx.query
const selectFields = fields.split(';').filter(f => f).map(f => '+' + f).join('')
const answer = await Answer.findById(ctx.params.id).select(selectFields).populate('answerer')
ctx.body = answer
}
async create (ctx) {
ctx.verifyParams({
content: { type: 'string', required: true }
})
const answerer = ctx.state.user._id
const questionId = ctx.params.questionId
const answer = await new Answer({ ...ctx.request.body, answerer, questionId }).save()
ctx.body = answer
}
async checkAnswerer (ctx, next) {
const { answer } = ctx.state
if (answer.answerer.toString() !== ctx.state.user._id) { ctx.throw(403, '没有权限') }
await next()
}
async update (ctx) {
ctx.verifyParams({
content: { type: 'string', required: false }
})
await ctx.state.answer.update(ctx.request.body)
ctx.body = ctx.state.answer
}
async delete (ctx) {
await Answer.findByIdAndRemove(ctx.params.id)
ctx.status = 204
}
}
module.exports = new AnswersCtl()
// router
const jwt = require('koa-jwt')
const Router = require('koa-router')
const router = new Router({ prefix: '/questions/:questionId/answers' })
const { find, findById, create, update, delete: del, checkAnswerExist, checkAnswerer } = require('../controllers/answers')
const { secret } = require('../config')
const auth = jwt({ secret })
router.get('/', find)
router.post('/', auth, create)
router.get('/:id', checkAnswerExist, findById)
router.patch('/:id', auth, checkAnswerExist, checkAnswerer, update)
router.delete('/:id', auth, checkAnswerExist, checkAnswerer, del)
module.exports = router
14-3 互斥关系的赞/踩答案接口设计与实现
操作步骤
- 设计数据库Schema
- 实现接口
- 使用 Postman 测试
// users model
likingAnswers: {
type: [{ type: Schema.Types.ObjectId, ref: 'Answer' }],
select: false
},
dislikingAnswers: {
type: [{ type: Schema.Types.ObjectId, ref: 'Answer' }],
select: false
}
// answers model 增加字段
voteCount: { type: Number, required: true, default: 0 }
// 修改 checkAnswerExist
async checkAnswerExist (ctx, next) {
const answer = await Answer.findById(ctx.params.id).select('+answerer')
if (!answer) {
ctx.throw(404, '答案不存在!')
}
// 只有在删改查答案时才检查此逻辑,赞、踩答案时不检查
if (ctx.params.questionId && answer.questionId !== ctx.params.questionId) {
ctx.throw(404, '该问题下没有此答案')
}
ctx.state.answer = answer
await next()
}
// users controller
async listLikingAnswers (ctx) {
const user = await User.findById(ctx.params.id).select('+likingAnswers').populate('likingAnswers')
if (!user) {
ctx.throw(404, '用户不存在')
}
ctx.body = user.likingAnswers
}
async likeAnswer (ctx, next) {
const me = await User.findById(ctx.state.user._id).select('+likingAnswers')
if (!me.likingAnswers.map(id => id.toString()).includes(ctx.params.id)) {
me.likingAnswers.push(ctx.params.id)
me.save()
await Answer.findByIdAndUpdate(ctx.params.id, { $inc: { voteCount: 1 }})
}
ctx.status = 204
await next()
}
async unlikeAnswer (ctx) {
const me = await User.findById(ctx.state.user._id).select('+likingAnswers')
const index = me.likingAnswers.map(id => id.toString()).indexOf(ctx.params.id)
if (index > -1) {
me.likingAnswers.splice(index, 1)
me.save()
await Answer.findByIdAndUpdate(ctx.params.id, { $inc: { voteCount: -1 }})
}
ctx.status = 204
}
async listDislikingAnswers (ctx) {
const user = await User.findById(ctx.params.id).select('+dislikingAnswers').populate('dislikingAnswers')
if (!user) {
ctx.throw(404, '用户不存在')
}
ctx.body = user.dislikingAnswers
}
async dislikeAnswer (ctx, next) {
const me = await User.findById(ctx.state.user._id).select('+dislikingAnswers')
if (!me.dislikingAnswers.map(id => id.toString()).includes(ctx.params.id)) {
me.dislikingAnswers.push(ctx.params.id)
me.save()
}
ctx.status = 204
await next()
}
async undislikeAnswer (ctx) {
const me = await User.findById(ctx.state.user._id).select('+dislikingAnswers')
const index = me.dislikingAnswers.map(id => id.toString()).indexOf(ctx.params.id)
if (index > -1) {
me.dislikingAnswers.splice(index, 1)
me.save()
}
ctx.status = 204
}
// users router
router.get('/:id/likingAnswers', listLikingAnswers)
router.put('/likingAnswers/:id', auth, checkAnswerExist, likeAnswer, undislikeAnswer)
router.delete('/likingAnswers/:id', auth, checkAnswerExist, unlikeAnswer)
router.get('/:id/dislikingAnswers', listDislikingAnswers)
router.put('/dislikingAnswers/:id', auth, checkAnswerExist, dislikeAnswer, unlikeAnswer)
router.delete('/dislikingAnswers/:id', auth, checkAnswerExist, undislikeAnswer)
14-4 RESTful 风格的收藏答案接口
操作步骤
- 设计数据库Schema
- 实现接口
- 使用 Postman 测试
// user Schema 新增一个字段
collectingAnswers: {
type: [{ type: Schema.Types.ObjectId, ref: 'Answer' }],
select: false
}
// user controller
async listCollectingAnswers (ctx) {
const user = await User.findById(ctx.params.id).select('+collectingAnswers').populate('collectingAnswers')
if (!user) {
ctx.throw(404, '用户不存在')
}
ctx.body = user.collectingAnswers
}
async collectingAnswer (ctx, next) {
const me = await User.findById(ctx.state.user._id).select('+collectingAnswers')
if (!me.collectingAnswers.map(id => id.toString()).includes(ctx.params.id)) {
me.collectingAnswers.push(ctx.params.id)
me.save()
}
ctx.status = 204
await next()
}
async uncollectingAnswer (ctx) {
const me = await User.findById(ctx.state.user._id).select('+collectingAnswers')
const index = me.collectingAnswers.map(id => id.toString()).indexOf(ctx.params.id)
if (index > -1) {
me.collectingAnswers.splice(index, 1)
me.save()
}
ctx.status = 204
}
// user routes
router.get('/:id/collectingAnswers', listCollectingAnswers)
router.put('/collectingAnswers/:id', auth, checkAnswerExist, collectingAnswer)
router.delete('/collectingAnswers/:id', auth, checkAnswerExist, uncollectingAnswer)
第15章 评论模块
15-1 评论模块需求分析
浏览知乎的评论模块功能
评论模块功能点
- 评论的增删改查
- 答案-评论/问题-评论/用户-评论一对多
- 一级评论与二级评论
- 赞/踩评论(自行实现)
15-2 问题-答案-评论模块三级嵌套的增删改查接口
// comment model
const mongoose = require('mongoose')
const { Schema, model } = mongoose;
const commentSchema = new Schema({
__v: { type: Number, select: false },
content: { type: String, required: true },
commentator: { type: Schema.Types.ObjectId, ref: 'User', required: true, select: false },
questionId: { type: String, required: true },
answerId: { type: String, required: true }
})
module.exports = model('Comment', commentSchema)
// comment controller
const Comment = require('../models/comments')
class CommentsCtl {
async find (ctx) {
const page = Math.max((ctx.query.page || 1) * 1, 1) - 1
const perPage = Math.max((ctx.query.per_page || 2) * 1, 1)
const q = new RegExp(ctx.query.q)
const { questionId, answerId } = ctx.params;
ctx.body = await Comment.find({ content: q, questionId, answerId }).limit(perPage).skip(page * perPage).populate('commentator')
}
async checkCommentExist (ctx, next) {
const comment = await Comment.findById(ctx.params.id).select('+commentator')
if (!comment) {
ctx.throw(404, '评论不存在!')
}
// 只有在删改查答案时才检查此逻辑,赞、踩答案时不检查
if (ctx.params.questionId && comment.questionId !== ctx.params.questionId) {
ctx.throw(404, '该问题下没有此评论')
}
if (ctx.params.answerId && comment.answerId !== ctx.params.answerId) {
ctx.throw(404, '该答案下没有此评论')
}
ctx.state.comment = comment
await next()
}
async findById (ctx) {
const { fields = '' } = ctx.query
const selectFields = fields.split(';').filter(f => f).map(f => '+' + f).join('')
const comment = await Comment.findById(ctx.params.id).select(selectFields).populate('commentator')
ctx.body = comment
}
async create (ctx) {
ctx.verifyParams({
content: { type: 'string', required: true }
})
const commentator = ctx.state.user._id
const { questionId, answerId } = ctx.params
const comment = await new Comment({ ...ctx.request.body, commentator, questionId, answerId }).save()
ctx.body = comment
}
async checkCommentator (ctx, next) {
const { comment } = ctx.state
if (comment.commentator.toString() !== ctx.state.user._id) { ctx.throw(403, '没有权限') }
await next()
}
async update (ctx) {
ctx.verifyParams({
content: { type: 'string', required: false }
})
await ctx.state.comment.update(ctx.request.body)
ctx.body = ctx.state.comment
}
async delete (ctx) {
await Comment.findByIdAndRemove(ctx.params.id)
ctx.status = 204
}
}
module.exports = new CommentsCtl()
// comment routes
const jwt = require('koa-jwt')
const Router = require('koa-router')
const router = new Router({ prefix: '/questions/:questionId/answers/:answerId/comments' })
const { find, findById, create, update, delete: del, checkCommentExist, checkCommentator } = require('../controllers/comments')
const { secret } = require('../config')
const auth = jwt({ secret })
router.get('/', find)
router.post('/', auth, create)
router.get('/:id', checkCommentExist, findById)
router.patch('/:id', auth, checkCommentExist, checkCommentator, update)
router.delete('/:id', auth, checkCommentExist, checkCommentator, del)
module.exports = router
15-3 一级评论与二级评论接口的设计与实现
操作步骤
- 设计数据库的Schema
- 实现接口
- 使用 Postman 测试
// 修改comment.find
async find (ctx) {
const page = Math.max((ctx.query.page || 1) * 1, 1) - 1
const perPage = Math.max((ctx.query.per_page || 2) * 1, 1)
const q = new RegExp(ctx.query.q)
const { questionId, answerId } = ctx.params;
const { rootCommentId } = ctx.query
ctx.body = await Comment.find({ content: q, questionId, answerId, rootCommentId }).limit(perPage).skip(page * perPage).populate('commentator replyTo')
}
// 修改 comment.create
async create (ctx) {
ctx.verifyParams({
content: { type: 'string', required: true },
rootCommentId: { type: 'string', required: false },
replyTo: { type: 'string', required: false },
})
const commentator = ctx.state.user._id
const { questionId, answerId } = ctx.params
const comment = await new Comment({ ...ctx.request.body, commentator, questionId, answerId }).save()
ctx.body = comment
}
// 修改 comment.update
async update (ctx) {
ctx.verifyParams({
content: { type: 'string', required: false }
})
// 只允许更新content属性
const { content } = ctx.request.body
await ctx.state.comment.update({ content })
ctx.body = ctx.state.comment
}
// Comment Schema增加两个字段:
{
rootCommentId: { type: String },
replyTo: { type: Schema.Types.ObjectId, ref: 'User'}
}
15-4 添加日期
操作步骤
- 设计数据库Schema
- 实现接口
- 使用 Postman 测试
Schema的第二个参数:
{ timestamps: true }
第16章 丑媳妇终要见公婆:项目上线、部署与配置
16-1 在服务器上安装Git与Node.js
操作步骤
- ssh登陆到服务器
工具:gitbash
ssh username@ipAddress
- 安装Git,下载代码到服务器
ubuntu:
apt-get install git
git clone 代码仓库地址
- 安装 Node.js,运行程序
curl -SL https://deb.nodesource.com/setup_11.x | sudo -E bash -
sudo apt-get install -y nodejs
npm i
npm run dev
16-2 用NGINX实现端口转发
操作步骤
- 安装NGINX(Ubuntu)
apt-get install nginx
nginx -t //测试配置文件
vim 配置文件地址
- 配置NGINX,把外网 80 端口转到内网 3000 端口
server {
listen 80;
server_name: localhost;
location / {
proxy_pass http://127.0.0.1:3000
}
}
配置完后,重启NGINX:
service nginx restart
或:
service nginx reload
- 使用 Postman 测试外网接口
设置Postman环境变量
16-3 使用 PM2 管理进程
操作步骤
- 安装 PM2
npm i pm2 -g
- 使用 PM2 启动、停止、重启、重载程序
启动进程:pm2 start app
停止进程:pm2 stop app
停止所有进程:pm2 stop all
重启进程:pm2 restart app
重载程序:pm2 reload app
- 使用 PM2 的日志、环境变量管理功能
加环境变量:NODE_ENV=product pm2 start app --update-env
查看日志:pm2 log app
日志加上日期:NODE_ENV=product pm2 start app --update-env --log-date-format "YYYY-MM-DD HH:mm:ss"
第17章 回顾与总结
回顾课程
- REST 理论与最佳实践(REST 的六个限制)
- Koa2、MongoDB、JWT简介与实践
- 仿知乎REST API实战
重点难点
- REST 理论与实践
- JWT 原理及 Node.js 实现
- MongoDB Schema 设计
经验心得
- RESTful API 设计参考 GitHub API v3
- 使用 GitHub 搜索 Koa2 资源
- 使用 Stack Overflow 搜索问题
拓展建议
- 使用企业级 Node.js 框架 —— Egg.js
- 掌握多进程编程知识
- 学习使用日志和性能监控