背景
在我们日常开发的中后台系统中,总避免不了大量重复性工作:相似的业务模型、雷同的CRUD接口、千篇一律的表单和表格。不仅繁琐,消耗大量时间和精力,而且也很难让人有成就感。为了解决这一痛点,elipis框架应运而生,旨在让开发者从重复性的体力活工作中解放出来,更多参与系统框架的开发迭代。
elpis
elpis基于领域模型+面向对象解决方案,沉淀了80%重复性工作,同时支持20%定制化开发。
领域模型
领域模型是针对特定业务领域,提炼高频复用的核心逻辑、流程与规则(例如,电商系统都会用商品管理、订单管理)进而形成复用的业务能力模板,在固化了重复性开发,后续有相同业务的系统可直接复用此模板开发,提高效率。
面向对象
对于20%定制化需求,面向对象设计模式可以很好地解决。通过继承原有的基类模型,根据客户的定制化需动态扩展;以及可通过多态特性重写,灵活是适配个性化需求。
架构设计
上图为框架的大致设计,不同项目可以继承封装好的领域模型(基类),同时新增或者重载基类配置,再由前端配置的解析器解析,进而生成最终的系统。
项目配置描述了系统的具体结构,而模板页用于响应项目配置,模板页沉淀了大部分可复用模块,也提供可扩展能力(图中绿色部分),例如组件库,自定义页面,第三方页面等等。
BFF
BFF服务主要分为3层:接入层、业务层和服务层。接入层处理路由转发、规则校验、以及中间件校验等,业务层处理核心业务逻辑、配置读取、环境分发等,而服务层主要处理业务层请求,与数据库进行交互,对外提供了原子化操作能力。
一、elpis-core服务端内核引擎
elpis-core类似egg,也是“约定优于配置”,所以需要制定一套目录规范。app作为根目录,不同功能的文件放置对应的目录下,例如,middleware目录存放中间件相关的文件,router-schema存放路由规则文件。
elpis-core启动时会通过编写好的各种loader解析器读取对应的各个模块文件,解析并挂载于app实例上,最终在运行时能方便获取各个模块提供的能力。例如,app/middleware/error-handle.js,error-handle.js提供错误捕获,运行时可通过app.middlewares.errorHandle直接获取并注册使用。
loader解析器与模块一一对应,例如,router-loader用于加载解析app/router模块。loader核心实现逻辑:获取模块下所有文件路径,通过require获取文件导出内容,最终将内容挂载实例上。
elpis-core运行时如上图所示,即koa洋葱圈模型,先进后出的流程。当一个请求到达时,在最外层先经历中间件进行参数校验、路由规则校验等,后进行路由转发对应controller层处理,若是数据库处理,controller调用service层提供能力,并拿到结果处理后逐层返回,最终响应给客户端。
二、webpack工程化建设
工程化
工程化核心是通过规范化、自动化、模块化和标准化解决前端开发到上线全流程中的痛点,如效率低下,质量不可控、协作混乱等问题。
在浏览器中,可识别资源为js,html,css等,而我们在开发中为了提高效率,一般使用vue,react等框架,这些都无法直接在浏览器中运行,因此需要一款工具将代码转成浏览器可识别的代码,而webpack就充当这样一个角色,自动完成代码转译、资源压缩、依赖打包等,解决手动处理资源低效问题。
热更新
在开发环境下,一般使用热更新模式,允许不刷新整个页面情况下更新修改后的模块,极大提升开发效率。热更新核心是通过服务器进行监听变化并发送通知以获取最新文件。具体步骤如下:
- 监听文件变化
- 文件变化后通知解析引擎打包,并放入内存中
- 服务器获取最新文件,并通知客户端(注入了与服务端通信的代码)
- 客户端下载并替换最新文件
大致实现
webpack配置
module.exports = {
...
entry: [
// 1. 引入热更新客户端
'webpack-hot-middleware/client?reload=true',
// 2. 业务入口
'./src/index.js'
],
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/'
},
...
plugins: [
new webpack.HotModuleReplacementPlugin(), // HMR 插件
...
]
};
创建dev Server服务器
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const config = require('./webpack.config');
const app = express();
const compiler = webpack(config);
// 配置 webpack-dev-middleware
app.use(webpackDevMiddleware(compiler, {
// 与webpack配置中的output.path一致,拦截请求,从服务器内存中获取打包的资源
publicPath: config.output.publicPath,
// 指定落地到磁盘的文件
writeToDisk: filePath => filePath.endswith('.html')
}));
// 配置 webpack-hot-middleware 热更新通讯
app.use(webpackHotMiddleware(compiler, {
log: false, // 禁用日志输出
}));
// 提供静态文件访问
app.use(express.static('./dist'));
// 启动服务器
const PORT = 3000;
app.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`);
});
三、领域模型DSL架构建设
DSL设计
领域模型是针对特定领域抽象出来的,具有通用业务能力的标准化模板,而DSL 是一种专门面向领域的描述语言,它能将领域模型转化为结构化的定义,从而指导系统实现。在本框架中,使用 JSON-Schema 定义规范,以便更直观地描述模块、页面和交互配置。
框架以中后台系统作为背景设计,大致设计如下:
{
mode: 'dashboard', // 模板类型,不同的模板类型会有不同的配置项
name: "", // 名称
desc: "", // 描述
icon: "", // 图标
homePage: "" ,//首页
menu: [{
key: "", // 菜单唯一标识
name: "", // 菜单名称
menuType: "", // 菜单类型,枚举值 group/module
// menuType == group
subMenu:[{
// 可递归menuItem
}, ...],
// menuType == module
moduleType: "", // schema/iframe/sider/custom
// moduleType == iframe
iframeConfig: {
path: ""
},
// moduleType == sider
siderConfig: {
// 侧边栏菜单
menu: [{
// 可递归menuItem
},...]
},
// moduleType == custom
customConfig: {
path: ''
},
// moduleType == schema
schemaConfig: {...},
}]
}
模板描绘的页面结构如下:
其中menu数组为头部菜单列表,菜单列表有group和module两种,group为下拉类型菜单,当为module时,可通过moduleType指定为iframeConfig(对应第三方页面iframe-view)、siderConfig(对应侧边栏)、customConfig(对应自定义页面custom-view),以及schemaConfig(对应shema-view)。
-
menuType = group
- 当菜单为group时,需配置subMenu下拉菜单列表,每一项也是跟menu一致,是可递归的menuItem
-
menuType = module
-
moduleType = iframe
当moduleType为iframe,配置iframeConfig.path,指定第三方链接
-
moduleType = custom
当moduleType为custom,配置customConfig.path,指定自定义页面路径
-
moduleType = sider
当moduleType为sider,配置siderConfig,menu指定侧边栏菜单列表,menuItem也是同上面,但排除moduleType为sider情况
-
moduleType = schema
当moduleType为schema,配置schemaConfig,用于指定schema-view的结构,schemaConfig包含以下几部分
-
schemaConfig: {
api: '', // 数据源api,遵循Restful规范
schema: {//模块数据结构,描述数据源字段如何展示
type: 'object',
properties: {
key: {
...schema, // 标准schema配置
type: '', // 字段类型
label:" ", //字段名
tableOption: {}, // 字段在table中的相关配置
searchOption: {}, // 字段在搜索栏的配置
}
},
required: [], // 必选字段
} ,
tableConfig: {} ,// 表格配置
searchConfig: {}, // 搜索栏配置
compnentConfig: {} // 模块涉及组件配置
}
schemaConfig通过解析器解析成上图所展示的结构。在一个模块中,往往模块内数据都是来源于同一份数据并且有所关联,比如,表格展示姓名、年龄、性别,而搜索栏有性别筛选条件,因此可以在schemaConfig中用一份配置描述出该模块,这份数据数据不仅可以用于生成页面,同时也可以生成数据库表。而对于以往这种页面的开发,大多都是以分构建组件并加一份组件数据的配置,这种形式并没有沉淀重复性的工作,对于后续类似的页面,需复制一份并再此基础上进行修改。
因此schemaConfig是用于描述数据源在模块中如何展示,需要什么就配置什么,再通过解析器解析配置生成页面。对于不满足的需求,更新schemaConfig描述并编写对应的解析器。
继承
elpis框架为满足20%定制化开发,以面向对象设计模式,通过继承和重写已沉淀的模板,提供定制化需求能力。
如上图,在电商领域包含包含商品管理、订单管理、客户管理等,将其沉淀为一个可复用模板,其他系统即可通过继承模板,基于这份模板重写,新增模块,以此提高效率。
四、动态组件库建设
设计组件的思路
在设计组件时候,可以从三个方面来考虑:1.设计组件的目的、2.确定组件的边界、3.实现设计
- 目的:首先需要明确组件的价值,定位组件,它用于解决什么问题,是否需要具备复用性,比如通用ui组件,或是用于解决页面代码复杂的组件。
- 边界:其次需要确定组件的边界,明确组件需要做什么,不做什么,防止组件无限膨胀。边界越清晰,组件的维护才越容易。
- 设计:最后进入实现阶段,包含:确定组件接收参数,暴露什么方法,返回什么事件,预留扩展点。
schemaConfig: {
api: "",// 数据源api符合restful api规范
schema: { //模块数据解构
type: "object",
properties: {
key: {
...shcema, //标准schema配置
type: "", //字段类型
label:" ", //字段名
// 字段在不同动态组件的相关配置,前缀对应componentConfig中的键值
// 例如 componentConfig.comA , 对应comAOption
comAOption: {
...elComponentConfig, // el-component-column配置
visible: true, // 是否展示,默认true
comType: "",// 组件类型 input/select/
default: "", //默认值
disabled: false, // 是否禁用,默认false
// 当comType==select
enumList: [],
},
}
},
required: [],// 必选字段
},
tableConfig: {},
// search-bar相关配置
searchConfig: {},
// 动态组件相关配置
componentConfig: {
// comA组件
comA: {
// 组件配置
title: "", // 标题
saveBtnText: "", // 保存按钮文案
},
}
}
动态组件的dsl设计如上:componentConfig描述组件相关配置,例如组件标题,保存按钮文案。源数据中的xxxOption描述字段在这个xxx这个组件中的配置,例如上面描述在comA这个组件的一些配置信息,comType为组件的类型,default默认值等等。
动态组件库实现
const componentConfig = {
createForm: {
component: CreateForm,
},
editForm: {
component: EditForm,
},
detailPanel: {
component: DetailPanel,
},
};
// 动态组件列表
const components = {
createForm: {
// 组件相关配置
...
},
...
}
// 动态组件渲染
<component
:is="componentConfig[key]?.component"
v-for="(com, key) in components"
ref="comListRef"
@command="onComponentCommand"
/>
function showComponent() {
...
}
const EventHandlerMap = {
showComponent: showComponent,
...
}
// 统一处理组件抛出的事件
function onComponentCommand(data) {
const { event } = data
if (EventHandlerMap[event]) {
EventHandlerMap[event]({ ... });
}
}
组件内部不处理业务逻辑,将事件都向外抛出,由外部调用方统一的事件分发机制决定执行什么动作。
五、npm包封装
npm 包的抽离是提升框架通用性与可维护性的关键环节,大致分为以下几点:
1、elpis-core服务端内核引擎:需要支持内部和使用方自定义,包含controller,extend,middleware,service,router,router-schema,config。核心是读取文件指向,即process.cwd()获取使用方自定义的文件,__dirname读取框架内部文件。同时提供引擎启动能力。
2、webpack工程化:暴露前端构建函数,支持本地和生成环境构建;进行依赖区分,明确devdependencies和dependencies;区分打包产物目录,读取产物的文件路径指向;支持使用方自定义webpack配置,如下代码
// 使用方业务webpack配置
let businessWebpackConfig = {};
try {
// 使用方配置文件位置需定义于规定的位置
businessWebpackConfig = require(path.resolve(
process.cwd(),
"./app/webpack.config.js"
));
} catch (error) {}
module.exports = merge.smart(
{
// elpis内部webapck配置
entry: Object.assign({}, elpisPageEntries, businessPageEntries),
output: {},
...
},
businessWebpackConfig
)
3、自定义扩展支持:elpis支持扩展动态组件、自定义页面、路由等,同理,需获取使用方自定义内容,然后进行合并。如下,其中$businessComponentConfig为读取使用方自定义的内容的别名。
// 使用方自定义内容
import businessComponentConfig from "$businessComponentConfig";
const componentConfig = {
createForm: {
component: CreateForm,
},
editForm: {
component: EditForm,
},
detailPanel: {
component: DetailPanel,
},
};
export default {
...componentConfig,
...businessComponentConfig,
};
elpis后续迭代发展建议
elpis通过统一的目录结构和规范化的约定来降低学习和使用成本,让开发者无需在各种繁琐的配置上耗费精力。因此在下一步迭代方向可以是脚手架工具的开发上。脚手架能够帮助用户快速生成符合约定的项目结构和基础代码模板,提高开发效率,降低手动配置出错概率等。
方案:
-
背景
目前用户在使用框架时仍需手动搭建目录、准备配置,过程重复且容易出错。因此开发一套 CLI 脚手架,支持快速初始化项目,并逐步扩展代码生成能力。 -
功能范围
- elpis-cli init my-app 初始化项目
- 自动生成约定式目录结构(controller / service / model)
- elpis-cli g controller User,elpis-cli g service User等代码生成器
-
技术方案
- 核心库:Node.js + Commander.js(命令行解析)+ Inquirer.js(交互式问答)
- 模板引擎:Handlebars(支持条件渲染和变量替换)
-
交付物
- 一个可通过 npm 安装的包:
npm install elpis-cli
- 一个可通过 npm 安装的包: