新建 apiServer 文件夹作为项目根目录,并在项目根目录中运行如下的命令,初始化包管理配置文件:
npm init -y
运行如下的命令,安装 express、cors:
npm i express cors
在项目根目录中新建 app.js 作为整个项目的入口文件,配置 cors 跨域中间件、解析表单数据的中间件,并启动服务器:
// 导入 express 模块
const express = require('express');
// 创建 express 的服务器实例
const app = express();
// 配置跨域中间件
const cors = require('cors');
app.use(cors());
// 配置解析 application/x-www-form-urlencoded 格式的表单数据的中间件
app.use(express.urlencoded({ extended: false }));
// 指定端口号并启动web服务器
app.listen('80', function() {
console.log('server running at http://127.0.0.1:80');
});
因为在处理函数中,需要多次调用 res.send() 向客户端响应 处理失败 的结果,为了简化代码, 可以手动封装一个 res.cc() 函数。在 app.js 中所有路由之前,声明一个全局中间件,为 res 对象挂载一个 函数 :
// 在 路由 之前自定义中间件,处理错误的响应数据
app.use(function(req, res, next) {
// status 默认值1,表示失败,0表示成功
// err 可能是错误对象,也可能是错误描述字符串
res.cc = function(err, status = 1) {
res.send({
status: status,
message: err instanceof Error ? err.message : err
});
};
next();
});
新建 users 表,在 my_db_01 数据库中,新建 ev_users 表如下:
安装并配置 mysql 模块,需要安装并配置 mysql 这个第三方模块,来连接和操作 MySQL 数据库,运行如下命令,安装 mysql 模块:
npm i mysql
在项目根目录中 db 文件夹新建 index.js 文件,在此自定义模块中创建数据库的连接对象:
const mysql = require('mysql');
const db = mysql.createPool({
host: 'localhost',
port: '3306',
user: 'root',
password: 'admin123',
database: 'my_db_01'
});
module.exports = db;
在 router 文件夹中,新建 user.js 文件,作为用户的路由模块,并初始化代码如下:
const express = require('express') // 创建路由对象
const router = express.Router()
// 注册新用户
router.post('/signin', (req, res) => {
res.send('signin OK')
})
// 登录
router.post('/login', (req, res) => {
res.send('login OK')
})
// 将路由对象共享出去 module.exports = router
在 app.js 中,导入并使用 用户路由模块 :
// 配置路由
const user = require('./router/user');
app.use('/api', user);
为了保证 路由模块 的纯粹性,所有的 路由处理函数,必须抽离到对应的 路由处理函数模块中。
在 /routerHandler/user.js 中,使用 exports 对象,分别向外共享如下两个 路由处理函数:
/**
* 在这里定义和用户相关的路由处理函数,供 /router/user.js 模块进行调用 */
// 注册用户的处理函数
exports.signin = (req, res) => {
res.send('signin OK')
}
// 登录的处理函数
exports.login = (req, res) => {
res.send('login OK')
}
将 /router/user.js 中的代码修改为如下结构:
const express = require('express');
const router = express.Router();
const handler = require('../routerHandler/user');
// 注册
router.post('/signin', handler.signin);
// 登录
router.post('/login', handler.login);
module.exports = router;
表单验证的原则:前端验证为辅,后端验证为主,后端 永远不要相信 前端提交过来的 任何内容。
在实际开发中,前后端都需要对表单的数据进行合法性的验证,而且, 后端做为数据合法性验证的最后 一个关口 ,在拦截非法数据方面,起到了至关重要的作用。
单纯的使用 if…else… 的形式对数据合法性进行验证,效率低下、出错率高、维护性差。因此, 推荐使用 第三方数据验证模块 ,来降低出错率、提高验证的效率与可维护性, 让后端程序员把更多的精力放在核心业务逻辑的处理上。
安装 joi 包,为表单中携带的每个数据项,定义验证规则:
npm install joi
在 /middleware/expressJoi.js 中,使用 joi 对象,定义并向外共享表单数据验证中间件:
const joi = require('joi');
const expressJoi = function (schemas, options = { strict: false }) {
// 自定义校验选项
// strict 自定义属性,默认不开启严格模式,会过滤掉那些未定义的参数项
// 如果用户指定了 strict 的值为 true,则开启严格模式,此时不会过滤掉那些未定义的参数项
if (!options.strict) {
// allowUnknown 允许提交未定义的参数项
// stripUnknown 过滤掉那些未定义的参数项
options = { allowUnknown: true, stripUnknown: true, ...options }
}
// 从 options 配置对象中,删除自定义的 strict 属性
delete options.strict
// TODO: 用户指定了什么 schema,就应该校验什么样的数据
return function (req, res, next) {
['body', 'query', 'params'].forEach(key => {
// 如果当前循环的这一项 schema 没有提供,则不执行对应的校验
if (!schemas[key]) return
// 执行校验
const schema = joi.object(schemas[key])
const { error, value } = schema.validate(req[key], options)
if (error) {
console.log('---------------');
// 校验失败
throw error
} else {
// 校验成功,把校验的结果重新赋值到 req 对应的 key 上
req[key] = value
}
})
// 校验通过
next()
}
}
module.exports = expressJoi
新建 /schema/user.js 用户信息验证规则模块,并初始化代码如下:
// @hapi/joi 包,为表单中携带的每个数据项,定义验证规则
const joi = require('joi');
// * string() 值必须是字符串
// * alphanum() 值只能是包含 a-zA-Z0-9 的字符串 * min(length) 最小长度
// * max(length) 最大长度
// * required() 值是必填项,不能为 undefined
// * pattern(正则表达式) 值必须符合正则表达式的规则
const username = joi.string().alphanum().min(2).max(20).required();
const password = joi.string().required().pattern(/^[\S]{6,16}$/);
exports.schema = {
user: {
body: {
username,
password
}
}
}
修改 /router/user.js 中的代码如下:
// 导入验证表单数据的中间件
const expressJoi = require('../middleware/expressJoi');
// 导入需要的验证规则对象
const { schema } = require('../schema/user');
// 为 注册新用户 配置表单验证中间件
// 在注册新用户的路由中,声明局部中间件,对当前请求中携带的数据进行验证
// 数据验证通过后,会把这次请求流转给后面的路由处理函数
// 数据验证失败后,终止后续代码的执行,并抛出一个全局的 Error 错误,进入全局错误级别中间件中进行 处理
router.post('/signin', expressJoi(schema.user), handler.signin);
// 登录
router.post('/login', expressJoi(schema.user), handler.login);
在 app.js 的全局错误级别中间件中,捕获验证失败的错误,并把验证失败的结果响应给客户端:
// 配置 错误处理 中间件
const joi = require('joi');
app.use(function(err, req, res, next) {
// console.log(err);
if (err instanceof joi.ValidationError) {
// 处理数据校验错误
} else {
// 处理其他未知错误
}
return res.cc(err);
});
为了保证密码的安全性,不建议在数据库以 明文 的形式保存用户密码,推荐对密码进行 加密存储。使用 对用户密码进行加密,优点:
运行如下命令,安装 bcryptjs:
npm i bcryptjs
调用 bcrypt.hashSync(明文密码, 随机盐的长度) 方法,对用户的密码进行加密处理:
const bcrypt = require('bcryptjs');
// 对用户的密码,进行 bcrype 加密,返回值是加密之后的密码字符串
userinfo.password = bcrypt.hashSync(userinfo.password, 10);
调用 bcrypt.compareSync(用户提交的密码, 数据库中的密码) 方法比较密码是 否一致,返回值是布尔值(true 一致、false 不一致):
// 拿着用户输入的密码,和数据库中存储的密码进行对比
const compareResult = bcrypt.compareSync(userinfo.password, results[0].password);
// 如果对比的结果等于 false, 则证明用户输入的密码错误
if (!compareResult) {
console.log('密码错误!');
}
新建 /middleware/constValue.js 常量数据模块,并初始化代码如下:
// sql 语句
// 查找用户名
const selectUN = `select * from users where username = ?`;
// 插入用户
const insertUser = `insert into users set ?`;
module.exports = {
selectUN,
insertUser
}
修改 /routerHandler/user.js 中的代码,实现最终完整的登录逻辑,如下:
const bcrypt = require('bcryptjs');
const constValue = require('../middleware/constValue');
const signin = (req, res) => {
const info = req.body;
// 数据校验交给 expressJoi 中间件处理
// if (info.username && info.password) {
db.query(constValue.selectUN, info.username, function(err, result) {
if (err) {
res.cc(err);
} else if (result.length > 0) {
res.cc('用户名被占用,请更换其他用户名!');
} else {
// 调用 bcrypt.hashSync(明文密码, 随机盐的长度) 方法,对用户的密码进行加密处理
info.password = bcrypt.hashSync(info.password, 10);
db.query(constValue.insertUser, {username: info.username, password: info.password}, function(err, result) {
if (err) {
res.cc(err);
} else if (result.affectedRows === 1) {
res.cc('注册成功!', 0);
} else {
res.cc('注册失败,请稍后重试!');
}
});
}
});
// } else {
// res.cc('用户名或密码不能为空!');
// }
};
在 /middleware/constValue.js 定义并导出查询用户数据的 SQL 语句以及 JWT 秘钥、加密算法、无权限表达式:
// 查找用户信息
const selectInfo = `select id, username, nickname, email, user_pic from users where id = ?`;
// JWT 秘钥
const jwtSecretKey = 'JWTSecretKeyabcABC123!@#JWTSecretKey';
// JWT 加密算法
const jwtAlgorithms = 'HS256';
// JWT 无需权限的接口 表达式
const jwtUnlessPath = /^\/api\//;
运行如下的命令,安装生成 Token 字符串的包、解析 Token 的中间件的包:
npm i jsonwebtoke express-jwt
核心注意点: 在生成 Token 字符串的时候,一定要剔除 密码 和 头像 的值 通过 ES6 的高级语法,快速剔除 密码 和 头像 的值,将用户信息对象加密成 Token 字符串:
// 通过 ES6 的高级语法,快速剔除用户敏感信息后的数据作为 token 的加密数据
const user = { ...result[0], password: '', user_pic: '' }
// token 有效期为 10 天
const token = jwt.sign(user, constValue.jwtSecretKey, { expiresIn: '10d' });
在 app.js 中注册路由之前,配置解析 Token 的中间件:
// 配置 解析 Token 的中间件
var { expressjwt } = require('express-jwt');
const constValue = require('./middleware/constValue');
app.use(expressjwt({
secret: constValue.jwtSecretKey,
algorithms: [constValue.jwtAlgorithms]
}).unless({
path: [constValue.jwtUnlessPath]
}));
在 app.js 中的 错误级别中间件里面,捕获并处理 Token 认证失败后的错误:
app.use(function(err, req, res, next) {
// console.log(err);
if (err instanceof joi.ValidationError) {
// 处理数据校验错误
} else if (err.name === 'UnauthorizedError') {
// 处理身份认证失败的错误
return res.cc('身份验证失败!');
} else {
// 处理其他未知错误
}
return res.cc(err);
});
修改 /routerHandler/user.js 中的代码,实现最终完整的登录逻辑,如下:
const jwt = require('jsonwebtoken');
const expressJwt = require('express-jwt');
const { use } = require('../router/user');
const login = (req, res) => {
const info = req.body;
// 查询 username 是否存在
db.query(constValue.selectUN, info.username, function(err, result) {
if (err) {
res.cc(err);
} else if (result.length === 1) {
// 判断密码是否正确
if (bcrypt.compareSync(info.password, result[0].password)) {
// 剔除用户敏感信息后的数据作为 token 的加密数据
const user = { ...result[0], password: '', user_pic: '' }
console.log(user);
const token = jwt.sign(user, constValue.jwtSecretKey, { expiresIn: '10d' });
res.send({
status: 0,
token: 'Bearer ' + token,
message: '登录成功!'
});
} else {
res.cc('密码错误!');
}
} else {
res.cc('登录失败!');
}
});
};
创建 /router/userinfo.js 路由模块,并初始化如下的代码结构:
const express = require('express');
const router = express.Router();
const handler = require('../routerHandler/userinfo');
// 获取用户信息
router.get('/userinfo', handler.userinfo);
module.exports = router;
在 app.js 中导入并使用个人中心的路由模块:
const userinfo = require('./router/userinfo');
app.use('/my', userinfo);
在 /middleware/constValue.js 定义并导出查询用户信息的 SQL 语句:
// 查找用户信息
const selectInfo = `select id, username, nickname, email, user_pic from users where id = ?`;
创建 /routerHandler/userinfo.js 文件,实现最终完整的逻辑,如下:
const db = require('../db/index');
const constValue = require('../middleware/constValue');
const userinfo = function(req, res) {
console.log(req.auth);
db.query(constValue.selectInfo, req.auth.id, function(err, result) {
if (err) {
res.cc(err);
} else if (result.length === 1) {
res.send({
status: 0,
data: result[0],
message: '用户信息获取成功!'
});
} else {
res.cc('用户信息获取失败!');
}
});
};
module.exports = {
userinfo
};
具体实现见代码……
在 /schema/user.js 验证规则模块中,定义 newPassword 的验证规则并使用 exports 向外共享如下的验证规则对象:
const password = joi.string().required().pattern(/^[\S]{6,16}$/);
// 1. joi.ref('oldPassword') 表示 newPassword 的值必须和 oldPassword 的值保持一致
// 2. joi.not(joi.ref('oldPwd')) 表示 newPwd 的值不能等于 oldPwd 的值
// 3. .concat() 用于合并 joi.not(joi.ref('oldPwd')) 和 password 这两条验证规则
const newPassword = joi.not(joi.ref('oldPassword')).concat(password);
exports.schema = {
updatePassword: {
body: {
oldPassword: password,
newPassword
}
}
}
其他具体实现见代码……
在 /schema/user.js 验证规则模块中,定义 user_pic 的验证规则并使用 exports 向外共享如下的验证规则对象:
// dataUri() 指的是如下格式的字符串数据:
// data:image/png;base64,VE9PTUFOWVNFQ1JFVFM=
const user_pic = joi.string().dataUri().required();
exports.schema = {
updateAvatar: {
body: {
user_pic
}
}
}
在 /router/userinfo.js 模块中,导入需要的验证规则对象, 修改 更新用户头像 的路由:
const expressJoi = require('../middleware/expressJoi');
const { schema } = require('../schema/user');
// 更换头像
router.post('/updateAvatar', expressJoi(schema.updateAvatar), handler.updateAvatar);
在 /middleware/constValue.js 定义并导出 更新用户头像 的 SQL 语句:
// 修改头像
const updateAvatar = `update users set user_pic = ? where id = ?`;
处理 /routerHandler/userinfo.js 中的代码,实现最终完整的登录逻辑,如下:
// 更换头像
const updateAvatar = (req, res) => {
console.log('------------');
console.log(req.body);
db.query(constValue.updateAvatar, [req.body.user_pic, req.auth.id], (err, result) => {
if (err) {
res.cc(err);
} else if (result.affectedRows === 1) {
res.cc('更换头像成功!', 0);
} else {
res.cc('更换头像失败!');
}
})
}
具体实现见代码……
新建 articles 表如下:
创建 /routerHandler/article.js 路由处理函数模块,并初始化如下的代码结构:
// 发布新文章的处理函数
exports.addArticle = (req, res) => {
res.send('ok')
}
创建 /router/article.js 路由模块,并初始化如下的代码结构:
// 导入 express
const express = require('express')
// 创建路由对象
const router = express.Router()
// 导入文章的路由处理函数模块
const handler = require('../routerHandler/article');
// 发布新文章
routor.post('/addArticle', handler.addArticle);
// 向外共享路由对象
module.exports = router
在 app.js 中导入并使用文章的路由模块:
const article = require('./router/article');
app.use('/my', article);
注意: 使用 express.urlencoded() 中间件无法解析 multipart/form-data 格式的请求体 数据。
推荐使用 multer 来解析 multipart/form-data 格式的表单数据。运行如下的终端命令,在项目中安装 multer :
npm i multer
在 /router/article.js 模块中导入并配置 multer :
// 导入解析 formdata 格式表单数据的包
const multer = require('multer')
// 导入处理路径的核心模块
const path = require('path')
// 创建 multer 的实例对象,通过 dest 属性指定文件的存放路径
const upload = multer({ dest: path.join(__dirname, '../uploads') })
// 发布新文章的路由
// upload.single() 是一个局部生效的中间件,用来解析 FormData 格式的表单数据
// 将文件类型的数据,解析并挂载到 req.file 属性中
// 将文本类型的数据,解析并挂载到 req.body 属性中
routor.post('/addArticle', uploads.single('cover_img'), handler.addArticle);
通过 express-joi 自动验证 req.body 中的文本数据;通过 if 判断手动验证 req.file 中的 文件数据。
在 /schema/user.js 验证规则模块中,定义数据验证规则并使用 exports 向外共享如下的 验证规则对象:
const id = joi.number().integer().min(1).required();
const title = joi.string().required();
const content = joi.string().required().allow('');
const state = joi.string().valid('已发布', '草稿').required();
exports.schema = {
addArticle: {
body: {
title,
content,
cate_id: id,
state
}
}
}
在 /router/article.js 模块中,导入需要的验证规则对象,并在路由中使用。
在 /middleware/constValue.js 定义并导出 发布文章 的 SQL 语句:
// 插入文章
const insertArticle = `insert into articles set ?`;
修改 /routerHandler/article.js 中的代码,实现最终完整的登录逻辑,如下:
const addArticle = (req, res) => {
console.log(req.body);
console.log(req.file);
if (req.file && req.file.fieldname === 'cover_img') {
const article = {
...req.body,
// 文章封面在服务器端的存放路径
cover_img: `/uploads/${req.file.filename}`,
pub_date: new Date(),
author_id: req.auth.id
};
db.query(constValue.insertArticle, article, (err, result) => {
if (err) {
res.cc(err);
} else if (result.affectedRows === 1) {
res.cc('文章添加成功!', 0);
} else {
res.cc('文章添加失败!');
}
});
} else {
res.cc('cover_img 是必选参数!');
}
}