Mint 自研框架设计与实现:从重复开发走向配置驱动(二)

BFF 层设计与实现

为什么需要分层

如果不考虑代码分层,一个接口可能会被写成下面这样:

js 复制代码
router.get("/api/getData", (req, res) => {
  // 判断前置条件
  // 校验请求参数
  // 执行业务逻辑
  // 组装并返回响应数据
});

这种写法在业务规模较小时简单直接,但随着需求不断增加,参数校验、权限判断、业务处理和响应组装等逻辑会逐渐堆积在同一个接口中。

当接口数量和业务复杂度持续增长后,代码会变得越来越臃肿,不仅难以阅读,也不利于复用、测试和维护。

因此,Mint 对 BFF 应用进行了职责拆分,将不同类型的逻辑放入对应的目录中,再通过框架内核将这些模块加载并关联起来。

目录结构与职责划分

Mint 约定业务代码统一放在 app 目录下,整体结构如下:

text 复制代码
根目录
  ├──  
    app
    ├── router              # 路由定义
    ├── controller          # 控制层,接收请求并组织响应
    ├── service             # 服务层,承载业务逻辑
    ├── model               # 数据及领域模型
    ├── extend              # 扩展层,为 Koa 上下文扩展能力
    ├── router-params       # 路由参数与校验规则
    ├── middleware          # 路由级中间件,只负责定义,由业务按需注册
    ├── pre-middleware.js   # 全局前置中间件
  └── config              # 应用配置

各层之间的职责如下:

  • router:声明请求路径、请求方法以及对应的控制器。
  • controller:接收请求参数,调用服务层并组装响应结果。
  • service:承载具体的业务逻辑,也可以在这里调用企业后端服务。
  • model:管理数据模型。
  • extend:为 Koa 应用或上下文扩展自定义属性和方法。
  • router-params:描述路由参数及其校验规则。
  • middleware:定义可复用的路由中间件,由业务根据需要注册。
  • pre-middleware.js:注册需要在所有路由之前执行的全局中间件。
  • config:管理不同运行环境下的应用配置。

通过目录约定,每一层只负责自身职责,从而降低模块之间的耦合度。

使用 Loader 关联各层模块

完成目录分层后,下一个问题是:框架如何发现这些文件,并让不同层之间能够互相访问?

Mint 的解决方案是实现一套基于约定的 Loader 机制。

执行 node index.js 启动 Koa 应用时,框架内核会依次运行多个 Loader。每个 Loader 负责扫描指定目录,将符合约定的文件加载到内存中,并挂载到 Koa 应用实例上。

例如:

js 复制代码
app.config;
app.extend;
app.service;
app.middleware;
app.routerSchema;
app.controller;

业务代码可以通过这些统一入口访问不同层的能力,而不需要在每个文件中手动维护复杂的模块引用关系。

整体加载流程如下:

  1. 创建 Koa 应用实例;
  2. 初始化项目路径和运行环境;
  3. 加载应用配置;
  4. 加载上下文扩展;
  5. 加载 Service;
  6. 加载 Middleware;
  7. 加载路由参数配置;
  8. 加载 Controller;
  9. 注册框架及业务的全局前置中间件;
  10. 加载并注册 Router;
  11. 启动 HTTP 服务。

BFF服务内核自研

core/index.js 是整个 BFF 应用的启动入口,其实现如下:

js 复制代码
const Koa = require("koa");
const path = require("path");
const env = require("./env");
const { sep } = path;

const middlewareLoader = require("./loader/middleware");
const routerLoader = require("./loader/router");
const routerSchema = require("./loader/router-schema");
const controllerLoader = require("./loader/controller");
const serviceLoader = require("./loader/service");
const configLoader = require("./loader/config");
const extendLoader = require("./loader/extend");
const mergeWith = require("lodash/mergeWith");

const defaultOptions = {
  name: "m-service",
  homePage: "",
  noAuthDirectPath: "",
};

