ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot低代码实践:用JSON配置驱动动态表单与审批流引擎

SpringBoot低代码实践:用JSON配置驱动动态表单与审批流引擎 先说个我见过太多次的场景。业务方提了个需求“帮我加个请假审批流程就是填个单子、经理审批、HR备案就行了。”于是你开始建表、写Controller、写Service、写Mapper把表单字段写死在代码里再写几个状态枚举加一张审批记录表——一套流程下来半天没了。下个月业务方说“再加个报销审批”你又把类似的事重复一遍。页面长得差不多、流程套路也差不多唯一变的只是字段名和审批节点。SpringBoot项目里这种重复CRUD写多了我一直在想一个问题能不能把“表单长什么样”和“流程怎么走”变成配置数据而不是代码这就是我这篇文章要聊的东西——一套基于SpringBoot的Low-Code思路用JSON来描述表单定义和审批流定义然后写一个通用引擎去解释和执行它们。配好之后新的审批流不需要写一行Java代码填Json配置就行实测一个请假审批从零到跑通五分钟真的够。这篇文章适合谁看在SpringBoot项目里写过三张以上业务表单、厌倦了复制粘贴式CRUD的后端开发或者团队里想引入低成本低代码方案、但不想上重型平台的架构师。我会把表结构、JSON设计、核心引擎代码、完整实操步骤和踩过的坑都摊开讲不藏着掖着。1. 为什么重复CRUD让人难受JSON表单引擎解决的问题1.1 重复CRUD的尽头是表单的“写死”传统SpringBoot项目里的业务表单开发流程几乎是固定的根据需求设计数据库表一张表对应一个实体类然后Controller、Service、Mapper三件套再写一个前端页面。表面上看每张表字段不同但骨架完全一样——增删改查都是那几行代码换个字段名而已。真正的问题是存量系统里这种表单特别多。我今天统计了一下之前维护的项目光审批相关的表就有十一张请假表、报销表、用章申请、采购申请、合同会签……每张表都有自己的增删改查接口加起来几十个URL。后面想加一个权限控制或者统一日志得挨个接口改累得不是手是心。而且业务方改需求的频率远比我们想象的高。今天说请假要加一个“是否紧急”字段明天说报销要按项目维度统计。每次改动都牵动数据库、后端代码和前端页面哪怕是加一个下拉框选项都要走一遍发布流程。这种模式在低代码平台出现之前是没办法的事但现在明明有更好的方案为什么不用1.2 JSON描述表单把“界面”变成数据核心思路其实不复杂把表单字段的定义抽象成JSON比如这个表单有哪些字段、每个字段是什么类型、哪个必填、可选项是什么都放在JSON配置里。SpringBoot后端只需要一套通用的解析和校验逻辑读取JSON配置后动态处理数据。打个比方传统写法像是你亲手盖每一栋房子虽然户型不同但工序完全一样你得重复垒砖抹灰JSON表单引擎则是先造了一个标准框架每栋房子只需要填写“户型图”JSON配置框架自己会盖好。这意味着几件事第一新增表单不需要建表和写代码只需要往配置表里插一条JSON记录第二表单字段变化只改JSON不需要重新发布应用第三同一套校验逻辑服务所有表单代码量大幅下降。Low-Code的核心从来不是“不用程序员”而是让程序员把时间花在真正有挑战的事情上而不是重复劳动。1.3 这套方案适合谁、不适合谁我在好几个项目里用过这套思路经验是它适合业务表单多、结构相对固定、但经常微调的B端系统比如OA、ERP、内部管理系统。这类系统的特点是表单字段上百个但单个表单的字段数量一般不超过五十性能压力不大非常契合JSON存储和动态解析的模式。不适合的大概有两类一类是数据量大、对查询性能要求极高的核心业务表比如交易流水这种就别用JSON存了还是老老实实建表加索引另一类是前后端交互极其复杂的表单——比如包含嵌套表格、动态行编辑、跨字段复杂联动的高阶页面纯JSON驱动的通用引擎实现起来成本极高不如直接写页面。我觉得做技术选型最忌讳“一招鲜”。JSON表单引擎是一个很好的增量方案解决的是大量中低复杂度的表单和审批场景而不是要推翻所有数据库设计。理解了这个边界后面看代码和配置你就知道哪些该用、哪些不该用了。2. 表单引擎核心设计一份JSON定义跑通前端界面与后端校验2.1 表单定义的核心JSON结构表单定义的数据结构我设计了三层最外层是表单元信息formKey、名称、版本中间是字段列表字段内部是字段属性。字段属性是核心它既决定前端渲染成什么控件也决定后端如何校验数据。下面是我在一套请假单上用的表单定义基本能代表这套方案的标准写法{ formKey: leave_request, formName: 请假申请单, version: 1, fields: [ { name: applicant, label: 申请人, type: text, required: true, disabled: true }, { name: leaveType, label: 请假类型, type: select, required: true, options: [ { label: 事假, value: personal }, { label: 病假, value: sick }, { label: 年假, value: annual } ] }, { name: days, label: 请假天数, type: number, required: true, rules: { min: 0.5, max: 30, precision: 1 } }, { name: reason, label: 请假事由, type: textarea, required: true, rules: { maxLength: 500 } } ] }每个字段的type决定控件类型目前我支持text、textarea、number、select、radio、checkbox、date、datetime、file这几种覆盖了90%的审批表单场景。rules里可以存校验规则min和max是数值范围maxLength是长度后面想支持正则也可以往里加。前端拿到这个JSON直接渲染成表单后端拿到同一个JSON做数据校验一份配置两头通用这是JSON Schema思路带来的最大红利。2.2 动态表单实例一张表存所有业务数据表单定义存配置表单实例存业务数据。传统方案是一个表单一张表我的方案是字段配置在JSON里业务数据也直接存在一个JSON字段中。form_instance表的设计如下CREATE TABLE form_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, form_key VARCHAR(64) NOT NULL, version INT NOT NULL, data_json JSON NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-草稿 1-审批中 2-通过 3-驳回, process_instance_id BIGINT COMMENT 关联流程实例, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_form_key (form_key, status) );提交请假单的时候前端POST过来就是一个普通JSON比如{applicant:张三,leaveType:personal,days:2,reason:家里有事}。后端做的事情是先根据formKey拿到表单定义逐字段做校验校验通过后把整个JSON塞进data_json字段。这种设计的优点是极度的灵活新增表单不需要动数据库表结构缺点是查询和统计确实比普通表麻烦一点。我的解决思路是高频查询字段用MySQL 8的JSON函数比如JSON_EXTRACT(data_json, $.applicant)同时用generated column加索引低频统计直接一次性全查出来在内存里处理毕竟表单实例数据量通常不会膨胀到百万级。2.3 后端通用校验逻辑规则写在Schema里不是写在代码里以前每次写一个表单接收集合都得写一段“如果是null就报错”“如果字符串长度超了就报错”的代码。有了Schema这些逻辑全都可以自动化。我封装了一个FormSchemaValidator组件核心逻辑大概是这样的Component public class FormSchemaValidator { Resource private FormDefinitionMapper formDefinitionMapper; public void validate(String formKey, Integer version, MapString, Object data) { FormDefinition formDef formDefinitionMapper.findByKeyAndVersion(formKey, version); JSONObject schema JSON.parseObject(formDef.getSchemaJson()); JSONArray fields schema.getJSONArray(fields); for (int i 0; i fields.size(); i) { JSONObject field fields.getJSONObject(i); String name field.getString(name); Object value data.get(name); boolean required field.getBooleanValue(required); if (required (value null || String.valueOf(value).trim().isEmpty())) { throw new BizException(field.getString(label) 不能为空); } if (value null) { continue; } String type field.getString(type); switch (type) { case number - validateNumber(field, value); case select, radio - validateOptions(field, value); case text, textarea - validateLength(field, value); case date, datetime - validateDateTime(field, value); default - throw new BizException(未知字段类型: type); } } } }我特别想强调一个坑前端的校验只是用户体验后端的校验才是安全底线。配置化的表单意味着前端可能被绕过攻击者直接POST脏数据到接口。所以这套通用校验逻辑必须放在后端而且是强制执行的不能依赖前端判断。实际开发中我还加了一个切面用注解FormValidate(formKey leave_request)打在Controller方法上调用前自动完成校验代码更干净。这里还有一个设计细节校验规则只支持min/max/maxLength这些基础约束。跨字段的组合校验怎么办比如“结束时间必须晚于开始时间”。我的方案是预留了一个自定义校验code字段在规则里写rule: endTimeGreaterThanStartTime后端实现一个校验函数注册表按code分发到具体逻辑。这算是一个折中方案低频的特殊校验走扩展点高频的标准校验走通用逻辑兼顾灵活性和维护成本。3. 审批流引擎实现节点、条件与流转3.1 审批流本质上是一个状态机把表单引擎搞定之后审批流其实简单了一半。审批流的本质是状态机数据从某个状态开始根据操作者的动作同意/驳回沿着预定义好的路径迁移到下一个状态直到终态。传统做法是在代码里硬编码状态流转——写一堆if-else团队小还好流程多了维护起来就是泥潭。我采用的方案是流程驱动数据把流程定义也做成JSON配置节点之间通过next字段连接通过type字段区分节点类型。审批的时候引擎只需要负责一件事——根据当前节点配置和操作结果找到下一个该去的节点更新表单状态。这套逻辑可以说是整个审批流引擎的全部核心。状态机思路的优势是显式和可控每一步的迁移都记录在案审批历史可追溯流程变更时只需要改配置不用改状态枚举和流转代码任何时刻你都能知道一条流程实例在哪个节点、经历过什么。3.2 流程定义的JSON设计节点类型与跳转规则流程定义的JSON结构我反复改过几个版本现在是这个形态{ processKey: leave_process, processName: 请假审批流程, version: 1, startNode: start, nodes: [ { id: start, type: start, next: manager_approve }, { id: manager_approve, type: approve, name: 经理审批, assignee: { type: role, value: role_dept_manager }, actions: [agree, reject], next: branch_days, rejectTo: start }, { id: branch_days, type: condition, name: 请假天数判断, conditions: [ { expression: days 3, next: vp_approve }, { expression: default, next: hr_cc } ] }, { id: vp_approve, type: approve, name: 分管副总审批, assignee: { type: role, value: role_vp }, actions: [agree, reject], next: hr_cc, rejectTo: manager_approve }, { id: hr_cc, type: cc, name: 人力备案, assignee: { type: role, value: role_hr }, next: end }, { id: end, type: end } ] }节点类型目前就这么几种start开始、approve审批、condition条件分支、cc抄送、end结束。approve节点需要配置assignee决定谁能审批actions决定支持哪些操作同意/驳回next指向审批通过后的下一个节点rejectTo指向驳回后回到哪。cc节点不阻塞流程只是记录通知自动往下走。condition节点是纯逻辑节点从表单数据里取字段做判断命中哪个条件就走哪条分支。assignee支持两种模式type为user时指定具体人type为role时指定角色运行时动态解析角色的所有成员。为什么要设计成动态解析而不是直接把审批人写死在配置里因为业务中经常要求“请假由直属上级审批”上级是变化的角色稳定所以配置里绑定角色、运行时查用户信息才是正解。这一点后面讲坑的时候还会细说。3.3 流转引擎核心实现提交、同意、驳回流程实例表记录每条业务的流转状态核心字段是current_node指向流程定义里的节点ID。整体流转服务我封装了一个ProcessEngine类最关键的是approve方法我简化出核心逻辑public void approve(Long processInstanceId, String userId, String action, String comment) { ProcessInstance pi processInstanceMapper.findById(processInstanceId); ProcessDefinition pd processDefinitionMapper.findByKeyAndVersion( pi.getProcessKey(), pi.getVersion()); JSONObject flow JSON.parseObject(pd.getFlowJson()); JSONArray nodes flow.getJSONArray(nodes); JSONObject currentNode findNodeById(nodes, pi.getCurrentNode()); checkAssignee(currentNode, userId); // 记录审批痕迹 nodeRecordMapper.insert(new ProcessNodeRecord( processInstanceId, currentNode.getString(id), currentNode.getString(name), userId, action, comment)); if (reject.equals(action)) { moveToNode(pi, currentNode.getString(rejectTo)); formInstanceMapper.updateStatus(pi.getFormInstanceId(), 3); return; } // agree找下一个节点 String nextNodeId currentNode.getString(next); JSONObject nextNode findNodeById(nodes, nextNodeId); // 条件节点动态计算走哪条分支 if (condition.equals(nextNode.getType())) { JSONObject formData loadFormData(pi.getFormInstanceId()); nextNodeId evalCondition(nextNode, formData); } if (end.equals(nextNodeId)) { moveToNode(pi, nextNodeId); formInstanceMapper.updateStatus(pi.getFormInstanceId(), 2); } else { moveToNode(pi, nextNodeId); formInstanceMapper.updateStatus(pi.getFormInstanceId(), 1); } }整体流程不复杂找到当前节点校验当前用户身份根据动作决定去下个节点还是退回前面节点。记录审批日志是对账和追溯的基础必须每次操作都落一条。到end节点时表单状态同步改成2通过驳回时改成3驳回。提交创建流程实例就走另一个方法先校验表单数据的合法性然后插入form_instance和process_instance把current_node设为start节点的next状态置为审批中。这个方法的骨架在实操章节里演示完整调用链路。3.4 条件分支如何安全地解析表达式条件分支是审批流里最容易翻车的点。我上面写的condition节点里用了字符串表达式days 3初步实现是自己解析这个表达式拆分出字段名、操作符、比较值然后从表单数据里取值做比较。这种方式对付简单的数值比较没问题但遇到字符串等于、包含、多条件与或就力不从心了。所以在生产环境里我推荐直接用成熟的表达式引擎比如Aviator或者Spring自带的SpEL。Aviator的语法简单、性能好非常适合这种配置化场景。改造后的条件节点判断逻辑大概长这样private String evalCondition(JSONObject conditionNode, MapString, Object formData) { JSONArray conditions conditionNode.getJSONArray(conditions); for (int i 0; i conditions.size(); i) { JSONObject cond conditions.getJSONObject(i); String expression cond.getString(expression); if (default.equals(expression)) { return cond.getString(next); } // Aviator 表达式直接求值字段值从 formData 里取 Boolean matched AviatorEvaluator.exec(expression, formData); if (matched ! null matched) { return cond.getString(next); } } throw new BizException(条件节点未命中任何分支); }用Aviator之后表达式可以写成days 3 leaveType sick甚至支持函数调用表达能力不知道强了多少个档次。而且Aviator的exec方法直接接收一个Map作为变量环境表单数据天然就能喂进去集成起来非常丝滑。这个替换成本极其低但收益巨大强烈建议直接上表达式引擎别自己写字符串解析。4. 5分钟实操配置一套请假审批流的完整过程4.1 准备基础表结构与工程骨架要复现这套方案首先把前面提到的五张核心表建好form_definition表单定义、form_instance表单实例、process_definition流程定义、process_instance流程实例、process_node_record节点审批记录。我前面已经给了form_instance的建表脚本其余几张结构类似关键点在流程定义和表单定义都要有version字段用(form_key, version)做联合唯一键这样才能支持同一套配置多版本并存。工程结构方面SpringBoot标准分层即可Controller层暴露提交、审批、查询接口Service层放表单校验FormSchemaValidator和流程引擎ProcessEngineMapper层就是普通的MyBatis Plus接口。我这里特意不用任何重量级工作流引擎就是想让核心依赖保持最小——SpringBoot Web MyBatis Plus Fastjson2 Aviator足够了。4.2 配置请假表单插入一条JSON配置把2.1节那段请假单的JSON配置塞进form_definition表SQL就长这样INSERT INTO form_definition (form_key, form_name, version, schema_json, status) VALUES (leave_request, 请假申请单, 1, {formKey:leave_request,formName:请假申请单,version:1,fields:[...]}, 1);这一步在真实管理后台里可以做成一个“表单配置”页面把JSON粘进去就算创建成功。我一开始是直接写SQL配置的后来做了一个极简的可视化编辑器本质上就是个JSON编辑器加校验提示体验好很多。表单一旦发布前端动态渲染组件就能根据这个配置画出页面来不需要任何前端代码改动。4.3 配置审批流程插入流程定义再把3.2节的流程定义JSON插入process_definition表INSERT INTO process_definition (process_key, process_name, version, flow_json, status) VALUES (leave_process, 请假审批流程, 1, {processKey:leave_process,processName:请假审批流程,version:1,startNode:start,nodes:[...]}, 1);到这里“5分钟配置”的重头戏其实已经完成了——建表脚本只要执行一次后续每次新流程都是两条INSERT语句的事。我第一次在项目里给同事演示的时候他们盯着这两条SQL看了半天问“就这样”我说对就这样。真正让表单和流程跑起来的代码早就写好了配置只是喂数据而已。4.4 完整调用链路从提交到审批通过配置好了我就用请假审批来演示一遍完整链路。假设业务方要两天事假操作流程如下第一步提交请假单。前端调用表单提交接口或者直接调后端接口模拟curl -X POST http://localhost:8080/api/form/leave_request/submit \ -H Content-Type: application/json \ -d { formKey: leave_request, data: { applicant: 张三, leaveType: personal, startTime: 2025-06-10 09:00:00, endTime: 2025-06-11 18:00:00, days: 2, reason: 家里有急事 } }后端ProcessEngine.submit方法会做三件事加载表单定义并全字段校验、把data写入form_instance、读取流程定义并创建process_instance初始节点指向manager_approve。接口返回processInstanceId前端靠这个ID跳转审批进度页。第二步经理审批通过。经理账号登录后看到待办点“同意”curl -X POST http://localhost:8080/api/process/1001/approve \ -H Content-Type: application/json \ -d {userId: lisi, action: agree, comment: 同意}引擎发现当前节点是manager_approve操作是agree就读取next指向branch_days。由于是condition节点引擎加载表单数据计算days 3表达式2不大于3走了default分支进入hr_cc抄送节点。抄送节点不需要人工操作引擎自动继续流转到end流程结束表单状态变为2通过。第三步查看审批历史。curl -X GET http://localhost:8080/api/process/1001/history返回的列表里能清楚看到张三提交、李四同意每条记录都有操作人、动作、意见和时间这些全部来自process_node_record表。第四步演示驳回场景。如果经理点击“驳回”引擎会把current_node改回start的rejectTo指向。这里重点是流程定义了一个rejectTo字段明确指定驳回回到哪个节点可以是提交节点也可以是中间某个审批节点配置错误的话审批会乱套。用这套流程跑通一遍之后我确信两个结论第一中小型审批流程用JSON配置远比硬编码高效第二所谓Low-Code真正要打磨的是引擎的健壮性和配置的易用性而不是堆一堆拖拽组件。5. 踩坑实录动态表单与流程引擎的常见问题与排查5.1 动态数据的查询与性能问题表单数据存JSON字段最直接的坑就是查询。同事第一次接我的代码写了个“查所有事假申请单”的需求他下意识想了一句SQLSELECT * FROM form_instance WHERE JSON_EXTRACT(data_json, $.leaveType) personal然后发现数据量到十万级之后这查询越来越慢。解决办法也不复杂把高频过滤字段提取成generated column然后加索引。MySQL 8支持这种用法ALTER TABLE form_instance ADD COLUMN leave_type VARCHAR(20) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(data_json, $.leaveType))) STORED, ADD INDEX idx_leave_type (leave_type);创建之后查询就可以直接WHERE leave_type personal走索引性能跟普通字段没差别。原则是哪些字段频繁用于筛选排序统计就给哪些字段建generated column别想着给所有字段建过度设计会让表结构看起来很奇怪。5.2 流程版本变更与存量实例的处理业务流程改了怎么办这是大家最关心的。比如原来请假流程是“经理审批——结束”现在改成“经理审批——人力审批——结束”。如果直接把流程定义update掉正在流转的实例就会出问题因为它们记录的是旧版本节点新旧配置对接时大概率报错。我的解决思路是版本隔离每次流程修改都插入一条新版本记录version递增而不是更新原记录。流程实例创建时把当时的版本号记录下来之后流转就一直按这个版本走。这跟发布系统里灰度发布的意思差不多旧实例走旧逻辑新实例走新逻辑互不干扰。表单定义也一样。业务方说“请假单加一个紧急程度字段”我就在form_definition里插入version2的新定义旧的请假单实例继续用version1的Schema校验。前端动态渲染的时候要做版本匹配别用最新的配置渲染老数据。5.3 审批人身份校验与动态角色解析这个坑我踩得很深。最初的设计里assignee直接写死了一个用户ID结果业务方说“张三调部门了以后经理审批不该是张三了”我赶紧去找所有相关配置改了一通气得想拍桌子。后来统一改成assignee只配置角色代码审批的时候先用角色代码查当前有哪些用户再跟当前登录用户比对。角色下可能有多个人那还要有一个策略是任意一个角色成员审批即可还是需要会签。我目前实现的是“任一成员即可”会签加一个type字段区分。企业级的复杂审批或签、会签、加签、转办是一个无底洞如果不是硬性需求建议先不要往深处做。5.4 条件表达式过于复杂的问题有些同事看到条件节点之后非常兴奋开始写“days 3 leaveType sick || applicant boss”这种六个条件的表达式。表面上看Aviator处理这些很轻松但实际问题在于表达式越复杂配置的可维护性就越差等业务方自己也想看配置的时候没人能看懂这串逻辑。我踩过这个坑之后定了一条规矩条件节点只做简单判断一到两个条件足矣复杂的多条件判断宁可拆成多级条件节点也不要揉在一个大表达式里。多级条件节点的好处是流程图的节点可以命名清晰比如“天数超过3天”和“是否病假”是两个独立节点一看就知道业务意图排查问题的时候不知道省了多少时间。5.5 速查表几个高频问题一眼定位现象可能原因排查方向提交表单时提示“XX不能为空”前端没传该字段看表单定义required字段对照请求json审批操作报“当前用户无权限”assignee配置与实际角色不匹配查角色表确认该角色下是否有当前用户流转到下一步后表单状态没变条件节点表达式返回false且无default分支检查表达式字段名是否与表单字段完全一致新流程配置后老接口404可能formKey或processKey发错了确认配置表里的key与接口参数严格一致驳回后状态到了结束节点rejectTo配置成了end回溯环节检查rejectTo是否指向start或中间节点排查工具我建议直接看process_node_record表按流程实例ID倒序查一遍每一步走到哪看得清清楚楚比对着代码猜状态快得多。还有个小技巧流程引擎的每个流转方法入口打一条info日志把processInstanceId、当前节点、目标节点、操作人打出来线上问题定位效率翻倍。这套方案我在实际项目里用了一年多给我最直观的感受是业务方再提“加个审批流”的时候我不慌了。配置模板和引擎代码都是现成的新流程从提需求到上线跑通真正就是一杯茶的功夫。如果有朋友正在自己的项目里纠结要不要做低代码化改造我建议别急着买平台先按这套JSON驱动的方式搭一个最小引擎试试1000行代码以内你就知道它能给你省下多少事了。最后再分享一个小技巧表单配置和流程配置都建议做成带diff对比的版本管理改动之前先备份一个版本。有一次我在测试环境改流程手一抖把next指错了整个流程直接走进死胡同靠版本回滚才救回来。配置化的工具数据就是人命备份意识一定得有。
返回列表