用Eggjs 实现 RESTful API

2,950 阅读1分钟

Eggjs 实现 RESTful API

RESTful API 不理解的,可以Google一下。

Egg.js 为企业级框架和应用而生 (对Koa进行了封装📦)

我认为Egg的优势

1.相对于NestJS上手简单。

2.Egg 奉行『约定优于配置』,方便维护。

3.一个插件只做一件事,方便自由拓展。

1.开始上手吧

通过官方脚手架搭建项目

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

创建配置文件

Egg项目默认开启了Eslint & 严格模式

我习惯使用prettierrc插件来格式化代码

创建.prettierrc

// demo-api/.prettierrc
// 使用单引号 
// 行末使用分号
{
  "semi": true,
  "singleQuote": true
}

开启 validate 插件

npm i egg-validate --save

选择 egg-validate 作为 validate 插件 校验请求参数

PS:Koa 可以使用 koa-parameter 😊

// config/plugin.js
'use strict';

/** @type Egg.EggPlugin */
module.exports = {
  // had enabled by egg
  static: {
    enable: true,
  },
  validate: {
    enable: true,
    package: 'egg-validate',
  },
};

注册路由

框架提供了一个便捷的方式来创建 RESTful 风格的路由,并将一个资源的接口映射到对应的 controller 文件。

官网介绍 eggjs.org/zh-cn/basic…

规则如下表:

MethodPathRoute NameController.Action
GET/blogblogapp.controllers.blog.index
GET/blog/newnew_postapp.controllers.blog.new
GET/blog/:idpostapp.controllers.blog.show
GET/blog/:id/editedit_postapp.controllers.blog.edit
POST/blogblogapp.controllers.blog.create
PUT/blog/:idpostapp.controllers.blog.update
DELETE/blog/:idpostapp.controllers.blog.destroy

我们以博客blog为例

// app/router.js
'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
  router.resources('blog', '/api/v1/blog', controller.blog);
};

通过 app.resources 方法,我们将 topics 这个资源的增删改查接口映射到了 app/controller/blog.js 文件。

controller 开发

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

'use strict';

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

// 定义创建接口的请求参数规则
const createRule = {
  title: 'string',
  tab: { type: 'enum', values: [ 'vue', 'nodejs', 'web' ], required: false },
  content: 'string',
};

class BlogController extends Controller {
  async create() {
    const ctx = this.ctx;
    // 校验 `ctx.request.body` 是否符合我们预期的格式
    // 如果参数校验未通过,将会抛出一个 status = 422 的异常
    ctx.validate(createRule, ctx.request.body);
    // 调用 service 创建一个 Blog
    const id = await ctx.service.blog.create(ctx.request.body);
    // 设置响应体和状态码
    ctx.body = {
      blog_id: id,
    };
    ctx.status = 201;
  }
}
module.exports = BlogController;

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

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

service 开发

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

'use strict';

// app/service/blog.js
const Service = require('egg').Service;

class BlogService extends Service {

  async create(params) {
    // 创建Blog
    console.log(params);
    // TODO 创建blog
    const newBlog = { id: '001' };
    // 创建成功返回ID
    return newBlog.id;
  }

}

module.exports = BlogService;

在创建 blog 的 Service 开发完成之后,我们就从上往下的完成了一个接口的开发。

是不是很Easy!

接着把 GET、PUT、DELETE 着几个常用的接口也补上

首先补全controller

'use strict';

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

// 定义创建接口的请求参数规则
const createRule = {
  title: 'string',
  tab: { type: 'enum', values: [ 'vue', 'nodejs', 'web' ], required: false },
  content: 'string',
};

class BlogController extends Controller {
  async create() {
    const ctx = this.ctx;
    // 校验 `ctx.request.body` 是否符合我们预期的格式
    // 如果参数校验未通过,将会抛出一个 status = 422 的异常
    ctx.validate(createRule, ctx.request.body);
    // 调用 service 创建一个 Blog
    const id = await ctx.service.blog.create(ctx.request.body);
    // 设置响应体和状态码
    ctx.body = {
      blog_id: id,
    };
    ctx.status = 201;
  }

