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