里程碑一:基于 node.js 实现 BFF 层服务端内核过程总结

0 阅读3分钟

前言

最近在学习全栈开发的思想,然后看了哲玄的课程做了一个项目实战,这个练手项目叫 Elpis,借着这个实战项目从而了解全栈的开发思想,框架设计方案,以及 node 的应用

为什么要实现 BFF 层?

BFF层可以作为前后端中转站,前端页面发送请求到 BFF 层,经过 BFF 层 再到数据层: iShot_2026-04-06_17.53.49.png

这么做的好处是

能够解决多端展示问题:我们有两个需求一个是PC,另一个是移动端。此时这两个需求逻辑相似,可以调用一个接口去实现,但接口的数据格式又不太一样,这个时候我们可以用 BFF 层作为中间件去解决。

iShot_2026-04-06_18.14.34.png

能够将多个业务进行整合:例如:实现一个功能需求可能触发多个服务:用户服务、朋友关系服务、热门数据服务。这些服务请求可以不放在展示层作处理,可以先请求 BFF 层,然后 BFF 请求多个服务,这样能减轻展示层的复杂性。

iShot_2026-04-06_18.21.22.png

这样的 BFF 层的具体实现分析:

为了实现这个 BFF 层设计了一个内核引擎:Elpis-core 这么一个 node 服务,由它将 服务文件通过解析器将这些服务运行加载出来。 iShot_2026-04-06_18.37.18.png 将页面配置的相关代码逻辑,通过 elpis-core 自动解析然后挂载到一个 Koa 的实例上然后运行,elpis-core 的实现用到了 Koa 和 Koa-router 路由解析,使用 Koa 提供的API能够更方便的开发 node 服务。

核心实现相关代码:

// elpis-core/index.js
const Koa = require('koa');
const path = require('path');
const glob = require('glob');
const { sep } = path; // 兼容不同操作系统上的斜杠
const env = require('./env');

// 导入对应模块Loader
const loaderPath = path.resolve(process.cwd(), `.${sep}elpis-core${sep}loader`);
// 读取 loader 目录下的所有 js 文件
const loaderModules = glob.sync(path.resolve(loaderPath, `.${sep}**${sep}**.js`));

module.exports = {
    /**
    * 启动项目
    * @param options 项目配置
    */
    start(options = {}) {
        // 创建 Koa 实例
        const app = new Koa();

        // 应用配置
        app.options = options;

        // 基础路径
        app.baseDir = process.cwd();

        // 业务文件路径
        app.businessPath = path.resolve(app.baseDir, `.${sep}app`)

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

        // 注册全局中间件
        try {
            require(`${app.businessPath}${sep}middleware.js`)(app);
            console.log(`-- [start] load global middleware done --`);
        } catch (e) {
            console.log(`-- [exception] there is on global middleware file --`);
        }

        // 加载所有 loader 模块到内存
        loaderModules.forEach(file => {
            // 调用对应 loader 模块,传入 app 实例
            require(path.resolve(file))(app);
        });

        // 启动服务
        try {
            const port = process.env.PORT || 8080;
            const host = process.env.IP || '0.0.0.0';
            app.listen(port, host);
            console.log(`Server runnin on port: ${port}`)
        } catch (e) {
            console.error(e);
        };
    }
}
// elpis-core/loader/router.js
const KoaRouter = require('koa-router');
const glob = require('glob');
const path = require('path');
const { sep } = path;
/**
* roter loader
* @param {object} app koa 实例
* 解析所有 app/router/ 下所有 js 文件,加载到 KoaRouter 下
*/
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 => {
        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());
};

这里会引入另一个概念 “洋葱圈模型” iShot_2026-04-06_19.03.25.png 利用注册的中间件进行请求的捕获和校验:

// middleware.js
const path = require('path');
const { sep } = path;

module.exports = (app) => {
    // 配置静态根目录
    const koaStatic = require('koa-static');
    app.use(koaStatic(path.resolve(process.cwd(), `.${sep}app${sep}public`)));

    // 模版渲染引擎
    const koaNunjucks = require('koa-nunjucks-2');
    app.use(koaNunjucks({
        ext: 'tpl',
        path: path.resolve(process.cwd(), `.${sep}app${sep}public`),
        nunjucksConfig: {
            noCache: true,
            // 去掉多余行
            trimBlocks: true
        }
    }));

    // 引入 cxt.body 解析中间件
    const bodyParser = require('koa-bodyparser');
    app.use(bodyParser({
        formList: '1000mb',
        enableTypes: ['from', 'json', 'text']
    }));

    // 异常捕获中间件
    app.use(app.middlewares.errorHanlder);

    // 签名合法性校验
    app.use(app.middlewares.apiSignVerify);

    // API 参数的合法性校验
    // app.use(app.middlewares.apiParamsVerify);
};

接口 API 的校验采用了 ajv + json-schema 进行校验

// app/middleware/api-params-verify.js
const Ajv = require('ajv');
const ajv = new Ajv();
/**
* API 参数校验
*/
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;
        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}] params: ${JSON.stringify(params)}`);
        app.logger.info(`[${method} ${path}] headers: ${JSON.stringify(headers)}`);

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

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

        let valid = true;
        // jav 校验器
        let validate;

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

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

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

        // 校验 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();
    };
}

对于接口时效校验采用了 “前端+后端” 的双重校验,约束好密钥key,通过 md5 加密,在发送请求时,前端传入和后端存的“密钥key”比较一致性,如果不一致,就返回 445 请求。

const md5 = require("md5");
/**
* API 签名合法性校验
*/
module.exports = (app) => {
    return async (ctx, next) => {
        // 只对 API 请求做签名校验
        if (ctx.path.indexOf('/api') < 0) {
            return await next();
        };

        const { path, method } = ctx;
        const { headers } = ctx.request;
        const { s_sign: sSign, s_t: st } = headers;

        const signKey = 'f5b39c88-c0db-4df9-ba38-6d79bf898806';
        const signature = md5(`${signKey}_${st}`);
        app.logger.info(`[${method} ${path}] signature: ${signature}`);

        if (!sSign || !st || signature !== sSign.toLowerCase() || Date.now() - st > 600 * 1000) {

            ctx.status = 200;
            ctx.body = {
                success: false,
                message: 'signature not correct or api timeout !!',
                code: 445
            }
            return;
        };

        await next();
    };
};

关于日志工具使用了 log4js

// app/extend/logger.js
const log4js = require('log4js');
/**
* 日志工具
* 外部调用 app.logger.info app.logger.error
*/
module.exports = (app) => {
    let logger;
    if (app.env.isLocal()) {
        // 打印在控制台即可
        logger = console;
    } else {
        // 把日志输出并落地到磁盘(日志落盘)
        log4js.configure({
            appenders: {
                console: {
                    type: 'console'
                },
                // 日志文件切分
                dateFile: {
                    type: 'dateFile',
                    filename: './logs/application.log',
                    pattern: '.yyyy-MM-dd'
                }
            },
            categories: {
                default: {
                    appenders: [ 'console', 'dateFile' ],
                    level: 'trace'
                }
            }
        });
        logger = log4js.getLogger();
    };
    return logger;
};

总结:

本次学习主要是了解 elpis-core 内核的实现方式,以及封装思路: 定义每一个块业务逻辑的 loader 将业务逻辑加载进来,再经过 层层 “洋葱圈”模型的中间件(接口校验、接口参数校验,页面校验...),再到每个 controller (获取日志信息、读写 mysql...)业务处理后,再响应请求结果。