  async index() {
    const ctx = this.ctx;
    // 处理GET请求
    const blogList = await ctx.service.blog.create(ctx.query);
    ctx.body = blogList;
    ctx.status = 200;
  }

  async update() {
    const ctx = this.ctx;
    // 校验ctx.validate(createRule, ctx.request.body);
    ctx.validate(createRule, ctx.request.body);
    // 调用 service 修改一个 Blog
    await ctx.service.blog.update(ctx.request.body, ctx.params.id);
    // 设置响应体和状态码
    ctx.body = ctx.request.body;
    ctx.status = 202;
  }

  async destroy() {
    const ctx = this.ctx;
    // 调用 service 删除一个 Blog
    await ctx.service.blog.destroy(ctx.params.id);
    // 设置响应体和状态码
    ctx.status = 204;
  }

}
module.exports = BlogController;

接着补充service

'use strict';

// app/service/blog.js
const Service = require('egg').Service;

class BlogService extends Service {

  async create(params) {
    // 创建Blog
    console.log(params);
    // TODO 创建blog
    const newBlog = { id: '001' };
    // 创建成功返回ID
    return newBlog.id;
  }

  async index(params) {
    // 获取查询参数
    console.log(params);
    // TODO 在数据库中查询
    const blogList = [
      {
        id: '001',
        title: 'egg入门',
      },
      {
        id: '002',
        title: '精通Vue',
      },
      {
        id: '003',
        title: '进击全栈',
      },
    ];
    // 返回查询结果
    return blogList;
  }

  async update(params, blogid) {
    // 获取参数
    console.log(params, blogid);
    // TODO 修改数据库
  }

  async destroy(params) {
    // 获取参数
    console.log(params);
    // TODO 修改数据库
  }


}

module.exports = BlogService;

统一错误处理

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

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

框架虽然提供了默认的异常处理,但是可能和我们在前面的接口约定不一致,因此我们需要自己实现一个统一错误处理的中间件来对错误进行处理。

app/middleware 目录下新建一个 error_handler.js 的文件来新建一个 middleware

'use strict';

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)加载进来:

/* eslint valid-jsdoc: "off" */

'use strict';

/**
 * @param {Egg.EggAppInfo} appInfo app info
 */
module.exports = appInfo => {
  /**
   * built-in config
   * @type {Egg.EggAppConfig}
   **/
  const config = exports = {};

  // use for cookie sign key, should change to your own and keep security
  config.keys = appInfo.name + '_1606221621840_7108';

  // add your middleware config here
  config.middleware = [];
  
  // 关闭安全威胁csrf防范
  config.security = {
    csrf: {
      enable: false,
    },
  };

// +++++++++++++++++++++++++++++++++++++++++++++++++
  // 加载 errorHandler 中间件
  config.middleware = [ 'errorHandler' ];

  // 只对 /api 前缀的 url 路径生效
  config.errorHandler = {
    match: '/api',
  };
// +++++++++++++++++++++++++++++++++++++++++++++++++
  // add your user config here
  const userConfig = {
    // myAppName: 'egg',
  };

  return {
    ...config,
    ...userConfig,
  };
};

API测试

POST
$ curl --location --request POST 'http://localhost:7001/api/v1/blog' \
> --header 'Content-Type: application/json' \
> --header 'Cookie: csrfToken=-7zv8KR2is7chKGm9GJ5ZE8Z' \
> --data-raw '{
>     "title": "vue+Node",
>     "tab": "web",
>     "content": "vue入门到精通"
> }'
$ {"blog_id":"001"}

GET
$ curl http://localhost:7001/api/v1/blog?page=1&list=10
$ 001

PUT
$ curl --location --request PUT 'http://localhost:7001/api/v1/blog/001' \
> --header 'Content-Type: application/json' \
> --header 'Cookie: csrfToken=-7zv8KR2is7chKGm9GJ5ZE8Z' \
> --data-raw '{
>     "title": "vue+Node",
>     "tab": "web",
>     "content": "vue入门到精通"
> }'
$ {"title":"vue+Node","tab":"web","content":"vue入门到精通"}

DELETE
curl --location --request DELETE 'http://localhost:7001/api/v1/blog/001' \
--header 'Cookie: csrfToken=-7zv8KR2is7chKGm9GJ5ZE8Z'
// 204No Content