一、elpis 基于 nodejs 实现服务端内核引擎

BFF层主要工作: 根据架构图、Koa 洋葱圈模型,收到前端请求后,首先进行路由参数校验,校验通过后路由分发到业务层(中间可能经过一系列的中间件),在业务层可能是做 SSR 页面渲染、环境配置(生产/测试等环境对应的服务不同)、能力扩展等(如日志记录能力),然后到 Service 层,在这里进行调取日志、读写 Mysql、调用外部服务等工作,最后将响应结果返回给前端。

一、elpis-core 设计

该部分主要是实现 BFF 层,每个人都有自己的开发风格和设计思路,所以 BFF 层的实现也因人而异,但是在团队中这样对其他成员有一定的理解成本,不利于维护,为解决这个问题,借鉴 egg.js, 遵循「约定大于配置」,定义好项目结构以及每个部分具体做什么,让成员快速理解和上手,专注于具体业务,然后核心实现解析器,通过解析器将所有文件转化为运行时内存对象,需要时直接从 app 对象获取。

根据前面的架构设计,项目结构如下:

markdown 复制代码
|- app
    |- middleware 中间件
    |- extend 扩展
    |- router 路由分发
    |- router-schema 路由参数校验
    |- controller 业务处理
    |- service 针对数据层的原子性操作
    |- middleware.js 全局中间件
|- config 项目在不同运行环境下的自定义配置

对应的解析器如下:

markdown 复制代码
|- elpis-core
    |- loader 解析器
        |- middleware.js 
        |- extend.js 
        |- router.js 
        |- router-schema.js 
        |- controller.js 
        |- service.js 
        |- config.js 
    |- env.js 环境配置
    |- index.js 服务启动

二、elpis-core 实现

index.js

bash 复制代码
## 引入所有loader
const xxxLoader = require("./loader/xx.js");

## 1. 实例化 koa 项目
const app = new Koa();

## 2. 将 config、env 等基础信息挂载到 app 上
app.config = config; # 外部传入
app.baseDir = process.cwd();
app.env = env();

## 3. 引入所有 loader
xxxLoader(app)

## 全局中间件

## 4. 注册路由
routerLoader(app)

## 5. 启动服务
const port = process.env.PORT;
const host = process.env.IP;
app.listen(port, host);

configLoader

读取当前环境下项目 config 中对应的配置文件,然后将配置覆盖到 app.config 中

js 复制代码
// config/config.[env].js
// env: production、default、beta
module.exports = {};

// elpis-core/loader/config.js
// configLoader
module.exports = (app) => {
    const env = app.env.xx();
    const config = require(`config/${env}.js`);
    app.config = Object.assign({}, config);
}

extendLoader

项目扩展(如:引入 log4js 进行日志打印)

读取 extend 文件下的内容(一个文件视为一个扩展),将扩展直接挂在 app 上,通过 app.xxx 调用

js 复制代码
// app/extend/*.js 扩展实现,如日志打印
module.exports = (app) => {
  let logger;
  
  // 具体逻辑 开发环境打印到控制台,生产环境打印到日志文件中
  if(app.env.isLocal()) {
      logger = console.log
  } else {
      logger = log4js.getLogger();
  }

  return logger;
}

// elpis-core/loader/extend.js
// extendLoader
module.exports = (app) => {
    const extendPath = path.resolve(app.businessPath, `.${sep}extend`); 
    const fileList = glob.sync(path.resolve(extendPath, `.${sep}**${sep}**.js`));
    fileList.forEach(file => {
        // 文件名转化为驼峰式
        const name = path.resolve(file).substring(...);
        
        // app 上已有的 name 过滤掉
        
        app[name] = require(path.resolve(file))(app);
    })  
}

serviceLoader

数据服务相关

读取 service 文件夹下的内容,将每个 service 挂在 app.service 上,通过 app.service.customModule.customService 访问

js 复制代码
// app/service/*.js
module.exports = (app) => {
  return class xxxService {
    constructor() {
      this.app = app;
      this.config = config;
    }
    async function xxx1(ctx) {}
    
    async function xxx2(ctx) {}
  }
}

