基于koa 实现 elpis-core

81 阅读5分钟

前言

elpis-core是一个基于koa.js开发的一个简易版egg,通过约定好的目录结构与编写好的各种loader文件去加载代码,实现规范、易维护的api接口开发流程。

主要奉行约定优于配置的设计理念,通过一系列的loader来加载约定目录下 configmiddlewareserviceextendrouterSchemaroutercontroller.

项目结构

│ app
  │ -- controller # 控制器层,处理业务逻辑和请求响应
  │ -- extend # 自定义公共组件或一些通用方法
  │ -- middleware # 各种自定义中间件
  │ -- router # 路由配置
  │ -- router-schema # API接口的路由和参数规则
  │ -- service # 服务层,负责数据库与外部API的交互
  │ -- router-schema # 页面组件,每个页面一个文件夹,包含该页面的逻辑和UI 
  │ -- middleware.js # 用于加载项目必须的中间件与自定义中间件
│ config
  │ -- config.default.js  默认系统配置
  │ -- config.local.js    开发环境系统配置
  │ -- config.beta.js     测试环境系统配置
  │ -- config.prod.js     生产环境系统配置
│ elpis-core
  │ -- loader 
       │ -- config.js # 通过 env 读取app/config下的环境配置
       │ -- controller.js # 加载所有在 app/controller 目录下的controller
       │ -- extend.js # 加载所有在 app/extend 目录下的 extend
       │ -- middleware.js # 加载所有在 app/middleware 目录下的 middleware
       │ -- router-schema.js # 加载所有在 app/router-schema 目录下的 router-schema
       │ -- router.js # 解析所有在 app/router/ 目录下所有的js文件,加载到koaRouter中
       │ -- service.js # 加载所有在 app/service 目录下的 service
  │ -- env.js # 提供环境相关的工具类 
  │ -- index.js # 入口文件
│ 其他

loader详解

loader是通过加载特定目录下的js文件,调用其方法 传入koa的app实例,从而将对应的代码执行并挂载。

middlewareLoader

作为koa的中间件,有着洋葱圈模型的特性

image.png

在洋葱模型中,每一层相当于一个中间件,用来处理特定的功能,比如错误处理、接口校验、身份验证等等。其处理顺序先是 next() 前请求(Request,从外层到内层)然后执行 next() 函数,最后是 next() 后响应(Response,从内层到外层),也就是说每一个中间件都有两次处理时机

image.png

在elpis-core中的mindlewareLoader自动扫描 app/middleware 目录下的所有中间件文件,并将它们挂载到应用的上下文

  • 核心代码
module.exports = (app) => {
  // 读取 app/middleware/**/**.js 下所有的文件
  const middlewarePath = path.resolve(app.businessPath, `.${sep}middleware`);
  const fileList = glob.sync(
    path.resolve(middlewarePath, `.${sep}**${sep}**.js`)
  );

  // 遍历所有文件目录,把内容加载到 app.middleware 下
  const middlewares = {};
  fileList.forEach((file) => {
    // 提取文件名称
    let name = path.resolve(file);
    // 截取路径 app/middleware/custom-module/custom-middleware.js => custom-module/custom-middleware
    name = name.substring(
      name.lastIndexOf(`middleware${sep}`) + `middleware${sep}`.length,
      name.lastIndexOf(".")
    );
    // 把 '-' 统一改成驼峰模式,custom-module/custom-middleware.js => customModule/customMiddleware
    name = name.replace(/[_-][a-z]/gi, (s) => s.substring(1).toUpperCase());
    // 挂载middleware 到内存app对象中
    let tempMiddleware = middlewares;
    const names = name.split(sep);
    for (let i = 0; i < names.length; i++) {
      if (i === names.length - 1) {
        tempMiddleware[names[i]] = require(path.resolve(file))(app);
      } else {
        if (!tempMiddleware[names[i]]) {
          tempMiddleware[names[i]] = {};
        }
        tempMiddleware = tempMiddleware[names[i]];
      }
    }
  });

  app.middlewares = middlewares;
};

routerSchemaLoader

用于定义各个api接口的参数协议

  • 核心代码
  // 读取 app/router-schema/**/**.js 下所有的文件
  const routerSchemaPath = path.resolve(
    app.businessPath,
    `.${sep}router-schema`
  );
  const fileList = glob.sync(
    path.resolve(routerSchemaPath, `.${sep}**${sep}**.js`)
  );
  // 注册所有 routerSchema,使得可以 'app.routerSchema' 这样访问
  let routerSchema = {};
  fileList.forEach((file) => {
    routerSchema = {
      ...routerSchema,
      ...require(path.resolve(file)),
    };
  });
  app.routerSchema = routerSchema;
};

controllerLoader

控制器是处理请求并返回响应的模块,它们会调用服务来获取数据并将其返回给客户端,如接口,页面。

  • 核心代码
  // 读取 app/controller/**/**.js 下所有的文件
  ...
     省略类似文件路径获取代码
  ...
  
  // 遍历所有文件目录,把内容加载到 app.controller 下
  const controller = {};
  fileList.forEach((file) => {
  ...
      省略类似name处理代码
  ...
    for (let i = 0; i < names.length; i++) {
      if (i === names.length - 1) { //文件
        const ControllerMoudule = require(path.resolve(file))(app);
        tempController[names[i]] = new ControllerMoudule();
      } else { //文件夹
        if (!tempController[names[i]]) {
          tempController[names[i]] = {};
        }
        // tempController ==== {customModule: { a:{ b:{ } } };
        tempController = tempController[names[i]];
      }
    }
  });
  
  app.controller = controller;
};

serviceLoader

