前言
对于大多数后台管理系统而言,往往是由layout,header,sider,main组成,而main绝大多数都是有search-form、table组成,对于大多数中后台的前端开发者,每天可能就是在重复的做着相同的crud的工作,而elpis设计的目的就是为了解决这个问题。
接下来我将从围绕5个方面来讲述elpis这个全栈框架是如何帮助我们来解决这大量重复的crud的工作的。
一、elpis-core内核的设计(简易版egg.js)
elpis-core基于koa2封装,秉承"约定大于配置"的理念,主要依赖于loader来加载文件,由以下模块组成
- middlewareLoader (挂载中间件,例如验签、校验参数)
- routerSchemaLoader (描述api请求规范)
- controllerLoader (控制器,接受用户的输入,返回结果)
- serviceLoader (业务逻辑层)
- configLoader (配置项)
- extendLoader(框架拓展能力,例如日志服务、数据库连接)
- elpisMiddleware (注册中间件)
- routerLoader (注册路由)
接下来以middlewareLoader为例来阐述elpis-core是如何挂载中间件的
js
const path = require('path');
const glob = require('glob');
const { sep } = path;
/**
* middleware loader
* @param {Object} app koa实例
*
* 加载所有middleware,可通过app.middlewares.${目录}.${文件}访问
*
* @example
* app/middleware
* |
* | -- custom-mudule
* |
* | -- custom-middleware.js
*
* => app.middlewares.customMudule.customMiddleware
*/
module.exports = (app) => {
const middlewares = {};
//读取elpis/app/middleware/**/*.js 下的所有文件
const elpisMiddlewarePath = path.resolve(__dirname, `..${sep}..${sep}app${sep}middleware`);
const elpisFileList = glob.sync(path.resolve(elpisMiddlewarePath, `.${sep}**${sep}*.js`));
elpisFileList.forEach((file) => {
handleFile(file);
});
//读取业务根目录/app/middleware/**/*.js 下的所有文件
const businessMiddlewarePath = path.resolve(app.bussinessPath, `.${sep}middleware`);
const businessFileList = glob.sync(path.resolve(businessMiddlewarePath, `.${sep}**${sep}*.js`));
businessFileList.forEach((file) => {
handleFile(file);
});
//将内容加载到app/middlewares下
function handleFile(file) {
let name = path.resolve(file);
//截取路径,app/middleware/custom-mudule/custom-middleware.js => custom-mudule/custom-middleware
name = name.substring(name.lastIndexOf(`middleware${sep}`) + `middleware${sep}`.length, name.lastIndexOf('.'));
//将 '-' 命名替换成驼峰命名 custom-mudule/custom-middleware=> customMudule/customMiddleware
name = name.replace(/[_-][a-z]/gi, (s) => s.substring(1).toUpperCase());
//挂载middlewares到内存对象app中
let tempMiddleware = middlewares;
const names = name.split(sep);
names.forEach((item, index) => {
if (index === names.length - 1) {
//文件
tempMiddleware[item] = require(path.resolve(file))(app);
} else {
// 文件夹 递归 tempMiddleware = {a:{b:{c:{d:{}}}
tempMiddleware[item] = tempMiddleware[item] ?? {};
//指向下一层
tempMiddleware = tempMiddleware[item];
}
});
}
app.middlewares = middlewares;
};
elpis是一个框架,所以elpis-core它不仅仅要能够挂载框架自身提供的中间件的能力,同时也需要有挂载框架使用者业务上自定义的中间件的能力。其他loader也是同理,在处理框架本身文件的同时,也要处理框架使用者业务上的定制需求。
二、elpis工程化的设计
工程化是现代前端开发的基石,通过一系列规范、工具和流程来提高开发效率和质量的实践,而elpis致力于打造一个企业级应用框架,必须集成工程化的能力,提高项目的开发效率和可维护性。
elpis通过ssr动态分发站点,每个入口承载不同的应用,所以elpis要解决的是根据不同的入口文件,生成不同的页面模板,同时也要为使用者提供开发环境下所需要承载的热更新能力,以及生产环境的分包、压缩等能力。
工程化解决方案
- 入口文件的处理,约定入口文件为entry.xx.js
js
const elpisPageEntries = {};
const elpisHtmlWebPackPluginList = [];
//获取elpis/app/pages下面的所有入口文件(entry.xxx.js)
const elpisEntryPathList = path.resolve(__dirname, '../../pages/**/entry.*.js');
glob.sync(elpisEntryPathList).forEach((file) => {
hanleFile(file, elpisPageEntries, elpisHtmlWebPackPluginList);
});
//获取业务/app/pages下面的所有入口文件(entry.xxx.js)
const businessPageEntries = {};
const businessHtmlWebPackPluginList = [];
const businessEntryPathList = path.resolve(process.cwd(), './app/pages/**/entry.*.js');
glob.sync(businessEntryPathList).forEach((file) => {
hanleFile(file, businessPageEntries, businessHtmlWebPackPluginList);
});
function hanleFile(file, pageEntries = {}, htmlWebPackPluginList = []) {
const entryName = path.basename(file, '.js');
pageEntries[entryName] = file;
htmlWebPackPluginList.push(
new HtmlWebPackPlugin({
// 产物(最终模板)输出路径
filename: path.resolve(process.cwd(), './app/public/dist/', `${entryName}.tpl`),
//指定要使用的模板文件
template: path.resolve(__dirname, '../../view/entry.tpl'),
// 要注入的代码块
chunks: [entryName],
}),
);
}
// elpis webpack基础配置
const elpisConfig = {
//入口配置
entry: Object.assign({}, elpisPageEntries, businessPageEntries)
}
- 利用loader对模块的源代码进行转化
- 利用plugin赋予webpack额外的能力,进行打包优化
- 利用分包策略达到更好的利用浏览器缓存的目的
- 开发环境热更新的能力
webpack.dev.js
const merge = require('webpack-merge');
const path = require('path');
const webpackBaseConfig = require('./webpack.base.js');
const webpack = require('webpack');
//devserver 配置
const DEV_SERVER_CONFIG = {
HOST: '127.0.0.1',
PORT: 9002,
HMR_PATH: '__webpack_hmr', // 官方规定
TIMEOUT: 20000,
};
// 开发环境的entry配置需要注入 hmr
Object.keys(webpackBaseConfig.entry).forEach((key) => {
//第三方包不需要热更新
if (!key.indexOf('vendor') != -1) {
webpackBaseConfig.entry[key] = [
webpackBaseConfig.entry[key],
`${require.resolve('webpack-hot-middleware/client')}?path=http://${DEV_SERVER_CONFIG.HOST}:${
DEV_SERVER_CONFIG.PORT
}/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`,
];
}
});
// 开发环境webpack配置
const webpackConfig = merge.smart(webpackBaseConfig, {
//开发环境插件配置
...省略
plugins: [
// 热模块替换插件(HMR)
// 模块热替换允许在应用程序运行时替换模块
// 不需要刷新整个页面就能实现代码更新,提高开发效率
new webpack.HotModuleReplacementPlugin(),
],
});
module.exports = {
//webpack 配置
webpackConfig,
//devserver 配置,暴露给dev.js使用
DEV_SERVER_CONFIG,
};
启动文件dev.js
//本地开发启动 devserver
const path = require('path');
const consoler = require('consoler');
const express = require('express');
const webpack = require('webpack');
const devMiddleware = require('webpack-dev-middleware');
const hotMiddleware = require('webpack-hot-middleware');
//从webpack.dev.js中获取webpack配置和devserver配置
const { webpackConfig, DEV_SERVER_CONFIG } = require('./config/webpack.dev.js');
module.exports = () => {
const app = express();
const compiler = webpack(webpackConfig); //初始化配置
//指定静态目录
app.use(express.static(path.resolve(process.cwd(), './app/public/dist/')));
// 引入 webpack-dev-middleware 中间件 (监控文件改动)
app.use(
devMiddleware(compiler, {
//落地文件
writeToDisk: (filePath) => filePath.endsWith('.tpl'),
//资源路径
publicPath: webpackConfig.output.publicPath,
//headers配置
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'X-Request-With,content-type,Authorization',
},
stats: {
colors: true, //在控制台显输出色彩信息
},
}),
);
// 引入 webpack-hot-middleware 中间件(实现热更新通讯)
app.use(
hotMiddleware(compiler, {
//热更新的路径
path: '/' + DEV_SERVER_CONFIG.HMR_PATH,
log: () => {},
}),
);
consoler.info('请等待webpack初次构建完成提示...');
const port = DEV_SERVER_CONFIG.PORT;
// 启动devserver
app.listen(port, () => {
console.log(`dev server listening on port ${port}`);
});
};
webpack仅仅是工程化的一部分,在elpis中帮我们处理了多入口的配置、开发环境的搭建、源文件以及资源等转化、生产环境的打包等等,工程化还包含很多,例如CICD,后续会提到,这里就不做多的说明。
三、基于领域模型的dsl设计
领域模型是对业务领域的抽象表示,定义业务实体和规则,而dsl则是已更贴近业务的语言来描述规则,用更加通俗的话来讲就是领域模型负责做什么,dsl负责怎么描述。
通常我们常见的一个中后台管理系统的架构都如下图所示
从图中我们可以看到,系统中80%的工作可能都是重复性的crud,只有20%的工作是需要额外定制开发的,那我们是不是可以寻求一种方法来处理这80%的重复性工作。
如何设计dsl
以下是一份dsl demo
js
module.exports = {
name: '拼多多',
desc: '拼多多电商系统',
homePage: '/schema?proj_key=pdd&key=product',
menu: [
{
key: 'product',
name: '商品管理(PDD)',
},
{
key: 'client',
name: '客户管理(PDD)',
moduleType: 'schema',
schemaConfig: {
api: '/api/client',
schema: {},
},
},
{
key: 'data',
name: '数据分析',
menuType: 'module',
moduleType: 'sider',
siderConfig: {
menu: [
{
key: 'analysis',
name: '电商罗盘',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
{
key: 'sider-search',
name: '信息查询',
menuType: 'module',
moduleType: 'iframe',
iframeConfig: {
path: 'https://cn.vuejs.org',
},
},
{
key: 'categories',
name: '分类数据',
menuType: 'group',
subMenu: [
{
key: 'category-1',
name: '一级分类',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
{
key: 'category-2',
name: '二级分类',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
{
key: 'tags',
name: '标签管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/tags',
schema: {},
},
},
],
},
],
},
},
{
key: 'search',
name: '信息查询',
menuType: 'module',
moduleType: 'iframe',
iframeConfig: {
path: 'https://cn.vuejs.org',
},
},
],
};
这个时候80%的重复性工作已经转变成如何配置一份站点需要的dsl,当配置多份dsl的时候,我们会发现相似站点之间的dsl存在很多共性,因此我们可以利用面向对象的思维,将公共部分沉淀下来作为基类,子类通过继承的方式获取与基类相同的能力,并在此基础上拓展自身的能力,于是诞生了下图设计
通过解析器解析子类的dsl,结合渲染引擎生成相应的站点。
四、围绕DSL设计动态组件
动态组建的设计与dsl密切相关,以下是一份动态组建的dsl demo
js
{
...
schemaConfig: {
api: '/api/proj/product',
schema: {
type: 'object',
properties: {
id: {
type: 'string',
label: 'ID',
tableOption: {
width: 100,
'show-overflow-tooltip': true,
},
},
name: {
type: 'string',
label: '商品名称',
minLength: 2,
maxLength: 10,
tableOption: {
width: 100,
'show-overflow-tooltip': true,
},
searchOption: {
comType: 'dynamicSelect',
api: '/api/proj/product_enum/list',
},
createFormOption: {
comType: 'input',
default: '哲哥牛逼!!!!!!',
},
editFormOption: {
comType: 'input',
},
detailPanelOption: {},
},
price: {
type: 'number',
label: '价格',
tableOption: {
width: 100,
},
searchOption: {
comType: 'select',
emunList: [
{ label: '100', value: 100 },
{ label: '200', value: 200 },
],
},
createFormOption: {
comType: 'inputNumber',
},
editFormOption: {
comType: 'inputNumber',
},
detailPanelOption: {},
},
stock: {
type: 'number',
label: '库存',
tableOption: {
width: 100,
},
searchOption: {
comType: 'input',
},
createFormOption: {
comType: 'select',
emunList: [
{ label: '100', value: 100 },
{ label: '200', value: 200 },
],
},
editFormOption: {
comType: 'select',
emunList: [
{ label: '100', value: 100 },
{ label: '200', value: 200 },
],
},
detailPanelOption: {},
},
create_time: {
type: 'string',
label: '创建时间',
tableOption: {},
searchOption: {
comType: 'dateRange',
},
detailPanelOption: {},
},
},
required: ['name'],
},
tableConfig: {
headerButtons: [
{
label: '添加商品',
eventKey: 'showComponent',
eventOption: {
comName: 'createForm',
},
type: 'primary',
plain: true,
},
],
rowButtons: [
{
label: '详情',
eventKey: 'showComponent',
eventOption: {
comName: 'detailPanel',
},
},
{
label: '编辑',
eventKey: 'showComponent',
type: 'primary',
eventOption: {
comName: 'editForm',
},
},
{
label: '删除',
eventKey: 'remove',
type: 'danger',
eventOption: {
params: {
id: 'schema::id',
},
},
},
],
},
componentsConfig: {
createForm: {
title: '添加商品',
saveBtnText: '添加商品',
},
editForm: {
mainKey: 'id',
title: '编辑商品',
saveBtnText: '编辑商品',
},
detailPanel: {
mainKey: 'id',
title: '商品详情',
},
},
},
...
}
通过dsl描述组件的结构和行为,只要通过DSL解析器动态渲染组件,处理交互,用配置代替代码,原来开发页面的流程就转变成了 dsl配置
-> 解析dsl
-> 动态渲染
-> 处理交互
,elpis已经帮我们做好了后三步,所以对于框架使用者而言,只需要完成第一步即可,极大的提升了效率和可维护性。
五、打包SDK
elpis是一个框架,框架是不应该含有业务逻辑的,因此在发布npm包之前,我们需要对elpis进行提纯,即剔除业务代码,暴露相关的勾子给开发者使用并且要支持开发者拓展框架能力。
- 对外提供服务启动命令和构建命令
js
// 引入elpis-core
const ElpisCore = require('./elpis-core');
// 引入前端构建
const FEBuildDev = require('./app/webpack/dev');
const FEBuildProd = require('./app/webpack/prod');
module.exports = {
/**
* 服务端基础
*/
Controller: {
Base: require('./app/controller/base.js'),
},
Service: {
Base: require('./app/service/base.js'),
},
/**
* 前端构建
* @param {string} env 环境变量 local: 开发环境 production: 生产环境
*/
frontEndBuild(env) {
if (env === 'local') {
FEBuildDev();
} else if (env === 'production') {
FEBuildProd();
}
},
// 启动elpis服务
serverStart(options) {
const app = ElpisCore.start(options);
return app;
},
};
- 通过工程化工具暴露框架的能力给外部调用
js
//配置模块解析的具体行为(定义webpack在打包时如何找到并解析模块的路径)
resolve: {
extensions: ['.js', '.vue', '.less', '.css'],
alias: (() => {
const aliasMap = {};
const blackLibsPath = path.resolve(__dirname, '../libs/blank.js');
// dashboard 路由拓展配置
const businessDashboardRouterConfig = path.resolve(process.cwd(), './app/pages/dashboard/router.js');
aliasMap['@businessDashboardRouterConfig'] = fs.existsSync(businessDashboardRouterConfig)
? businessDashboardRouterConfig
: blackLibsPath;
// schema-view拓展组件配置
const businessComponentsConfig = path.resolve(
process.cwd(),
'./app/pages/dashboard/complex-view/schema-view/components/components-config.js',
);
aliasMap['@businessComponentsConfig'] = fs.existsSync(businessComponentsConfig) ? businessComponentsConfig : {};
// schema-form拓展组件配置
const businessFormItemConfig = path.resolve(process.cwd(), './app/pages/widgets/schema-form/form-item-config.js');
aliasMap['@businessFormItemConfig'] = fs.existsSync(businessFormItemConfig) ? businessFormItemConfig : {};
// schema-search-bar拓展组件配置
const businessSearchItemConfig = path.resolve(
process.cwd(),
'./app/pages/widgets/schema-search-bar/search-item-config.js',
);
aliasMap['@businessSearchItemConfig'] = fs.existsSync(businessSearchItemConfig) ? businessSearchItemConfig : {};
return {
vue: require.resolve('vue'),
'@babel/runtime/helpers/asyncToGenerator': require.resolve('@babel/runtime/helpers/asyncToGenerator'),
'@babel/runtime/regenerator': require.resolve('@babel/runtime/regenerator'),
'@elpisPages': path.resolve(__dirname, '../../pages'), //路径别名
'@elpisCommon': path.resolve(__dirname, '../../pages/common'),
'@elpisCurl': path.resolve(__dirname, '../../pages/common/curl.js'),
'@elpisUtil': path.resolve(__dirname, '../../pages/common/util.js'),
'@elpisWidgets': path.resolve(__dirname, '../../pages/widgets'),
'@elpisHeaderContainer': path.resolve(__dirname, '../../pages/widgets/header-container/header-container.vue'),
'@elpisSiderContainer': path.resolve(__dirname, '../../pages/widgets/sider-container/sider-container.vue'),
'@elpisSchemaTable': path.resolve(__dirname, '../../pages/widgets/schema-table/schema-table.vue'),
'@elpisSchemaForm': path.resolve(__dirname, '../../pages/widgets/schema-form/schema-form.vue'),
'@elpisSchemaSearchBar': path.resolve(__dirname, '../../pages/widgets/schema-search-bar/schema-search-bar.vue'),
'@elpisStore': path.resolve(__dirname, '../../pages/store'),
'@elpisPlugins': path.resolve(__dirname, '../../pages/plugins'),
'@elpisRouter': path.resolve(__dirname, '../../pages/router'),
'@elpisBoot': path.resolve(__dirname, '../../pages/boot.js'),
...aliasMap,
};
})(),
},
- 拓展loader需要支持处理开发者业务上的定制能力(在elpis-core里已有阐述)
- 剔除前期开发过程中的业务代码
- 发布到npm进行版本管理
CICD持续部署与集成
相信很多同学也经历过这个阶段
每次要更新前端服务都要做这一步重复的工作,所以我们希望尽可能减少去做重复工作,CICD就能很好的帮我们去解决这个问题,帮我们达到如下效果,当代码提交的时候,能自动触发以下流程
这个过程中jenkins承担着自动化构建的任务,执行前端构建命令,如npm install、npm run prod以及build docker镜像等;docker承担着容器的作用,帮助我们解决环境标准化的问题;kubernetes帮我们实现容器的编排与调度,k8s通过调度pod再worker节点上运行,service暴露pod给公网访问。
分工一句话
- Jenkins :负责 "什么时候做" (触发流程)和 "怎么做" (定义步骤)。
- Docker :负责 "用什么跑" (打包一致的环境)。
- Kubernetes :负责 "在哪里跑" (调度容器)和 "跑得稳" (管理生命周期)。
对于elpis这个还在建设中的框架,框架顾名思义就是为了让开发者减少重复劳动,专注业务本身。通过elpis-cli脚手架帮助开发者<5分钟完成项目的初始化,甚至于基于ai的能力接入elpis-ai-plugins,在脚手架created项目的时候,通过输入的描述直接帮助开发者完成站点的建设。