基於 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

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>, 就會被轉換為下面的語句並存入數據庫:

&lt;script&gt; alert(1) &lt;/script&gt;,已達到無法執行 <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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM