Skip to content

实现 RESTful API

通过 Web 技术开发服务,为客户端提供接口,可能是各个 Web 框架最广泛的应用之一。这篇文章我们拿 CNode 社区 的接口来看一看,通过 Egg 如何实现 RESTful API,供客户端调用。

CNode 社区现在的 v1 版本接口不是完全符合 RESTful 语义。在这篇文章中,我们将基于 CNode v1 的接口,封装一个更符合 RESTful 语义的 v2 版本 API。

设计响应格式

在 RESTful 风格的设计中,我们通过响应状态码标识响应的状态,保持响应的 body 简洁,只返回接口数据。以 topics 资源为例:

获取主题列表

  • GET /api/v2/topics
  • 响应状态码:200
  • 响应体:
json
[
  {
    "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
  • 响应体:
json
{
  "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
  • 响应体:
json
{
  "topic_id": "57ea257b3670ca3f44c5beb6"
}

更新主题

  • PUT /api/v2/topics/57ea257b3670ca3f44c5beb6
  • 响应状态码:204
  • 响应体:空

错误处理

在接口处理发生错误时,如果是客户端请求参数导致的错误,我们返回 4xx 状态码;如果是服务端自身的处理逻辑错误,我们返回 5xx 状态码。所有异常对象都是对这个异常状态的描述,其中 error 字段是错误的描述,detail 字段(可选)是导致错误的详细原因。

例如,当客户端传递的参数异常时,我们可能返回一个响应,状态码为 422,返回响应体为:

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

实现

在约定好接口之后,我们可以开始动手实现了。

初始化项目

还是通过快速入门章节介绍的 npm 来初始化我们的应用:

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

开启 validate 插件

我们选择 egg-validate 作为验证插件的示例。在 config/plugin.js 文件中开启它:

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

注册路由

首先,我们先按照前面的设计来注册路由,框架提供了一个便捷的方式来创建 RESTful 风格的路由,并将一个资源的接口映射到对应的 controller 文件。在 app/router.js 中编写:

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 开发

controller 中,我们只需要实现 app.resources 约定的 RESTful 风格的 URL 定义 中我们需要提供的接口即可。例如我们来实现创建一个 topics 的接口:

js
// app/controller/topics.js
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.request.body` 是否符合我们预期的格式
    // 如果参数校验未通过,将会抛出一个 status = 422 的异常
    ctx.validate(createRule, ctx.request.body);
    // 调用 service 创建一个 topic
    const id = await ctx.service.topics.create(ctx.request.body);
    // 设置响应体和状态码
    ctx.body = {
      topic_id: id,
    };
    ctx.status = 201;
  }
}
module.exports = TopicController;

如同注释中说明的,一个 Controller 主要实现了下面的逻辑:

  1. 调用 validate 方法对请求参数进行验证。
  2. 用验证过的参数调用 service 封装的业务逻辑来创建一个 topic。
  3. 按照接口约定的格式设置响应状态码和内容。

service 开发

service 中,我们可以更加专注地编写实际生效的业务逻辑。

js
// 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);
    // 返回创建的 topic 的 id
    return result.data.topic_id;
  }

  // 封装统一的调用检查函数,可以在查询、创建和更新等 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;

在创建 topicService 开发完成之后,我们就从上往下的完成了一个接口### 统一错误处理

正常的业务逻辑已经完成,但是异常我们还没有进行处理。在前面编写的代码中,Controller 和 Service 都可能抛出异常,这是我们推荐的编码方式,当发现客户端参数错误或调用后端服务异常时,通过抛出异常的方式来中断操作。

  • Controller 中通过 this.ctx.validate() 进行参数校验,校验失败时抛出异常。
  • Service 中调用了 this.ctx.curl() 方法访问 CNode 服务,可能会因网络问题等情况抛出服务端异常。
  • Service 中拿到 CNode 服务端返回的结果后,也可能会收到请求调用失败的返回结果,此时同样会抛出异常。

尽管框架提供了默认的异常处理方式,但可能与我们之前接口的约定不一致,因此需要自己实现一个统一错误处理的中间件来处理异常。

app/middleware 目录下新建一个 error_handler.js 文件,创建一个中间件:

js
// app/middleware/error_handler.js
module.exports = () => {
  return async function errorHandler(ctx, next) {
    try {
      await next();
    } catch (err) {
      // 所有的异常都会触发 app 上的一个 error 事件,框架会记录一条错误日志
      ctx.app.emit('error', err, ctx);

      const status = err.status || 500;
      // 在生产环境中,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) 中加载这个中间件:

js
// config/config.default.js
module.exports = {
  // 加载 errorHandler 中间件
  middleware: ['errorHandler'],
  // 只对以 /api 为前缀的 URL 路径生效
  errorHandler: {
    match: '/api',
  },
};

测试

代码完成只是第一步,我们还需要给代码加上单元测试

Controller 测试

我们先来编写 Controller 代码的单元测试。在写 Controller 单测的时候,我们可以适时地模拟 Service 层的实现,因为对 Controller 的单元测试而言,最重要的部分是测试自身的逻辑,而 Service 层按照约定的接口模拟(mock)掉,Service 自身的逻辑可以让 Service 的单元测试来覆盖,这样我们开发的时候也可以分层进行开发测试。

js
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' },
        ],
      });
  });

  // 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 创建了一个应用,并通过 SuperTest 来模拟客户端发送请求进行测试。在测试中我们会模拟 Service 层的响应来测试 Controller 层的处理逻辑。

Service 测试

Service 层的测试也只需要聚焦于自身的代码逻辑,egg-mock 同样提供了快速测试 Service 的方法,不再需要用 SuperTest 模拟从客户端发起请求,而是直接调用 Service 中的方法进行测试。

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

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

  beforeEach(() => {
    // 创建一个匿名的 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 方法可以便捷地对应用发起的 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.mockContext() 方法创建了一个 Context 对象,并直接调用 Context 上的 Service 方法进行测试,测试时可以通过 app.mockHttpclient() 方法模拟 HTTP 调用的响应,让我们剥离环境的影响而专注于 Service 自身逻辑的测试上。


完整的代码实现和测试都在 eggjs/examples/cnode-api 中可以找到。

Born to build better enterprise frameworks and apps