||

如何在前端开发中构建可扩展的mock服务器

在前端开发中,联调接口是一项必备技能,实际的开发中,经常是前后端并行开发,所以在后端接口还未完成环境部署的情况下,前端解决 API Mock 就显的尤为重要。

在基于 webpack 的打包架构中,webpack 提供了一个 devServer.before 字段,用来在启动开发服务器的时候执行一段代码。一种思路,便是在 before 中赋值一个 mock server ,这样一来,随着项目本地调试的开启,mock server 也会同步建立起来,本篇博文将介绍基于此方式的一种 mock server 方案。

本地服务

本地服务可以使用 express 建立,实现起来也比较简单,创建一个 http-server.js 文件,写入服务代码。

webpack 配置项中的 devServer.before 会传递一个 app 对象,这个对象也是一个 express 的实例。

function HttpServer() {
    this.version = '1.0.0';
    this.app = express();
    this.server = null;
}

HttpServer.prototype.start = function (port = 8080) {
    try {
        this.server = server = this.app.listen(port, () => {
            logger.success('Listening localhost on port ', this.server.address().port);
        });
    }
    catch (e) {
        logger.error(e);
    }
}
// 注册路由
HttpServer.prototype.route = function (method, uri, response) {
    this.app[method](uri, function (req, res) {
        let responseType = helper.getTypeOf(response);
        if (['Number', 'String', 'Array', 'Object'].includes(responseType)) {
            res.send(response);
            return;
        }
        if (responseType === 'Function') {
            res.send(response(req));
            return;
        }
    });
}

module.exports = HttpServer;

其中的 logger 和 helper 是辅助函数,logger 辅助日志输出,helper 提供工具方法。

API 模块化

API部分是可变的,每一个项目都会有所区别,也就是当前项目的API定义,不一定符合下一个项目的定义,所以API部分需要模块化处理。

我将 API 部分放到 services 目录下,services 目录下的每个目录都当做一个微服务来处理,这样一来,只需要遍历 services 目录,然后注册 services 下每个微服务的路由即可。

假设有一个服务叫 app,现处理如下:

  • app 下的 mock 文件夹,处理自定义的 mock 注册,返回值和返回方式由 mock 规则决定;
  • app 下的 yaml 文件夹,处理 swagger 定义的路由,mock 数据由 yaml 的解析决定;
  • 如果 mock 下的路由与 yaml 下的路由重名,则优先匹配 mock 下的路由;

这样一来,API 的部分同时执行了自定义和 swagger ,这便对基于 swagger 的后端非常有益,开发前 SE 如果确定了接口文档,那么前端基于 swagger 就可以马上生成一个虚拟接口服务器,前后端并行开发。

API 模块的加载

基于上述模块化的思路,API 的加载就是在加载 services 下的微服务,那么遍历 services 的每个目录即可。

不过,这并不是全部,为了可以让 API 在模块化的同时也规范化,我们可以定义 每一个API 模块必须有一个入库函数,我将次函数定义为 main 函数,main 函数是执行入口,必须返回一个路由表,再有加载器去向 http server 注册路由;

所以,API 模块的编写就变成了这样:

/**
 * 微服务入口函数
 * 
 * @param {object} random mockjs 的 Random 对象
 * @param {object} Logger logger 对象,需实例化
 * @param {object} helper 辅助函数库
 * @returns 
 */
function main(random, Logger, helper) {

  const logger = new Logger('MyTag');

  const hello = {
    method: 'get',
    uri: '/hello',
    response: function (req) {
      logger.log(req.route.path);
      return random.sentence();
    }
  }

  return {
    basePath: '/mytag/v1',
    routes: [
      hello,
    ],
  }
}

module.exports = {
  main,
}

basePath 是该服务的基础地址,routes 用来导出当前服务下的路由列表,即接口列表。路由表中的 response 则用来处理接口响应。

外部,则通过遍历 services 来注册服务接口。

/**
 * 加载 services 目录下的微服务
 * 
 * @param {object} app express 实例
 * @returns 
 */
function loadServices(app) {
  const services = fs.readdirSync(servicesPath);
  if (!services || !services.length) return;
  // 加载每一个微服务
  services.forEach(el => {
    // yaml
    const yamlHandleFile = helper.pathJoin(servicesPath, el, 'yaml');
    regYamlRoutes(app, yamlHandleFile);
    // mock
    const mockHandleFile = helper.pathJoin(servicesPath, el, 'mock/handle.js');
    regMockRoutes(app, mockHandleFile);
  });
}

其中,regYamlRoutes 方法用于加载 yaml 文件,以便注册 API 接口,regMockRoutes 方法则处理自定义 mock 的注册。

加载 mock 模块

