从零构建一个 Koa 企业级框架:Elpis 架构设计实战

43 阅读10分钟

从零构建一个 Koa 企业级框架:Elpis 架构设计实战

本文基于 Elpis 项目实战,深入剖析一个企业级 Node.js 框架的架构设计思路与实现细节。

前言

在 Node.js 生态中,Koa 以其轻量、灵活的特性深受开发者喜爱。但 Koa 本身只是一个内核,要支撑企业级应用,我们需要在其之上构建一套完整的框架体系。

本文将从架构设计的角度,带你拆解一个基于 Koa 的企业级框架——Elpis,涵盖:

  • Loader 自动加载机制
  • 中间件洋葱模型与注册顺序
  • MVC 架构与依赖组织
  • SSR 服务端渲染
  • API 签名验证
  • 参数校验与日志系统
  • 框架核心与业务代码的解耦

一、整体架构概览

1.1 目录结构

elpis/
├── elpis-core/              # 框架核心(可独立发布)
│   ├── index.js             # 框架入口
│   ├── env.js               # 环境变量处理
│   └── loader/              # 加载器集合
│       ├── config.js        # 配置加载器
│       ├── extend.js        # 扩展加载器
│       ├── middleware.js    # 中间件加载器
│       ├── controller.js    # 控制器加载器
│       ├── service.js       # 服务加载器
│       ├── router.js        # 路由加载器
│       └── router-schema.js # 路由Schema加载器
├── app/                     # 业务代码
│   ├── controller/          # 控制器层
│   ├── service/             # 服务层
│   ├── middleware/          # 中间件
│   ├── extend/              # 扩展
│   ├── router/              # 路由定义
│   ├── router-schema/       # 路由参数Schema
│   └── public/              # 静态资源 & 模板
├── config/                  # 配置文件
│   ├── config.default.js
│   └── config.prod.js
└── docs/                    # 文档

1.2 核心设计原则

  1. 约定优于配置:通过目录约定自动加载业务代码
  2. 核心与业务分离:elpis-core 不反向引用业务代码
  3. 依赖单向流动:config → extend → schema → service → controller → router
  4. 可扩展性:通过 extend 机制扩展框架能力

二、Loader 自动加载机制

Loader 是框架的"骨架",它负责按照约定自动扫描并加载业务代码。

2.1 为什么需要 Loader?

在原生 Koa 中,我们需要手动引入每一个中间件、控制器、路由:

// 没有 Loader 时的噩梦
const userController = require('./controller/user');
const projectController = require('./controller/project');
const authMiddleware = require('./middleware/auth');
const loggerMiddleware = require('./middleware/logger');
// ... 每加一个文件都要手动引入

有了 Loader 后,只需在对应目录下创建文件,框架自动识别并加载:

app/controller/
├── user.js      ← 自动加载
├── project.js   ← 自动加载
└── admin/
    └── role.js  ← 自动加载(支持嵌套目录)

2.2 Loader 通用模式

所有 Loader 都遵循相同的模式:

// 伪代码
module.exports = (app) => {
    // 1. 扫描目录获取所有 .js 文件
    const fileList = glob.sync(path.join(targetPath, '**', '*.js'));
    
    fileList.forEach(file => {
        // 2. 将文件路径转换为命名空间(支持嵌套目录)
        const name = path.relative(targetPath, file)
            .replace('.js', '')
            .replace(/[_-][a-z]/ig, m => m[1].toUpperCase());
        
        // 3. 加载模块
        const module = require(file);
        
        // 4. 实例化(如果是类)并挂载到 app 上
        app[namespace][name] = typeof module === 'function' 
            ? new module(app) 
            : module;
    });
};

2.3 加载顺序优化

Loader 的加载顺序不是随意的,必须遵循依赖单向流动原则:

顺序Loader依赖说明
1config最先,所有模块都可能依赖配置
2extend无依赖,扩展 Koa 实例
3routerSchema无依赖,纯数据定义
4service依赖 config,被 controller 依赖
5controller依赖 service,被 router 依赖
6middleware可能依赖 schema/service
7router最后,依赖 controller/middleware

