基于 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_generatorentity_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.

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