Egg 實(shí)現(xiàn) RESTful API

2020-02-06 14:11 更新

通過(guò) Web 技術(shù)開(kāi)發(fā)服務(wù)給客戶(hù)端提供接口,可能是各個(gè) Web 框架最廣泛的應(yīng)用之一。這篇文章我們拿 CNode 社區(qū) 的接口來(lái)看一看通過(guò) Egg 如何實(shí)現(xiàn) RESTful API 給客戶(hù)端調(diào)用。

CNode 社區(qū)現(xiàn)在 v1 版本的接口不是完全符合 RESTful 語(yǔ)義,在這篇文章中,我們將基于 CNode V1 的接口,封裝一個(gè)更符合 RESTful 語(yǔ)義的 V2 版本 API。

設(shè)計(jì)響應(yīng)格式

在 RESTful 風(fēng)格的設(shè)計(jì)中,我們會(huì)通過(guò)響應(yīng)狀態(tài)碼來(lái)標(biāo)識(shí)響應(yīng)的狀態(tài),保持響應(yīng)的 body 簡(jiǎn)潔,只返回接口數(shù)據(jù)。以 topics 資源為例:

獲取主題列表

  • GET /api/v2/topics
  • 響應(yīng)狀態(tài)碼:200
  • 響應(yīng)體:
[
{
"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": "《一起學(xué) Node.js》徹底重寫(xiě)完畢",
"last_reply_at": "2017-01-11T10:20:56.496Z",
"good": false,
"top": true,
"reply_count": 193,
"visit_count": 47633,
},
]

獲取單個(gè)主題

  • GET /api/v2/topics/57ea257b3670ca3f44c5beb6
  • 響應(yīng)狀態(tài)碼:200
  • 響應(yīng)體:
{
"id": "57ea257b3670ca3f44c5beb6",
"author_id": "541bf9b9ad60405c1f151a03",
"tab": "share",
"content": "content",
"title": "《一起學(xué) Node.js》徹底重寫(xiě)完畢",
"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
  • 響應(yīng)狀態(tài)碼:201
  • 響應(yīng)體:
{
"topic_id": "57ea257b3670ca3f44c5beb6"
}

更新主題

  • PUT /api/v2/topics/57ea257b3670ca3f44c5beb6
  • 響應(yīng)狀態(tài)碼:204
  • 響應(yīng)體:空

錯(cuò)誤處理

在接口處理發(fā)生錯(cuò)誤的時(shí)候,如果是客戶(hù)端請(qǐng)求參數(shù)導(dǎo)致的錯(cuò)誤,我們會(huì)返回 4xx 狀態(tài)碼,如果是服務(wù)端自身的處理邏輯錯(cuò)誤,我們會(huì)返回 5xx 狀態(tài)碼。所有的異常對(duì)象都是對(duì)這個(gè)異常狀態(tài)的描述,其中 error 字段是錯(cuò)誤的描述,detail 字段(可選)是導(dǎo)致錯(cuò)誤的詳細(xì)原因。

例如,當(dāng)客戶(hù)端傳遞的參數(shù)異常時(shí),我們可能返回一個(gè)響應(yīng),狀態(tài)碼為 422,返回響應(yīng)體為:

{
"error": "Validation Failed",
"detail": [ { "message": "required", "field": "title", "code": "missing_field" } ]
}

實(shí)現(xiàn)

在約定好接口之后,我們可以開(kāi)始動(dòng)手實(shí)現(xiàn)了。

初始化項(xiàng)目

還是通過(guò)快速入門(mén)章節(jié)介紹的 npm 來(lái)初始化我們的應(yīng)用

$ mkdir cnode-api && cd cnode-api
$ npm init egg --type=simple
$ npm i

開(kāi)啟 validate 插件

我們選擇 egg-validate 作為 validate 插件的示例。

// config/plugin.js
exports.validate = {
enable: true,
package: 'egg-validate',
};

注冊(cè)路由

首先,我們先按照前面的設(shè)計(jì)來(lái)注冊(cè)路由,框架提供了一個(gè)便捷的方式來(lái)創(chuàng)建 RESTful 風(fēng)格的路由,并將一個(gè)資源的接口映射到對(duì)應(yīng)的 controller 文件。在 app/router.js 中:

// app/router.js
module.exports = app => {
app.router.resources('topics', '/api/v2/topics', app.controller.topics);
};

通過(guò) app.resources 方法,我們將 topics 這個(gè)資源的增刪改查接口映射到了 app/controller/topics.js 文件。

controller 開(kāi)發(fā)

在 controller 中,我們只需要實(shí)現(xiàn) app.resources 約定的 RESTful 風(fēng)格的 URL 定義 中我們需要提供的接口即可。例如我們來(lái)實(shí)現(xiàn)創(chuàng)建一個(gè) topics 的接口:

// app/controller/topics.js
const Controller = require('egg').Controller;

// 定義創(chuàng)建接口的請(qǐ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;
// 校驗(yàn) `ctx.request.body` 是否符合我們預(yù)期的格式
// 如果參數(shù)校驗(yàn)未通過(guò),將會(huì)拋出一個(gè) status = 422 的異常
ctx.validate(createRule, ctx.request.body);
// 調(diào)用 service 創(chuàng)建一個(gè) topic
const id = await ctx.service.topics.create(ctx.request.body);
// 設(shè)置響應(yīng)體和狀態(tài)碼
ctx.body = {
topic_id: id,
};
ctx.status = 201;
}
}
module.exports = TopicController;