直接看代码:

/**
 * 注册 mock 路由
 * 
 * @param {object} app server 对象
 * @param {string} mockFile mock入口文件
 */
function regMockRoutes(app, mockFile) {
  if (!fs.existsSync(mockFile)) {
    logger.warn(`mock handle file '{mockFile}' is not exist`);
    return;
  }
  let handlerRouteDefine = null;
  try {
    handlerRouteDefine = require(mockFile).main(Random, Logger, helper);
  }
  catch (e) {
    logger.error(e.toString());
    return;
  }
  if (!handlerRouteDefine.routes || !handlerRouteDefine.routes.length) {
    logger.warn(`{mockFile} undefine routes`);
    return;
  }
  // 遍历定义,注册路由
  handlerRouteDefine.routes.forEach(el => {
    if (!config.http.enabledMethods.includes(el.method)) {
      logger.warn(`request method '${el.method}' is invalid`);
      return;
    }
    const uri = helper.urlJoin(handlerRouteDefine.basePath, el.uri);
    app.route(el.method, uri, el.response);
    logger.log('Reg router =>', el.method.toUpperCase().bold().cyan(), uri);
  });
}

简单介绍下加载过程:

  • 基于传入参数判断入口文件是否存在,不存在则直接跳出;
  • 尝试 require 入口文件,并执行入口函数;
  • 入口函数执行完成后会返回一个路由表,对路由表进行校验;
  • 根据路由表注册路由;

加载 yaml 模块

直接上代码:

/**
 * 注册 yaml 路由
 * 
 * @param {object} app server 对象
 * @param {string} yamlPath yaml文件路径
 */
function regYamlRoutes(app, yamlPath) {
  if (!fs.existsSync(yamlPath)) {
    logger.warn(`path '{yamlPath}' is not exist`);
    return;
  }
  const yamls = fs.readdirSync(yamlPath);
  if (!yamls || !yamls.length) return;
  yamls.forEach(el => {
    if (!/\.yaml/.test(el)) return;
    // 读取 swagger 的 yaml 文档
    const jsonObj = yamlLoader.load(helper.pathJoin(yamlPath, el));
    const requestDefine = yamlLoader.translate(jsonObj, config.http.enabledMethods);
    // 遍历接口,注册路由
    logger.info(`Reg Yaml router from ${el}`);
    requestDefine.forEach(req => {
      if (req.mock && req.mock.response) {
        app.route(req.method, req.uri, decorator(req.mock.response));
        logger.log('Reg router =>', req.method.toUpperCase().bold().cyan(), req.uri);
      }
    });
  });
}

执行思路:

  • 基于传入参数遍历目录下的文件;
  • 过滤 .yaml 文件,解析 .yaml 文件;
  • .yaml 解析结果做判断,以确定该文件是可用的路由表;
  • 根据解析出的路由表注册路由;

那么这里就涉及到了 yaml 的解析,我是通过编写一个 yaml-loader 来实现的,大体上分两步:

  • 使用 yamljs 将 yaml 解析为 json 对象,以便分析;
  • 然后通过 yaml-loadertranslate 方法将 json 对象转换成路由表结构;

以下是 translate 的处理过程:

/**
 * 将 yaml 对象转换成请求定义
 * 
 * @param {object} yamlobj yaml 对象
 * @param {array} enabledMethods 支持的 HTTP 请求类型
 * @returns 
 */
function translate(yamlobj = {}, enabledMethods = []) {
  const basePath = yamlobj.basePath || '';
  const paths = yamlobj.paths;

  let routes = [];
  for (let uri in paths) {
    let reqObj = yamlobj.paths[uri];
    enabledMethods.forEach(type => {
      if (!reqObj[type]) return;
      const reguri = `${helper.urlJoin(basePath, uri)}`;
      const regtype = reqObj[type];
      // logger.log(reguri)
      routes.push({
        method: type,
        uri: reguri,
        mock: _createRequestDefine(regtype, yamlobj)
      });
    });
  }
  return routes;
}

Response 处理

response 是指向前台返回数据的处理,这里做了兼容,可以是函数类型,或者非函数类型。

总结之下,response 可以有以下几种返回状态:

  • 纯静态返回;
  • 基于 mockjs 的伪静态返回;
  • 响应 request 的动态返回;

返回来看注册路由的 route 方法:

// 注册路由
HttpServer.prototype.route = function (method, uri, response) {
    this.app[method](uri, function (req, res) {
        let responseType = helper.getTypeOf(response);
        if (['Number', 'String', 'Array', 'Object'].includes(responseType)) {
            res.send(response);
            return;
        }
        if (responseType === 'Function') {
            res.send(response(req));
            return;
        }
    });
}

