基于 Koa 二次封装的前端BFF应用程序(一)

358 阅读4分钟

Elpis-core

该项目是一个基于 Koa 二次封装的框架内核,使用一系列的 loader 来加载各个组件并注入到 koa 实例上,loader 部分包含 middlewareLoaderrouterSchemaLoaderrouterLoadercontrollerLoaderserviceLoaderconfigLoaderextendLoaderglobalMiddlewareLoader 八个 loader 组件,项目使用 pnpm 的 workspace 能力将 elpis-core 封装为一个独立的包,目录结构如下:

package
    |
    | -- elpis-core
             |
             | -- loader
                   |
                   | -- middleware.ts
                   | -- router-schema.ts
                   | -- router.ts
                   | -- controller.ts
                   | -- service.ts
                   | -- config.ts
                   | -- extend.ts
                   | -- globalMiddleware.ts
             | -- index.ts
             | -- package.json
    |
    | -- elpis-types
             |
             | -- core.d.ts
             | -- index.d.ts
             | -- package.json
    |
    | -- elpis-utils
             |
             | -- env.ts
             | -- helper.ts
             | -- index.ts
             | -- package.json
    |
    | -- elpis-base
             |
             | -- constroller.ts
             | -- service.ts
             | -- index.ts
             | -- package.json

核心组件

下面介绍各个核心组件。

middlewareLoader

middlewareLoader 会自动扫描 app/middleware 目录下的所有中间件,并将他们挂载到 koa 实例上下文上去,这些中间件组成洋葱圈模型,请求进入时会由一层一层的中间件去处理,这种方式可以极好的提高代码的拓展性和可维护性。 image.png

const middlewareLoader = async (app: Elpis.App): Promise<void> => {
  // 读取 app/middleware 目录下的所有文件
  const middlewarePath = path.resolve(
    app.businessPath || "",
    `.${sep}middleware`
  );
  const files = globSync(path.resolve(middlewarePath, `.${sep}**${sep}*.ts`), {
    windowsPathsNoEscape: true,
  });

  // 遍历所有文件,把内容加载到 app.middlewares 中
  const middlewares: Record<string, any> = {};
  for (const file of files) {
    const middleware = (await import(file)).default(app);
    // 获取相对于 middleware 目录的路径
    const relativePath = path.relative(middlewarePath, file);
    // 分割路径为数组,移除最后一个(文件名)
    const pathParts = relativePath.split(sep).slice(0, -1);
    // 获取文件名(不含扩展名)并处理为驼峰式
    let fileName = path.basename(file, ".ts");
    fileName = camelCase(fileName);
    // 递归创建嵌套对象
    let current = middlewares;
    pathParts.forEach((part) => {
      current[part] = current[part] || {};
      current = current[part];
    });

    // 在最终层级设置中间件
    current[fileName] = middleware;
  }

  app.middlewares = middlewares;
};

routerSchemaLoader

routerSchemaLoader 会自动扫描 app/routerSchema 目录下的所有文件,并挂载到 koa 实例上下文,routerSchema 是对接口参数的规则校验,通过 json-schema & ajv 对 API 规则进行约束,配合 ApiParamsVerifyMiddleware 中间件使用

const routerSchemaLoader = async (app: Elpis.App): Promise<void> => {
  // 读取 app/router-schema 目录下的所有文件
  const routerSchemaPath = path.resolve(
    app.businessPath || "",
    `.${sep}router-schema`
  );
  const files = globSync(path.resolve(routerSchemaPath, `.${sep}**${sep}*.ts`), {
    windowsPathsNoEscape: true,
  });

  // 注册所有 routerSchema,使得可以 'app.routerSchema' 访问
  let routerSchemas: Record<string, any> = {};
  for (const file of files) {
    const routerSchema = (await import(file)).default;
    routerSchemas = { ...routerSchemas, ...routerSchema };
  }
  app.routerSchema = routerSchemas;
};

