Node.js仿知乎服务端-深入理解RESTful API2020-01-13

课程地址: 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. 资源的标识

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 = '<h1>这是主页</h1>'
  } 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 = '<h1>这是主页</h1>'
})

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 工作原理

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测试
  1. 将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章 项目实战之问题模块——复杂的数据库设计

问题模块需求分析

浏览知乎的问题模块功能

image.png

问题模块功能点

  • 问题的增删改查
  • 用户的问题列表
  • 话题的问题列表+问题的话题列表(话题-问题多读多关系)
  • 关注、取消关注问题

用户-问题一对多关系设计与实现

操作步骤

  • 实现增删改查接口
  • 实现用户的问题列表接口
  • 使用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
  • 掌握多进程编程知识
  • 学习使用日志和性能监控
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容

  • 一说到REST,我想大家的第一反应就是“啊,就是那种前后台通信方式。”但是在要求详细讲述它所提出的各个约束,以及如...
    时待吾阅读 3,390评论 0 19
  • REST 为什么会有REST呢? Web服务已经成为异构系统之间的互联于集成的主要手段,过去Web服务几乎都是采用...
    JunChow520阅读 3,094评论 0 4
  • 回不去的惆怅 有些时候 真的好想回去 回到从未开始的开始 带着自己最美的期许 不错过一个细节 哪怕一个个小小的细节...
    朱三妹阅读 489评论 2 3
  • 最近阿牙被一个内容极度舒适的旅行节目诱惑到了,今天也在这里推荐给大家。 这个叫做《旅行者》的韩国真人秀是由韩国演员...
    如果一起旅行阅读 266评论 0 2
  • 一个人最大的失败,莫过于凡事浅尝辄止。人这一辈子,必须要对某件事情奋不顾身。越过山丘,方能有人等候。 越投入,越幸...
    Carly0502阅读 152评论 0 0