如同注釋中說(shuō)明的,一個(gè) Controller 主要實(shí)現(xiàn)了下面的邏輯:

  1. 調(diào)用 validate 方法對(duì)請(qǐng)求參數(shù)進(jìn)行驗(yàn)證。
  2. 用驗(yàn)證過(guò)的參數(shù)調(diào)用 service 封裝的業(yè)務(wù)邏輯來(lái)創(chuàng)建一個(gè) topic。
  3. 按照接口約定的格式設(shè)置響應(yīng)狀態(tài)碼和內(nèi)容。

service 開(kāi)發(fā)

在 service 中,我們可以更加專(zhuān)注的編寫(xiě)實(shí)際生效的業(yè)務(wù)邏輯。

// 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)用失敗會(huì)拋出異常
this.checkSuccess(result);
// 返回創(chuàng)建的 topic 的 id
return result.data.topic_id;
}

// 封裝統(tǒng)一的調(diào)用檢查函數(shù),可以在查詢(xún)、創(chuàng)建和更新等 Service 中復(fù)用
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) {
// 遠(yuǎn)程調(diào)用返回格式錯(cuò)誤
this.ctx.throw(500, 'remote response error', { data: result.data });
}
}
}

module.exports = TopicService;

在創(chuàng)建 topic 的 Service 開(kāi)發(fā)完成之后,我們就從上往下的完成了一個(gè)接口的開(kāi)發(fā)。

統(tǒng)一錯(cuò)誤處理

正常的業(yè)務(wù)邏輯已經(jīng)正常完成了,但是異常我們還沒(méi)有進(jìn)行處理。在前面編寫(xiě)的代碼中,Controller 和 Service 都有可能拋出異常,這也是我們推薦的編碼方式,當(dāng)發(fā)現(xiàn)客戶(hù)端參數(shù)傳遞錯(cuò)誤或者調(diào)用后端服務(wù)異常時(shí),通過(guò)拋出異常的方式來(lái)進(jìn)行中斷。

  • Controller 中 this.ctx.validate() 進(jìn)行參數(shù)校驗(yàn),失敗拋出異常。
  • Service 中調(diào)用 this.ctx.curl() 方法訪問(wèn) CNode 服務(wù),可能由于網(wǎng)絡(luò)問(wèn)題等原因拋出服務(wù)端異常。
  • Service 中拿到 CNode 服務(wù)端返回的結(jié)果后,可能會(huì)收到請(qǐng)求調(diào)用失敗的返回結(jié)果,此時(shí)也會(huì)拋出異常。

框架雖然提供了默認(rèn)的異常處理,但是可能和我們?cè)谇懊娴慕涌诩s定不一致,因此我們需要自己實(shí)現(xiàn)一個(gè)統(tǒng)一錯(cuò)誤處理的中間件來(lái)對(duì)錯(cuò)誤進(jìn)行處理。

在 app/middleware 目錄下新建一個(gè) error_handler.js 的文件來(lái)新建一個(gè) middleware

