Egg 實現(xiàn) RESTful API

2020-02-06 14:11 更新

通過 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)了下面的邏輯:

  1. 調用 validate 方法對請求參數(shù)進行驗證。
  2. 用驗證過的參數(shù)調用 service 封裝的業(yè)務邏輯來創(chuàng)建一個 topic。
  3. 按照接口約定的格式設置響應狀態(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 中可以找到。


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號