通過 Web 技術開發(fā)服務給客戶端提供接口,可能是各個 Web 框架最廣泛的應用之一。這篇文章我們拿 CNode 社區(qū) 的接口來看一看通過 Egg 如何實現(xiàn) RESTful API 給客戶端調用。
CNode 社區(qū)現(xiàn)在 v1 版本的接口不是完全符合 RESTful 語義,在這篇文章中,我們將基于 CNode V1 的接口,封裝一個更符合 RESTful 語義的 V2 版本 API。
設計響應格式
在 RESTful 風格的設計中,我們會通過響應狀態(tài)碼來標識響應的狀態(tài),保持響應的 body 簡潔,只返回接口數(shù)據(jù)。以 topics 資源為例:
獲取主題列表
- GET /api/v2/topics
- 響應狀態(tài)碼:200
- 響應體:
[ { "id": "57ea257b3670ca3f44c5beb6", "author_id": "541bf9b9ad60405c1f151a03", "tab": "share", "content": "content", "last_reply_at": "2017-01-11T13:32:25.089Z", "good": false, "top": true, "reply_count": 155, "visit_count": 28176, "create_at": "2016-09-27T07:53:31.872Z", }, { "id": "57ea257b3670ca3f44c5beb6", "author_id": "541bf9b9ad60405c1f151a03", "tab": "share", "content": "content", "title": "《一起學 Node.js》徹底重寫完畢", "last_reply_at": "2017-01-11T10:20:56.496Z", "good": false, "top": true, "reply_count": 193, "visit_count": 47633, }, ]
|
獲取單個主題
- GET /api/v2/topics/57ea257b3670ca3f44c5beb6
- 響應狀態(tài)碼:200
- 響應體:
{ "id": "57ea257b3670ca3f44c5beb6", "author_id": "541bf9b9ad60405c1f151a03", "tab": "share", "content": "content", "title": "《一起學 Node.js》徹底重寫完畢", "last_reply_at": "2017-01-11T10:20:56.496Z", "good": false, "top": true, "reply_count": 193, "visit_count": 47633, }
|
創(chuàng)建主題
- POST /api/v2/topics
- 響應狀態(tài)碼:201
- 響應體:
{ "topic_id": "57ea257b3670ca3f44c5beb6" }
|
更新主題
- PUT /api/v2/topics/57ea257b3670ca3f44c5beb6
- 響應狀態(tài)碼:204
- 響應體:空
錯誤處理
在接口處理發(fā)生錯誤的時候,如果是客戶端請求參數(shù)導致的錯誤,我們會返回 4xx 狀態(tài)碼,如果是服務端自身的處理邏輯錯誤,我們會返回 5xx 狀態(tài)碼。所有的異常對象都是對這個異常狀態(tài)的描述,其中 error 字段是錯誤的描述,detail 字段(可選)是導致錯誤的詳細原因。
例如,當客戶端傳遞的參數(shù)異常時,我們可能返回一個響應,狀態(tài)碼為 422,返回響應體為:
{ "error": "Validation Failed", "detail": [ { "message": "required", "field": "title", "code": "missing_field" } ] }
|
實現(xiàn)
在約定好接口之后,我們可以開始動手實現(xiàn)了。
初始化項目
還是通過快速入門章節(jié)介紹的 npm 來初始化我們的應用
$ mkdir cnode-api && cd cnode-api $ npm init egg --type=simple $ npm i
|
開啟 validate 插件
我們選擇 egg-validate 作為 validate 插件的示例。
// config/plugin.js exports.validate = { enable: true, package: 'egg-validate', };
|
注冊路由
首先,我們先按照前面的設計來注冊路由,框架提供了一個便捷的方式來創(chuàng)建 RESTful 風格的路由,并將一個資源的接口映射到對應的 controller 文件。在 app/router.js 中:
// app/router.js module.exports = app => { app.router.resources('topics', '/api/v2/topics', app.controller.topics); };
|
通過 app.resources 方法,我們將 topics 這個資源的增刪改查接口映射到了 app/controller/topics.js 文件。
controller 開發(fā)
在 controller 中,我們只需要實現(xiàn) app.resources 約定的 RESTful 風格的 URL 定義 中我們需要提供的接口即可。例如我們來實現(xiàn)創(chuàng)建一個 topics 的接口:
// app/controller/topics.js const Controller = require('egg').Controller;
// 定義創(chuàng)建接口的請求參數(shù)規(guī)則 const createRule = { accesstoken: 'string', title: 'string', tab: { type: 'enum', values: [ 'ask', 'share', 'job' ], required: false }, content: 'string', };
class TopicController extends Controller { async create() { const ctx = this.ctx; // 校驗 `ctx.request.body` 是否符合我們預期的格式 // 如果參數(shù)校驗未通過,將會拋出一個 status = 422 的異常 ctx.validate(createRule, ctx.request.body); // 調用 service 創(chuàng)建一個 topic const id = await ctx.service.topics.create(ctx.request.body); // 設置響應體和狀態(tài)碼 ctx.body = { topic_id: id, }; ctx.status = 201; } } module.exports = TopicController;
|
如同注釋中說明的,一個 Controller 主要實現(xiàn)了下面的邏輯:
- 調用 validate 方法對請求參數(shù)進行驗證。
- 用驗證過的參數(shù)調用 service 封裝的業(yè)務邏輯來創(chuàng)建一個 topic。
- 按照接口約定的格式設置響應狀態(tài)碼和內容。
service 開發(fā)
在 service 中,我們可以更加專注的編寫實際生效的業(yè)務邏輯。
// app/service/topics.js const Service = require('egg').Service;
class TopicService extends Service { constructor(ctx) { super(ctx); this.root = 'https://cnodejs.org/api/v1'; }
async create(params) { // 調用 CNode V1 版本 API const result = await this.ctx.curl(`${this.root}/topics`, { method: 'post', data: params, dataType: 'json', contentType: 'json', }); // 檢查調用是否成功,如果調用失敗會拋出異常 this.checkSuccess(result); // 返回創(chuàng)建的 topic 的 id return result.data.topic_id; }
// 封裝統(tǒng)一的調用檢查函數(shù),可以在查詢、創(chuàng)建和更新等 Service 中復用 checkSuccess(result) { if (result.status !== 200) { const errorMsg = result.data && result.data.error_msg ? result.data.error_msg : 'unknown error'; this.ctx.throw(result.status, errorMsg); } if (!result.data.success) { // 遠程調用返回格式錯誤 this.ctx.throw(500, 'remote response error', { data: result.data }); } } }
module.exports = TopicService;
|
在創(chuàng)建 topic 的 Service 開發(fā)完成之后,我們就從上往下的完成了一個接口的開發(fā)。
統(tǒng)一錯誤處理
正常的業(yè)務邏輯已經正常完成了,但是異常我們還沒有進行處理。在前面編寫的代碼中,Controller 和 Service 都有可能拋出異常,這也是我們推薦的編碼方式,當發(fā)現(xiàn)客戶端參數(shù)傳遞錯誤或者調用后端服務異常時,通過拋出異常的方式來進行中斷。
- Controller 中 this.ctx.validate() 進行參數(shù)校驗,失敗拋出異常。
- Service 中調用 this.ctx.curl() 方法訪問 CNode 服務,可能由于網絡問題等原因拋出服務端異常。
- Service 中拿到 CNode 服務端返回的結果后,可能會收到請求調用失敗的返回結果,此時也會拋出異常。
框架雖然提供了默認的異常處理,但是可能和我們在前面的接口約定不一致,因此我們需要自己實現(xiàn)一個統(tǒng)一錯誤處理的中間件來對錯誤進行處理。
在 app/middleware 目錄下新建一個 error_handler.js 的文件來新建一個 middleware
// app/middleware/error_handler.js module.exports = () => { return async function errorHandler(ctx, next) { try { await next(); } catch (err) { // 所有的異常都在 app 上觸發(fā)一個 error 事件,框架會記錄一條錯誤日志 ctx.app.emit('error', err, ctx);
const status = err.status || 500; // 生產環(huán)境時 500 錯誤的詳細錯誤內容不返回給客戶端,因為可能包含敏感信息 const error = status === 500 && ctx.app.config.env === 'prod' ? 'Internal Server Error' : err.message;
// 從 error 對象上讀出各個屬性,設置到響應中 ctx.body = { error }; if (status === 422) { ctx.body.detail = err.errors; } ctx.status = status; } }; };
|
通過這個中間件,我們可以捕獲所有異常,并按照我們想要的格式封裝了響應。將這個中間件通過配置文件(config/config.default.js)加載進來:
// config/config.default.js module.exports = { // 加載 errorHandler 中間件 middleware: [ 'errorHandler' ], // 只對 /api 前綴的 url 路徑生效 errorHandler: { match: '/api', }, };
|
測試
代碼完成只是第一步,我們還需要給代碼加上單元測試。
Controller 測試
我們先來編寫 Controller 代碼的單元測試。在寫 Controller 單測的時候,我們可以適時的模擬 Service 層的實現(xiàn),因為對 Controller 的單元測試而言,最重要的部分是測試自身的邏輯,而 Service 層按照約定的接口 mock 掉,Service 自身的邏輯可以讓 Service 的單元測試來覆蓋,這樣我們開發(fā)的時候也可以分層進行開發(fā)測試。
const { app, mock, assert } = require('egg-mock/bootstrap');
describe('test/app/controller/topics.test.js', () => { // 測試請求參數(shù)錯誤時應用的響應 it('should POST /api/v2/topics/ 422', () => { app.mockCsrf(); return app.httpRequest() .post('/api/v2/topics') .send({ accesstoken: '123', }) .expect(422) .expect({ error: 'Validation Failed', detail: [ { message: 'required', field: 'title', code: 'missing_field' }, { message: 'required', field: 'content', code: 'missing_field' }, ], }); });
// mock 掉 service 層,測試正常時的返回 it('should POST /api/v2/topics/ 201', () => { app.mockCsrf(); app.mockService('topics', 'create', 123); return app.httpRequest() .post('/api/v2/topics') .send({ accesstoken: '123', title: 'title', content: 'hello', }) .expect(201) .expect({ topic_id: 123, }); }); });
|
上面對 Controller 的測試中,我們通過 egg-mock 創(chuàng)建了一個應用,并通過 SuperTest 來模擬客戶端發(fā)送請求進行測試。在測試中我們會模擬 Service 層的響應來測試 Controller 層的處理邏輯。
Service 測試
Service 層的測試也只需要聚焦于自身的代碼邏輯,egg-mock 同樣提供了快速測試 Service 的方法,不再需要用 SuperTest 模擬從客戶端發(fā)起請求,而是直接調用 Service 中的方法進行測試。
const { app, mock, assert } = require('egg-mock/bootstrap');
describe('test/app/service/topics.test.js', () => { let ctx;
beforeEach(() => { // 創(chuàng)建一個匿名的 context 對象,可以在 ctx 對象上調用 service 的方法 ctx = app.mockContext(); });
describe('create()', () => { it('should create failed by accesstoken error', async () => { try { await ctx.service.topics.create({ accesstoken: 'hello', title: 'title', content: 'content', }); } catch (err) { assert(err.status === 401); assert(err.message === '錯誤的accessToken'); return; } throw 'should not run here'; });
it('should create success', async () => { // 不影響 CNode 的正常運行,我們可以將對 CNode 的調用按照接口約定模擬掉 // app.mockHttpclient 方法可以便捷的對應用發(fā)起的 http 請求進行模擬 app.mockHttpclient(`${ctx.service.topics.root}/topics`, 'POST', { data: { success: true, topic_id: '5433d5e4e737cbe96dcef312', }, });
const id = await ctx.service.topics.create({ accesstoken: 'hello', title: 'title', content: 'content', }); assert(id === '5433d5e4e737cbe96dcef312'); }); }); });
|
上面對 Service 層的測試中,我們通過 egg-mock 提供的 app.createContext() 方法創(chuàng)建了一個 Context 對象,并直接調用 Context 上的 Service 方法進行測試,測試時可以通過 app.mockHttpclient() 方法模擬 HTTP 調用的響應,讓我們剝離環(huán)境的影響而專注于 Service 自身邏輯的測試上。
完整的代碼實現(xiàn)和測試都在 eggjs/examples/cnode-api 中可以找到。
更多建議: