1. 项目地址
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-8body:{"name":"cedric"}res.send(["name","cedric"]);header: Content-Type: application/json; charset=utf-8body:["name","cedric"]res.send('hello world');header: Content-Type: text/html; charset=utf-8body:hello worldres.send(new Buffer('abc'));header:Content-Type: application/octet-streambody:
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-8body:'{"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-8body:hello world
7. 登录, cookie + session 机制
Http 协议是一个无状态协议, 客户端每次发出请求, 请求之间是没有任何关系的。但是当多个浏览器同时访问同一服务时,服务器怎么区分来访者哪个是哪个呢?cookie、session、token 就是来解决这个问题的。详情参考:
本项目通过 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)··· ···// 处理 cookieapp.use(cookieParser());··· ···const redisClient = require('./db/redis').redisClientconst 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 中主要使用 中间件处理日志,app.js 文件已经默认引入了改中间件,使用app.use(logger('dev'))
可以将请求信息打印在控制台,便于开发进行调试,但实际生产环境中,需要将日志记录在logs目录里,可以使用如下代码:
var path = require('path');var fs = require('fs')var logger = require('morgan'); // 中间件,生成日志// 处理日志const ENV = process.env.NODE_ENVif (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 streamconst readStream = fs.createReadStream(fileName)// 创建 readline 对象const rl = readline.createInterface({ input: readStream})let chromeNum = 0let 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 反向代理
参考
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>
的目的。
注:
更多预防攻击措施可参考:
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, 详情请参考: