基于 koa 1.x 实现 api_generator

Author Avatar
Splendour 9月 24, 2017

背景

在实际项目中,我们采用前后端分离的方式进行开发,即前端需要负责浏览器端、以及 nodejs 端,后台负责提供服务。因此,一个浏览器的 API 请求如下

  1. 浏览器通过请求,调用 nodejs 提供的 API
  2. nodejs 拿到请求后,进行必要的数据包装,然后调用后台提供的 API
  3. nodejs 拿到结果后,进行必要的数据处理,执行必要的操作,然后将结果返回给浏览器

目前代码分析

以 根据 id 获取用户详情 这一个接口为例,在 nodejs 端我们的代码如下

  • router

    router.get('/apis/user/:id', apiAuthCheck, controller.getUser);
    
  • controller

    exports.getUser = function*() {
    const ctx = this;
    const id = ctx.params.id;
    
    const result = userService.getOneById(id, ctx);
    console.log(`[获取单个用户详情]: ${result}`);
    return messageHelper.success(ctx, result); // messageHelper 是返回辅助类,此处不细说
    }
    
  • service

    exports.getOneById = (id, ctx) =>
    request.send('get', `/user/${id}`, null, null, ctx, false);
    
  • utils

    // 通过 cookie 判断是否登陆
    exports.apiAuthCheck = function*() {
    if (this.cookies.get('token')) {
      ...
      yield next;
    }
    }
    // 请求辅助类,request 使用 superagent 库
    exports.send = function (method, path, query, data, ctx, isForm) => {
    return new Promise((resolve, reject) => {
      const q = request[method](`${serverConfig.url}${path}`)
        .query(Object.assign({
        }, query || {}));
    
      if (ctx.state.accessToken) {
        q.set('access-token', ctx.state.accessToken);
      }
    
      if (data) {
        q.send(data);
      }
    
      if (isForm) {
        q.type('form');
      }
    
      q.end((err, result) => {
        if (err) {
          if (err.code === 'ECONNREFUSED') {
            reject({
              errCode: 50000,
              errMsg: '服务器连接失败,请重试'
            });
          }
          if (err.response) {
            reject({
              errCode: err.response.body.status || 50000,
              errMsg: err.response.body.message || '服务器连接失败,请重试'
            });
          } else {
            reject({
              errCode: 50000,
              errMsg: '服务器连接失败,请重试'
            });
          }
        } else {
          if (result.body) {
            resolve(result.body);
          } else {
            resolve(result.text);
          }
        }
      });
    });
    }
    

可以看到,就算有提取了一些辅助类,每添加一个接口,我们还是需要在 router controller service 中分别添加代码,而且几乎都是复制粘贴!而且,随着接口的增多,这些文件将会不可避免地产生代码量爆炸

思路

仔细分析上面的代码会发现,其实每一个接口,变化的只有下面的东西:

  • node 路由的 path
  • 后台接口的 path
  • 调用方法
  • 参数传递
  • 是否 form 提交(后台提供的接口分为 form 提交和 json 提交)
  • 打印

根据之前写的 action_generator,entity_generator 的经验,可以实现一个 api_generator,将变化的东西变成配置,将不变的东西抽取出来,一劳永逸

我们定义 api_generator 接受一个 Array,该 Array 的每一个元素都是一个 json 配置,根据该配置可以生成一个对应的接口

Talk is cheap. Show me the code.

// 获取调用方法,其中,将 delete 转为 koa 路由识别的 del
const getMethod = (serverMethod, apiMethod) => {
  const method = serverMethod || apiMethod;
  return method === 'delete' ? 'del' : method;
};

// 获取路径中的参数
const getParams = path => (path.match(/:\w*/g) || []).map(param => param.substring(1));

// 通过参数替换,获取最终调用的后台地址
const getRealServerPath = (serverPath, serverParams, params) => {
  let result = serverPath;
  serverParams.forEach(param => {
    result = result.replace(`:${param}`, params[param]);
  });
  return result;
};

