实现 RESTful API
通过 Web 技术开发服务给客户端提供接口,可能是各个 Web 框架最广泛的应用之一。这篇文章我们拿 CNode 社区 的接口来看一看通过 Egg 如何实现 RESTful API 给客户端调用。
CNode 社区现在 v1 版本的接口不是完全符合 RESTful 语义,在这篇文章中,我们将基于 CNode V1 的接口,封装一个更符合 RESTful 语义的 V2 版本 API。
# 设计响应格式
在 RESTful 风格的设计中,我们会通过响应状态码来标识响应的状态,保持响应的 body 简洁,只返回接口数据。以 topics
资源为例:
# 获取主题列表
GET /api/v2/topics
- 响应状态码: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
- 响应状态码: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, }
|
# 创建主题
POST /api/v2/topics
- 响应状态码:201
- 响应体:
{ "topic_id": "57ea257b3670ca3f44c5beb6" }
|
# 更新主题
PUT /api/v2/topics/57ea257b3670ca3f44c5beb6
- 响应状态码:204
- 响应体:空
# 错误处理
在接口处理发生错误的时候,如果是客户端请求参数导致的错误,我们会返回 4xx 状态码,如果是服务端自身的处理逻辑错误,我们会返回 5xx 状态码。所有的异常对象都是对这个异常状态的描述,其中 error 字段是错误的描述,detail 字段(可选)是导致错误的详细原因。
例如,当客户端传递的参数异常时,我们可能返回一个响应,状态码为 422,返回响应体为:
{ "error": "Validation Failed", "detail": [ { "message": "required", "field": "title", "code": "missing_field" } ] }
|
# 实现
在约定好接口之后,我们可以开始动手实现了。
# 初始化项目
还是通过快速入门章节介绍的 npm
来初始化我们的应用
$ mkdir cnode-api && cd cnode-api $ npm init egg --type=simple $ npm i
|
# 开启 validate 插件
我们选择 egg-validate 作为 validate 插件的示例。
exports.validate = { enable: true, package: 'egg-validate', };
|
# 注册路由
首先,我们先按照前面的设计来注册路由,框架提供了一个便捷的方式来创建 RESTful 风格的路由,并将一个资源的接口映射到对应的 controller 文件。在 app/router.js
中:
module.exports = app => { app.router.resources('topics', '/api/v2/topics', app.controller.topics); };
|
通过 app.resources
方法,我们将 topics 这个资源的增删改查接口映射到了 app/controller/topics.js
文件。
# controller 开发
在 controller 中,我们只需要实现 app.resources
约定的 RESTful 风格的 URL 定义 中我们需要提供的接口即可。例如我们来实现创建一个 topics 的接口:
const Controller = require('egg').Controller;
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.validate(createRule, ctx.request.body); const id = await ctx.service.topics.create(ctx.request.body); ctx.body = { topic_id: id, }; ctx.status = 201; } } module.exports = TopicController;
|
如同注释中说明的,一个 Controller 主要实现了下面的逻辑:
- 调用 validate 方法对请求参数进行验证。
- 用验证过的参数调用 service 封装的业务逻辑来创建一个 topic。
- 按照接口约定的格式设置响应状态码和内容。
# service 开发
在 service 中,我们可以更加专注的编写实际生效的业务逻辑。
const Service = require('egg').Service;
class TopicService extends Service { constructor(ctx) { super(ctx); this.root = 'https://cnodejs.org/api/v1'; }
async create(params) { const result = await this.ctx.curl(`${this.root}/topics`, { method: 'post', data: params, dataType: 'json', contentType: 'json', }); this.checkSuccess(result); return result.data.topic_id; }
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;
|
在创建 topic 的 Service 开发完成之后,我们就从上往下的完成了一个接口的开发。
# 统一错误处理
正常的业务逻辑已经正常完成了,但是异常我们还没有进行处理。在前面编写的代码中,Controller 和 Service 都有可能抛出异常,这也是我们推荐的编码方式,当发现客户端参数传递错误或者调用后端服务异常时,通过抛出异常的方式来进行中断。
- Controller 中
this.ctx.validate()
进行参数校验,失败抛出异常。
- Service 中调用
this.ctx.curl()
方法访问 CNode 服务,可能由于网络问题等原因抛出服务端异常。
- Service 中拿到 CNode 服务端返回的结果后,可能会收到请求调用失败的返回结果,此时也会抛出异常。
框架虽然提供了默认的异常处理,但是可能和我们在前面的接口约定不一致,因此我们需要自己实现一个统一错误处理的中间件来对错误进行处理。
在 app/middleware
目录下新建一个 error_handler.js
的文件来新建一个 middleware
module.exports = () => { return async function errorHandler(ctx, next) { try { await next(); } catch (err) { ctx.app.emit('error', err, ctx);
const status = err.status || 500; const error = status === 500 && ctx.app.config.env === 'prod' ? 'Internal Server Error' : err.message;
ctx.body = { error }; if (status === 422) { ctx.body.detail = err.errors; } ctx.status = status; } }; };
|
通过这个中间件,我们可以捕获所有异常,并按照我们想要的格式封装了响应。将这个中间件通过配置文件(config/config.default.js
)加载进来:
module.exports = { middleware: [ 'errorHandler' ], errorHandler: { match: '/api', }, };
|
# 测试
代码完成只是第一步,我们还需要给代码加上单元测试。
# Controller 测试
我们先来编写 Controller 代码的单元测试。在写 Controller 单测的时候,我们可以适时的模拟 Service 层的实现,因为对 Controller 的单元测试而言,最重要的部分是测试自身的逻辑,而 Service 层按照约定的接口 mock 掉,Service 自身的逻辑可以让 Service 的单元测试来覆盖,这样我们开发的时候也可以分层进行开发测试。
const { app, mock, assert } = require('egg-mock/bootstrap');
describe('test/app/controller/topics.test.js', () => { 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' }, ], }); });
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 创建了一个应用,并通过 SuperTest 来模拟客户端发送请求进行测试。在测试中我们会模拟 Service 层的响应来测试 Controller 层的处理逻辑。
# Service 测试
Service 层的测试也只需要聚焦于自身的代码逻辑,egg-mock 同样提供了快速测试 Service 的方法,不再需要用 SuperTest 模拟从客户端发起请求,而是直接调用 Service 中的方法进行测试。
const { app, mock, assert } = require('egg-mock/bootstrap');
describe('test/app/service/topics.test.js', () => { let ctx;
beforeEach(() => { 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 () => { 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()
方法创建了一个 Context 对象,并直接调用 Context 上的 Service 方法进行测试,测试时可以通过 app.mockHttpclient()
方法模拟 HTTP 调用的响应,让我们剥离环境的影响而专注于 Service 自身逻辑的测试上。
完整的代码实现和测试都在 eggjs/examples/cnode-api 中可以找到。