app.js
配置 api,基础路径是 /api
Path: /signup
Method: POST
Body
参数名称 | 是否必传 | 类型 | 说明 |
---|---|---|---|
name | 是 | String | 昵称 |
是 | String | 邮箱地址(登录账户) | |
password | 是 | String | 密码 |
用户注册只能注册普通身份(
role = 0
)的用户,管理员角色可以手动去数据库修改role
为1
。
字段名称 | 说明 |
---|---|
name | 昵称 |
邮箱地址(登录账户) | |
role | 角色(普通用户 0; 管理员用户 1) |
createdAt | 创建 Schema 时指定分配的自动管理的 createdAt 字段 |
updatedAt | 创建 Schema 时指定分配的自动管理的 updatedAt 字段 |
_id | mongoose 默认分配的 id 字段,当访问 id 时就是获取的它 |
_v | mongoose 默认分配的 versionKey 字段 |
// models\user.js
const mongoose = require('mongoose')
// 创建 Schema
const userSchema = new mongoose.Schema(
{
// 昵称
name: {
type: String, // SchemaType(属性类型)
trim: true, // 是否在保存前对此值调用 `.trim()`
maxlength: [4, '昵称长度不能大于4'], // String 类型的内置验证器
required: [true, '请填写昵称'] // 必填验证器
},
// 邮件地址
email: {
type: String,
trim: true,
unique: true, // 唯一索引
required: [true, '请填写邮件地址']
},
// 加密后的密码(为了安全,不建议在数据库存储明文密码)
hashed_password: {
type: String,
required: true
},
// 盐
salt: String,
// 角色
role: {
type: Number,
default: 0, // 普通用户 0; 管理员用户 1
enum: [[0, 1], '{VALUE} 不是有效的值']
}
},
{
timestamps: true // 向 Schema 分配 createdAt 和 updatedAt 属性并自动管理
}
)
module.exports = mongoose.model('User', userSchema)
密码加密需要用到虚拟属性(Virtual),虚拟属性不会保存到 MongoDB,它可以通过设置 setter/getter
处理其它属性的数据。
定义一个 password
虚拟属性,当触发 setter
的时候,为用户生成 UUID 的盐(salt
),使用 salt
和 password
生成加密后的密码,存储到 hashed_password
。
UUID 全称 Universallly Unique Identifier
,中文是通用唯一识别码
。
由 32 个 16进制 数字构成,按照 8-4-4-4-12
形式以 -
分隔的字符串,如:
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
如:30385d15-0a88-42eb-bc43-2c000e9f778c
M
表示 UUID 版本,目前有5个版本:1,2,3,4,5
只有四个值:
8,9,a,b`UUID 版本选择:
// models\user.js
const mongoose = require('mongoose')
const crypto = require('crypto')
const { v1: uuidv1 } = require('uuid')
// 创建 Schema
const userSchema = new mongoose.Schema(
{
// 昵称
name: {
type: String, // Schema(属性类型)
trim: true, // 是否在保存前对此值调用 `.trim()`
maxlength: [4, '昵称长度不能大于4'], // String 类型的内置验证器
required: [true, '请填写昵称'] // 必填验证器
},
// 邮件地址
email: {
type: String,
trim: true,
unique: true, // 唯一索引
required: [true, '请填写邮件地址']
},
// 加密后的密码(为了安全,不建议在数据库存储明文密码)
hashed_password: {
type: String,
required: true
},
// 盐
salt: String,
// 角色
role: {
type: Number,
default: 0, // 普通用户 0; 管理员用户 1
enum: [[0, 1], '{VALUE} 不是有效的值']
}
},
{
timestamps: true // 向 Schema 分配 createdAt 和 updatedAt 属性并自动管理
}
)
// 添加实例方法
userSchema.method({
// 密码加密
encryptPassword(password) {
if (!password) return ''
// crypto 是 Node.js 内置的提供了加密功能的模块
return crypto.createHmac('sha1', this.salt).update(password).digest('hex')
}
})
// 定义虚拟属性 password
userSchema
.virtual('password')
.set(function (password) {
this._password = password
// 生成 salt
this.salt = uuidv1()
// 生成加密密码
this.hashed_password = this.encryptPassword(password)
})
.get(function () {
return this._password
})
module.exports = mongoose.model('User', userSchema)
// controllers\user.js
const User = require('../models/user')
// 注册
const signup = (req, res) => {
// 创建用户
const user = new User(req.body)
// 插入数据库
// save 可以接收一个 error-first 格式回调函数
// 回调函数的第二个参数 `user` 是自身实例对象
user.save((error, user) => {
// 插入失败
if (error) {
// 响应
return res.status(400).json(error)
}
// 删除不需要返回客户端的字段
// undefined 的字段不会被返回
user.salt = undefined
user.hashed_password = undefined
// 响应
res.json(user)
})
}
module.exports = {
signup,
signin
}
// routes\user.js
const express = require('express')
const { signup } = require('../controllers/user')
const router = express.Router()
// 注册
router.post('/signup', signup)
module.exports = router
// app.js
const express = require('express')
const cors = require('cors')
const morgan = require('morgan')
const bodyParse = require('body-parse')
const cookieParser = require('cookie-parser')
// api
const userRoutes = require('./routes/user')
// ...
// 接口配置
app.use('/api', userRoutes)
const APP_PORT = process.env.APP_PORT || 80
app.listen(APP_PORT, () => {
console.log(`服务器启动成功,监听 ${APP_PORT} 端口`)
})
使用 postman 测试接口:POST 请求 http://localhost/api/signup
请求参数:
{
"name": "张三",
"email": "zhangsan@163.com",
"password": "123456"
}
正常返回:
{
"role": 0,
"_id": "....",
"name": "张三",
"email": "zhangsan@163.com",
"createdAt": "....",
"updatedAt": "....",
"__v": 0
}
可以打开 Robo 3T 查看数据库存储的 salt 和 hashed_password。
mongoose 文档中关于 enum
带错误提示验证的使用有两种方法:
enum: [[0, 1], '{VALUE} 不是有效值']
enum: { values: [0, 1], message: '{VALUE} 不是有效语法' }
但是经测试,对象语法并没有生效,所以这里使用的数组语法。
不过有时使用数组语法的时候,mongoose 会将它的枚举值(数组第一项)和错误提示(数组第二项)作为判断的枚举值,如上例会将属性的枚举值限制认定为 [0, 1]
和 {VALUE} 不是有效值
,而不是正确的 0
和 1
。
网上并没有找到相关原因,替代的方案是自定义一个校验器 validation:
validate: {
validator: function(v) {
return [0, 1].includes(v);
},
message: props => `${props.value} 不是有效值`
},
注意:校验器只会在 save()
方法执行前触发,update()
相关的方法并不会执行校验。
尝试使用相同的 email
(设置了 unique 的属性)去请求注册,向数据库插入数据,接口会失败并返回:
{
"driver": true,
"name": "MongoError",
"index": 0,
"code": 11000,
"keyPattern": {
"email": 1
},
"keyValue": {
"email": "zhangsan@163.com"
}
}
当视图违反 unique 约定的时候,MongoDB 就会返回 11000
错误。
这与 Mongoose 返回的错误对象格式不同:
// Mongoose 的错误对象示例
{
"errors": {
"name": {
"name": "ValidatorError",
"message": "昵称长度不能大于4",
"properties": {
"message": "昵称长度不能大于4",
"type": "maxlength",
"maxlength": 4,
"path": "name",
"value": "张三12345"
},
"kind": "maxlength",
"path": "name",
"value": "张三12345"
}
},
"_message": "User validation failed",
"name": "ValidationError",
"message": "User validation failed: name: 昵称长度不能大于4"
}
为了更方便的处理错误,可以使用插件 mongoose-unique-validator
将其转化成 Mongoose 格式的错误,还可以自定义错误消息。
配置插件:
// models\user.js
const mongoose = require('mongoose')
const crypto = require('crypto')
const { v1: uuidv1 } = require('uuid')
const uniqueValidator = require('mongoose-unique-validator')
// ...
// 配置插件
userSchema.plugin(uniqueValidator, {
message: '{VALUE} 已经存在,请更换'
})
module.exports = mongoose.model('User', userSchema)
测试结果:
{
"errors": {
"email": {
"name": "ValidatorError",
"message": "zhangsan@163.com 已经存在,请更换",
"properties": {
"message": "zhangsan@163.com 已经存在,请更换",
"type": "unique",
"path": "email",
"value": "zhangsan@163.com"
},
"kind": "unique",
"path": "email",
"value": "zhangsan@163.com"
}
},
"_message": "User validation failed",
"name": "ValidationError",
"message": "User validation failed: email: zhangsan@163.com 已经存在,请更换"
}
现在的校验都是 Mongoose 和 MongoDB 的校验机制,接口应该在执行数据库操作前进行预前置验环节。
express 可以通过添加回调的方式增加校验环节,类似中间件。
express-validator 提供了一组验证器(Validators)和消毒器(Sanitizers)的中间件,内部处理自己附加的,还包装了 validator.js 的全部验证器和消毒器。
req.check()
等旧版本的方法已被弃用(v6.x 版本已被启动,v5.x 还可以使用,但已声明是遗留 API)validationResult(req)
方法获取验证结果对象
// validator\user.js
const { body, validationResult } = require('express-validator')
// 判断校验是否成功
const validResultCallback = (req, res, next) => {
// 获取校验结果
const result = validationResult(req)
if (!result.isEmpty()) {
// result.array() 以数组形式返回验证结果,相同字段的验证结果会全部返回
// result.mapped() 以对象形式返回验证结果,相同字段的验证结果只会返回第一个
return res.status(400).json({ errors: result.array() })
}
next()
}
// 注册校验
// express route 配置中间件回调函数可以依次添加,也可以合并成一个数组添加
const userSignupValidator = [
// body([fields, message]) 只验证请求主体 body 中的字段
// fields 是要验证的字段名称字符串或数组
// message 是默认的验证错误提示,如果没有为验证器指定验证错误提示就会使用它
body('name', '请传入昵称')
// 校验是否为空
.notEmpty(),
body('email', '邮箱最少4个字符,最多32个字符')
// 正则校验
.matches(/.+@.+\..+/)
// withMessage(message) 为上一个验证器指定验证错误提示
.withMessage('邮箱地址格式不正确')
// 长度范围校验
// undefined 为`不限`
.isLength({
min: 4,
max: 32
}),
body('password', '请传入密码')
.notEmpty()
.isLength({ min: 6 })
.withMessage('密码不能少于6位')
.matches(/\d/)
.withMessage('密码至少包含一个数字'),
validResultCallback
]
module.exports = {
userSignupValidator
}
添加中间件:
// routes\user.js
const express = require('express')
const { signup } = require('../controllers/user')
const { userSignupValidator } = require('../validator/user')
const router = express.Router()
// 注册
router.post('/signup', userSignupValidator, signup)
module.exports = router
// 请求参数
{
"name": "",
"email": "123",
"password": "a"
}
// 返回结果
{
"errors": [
{
"value": "",
"msg": "请传入昵称",
"param": "name",
"location": "body"
},
{
"value": "123",
"msg": "邮箱地址格式不正确",
"param": "email",
"location": "body"
},
{
"value": "123",
"msg": "邮箱最少4个字符,最多32个字符",
"param": "email",
"location": "body"
},
{
"value": "a",
"msg": "密码不能少于6位",
"param": "password",
"location": "body"
},
{
"value": "a",
"msg": "密码至少包含一个数字",
"param": "password",
"location": "body"
}
]
}
现在 Mongoose 和 express-validatior 返回的错误信息结构不一样,且不方便提取 message。
// Mongoose 错误信息
{
errors: {
name: {
message: '...'
...
}
}
...
}
// express-validator result.array() 错误信息
[
{
msg: '...',
...
},
...
]
定义一个辅助函数,专门从它们的错误信息对象中提取收集 message。
// helpers\dbErrorHandler.js
// 统一提取收集错误信息
const errorHandler = errorInfo => {
const message = []
if (typeof errorInfo === 'string') {
// 自定义错误提示
message.push(errorInfo)
} else if (Array.isArray(errorInfo)) {
// express-validator 错误信息
errorInfo.forEach(error => {
if (error.msg) {
message.push(error.msg)
}
})
} else {
// Mongoose 错误信息
const { errors } = errorInfo
Object.keys(errors).forEach(field => {
if (errors[field].message) {
message.push(errors[field].message)
}
})
}
return { errors: message }
}
module.exports = {
errorHandler
}
应用辅助函数:
// validator\user.js
const { body, validationResult } = require('express-validator')
const { errorHandler } = require('../helpers/dbErrorHandler')
// 判断校验是否成功
const validResultCallback = (req, res, next) => {
const result = validationResult(req)
if (!result.isEmpty()) {
return res.status(400).json(errorHandler(result.array()))
}
next()
}
...
// controllers\user.js
const User = require('../models/user')
const { errorHandler } = require('../helpers/dbErrorHandler')
// 注册
const signup = (req, res) => {
const user = new User(req.body)
user.save((error, user) => {
if (error) {
// 响应
return res.status(400).json(errorHandler(error))
}
user.salt = undefined
user.hashed_password = undefined
res.json(user)
})
}
module.exports = {
signup
}
请求失败返回数据格式示例:
{
"errors": [
"请传入昵称",
"邮箱地址格式不正确",
"邮箱最少4个字符,最多32个字符",
"请传入密码",
"密码不能少于6位",
"密码至少包含一个数字"
]
}
Path: /signin
Method: POST
Body
参数名称 | 是否必传 | 类型 | 说明 |
---|---|---|---|
是 | String | 邮箱地址(登录账户) | |
password | 是 | String | 密码 |
字段名称 | 说明 |
---|---|
token | 登录认证 |
user | 用户信息对象 |
├─ _id | mongoose 默认分配的 id 属性 |
├─ name | 昵称 |
邮箱地址(登录账户) | |
├─ role | 角色(普通用户 0; 管理员用户 1) |
# .env
# 服务启动监听端口
APP_PORT=80
# JWT 密钥
JWT_SECRET=test
# 本地数据库连接信息
# 连接地址
DB_HOST=localhost
# 数据库名称
DB_NAME=ecommerce
# 用户名(默认为空)
DB_USER=
# 密码(默认为空)
DB_PASS=
# 端口(默认27017)
DB_PORT=27017
// models\user.js
...
// 添加实例方法
userSchema.method({
// 密码加密
encryptPassword(password) {...},
// 验证密码
authenticate(password) {
return this.encryptPassword(password) === this.hashed_password
}
})
...
// controllers\user.js
const User = require('../models/user')
const { errorHandler } = require('../helpers/dbErrorHandler')
const jwt = require('jsonwebtoken')
const _ = require('lodash')
// 注册
const signup = (req, res) => {...}
// 登录
const signin = (req, res) => {
// 获取 email 和 password
const { email, password } = req.body
// 根据 email 查找用户
User.findOne({ email }, (error, user) => {
// 查询失败
// 查询结果为空也属于查询成功,所以要判断结果是否为空
if (error || !user) {
// 响应
return res.status(400).json(errorHandler('用户不存在'))
}
// 验证密码
if (!user.authenticate(password)) {
// 响应
return res.status(400).json(errorHandler('邮箱和密码不匹配'))
}
// 生成 token
// jwt.sign(payload, secretOrPrivateKey)
const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET)
// 响应
return res.json({
token,
// lodash 的 pick() 方法基于传递的对象生成包含指定属性的新对象
user: _.pick(user, ['_id', 'name', 'email', 'role'])
})
})
}
module.exports = {
signup,
signin
}
// validator\auth.js
const { body, validationResult } = require('express-validator')
const { errorHandler } = require('../helpers/dbErrorHandler')
// 判断校验是否成功
const validResultCallback = (req, res, next) => {...}
// 注册校验
const userSignupValidator = [...]
// 登录校验
const userSigninValidator = [
body('email')
.notEmpty()
.withMessage('请传入邮箱')
.matches(/.+@.+\..+/)
.withMessage('邮箱地址格式不正确'),
body('password').notEmpty().withMessage('请传入密码'),
validResultCallback
]
module.exports = {
userSignupValidator,
userSigninValidator
}
// routes\user.js
const express = require('express')
const { signup, signin } = require('../controllers/user')
const { userSignupValidator, userSigninValidator } = require('../validator/user')
const router = express.Router()
// 注册
router.post('/signup', userSignupValidator, signup)
// 登录
router.post('/signin', userSigninValidator, signin)
module.exports = router
// 请求
{
"email": "zhangsan@163.com",
"password": "123456"
}
// 返回
{
"token": "...",
"user": {
"_id": "...",
"name": "张三",
"email": "zhangsan@163.com",
"role": 0
}
}
Path: /user/:userId
Method: GET
Headers
参数名称 | 是否必须 | 备注 |
---|---|---|
Authorization | 是 | 认证token,格式 Bearer <JSON WEB TOKEN> |
字段名称 | 说明 |
---|---|
role | 角色(普通用户 0; 管理员用户 1) |
_id | mongoose 默认分配的 id 字段 |
name | 昵称 |
邮箱地址(登录账户) | |
createdAt | 创建 Schema 时指定分配的自动管理的 createdAt 字段 |
updatedAt | 创建 Schema 时指定分配的自动管理的 updatedAt 字段 |
Authorization
中的 token,获取 token 中的用户 idexpress 的路由方法router.param(name, callback)
用于监听请求路径上的参数(param),当监听的参数出现在请求路径上时会先执行回调逻辑,再匹配后续的路由。
例如 router.param('id', myCallback)
当请求 /user/:id
的时候就会执行回调 myCallback
。
回调中可以进行数据库查询,向 req
扩展数据等操作。
router.param()
的回调函数接收的第四个参数是监听参数的值。
// controllers\user.js
const User = require('../models/user')
const { errorHandler } = require('../helpers/dbErrorHandler')
const jwt = require('jsonwebtoken')
const _ = require('lodash')
// 注册
const signup = (req, res) => {...}
// 登录
const signin = (req, res) => {...}
// 根据 id 获取用户信息的中间件
const getUserById = (req, res, next, id) => {
User.findById(id, (error, user) => {
if (error || !user) {
return res.status(400).json(errorHandler('用户没找到'))
}
// 将查询到的用户信息存储再 request 对象上供后续路由使用
req.profile = user
next()
})
}
// 返回用户信息
const read = (req, res) => {
// 删除不必要的字段
req.profile.hashed_password = undefined
req.profile.salt = undefined
// 响应
res.json(req.profile)
}
module.exports = {
signup,
signin,
getUserById,
read
}
使用 router.param()
:
// routes\user.js
const express = require('express')
const { signup, signin, getUserById, read } = require('../controllers/user')
const { userSignupValidator, userSigninValidator } = require('../validator/user')
const router = express.Router()
// 注册
router.post('/signup', userSignupValidator, signup)
// 登录
router.post('/signin', userSigninValidator, signin)
// 根据 id 获取用户信息
router.get('/user/:userId', read)
// 监听路由参数
// 根据 id 获取用户信息
router.param('userId', getUserById)
module.exports = router
express-jwt 是 Express 框架的中间件,它通过 jsonwebtoken 模块解析请求报文中的 JWT。
该模块默认解析请求头中的 Authorization
字段,并默认按照 OAuth2 Bearer token 去解析。
OAuth2 Bearer token 的格式是 Bearer <JSON WEB TOKEN>
,所以请求头中的 Authorization
传递 token 的时候要在前面拼接 Bearer
,也可以编写自定义解析方法自定义 Authorization
的格式。
express-jwt 会解析 JWT 中的 payload,并将其存储在 req
上供后续路由使用。
可以通过 requestProperty
指定存储在 req
的属性名,默认 user
。
// validator\user.js
const { body, validationResult } = require('express-validator')
const { errorHandler } = require('../helpers/dbErrorHandler')
const expressJwt = require('express-jwt')
// 判断校验是否成功
const validResultCallback = (req, res, next) => {...}
// 注册校验
const userSignupValidator = [...]
// 登录校验
const userSigninValidator = [...]
// token 解析
// header => Authorization: Bearer <JSON WEB TOKEN>
const tokenParser = expressJwt({
secret: process.env.JWT_SECRET,
algorithms: ['HS256'], // token 算法,jsonwebtoken 默认 HS256
requestProperty: 'auth' // 将 payload 存储到 req.auth
})
// 判断获取的用户信息是否是登录人的用户信息
const authValidator = (req, res, next) => {
// req.profile => 通过用户 id 获取的用户信息
// req.auth => token 中解析的登录人的用户信息
// _id 是 Mongoose 自动分配并设置的 id 属性,如果没有该字段就无法保存 document
// _id 默认是由 ObjectId 实例化的对象,可通过调用它的 toString() 方法转化为字符串
if (!(req.profile && req.auth && req.profile._id.toString() === req.auth.id)) {
return res.status(403).json(errorHandler('用户认证失败'))
}
return res.status(403).json(errorHandler('用户认证失败'))
}
next()
}
module.exports = {
userSignupValidator,
userSigninValidator,
tokenParser,
authValidator
}
配置中间件:
// routes\user.js
const express = require('express')
const { signup, signin, getUserById, read } = require('../controllers/user')
const { userSignupValidator, userSigninValidator, tokenParser, authValidator } = require('../validator/user')
const router = express.Router()
// 注册
router.post('/signup', userSignupValidator, signup)
// 登录
router.post('/signin', userSigninValidator, signin)
// 根据 id 获取用户信息
router.get('/user/:userId', [tokenParse, authValidator], read)
// 监听路由参数
// 根据 id 获取用户信息
router.param('userId', getUserById)
module.exports = router
Path: /user/:userId
Method: PUT
本例 API 遵循 RESTful 规范,从语义上选择,PUT是幂等方法,POST不是,PUT用于更新、POST用于新增。
Headers
参数名称 | 是否必须 | 备注 |
---|---|---|
Authorization | 是 | 认证token,格式 Bearer <JSON WEB TOKEN> |
Body
参数名称 | 是否必传 | 类型 | 说明 |
---|---|---|---|
name | 否 | String | 昵称(如果传了则修改昵称) |
是 | String | 邮箱地址(登录账户) | |
password | 否 | String | 密码(如果传了则修改密码) |
字段名称 | 说明 |
---|---|
name | 昵称 |
邮箱地址(登录账户) | |
role | 角色(普通用户 0; 管理员用户 1) |
createdAt | 创建 Schema 时指定分配的自动管理的 createdAt 字段 |
updatedAt | 创建 Schema 时指定分配的自动管理的 updatedAt 字段 |
_id | mongoose 默认分配的 id 字段,当访问 id 时就是获取的它 |
_v | mongoose 默认分配的 versionKey 字段 |
// controllers\user.js
const User = require('../models/user')
const { errorHandler } = require('../helpers/dbErrorHandler')
const jwt = require('jsonwebtoken')
const _ = require('lodash')
// 注册
const signup = (req, res) => {...}
// 登录
const signin = (req, res) => {...}
// 根据 id 获取用户信息的中间件
const getUserById = (req, res, next, id) => {...}
// 返回用户信息
const read = (req, res) => {...}
// 根据 id 更新用户信息
const update = (req, res) => {
const { name, password } = req.body
const user = req.profile
if (name) {
user.name = name
}
if (password) {
// password 的 setter 会触发生成 hashed_password
user.password = password
}
user.save((error, updateUser) => {
if (error) {
return res.status(400).json(errorHandler(error))
}
// 删除不必要的字段
updateUser.hashed_password = undefined
updateUser.salt = undefined
// 响应
res.json(updateUser)
})
}
module.exports = {
signup,
signin,
getUserById,
read,
update
}
// validator\user.js
...
// 根据 id 更新用户信息
const updateValidator = [
body('password')
// 如果传递了 password(不是 undefined)就继续校验
.if(body('password').exists())
.isLength({ min: 6 })
.withMessage('密码不能少于6位')
.matches(/\d/)
.withMessage('密码至少包含一个数字'),
validResultCallback
]
module.exports = {
userSignupValidator,
userSigninValidator,
tokenParser,
authValidator,
updateValidator
}
// routes\user.js
const express = require('express')
const { signup, signin, getUserById, read, update } = require('../controllers/user')
const {
userSignupValidator,
userSigninValidator,
tokenParser,
authValidator,
updateValidator
} = require('../validator/user')
const router = express.Router()
// 注册
router.post('/signup', userSignupValidator, signup)
// 登录
router.post('/signin', userSigninValidator, signin)
// 根据 id 获取用户信息
router.get('/user/:userId', [tokenParser, authValidator], read)
// 根据 id 更新用户信息
router.put('/user/:userId', updateValidator, [tokenParser, authValidator], update)
// 监听路由参数
// 根据 id 获取用户信息
router.param('userId', getUserById)
module.exports = router