Koa中介軟體方式實現API的Undo功能,koa中介軟體apiundo
Koa中介軟體方式實現API的Undo功能API的Undo功能
使用過Gmail或者163郵箱的同學會經常看到,當對郵件進行一些操作時會出現一個類似Toast的提示(大致意思是:操作已經完成,是否撤銷)如所示:
當點擊撤銷時,之前執行的操作能夠被還原,這種設計對於使用者的誤操作是一個非常棒的補救方案。
之前聽過一句有關互動設計的話說的非常好,不要在使用者每做一步操作時彈出Alert讓使用者選擇”確定”或者”取消”,更好的做法是執行操作,然後讓使用者能夠Undo。
實現API Undo的方案
其實Undo是很老的技術,在編輯器中無處不在,只是在API設計中使用的還比較少。最近一直在使用Node開發,因此想使用Node實現一下API的Undo。
首先要明確幾點:
- 並不是所有的API都需要Undo,比如擷取資料的介面就完全沒有Undo的必要(而且邏輯上也沒辦法做)
- 只能針對使用者最後一次操作進行Undo
- Undo是在使用者維度,使用者不能Undo其他使用者的操作
傳統方案(Plan A):
- 需要對所有需要Undo的介面提供Undo邏輯;
- 當使用者要Undo最近一次操作時需要調用這個方法(常常會涉及資料庫操作)。
新方案(Plan B):
- 以中介軟體(類似Java中的攔截器)方式提供Undo服務
- 對需要Undo的API邏輯放入指定隊列順延強制
- 調用Undo介面時將最近一次操作從順延強制的隊列中移除
- 調用其他介面是立即執行延遲隊列中的邏輯
方案對比優點:方案A:
- 可以對任意介面進行undo操作;
- 邏輯簡單,undo時無需操作資料庫;
方案B:
- 操作真實反映到了資料庫;
- 無需限制undo到期時間;
缺點方案A:
- 訪問可undo介面後,再訪問非undo介面,之前邏輯不能undo;
方案B:
- 邏輯較為複雜,undo需要做資料庫操作;
- 並非所有操作都能夠undo;
方案實現
通過上邊的方案對比,我發現Plan A更簡單、靈活,因此決定實現Plan A。
使用過Koa的通許都知道,koa的中介軟體非常強大(類似Java web開發中的攔截器),它能夠攔截所有請求並執行一些邏輯,例如計算API請求到響應時間長度等,這裡我們就可以使用這個特性將需要Undo的API順延強制。
首先我們要設定Undo的逾時時間,以及那些API需要Undo:
var apis = (options || {}).apis;var expired = (options || {}).expired || 3000;
還要明確當前訪問API的使用者:
/** * `x-identify-key` is used to identify the user of this request, * one user can not undo another`s request. */var user = context.header['x-identify-key'];
然後順延強制API邏輯:
var undo = yield delayNext(user, expired, context);
如果使用者沒有調用Undo介面,則執行邏輯,否則返回’undo’:
if (!undo) { return yield next; }this.body = 'undo';
如果使用者調用Undo介面,移除順延強制的邏輯;調用其他介面則立即執行延遲的邏輯:
clearTimeout(undoObj.timeoutId);if (path === '/undo' && method === 'POST') { undoObj.delayFn.call(undoObj.context, true); context.body = 'done'; return;} else if (undoObj.delayFn) { undoObj.delayFn.call(undoObj.context, false);}
具體實現邏輯大概就這些,完整代碼如下:
/** * Store users' undo context * * @type {Object} */var undos = {};/** * Expose `undo` * * @param {Object} options Config object for undo * @example * { * expired: 3000 * } */module.exports = function (options) { var apis = (options || {}).apis; var expired = (options || {}).expired || 3000; return function* (next) { var context = this; var path = context.path; var needUndo = false; if (apis && Array.isArray(apis) && apis.length) { needUndo = apis.filter(function (api) { return path === api; }).length; } if (!needUndo && path !== '/undo') { return yield next; } var method = context.method; /** * Can not undo get request. */ if (method === 'GET') { return yield next; } /** * 'x-identify-key' is used to identify the user of this request, * one user can not undo another's request. */ var user = context.header['x-identify-key']; if (!user) { return yield next; } var undoObj = undos[user]; if (undoObj) { clearTimeout(undoObj.timeoutId); if (path === '/undo' && method === 'POST') { undoObj.delayFn.call(undoObj.context, true); context.body = 'done'; return; } else if (undoObj.delayFn) { undoObj.delayFn.call(undoObj.context, false); } } var undo = yield delayNext(user, expired, context); if (!undo) { return yield next; } this.body = 'undo'; };};/** * Block the logic for specified ms. * * @param {String} user The user's identity * @param {String} expired The expired ms * @param {Object} context The koa context object * @api private */function delayNext(user, expired, context) { return function (callback) { var delayFn = function (undo) { delete undos[user]; callback(null, undo); }; var timeoutId = setTimeout(delayFn, expired); undos[user] = { timeoutId: timeoutId, delayFn: delayFn, context: context }; };}
項目相關
目前此項目託管在Github上,https://github.com/sweetvvck/koa-undo,koa-undo具體使用方法項目首頁有詳細介紹,感興趣的同學歡迎提Issue、PR;同時koa-undo也發布到了Npm上,https://www.npmjs.com/package/koa-undo ,歡迎大家使用。