上面对 response 做了判断,非函数类型则直接返回,函数类型则执行后返回,这便带来了非常灵活的处理能力。

除此之外,还需要注意,上文中对 yaml 文件处理 response 时,使用了一个 decorator 函数,其主要目的是用于解析 yaml 的返回类型。

decorator 的简要实现如下:

/**
 * 将返回对象重新包装成 mock 函数
 * 
 * @param {object} response 响应定义
 * @returns 返回一个闭包函数,每次请求时调用闭包函数实现数据mock
 */
function decorator(response = {}) {
  return function () {
    return _responseDecorator(response);
  }
}

_responseDecorator 内部会调用 _mockResponse 方法,而 _mockResponse 是这样定义的:

/**
 * 获取根类型的mock数据
 * 
 * @param {object} repdefine 根返回值定义 {type: string|number|boolean...}
 * @returns 
 */
function _mockResponse(repdefine = {}) {
  if (!repdefine.type) {
    return mockMap.string();
  }
  // 如果有默认值,直接返回默认值
  if (repdefine.default) {
    return repdefine.default;
  }
  // 处理枚举类型
  if (repdefine.enum) {
    return mockMap.enum(repdefine.enum);
  }
  // 其他类型
  return mockMap[repdefine.type]();
}

其中的 mockMap 是一个类型处理的映射,mock-map.js 文件定义如下:

const { Random } = require("mockjs");

/**
 * 返回枚举数组中某个随机索引对应的值
 * 
 * @param {array} enumbase 枚举值数组
 * @param {any} defaultValue 默认值,非索引
 * @returns 默认值,或枚举值中的任意一个
 */
Random.oneOf = function (enumbase = [], defaultValue = null) {
  if (defaultValue !== null) return defaultValue;
  let i = Random.integer(0, enumbase.length - 1);
  return enumbase[i];
}

module.exports = {
  'string': Random.string.bind(Random),
  'integer': Random.integer.bind(Random),
  'number': Random.float.bind(Random),
  'date': Random.date.bind(Random),
  'boolean': Random.boolean.bind(Random),
  'enum': Random.oneOf.bind(Random),
}

对 webpack 兼容

对 webpack 的兼容,还是体现在 devServer.before 字段上,webpack 在启动时会传一个 app 参数进来,这个参数本身就是一个 express 的实例,不过为了能让 mock server 不依赖 webpack ,mock server 内部也定义了一个 HttpServer 类,所以 mock server 被用于 webpack 时,需要对这个实例做兼容。

这里使用适配器模式来兼容这两个实例。

先看我的启动入口:

/**
 * 启动 mock 服务器
 * 
 * @param {object} app server 实例,可由 webpack 传入
 */
function startMockServer(app = httpServer) {
  const adapterApp = serverAdapter(app);
  loadServices(adapterApp);
  adapterApp.start(config.http.port);
}

其中的 serverAdapter 是一个适配器,用于适配 webpack 的 app 对象和 mock server 本身的 httpServer 对象,实现原理如下:

/**
 * 适配传入的 server 对象,生成一个适配后的对象
 * 
 * @param {object} app server 实例
 * @returns 
 */
function serverAdapter(app = httpServer) {
  // 如果入参本身就是 httpServer 的实例,则原样返回
  if (app instanceof HttpServer) {
    return app;
  }
  // 如果不是 httpServer 实例,说明 webpack 启动时传入了 app 参数
  // 则将 app 适配到 httpServer
  return {
    route(method, uri, response) {
      if (!config.http.enabledMethods.includes(method)) {
        logger.warn(`request method '${method}' is invalid`);
        return;
      }
      const adApp = { app };
      HttpServer.prototype.route.call(adApp, method, uri, response);
    },
    start() {
      logger.success('Mock server is running...');
    }
  };
}

这样一来,在后续使用 app 的地方都可以只有一种写法,无需再进行过多的判断,同时也达到了兼容 webpack 的目的。

在 Vue 项目中使用

写到这里,应该很容易理解如何在项目中引用 mock server 了。

我这里也做一下简要的介绍:

  • 将项目目录整体拷贝到 Vue 项目的根目录 mock 文件夹下(文件夹名可自定义);
  • vue.config.js 中引入 mock server ,并将入口函数 startMockServer 赋值到 devServer.before 上;
  • 在 mock server 的 services 中定义自己的微服务;
  • 使用 npm run start 启动 Vue 本地调试,mock server 也将同步启动;

来一段代码示意一下,修改 vue.config.js 如下:

const startMockServer = require("./mock");

module.exports = {
  // ...
  devServer: {
    before: startMockServer
  },
  // ...
}

是不是很简单。

类似文章

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注