前言
作为一名前端练习生,回顾以往工作日常,基本都是CRUD的需求,到现在最多也就是个熟练工,比如像服务端、项目部署、架构设计等很多方面都是短板,不得不承认,现在的市场不仅要求前端只会前端框架,相反,对非前端的知识体系都要涉及到。也是为了更好的拓展知识广度,所以我们的主角诞生喽,一个基于Vue3 + Node.js服务端渲染的框架-Elpis。也是机缘巧合,某平台刷到了"哲玄前端",买下了《大前端全栈实践系列》,所以,谨以此记录一下本人的学习过程。
elpis整体架构设计

elpis-core整体设计
elpis-core
是基于koa.js
实现的类似于egg.js
的轻量化服务端的框架。通过koa.js
实例,将各种loader
文件进行解析并挂载到Koa实例中,本篇着重来说BFF层。
BFF
BFF(Backend For Frontend)从字面意思来看就是为前端而生的后端(这里说的有点过分哈哈),它是一种架构设计,主要用于解决前后端数据协作及微服务框架的数据聚合等问题,结构大体如下图所示:

洋葱模型
Koa中间件机制基于一个称为"洋葱模型"的设计模式。这种模式形象地将中间件的执行过程比喻为洋葱的层次。每个中间件都是洋葱的一层,每一层都有对应的处理逻辑,把controller需要处理的非业务逻辑细化到各个loader中去单独处理,controller只关心对应的业务逻辑,减少了杂项干扰。elpis-core内核引擎也是基于这个思维模式去建立起来的,整体流程如下图:

目录结构
js
Elpis
| app/
| | controller/ //控制器
| | extend/ // 扩展
| | middleware/ // 中间件
| | public/
| | | output/ // 入口文件
| | | static/ // 静态资源
| | router/ // 路由
| | router-schema/ // 路由schema配置
| | service/ // 服务层
| config/ // 环境配置
| elpis-core/ // 内核引擎
| | laoder/ // 存放各个模块的loader
| | env.js // 环境配置
| | index.js // 启动入口
| logs/ // 日志文件
| index.js // 框架页面配置
| .eslintignore
| .eslintrc
| .gitignore
| package-lock.json
| package.json
具体实现如下:
js
const Koa = require("koa");
const path = require('path')
const { sep } = path
const env = require('./env')
const configLoader = require('./loader/config')
const serviceLoader = require('./loader/service')
const middlewareLoader = require('./loader/middleware')
const routerSchemaLoader = require('./loader/router-schema')
const controllerLoader = require('./loader/controller')
const extendLoader = require('./loader/extend')
const routerLoader = require('./loader/router')
module.exports = {
start(options = {}) {
const app = new Koa();
app.options = options // 应用配置
app.baseDir = process.cwd() // 基础路径
app.businessPath = path.resolve(app.baseDir, `.${sep}app`) // 业务文件路径
app.env = env()
configLoader(app)
serviceLoader(app)
middlewareLoader(app)
routerSchemaLoader(app)
controllerLoader(app)
extendLoader(app)
try {
require(`${app.businessPath}${sep}middleware.js`)(app)
} catch (err) {
console.error(`[error]:register global middleware failed`)
}
routerLoader(app)
try {
const port = process.env.PORT ?? 8080;
const host = process.env.IP ?? "0.0.0.0";
app.listen(port, host);
} catch (err) {
console.error("Failed to start server:", err);
}
},
};
通过上述代码逻辑,会把各个loader通过从对应文件夹下读取对应文件内容并加载进来,然后统一挂载到Koa的实例对象上去,然后通过app去获取。
configLoader
configLoader
主要是配置不同的环境变量,将本地、测试、生产多个环境下的配置挂载到Koa的实例上。
js
/**
* config loader
* @param {object} app koa实例
* 配置区分: 本地、测试、生产不同环境下的配置文件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
*/
const path = require('path')
const { sep } = path // 兼容不同操作系统的路径分隔符
module.exports = (app) => {
// 找到config目录
const configPath = path.resolve(app.baseDir, `.${sep}config`)
// 获取默认配置目录
let defaultConfig = {};
try {
defaultConfig = require(path.resolve(configPath, `.${sep}config.default.js`))
} catch (error) {
console.log('[error]:there is no config.default file')
}
// 获取env.config
let envConfig = {}
try {
if (app.env.isLocal()) {
envConfig = require(path.resolve(configPath, `.${sep}config.local.js`))
} else if (app.env.isBeta()) {
envConfig = require(path.resolve(configPath, `.${sep}config.beta.js`))
} else if (app.env.isProd()) {
envConfig = require(path.resolve(configPath, `.${sep}config.prod.js`))
}
} catch (error) {
console.log('[error]:there is no envConfig', error)
}
// 覆盖并加载config配置
app.config = Object.assign({}, defaultConfig, envConfig)
}
serviceLoader
serviceLoader会通过app/service下以及其子目录下的所有文件内容,并将service挂载到Koa上,通过app.service.${目录}.${文件}
访问。
js
/**
* controller loader
* @param {object} app koa实例
* 加载所有service,可通过app.service.${目录}.${文件}访问
* eg:
* app/service
* ├── custom-module
* ├── custom-service.js
*/
const glob = require('glob')
const path = require('path')
const { sep } = path // 兼容不同操作系统的路径分隔符
module.exports = (app) => {
// 读取app/service/**/**.js的所有js文件
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)
// 截取文件路径 eg:app/service/custom-module/custom-service.js ==> custom-module/custom-service
name = name.substring(name.lastIndexOf(`service${sep}`) + `service${sep}`.length, name.lastIndexOf('.'))
// 替换文件路径中的-和_为驼峰 eg:custom-module/custom-service.js ==> customModule/customService
name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
// 挂载service到app对象中
let tempService = service
const names = name.split(sep)
for (let i = 0, len = names.length; i < len; ++i) {
if (i === len - 1) {
const ServiceModule = require(path.resolve(file))(app)
tempService[names[i]] = new ServiceModule()
} else {
if (!tempService[names[i]]) {
tempService[names[i]] = {}
}
tempService = tempService[names[i]]
}
}
})
app.service = service
}
middlewareLoader
middlewareloader会读取app/middleware下子目录以及下面所有中间件文件内容,并挂载到Koa上。中间件做了一系列的处理,这里设计了三种常见的场景:异常处理
、api合法性校验
、api参数校验
。