module.exports = apis => router => {
  apis.forEach(api => {
    /** api 本身的调用方式 */
    const method = api.method;

    /** api 的显示名称 */
    const name = api.name;

    /** api 本身的 path */
    const apiPath = api.apiPath;

    /** 后台接口的调用方式 */
    const serverMethod = api.serverMethod;

    /** superagent 请求后台接口时调用方法 */
    const requestMethod = getMethod(serverMethod, method);

    /** superagent 请求后台接口时是否使用 form 提交 */
    const isForm = api.isForm || false;

    router[method](apiPath,
      function *() {
        const ctx = this;
        const params = ctx.params || {};
        const query = ctx.request.query || null;
        const body = (ctx.request.body || {}).fields || null;
        const file = (ctx.request.body || {}).files || null;

        /** 获取后台接口的 path,可以是直接配置,也可以通过请求参数转化得到,若不配置,默认和 api 一致 */
        const serverPath = api.serverPath ||
          api.mapServerPath && api.mapServerPath(params, query, body, ctx) ||
          apiPath;

        /** 根据 params,将参数填充,得到真正的 path */
        const serverParams = getParams(serverPath);
        const realServerPath = getRealServerPath(serverPath, serverParams, params);

        ctx.logger.log(`/******************** ${name} send to server ***********************/`);
        ctx.logger.log(JSON.stringify({
          path: realServerPath,
          query,
          body,
          isForm
        }));
        ctx.logger.log(`/*******************************************************************/`);

        try {
          const result = yield request.send({
            url,
            method: requestMethod,
            path: realServerPath,
            query,
            data: body,
            ctx,
            isForm
          });

          ctx.logger.log(`/****************** ${name} response from server ********************/`);
          ctx.logger.log(JSON.stringify(result));
          ctx.logger.log(`/********************************************************************/`);

          return messageHelper.success(ctx, result.data);

        } catch(e) {
          ctx.logger.log(`/****************** ${name} error from server ***********************/`);
          ctx.logger.log(JSON.stringify(e));
          ctx.logger.log(`/********************************************************************/`);

          return messageHelper.response(ctx, e);
        }
      });
  });
};

通过以上的 api_generator,我们只需要以下的配置,就可以添加一开始我们想要的获取用户详情的接口了!

{
  name: '获取单个用户详情',
  method: 'get',
  apiPath: '/user/:id'
}

兼容所有 API

参数转换,返回结果转换

// 添加一个 noWrap 函数表示不需要转换,作为参数转换的默认函数
const noWrap = data => data;

// 添加参数转换配置
const wrapQuery = api.wrapQuery || noWrap;
const wrapBody = api.wrapBody || noWrap;
const wrapParams = api.wrapParams || noWrap;
const wrapResult = api.wrapResult || noWrap;

// 在获取参数之后,对参数进行转换
/** 包装后台接口的参数 */
const wrappedParams = wrapParams(params, query, body, ctx);
const wrappedQuery = wrapQuery(query, params, body, ctx);
const wrappedBody = wrapBody(body, params, query, ctx);

// 对返回值做转换
return messageHelper.success(ctx, wrapResult(result.data));

// 使用示例
{
  method: 'post',
  name: '添加用户',
  apiPath: '/user',
  wrapBody: (body, params, query, ctx) => Object.assign({}, body, {
    id: query.id,
    time: moment(body.time).format('YYYY-MM-DD HH:mm:ss')
  })
}

调用前后执行自定义业务

// 添加业务配置
/** 调用后台接口前执行的业务 */
const businessBefore = api.businessBefore;
/** 调用后台接口后,或者不调用接口时执行的业务 */
const business = api.business;

// 在获取参数后,添加前置业务
/** 执行调用后台接口前的逻辑 */
if (businessBefore) yield businessBefore(ctx, params, query, body, file);

// 调用接口后,如果有后置业务,则将接口返回结果暂存至 ctx 中,并执行后置业务
if (business) {
  ctx.apiResult = wrapResult(result.data);
  yield business(ctx, params, query, body, file);
} else {
  return messageHelper.success(ctx, wrapResult(result.data));
}

// 使用示例
{
  method: 'post',
  name: '添加用户',
  apiPath: '/user',
  wrapBody: (body, params, query, ctx) => Object.assign({}, body, {
    id: query.id,
    time: moment(body.time).format('YYYY-MM-DD HH:mm:ss')
  }),
  businessBefore: function* (ctx, params, query, body, file) {
    // do something.
  },
  business: function* (ctx, params, query, body, file) {
    if (ctx.apiResult.errCode) {
      // do something.
    } else {
      return messageHelper.success(ctx.apiResult);
    }
  }
}

普通接口,不需要调用后台

对于这样的接口,只需要加一个配置 needRequestServer,默认为 true,如果配置为 false 就不需要调用后台接口,直接执行 business 即可,此处代码忽略