ApiParamsVerifyMiddleware

ApiParamsVerifyMiddleware 是 API 参数验证中间件,存放于 api/middleware 目录下,由middlewareLoader 挂载到 koa 上下文中,并由 globalMiddleware 使用。

const ApiParamsVerifyMiddleware = (app: Elpis.App) => {
  const ajv = new Ajv();
  return async (ctx: Elpis.Ctx, next: () => Promise<void>) => {
    const { path, params, method } = ctx;
    const { body, query, headers } = ctx.request;
    // 只对 API 请求进行参数验证
    if (!path.includes("/api")) {
      return await next();
    }

    // 日志记录请求参数
    app.logger.info(`[${method} ${path}] body: ${JSON.stringify(body)}`);
    app.logger.info(`[${method} ${path}] query: ${JSON.stringify(query)}`);
    app.logger.info(`[${method} ${path}] params: ${JSON.stringify(params)}`);
    app.logger.info(`[${method} ${path}] headers: ${JSON.stringify(headers)}`);

    const schema = app.routerSchema[path]?.[method.toLowerCase()];
    const appendValidate = {
      body: body,
      query: query,
      params,
    };

    if (!schema) {
      return await next();
    }

    // 验证 body,query,parmas 参数是否合法
    const schemaKeys = Object.keys(schema) as (keyof typeof appendValidate)[];

    const validateArr = schemaKeys.map(async (sKey) => {
      const validate = await ajv.compile(schema[sKey]);
      const flag = await validate(appendValidate[sKey]);

      // 验证不通过,返回相关错误信息
      if (!flag)
        return Promise.reject({
          key: sKey,
          message: validate.errors,
        });

      return flag;
    });

    await Promise.all(validateArr)
      .then(() => {
        next();
      })
      .catch((err) => {
        let message = "request validata fail";

        // 输出错误信息
        if ("key" in err) {
          const msg = err.message[0];
          message = `[${
            err.key
          }] request validata fail: ${msg?.instancePath?.slice(1)} ${
            msg.message
          }`;
        }

        ctx.status = 200;
        ctx.body = {
          success: false,
          message,
          code: 442,
        };

        return;
      });
  };
};

globalMiddleware

globalMiddleware 的作用是加载全局的 middleware 文件,即加载 app/middleware.ts 文件,该文件中决定了 use 哪些中间件。

const globalMiddleware = async (app: Elpis.App) => {
  try {
    const middleware = await import(`${app.businessPath}${sep}middleware.ts`);
    middleware.default(app);
    console.log(`-- [start] load appMiddleware done --`);
  } catch (e) {
    console.log(e);
    console.log('[exception] there is no global middleware file');
  }
};

controllerLoader

controllerLoader 会自动扫描 app/controller 目录下的所有文件,并将所有的 controller 实例(约定:controller 都是 class)化后挂载到 koa 上下文。controller控制器是负责调用对应的service处理请求并返回响应的模块,

const controllerLoader = async (app: Elpis.App): Promise<void> => {
  // 读取 app/controller 目录下的所有文件
  const controllerPath = path.resolve(
    app.businessPath || "",
    `.${sep}controller`
  );
  const files = globSync(path.resolve(controllerPath, `.${sep}**${sep}*.ts`), {
    windowsPathsNoEscape: true,
  });

  // 遍历所有文件,把内容加载到 app.controllers 中
  const controllers: Record<string, any> = {};
  await Promise.all(
    files.map(async (file) => {
      // controller 都是class, 所有需要new
      const ControllerModule = (await import(file)).default;
      const controller = new ControllerModule(app);
      // 获取相对于 controller 目录的路径
      const relativePath = path.relative(controllerPath, file);
      // 分割路径为数组,移除最后一个(文件名)
      const pathParts = relativePath.split(sep).slice(0, -1);
      // 获取文件名
      let fileName = path.basename(file, ".ts");
      // 文件名驼峰化
      fileName = camelCase(fileName);
      let current = controllers;
      pathParts.forEach((part) => {
        current[part] = current[part] || {};
        current = current[part];
      });
      current[fileName] = controller;
    })
  );
  app.controller = controllers;
};

