基於 Express + MySQL + Redis 搭建多用戶博客系統

1. 項目地址

https://github.com/caochangkui/node-express-koa2-project/tree/master/blog-expresshtml

2. 項目實現

  • Express 框架
    • Node 鏈接 MySQL
    • 路由處理
    • API 接口開發
    • 開發中間件
  • 登陸
    • Cookie / Session 機制
    • 登陸驗證中間件開發
    • 使用 Redis 存儲 Session
  • 數據存儲
    • MySQL
    • Redis
  • 安全防護
    • SQL 注入
    • XSS 攻擊
  • Nginx 反向代理前端

  • 日誌操做
    • stream 流
    • morgan 處理日誌
    • crontab 日誌拆分,任務定時
    • readline 逐行分析日誌
  • 線上環境部署
    • 使用 PM2
    • 進程守護,系統崩潰自啓動
    • 啓動多進程
    • 線上日誌記錄

3. 項目依賴

使用 express-generator 初始化項目node

跨平臺環境變量設置:mysql

$ npm install cross-env --save-dev

安裝文件監測工具 nodemon:nginx

$ 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"   // 開發環境下,文件監測
  }

啓動項目:git

$ 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 數據庫

環境變量配置

項目從開發、測試、預發佈到生成環境(線上)的環境變量通常都是不一樣的,爲避免每次都手動修改,這裏先配置環境變量github

/conf/db.js:web

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:redis

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 查詢:sql

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 就是來解決這個問題的。詳情參考:http://www.javashuo.com/article/p-cggfdplt-kq.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 反向代理

參考 http://www.javashuo.com/article/p-cpdetwfi-kn.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> 的目的。

注:

更多預防攻擊措施可參考:http://www.javashuo.com/article/p-ddhcytfc-kh.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, 詳情請參考:http://www.javashuo.com/article/p-uumvhdwc-eo.html

相關文章
相關標籤/搜索