基於Nodejs搭建博客後臺

基礎準備:

1.NodeJs是一個非阻塞IO,單線程的,運行在服務端的JavaScript平臺,基於Google的V8引擎

2.NodeJs使用事件驅動模型,採用的是觀察訂閱模式,實現在event模塊下

3.Nodejs有很多模塊,採用npm管理包,比如node和mysql,redis對接,都需要用到相應的包

4 mysql是關係型數據庫,我們在本地計算機的某個端口,起一個mysql服務,在node中監聽這個端口

5.redis是是一個key-value的存儲系統,存在內存裏,斷電存儲消失,適合做登錄,同樣我們在本地計算機起一個redis服務

整體框架:

1.前端通過http-server的npm插件起一個前端http服務器,返回瀏覽器博客的前端頁面,用jquery發送ajax請求 前端的請求包括有:GET,POST兩種,POST用於更新博客內容,刪除博客,用戶登錄,前端的服務器端口在8001

2.服務器同樣用http的createServer方法起一個http服務器,端口在8000

3.用戶的登錄使用redis保存session,session和cookie驗證用戶是否登錄

4.博客的保存使用mysql數據庫

5.使用nodejs的http模塊處理網絡請求,使用fs模塊處理保存日誌

6.使用koa2框架重構,編寫自定義的中間件

7.使用nginx代理

8.前端頁面返回的數據以JSON對象表示,確定前端所需的數據格式和結構,不同路由返回的數據如何解析,數據格式是{data,errno}一個是數據

模塊劃分:

 

前端頁面結構’

1.http請求處理模塊:

使用nodejs原生的http模塊來處理,首先在設置www.js爲package.json中的npn run dev的入口文件

並使用nodemon實時監控文件的變化實現代碼變更的熱更新

const http = require('http')

const PORT = 8000
const serverHandle = require('../app')

const server = http.createServer(serverHandle)
server.listen(PORT)

  後端起了一個http服務器,監聽8000端口的http請求,我們在app.js中編寫我們如何處理前端發過來

的http請求,使用註冊回調的方式,在http.createServer(callback)中,我們傳入一個callback,這個callback需要兩個參數,一個是request,這兩個都是對象,一個就是服務器返回給前端的response,http模塊提供給處理http請求的的callback這兩個參數

http.createServer((req,res) => {
    //do something with req & res
})

前端來了一個http請求,它的目的就是從服務器返回它需要的數據,以JSON格式返回它需要的數據,分爲Get和POST兩種,GET請求負責請求數據,返回成功status爲200。POST用於提交數據,fetch需要發送兩次請求

serverHandle = (req,res) => {}如下

const serverHandle = (req, res) => {
    // 記錄 access log
    access(`${req.method} -- ${req.url} -- ${req.headers['user-agent']} -- ${Date.now()}`)

    // 設置返回格式 JSON
    res.setHeader('Content-type', 'application/json')

    // 獲取 path
    const url = req.url
    req.path = url.split('?')[0]

    // 解析 query
    req.query = querystring.parse(url.split('?')[1])

    // 解析 cookie
    req.cookie = {}
    const cookieStr = req.headers.cookie || ''  // k1=v1;k2=v2;k3=v3
    cookieStr.split(';').forEach(item => {
        if (!item) {
            return
        }
        const arr = item.split('=')
        const key = arr[0].trim()
        const val = arr[1].trim()
        req.cookie[key] = val
    })

    // 解析 session (使用 redis)
    let needSetCookie = false
    let userId = req.cookie.userid
    if (!userId) {
        needSetCookie = true
        userId = `${Date.now()}_${Math.random()}`
        // 初始化 redis 中的 session 值
        set(userId, {})
    }
    

    // 獲取 session
    req.sessionId = userId
    get(req.sessionId).then(sessionData => {
        if (sessionData == null) {
            // 初始化 redis 中的 session 值
            set(req.sessionId, {})
            // 設置 session
            req.session = {}
        } else {
            // 設置 session
            req.session = sessionData
        }
        // console.log('req.session ', req.session)

        // 處理 post data
        return getPostData(req)
    })
    .then(postData => {
        req.body = postData

        // 處理 blog 路由
   
        const blogResult = handleBlogRouter(req, res)
        if (blogResult) {
            blogResult.then(blogData => {
                if (needSetCookie) {
                    res.setHeader('Set-Cookie', `userid=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`)
                }

                res.end(
                    JSON.stringify(blogData)
                )
            })
            return
        }
        
        // 處理 user 路由
   
        const userResult = handleUserRouter(req, res)
        if (userResult) {
            userResult.then(userData => {
                if (needSetCookie) {
                    res.setHeader('Set-Cookie', `userid=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`)
                }

                res.end(
                    JSON.stringify(userData)
                )
            })
            return
        }

        // 未命中路由,返回 404
        res.writeHead(404, {"Content-type": "text/plain"})
        res.write("404 Not Found\n")
        res.end()
    })
}