最终形态

const getMethod = (serverMethod, apiMethod) => {
  const method = serverMethod || apiMethod;
  return method === 'delete' ? 'del' : method;
};

const noWrap = data => data;

const getParams = path => (path.match(/:\w*/g) || []).map(param => param.substring(1));

const getRealServerPath = (serverPath, serverParams, params) => {
  let result = serverPath;
  serverParams.forEach(param => {
    result = result.replace(`:${param}`, params[param]);
  });
  return result;
};

const requestAuthCheck = function *(next) {
  // 校验 cookie
  yield next;
}

module.exports = apis => router => {
  apis.forEach(api => {
    /** api 本身的调用方式 */
    const method = api.method;

    /** api 的显示名称 */
    const name = api.name;

    /** api 本身的 path */
    const apiPath = api.apiPath;

    /** 是否需要请求后台接口 */
    const needRequestServer = api.needRequestServer === false ? false : true;

    /** 校验中间件 */
    const requestAuthCheck = api.requestAuthCheck || requestAuthCheck;

    /** 后台接口的调用方式 */
    const serverMethod = api.serverMethod;

    /** superagent 请求后台接口时调用方法 */
    const requestMethod = getMethod(serverMethod, method);

    /** superagent 请求后台接口时是否使用 form 提交 */
    const isForm = api.isForm || false;

    /** 后台接口的参数和返回值包装 */
    const wrapQuery = api.wrapQuery || noWrap;
    const wrapBody = api.wrapBody || noWrap;
    const wrapParams = api.wrapParams || noWrap;
    const wrapResult = api.wrapResult || noWrap;

    /** 调用后台接口前执行的业务 */
    const businessBefore = api.businessBefore;
    /** 调用后台接口后,或者不调用接口时执行的业务 */
    const business = api.business;

    router[method](apiPath, requestAuthCheck,
      function *() {
        const ctx = this;
        const params = ctx.params || {};
        const query = ctx.request.query || null;
        const body = (ctx.request.body || {}).fields || null;
        const file = (ctx.request.body || {}).files || null;

        /** 执行调用后台接口前的逻辑 */
        if (businessBefore) yield businessBefore(ctx, params, query, body, file);

        if (needRequestServer) {
          /** 包装后台接口的参数 */
          const wrappedParams = wrapParams(params, query, body, ctx);
          const wrappedQuery = wrapQuery(query, params, body, ctx);
          const wrappedBody = wrapBody(body, params, query, ctx);

          /** 获取后台接口的 path,可以是直接配置,也可以通过请求参数转化得到,若不配置,默认和 api 一致 */
          const serverPath = api.serverPath ||
            api.mapServerPath && api.mapServerPath(params, query, body, ctx) ||
            apiPath;

          /** 根据 params,将参数填充,得到真正的 path */
          const serverParams = getParams(serverPath);
          const realServerPath = getRealServerPath(serverPath, serverParams, wrapParams);

          ctx.logger.log(`/******************** ${name} send to server ***********************/`);
          ctx.logger.log(JSON.stringify({
            path: realServerPath,
            query: wrappedQuery,
            body: wrappedBody,
            isForm
          }));
          ctx.logger.log(`/*******************************************************************/`);

          try {
            const result = yield request.send({
              url,
              method: requestMethod,
              path: realServerPath,
              query: wrapQuery,
              data: wrapBody,
              ctx,
              isForm
            });

            ctx.logger.log(`/****************** ${name} response from server ********************/`);
            ctx.logger.log(JSON.stringify(result));
            ctx.logger.log(`/********************************************************************/`);

            if (business) {
              ctx.apiResult = wrapResult(result.data);
              yield business(ctx, params, query, body, file);
            } else {
              return messageHelper.success(ctx, wrapResult(result.data));
            }

          } catch(e) {
            ctx.logger.log(`/****************** ${name} error from server ***********************/`);
            ctx.logger.log(JSON.stringify(e));
            ctx.logger.log(`/********************************************************************/`);

            if (business) {
              ctx.apiResult = e;
              yield business(ctx, params, query, body, file);
            } else {
              return messageHelper.response(ctx, e);
            }
          }
        } else {
          if (business) {
            yield business(ctx, params, query, body, file);
          }
        }
      });
  });
};

结语

记住,Don’t repeat yourself.

一切的一切,初衷都是为了简化代码,提高开发效率。