serviceLoader

serviceLoader 会自动扫描 app/service 目录下的所有文件,并将所有的 service 挂载到 koa 上下文。service 负责处理具体的业务逻辑,一个 service 可以被多个 controller 调用,这样保证 controller 控制器逻辑相对简单,只负责调用 service 来处理具体的业务。

const serviceLoader = async (app: Elpis.App): Promise<void> => {
  // 读取 app/service 目录下的所有文件
  const servicePath = path.resolve(
    app.businessPath || "",
    `.${sep}service`
  );
  const files = globSync(path.resolve(servicePath, `.${sep}**${sep}*.ts`), {
    windowsPathsNoEscape: true,
  });

  // 遍历所有文件,把内容加载到 app.controllers 中
  const services: Record<string, any> = {};
  for (const file of files) {
    const serviceModule = (await import(file)).default;
    const service = new serviceModule(app);
    // 获取相对于 service 目录的路径
    const relativePath = path.relative(servicePath, file);
    // 分割路径为数组,移除最后一个(文件名)
    const pathParts = relativePath.split(sep).slice(0, -1);
    // 获取文件名(不含扩展名)并处理为驼峰式
    let fileName = path.basename(file, ".ts");
    fileName = camelCase(fileName);
    // 递归创建嵌套对象
    let current = services;
    pathParts.forEach((part) => {
      current[part] = current[part] || {};
      current = current[part];
    });

    // 在最终层级设置中间件
    current[fileName] = service;
  }

  app.service = services;
} 

configLoader

configLoader 会自动扫描根目录下 config 文件下的所有文件,并将所有的 config 挂载到 koa 上下文。 config 为不同环境的配置文件,如: config.default.ts(默认配置文件)config.local.ts(本地配置文件)config.bate.ts(测试配置文件)config.prod.ts(生产配置文件)。Elpis-core 会根据 loadEnv 提供的方法与环境变量,选择对应的 config

const configLoader = async (app: Elpis.App): Promise<void> => {
  // 找到 config/ 目录
  const configPath = path.resolve(app.baseDir || "", `.${sep}config`);

  // 获取 default.config
  let defaultConfig = {};
  try {
    const module = await import(
      path.resolve(configPath, `.${sep}config.default.ts`)
    );
    defaultConfig = module.default || module;
  } catch {
    console.log(`[exception] there is no default.config file `);
  }

  // 获取 env.config
  let envConfig = {};
  
  try {
    if (app.loadEnv.isLocal()) {
      // 本地环境
      const module = await import(
        path.resolve(configPath, `.${sep}config.local.ts`)
      );
      envConfig = module.default || module;
    } else if (app.loadEnv.isBeta()) {
      // 测试环境
      const module = await import(
        path.resolve(configPath, `.${sep}config.beta.ts`)
      );
      envConfig = module.default || module;
    } else if (app.loadEnv.isProduction()) {
      // 生产环境
      const module = await import(
        path.resolve(configPath, `.${sep}config.prod.ts`)
      );
      envConfig = module.default || module;
    }
  } catch {
    console.log(`[exception] there is no env.config file `);
  }

  // 覆盖并加载 config 配置
  app.config = Object.assign({}, defaultConfig, envConfig);
};

loadEnv

loadEnv 用于提供一系列方法判断或获取环境变量。

const loadEnv = (app: Elpis.App): Elpis.EnvUtils => {
  return {
    // 判断是否是本地环境
    isLocal: () => {
      return process.env._ENV === "local";
    },

    // 判断是否是测试环境
    isBeta: () => {
      return process.env._ENV === "beta";
    },

    // 判断是否是生产环境
    isProduction: () => {
      return process.env._ENV === "production";
    },

    // 获取当前环境
    getEnv: () => {
      return process.env._ENV ?? "local";
    }
  }
}