js
/**
* middleware loader
* @param {object} app koa实例
* 加载所有middleware,可通过app.middleware.${目录}.${文件}访问
* eg:
* app/middleware
* ├── custom-module
* ├── custom-middleware.js
*/
const glob = require('glob')
const path = require('path')
const { sep } = path // 兼容不同操作系统的路径分隔符
module.exports = (app) => {
// 读取app/middleware/**/**.js的所有js文件
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)
// 截取文件路径 eg:app/middleware/custom-module/custom-middleware.js ==> custom-module/custom-middleware
name = name.substring(name.lastIndexOf(`middleware${sep}`) + `middleware${sep}`.length, name.lastIndexOf('.'))
// 替换文件路径中的-和_为驼峰 eg:custom-module/custom-middleware.js ==> customModule/customMiddleware
name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
// 挂载middleware到app对象中
let tempMiddleware = middlewares
const names = name.split(sep)
for (let i = 0, len = names.length; i < len; ++i) {
if (i === len - 1) {
tempMiddleware[names[i]] = require(path.resolve(file))(app)
} else {
if (!tempMiddleware[names[i]]) {
tempMiddleware[names[i]] = {}
}
tempMiddleware = tempMiddleware[names[i]]
}
}
})
app.middlewares = middlewares
}
routerSchemaLoader
routerSchemeLoader会读取app/router-schema下的文件内容,app/router-schema下的文件描述信息遵循json-schema的配置规范,并且使用了ajv.js
对api请求信息进行了校验。
js
/**
* router-schema loader
* @param {object} app koa实例
* 通过'json-schema & ajv'对API 规则进行约束,配合api-params-verify 中间件
* app/router-schema/**.js
*
* 输出:
* app.routerSchema = {
* "${api}":${jsonSchema},
* ...
* }
* */
const glob = require('glob')
const path = require('path')
const { sep } = path
module.exports = (app) => {
// 读取app/router-schema/**.js的所有js文件
const routerSchemaPath = path.resolve(app.businessPath, `.${sep}router-schema`)
const fileList = glob.sync(path.resolve(routerSchemaPath, `.${sep}**${sep}**.js`))
// 注册所有routerSchema, 可以通过'app.routerSchema'访问
let routerSchema = {}
fileList.forEach(file => {
routerSchema = {
...routerSchema,
...require(path.resolve(file))
}
})
app.routerSchema = routerSchema
}
schema配置举例demo:
js
module.exports = {
'/api/project/list': {
'get': {
query: {
type: 'object',
properties: {
proj_key: {
type: 'string',
},
},
required: ['proj_key']
},
}
},
}
如上配置所示,当请求这个接口时,如果query参数缺少proj_key
,这个请求就会返回报错信息。
controllerLoader
controllerLoader会读取app/controller下以及其子目录下的所有文件内容,并将controller挂载到Koa上,通过app.controller.${目录}.${文件}
访问。
js
/**
* controller loader
* @param {object} app koa实例
* 加载所有controller,可通过app.controller.${目录}.${文件}访问
* eg:
* app/controller
* ├── custom-module
* ├── custom-controller.js
*/
const glob = require('glob')
const path = require('path')
const { sep } = path // 兼容不同操作系统的路径分隔符
module.exports = (app) => {
// 读取app/controller/**/**.js的所有js文件
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)
// 截取文件路径 eg:app/controller/custom-module/custom-controller.js ==> custom-module/custom-controller
name = name.substring(name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length, name.lastIndexOf('.'))
// 替换文件路径中的-和_为驼峰 eg:custom-module/custom-controller.js ==> customModule/customController
name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
// 挂载controller到app对象中
let tempController = controller
const names = name.split(sep)
for (let i = 0, len = names.length; i < len; ++i) {
if (i === len - 1) {
const ControllerModule = require(path.resolve(file))(app)
tempController[names[i]] = new ControllerModule()
} else {
if (!tempController[names[i]]) {
tempController[names[i]] = {}
}
tempController = tempController[names[i]]
}
}
})
app.controller = controller
}
extendLoader
extendLoader读取app/extend下的所有文件内容,并挂载到Koa上。主要用于拓展应用的额外能力,例如日志,当线上环境遇见报错,想要排查具体报错内容,就需要查看对应的日志内容,结合日志内容来定位报错原因并解决问题。
js
/**
* extend loader
* @param {object} app koa实例
* 加载所有extend,可通过app.extend.${文件}访问
* eg:
* app/extend
* ├── custom-extend.js
*/
const glob = require('glob')
const path = require('path')
const { sep } = path // 兼容不同操作系统的路径分隔符
module.exports = (app) => {
// 读取app/extend/**.js的所有js文件
const extendPath = path.resolve(app.businessPath, `.${sep}extend`)
const fileList = glob.sync(path.resolve(extendPath, `.${sep}**${sep}**.js`))
// 遍历文件目录
fileList.forEach(file => {
// 获取文件名称
let name = path.resolve(file)
// 截取文件路径 eg:app/extend/custom-extend.js ==> custom-extend
name = name.substring(name.lastIndexOf(`extend${sep}`) + `extend${sep}`.length, name.lastIndexOf('.'))
// 替换文件路径中的-和_为驼峰 eg:custom-module/custom-extend.js ==> customModule.customExtend
name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())
// 过滤app存在的key
for (const key in app) {
if (key === name) {
console.log(`extend key is existed, ${name}`);
return
}
}
app[name] = require(path.resolve(file))(app)
})
}
全局中间件
注册全局中间件就是为了渲染模板引擎,并且引入之前提到在app/middleware文件夹下设定的三种场景:
js
const path = require('path')
module.exports = (app) => {
// 配置静态根目录
const koaStatic = require('koa-static')
app.use(koaStatic(path.resolve(process.cwd(), './app/public')))
// 模版渲染引擎
const koaNunjucks = require('koa-nunjucks-2')
app.use(koaNunjucks({
ext: 'tpl',
path: path.resolve(process.cwd(), './app/public'),
nunjucksConfig: {
noCache: true,
trimBlocks: true,
}
}))
// 引入解析请求体的中间件
const koaBodyParser = require('koa-bodyparser')
app.use(koaBodyParser({
formLimit: '10mb',
enableTypes: ['json', 'form', 'text'],
}))
// 引入错误处理中间件
app.use(app.middlewares.errorHandler)
// 引入api签名校验中间件
app.use(app.middlewares.apiSignVerify)
// 引入api参数校验中间件
app.use(app.middlewares.apiParamsVerify)
}
routerLoader
routerLoader读取app/router下的所有文件内容,并挂载到Koa上。
js
/**
* router loader
* @param {object} app koa实例
* 解析所有app/router目录下的js文件,加载到KoaRouter下
*/
const glob = require('glob')
const path = require('path')
const { sep } = path
const KoaRouter = require('koa-router')
module.exports = (app) => {
// 找到路由文件路径
const routerPath = path.resolve(app.businessPath, `.${sep}router`)
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())
}
总结
至此,elpis-core内核引擎这一基础模块可以画上句号了,通过上述实践,我对Node.js
有一个初步的认知,也是迈出了服务端渲染的第一步,继续加油~~