Koa 使用小技巧
koa使用小技巧
cookie的安全保護
基於cookie來驗證使用者狀態的系統中,如何提高cookie的安全級別是首要因素,最簡單直接的方式就生成的cookie值隨機而且複雜。一般使用uuid來生成cookie,生成的隨機串在複雜度上已滿足需求,但是如果真被攻擊者嘗試到一個可用的值,那怎麼防範呢?使用signed的cookie設定,如下所示:
app.keys = ["token"]; ... ctx.cookies.set("jt", "abcd", { signed: true, });
在設定jt
這個cookie的時候,koa會以jt
的值abcd
加上設定的金鑰,生成校驗值,並寫入至jt.sig
這個cookie中,所以能看到響應的HTTP頭中如下所示:
Set-Cookie: jt=abcd; path=/; httponly Set-Cookie: jt.sig=gpDbdxr25sarDhE_1yMSAnIn_bU; path=/; httponly
在後續的請求中,獲取jt
這個cookie時,則會根據jt.sig
的值判斷是否合法,安全性上又明顯提升。
那麼app.keys
為什麼是設計為陣列呢?先來考慮以下的一種場景,當希望更換金鑰的時候,原有的的cookie都將因為金鑰更新而導致校驗失敗,則使用者的登入狀態失效。一次還好,如果需要經常需要更新金鑰(我一般一個月更換一次),那怎麼處理好?這就是app.keys
為配置為陣列的使用邏輯了。
當生成cookie時,使用keys中的第一個元素來生成,而校驗的時候,是從第一個至最後一個,一個個的校驗,直到通過為止,所以在更新金鑰的時候,只需要把新的金鑰加到陣列第一位則可以。我一般再保留兩組金鑰,因為更新是一個月一次,因此如果客戶的cookie是三個月前生成的,那就會失效了。
cookie的校驗是基於ofollow,noindex" target="_blank">keygrip 來處理的,大家也可以使用它來做自己的一些資料校驗,如驗證碼之類。
異常處理
在使用koa時,一般出錯都是使用ctx.throw
來丟擲一個error,中斷處理流程,介面響應出錯,處理邏輯如下所未:
app.on('error', (err, ctx) => { // 記錄異常日誌 console.error(err); }); app.use((ctx) => { ctx.throw(400, '引數錯誤'); });
此處只利用了koa自帶的異常出錯,過於簡單,我們希望能針對主動丟擲的異常與程式異常能加以區分,因此需要自定義異常處理的中介軟體,如下:
app.on('error', (err, ctx) => { // 記錄異常日誌 console.error(err); }); app.use(async(ctx, next) => { try { await next() } catch (err) { let status = 500; const message = err.message; // koa的throw使用http-errors來生成error // 此處只判斷是否有status,有則認為是http-errors if (err.status) { status = err.status } else { // 非主動丟擲異常,則觸發error事件,記錄異常日誌 ctx.app.emit("error", err, ctx); } ctx.status = status; ctx.body = { message, }; } }) app.use((ctx) => { // 程式碼異常 // ctx.i.j = 0; // 主動丟擲異常 ctx.throw(400, '引數錯誤'); });
通過此調整後,將邏輯主動丟擲異常與程式異常區分開,定時去檢視異常日誌,減少程式異常。此例子只是簡單的使用了http-errors來建立主動丟擲的異常,在實際使用中,可以根據自己的場景建立自定義的Error類,定製相應的異常資訊。
當前正在處理請求數
得益於nodejs的IO處理,koa在高併發的場景下的CPU、記憶體都佔用並不高,但是也因為這樣,如果只通過CPU、記憶體來監控程式執行狀態並不全面,因此需要增加當前處理請求數的監控,程式碼如下:
let processingCount = 0; const maxProcessingCount = 1000; app.use(async (ctx, next) => { processingCount++; if (processingCount > maxProcessingCount) { // 如果需要也可以直接在處理請求超時時,直接出錯 console.error("processing request over limit"); } try { await next(); } catch (err) { throw err; } finally { processingCount--; } }); app.use(async (ctx) => { // 延時一秒 await new Promise(resolve => setTimeout(resolve, 1000)); ctx.body = { account: 'vicanso', }; });
此中介軟體在接收到請求時,將處理請求數加一,在處理完成後減一。最大的處理請求數根據系統的效能與使用者數量選擇合理的值。如果介面處理慢或者突然併發請求暴漲的時,可以儘早得知異常情況,儘早排查。
延時響應
介面的處理一般而言都是希望越快越好,但有些場景我們不希望介面響應的太快(如註冊),避免惡意者迅速嘗試功能,因此需要一個延時響應的中介軟體,程式碼如下:
function delayResponse(delayMs) { const delay = (t) => { const d = delayMs - (Date.now() - t); // 如果處理時長已超過delayMs,無需等待 if (d <= 0) { return Promise.resolve(); } return new Promise(resolve => setTimeout(resolve, d)); } return async(ctx, next) => { const startedAt = Date.now(); try { await next(); // 成功處理時等待 await delay(startedAt); } catch (err) { // 失敗時也等待 await delay(startedAt); throw err; } } } router.post('/users/v1/register', delayResponse(1000), (ctx) => { ctx.body = { account: 'vicanso', }; });
通過此中介軟體,可以限制某些功能的響應時長(保證每次處理時間都大於期望值),需要注意的是,延時響應的不要超過全域性的超時配置。
介面效能統計
系統是否穩定,效能是否需要優化等都依賴於統計,為了能及時反應出系統狀態,並方便新增告警指標,我將相關的統計資料寫入influxdb,主要指標如下:
tags:
- method,請求型別
- type,根據響應狀態碼分組,1xx -> 1, 2xx -> 2
- spdy,根據自定義的響應時間劃分區間,方便將介面響應時間分組
- route,介面路由
fields:
- connecting,處理請求數
- use,處理時長
- bytes,響應數字長度
- code,響應狀態碼
- url,請求地址
- ip,使用者IP
在influxdb中,tags可用於對資料分組,根據type
將介面請求分組,將4
與5
的單獨監控,可以簡單快速的把當前接口出錯彙總。統計中介軟體程式碼如下:
function stats() { let connecting = 0; const spdyList = [ 100, 300, 1000, 3000, ]; return async (ctx, next) => { const start = Date.now(); const tags = { method: ctx.method, }; connecting++; const fields = { connecting, url: ctx.url, } let status = 0; try { await next(); } catch (err) { // 出錯時狀態碼從error中獲取 status = err.status; throw err; } finally { // 如果非出錯,則從ctx中取狀態碼 if (!status) { status = ctx.status; } const use = Date.now() - start; connecting--; tags.route = ctx._matchedRoute; tags.type = `${status / 100 | 0}` let spdy = 0; // 確認處理時長所在區間 spdyList.forEach((v, i) => { if (use > v) { spdy = i + 1; } }); tags.spdy = `${spdy}`; fields.use = use; fields.bytes = ctx.length || 0; fields.code = status; fields.ip = ctx.ip; // 統計資料寫入統計系統(如influxdb) console.info(tags); console.info(fields); } }; } app.use(stats()); router.post('/users/v1/:type', async (ctx) => { await new Promise(resolve => setTimeout(resolve, 100)) ctx.body = { account: 'vicanso', }; });
介面全日誌記錄
為了方便排查問題,需要將介面的相關資訊輸出至日誌中,中介軟體的實現如下:
function tracker() { const stringify = (data) => JSON.stringify(data, (key, value) => { // 對於隱私資料做***處理 if (/password/.test(key)) { return '***'; } return value; }); return async (ctx, next) => { const trackerInfo = { url: ctx.url, form: ctx.request.body, }; try { await next(); } catch (err) { trackerInfo.error = err.message; throw err; } finally { trackerInfo.params = ctx.params; if (!trackerInfo.error) { trackerInfo.body = ctx.body; } console.info(stringify(trackerInfo)) } }; } app.use(bodyParser()); app.use(tracker()); router.post('/users/v1/:type', async (ctx) => { // ctx.throw(400, '密碼出錯'); await new Promise(resolve => setTimeout(resolve, 100)) ctx.body = { account: 'vicanso', }; });
使用此中介軟體之後,可以將所有介面的引數、正常響應資料或出錯資訊都全部輸出至日誌中,可根據需要調整stringify
的實現,將一些隱私資料做***處理。需要注意的是,由於部分介面的body響應體部分較大,是否需要將所有資料都輸出至日誌最好根據實際情況衡量。如可根據HTTP Method過濾,或者根據url規則等。
引數校驗
由於javascript的弱型別,介面引數校驗一直是要求最嚴格的一點,而在瞭解過joi
之後,我就一直使用它來做引數校驗,如註冊功能,賬號、密碼為必選引數,而郵箱為可選,介面校驗的程式碼如下:
function validate(data, schema) { const result = Joi.validate(data, schema); if (result.error) { // 出錯可建立自定義的校驗出錯型別 throw result.error; } return result.value; } router.post('/users/v1/register', async (ctx) => { const data = validate(ctx.request.body, Joi.object({ // 賬號限制長度為3-20個字串 account: Joi.string().min(3).max(20).required(), // 密碼限制長度為6-30,而且只允許字母與數字 password: Joi.string().regex(/^[a-zA-Z0-9]{6,30}$/).required(), email: Joi.string().email().optional(), })); ctx.body = { account: data.account, }; });
通過joi簡單快捷實現了引數的校驗,不過在實際使用中,有部分的引數校驗規則是通用的,如賬號、密碼這些的校驗規則在註冊和登入中都通過,但是有些介面是可選,有一些是必須,怎麼才能更通用一些呢?程式碼調整如下:
const userSchema = { // 賬號限制長度為3-20個字串 account: () => Joi.string().min(3).max(20), // 密碼限制長度為6-30,而且只允許字母與數字 password: () => Joi.string().regex(/^[a-zA-Z0-9]{6,30}$/), email: () => Joi.string().email(), } router.post('/users/v1/register', async (ctx) => { const data = validate(ctx.request.body, Joi.object({ account: userSchema.account().required(), password: userSchema.password().required(), email: userSchema.email().optional(), })); ctx.body = { account: data.account, }; });
經此調整後,將使用者引數校驗的基本規則都定義在userSchema
中,每個介面在各自的場景下選擇不同的引數以及增加規則,提高程式碼複用率以及校驗準確性。