基于 Express + MySQL + Redis 搭建多用户博客系统
1. 项目地址
https://github.com/caochangkui/node-express-koa2-project/tree/master/blog-express
2. 项目实现
-
Express 框架
- Node 连接 MySQL
- 路由处理
- API 接口开发
- 开发中间件
-
登录
- Cookie / Session 机制
- 登录验证中间件开发
- 使用 Redis 存储 Session
-
数据存储
- MySQL
- Redis
-
安全防御
- SQL 注入
- XSS 攻击
-
Nginx 反向代理
-
日志操作
- stream 流
- morgan 处理日志
- crontab 日志拆分,任务定时
- readline 逐行分析日志
-
线上环境部署
- 使用 PM2
- 进程守护,系统崩溃自启动
- 启动多进程
- 线上日志记录
3. 项目依赖
使用 express-generator 初始化项目
跨平台环境变量设置:
$ npm install cross-env --save-dev
安装文件监测工具 nodemon:
$ npm install nodemon --save-dev
"dependencies": {
"connect-redis": "^3.4.1",
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"express": "~4.16.1",
"express-session": "^1.16.1",
"http-errors": "~1.6.3",
"jade": "~1.11.0",
"morgan": "~1.9.1",
"mysql": "^2.17.1",
"redis": "^2.8.0",
"xss": "^1.0.6"
},
"devDependencies": {
"cross-env": "^5.2.0", // 跨平台环境变量设置
"nodemon": "^1.19.1" // 开发环境下,文件监测
}
启动项目:
$ npm run dev
4. 文件目录
├── README.md
├── project.json // 项目配置文件
├── app.js // 项目主文件
├── bin
│ └── www // 项目启动入口
├── conf
│ └── db.js // mysql和redis配置文件(开发环境和线上环境)
│── controller // 数据层
│ ├── blog.js // 处理blog数据的增删改查
│ └── user.js // 处理user数据, 登录
│── db // 数据层
│ ├── mysql.js // mysql连接,promise 统一处理sql语句
│ └── redis.js // redis连接
│── middleware // 存放中间件的目录
│ └── loginCheckt.js // 登录校验的中间件
│── logs // 存放日志的目录
│ │── access.log // 访问日志
│ │── error.log // 错误日志
│ └── event.log // 事件日志
│── model // 存放中间件的目录
│ └── resModel.js // 统一定义各个接口返回的数据格式
│── public // 存放前端静态文件的目录(对于前后端分类的项目不需要)
│── views // 前端视图文件目录,对于前后端分离项目,不需要
│── routes // 路由层
│ ├── blog.js // blog 操作 接口
│ └── user.js // user 登录 接口
└── utils // 存放中间件的目录
└── cryp.js // cypto 加密处理
5. Mysql 和 Redis 数据库
环境变量配置
项目从开发、测试、预发布到生成环境(线上)的环境变量一般都是不同的,为避免每次都手动修改,这里先配置环境变量
/conf/db.js:
const env = process.env.NODE_ENV // 环境参数
// 配置
let MYSQL_CONF
let REDIS_CONF
// 开发环境下
if (env === 'dev') {
// mysql 配置
MYSQL_CONF = {
host: 'localhost',
user: 'user',
password: 'password',
port: '3306',
database: 'database'
}
// redis 配置
REDIS_CONF = {
host: '127.0.0.1',
port: 6379
}
// 线上环境时,这里和开发环境配置一样,当发布到线上时,需要将配置改为线上
if (env === 'production') {
MYSQL_CONF = {
host: 'localhost',
user: 'user',
password: 'password',
port: '3306',
database: 'database'
}
REDIS_CONF = {
host: '127.0.0.1',
port: 6379
}
}
// 其他环境配置
... ...
module.exports = {
MYSQL_CONF,
REDIS_CONF,
}
MySQL 连接与使用
/db/mysql.js:
let mysql = require('mysql')
const { MYSQL_CONF } = require('../conf/db')
let connection = mysql.createConnection(MYSQL_CONF)
connection.connect((err, result) => {
if (err) {
console.log("数据库连接失败");
return;
}
console.log("数据库连接成功");
})
// 通过 Promise 统一执行 sql 函数
function exec(sql) {
return new Promise((resolve, reject) => {
connection.query(sql, (err, result) => {
if (err) {
reject(err)
return;
}
resolve(result)
})
})
}
module.exports = {
exec,
escape: mysql.escape
}
例如:根据 id 查询:
const getDetail = (id) => {
const sql = `select * from blogs where id='${id}';`
return exec(sql).then(rows => {
return rows[0]
})
}
... ...
router.get('/detail', (req, res, next) => {
const id = req.query.id
const result = getDetail(id)
return result.then(data => {
res.json(
new SuccessModel(data)
)
})
})
Redis 连接
/db/redis.js:
const redis = require('redis')
const { REDIS_CONF } = require('../conf/db')
// 创建客户端
const redisClient = redis.createClient(REDIS_CONF.port, REDIS_CONF.host)
redisClient.on('ready', res => {
console.log('redis启动成功', res)
})
redisClient.on('error', err => {
console.log('redis启动失败', err)
})
module.exports = {
redisClient
}
6. 路由处理
/routes/里包含了blog和用户的路由处理。例如:
get请求:
router.get('/list', (req, res, next) => {
let author = req.query.author || ''
const keyword = req.query.keyword || ''
const result = getList(author, keyword)
return result.then(listData => {
res.json({
errno: 0,
listData
})
})
})
post 请求:
router.post('/update', (req, res, next) => {
const id = req.query.id
const result = updateBlog(id, req.body)
return result.then(val => {
if (val) {
res.json({
errno: 0,
msg: "更新成功"
})
} else {
res.json({
errno: 0,
msg: "更新失败"
})
}
})
})
res.send() 和 res.json() 和 res.end() 和 res.set()
express 路由中根据不同的响应头字段,有不同的响应方式:
· res.render()
主要用来渲染 views 中的前端模板文件,对于前后端分离的项目,暂时不需要
· res.send([body])
用来发送HTTP响应。该body参数可以是一个Buffer对象、字符串、数组或对象。
express 针对不同参数,发出的相应行为也不一样:
- 当参数为 Buffer 对象时,res.send()方法将 Content-Type 响应头字段设置为“application/octet-stream”
- 当参数为 String 时,res.send()方法将 Content-Type 响应头字段设置为“text/html”
- 当参数为 Array 或 Object 对象时,res.send()方法将 Content-Type 响应头字段设置为“application/json”
如下:
res.send({name: "cedric"});
header: Content-Type: application/json; charset=utf-8
body:{"name":"cedric"}
res.send(["name","cedric"]);
header: Content-Type: application/json; charset=utf-8
body:["name","cedric"]
res.send('hello world');
header: Content-Type: text/html; charset=utf-8
body:hello world
res.send(new Buffer('abc'));
header:Content-Type: application/octet-stream
body:<Buffer 61 62 63>
res.json([body])
- 发送一个json的响应, 相当于原生 Node 的: res.end(JSON.stringify(data))
- 将Content-Type 响应头字段设置为: Content-Type: application/json; charset=utf-8
- 该方法res.send()与将对象或数组作为参数相同
- 不过,res.json() 可以将其他值转换为JSON,例如null、undefined、String
· res.end()
结束响应过程, 用于快速结束没有任何数据的响应
· res.set()
用来设置 header ‘content-type’参数。
// 即使res.send 参数是数组或对象,也可以通过res.set()将 Content-Type 响应头字段设置为“text/html”
res.set('Content-Type', 'text/html');
res.send({name: "cedric"});
header: Content-Type: text/html; charset=utf-8
body:'{"name":"cedric"}'
// 即使res.send 参数是字符串,也可以通过res.set()将 Content-Type 响应头字段设置为“application/json”
res.set('Content-Type', 'application/json');
res.send('hello world');
header: Content-Type: application/json; charset=utf-8
body:hello world
7. 登录, cookie + session 机制
Http 协议是一个无状态协议, 客户端每次发出请求, 请求之间是没有任何关系的。但是当多个浏览器同时访问同一服务时,服务器怎么区分来访者哪个是哪个呢?cookie、session、token 就是来解决这个问题的。详情参考:https://www.cnblogs.com/cckui/p/10967266.html
本项目通过 cookie + session 机制处理登录,并通过 Redis 存储 session 数据。
依赖:
$ npm i express-session
$ npm i redis connect-redis
在 app.js 中配置:
··· ···
const session = require('express-session')
const RedisStore = require('connect-redis')(session)
··· ···
// 处理 cookie
app.use(cookieParser());
··· ···
const redisClient = require('./db/redis').redisClient
const sessionStore = new RedisStore({
client: redisClient
})
app.use(session({
secret: 'CEdriC_#18603193', // 密匙可以随意添加,建议由大写+小写+加数字+特殊字符组成
cookie: {
path: '/', // 默认配置
httpOnly: true, // 默认配置,只允许服务端修改
maxAge: 24 * 60 * 60 * 1000 // cookie 失效时间 24小时
},
store: sessionStore // 将 session 存入 redis
}))
在 routes/user.js 中 登录路由时,设置 session:
router.post('/login', function (req, res, next) {
const { username, password } = req.body
const result = login(username, password)
return result.then(data => {
if (data.username) {
// 登录时 设置 session, 然后被connect-redis同步到redis
req.session.username = data.username
req.session.realname = data.realname
res.json(
new SuccessModel('登录成功')
)
}
res.json(
new ErrorModel('用户名和密码错误,登录失败')
)
})
})
登录校验 中间件
/middleware/loginCheck.js:
const { ErrorModel } = require('../model/resModel')
module.exports = (req, res, next) => {
if (req.session.username) {
// 登陆成功,需执行 next(),以继续执行下一步
next()
return
}
// 登陆失败,禁止继续执行,所以不需要执行 next()
res.json(
new ErrorModel('未登录')
)
}
用新增、删除、更改blog时,都需要验证是否登录:
使用示例如下:
// 新建blog, 通过中间件进行登录验证
router.post('/new', loginCheck, (req, res, next) => {
req.body.author = req.session.username
const result = newBlog(req.body)
return result.then(data => {
res.json(
new SuccessModel(data)
)
})
})
8. 日志处理
一般项目中,在开发环境下,将日志直接打印在控制台记录;生成环境(线上)下,需要将日志写入指定的文件下,如访问日志、错误日志、事件追踪日志等。
express 中主要使用 morgan 中间件处理日志,app.js 文件已经默认引入了改中间件,使用app.use(logger('dev'))
可以将请求信息打印在控制台,便于开发进行调试,但实际生产环境中,需要将日志记录在logs目录里,可以使用如下代码:
var path = require('path');
var fs = require('fs')
var logger = require('morgan'); // 中间件,生成日志
// 处理日志
const ENV = process.env.NODE_ENV
if (ENV !== 'production') {
// 如果是开发环境 / 测试环境,则直接在控制台终端打印 log 即可
app.use(logger('dev'));
} else {
// 如果当前是线上环境,则将请求日志写入/logs/access.log文件中,其他日志(错误日志和事件追踪日志也做类似处理)
const logFileName = path.join(__dirname, 'logs', 'access.log')
const writeStream = fs.createWriteStream(logFileName, {
flags: 'a'
})
app.use(logger('combined', {
stream: writeStream
}))
}
日志分析
- 如:针对日志 access.log,分析 chrome 的占比
- 日志按行存储,一行就是一条日志
- 通过 node.js readline 进行逐行分析
/utils/readline.js:
const fs = require('fs')
const path = require('path')
const readline = require('readline')
// 文件名
const fileName = path.join(__dirname, '../', '../', 'logs', 'access.log')
// 创建 read stream
const readStream = fs.createReadStream(fileName)
// 创建 readline 对象
const rl = readline.createInterface({
input: readStream
})
let chromeNum = 0
let sum = 0
// 逐行读取
rl.on('line', (lineData) => {
if (!lineData) {
return
}
// 记录总行数
sum++
const arr = lineData.split(' -- ')
if (arr[2] && arr[2].indexOf('Chrome') > 0) {
// 累加 chrome 的数量
chromeNum++
}
})
// 监听读取完成
rl.on('close', () => {
console.log(chromeNum, sum)
console.log('chrome 占比:' + chromeNum / sum)
})
9. Nginx 反向代理
参考 https://www.cnblogs.com/cckui/p/10972749.html
10. 安全防御
SQL 注入
SQL 注入,一般是通过把 SQL 命令插入到 Web 表单提交或输入域名或页面请求的查询字符串,最终达到欺骗服务器执行恶意的 SQL 命令。
SQL 注入预防措施
使用 mysql 的 escape 函数处理输入内容即可
在所有输入 sql 语句的地方,用 escape 函数处理一下即可, 例如:
const login = (username, password) => {
// 预防 sql 注入
username = escape(username)
password = escape(password)
const sql = `
select username, realname from users where username=${username} and password=${password};
`
return exec(sql).then(rows => {
return rows[0] || {}
})
}
XSS 攻击
XSS 是一种在web应用中的计算机安全漏洞,它允许恶意web用户将代码(代码包括HTML代码和客户端脚本)植入到提供给其它用户使用的页面中。
XSS 攻击预防措施
转换升级 js 的特殊字符
$ npm install xss
然后修改:
const xss = require('xss')
const title = data.title // 未进行 xss 防御
const title = xss(data.title) // 已进行 xss 防御
然后如果在 input 输入框 恶意输入 <script> alert(1) </script>
, 就会被转换为下面的语句并存入数据库:
<script> alert(1) </script>
,已达到无法执行 <script>
的目的。
注:
更多预防攻击措施可参考:https://www.cnblogs.com/cckui/p/10990006.html
11. 密码加密
/utils/cryp.js
const crypto = require('crypto')
// 密匙
const SECRET_KEY = '这个密钥可以随意填写'
// md5 加密
function md5(content) {
let md5 = crypto.createHash('md5')
return md5.update(content).digest('hex')
}
// 加密函数
function genPassword(password) {
const str = `password=${password}&key=${SECRET_KEY}`
return md5(str)
}
module.exports = {
genPassword
}
使用:
const { genPassword } = require('../utils/cryp')
const login = (username, password) => {
// 预防 sql 注入
username = escape(username)
// 生成加密密码
password = genPassword(password)
password = escape(password)
const sql = `
select username, realname from users where username=${username} and password=${password};
`
return exec(sql).then(rows => {
return rows[0] || {}
})
}
12. 线上部署与配置:PM2
线上部署通过 PM2, 详情请参考:https://www.cnblogs.com/cckui/p/10997638.html