// elpis-core/loader/service.js
// serviceLoader
module.exports = (app) => {
    let services = {};
    
    const servicePath = path.resolve(app.businessPath, `.${sep}service`); 
    const fileList = glob.sync(path.resolve(servicePath, `.${sep}**${sep}**.js`));
    
    fileList.forEach(file => {
        let temp = services;
        
        // 截取路径 app/service/custom-module/custom-service.js => custom-module/custom-service
        // 转化为驼峰式 customModule/customService
        const name = path.resolve(file).substring(...);
        const names = name.split('/');
        for(let i = 0; i < names.length; i++) {
            if(i == names.length - 1) {
                const ServiceModule = require(path.resolve(file))(app);
                temp[names[i]] = new ServiceModule()
            } else {
                if(!temp[names[i]]) {
                    temp[names[i]] = {}
                }
                temp = temp[names[i]]
            }
        }
    })

    app.service = services;
}

controllerLoader

业务处理

同 Service, 将 controller 挂在 app.controller 上,通过 app.controller.customModule.customController 访问

js 复制代码
// app/controller/*.js
module.exports = (app) => {
  return class xxxController {
    constructor() {
      this.app = app;
      this.config = config;
      this.service = app.service;
    }
    async function method1(ctx) {}
  }
}

// elpis-core/loader/controller.js
// controllerLoader
module.exports = (app) => {
    let controllers = {};
    
    const controllerPath = path.resolve(app.businessPath, `.${sep}controller`); 
    const fileList = glob.sync(path.resolve(controllerPath, `.${sep}**${sep}**.js`));
    
    fileList.forEach(file => {
        let temp = controllers;
        
        // 截取路径 app/controller/custom-module/custom-controller.js => custom-module/custom-controller
        // 转化为驼峰式 customModule/customController
        const name = path.resolve(file).substring(...);
        const names = name.split('/');
        for(let i = 0; i < names.length; i++) {
            if(i == names.length - 1) {
                const ControllerModule = require(path.resolve(file))(app);
                temp[names[i]] = new ControllerModule()
            } else {
                if(!temp[names[i]]) {
                    temp[names[i]] = {}
                }
                temp = temp[names[i]]
            }
        }
    })

    app.controller = controllers;
}

middlewareLoader

中间件(如:errorHandler 统一处理错误、apiParamsVerify api请求参数校验)

读取 middleware 文件夹下所有内容,通过 app.middlewares.customModule.customMiddleware 访问

js 复制代码
// app/middleware/*.js
module.exports = (app) => {
  // ctx: 请求上下文
  return async (ctx, next) => {
    // ...
  };
};

// elpis-core/loader/middleware.js
// middlewareLoader
// 加载所有 middleware
moudle.exports = (app) => {
    const middlewares = {};
    
    const middlewarePath = path.resolve(app.businessPath, `.${sep}middleware`); 
    const fileList = glob.sync(path.resolve(middlewarePath, `.${sep}**${sep}**.js`));
    
    fileList.forEach(file => {
        let temp = middlewares;
        
        // 截取路径 app/middleware/custom-module/custom-middleware.js => custom-module/custom-middleware
        // 转化为驼峰式 customModule/customMiddleware
        const name = path.resolve(file).substring(...);
        const names = name.split('/');
        for(let i = 0; i < names.length; i++) {
            if(i == names.length - 1) {
                temp[names[i]] = require(path.resolve(file))(app)
            } else {
                if(!temp[names[i]]) {
                    temp[names[i]] = {}
                }
                temp = temp[names[i]]
            }
        }
    })  
}

app.middlewares = middlewares;

routerLoader

路由注册

js 复制代码
// app/router/*.js 
module.exports = (app, router) => {
    const { xxController } = app.controller;
    router.get([路由], xxController[方法].bind(xxController));
};

