通過 Web 技術(shù)開發(fā)服務給客戶端提供接口,可能是各個 Web 框架最廣泛的應用之一。這篇文章我們拿 CNode 社區(qū) 的接口來看一看通過 Egg 如何實現(xiàn) RESTful API 給客戶端調(diào)用。
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); // 調(diào)用 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)了下面的邏輯:
調(diào)用 validate 方法對請求參數(shù)進行驗證。 用驗證過的參數(shù)調(diào)用 service 封裝的業(yè)務邏輯來創(chuàng)建一個 topic。 按照接口約定的格式設置響應狀態(tài)碼和內(nè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) { // 調(diào)用 CNode V1 版本 API const result = await this.ctx.curl(`${this.root}/topics`, { method: 'post', data: params, dataType: 'json', contentType: 'json', }); // 檢查調(diào)用是否成功,如果調(diào)用失敗會拋出異常 this.checkSuccess(result); // 返回創(chuàng)建的 topic 的 id return result.data.topic_id; } // 封裝統(tǒng)一的調(diào)用檢查函數(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) { // 遠程調(diào)用返回格式錯誤 this.ctx.throw(500, 'remote response error', { data: result.data }); } } } module.exports = TopicService;
在創(chuàng)建 topic 的 Service 開發(fā)完成之后,我們就從上往下的完成了一個接口的開發(fā)。
統(tǒng)一錯誤處理正常的業(yè)務邏輯已經(jīng)正常完成了,但是異常我們還沒有進行處理。在前面編寫的代碼中,Controller 和 Service 都有可能拋出異常,這也是我們推薦的編碼方式,當發(fā)現(xiàn)客戶端參數(shù)傳遞錯誤或者調(diào)用后端服務異常時,通過拋出異常的方式來進行中斷。
Controller 中 this.ctx.validate() 進行參數(shù)校驗,失敗拋出異常。 Service 中調(diào)用 this.ctx.curl() 方法訪問 CNode 服務,可能由于網(wǎng)絡問題等原因拋出服務端異常。 Service 中拿到 CNode 服務端返回的結(jié)果后,可能會收到請求調(diào)用失敗的返回結(jié)果,此時也會拋出異常。 框架雖然提供了默認的異常處理,但是可能和我們在前面的接口約定不一致,因此我們需要自己實現(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; // 生產(chǎn)環(huán)境時 500 錯誤的詳細錯誤內(nèi)容不返回給客戶端,因為可能包含敏感信息 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ā)起請求,而是直接調(diào)用 Service 中的方法進行測試。
const { app, mock, assert } = require('egg-mock/bootstrap'); describe('test/app/service/topics.test.js', () => { let ctx; beforeEach(() => { // 創(chuàng)建一個匿名的 context 對象,可以在 ctx 對象上調(diào)用 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 的調(diào)用按照接口約定模擬掉 // 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 對象,并直接調(diào)用 Context 上的 Service 方法進行測試,測試時可以通過 app.mockHttpclient() 方法模擬 HTTP 調(diào)用的響應,讓我們剝離環(huán)境的影響而專注于 Service 自身邏輯的測試上。
完整的代碼實現(xiàn)和測試都在 eggjs/examples/cnode-api 中可以找到。
更多建議: