本文内容学习自 哲玄前端 《大前端全栈实践》课程
什么是 elpis-core
elpis-core 是一个基于 Koa 的轻量级框架核心。它是整个项目的基础架构和启动引擎。
- 应用启动与初始化
- 提供了 start 方法作为应用的入口点
- 创建 Koa 实例并配置基本环境
- 设置应用的基础路径和业务文件路径
- 启动 HTTP 服务器并监听指定端口
- 模块化加载系统
- 通过一系列 loader 加载不同类型的模块
- configLoader : 加载应用配置
- serviceLoader : 加载服务层组件
- controllerLoader : 加载控制器
- routerSchemaLoader : 加载路由校验
- middlewareLoader : 加载中间件
- extendLoader : 加载扩展功能
- routerLoader : 加载路由定义
- 框架与业务代码分离
- 实际业务逻辑位于 app 目录
它采用约定优于配置的方式,通过对目录的约定来规范项目结构,减少协作和沟通成本。
- config/ 中存放不同环境下的配置文件
- app/service/ 中存放所有服务层文件
- app/controller/ 中存放所有控制器
- app/router-schema/ 中存放所有路由接口的校验
- app/middleware/ 中存放自定义中间件
- app/extend/ 中存放所有扩展功能文件
- app/router/ 中存放所有路由接口
开发者可以专注于在 app 目录中编写业务逻辑,而不必关心底层架构的实现细节。
elpis-core 的实现
const Koa = require('koa')
const path = require('path')
const env = require('./env')
const configLoader = require('./loader/config')
const serviceLoader = require('./loader/service')
const controllerLoader = require('./loader/controller')
const routerSchemaLoader = require('./loader/router-schema')
const middlewareLoader = require('./loader/middleware')
const extendLoader = require('./loader/extend')
const routerLoader = require('./loader/router')
module.exports = {
/**
* start elpis core
* @param {object} options - options
options = {
name 项目名称,
}
*/
start: (options = {}) => {
const app = new Koa()
// 应用配置
app.options = options
console.log('projectName:', app.options.name)
// 初始化环境配置
app.$env = env(app)
console.log(`-- [start] $env: ${app.$env.get()} --`)
// 基础路径, 项目根目录
app.baseDir = process.cwd()
// 业务文件路径
app.businessPath = path.resolve(app.baseDir, 'app')
// 加载 Loader
configLoader(app)
console.log(`-- [start] load config done --`)
serviceLoader(app)
console.log(`-- [start] load service done --`)
controllerLoader(app)
console.log(`-- [start] load controller done --`)
routerSchemaLoader(app)
console.log(`-- [start] load router schema done --`)
// 加载自定义的中间件
middlewareLoader(app)
console.log(`-- [start] load middleware done --`)
extendLoader(app)
console.log(`-- [start] load extend done --`)
// 直接使用生态中的中间件
try {
// app/middleware.js
require(path.resolve(app.businessPath, 'middleware.js'))(app)
console.log(`-- [start] load global middleware done --`)
} catch (e) {
console.log(`[exception] An error occurred while parsing the file 'middleware.js' in ${app.businessPath}`)
}
routerLoader(app)
console.log(`-- [start] load router done --`)
try {
const host = process.env.HOST || '0.0.0.0'
const port = process.env.PORT || 8080
app.listen(port, host)
console.log(`server is running at http://${host}:${port}`)
} catch (error) {
console.log(error)
}
return app
},
}
- 此处采用 app.$env的原因:
env是Koa的实例属性, 类型为string, koa.env会读取process.env.NODE_ENV的值
lodaer 的处理
需要注意 loader的加载顺序。
- controller 依赖于 service 和 config
- router 依赖于 router-schema, controller 和 middleware
- router-schema需要在middleware前加载(API 检验需要)
还有以下注意事项
- middleware 中执行返回的是异步函数,而 service 和 controller 中执行返回的是class类
- koa实例中具有middleware属性。它是一个数组,专门用来存储通过
app.use()方法注册的所有中间件函数- 自己手动挂载 middleware 需要变成 app.middlewares
- middlewareLoader只是加载了自定义的中间件,为了使用生态中的中间件,还需要加载 app/middleware.js文件
configLoader
配置区分 本地/测试/生产, 通过 env 环境读取不同文件配置 env.config
通过 env.config 覆盖 default.config 加载到 app.config 中
目录下对应的 config 配置
- 默认配置:config/config.default.js
- 本地配置:config/config.local.js
- 测试配置:config/config.beta.js
- 生产配置:config/config.prod.js
app.config = Object.assign({}, defaultConfig, envConfig)
serviceLoader
加载所有 service, 可以通过 'app.service.${目录}.${文件}' 访问
app/service
|
| -- custom-moduleA
|
| -- custom-serviceA.js
=> app.service.customModuleA.customServiceA
controllerLoader
加载所有 controller, 可以通过 'app.controller.${目录}.${文件}' 访问
app/controller
|
| -- custom-moduleA
|
| -- custom-controllerA.js
=> app.controller.customModuleA.customControllerA
routerSchemaLoader
通过 'json-schema & ajv ' 对 API 规则进行约束,配合自定义的 api-paramas-verify 中间件
app/router-schema/**.js
输出:
app.routerSchema = {
'${api1}': ${jsonSchema},
'${api2}': ${jsonSchema},
'${api3}': ${jsonSchema},
}
middlewareLoader
加载所有 middleware, 可以通过 'app.middlewares.${目录}.${文件}' 访问
app/middleware
|
| -- custom-moduleA
|
| -- custom-middlewareA.js
=> app.middlewares.customModuleA.customMiddlewareA
extendLoader
加载所有 extend, 可以通过 'app.${文件}' 访问
app/extend
|
| -- custom-extend.js
=> app.customExtend
routerLoader
解析所有 app/router/ 下所有 js 文件,加载到 KoaRouter
至此,elpis-core的基础开发已经完成
elpis-core 的基础应用
通过添加中间件和扩展,丰富框架功能
模板页面渲染能力
Nunjucks 引入中间件 koa-nunjucks-2, ctx 会增加一个 render 方法
// app/middleware.js
const path = require('path')
const koaNunjucks = require('koa-nunjucks-2')
module.exports = app => {
const publicPath = path.resolve(app.businessPath, 'public')
// 模板渲染引擎
app.use(
koaNunjucks({
ext: 'tpl',
path: publicPath,
nunjucksConfig: {
noCache: true,
trimBlocks: true,
},
})
)
}
// app/router/view.js
module.exports = (app, router) => {
const { view: ViewController } = app.controller
// 用户输入 http://ip:port/view/page1 能渲染出对应的 enter.page1 页面
router.get('/view/:page', ViewController.renderPage.bind(ViewController))
}
// app/controller/view.js
module.exports = app => {
return class ViewController {
/**
* 渲染页面
* @param {object} ctx 上下文
*/
async renderPage(ctx) {
await ctx.render(`output/entry.${ctx.params.page}`, {
name: app.options?.name,
env: app.$env.get(),
options: JSON.stringify(app.options),
})
}
}
}
用 tpl 而不用 html
- 有动态数据,而且动态数据是由服务端注入进去的
- html 往往是静态数据。直接返回 html 无法利用框架的数据绑定能力
post body 解析中间件
- ctx.request 请求对象: Koa封装的请求对象
- ctx.req 请求对象: Node封装的请求对象
- ctx.response 响应对象: Koa封装的响应对象
- ctx.res 响应对象: Node封装的响应对象
为了解析 ctx.request.body 引入中间件
// app/middleware.js
const bodyParser = require('koa-bodyparser')
module.exports = app => {
// ctx.body 解析中间件
app.use(
bodyParser({
enableTypes: ['json', 'form', 'text'],
formLimit: '1000mb',
})
)
}
添加日志能力
添加扩展,使得调用 app.logger.info / app.logger.error 进行输出并添加日志
// app/extend/logger.js
const log4js = require('log4js')
/**
* 日志工具
* @param {object} app Koa 实例
* 调用 app.logger.info / app.logger.error
*/
module.exports = app => {
let logger
if (app.$env.isLocal()) {
// 打印到控制台
logger = console
} else {
// 日志输出并落地到磁盘文件
log4js.configure({
appenders: {
console: { type: 'console' },
// 日志文件切分
dataFile: { type: 'dateFile', filename: `./logs/application_${app.$env.get()}.log`, pattern: '.yyyy-MM-dd' },
},
categories: {
default: {
appenders: ['console', 'dataFile'],
level: 'trace',
},
},
})
logger = log4js.getLogger()
}
return logger
}
增强系统健壮性
// app/middleware.js
module.exports = app => {
// 异常捕获中间件
app.use(app.middlewares.errorHandler)
// API 签名校验中间件
app.use(app.middlewares.apiSignVerify)
// API 参数校验中间件
app.use(app.middlewares.apiParamsVerify)
}
错误处理
运行时异常错误处理,兜底所有异常
// app/middleware/error-handler.js
/**
* @param {object} app Koa 实例
*/
module.exports = app => {
return async (ctx, next) => {
try {
await next()
} catch (err) {
// 异常处理
const { status, message, detail } = err
app.logger.info(JSON.stringify(err))
app.logger.error('[-- exception --]:', err)
app.logger.error('[-- exception --]:', status, message, detail)
if (message && message.indexOf('template not found') > -1) {
ctx.status = 302 // 临时重定向
ctx.redirect(`${app.options?.homePage}`)
return
}
ctx.status = 200
ctx.body = {
success: false,
code: 50000,
message: '网络异常 请稍后重试',
}
}
}
}
API校验
API 签名合法性校验
// app/middleware/api-sign-verify.js
const md5 = require('md5')
/**
* @param {object} app Koa 实例
*/
module.exports = app => {
return async (ctx, next) => {
// 只校验 '/api' 开头的请求路径
if (!ctx.request.path.startsWith('/api')) {
return await next()
}
// 签名校验
const { path, method, headers } = ctx.request
const { s_sign, s_t } = headers
const signKey = 'CJGXcdChs2EYFoU78LFpU5ElBbL0n7AcJ8rbgEBB'
const signature = md5(`${signKey}_${s_t}`)
app.logger.info(`[${method} ${path}] signature: ${signature}`)
if (!s_sign || !s_t || s_sign != signature || Date.now() - s_t > 1000 * 60 * 5) {
ctx.status = 200
ctx.body = {
success: false,
code: 445,
message: 'Signature not correct or api timeout!!!',
}
return
}
await next()
}
}
API 参数合法性校验
router-schema & ajv
controller 中不用再关心某个值是否存在的逻辑代码,更专注于业务代码
ajv.compile 预编译所有的 router-schema,提升性能
- 将 Schema 编译成验证函数并缓存起来,避免在每次请求中重复编译
- routerSchemaLoader需要在middlewareLoader前加载
// app/middleware/api-params-verify.js
const Ajv = require('ajv')
const ajv = new Ajv()
/**
* @param {object} app Koa 实例
*/
module.exports = app => {
// 预编译所有 router-schema
const compiledValidators = {}
for (const path in app.routerSchema) {
for (const method in app.routerSchema[path]) {
compiledValidators[`${path}_${method.toLowerCase()}`] = {}
for (const verifyKey in app.routerSchema[path][method]) {
compiledValidators[`${path}_${method.toLowerCase()}`][verifyKey] = ajv.compile(
app.routerSchema[path][method][verifyKey]
)
}
}
}
return async (ctx, next) => {
// 只校验 '/api' 开头的请求路径
if (!ctx.request.path.startsWith('/api')) {
return await next()
}
// 获取需要校验的参数
const { path, method, headers, body, query } = ctx.request
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)}`)
const validators = compiledValidators[`${path}_${method.toLowerCase()}`]
if (!validators) {
return await next()
}
const verifyObj = { headers, body, query }
for (const verifyKey in verifyObj) {
const validator = validators[verifyKey]
if (!validator) continue
const valid = validator(verifyObj[verifyKey])
if (!valid) {
ctx.status = 200
ctx.body = {
success: false,
code: 442,
message: `request validate error: ${ajv.errorsText(validator.errors)}`,
}
return
}
}
await next()
}
}
params 有没有校验的必要性?
- params 是 url 的组成部分, 所以它只能是 string 类型
- 如果 url 格式不正确, 无法匹配到对应路由处理程序, 会走进兜底路由, 不需要手动校验
- 由于 params 类型单一, 并且 koa-router 进行了 url 匹配, 所以对于 params 的前置校验并没有必要性, 对于不匹配的路由使用兜底路由或者 404 处理即可。