为什么 routerSchema 要放在 middleware 之前?

虽然当前中间件在运行时才访问 app.routerSchemaList,运行时不会报错,但将被依赖的模块前置是更合理的架构设计,也为未来中间件在加载阶段就引用 schema 留出了余量。

三、中间件体系深度解析

3.1 洋葱模型回顾

Koa 的中间件采用洋葱模型:

请求 → 中间件1 → 中间件2 → 中间件3 → 路由处理
                ↓           ↓
响应 ← 中间件1 ← 中间件2 ← 中间件3 ← 路由处理

每个中间件都有两次执行机会:

  • next() 之前:请求进入时执行(从上到下)
  • next() 之后:响应返回时执行(从下到上)

3.2 中间件注册顺序

中间件的注册顺序至关重要,顺序错了会导致功能异常:

const middlewareOrder = [
    'static',        // 第1层:静态资源优先,命中直接返回
    'errorHandler',  // 第2层:捕获所有后续中间件的错误
    'accessLogger',  // 第3层:记录所有请求的访问日志
    'apiSignVerify', // 第4层:API签名验证(业务安全)
    'validator',     // 第5层:参数校验(业务校验)
    'requestLogger'  // 第6层:开发环境详细日志
];

为什么 errorHandler 要放在前面?

因为洋葱模型的特性,errorHandler 放在越外层,能捕获的错误范围越大。如果放在最后,它前面的中间件抛出的错误就捕获不到了。

3.3 经典中间件实现

错误处理中间件
module.exports = async (ctx, next) => {
    try {
        await next(); // 进入内层
        
        // 响应阶段:处理 404
        if (ctx.status === 404) {
            if (!ctx.url.startsWith('/api/') && !ctx.url.startsWith('/static/')) {
                const err = new Error('template not found');
                err.status = 404;
                throw err; // 抛给 catch 统一处理
            }
            ctx.body = { code: 404, message: 'Not Found' };
        }
        
        // 重定向状态码不设置 body,避免覆盖 Location 头
        if ([301, 302, 307, 308].includes(ctx.status)) {
            return;
        }
    } catch (error) {
        // 统一错误处理
        ctx.app.logger.logError(error, ctx);
        
        // template not found → 302 跳转首页
        if (error.message?.includes('template not found')) {
            ctx.status = 302;
            ctx.redirect(ctx.app.options.homepage || '/');
            return;
        }
        
        ctx.status = error.status || 500;
        ctx.body = process.env.NODE_ENV === 'production'
            ? { code: ctx.status, message: 'Internal Server Error' }
            : { code: ctx.status, message: error.message, stack: error.stack };
    }
};
API 签名验证中间件

签名验证是接口安全的重要防线,原理如下:

客户端                              服务端
  |                                  |
  | 1. 构造参数 params               |
  | 2. 加 timestamp + nonce          |
  | 3. 按 key 字典序排序             |
  | 4. 拼接字符串 + secret           |
  | 5. MD5 加密 → signature          |
  |                                  |
  | 6. 发送请求(带 signature/timestamp/nonce)
  |                                  |
  |                                  | 7. 接收请求,提取参数
  |                                  | 8. 验证 timestamp 是否过期
  |                                  | 9. 验证 nonce 是否重复
  |                                  | 10. 同样算法计算签名
  |                                  | 11. 比对签名是否一致
  |                                  |
  | ← 12. 返回 200/401              |

核心实现:

function verifySign(params, secret, receivedSign) {
    // 1. 过滤签名字段
    const filtered = Object.fromEntries(
        Object.entries(params).filter(([k]) => k !== 'signature' && k !== 'sign')
    );
    
    // 2. 按 key 排序
    const sortedKeys = Object.keys(filtered).sort();
    
    // 3. 拼接字符串
    const signStr = sortedKeys
        .map(key => `${key}=${typeof filtered[key] === 'object' 
            ? JSON.stringify(filtered[key]) 
            : filtered[key]}`)
        .join('&') + secret;
    
    // 4. MD5 加密并比对
    return crypto.createHash('md5').update(signStr, 'utf8').digest('hex') === receivedSign;
}