module.exports = {
  /**
   * 启动项目
   * @param {object} options 项目配置
   * @param {string} options.name 项目名称
   */
  start(options = {}) {
    const app = new Koa();

    // 合并默认配置与业务配置
    app.options = mergeWith(defaultOptions, options);

    // 项目根目录
    app.baseDir = process.cwd();

    // 业务代码目录
    app.businessPath = path.resolve(app.baseDir, `.${sep}app`);

    // 初始化运行环境
    app.$env = env(app);
    console.log(`-- [start] env: ${app.$env.get()}`);

    // 加载配置
    configLoader(app);
    console.log("-- [start] config loader done --");

    // 加载上下文扩展
    extendLoader(app);
    console.log("-- [start] extend loader done --");

    // 加载服务
    serviceLoader(app);
    console.log("-- [start] service loader done --");

    // 加载中间件
    middlewareLoader(app);
    console.log("-- [start] middleware loader done --");

    // 加载路由参数配置
    routerSchema(app);
    console.log("-- [start] router schema loader done --");

    // 加载控制器
    controllerLoader(app);
    console.log("-- [start] controller loader done --");

    // 注册框架内置的全局前置中间件
    try {
      require(path.resolve(__dirname, `..${sep}app${sep}pre-middleware.js`))(app);
      console.log("-- [start] framework pre-middleware loader done --");
    } catch {
      console.error("-- [exception] framework pre-middleware load exception --");
    }

    // 注册业务侧的全局前置中间件
    try {
      require(path.resolve(app.businessPath, `.${sep}pre-middleware.js`))(app);
      console.log("-- [start] business pre-middleware loader done --");
    } catch {
      console.error("-- [exception] business pre-middleware load exception --");
    }

    // 路由依赖其他模块,因此最后加载
    routerLoader(app);
    console.log("-- [start] router loader done --");

    try {
      const port = process.env.PORT || 8080;
      const host = process.env.HOST || "0.0.0.0";

      app.listen(port, host);
      console.log(`${app.options.name} listening on ${host}:${port}`);
    } catch (error) {
      console.error(error);
    }

    return app;
  },
};

这里需要注意 Loader 的执行顺序。 Loader 不只是负责扫描文件,也负责建立应用各层之间的依赖关系。

Loader 的实现原理

不同 Loader 的职责虽然不同,但实现思路基本一致:

  1. 确定需要扫描的目录;
  2. 决定是否支持递归扫描子目录;
  3. 使用 glob 获取符合规则的文件;
  4. 遍历文件列表;
  5. 根据相对路径生成模块名称;
  6. 创建对应的嵌套对象;
  7. 将模块挂载到 Koa 应用实例上。

下面以 Controller Loader 为例。

假设存在以下目录:

text 复制代码
app/controller
└── custom-model
    ├── xxxx.js
    └── custom-controller.js

加载完成后,Controller 会按照文件目录结构映射为嵌套对象:

js 复制代码
app.controller.customModel.xxxx;
app.controller.customModel.customController;

对应的实现如下:

js 复制代码
const path = require("path");
const glob = require("glob");
const { sep } = path;

/**
 * 扫描 Controller 文件,并将其注册到 app.controller
 *
 * 文件结构:
 * app/controller
 * └── custom-model
 *     ├── xxxx.js
 *     └── custom-controller.js
 *
 * 注册结果:
 * app.controller.customModel.xxxx
 * app.controller.customModel.customController
 */
