ARTICLE DETAIL

资讯详情

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

SpringBoot+JSON表单引擎:5分钟配置一套动态审批流

SpringBoot+JSON表单引擎:5分钟配置一套动态审批流 重复的 CRUD 写多了真的会让人怀疑人生。前几年我在一家做企业信息化的小公司待过一阵子几乎每个月都要接到一个新需求做个报销单、做个领料单、做个请假审批、做个项目立项……表单长得都差不多无非是字段多几个少几个流程无非是“发起 → 部门主管 → 分管领导 → 归档”。但每个需求却都要从零写一遍实体类、Mapper、Service、Controller再让前端同事配合写一套页面光联调就得花好几天。后来我下决心换个思路能不能把“表单本身”变成数据用 JSON 描述一个表单长什么样、校验规则是什么、审批流怎么走后端只维护一套通用的解析、渲染、校验和流转逻辑。这个思路落地之后就成了今天要分享的这套基于 SpringBoot Low-Code JSON 表单引擎的轻量审批流方案。你只需要花 5 分钟写一段配置就能上线一套带完整审批流程的业务表单彻底告别那种“加一张表就多写一套 CRUD”的日子。这套方案适合谁适合后端开发、全栈工程师也适合正在做企业内部管理系统、想给团队减负的架构师。读完你就能明白它的原理并且能直接照着把核心代码抄到自己的项目里。1. 为什么我决定写一个 JSON 表单引擎1.1 重复 CRUD 是压垮我的最后一根稻草我印象最深刻的一个需求是公司要做一个“用章申请”就是员工出去办事需要盖章得先填一张单子部门领导审批再由行政审核最后归档。看起来很简单但当时项目里已经有六个类似的申请单了请假申请、加班申请、报销申请、采购申请、用车申请、用章申请。六张表、六个实体、六套 Service 加六套 Controller而它们的逻辑相似度高达 80%。这种感觉就像你每天在流水线上拧同一个螺丝拧完一个还有下一个。更难受的是业务方永远会有新需求“这个字段帮我加一下”“这个字段改成下拉框”“这个审批环节多加一个人”。每次改动都要动代码、重新打包、重新发布。项目晚发版是常态加班成了家常便饭。我开始意识到问题不在“需求太多”而在“我们写代码的方式是错的”。对于一个企业内部管理系统来说表单和审批流是高度模式化的东西它们完全可以用配置来描述而不是用代码来堆砌。1.2 低代码不等于“零代码”很多人对 Low-Code低代码有误解觉得低代码就是拖拽生成系统、业务人员也能开发。那是一种理想状态真正的工程实践中低代码的核心价值应该是让 80% 的重复逻辑收敛到一套通用引擎里剩下 20% 的个性化逻辑才需要写代码。我选择的切入点就是“表单 审批流”这两个最标准化的场景。用 JSON 描述表单字段、布局、校验规则、审批节点和流转条件后端引擎负责统一处理数据持久化、规则校验、流程推进、历史记录。业务需求变了我只需要改 JSON 配置不用重启服务——配置热加载之后一个新的业务表单马上就能用。这种做法的优势很明显维度传统写法JSON 表单引擎新表单上线周期3-5 天30 分钟含配置和测试改字段改实体改接口改前端发版改 JSON 配置代码重复度高极低个性化扩展自由需要遵循引擎约束维护成本随业务量线性增长几乎恒定当然它不是银弹后面我会专门讲它的边界在哪里。但对于“企业内部管理类系统”这个场景它是我见过性价比最高的解法。2. 整体架构设计JSON 配置如何变成一套可用的表单和审批流2.1 核心数据模型三张表撑起整个引擎整套引擎的数据模型其实非常朴素核心就三张表表单配置表、业务数据表、审批实例表。我最初设计时也纠结过要不要搞动态建表后来果断放弃了原因后面说。表单配置表form_config存储每一类表单的 JSON 定义比如“请假申请单”是一条记录“报销申请单”是另一条记录。简化后的表结构大概长这样CREATE TABLE form_config ( id BIGINT PRIMARY KEY AUTO_INCREMENT, form_code VARCHAR(64) NOT NULL UNIQUE COMMENT 表单编码如 leave_form, form_name VARCHAR(128) NOT NULL COMMENT 表单名称, form_schema TEXT NOT NULL COMMENT 表单JSON定义含字段、校验、布局, flow_config TEXT COMMENT 审批流JSON定义含节点、分支、审批人, version INT DEFAULT 1 COMMENT 版本号每次修改1, status TINYINT DEFAULT 1 COMMENT 1启用 0停用, create_time DATETIME, update_time DATETIME );业务数据表form_data保存所有表单实例的数据。这里最关键的设计决策是直接用 JSON 字段存业务数据而不是给每种表单建一张表。因为表单是动态配置的你没法预知它有多少字段动态建表虽然查询性能好但带来的是无尽的运维噩梦——表结构变更、ORM 映射、索引管理每一样都够你喝一壶。CREATE TABLE form_data ( id BIGINT PRIMARY KEY AUTO_INCREMENT, form_code VARCHAR(64) NOT NULL COMMENT 对应表单配置编码, data_content JSON NOT NULL COMMENT 业务数据如{name:张三,days:3}, status TINYINT DEFAULT 0 COMMENT 0草稿 1审批中 2通过 3驳回, current_node VARCHAR(64) COMMENT 当前审批节点编码, create_by VARCHAR(32), create_time DATETIME, update_time DATETIME );审批实例表flow_instance记录每次审批的过程数据包括每个节点的处理人、处理意见、处理时间。CREATE TABLE flow_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, form_data_id BIGINT NOT NULL COMMENT 关联form_data.id, flow_code VARCHAR(64) NOT NULL COMMENT 流程编码, node_code VARCHAR(64) NOT NULL COMMENT 当前/历史节点编码, node_name VARCHAR(128) COMMENT 节点名称, approver VARCHAR(32) COMMENT 审批人, action VARCHAR(16) COMMENT PASS/REJECT, comment TEXT COMMENT 审批意见, create_time DATETIME );看清这个结构你会发现整个系统的核心不是“表单”而是“配置”和“数据”分离。配置我可以随时改数据一旦生成就不可变审计的时候只需要看 form_data flow_instance 就能还原整个业务过程。这就是所有低代码平台的通用底座思路。2.2 为什么业务数据用 JSON 字段而不是动态建表关于用 JSON 字段存业务数据懂行的同学可能第一反应是查询怎么办比如我按“请假天数大于 3 天”筛选数据data_content是 JSON 字段怎么查这个问题问得很好。我这里给三个答案第一企业内部这类表单系统查询频率和复杂度并没有你想象的那么高。大多数场景是“查某个人提交过的单子”或“查所有待我审批的单子”这两个查询都基于 create_by 或者 current_node根本不需要解析 JSON。第二MySQL 从 5.7 开始支持 JSON 类型和 JSON 表达式查询WHERE JSON_EXTRACT(data_content, $.days) 3这种写法是可行的。只不过要注意性能数据量大之后建议配合虚拟列加索引或者把高频查询字段单独抽出来冗余存储。第三我做了折中方案高频查询字段比如申请人、申请日期、金额定义在 JSON 配置里时允许标记searchable: true引擎在保存数据时会自动把这些字段冗余到辅助表里做查询走辅助表拿详情走 JSON。这个设计既保留了动态性又不牺牲查询性能。我更想强调的是设计哲学系统最怕的不是性能慢而是改不动。用 JSON 存储的代价是用空间和一点查询便利性换取了无限扩展性这个账在业务演化面前是划算的。就像盖房子用轻钢龙骨前期看着不厚实但你想改格局的时候发现它极其灵活。3. 核心实现SpringBoot 里的关键代码与配置3.1 表单配置的加载与缓存SpringBoot 项目里表单配置是存在数据库里的不可能每次请求都查一遍 MySQL。我的做法是应用启动时把启用的表单配置全部加载进本地缓存内存里用ConcurrentHashMapString, FormConfig存着key 是 form_code。配置修改之后通过一个简单的过期机制或者管理接口主动触发刷新。核心代码长这样Component public class FormConfigCache { private final MapString, FormConfig configMap new ConcurrentHashMap(); Resource private FormConfigMapper formConfigMapper; PostConstruct public void init() { refresh(); } public void refresh() { ListFormConfig all formConfigMapper.selectList( new LambdaQueryWrapperFormConfig() .eq(FormConfig::getStatus, 1) ); MapString, FormConfig newMap new ConcurrentHashMap(); for (FormConfig config : all) { String schemaJson config.getFormSchema(); String flowJson config.getFlowConfig(); config.setFormSchemaJson(schemaJson); config.setFlowConfigJson(flowJson); newMap.put(config.getFormCode(), config); } // 整体替换避免替换过程中读到半新半旧的配置 configMap.clear(); configMap.putAll(newMap); log.info(表单配置加载完成共 {} 条, newMap.size()); } public FormConfig get(String formCode) { return configMap.get(formCode); } }注意几个细节我没有在每次请求时都解析 JSON而是加载时就把字符串解析好放在 FormConfig 对象的成员变量里相当于一次解析、多次使用省掉了重复的JSON.parseObject开销。刷新时先构造新 Map 再整体替换这是为了避免并发情况下有人读到配置一半新一半旧的脏状态。管理后台改配置后调用refresh()方法就行不需要重启 JVM。实测刷新一个表单配置的耗时在毫秒级完全不影响在线业务。这段逻辑是整个引擎的地基地基稳了上面怎么盖楼都安心。3.2 动态校验用配置驱动后端校验表单引擎必须同时做前端校验和后端校验。前端校验是提高体验的后端校验才是保证数据正确的最后一道防线。手写后端校验很简单但我要的是“配置驱动”校验引擎读取 JSON 配置里的校验规则统一执行。我在表单配置里约定了这样的字段定义格式{ field: leaveDays, label: 请假天数, type: number, required: true, rules: [ { validator: min, value: 0.5, message: 请假天数不能小于0.5天 }, { validator: max, value: 30, message: 单次请假不能超过30天 } ] }后端校验服务的实现思路是用 Java 反射加一个校验器注册表把每种 validator 类型映射到对应的校验实现类Component public class FormValidator { private final MapString, ValidatorHandler handlerMap new HashMap(); public FormValidator() { // 注册内置校验器 handlerMap.put(required, (value, params) - value ! null !value.toString().trim().isEmpty()); handlerMap.put(min, (value, params) - { if (value null) return true; double num Double.parseDouble(value.toString()); return num Double.parseDouble(params); }); handlerMap.put(max, (value, params) - { if (value null) return true; double num Double.parseDouble(value.toString()); return num Double.parseDouble(params); }); handlerMap.put(regex, (value, params) - value ! null value.toString().matches(params)); } public ListString validate(String formCode, JsonObject data) { FormConfig config formConfigCache.get(formCode); ListString errors new ArrayList(); JsonArray fields config.getFormSchemaJson().getAsJsonArray(fields); for (JsonElement element : fields) { JsonObject field element.getAsJsonObject(); String fieldName field.get(field).getAsString(); boolean required field.has(required) field.get(required).getAsBoolean(); JsonElement value data.get(fieldName); if (required (value null || value.isJsonNull())) { errors.add(field.get(label).getAsString() 不能为空); continue; } if (field.has(rules)) { for (JsonElement ruleEl : field.getAsJsonArray(rules)) { JsonObject rule ruleEl.getAsJsonObject(); String validator rule.get(validator).getAsString(); String message rule.get(message).getAsString(); String ruleValue rule.has(value) ? rule.get(value).getAsString() : null; ValidatorHandler handler handlerMap.get(validator); if (handler ! null !handler.validate(value, ruleValue)) { errors.add(message); } } } } return errors; } }这段代码的精髓在handlerMap它本质上就是一个“校验策略注册表”。你要加一种新的校验规则比如“手机号格式”做的事情就是写一个 lambda 塞进 Map 里完全不用改引擎主流程。这种“开闭原则”带来的快乐写过程式 CRUD 的人特别有共鸣。注意这里的校验是同步执行的适合字段数量不特别夸张的表单。如果表单有几十上百个字段建议把校验逻辑拆到独立线程池异步跑避免请求线程一直阻塞。3.3 审批流引擎状态机是它的灵魂审批流最简单可靠的实现方式不是上 Flowable 或者 Activiti 这种重量级工作流引擎而是一个“状态机 节点配置”的轻量模型。对于企业内部 90% 的审批场景顺序审批、会签、或签这个模型完全够用而且极其好理解。我把审批流配置定义为这样{ flowCode: leave_flow, startNode: dept_manager, nodes: [ { nodeCode: dept_manager, nodeName: 部门经理审批, approverType: role, approverValue: DEPT_MANAGER, passNode: hr_review, rejectNode: end }, { nodeCode: hr_review, nodeName: 人事复核, approverType: role, approverValue: HR, passNode: end, rejectNode: end } ] }流转的核心逻辑就一个方法推进节点。代码简写如下public FlowResult process(FlowContext context) { FormData formData context.getFormData(); String currentAction context.getAction(); JsonObject flowConfig getFlowConfig(formData.getFormCode()); JsonArray nodes flowConfig.getAsJsonArray(nodes); JsonObject currentNode findNode(nodes, formData.getCurrentNode()); String nextNodeCode; if (REJECT.equals(currentAction)) { nextNodeCode currentNode.get(rejectNode).getAsString(); } else { nextNodeCode currentNode.get(passNode).getAsString(); } if (end.equals(nextNodeCode)) { formData.setStatus(PASS.equals(currentAction) ? 2 : 3); } else { formData.setCurrentNode(nextNodeCode); formData.setStatus(1); } // 记录审批历史 saveFlowInstance(formData.getId(), currentNode.get(nodeCode).getAsString(), currentNode.get(nodeName).getAsString(), context.getOperator(), currentAction, context.getComment()); formDataMapper.updateById(formData); return FlowResult.success(formData.getStatus()); }别看代码简单这套模型足够支撑顺序审批、分支驳回、多级会签把多个审批人配置在同一个节点的 approverValue 里用逗号分隔、甚至条件分支在节点上加一个 condition 表达式引擎根据表单数据计算往哪个方向流转。想复杂了反而容易翻车轻量模型的好处是任何一环出了问题你打开日志就能定位。4. 实操演示5 分钟配置一套“请假审批流”4.1 第一步定义请假表单的 JSON 配置空谈架构都是耍流氓我们直接走一遍完整实操。假设公司需要上线一个请假审批要求员工填写请假类型、开始时间、结束时间、请假天数、事由部门经理审批通过后人事复核任一步驳回都直接结束。我准备的form_schema如下{ fields: [ { field: leaveType, label: 请假类型, type: select, required: true, options: [ { label: 事假, value: PERSONAL }, { label: 病假, value: SICK }, { label: 年假, value: ANNUAL } ] }, { field: startDate, label: 开始日期, type: date, required: true }, { field: endDate, label: 结束日期, type: date, required: true }, { field: leaveDays, label: 请假天数, type: number, required: true, rules: [ { validator: min, value: 0.5, message: 请假天数不能小于0.5天 }, { validator: max, value: 30, message: 单次请假不能超过30天 } ] }, { field: reason, label: 请假事由, type: textarea, required: true, rules: [ { validator: max, value: 500, message: 请假事由不能超过500字 } ] } ] }我没有为每个字段标注 UI 布局因为引擎约定默认渲染顺序就是 fields 数组的顺序。如果你的团队想支持多列布局或者分组渲染可以在 field 上加一个colSpan或者group属性前端组件读到之后做栅格布局。这个扩展成本很低。4.2 第二步配置审批节点接着配置flow_config我上面的示例已经够用。这里多说一句审批人的指定方式我支持三种模式。第一种是“角色模式”approverType: role系统根据当前登录用户的角色去查询符合条件的用户列表适合大多数按组织架构审批的场景。第二种是“指定人模式”approverType: user直接写死用户的账号适合某些特殊节点必须由指定人审批的情况。第三种是“发起人部门负责人模式”approverType: dept_manager这个稍微复杂一点需要调用组织架构接口查找发起人所在部门的负责人。对于这个请假流程我用角色模式就够了。关键是表单配置和流程配置要对应同一个form_code保存进form_config表。你可以写一个初始化接口在项目启动时自动把这两段 JSON 初始化进数据库也可以写一个简单的 SQL 手工插入。我习惯做一个/admin/form/init接口传 formCode 和 JSON 内容接口内部做校验和落库方便测试环境快速造数据。4.3 第三步提交、审批、实测完整流程配置完成后跑起来实测。首先是提交申请调用引擎的统一提交接口curl -X POST http://localhost:8080/api/form/data/submit \ -H Content-Type: application/json \ -d { formCode: leave_form, data: { leaveType: ANNUAL, startDate: 2026-06-01, endDate: 2026-06-03, leaveDays: 3, reason: 回家探亲 }, operator: zhangsan }引擎收到请求后核心流程是找到表单配置 → 校验必填和规则 → 根据流程配置定位开始节点 → 保存业务数据 → 记录审批实例 → 设置状态为审批中。响应里会带上formDataId和currentNode前端页面凭这两个字段就能渲染“当前审批到哪了”。这一步搞定之后模拟部门经理审批curl -X POST http://localhost:8080/api/form/data/approve \ -H Content-Type: application/json \ -d { formDataId: 1001, action: PASS, comment: 同意, operator: lisi }部门经理点“同意”引擎把节点推到hr_review人事再点“同意”流程走到end表单状态变为“通过”。整个过程只花了两分钟而且我全程没有写任何 Java 业务代码——这就是低代码的价值兑现。5. 常见问题与排查技巧实录5.1 前后端 JSON 日期格式不一致反序列化直接报错这个坑几乎每个做 SpringBoot 的开发者都踩过。前端传的是2026-06-01这种字符串后端实体类的字段类型是java.util.Date结果报JSON parse error: Cannot deserialize value of type java.util.Date from String 2026-06-01: not a valid representation解决办法有几种我建议的组合是在 SpringBoot 全局配置里注册一个自定义的日期反序列化器并在统一的时间格式上下文中处理Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { DateTimeFormatter dateFormatter DateTimeFormatter.ofPattern(yyyy-MM-dd); DateTimeFormatter dateTimeFormatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); builder.serializerByType(LocalDate.class, new LocalDateSerializer(dateFormatter)); builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter)); builder.deserializerByType(LocalDate.class, new LocalDateDeserializer(dateFormatter)); builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(dateTimeFormatter)); }; } }我的经验是定义 JSON 表单引擎里的日期字段时直接在配置里带一个format属性比如{field: startDate, type: date, format: yyyy-MM-dd}引擎在提交和展示时统一按这个 format 来读写这样每个表单的日期格式都可以独立定制互不干扰。5.2 前端渲染组件与 JSON 字段类型不匹配另一个高频问题JSON 配置里的type字段前端不认识。比如我配置了type: number但前端自己的组件库只支持input和select两种类型一渲染就报“未知字段类型”。我早期设计时没约束好后来定了约定前端和后端共用同一套字段类型枚举。我把类型的值收缩成五类input普通文本、textarea多行文本、number数字、select下拉、date日期。前端组件库只需要实现这五种渲染组件其余复杂的组件上传、级联选择、富文本通过扩展属性component额外指定而不是挂在 type 上。这样双方的心智负担都小排查问题也容易。5.3 审批并发场景下的状态一致性问题有一次上线后收到反馈同一个单子被两个审批人同时打开了都点了“通过”结果流程跳了两级直接从部门经理跳到了归档。查日志发现是并发请求把current_node覆盖了。解决方式也很经典就是在更新form_data时带上当前状态作为条件int rows formDataMapper.update( new LambdaUpdateWrapperFormData() .eq(FormData::getId, formDataId) .eq(FormData::getCurrentNode, expectedNode) // 关键 .set(FormData::getCurrentNode, nextNodeCode) .set(FormData::getStatus, newStatus) ); if (rows 0) { throw new BusinessException(单据状态已变化请刷新后重试); }返回 0 表示当前状态已经被别人改过此时直接给客户端一个友好提示让用户刷新页面重新获取最新状态。这就是乐观锁的思路不需要引入分布式锁代码少而且效果显著。另外一个和它配套的是审批历史记录的唯一性校验同一个 formDataId nodeCode 不应该产生两条 PENDING 记录可以在数据库里加唯一索引兜底双保险。6. 写在最后这套引擎的边界与扩展方向做这个项目之前我总以为系统复杂是因为业务复杂其实很多时候是我们把简单的东西做复杂了。JSON 表单引擎不是万能的它最适合的是“结构化、规则化、可穷举”的管理类表单如果你的业务里有重计算、复杂关联事务、高频大数据量的场景用它就有点勉为其难。我的建议是把它定位成“中后台系统的加速器”而不是“万能的低代码平台”。在实际落地中有几个经验值得你直接拿走。第一配置里要加version字段每次配置改动版本号递增这样出了线上问题可以快速回滚到上一版。第二一定要保留“物理删除”的审计能力业务数据表只做逻辑删除别配置一改就把老数据搞丢了。第三如果团队前端能力弱可以先用服务端渲染方案用模板引擎基于 JSON 直接生成 HTML照样能跑后面再平滑迁移到 Vue 或 React 组件化方案。如果后续想扩展方向也很清晰加一个“表单设计器”的拖拽页面运营人员自己就能配表单彻底释放开发资源再深一步可以做数据字典和接口映射让字段值能远程联动查询。不过这些都是后话先把核心引擎跑稳让团队从 CRUD 里解放出来就是最大的胜利。按我实测的结果一个熟练的开发掌握了这套引擎之后从需求到上线一个带审批的新表单平均只要半天。剩下的大把时间用来做真正有价值的业务逻辑它不香吗
返回列表