// elpis-core/loader/router.js
// routerLoader
module.exports = (app) => {
    // 实例化路由
    const router = new KoaRouter();
    
    const routerPath = path.resolve(app.businessPath, `.${sep}router`); 
    const fileList = glob.sync(path.resolve(routerPath, `.${sep}**${sep}**.js`));
    
    fileList.forEach(file => {
        // 读取所有 router 文件,完成注册所有路由
        require(path.resolve(file))(app, router);
    })
    
    // 路由兜底
    router.get("*", async (ctx, next) => {});
    
    // 路由注册
    app.use(router.routes());
    app.use(router.allowedMethods());
}

routerSchemaLoader

路由参数格式定义

js 复制代码
// app/router-schema/*.js
module.exports = {
  // 遵循 RESTFUL API 规范、`json-schema`格式
  "/api/xxx": {
    get: {}, // 查询/获取
    post: {}, // 添加
    put: {}, // 修改
    delete: {}, // 删除
  },
};

// elpis-core/loader/router-schema.js
// routerSchemaLoader
// 最终结果
// app.routerSchema = {
//  `${api1}`: ${jsonSchema},
//  `${api2}`: ${jsonSchema},
// }

module.exports = (app) => {
    let routerSchema = {};
    
    const routerSchemaPath = path.resolve(app.businessPath, `.${sep}router-schema`); 
    const fileList = glob.sync(path.resolve(routerSchemaPath, `.${sep}**${sep}**.js`));
    
    fileList.forEach(file => {
        routerSchema = {
          ...routerSchema,
          ...require(path.resolve(file)), // 解构获取所有 api
        };
    })
    
    app.routerSchema = routerSchema;
}

三、elpis-core 应用

接收请求并渲染页面

CSR:客户端发送一条请求后,服务端返回空 HTML + JS 文件,然后浏览器下载执行 JS,调接口请求数据填充 HTML

SSR:客户端发送一条请求后,服务端动态生成完整页面(组装页面模版 + 数据)返回给前端,然后浏览器直接渲染

这里需要做的是 SSR:解析请求参数、生成对应页面字符串并赋值给 ctx.body、将结果返回给客户端

这些事情可以通过 koa-nunjucks-2 模版引擎中间件来做,内部的render 方法会挂在 ctx 上,render 会渲染模版页面并将页面模版字符串赋值给ctx.body

js 复制代码
// router/view.js
module.exports = (app, router) => {
    const {view: viewController} = app.controller;
    router.get(`/app/:page`, viewController.renderPage.bind(viewController))
}

// controller/view.js
module.exports = (app) => {
    return class ViewController { 
        async function renderPage(ctx){
            // output/entry.${ctx.params.page} 文件名
            await ctx.render(`output/entry.${ctx.params.page}`, {
                // 传递的参数,可挂在 window 对象上
                name: app.options?.name,
                env: app.env.get(),
                options: JSON.stringify(app.options),
            })
        }
    }
}

// app/middleware.js 全局中间件
module.exports = (app) => {
    // 配置静态根目录
    const root = path.resolve(process.cwd(), "./app/public");
    app.use(require("koa-static")(root));
    
    // 模版渲染引擎:渲染页面 + 赋值 `ctx.body`,koa 接收到 ctx.body 后,将内容返回给了前端
    const koaNunjucks = require("koa-nunjucks-2");
    app.use(
        koaNunjucks({
          ext: "tpl", // 文件后缀
          path: root, // 文件存放的目录
          nunjucksConfig: {
            noCache: true,
            trimBlocks: true, // 开启后,文件中的空白字符会被移除
          },
         }),
    );
}

// public/output/entry.pageX.tpl 文件
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <h1>Page1</h1>
  </body>
</html>

数据请求

在 BFF 层做参数约束以及校验工作,保证了前后端都用一套规则,避免出 bug

js 复制代码
// 假设某页面中发起 get 请求
const res = await axios.get('api/project/list', {
    query: {
        proj_Key: xx,
    }
});

// router/project.js 路由分发
router.get('/api/project/list',
app.middlewares.apiParamsVerify,// 这里可以传递多个中间件
projectController.getList.bind(projectController))

// controller/project.js
async function getList(ctx) {
    const {project: projectService} = app.service;
    const res = projectService.getList(ctx.xxx);
    // 这里可以对 res 进行组装裁剪,然后返回给前端
    ctx.body({
        success: true,
        data: res,
    })
}