四、MVC 架构与依赖组织

4.1 三层架构

Router(路由层)
   ↓ 调用
Controller(控制层)→ 参数校验、请求响应处理
   ↓ 调用
Service(服务层)→ 业务逻辑、数据处理
   ↓ 调用
Model(模型层)→ 数据库操作

4.2 BaseController 基类

基类封装通用能力,子类通过继承获得:

class BaseController {
    constructor(app) {
        this.app = app; // 持有 app 引用,访问 service/config 等
    }
    
    // 页面渲染(SSR)
    async render(ctx, template, data = {}) { ... }
    
    // 统一响应格式
    success(ctx, data, message) {
        ctx.body = { code: 200, message, data };
    }
    
    error(ctx, code, message, data) { ... }
    unauthorized(ctx, message) { ... }
    notFound(ctx, message) { ... }
}

4.3 Controller 与 Service 的关系

  • Controller:薄,只做 HTTP 层的事(参数解析、响应格式化、调用 service)
  • Service:厚,封装业务逻辑,可被多个 controller 复用
// 好的实践:Controller 很薄
class ProjectController extends BaseController {
    async getList(ctx) {
        const { page, pageSize } = ctx.query;
        const list = await ctx.app.services.project.getList(page, pageSize);
        this.success(ctx, list, '获取成功');
    }
}

// 坏味道:Controller 里写业务逻辑
class ProjectController extends BaseController {
    async getList(ctx) {
        // 直接在 controller 里写 SQL、复杂计算...
        const rows = await db.query('SELECT * FROM projects WHERE ...');
        // ...
    }
}

五、SSR 服务端渲染

5.1 什么是 SSR

SSR(Server-Side Rendering)即服务端渲染,是指在服务器端将模板 + 数据渲染成完整的 HTML,再返回给浏览器。

传统 CSR(客户端渲染):
浏览器 → 请求 HTML(空壳) → 下载 JS → 请求数据 → 渲染页面

SSR(服务端渲染):
浏览器 → 请求 → 服务器渲染 HTML → 返回完整页面 → 浏览器直接展示

5.2 ctx.render 的来源

ctx.render 并非 Koa 内置,而是由 koa-nunjucks-2 中间件注入的:

// app/middleware/nunjucks.js
const koaNunjucks = require('koa-nunjucks-2');

module.exports = koaNunjucks({
    ext: 'tpl',                                    // 模板扩展名
    path: path.join(__dirname, '../public/output'), // 模板目录
    nunjucksConfig: {
        autoescape: true,  // 自动转义(防 XSS)
        watch: true,       // 开发模式热更新
        noCache: process.env.NODE_ENV !== 'production'
    }
});

5.3 两种渲染方式对比

项目中存在两种渲染方式,注意区分:

方式实现能力适用场景
ctx.render()koa-nunjucks-2完整模板引擎(循环、条件、继承)复杂页面
this.render()BaseController 自定义仅变量替换 {{ key }}简单页面/原型

建议:统一使用 ctx.render,获得完整的模板引擎能力和安全特性。

5.4 SSR 的优劣势

优势:

  • SEO 友好,搜索引擎可直接抓取内容
  • 首屏加载快,无需等待 JS 下载执行
  • 兼容低版本浏览器和禁用 JS 的场景

劣势:

  • 服务器压力大,每次请求都要渲染
  • 前后端耦合度高,开发体验略差

六、参数校验(AJV + Schema)

6.1 为什么需要参数校验

  • 安全:防止恶意输入导致的注入攻击
  • 稳定:提前拦截非法参数,避免业务逻辑出错
  • 体验:给前端清晰的错误提示

6.2 Schema 驱动的参数校验

通过 router-schema 定义参数规则,validator 中间件自动校验:

// app/router-schema/project.js
module.exports = {
    '/api/projects': {
        get: {
            query: {
                type: 'object',
                properties: {
                    page: { type: 'integer', minimum: 1, default: 1 },
                    pageSize: { type: 'integer', minimum: 1, maximum: 100, default: 10 }
                }
            }
        }
    }
};

