Node API經驗與種子項目分享 (二)功能詳解

前言

基於本人在如今公司的Node微服務實踐, 不斷維護升級着一個Node Restful API種子項目, 特此共享出來以供借鑑和討論. 項目中幾乎全部的東西都使用了node/javascript及相應模塊的最新功能, 語法, 和實踐. javascript

接上一篇帖子, 本次分享將會對此項目提供的各個主要功能不分前後作下詳細介紹.
項目github倉庫地址, 歡迎star: https://github.com/xiaozhongliu/node-api-seedhtml

詳解

項目目錄結構

.vscode          VSC服務調試/測試調試配置  
    config           多環境服務配置, 不依賴外部邏輯  
    ctrl             控制器, 基本與路由對應  
    log              服務請求日誌, 自動生成  
    midware          express服務中間件  
    model            數據庫模型: mongo, postgres/mysql  
    service          服務層, 供控制器/中間件調用  
    test             API測試, 運行命令npm t  
    util             各類工具庫, 僅依賴系統配置  
    .eslintrc.js     eslint規則配置  
    app.js           應用服務入口文件  
    global-helper.js 掛載少量全局helper  
    message.js       集中管理接口/系統消息  
    package.json     應用服務包配置文件  
    pm2.config.js    多環境pm2配置文件  
    router.js        集中管理服務路由

項目首次運行

首次運行項目進行測試, 先腳本建表或執行User.sync()將表結構同步到數據庫.
服務運行起來以後, 直接使用postman來實驗提供的接口:
Run in Postman
java

路由註冊擴展

代碼文件: router.js
自動判斷有沒有控制器對應的接口數據校驗規則集合, 若有則採用.
包裝控制器來統一捕捉拋出的非預期錯誤, 並將在app.js中最後一箇中間件發送告警郵件.
提供基礎健康檢查接口.node

接口數據校驗

代碼文件: midware/validate.js & util/validator.js
按約定聲明與控制器名稱相同的接口數據校驗規則集合, 便可在請求時進行驗證. 例如:mysql

/**
* validate api: login
*/
login: [
    // 參數名     參數類型     是否必傳
    ['sysType', Type.Number, true],
    ['username', Type.String, true],
    ['password', Type.String, true],
],

類型校驗方法大可能是express-validator模塊提供的, 能夠自定義類型及其校驗方法. 例如:git

isHash(value) {
    return /^[a-f0-9]{32}$/i.test(value)
},

isUnixStamp(value) {
    return /^[0-9]{10}$/.test(value)
},

無效請求過濾

代碼文件: midware/auth.js
此中間件作的無效請求過濾, 和認證不要緊. 具體經過header中傳來的ts和token校驗請求有效性.
ts或token未傳則會直接回絕請求, 這個能夠過濾掉95%以上的無效請求了.
ts和token對校驗失敗回絕請求, 不會執行後續業務邏輯.
ts和token的計算規則參考中間件代碼, 客戶端要以相同的規則計算後傳入, 參考postman中Pre-request Script:github

const ts = new Date().getTime();
const TOKEN = "08fbf466b37a924a8b3d3b2e6d190ef3";

postman.setGlobalVariable("ts", ts);
postman.setGlobalVariable("token", CryptoJS.MD5(TOKEN+ts));

結果處理擴展

代碼文件: util/extender.js
給express的response添加擴展方法, 簡化使用. 例如:web

// 無需返回數據
res.success()

// 須要返回數據
res.success(payload)

res.success({
    accessToken,
    sysType: getRes.sysType,
    username: getRes.username,
    avatar: getRes.avatar,
    redirectUrl,
})

接口請求日誌

代碼文件: midware/httplog.js
記錄請求地址, 請求數據, 響應數據, 響應狀態碼及處理時長. 例如:redis