// service/project.js
async function getList(params) {
    // 进行数据库操作等
    const res = sql.xxx;
    return res;
}

// router-schema/project.js 参数约束
module.exports = {
    'api/project/list': {
        get: {
            query: {
                type: 'object'
                properties: {
                    proj_Key: {
                        type: "string",
                    }
                },
                required: ['proj_Key']
            }
        }
    }
}

// middleware/api-params-verify.js 参数校验中间件 利用 ajv
onst Ajv = require("ajv");
const ajv = new Ajv();

/**
 * API 参数校验
 * @param {object} app
 * @returns
 */
module.exports = (app) => {
  const $schema = "http://json-schema.org/draft-07/schema#";

  return async (ctx, next) => {
    // 只对 API 请求做签名校验
    if (ctx.path.indexOf("/api") < 0) {
      return await next();
    }

    // 获取请求参数
    const { body, query, headers } = ctx.request;
    // 在路由注册的时候app.middlewares.apiParamsVerify中间件如果没有传进去,这里是获取不到 params 参数的
    const { params, path, method } = ctx;

    app.logger.info(`[${method} ${path}] body: ${JSON.stringify(body)}`);
    app.logger.info(`[${method} ${path}] query: ${JSON.stringify(query)}`);
    app.logger.info(`[${method} ${path}] headers: ${JSON.stringify(headers)}`);
    app.logger.info(`[${method} ${path}] params: ${JSON.stringify(params)}`);

    const schema = app.routerSchema[path]?.[method.toLowerCase()];

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

    let valid = true;

    // ajv 校验器
    let validate;

    // 校验 headers
    if (valid && headers && schema.headers) {
      schema.headers.$schema = $schema;
      validate = ajv.compile(schema.headers);
      valid = validate(headers);
    }

    // 校验 query
    if (valid && query && schema.query) {
      schema.query.$schema = $schema;
      validate = ajv.compile(schema.query);
      valid = validate(query);
    }

    // 校验 body
    if (valid && body && schema.body) {
      schema.body.$schema = $schema;
      validate = ajv.compile(schema.body);
      valid = validate(body);
    }

    // 校验 params
    if (valid && params && schema.params) {
      schema.params.$schema = $schema;
      validate = ajv.compile(schema.params);
      valid = validate(params);
    }

    if (!valid) {
      ctx.status = 200;
      ctx.body = {
        success: false,
        message: `request validate fail ${ajv.errorsText(validate.errors)}`,
        code: 442,
      };
      return;
    }

    await next();
  };
};

 // app/middleware.js 全局中间件
 // 引入 ctx.body 解析中间件
  const bodyParser = require("koa-bodyparser");
  app.use(
    bodyParser({
      formLimit: "1000mb",
      enableTypes: ["form", "json", "text"],
    }),
  );

  // 引入 API 参数校验
  app.use(app.middlewares.apiParamsVerify);
相关推荐
前端双越老师2 天前
我开发 AI Agent 项目踩过的 5个坑
前端·agent·全栈
飘尘4 天前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
onething3655 天前
Spring Boot + Spring AI 从入门到实战:7天转型计划 Day 5 —— SSE 流式输出 + 打字机效果
人工智能·后端·全栈
onething3655 天前
Spring Boot + Spring AI 从入门到实战:7天转型计划 Day 6 —— 业务完善 + 会话消息预览
人工智能·后端·全栈
东坡白菜6 天前
破局全栈:一个前端开发的Java入门实战记录(1)
java·全栈
程序员黑豆8 天前
AI全栈开发系列开篇:从Java全栈到AI应用实战
前端·ai编程·全栈
chengliu05089 天前
从前端转型全栈、 Agent 开发
程序员·全栈
智码看视界10 天前
老梁聊全栈系列 JavaScript语言本质:从原型链到异步编程的深度解析
开发语言·javascript·全栈·javascript核心
To_OC10 天前
我一直以为 Ajax 是个黑盒,直到我写了这 50 行代码
前端·后端·全栈