校验中间件自动根据路由路径查找 Schema 并执行校验:

// 伪代码
module.exports = async (ctx, next) => {
    const schema = findSchema(ctx.path, ctx.method, app.routerSchemaList);
    if (!schema) {
        await next();
        return;
    }
    
    const ajv = new Ajv({ allErrors: true });
    const validate = ajv.compile(schema);
    const valid = validate(ctx.request.body);
    
    if (!valid) {
        ctx.status = 400;
        ctx.body = { code: 400, message: '参数错误', errors: validate.errors };
        return;
    }
    
    await next();
};

七、日志系统

7.1 日志分层

一个完善的日志系统应该分层:

层级用途示例
access访问日志记录所有 HTTP 请求的 method、url、status、耗时
error错误日志记录异常堆栈,用于排查问题
business业务日志记录关键业务操作,用于审计
debug调试日志开发环境详细日志,排查问题用

7.2 开发/生产环境差异

// app/extend/logger.js
if (process.env.NODE_ENV === 'production') {
    // 生产环境:log4js,写入文件,按日期切割
    app.logger = {
        logAccess: (ctx, duration) => { /* 写入 access.log */ },
        logError: (error, ctx) => { /* 写入 error.log */ },
        // ...
    };
} else {
    // 开发环境:console,直接输出到终端
    app.logger = {
        logAccess: (ctx, duration) => console.log('[Access]', ctx.method, ctx.url, duration + 'ms'),
        logError: (error) => console.error('[Error]', error),
        // ...
    };
}

八、框架核心与业务解耦

8.1 为什么要解耦

早期架构可能是这样的:

elpis-core/
  └── index.js  → 直接 require('../app/controller/base')  ← 反向引用业务代码

问题:

  • 核心框架依赖业务代码,无法独立发布
  • 换个项目就要改 core 代码
  • 职责不清,维护困难

8.2 解耦方案:Loader 模式

通过 Loader 自动扫描业务目录,core 不需要知道业务代码的存在:

elpis-core(框架)          app(业务)
     │                          │
     │ 1. 约定目录结构          │
     │ 2. 提供 Loader           │
     │ 3. 自动扫描加载          │
     ├──────────────────────────►
     │                          │ 业务代码放在约定目录
     │                          │
     ◄──────────────────────────┤
     │ 4. 挂载到 app 对象       │
     │                          │

核心原则:core 只提供机制,不关心业务内容。

九、HTTP 状态码速查

重定向状态码

状态码名称含义缓存请求方法典型场景
301Moved Permanently永久重定向✓ 缓存可能改变网站迁移、域名更换
302Found临时重定向✗ 不缓存可能改变临时维护、页面未开发跳转
303See Other查看其他✗ 不缓存强制 GETPOST 后重定向到结果页
307Temporary Redirect临时重定向✗ 不缓存保持不变临时跳转,保持方法
308Permanent Redirect永久重定向✓ 缓存保持不变永久迁移,保持方法

十、总结

10.1 核心知识点回顾

  1. Loader 机制:约定优于配置,自动扫描加载业务代码
  2. 洋葱模型:中间件执行顺序至关重要,外层捕获内层错误
  3. 依赖单向流动:config → extend → schema → service → controller → router
  4. SSR:服务端渲染模板 + 数据 = 完整 HTML
  5. API 安全:MD5 签名 + 时间戳 + nonce 防篡改防重放
  6. 参数校验:Schema 驱动,自动校验,统一错误格式
  7. 框架解耦:core 提供机制,业务遵循约定,核心不反向引用业务

10.2 后续优化方向

  • 引入数据库 ORM(如 Sequelize / Prisma)
  • 实现 Redis 缓存与 Session
  • 加入 JWT 认证中间件
  • 实现单元测试与集成测试
  • 性能监控与告警
  • Docker 容器化部署

关于作者:Elpis 框架学习项目

本文基于实战经验整理,如有错误欢迎指正。