服务负责处理具体的业务逻辑,它们可以被控制器调用。服务负责处理具体的业务逻辑,从而保持 controller 中的逻辑简洁,且一个 service 可提供给多个 controller 调用,也应分离逻辑和展示,从而便于编写测试用例。如:数据处理,第三方服务调用等

  • 核心代码
module.exports = (app) => {
  // 读取 app/service/**/**.js 下所有的文件
  ...
     省略类似文件路径获取代码
  ...
  
  // 遍历所有文件目录,把内容加载到 app.service 下
  const service = {};
  fileList.forEach((file) => {
   ...
      省略类似name处理代码
  ...
    for (let i = 0; i < names.length; i++) {
      if (i === names.length - 1) { //文件
        const ServiceMoudule = require(path.resolve(file))(app);
        tempService[names[i]] = new ServiceMoudule();
      } else { //文件夹
        if (!tempService[names[i]]) {
          tempService[names[i]] = {};
        }
        // tempService ==== {customModule: { a:{ b:{ } } };
        tempService = tempService[names[i]];
      }
    }
  });

  app.service = service;
};

configLoader

处理不同环境的配置文件和默认配置文件
如: config.default.js(默认)config.prod.js(生产)config.local.js(开发) 可以根据你运行的环境去加载对应的配置文件,且合并默认配置文件。

  • 核心代码
module.exports = (app) => {
  // 找到 config/ 目录
  const configPath = path.resolve(app.baseDir, `.${sep}config`);

  // 获取default.config
  let defaultConfig = {};
  try {
    defaultConfig = require(path.resolve(
      configPath,
      `.${sep}config.default.js`
    ));
  } catch (e) {
    console.log(`[exception] Failed to load default.config file: ${e.message}`);
  }

  // 获取env.config
  let envConfig = {};
  try {
    if (app.env.isLocal()) {
      // 本地环境
      envConfig = require(path.resolve(configPath, `.${sep}config.local.js`));
    } else if (app.env.isBeta()) {
      // 测试环境
      envConfig = require(path.resolve(configPath, `.${sep}config.beta.js`));
    } else if (app.env.isProduction()) {
      // 生产环境
      envConfig = require(path.resolve(configPath, `.${sep}config.prod.js`));
    }
  } catch (e) {
    console.log(`[exception] Failed to load env.config file: ${e.message}`);
  }

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

extendLoader

扩展的功能是为 koa 实例扩展额外的功能,如:日志 elpis-core中对extend的处理也是跟 controller 一样收集应用 extend 目录下的所有扩展,不同的是,extend是直接挂载到 koa实例 上, 应用中使用 app.xxx 获取

  • 核心代码
module.exports = (app) => {
  // 读取 app/extend/**.js 下所有的文件
   ...
     省略类似文件路径获取代码
   ...
  // 遍历所有文件目录,把内容加载到 app.extend 下
  fileList.forEach((file) => {
    // 提取文件名称
    ...
      省略类似name处理代码
    ...

    // 过滤 app 已经存在的key
    for (const key in app) {
      if (key === name) {
        console.log(`[extend load error] name:${name} is already in app`);
        return;
      }
    }

    // 挂载 extend 到内存app对象中
    app[name] = require(path.resolve(file))(app);
  });
};

routerLoader

路由的功能定义页面和接口路由,映射对应的controller.

  • 核心代码
module.exports = (app) => {
  // 找到路由文件路径
  const routerPath = path.resolve(app.businessPath, `.${sep}router`);

  // 实例化 KoaRouter
  const router = new KoaRouter();

  // 注册所有路由
  const fileList = glob.sync(path.resolve(routerPath, `.${sep}**${sep}**.js`));
  fileList.forEach((file) => {
    // module.exports = (app, router) => {
    //     router.get('xxxx/x/x/x/x', xxxcontre);
    // }
    require(path.resolve(file))(app, router);
  });

  // 路由兜底(健壮性)
  router.get("*", async (ctx, next) => {
    ctx.status = 302; // 临时重定向
    ctx.redirect(`${app?.options?.homePage ?? "/"}`);
  });

  // 路由注册到app上
  app.use(router.routes());
  app.use(router.allowedMethods());
};

loader加载顺序探讨

加载顺序是我本人比较困惑的点

当前项目loader的加载顺序如下

middlewareLoader > routerSchemaLoader > controllerLoader > serviceLoader > configLoader > extendLoader > routerLoader

按照个人理解

configLoader > extendLoader > mindlewareLoader > routerSchemaLoader > serviceLoader > controllerLoader > routerLoader

config应该在更早的阶段加载,因为其他组件如service或controller可能需要依赖配置信息。同样,extend(扩展)可能提供了其他模块需要的工具或方法,所以应该在它们之前加载。

接下来,全局中间件应该在路由之前注册,但其他中间件可能由middlewareLoader处理,这部分可能包括应用级别的中间件,需要在路由之前加载

然后,routerSchema可能在定义路由结构,而routerLoader实际注册路由,所以routerSchema应该在routerLoader之前执行,这也是当前的顺序。

但是,config和extend的加载位置可能有问题。比如,service可能依赖config,所以config应该在service之前加载。而extend可能提供一些扩展功能,其他loader可能依赖这些扩展,所以extend应该在controller、service等之前加载。

因此,以我理解的顺序应该是:先加载config和extend,然后是middleware、routerSchema,接着是service、controller,最后是全局中间件和路由。这样各个模块的依赖关系就能正确满足,比如service可以使用config中的配置,controller可以使用service和extend中的功能。

总结

以上为我个人对于elpis-core的理解,初次学习node.js和koa.js实现服务端应用,还有许多不理解和需要改进的地方,希望各位大佬指正!

注:抖音 “哲玄前端”,《大前端全栈实践课》