extendLoader

extendLoader 会自动扫描 app/extend 目录下的所有文件,并将所有的 extend 挂载到 koa 上下文。 extend 为扩展文件,用于为 koa 实例扩展额外的功能,如日志记录功能等。

const extendLoader = async (app: Elpis.App): Promise<void> => {
    // 读取 app/extend 目录下的所有文件
    const extendPath = path.resolve(
      app.businessPath || "",
      `.${sep}extend`
    );
    const files = globSync(path.resolve(extendPath, `.${sep}**${sep}*.ts`), {
      windowsPathsNoEscape: true,
    });

    // 遍历所有文件,把内容加载到 app 上
    for (const file of files) {
      const ExtendModule = (await import(file)).default(app);
      
      // 获取文件名(不含扩展名)并处理为驼峰式
      let fileName = path.basename(file, ".ts");
      fileName = camelCase(fileName);
      
      // 过滤 app 已经存在的 key
      for (const key in app) {
        if (key === fileName) {
          console.log(`[extend load error] name:${fileName} is already in app`);
          continue;
        }
      }

      // 挂载 extend 到 app 上
      app[fileName] = ExtendModule;
    }
} 

ApiSignVerifyMiddleWare

ApiSignVerifyMiddleWare 是一个 API 签名验证中间件,采用对称加密,并支持设置有效时间,用来判断请求的签名是否合法。

const ApiSignVerifyMiddleWare = (app: Elpis.App) => {
  return async (ctx: Elpis.Ctx, next: () => Promise<void>) => {
    const { path, method } = ctx;
    
    // 只对 API 请求进行签名验证
    if (!path.includes("/api")) {
      return await next();
    }

    // 进行签名验证
    const { headers } = ctx.request;
    const { s_sign: sSgin, s_t: st } = headers;

    // 签名密钥
    const signKey = "ylc5dgw2hasd0jwq";
    // 计算签名
    const signature = md5(`${signKey}_${st}`);
    // 有效时间
    const validTime = 1000 * 60 * 10;
    app.logger.info(`[${method} ${path}] signature: ${signature}`);

    if (
      !sSgin ||
      !st ||
      (sSgin as string).toLowerCase() !== signature ||
      Date.now() - Number(st) > validTime
    ) {
      ctx.status = 200;
      ctx.body = {
        success: false,
        message: "签名验证失败",
        code: 445,
      };
      return;
    }

    await next();
  };
};

ErrorHandleMiddleWare

ErrorHandleMiddleWare 是运行时的异常错误处理中间件,用于兜底所有异常,防止服务端异常直接抛出给前端页面,并支持进行重定向。

const ErrorHandleMiddleWare = (app: Elpis.App) => {
  return async (ctx: Elpis.Ctx, next: () => Promise<void>) => {
    try {
      await next();
    } catch (err: any) {
      // 异常处理
      const { status, message, detail } = err;
      app.logger.info(JSON.stringify(err));
      app.logger.error('[-- exception --]', err);
      app.logger.error('[-- exception --]', status, message, detail);

      if (message && message.indexOf('template not found') > -1) {
        // 页面不存在,进行重定向
        ctx.status = 302;
        ctx.redirect(`${app.options?.homePath}`);
        return;
      }

      // 返回给前端
      const resBody = {
        success: false,
        code: 500,
        message: '系统异常',
      }

      ctx.status = 200;
      ctx.body = resBody;
    }
  }
}

总结

以上是 Elpis-core 应用的核心组件及相关基建的介绍,Elpis是一个前端 BFF 应用框架,目的是为开发者提供一个高效,可扩展,易维护的应用程序框架,目前仅实现内核部分和相关的部分基建,文中实现可能存在错误的理解和待优化的地方,欢迎大家讨论或指出错误。