module.exports = serverHandle

一個http請求來了,我們首先對req進行預處理,cookie在req.header中,解析它的header,二次包裝req我們分如下幾步看:

(1)從req.url中得到url

(2)從url中解析出請求的path和query,分別是url.split(“?”)的第一項和第二項,我們使用querystring包來把url中的key-value參數解析成參數對象,然後我們把它放到req.query中

(3)解析req中的cookie,req.headers.cookie

const cookieStr = req.headers.cookie;
cookieStr.split(';').forEach(item => {
    if(!item} return 
    const arr = item.split('=')
    const key = arr[0].trim();
    const val = arr[1].trim();
    )

一定要注意的就是,這裏的解析處理來key-value一定要用trim處理才行

(4)解析session,session用作用戶登錄,我們解析完cookie後,就可以得到我們在cookie中放的userid,如果這個userId不存在,那我們爲它自動生成一個userId :`${Date.now()_${Math.random()}},然後我們要把這個值存到session中去,這裏需要一個與數據庫的redis接口。然後我們解析userId,我們把userId賦值給req.sessionId,`調用redis數據庫提供的get接口,對返回的異步Promise請求,如果是null,說明沒有登陸過,我們直接set這個sessionId,初始化爲空,如果有,那我們就設置這個返回的sessionData給req.session,這樣我們就得到了一組req.sessionId和req.session,

(5)處理路由,路由返回都是Promise對象,子路由只用if判斷室友路由paht匹配,不匹配不做處理,在servaerHandle中檢測不同的路由的返回值,返回值存在就處理,並且res.send(JSON.stringify(data)),如果都沒匹配上,我們在serverHandle的結尾返回404未命中路由,res.setStatus(404)  res.write('404 not found')  以res.end()結束路由

 

2.用戶登錄模塊:用戶登錄包括前端的登錄頁面,登錄後使用location.href = ‘./admin.html’跳轉到自己個人中心頁面,在admin裏面,給url拼接一個query :isadmin=1,表明這裏是管理員面,我們在前端拼接url的時候,就默認了:

 const $textKeyword = $('#text-keyword')
        const $btnSearch = $('#btn-search')
        const $tableContainer = $('#table-container')

        // 拼接接口 url
        let url = '/api/blog/list?isadmin=1'  // 增加一個 isadmin=1 參數,使用登錄者的用戶名,後端也需要修改 !!!
        const urlParams = getUrlParams()
        if (urlParams.keyword) {
            url += '&keyword=' + urlParams.keyword
        }

        // 加載數據
        get(url).then(res => {
            if (res.errno !== 0) {
                alert('數據錯誤')
                return
            }

            // 顯示數據
            const data = res.data || []
            data.forEach(item => {
                $tableContainer.append($(`
                    <tr>
                        <td>
                            <a href="/detail.html?id=${item.id}" target="_blank">${item.title}</a>
                        </td>
                        <td>
                            <a href="/edit.html?id=${item.id}">編輯</a>
                        </td>
                        <td>
                            <a data-id="${item.id}" class="item-del">刪除</a>
                        </td>
                    </tr>
                `))
            })
        })

在admin路由下,我們調用loginCheck函數來判斷是否已經登錄,接下來說一下用戶登錄的流程:

const loginCheck = (req) => {
    if (!req.session.username) {
        return Promise.resolve(
            new ErrorModel('尚未登錄')
        )
    }
}


 if (req.query.isadmin) {
            // 管理員界面
            const loginCheckResult = loginCheck(req)
            if (loginCheckResult) {
                // 未登錄
                return loginCheckResult
            }
            // 強制查詢自己的博客
            author = req.session.username
        }

首先用戶如果進行了登錄,首先經過serhandle的全局http請求處理,我們之前解析了cookie中是否有userId字段,首次登陸肯定沒有userId這個字段,所以我們自動生成一個,並且把這個userId賦值給req.sessionId,後面我們會在redis設置sessionId-sessiondata的鍵值對。

然後進行一個redis查詢,我們調用redis提供的get接口,以sessionId爲查詢的key,如果查詢到了,就返回對應value,並且保存你到req.session中,也就是完成一個session的重讀取。這個value應該是一個包括了username和realname的json對象。

第一次那自然是莫得這個sessionId對應的value,那返回爲空,我們也初始化一個redis鍵值對。

然後匹配/login路由,在req.body中取得username和password,經過escape函數的編碼,防止sql注入攻擊,我們用username和password拼接一個查詢myblog數據庫中users表的sql語句

const login = (username, password) => {
    username = escape(username)
    
    // 生成加密密碼
    password = genPassword(password)
    password = escape(password)

    const sql = `
        select username, realname from users where username=${username} and password=${password}
    `
    // console.log('sql is', sql)
    return exec(sql).then(rows => {
        return rows[0] || {}
    })
}

返回rows[0]就包含了username和realname或者返回空對象

在user路由下,我們檢測sql查詢返回的數據中,是否有username,有的話說明登錄成功了,我們把它們放到req.session.username和req.session.realname中,然後我們就有了req.session和req.sessionId,然後我們調用redis的set函數,把此次的登錄信息保存到redis中,redis的特點就是不斷電就保存在服務器的內存中,讀取速度也快,比起localStorage,可以儲存的多,用戶多的時候,我們一般用redis做登錄驗證。

好了, 現在第一次登錄了,我們已經完成了userId的自動生成, 在login的子路由中,我們調用login函數,login函數就負責解析req.body中的username和password並拼接sql語句進行查詢,然後返回的username和realname賦值給sessiondata,調用redis的set方法完成sessionId和sessiondata的redis存儲。

然後再serelhandle裏,我們就要設置cookie了,我們調用:

res.setHeader('Set-Cookie',`userId'=userId,path='/',httpOnly,expires={getExpiresTime()}`)

然後這一次登錄後,我們就成功的在redis中保存了登錄信息,又在cookie中設置了以這個用戶生成的userId爲session的key的信息。

然後接下里就很重要,login後,服務器會返回一個succeModel或者errormodel,前端通過判斷服務器返回的data中errno來判斷是否登錄成功:

$('#btnLogin').click(() => {
            const username = $('#textUsername').val()
            const password = $('#textPassword').val()
            const url = '/api/user/login'
            const data = {
                username,
                password
            }
            post(url, data).then(res => {
                if (res.errno === 0) {
                    // 登錄成功
                    location.href = './admin.html'
                } else {
                    // 登錄失敗
                    alert(res.message)
                }
            })
        })

如果errno爲0,登錄就成功了,用location.href跳轉到./admin.html頁面,然後這就又要向服務器發送一個http請求,前端admin頁面發送一個get(url)請求來請求博客的list列表,需要訪問blog下的子路由

  let url = '/api/blog/list?isadmin=1'

這個http請求也要走一遍serverHandle,那我們已經登錄了,所以cookie中就又userId,所以這次檢測userId的時候就是真了,就不需要自動生成userId啦。因爲login後,我們在redis中一sessionId(唯一userId)和sessionData(包含username和realname兩個字段)。然後我們調用get函數,這次就get到了!然後就得到了之前用這個userId設置的sessionId就可以查到這個用戶的username和realname組成的sessiondata,而且也放到了req.session中

所以登錄狀態的檢測只需要在blog路由下面寫一個loginCheck函數,這個函數檢測req.session.username是否存在,存在就登錄,返回一個Promise對象。

const loginCheck = (req) => {
    if (!req.session.username) {
        return Promise.resolve(
            new ErrorModel('尚未登錄')
        )
    }
}

下面是blog路由。如果loginCheck有返回值(loginCheck我們寫成只有錯誤纔有返回值),就返回沒登錄,現在情況是登錄了,那我們就把username賦值給author,用於sql查詢語句的拼接,我們

const handleBlogRouter = (req, res) => {
    const method = req.method // GET POST
    const id = req.query.id

    // 獲取博客列表
    if (method === 'GET' && req.path === '/api/blog/list') {
        let author = req.query.author || ''
        const keyword = req.query.keyword || ''
        // const listData = getList(author, keyword)
        // return new SuccessModel(listData)

        if (req.query.isadmin) {
            // 管理員界面
            const loginCheckResult = loginCheck(req)
            if (loginCheckResult) {
                // 未登錄
                return loginCheckResult
            }
            // 強制查詢自己的博客
            author = req.session.username
        }

        const result = getList(author, keyword)
        return result.then(listData => {
            return new SuccessModel(listData)
        })
    }

    // 獲取博客詳情
    if (method === 'GET' && req.path === '/api/blog/detail') {
        // const data = getDetail(id)
        // return new SuccessModel(data)
        const result = getDetail(id)
        return result.then(data => {
            return new SuccessModel(data)
        })
    }

    // 新建一篇博客
    if (method === 'POST' && req.path === '/api/blog/new') {
        // const data = newBlog(req.body)
        // return new SuccessModel(data)

        const loginCheckResult = loginCheck(req)
        if (loginCheckResult) {
            // 未登錄
            return loginCheckResult
        }

        req.body.author = req.session.username
        const result = newBlog(req.body)
        return result.then(data => {
            return new SuccessModel(data)
        })
    }

    // 更新一篇博客
    if (method === 'POST' && req.path === '/api/blog/update') {
        const loginCheckResult = loginCheck(req)
        if (loginCheckResult) {
            // 未登錄
            return loginCheckResult
        }

        const result = updateBlog(id, req.body)
        return result.then(val => {
            if (val) {
                return new SuccessModel()
            } else {
                return new ErrorModel('更新博客失敗')
            }
        })
    }

    // 刪除一篇博客
    if (method === 'POST' && req.path === '/api/blog/del') {
        const loginCheckResult = loginCheck(req)
        if (loginCheckResult) {
            // 未登錄
            return loginCheckResult
        }

        const author = req.session.username
        const result = delBlog(id, author)
        return result.then(val => {
            if (val) {
                return new SuccessModel()
            } else {
                return new ErrorModel('刪除博客失敗')
            }
        })
    }
}

module.exports = handleBlogRouter

接下來是getList函數

const getList = (author, keyword) => {
    let sql = `select * from blogs where 1=1 `
    if (author) {
        sql += `and author='${author}' `
    }
    if (keyword) {
        sql += `and title like '%${keyword}%' `
    }
    sql += `order by createtime desc;`

    // 返回 promise
    return exec(sql)
}

然後返回的就是執行了查詢語句後的異步promise包裝的博客列表數據。馬上就要大功告成了!

在servelhandle裏,,我們直接用then處理resolve出的blogData,並將其res.end返回給客戶端

res.end(JSON.stringify(blogData))

 const blogResult = handleBlogRouter(req, res)
        if (blogResult) {
            blogResult.then(blogData => {
                if (needSetCookie) {
                    res.setHeader('Set-Cookie', `userid=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`)
                }

                res.end(
                    JSON.stringify(blogData)
                )
            })
            return
        }

然後我們在admin中終於tm的得到了這個數據,在前端,我們就可以用map函數渲染這個列表數據了:

 get(url).then(res => {
            if (res.errno !== 0) {
                alert('數據錯誤')
                return
            }

            // 顯示數據
            const data = res.data || []
            data.forEach(item => {
                $tableContainer.append($(`
                    <tr>
                        <td>
                            <a href="/detail.html?id=${item.id}" target="_blank">${item.title}</a>
                        </td>
                        <td>
                            <a href="/edit.html?id=${item.id}">編輯</a>
                        </td>
                        <td>
                            <a data-id="${item.id}" class="item-del">刪除</a>
                        </td>
                    </tr>
                `))
            })
        })

然後終於就實現了admin主頁只看到屬於自己的列表,自然在新建頁面,編輯頁面,我們進入這些子路由的時候,都先做一個loginCheck,如果返回了login失敗的erroModel,路由直接return出這個ErrorModel,在serverHandle裏,我們同樣也把這個errormodel當數據返回。

if (method === 'POST' && req.path === '/api/blog/update') {
        const loginCheckResult = loginCheck(req)
        if (loginCheckResult) {
            // 未登錄
            return loginCheckResult
        }

        const result = updateBlog(id, req.body)
        return result.then(val => {
            if (val) {
                return new SuccessModel()
            } else {
                return new ErrorModel('更新博客失敗')
            }
        })
    }



post(url, data).then(res => {
                if (res.errno !== 0) {
                    alert('操作錯誤')
                    return
                }
                alert('更新成功')
                location.href = '/admin.html'
            })

前端POST請求後如果得到服務器返回的這個errorModel,那直接alert失誤,否則alert成功,

刪除和新建也是一樣的。

3.講講mysql和redis相關的知識

在node中使用mysql,首先我們需要在服務器上起一個mysql服務器,默認起在3306端口,在node配置中,我們寫好對應的config,導入mysql包,使用提供的createConnection方法建立node到mysql的連接

const mysql = require('mysql')
const { MYSQL_CONF } = require('../conf/db')

// 創建鏈接對象
const con = mysql.createConnection(MYSQL_CONF)

// 開始鏈接
con.connect()

// 統一執行 sql 的函數
function exec(sql) {
    const promise = new Promise((resolve, reject) => {
        con.query(sql, (err, result) => {
            if (err) {
                reject(err)
                return
            }
            resolve(result)//將promise.value = result,promise.status = 'fulfiiled'
        })
    })
    return promise
}


if (env === 'dev') {
    // mysql
    MYSQL_CONF = {
        host: 'localhost',
        user: 'root',
        password: 'lpl951004',
        port: '3306',
        database: 'myblog'
    }

    // redis
    REDIS_CONF = {
        port: 6379,
        host: '127.0.0.1'
    }
}

redis也是一樣,我們導入redis包,

const redis = require('redis')
const { REDIS_CONF } = require('../conf/db.js')

// 創建客戶端
const redisClient = redis.createClient(REDIS_CONF.port, REDIS_CONF.host)
redisClient.on('error', err => {
    console.error(err)
})

function set(key, val) {
    if (typeof val === 'object') {
        val = JSON.stringify(val)
    }
    redisClient.set(key, val, redis.print)
}

function get(key) {
    const promise = new Promise((resolve, reject) => {
        redisClient.get(key, (err, val) => {
            if (err) {
                reject(err)
                return
            }
            if (val == null) {
                resolve(null)
                return
            }

            try {
                resolve(
                    JSON.parse(val)
                )
            } catch (ex) {
                resolve(val)
            }
        })
    })
    return promise
}

module.exports = {
    set,
    get
}

4.講一講express框架和中間件的實現。

首先講一下express,我們之前用的是原生node的http,fs,path,等包來實現的這個服務端的http服務器

現在我們用express重構一下項目。使用express的時候,我們需要用到和express相關的一些包來管理session,路由等等。

express框架有一個express實例,

var createError = require('http-errors');
var express = require('express');
var path = require('path');
var fs = require('fs');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
const session = require('express-session')
const RedisStore = require('connect-redis')(session)

// var indexRouter = require('./routes/index');
// var usersRouter = require('./routes/users');
const blogRouter = require('./routes/blog')
const userRouter = require('./routes/user')

var app = express();

// // view engine setup
// app.set('views', path.join(__dirname, 'views'));
// app.set('view engine', 'jade');

const ENV = process.env.NODE_ENV
if (ENV !== 'production') {
  // 開發環境 / 測試環境
  app.use(logger('dev'));
} else {
  // 線上環境
  const logFileName = path.join(__dirname, 'logs', 'access.log')
  const writeStream = fs.createWriteStream(logFileName, {
    flags: 'a'
  })
  app.use(logger('combined', {
    stream: writeStream
  }));
}

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
// app.use(express.static(path.join(__dirname, 'public')));

const redisClient = require('./db/redis')
const sessionStore = new RedisStore({
  client: redisClient
})
app.use(session({
  secret: 'WJiol#23123_',
  cookie: {
    // path: '/',   // 默認配置
    // httpOnly: true,  // 默認配置
    maxAge: 24 * 60 * 60 * 1000
  },
  store: sessionStore
}))

// app.use('/', indexRouter);
// app.use('/users', usersRouter);
app.use('/api/blog', blogRouter);
app.use('/api/user', userRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'dev' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

使用express的流程:

首先導入相關的包,實例化一個express對象,根據process.env.NODE_ENV來決定使用不同的環境下的配置

然後按順序,注意要按順序註冊相關的中間件,比如第一個是前端返回的json形式的request,首先用express.json()進行解析

然後用express.urlencoded進行url的解析,然後使用cookieParser()進行cookie的解析,然後實例化我們的redis,使用的是express-session中間件,傳入中間件和中間件所需要的參數對象。

接下來就是處理路由了,和我們之前用原生nodejs處理是一樣的。不同的路由我們分發到不同的子路由處理,最後註冊了Error中間件和錯誤處理的中間件。

我們講一講路由

var express = require('express');
var router = express.Router();
const { login } = require('../controller/user')
const { SuccessModel, ErrorModel } = require('../model/resModel')

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
            req.session.username = data.username
            req.session.realname = data.realname

            res.json(
                new SuccessModel()
            )
            return
        }
        res.json(
            new ErrorModel('登錄失敗')
        )
    })
});



module.exports = router;

實例化一個express.Router(),對這個router註冊不同路由的處理回調,然後導出這個router

對於請求我們可以編寫一個middleware,

const {ErrorModel} from './model/resModel'

module.exports = (req,res,next) => {
    if(req.session.username) {
        next();
        return
    }
    res.json(
    new ErrorModel('未登錄')    
)
}

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章