module.exports = (app) => {
  // 框架内置的 Controller 目录
  const frameworkControllerPath = path.join(__dirname, `..${sep}..${sep}app${sep}controller`);

  // 递归获取目录下的所有 JavaScript 文件
  const frameworkFileList = glob.sync(path.join(frameworkControllerPath, "**", "*.js"));

  const controller = {};

  const handleFile = (filePath, controllerPath) => {
    let currentController = controller;

    // 获取文件相对于 Controller 根目录的路径
    let relativePath = path.relative(controllerPath, filePath);

    // 将短横线或下划线命名转换为驼峰命名
    relativePath = relativePath.replace(/[-_](\w)/gi, (_, letter) => letter.toUpperCase());

    const parts = relativePath.split(sep);
    let filename = parts.pop();
    filename = path.parse(filename).name;

    const names = [...parts, filename];

    // 按照目录层级创建嵌套对象
    for (let i = 0; i < names.length; i += 1) {
      const name = names[i];
      const isLast = i === names.length - 1;

      if (isLast) {
        const Controller = require(filePath)(app);
        currentController[name] = new Controller();
      } else {
        if (!currentController[name]) {
          currentController[name] = {};
        }

        currentController = currentController[name];
      }
    }
  };

  // 先加载框架能力,再加载业务能力
  frameworkFileList.forEach((filePath) => {
    handleFile(filePath, frameworkControllerPath);
  });

  // 将加载结果统一挂载到 Koa 应用实例
  app.controller = controller;
};

这套 Loader 机制将物理目录结构映射成了 JavaScript 对象结构。

运行环境管理

除了业务模块,内核还提供了统一的运行环境判断能力。

运行环境通过启动命令中的 NODE_ENV 传入,env.js 将常用的环境判断封装成统一方法:

js 复制代码
module.exports = () => {
  return {
    // 是否为开发环境
    isDevelopment() {
      return process.env.NODE_ENV === "development";
    },

    // 是否为测试环境
    isTest() {
      return process.env.NODE_ENV === "test";
    },

    // 是否为生产环境
    isProduction() {
      return process.env.NODE_ENV === "production";
    },

    // 获取当前运行环境,默认使用 development
    get() {
      return process.env.NODE_ENV ?? "development";
    },
  };
};

业务代码不需要在不同位置重复判断 process.env.NODE_ENV,只需要通过统一入口访问:

js 复制代码
app.$env.isDevelopment();
app.$env.isTest();
app.$env.isProduction();
app.$env.get();

这也为后续按环境加载不同配置、启用不同中间件以及调整日志策略提供了基础能力。

本章小结

至此,Mint 的 BFF 层已经具备了几个基础能力:

  • 通过目录约定划分 Router、Controller、Service 等模块;
  • 通过 Loader 自动发现并加载业务文件;
  • 将文件目录映射为可访问的嵌套对象;
  • 将各层能力统一挂载到 Koa 应用实例;
  • 支持框架内置能力与业务扩展能力共同加载;
  • 提供统一的运行环境判断机制。

这套设计的核心是"约定优于配置"。业务开发者只需要按照目录规范编写代码,框架便可以自动完成模块发现、实例化、注册和关联,从而减少重复的工程配置工作。

相关推荐
greenbbLV1 小时前
积分兑换商城供应商挑选:避坑要点与靠谱选择解析
前端·小程序
程序员cxuan1 小时前
OpenAI 把 Hugging Face 打穿了,然后 GLM 5.2 当了救火队长?
人工智能·后端·程序员
a1117762 小时前
3D 建筑编辑器 Pascal Editor THreeJS 开源
前端·3d·html
jason_yang2 小时前
写给女儿的英语App
前端·人工智能
誰能久伴不乏2 小时前
深入理解 C++ 多态:对象模型与底层机制解析
开发语言·c++·架构
思码梁田2 小时前
CSS 定位详解:从相对到固定,掌握网页布局的核心
前端·javascript·css
勇往直前plus2 小时前
Vite :从双击 HTML 到现代前端开发
前端·html
muddjsv2 小时前
CSS 高级选择器精讲:伪类、伪元素、逻辑伪类与选择器解耦规范
前端·css
HH‘HH2 小时前
前端应用的离线暂停更新策略:原理、实现与最佳实践
开发语言·前端·php