2018-02-02 13:23:46 - [B1qkId-Lf] Start  POST /login
2018-02-02 13:23:46 - [B1qkId-Lf] Data   {"sysType":1,"username":"unittest","password":"e10adc3949ba59abbe56e057f20f883e"}
2018-02-02 13:23:46 - [B1qkId-Lf] Resp   {"code":1,"msg":"success","data":{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVuaXR0ZXN0IiwiaWF0IjoxNTE3NTQ5MDI2LCJleHAiOjE1MTg0MTMwMjZ9.-U4P6ksOUN6WsmI3ZEWow9npYDmO-QI020eVY5Mg2bQ","sysType":1,"username":"unittest","avatar":"https://nodejs.org/static/images/logo.svg"}}
2018-02-02 13:23:46 - [B1qkId-Lf] Done   200 (134ms)

2018-02-02 13:23:49 - [SJCJUuZLM] Start  GET /verify
2018-02-02 13:23:49 - [SJCJUuZLM] Resp   {"code":1,"msg":"success","data":{"username":"unittest"}}
2018-02-02 13:23:49 - [SJCJUuZLM] Done   200 (7ms)

高併發時可經過請求ID來找到同一次請求的多行日誌記錄.
經過給原生res.json方法增長一個切面來實現非侵入記錄響應數據:sql

// add a logging aspect to the primary res.json function
const origin = express.response.json
express.response.json = function (json) {
    logger.info(`[${this.reqId}] Resp  `, JSON.stringify(json))
    return origin.call(this, json)
}

支持日誌在線預覽, 可在瀏覽器查看日誌文件內容(首次會有http auth認證):


固然若是使用的ELK(或者Elastic Stack), 則對於一次請求最好就輸出一行json, 以方便logstash或者filebeat抓取.

服務監控面板

代碼文件: midware/monitor.js
能夠打開這個地址查看服務監控面板(首次會有http auth認證): /dashboard

Jest接口測試

代碼文件: test/base.test.js
已經集成VSC Jest測試配置, 選擇Jest All這個profile, 加斷點並F5便可開始調試. 或者對當前打開的文件選擇Jest File這個profile.
我開始用Jest的時候它才8000多star, 和ava差很少並列第三, 但如今已經排第一了, 不得不服本身的眼光, 啊哈哈哈哈...嗝. 樣例:

describe('base ctrl tests', () => {

    test('login succeeds    ', async () => {
        const data = {
            sysType: 1,
            username: 'unittest',
            password: 'e10adc3949ba59abbe56e057f20f883e'
        }

        const res = await client.POST(`${host}/login`, data)
        expect(res.code).toBe(1)
        expect(res.data.username).toBe('unittest')
    })

    test('login fails       ', async () => {
        const data = {
            sysType: 1,
            username: 'unittest',
            password: 'invalid password'
        }

        const res = await client.POST(`${host}/login`, data)
        expect(res.code).toBe(message.LoginFail.code)
    })
})

執行npm t, 測試結果以下:

接口示例說明

提供了3個基於jsonwebtoken (jwt) 的接口示例: 註冊, 登陸, 驗證.
驗證接口僅供參考, 實際使用時應在中間件中驗證jwt, 這樣的中間件相似:

module.exports = async (req, res, next) => {
    if (
        ![
            '/path/needs/jwt/verification' // TODO: 考慮放到配置
        ].includes(req.path)
    ) {
        return next()
    }


    // // test generating a jwt token
    // const jwtToken = await jwtSvc.sign({
    //     foo: 'bar'
    // })
    // console.log(jwtToken)


    // verify
    const { authorization } = req.headers
    if (!authorization) {
        return next(new Error('verify fail')) // TODO: 修改錯誤處理, 下同
    }
    const jwtToken = authorization.substr(7)

    let payload
    try {
        payload = await jwtSvc.verify(jwtToken)
    } catch (e) {
        return next(new Error('verify fail'))
    }
    if (!payload) {
        return next(new Error('verify fail'))
    }


    console.log(payload) // TODO: 設置到req上, 後續就能拿到


    next()
}

thunk函數包裝

代碼文件: service/*.js
node進化到今天, 用原生async/await作代碼異步流程控制也已經很久了. 不少庫提供了基於promise的API, 但不免還有不少基於thunk的庫, 或者同時提供了promise的API但還不完善的庫.
對於thunk函數咱們可使用node提供的util.promisify來包裝爲promise. 例如:

/**
    * set value of a hash field
    * @param {string} key      hash key
    * @param {string} field    field name
    * @param {string} value    field value
    */
    async hset(key, field, value) {
        if (typeof value === 'object') {
            value = JSON.stringify(value)
        }
        return promisify(redis.hset)(key, field, value)
    },

   /**
    * get value of a hash field
    * @param {string} key      hash key
    * @param {string} field    field name
    */
    async hget(key, field) {
        const value = await promisify(redis.hget)(key, field)
        try {
            return JSON.parse(value)
        } catch (e) {
            return value
        }
    },
相關文章
相關標籤/搜索