// app/middleware/error_handler.js
module.exports = () => {
return async function errorHandler(ctx, next) {
try {
await next();
} catch (err) {
// 所有的異常都在 app 上觸發(fā)一個(gè) error 事件,框架會(huì)記錄一條錯(cuò)誤日志
ctx.app.emit('error', err, ctx);

const status = err.status || 500;
// 生產(chǎn)環(huán)境時(shí) 500 錯(cuò)誤的詳細(xì)錯(cuò)誤內(nèi)容不返回給客戶(hù)端,因?yàn)榭赡馨舾行畔?br> const error = status === 500 && ctx.app.config.env === 'prod'
? 'Internal Server Error'
: err.message;

// 從 error 對(duì)象上讀出各個(gè)屬性,設(shè)置到響應(yīng)中
ctx.body = { error };
if (status === 422) {
ctx.body.detail = err.errors;
}
ctx.status = status;
}
};
};

通過(guò)這個(gè)中間件,我們可以捕獲所有異常,并按照我們想要的格式封裝了響應(yīng)。將這個(gè)中間件通過(guò)配置文件(config/config.default.js)加載進(jìn)來(lái):

// config/config.default.js
module.exports = {
// 加載 errorHandler 中間件
middleware: [ 'errorHandler' ],
// 只對(duì) /api 前綴的 url 路徑生效
errorHandler: {
match: '/api',
},
};

測(cè)試

代碼完成只是第一步,我們還需要給代碼加上單元測(cè)試

Controller 測(cè)試

我們先來(lái)編寫(xiě) Controller 代碼的單元測(cè)試。在寫(xiě) Controller 單測(cè)的時(shí)候,我們可以適時(shí)的模擬 Service 層的實(shí)現(xiàn),因?yàn)閷?duì) Controller 的單元測(cè)試而言,最重要的部分是測(cè)試自身的邏輯,而 Service 層按照約定的接口 mock 掉,Service 自身的邏輯可以讓 Service 的單元測(cè)試來(lái)覆蓋,這樣我們開(kāi)發(fā)的時(shí)候也可以分層進(jìn)行開(kāi)發(fā)測(cè)試。

const { app, mock, assert } = require('egg-mock/bootstrap');

describe('test/app/controller/topics.test.js', () => {
// 測(cè)試請(qǐng)求參數(shù)錯(cuò)誤時(shí)應(yīng)用的響應(yīng)
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 層,測(cè)試正常時(shí)的返回
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,
});
});
});

上面對(duì) Controller 的測(cè)試中,我們通過(guò) egg-mock 創(chuàng)建了一個(gè)應(yīng)用,并通過(guò) SuperTest 來(lái)模擬客戶(hù)端發(fā)送請(qǐng)求進(jìn)行測(cè)試。在測(cè)試中我們會(huì)模擬 Service 層的響應(yīng)來(lái)測(cè)試 Controller 層的處理邏輯。

Service 測(cè)試

Service 層的測(cè)試也只需要聚焦于自身的代碼邏輯,egg-mock 同樣提供了快速測(cè)試 Service 的方法,不再需要用 SuperTest 模擬從客戶(hù)端發(fā)起請(qǐng)求,而是直接調(diào)用 Service 中的方法進(jìn)行測(cè)試。

const { app, mock, assert } = require('egg-mock/bootstrap');

describe('test/app/service/topics.test.js', () => {
let ctx;

beforeEach(() => {
// 創(chuàng)建一個(gè)匿名的 context 對(duì)象,可以在 ctx 對(duì)象上調(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 === '錯(cuò)誤的accessToken');
return;
}
throw 'should not run here';
});

it('should create success', async () => {
// 不影響 CNode 的正常運(yùn)行,我們可以將對(duì) CNode 的調(diào)用按照接口約定模擬掉
// app.mockHttpclient 方法可以便捷的對(duì)應(yīng)用發(fā)起的 http 請(qǐng)求進(jìn)行模擬
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');
});
});
});

上面對(duì) Service 層的測(cè)試中,我們通過(guò) egg-mock 提供的 app.createContext() 方法創(chuàng)建了一個(gè) Context 對(duì)象,并直接調(diào)用 Context 上的 Service 方法進(jìn)行測(cè)試,測(cè)試時(shí)可以通過(guò) app.mockHttpclient() 方法模擬 HTTP 調(diào)用的響應(yīng),讓我們剝離環(huán)境的影響而專(zhuān)注于 Service 自身邏輯的測(cè)試上。

完整的代碼實(shí)現(xiàn)和測(cè)試都在 eggjs/examples/cnode-api 中可以找到。


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)