基于 Koa 的服务端框架封装:打造可扩展的服务架构
在构建服务端框架时,我们通常需要解决以下几个核心问题:
- 路由的注册与管理:实现灵活的路由定义与加载。
- 代码分层:支持清晰的
Controller
和Service
分层结构,便于扩展和维护。 - 中间件支持:支持注册多种中间件,包括参数校验、登录校验、日志记录等。
- 配置管理:提供丰富的配置文件管理,支持环境变量和动态加载。
之后将一步步封装一个基于 Koa 的服务端框架,支持以上功能。
一、快速实现一个简单的 Koa 服务
在开始封装之前,我们先快速了解如何使用 Koa 构建一个基础服务:
const Koa = require('koa');
const Router = require('koa-router');
const app = new Koa()
const port = 8080
const host = '0.0.0.0'
app.listen(port, host)
二、框架设计思路
参考 Koa 官网
根据 Koa 的特性,我们设计了以下封装方法:
1. 功能抽象
将常见功能划分为以下几类:
- Controller 和 Service:实现代码分层,解耦业务逻辑和接口处理。
- 中间件:支持系统自带和用户自定义中间件,覆盖参数校验、日志记录等常见场景。
- 配置管理:提供灵活的配置文件管理和环境变量支持。
- 路由管理:集中化的路由定义和动态注册。
- 扩展功能:支持用户添加自定义扩展模块,以满足个性化需求。
2. 模块加载器设计
针对上述功能,设计了以下加载器(Loader),实现模块化加载与初始化:
Loader 名称 | 功能描述 |
---|---|
routerLoader | 加载路由文件并注册到应用 |
middlewareLoader | 加载并注册中间件 |
controllerLoader | 加载 Controller,处理接口逻辑 |
serviceLoader | 加载 Service,处理业务逻辑 |
configLoader | 加载配置文件和环境变量 |
extendLoader | 加载用户自定义扩展模块 |
routerSchemaLoader | 加载路由的校验规则 |
三、实现可扩展的框架
下面是框架核心模块设计与实现的部分代码示例。
路由加载器设计与实现
1. 设计思路
我们约定,开发者将所有路由相关的代码统一存放在 app/router
文件夹下,路由加载器的主要功能包括:
- 定义
KoaRouter
对象,用于管理路由。 - 遍历指定文件夹下的所有自定义路由文件,并动态加载。
- 将所有子路由文件中的路由注册到
KoaRouter
上。 - 配置一个兜底方案,处理未匹配的路由。
- 将路由统一注册到
app
对象中。 - 导出一个加载函数
routerLoader
。
2. 子路由文件样式
开发者需要按照以下格式编写自定义路由文件,所有路由逻辑均通过 router
对象挂载:
// 示例:app/router/test.js
const testController = require('../controllers/testController');
module.exports = (app, router) => {
router.get('/api/test', testController.test.bind(testController));
};
// routerLoader 定义
module.exports = (app) => {
// app.use(**) 导入需要的内容
}
我们通过将 app 对象 传到 Loader 中。通过在 Loader 函数中调用 Loader.use 来向应用中注册路由
3. 具体的代码实现
const KoaRouter = require('koa-router')
const glob = require('glob')
const path = require('path')
const { sep } = path
module.exports = (app) => {
// 1. 定义 KoaRouter 对象
const router = new KoaRouter()
// 2. 遍历制定文件夹下所有符合要求的文件。并加载
const routerPath = path.resolve(`./app/router`)
const fileList = glob.sync(path.resolve(routerPath, `.${sep}**${sep}**.js`))
// 3. 将所有路由文件中的路由挂在在KoaRouter上
fileList.forEach(file => {
require(path.resolve(file))(app, router)
})
// 4. 配置兜底方案
router.get('*', async (ctx, next) => {
ctx.status = 302
ctx.redirect(`${app?.options?.homePage ?? '/'}`)
})
// 5. 将路由注册到 app 中
app.use(router.routes())
app.use(router.allowedMethods())
}
中间件加载器设计与实现
1. 设计思路
Koa 使用“洋葱圈模型”处理中间件(middleware),即中间件按照加载顺序依次执行,先进入的中间件最后退出。为了满足这种严格的执行顺序,我们需要明确中间件的加载规则和顺序。
基于以上,我们为中间件加载器设计了以下功能:
- 遍历指定文件夹下的所有自定义中间件文件,并动态加载。
- 按照一定的规则修改文件名,生成统一的中间件名称。
- 定义一个中间件集合,存储所有加载的中间件。
- 将中间件按照相对路径存储到中间件集合中,方便后续管理。
- 将中间件集合存储在
app
对象中。 - 提供一个注册函数,用于按顺序将中间件注册到 Koa 应用。
以下参考内容来自 pauli.cn/koa-docs-1x…
2. loader 代码实现
module.exports = (app) => {
// 1. 遍历指定文件夹下的所有自定义中间件文件,并动态加载。
const middlewarePath = path.resolve(app.businessPath, `.${sep}middleware`)
const fileList = glob.sync(path.resolve(middlewarePath, `.${sep}**${sep}**.js`))
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('.'))
// 2. 按照一定的规则修改文件名,生成统一的中间件名称。
name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
let tempMiddleware = middlewares // 临时对象,用于最终存储加载的文件
const names = name.split(sep)
// 3. 定义一个中间件集合,存储所有加载的中间件。
// 4. 将中间件按照相对路径存储到中间件集合中,方便后续管理。
for (let pathIndex = 0, len = names.length; pathIndex < len; ++pathIndex) {
if (pathIndex === len - 1) {
// 此时为文件 动态加载这个文件,并把app传给他注册中间件
tempMiddleware[names[pathIndex]] = require(path.resolve(file))(app)
} else {
// 此时为目录 需要提前将父级目录创建
if (!tempMiddleware[names[pathIndex]]) {
tempMiddleware[names[pathIndex]] = {}
}
tempMiddleware = tempMiddleware[names[pathIndex]]
}
}
})
// 5. 将中间件集合存储在 `app` 对象中。
app.middlewares = middlewares
}
3. 中间件注册函数
除自定义的中间件意外,可以把必须的中间件全部放到这里来定义
// 省略依赖的引用
module.exports = (app) => {
// 配置静态根目录
const koaStatic = require('koa-static')
app.use(koaStatic(path.resolve(process.cwd(), './app/public')))
// 模板渲染
app.use(koaNunjucks({
ext: 'tpl',
path: path.resolve('./app/public'),
nunjucksConfig: {
noCache: true,
trimBlocks: true
}
}))
// 自定义中间件1
app.use(app.middlewares.customMiddleware1)
// 自定义中间件2
app.use(app.middlewares.customMiddleware2)
// 自定义中间件3
app.use(app.middlewares.customMiddleware3)
}
补充其他的Loader
1. controllerLoader 与 serviceLoader
在 Koa 应用中,controller
和 service
通常为类,我们需要将它们以类的实例形式存储在 app
对象中。类似于中间件加载器,controllerLoader
和 serviceLoader
也需要遍历指定文件夹获取对应的文件并加载,但其实现方式会有所不同,因为它们涉及到类的实例化。
目标:
- controllerLoader 和 serviceLoader 需要遍历指定文件夹并加载类文件。
- 将加载的类实例化后存储到
app
对象中,方便后续调用。 - 这两个加载器的实现可以基于类似的逻辑,但需要注意的是,
controllerLoader
主要处理与 HTTP 请求相关的控制器,而serviceLoader
则处理业务逻辑相关的服务。
// controller
module.exports = (app) => {
const controllerPath = path.resolve(app.businessPath, `.${sep}controller`)
const fileList = glob.sync(path.resolve(controllerPath, `.${sep}**${sep}**.js`))
const controller = {}
fileList.forEach(file => {
let name = path.resolve(file)
name = name.substring(name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length, name.lastIndexOf('.'))
// 把 '-' 统一为小驼峰, 例如 custom-controller => customController
name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
let tempController = controller
const names = name.split(sep)
// 按照文件相对路径分段
for (let pathIndex = 0, len = names.length; pathIndex < len; ++pathIndex) {
if (pathIndex === len - 1) {
// 此时为文件 动态加载这个文件,并把app传给他注册
const ControllerMoudle = require(path.resolve(file))(app)
tempController[names[pathIndex]] = new ControllerMoudle()
const item = new ControllerMoudle()
} else {
if (!tempController[names[pathIndex]]) {
tempController[names[pathIndex]] = {}
}
tempController = tempController[names[pathIndex]]
}
}
})
// 遍历所有文件目录,把export的中间件加载到 app.controller 下
app.controller = controller
}
// service
module.exports = (app) => {
const servicePath = path.resolve(app.businessPath, `.${sep}service`)
const fileList = glob.sync(path.resolve(servicePath, `.${sep}**${sep}**.js`))
const service = {}
fileList.forEach(file => {
let name = path.resolve(file)
name = name.substring(name.lastIndexOf(`service${sep}`) + `service${sep}`.length, name.lastIndexOf('.'))
// 把 '-' 统一为小驼峰, 例如 custom-service => customService
name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
let tempService = service
const names = name.split(sep)
for (let pathIndex = 0, len = names.length; pathIndex < len; ++pathIndex) {
if (pathIndex === len - 1) {
const ServiceMoudle = require(path.resolve(file))(app)
tempService[names[pathIndex]] = new ServiceMoudle()
} else {
if (!tempService[names[pathIndex]]) {
tempService[names[pathIndex]] = {}
}
tempService = tempService[names[pathIndex]]
}
}
})
app.service = service
}
2. configLoader
在开发过程中,我们通常会根据不同的环境(如本地环境、测试环境和生产环境)进行配置管理。这种配置不仅包括不同环境下的数据库连接、API 地址、端口号等信息,还可能涉及到一些默认配置。为了方便管理和使用,我们需要设计一个配置管理系统,支持环境区分和默认配置。
另外,配置文件一般存放在另外一个文件夹中。
目标:
- 根据不同的环境加载相应的配置文件。
- 提供默认配置,避免在每个环境中都需要重复配置。
- 方便动态扩展支持不同环境。
module.exports = (app) => {
const configPath = path.resolve(app.baseDir, `.${sep}config`)
let defaultConfig = {}
try {
defaultConfig = require(path.resolve(configPath, `.${sep}config.default`))
} catch (error) {
console.error('[exception] this is no config.default file')
}
// 获取 env.config
let envConfig = {}
try {
if (app.env.isLocal()) { // 本地
envConfig = require(path.resolve(configPath, `.${sep}config.local`))
} else if (app.env.isBeta()) { // 测试
envConfig = require(path.resolve(configPath, `.${sep}config.beta`))
} else if (app.env.isProduction()) { // 生产
envConfig = require(path.resolve(configPath, `.${sep}config.prod`))
}
} catch (error) {
console.error('[exception] this is no env.config file')
}
app.config = Object.assign(defaultConfig, envConfig)
}
3. extendLoader
在应用的架构中,除了核心的路由、中间件、控制器和服务等功能,往往还需要一些额外的功能模块,比如日志、缓存、权限校验等。这些功能不属于应用的核心功能,但又是应用正常运行不可或缺的一部分。
为了方便扩展和管理这些附加功能,我们设计了一个扩展模块。这个模块的作用是集中存放所有额外的功能,开发者可以通过向该模块添加新的功能,使应用更加灵活。
module.exports = (app) => {
const extendPath = path.resolve(app.businessPath, `.${sep}extend`)
const fileList = glob.sync(path.resolve(extendPath, `.${sep}**${sep}**.js`))
const extend = {}
fileList.forEach(file => {
let name = path.resolve(file)
name = name.substring(name.lastIndexOf(`extend${sep}`) + `extend${sep}`.length, name.lastIndexOf('.'))
name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
for (const key in app) {
if (key === name) {
console.log(`[extend load error] name:app.${name} is already exist`)
return
}
}
app[name] = require(path.resolve(file))(app)
})
app.extend = extend
}
三、测试
这样,我们设计并实现了一个基于 Koa 的基础后端框架:
之后我们简单做一下测试
首先完善一下我们的工程目录
app/ // 应用相关核心代码
├── controller/ // 控制器层,处理业务逻辑,响应路由请求
├── extend/ // 扩展模块(如日志、缓存等)
├── middleware/ // 中间件功能,支持自定义中间件
├── router/ // 路由文件夹,用于定义 API 路由规则
├── router-schema/ // 路由校验规则文件夹,定义接口的参数验证规则
├── service/ // 服务层,用于封装核心业务逻辑
middleware.js // 中间件加载器
config/ // 配置文件夹
├── config.beta.js // Beta 环境配置
├── config.default.js // 默认配置
├── config.local.js // 本地开发配置
├── config.prod.js // 生产环境配置
elpis-core/ // 核心库代码(可选,外部或内部工具方法封装)
// controler/test.js
module.exports = (app) => {
return class TestController {
constructor() {
this.app = app
this.config = app.config
this.service = app.service
}
success(ctx, data = {}, metadata = {}) {
ctx.status = 200
ctx.body = {
success: false,
data,
metadata
}
}
async getList(ctx) {
const { project: projectService } = app.service
const projectList = await projectService.getList()
this.success(ctx, projectList)
}
}
}
// service/test.js
module.exports = (app) => {
return class TestService {
constructor() {
this.app = app
this.config = app.config
this.curl = superagent
}
async getList() {
return [{
id: 1,
name: 'project1',
desc: 'project1 desc'
}, {
id: 2,
name: 'project2',
desc: 'project2 desc'
}]
}
}
}
// router/test.js
module.exports = (app, router) => {
const { project: testController } = app.controller
router.get('/api/test/list',testController.getList.bind(testController))
}
// middleware/error.js
// 写了一个错误处理类。可以 catch 常见错误并作统一处理
module.exports = (app) => {
return async (ctx, next) => {
try {
await next();
} catch (err) {
const { status, message, detail } = err
app.logger.error('[-- exception --]:', err)
app.logger.error('[-- exception --]:', status, message, detail)
if (message && message.indexOf('template not found') > -1) {
// 找不到模板
// OperationalError: template not found: output/entry.page3.tpl
// 页面重定向
ctx.status = 302
ctx.redirect(`${app.options?.homePage}`)
return
}
const resBody = {
code: 50000,
message: '网络异常 请稍后再试',
success: false
}
ctx.status = 200
ctx.body = resBody
}
}
}
// extend/logger.js
const log4js = require('log4js')
module.exports = (app) => {
let logger
if (app.env.isLocal()) {
logger = console
} else {
log4js.configure({
appenders: {
console: { type: 'console' },
dataFile: {
type: 'dateFile',
filename: './logs/application.log',
pattern: '.yyyy-MM-dd',
daysToKeep: 30,
keepFileExt: true
}
},
categories: {
default: {
appenders: ['console', 'dataFile'],
level: 'trace'
}
}
})
logger = log4js.getLogger()
}
return logger
}
大佬们,烟已点好,求多多指教!
如果在使用elpis-core
的过程中发现不足,欢迎各位大佬提出优化建议。我会虚心接受并快速迭代。希望大家能一起完善这个框架,让它更好地服务于开发者!
感谢哲哥的详细指导 live.douyin.com/60047125967…
注:抖音 “哲玄前端”,《大前端全栈实践课》