“重生之我来写低代码后端”,第一次看到这个标题我笑出声,因为它太像真实发生过的场面了——你一定接手过这样的工程:老板说要做一个低代码平台,三个月出第一版,半年后这套低代码后端的代码组织已经烂到没人敢动。点开目录,一个service包下面躺着两百多个类,其中一半叫XxxFormServiceImpl,另一半叫XxxHandlerServiceImpl,还有一个两千行的MetadataServiceImpl谁都不敢碰。你打开它想改一个校验逻辑,发现同样的逻辑在七个地方各写了一遍,而且这七份还不完全一样。
我写这篇不是想教你怎么从零造一个低代码平台,这个话题太大,也不缺资料。我想聊的是更窄、也更致命的一件事:低代码后端的代码到底该怎么组织。这件事决定了你的平台是做三个月就报废,还是能撑三年。适合正在做或者即将接手低代码平台的兄弟看,哪怕你只在里面负责一个模块,看完也能明白自己那部分代码该摆在哪。
先给一个贯穿全文的判断:低代码后端的代码量,应该和“平台能力”的数量成正比,而不是和“业务单据”的数量成正比。这句话如果你记住了,后面很多设计选择你自己就能推出来。
1. 低代码后端最先崩掉的地方,从来不是性能
1.1 为什么传统三层结构和低代码天生不对付
传统 CRUD 项目里,一个业务实体对应一套 Controller、Service、Mapper,天经地义。请假单有请假单的 Service,报销单有报销单的 Service,各自处理各自的字段校验、流转逻辑、数据落库。这种组织方式的前提是“每个业务的代码是独立演化的”,所以按业务建类是对的。
低代码平台把这个前提直接砸了。平台里可能有三千张表单,但你没有三千套代码。请假单和报销单在平台眼里是同一类东西:一个带字段定义、带校验规则、带流转策略的业务对象。它们的差异被下沉到了元数据里,而不是代码里。这时候如果你还按“一个业务一个 Service”来组织,会发生什么事?你会写出三千个类,每个类里 90% 的代码是重复的,剩下 10% 的差异还得靠复制粘贴。等哪天要改一个通用逻辑——比如金额字段的精度处理——你得改三千处,改完还得回归测试三千遍。
更麻烦的是,这三千个类会互相引用。请假单的 Service 里可能会调用报销单的工具方法,因为它们“都是表单”嘛。半年后你想抽一个公共能力出来,发现依赖关系已经织成了一张网,动一下全身疼。
所以真正的判断标准是:新增一张业务表单,应该只新增元数据,不新增代码。如果你发现自己在新表单上线时又写了一堆类,那说明你的平台抽象没做到位,或者你把本该属于元数据的东西硬编码成了代码。这个标准听起来苛刻,但它是唯一能撑住规模的标准。
1.2 把系统拆成“元数据 / 解释器 / 运行时”三条主线
我见过组织得比较健康的低代码后端,基本都遵循同一个切法:把系统分成三条主线,而不是按业务分。
元数据主线负责“谁定义了什么”。表单长什么样、字段什么类型、校验规则有哪些、流程节点怎么走、哪个角色能看哪个字段,这些东西全部以定义的形式存在,有独立的生命周期——创建、修改、发布、下线。这条主线的代码只关心定义的存储、校验、版本和权限,它不知道运行时是怎么执行这些定义的。
解释器主线负责“把定义翻译成能跑的东西”。它是编译期角色,把一堆 JSON 或表结构里的定义,解析、校验、合并、优化,最终产出一个不可变的运行时快照。表达式在这里预编译,字段映射在这里预先算好,查询条件在这里预先拼装。
运行时主线负责“真的跑起来”。请求进来,拿快照,绑参数,执行,返回。它不解析元数据,不碰定义表,只跟快照打交道。
这里的关键不是分三条线,而是依赖方向:运行时依赖解释器产物,解释器产物依赖元数据,元数据谁都不依赖。这个方向一旦反过来,比如元数据模块里为了“方便”去调用运行时的方法做预览,整个架构就开始塌了。
我用变化频率解释一下为什么要这么切,因为这是我判断分层是否合理的核心依据:
| 主线 | 变化频率 | 谁在改 | 出 bug 的影响面 |
|---|---|---|---|
| 元数据模型 | 低,半年可能才动一次结构 | 平台核心团队 | 全平台,灾难级 |
| 解释器/编译期 | 中,一到两个月一次 | 平台核心团队 | 大部分表单,严重 |
| 运行时 | 高,每周都在改 | 平台团队 + 插件 | 局部,可控 |
变化频率差异这么大的东西混在一个模块里,结果就是高频改动的代码不停地触碰低频稳定的代码,每次改动都在给核心结构埋雷。分开之后,改运行时的同学根本不用看元数据长什么样,他只知道快照接口提供了什么。
1.3 “低代码”不是“无代码”,边界必须显式画出来
有个误区我得提前说:很多人做低代码平台,目标定成“让业务人员自己配”,于是拼命把所有逻辑都塞进配置项。结果配置界面复杂得像写代码,元数据模型复杂得像编译器,最后业务人员配不明白,开发人员改不动,两头不讨好。
健康的做法是承认边界:通用能力走元数据配置,个性化能力走扩展点写代码。这两条路必须有清晰的划分,而且划分标准要写进团队的规范里。比如:字段级校验、可见性、默认值、简单联动,这些走配置;跨表单的业务逻辑、外部系统调用、复杂计算,这些走扩展点。
这个边界画在哪,直接决定了你的代码组织形态。如果边界模糊,代码里就会出现大量“既是配置又是代码”的中间态——比如一个if判断某个配置项是否为真,为真就执行一段硬编码逻辑。这种东西攒多了,你的平台就变成了一个巨大的开关面板,谁也不敢关任何一个开关。
提醒一句:边界不是一次定死的,但要有个原则——凡是能在不写代码的情况下解决的问题,就别开口子;一旦开口子,就必须做成正式的扩展点,有契约、有文档、有测试,不能是“临时先这么写”。
2. 目录结构本质上是依赖方向的物理表达
2.1 一套能扛住长期迭代的分层骨架
很多人以为目录结构是审美问题,随便摆摆就行。不是的。目录结构是你依赖方向的物理表达,包之间的引用关系如果和你脑子里的架构不一致,那么总有一天你会为了让代码跑起来而破坏架构。
我比较推荐的低代码后端骨架大概是这个样子(Java 生态举例,其他语言对应翻译即可):
platform/ ├── platform-api/ 对外契约:DTO、错误码、扩展点接口、注解 ├── platform-metadata/ 元数据模型、校验、存储、版本、发布 ├── platform-compiler/ 定义 -> 运行时快照的编译与合并 ├── platform-runtime/ 请求处理、数据读写、权限判定、规则执行 ├── platform-plugin-api/ 插件契约,只允许放接口和注解 ├── platform-plugins/ 内置插件实现,按能力分包 └── platform-bootstrap/ 启动装配、配置、依赖注入入口这个结构里,每一个模块的存在都有它非存在不可的理由,不是凑数:
platform-api单独拆出来的价值在于,它是所有跨模块调用的唯一出口。运行时要调权限,调的是 api 里的PermissionResolver接口;插件要回调平台,调的也是 api 里的接口。这样插件永远不依赖 runtime 的内部类,runtime 重构内部实现时插件不会跟着崩。这一条我吃过亏,后面第 5 章会细说。
platform-metadata不依赖任何其他业务模块,它只依赖存储和序列化。它是整个系统里最稳的部分,也是最该被严格测试的部分。这个模块的代码行数应该被控制住,一旦它开始膨胀,说明你把本该放编译期的东西塞进了元数据层。
platform-compiler是整个平台的心脏,也是最容易被忽视的模块。很多团队把它和元数据合并成一个模块,结果编译逻辑和存储逻辑纠缠在一起,测试根本没法写。
platform-runtime是改动最频繁的模块,所以要尽量让它薄。一个健康的状态是:runtime 里几乎没有if判断“这个字段是什么类型”,因为类型判断应该在编译期就完成,运行期拿到的快照已经是处理好的。
platform-plugin-api必须独立,哪怕它只有五个接口。因为这个模块的兼容性要求最高,一旦发布就不能随便改,独立出来才能强制你谨慎。
2.2 用模块边界把循环依赖挡在编译期
循环依赖是低代码后端最常见的死法,而且它出现的方式往往很“合理”。
举个典型场景:权限判定需要用到表达式引擎,因为权限规则允许写“金额大于一万且部门等于本人部门”这种条件;而表达式引擎在求值时又需要读当前用户的权限上下文,判断某个字段能不能读。于是权限模块依赖表达式模块,表达式模块依赖权限模块,循环了。
在同一个模块里,这种循环编译期根本发现不了,因为 Java 允许类之间互相引用。等到运行的时候,你可能会遇到初始化顺序问题、NPE、或者更隐蔽的“某些场景下权限判断失效”。
解决办法不是靠自觉,是靠模块边界强制。把“回调”定义成接口放在platform-api层,两个实现模块都只依赖 api,由platform-bootstrap在启动时装配。这样编译期依赖是单向的,循环在物理上就不存在了。
再举一个更常见的:runtime 需要调用某个插件的能力,插件又需要读 runtime 的数据访问对象。这种时候如果插件直接依赖 runtime 的DataAccessor类,你就锁死了 runtime 的实现。正确做法是在 plugin-api 里定义一个精简的DataReader接口,runtime 去实现它。插件只看到接口,runtime 想怎么改内部实现都行。
为了让这条规则不被破坏,我强烈建议加一个依赖检查测试。用 ArchUnit 或者自己写个简单的包扫描,把规则写成断言:
@ArchTest static final ArchRule metadata_should_not_depend_on_runtime = noClasses().that().resideInAPackage("..platform.metadata..") .should().dependOnClassesThat() .resideInAPackage("..platform.runtime..");这种测试跑在 CI 里,谁不小心引了不该引的包,构建直接红。比在代码评审里靠人眼盯有效一百倍,因为人眼总会漏,而构建不会。
常见的循环依赖场景和拆法,我列个表:
| 循环场景 | 表象 | 拆解方式 |
|---|---|---|
| 权限 ↔ 表达式 | 两边都要读对方的上下文 | 上下文抽成EvaluationContext放 api 层 |
| 运行时 ↔ 插件 | 插件回调平台能力 | plugin-api 定义最小接口,运行时实现 |
| 元数据 ↔ 编译期 | 元数据里想预览编译结果 | 预览走独立服务,不进元数据模块 |
| 流程 ↔ 表单 | 流程节点要读表单字段,表单提交要触发流程 | 抽“业务对象读写”接口到 api |
这张表里的每一条我都在真实工程里见过,而且每一条都不是“设计失误”,都是当时为了赶进度做的“小妥协”。妥协本身没问题,问题是没人回头还债。
3. 元数据模型切分:别让“万能对象”长成一张巨表
3.1 从“表单字段”思维切换到“业务能力”思维
元数据建模这块,最常见的错误设计是一张万能表:
CREATE TABLE t_metadata ( id BIGINT, type VARCHAR(32), -- form / field / rule / flow ... content JSON, tenant_id BIGINT );刚上手的时候这个设计爽得不行,什么都能存,加一种新元数据不用改表结构。但你用三个月就会发现几个问题全来了:想按“某个字段被哪些表单引用”查询,得把 JSON 全捞出来在内存里遍历;想给元数据加索引,加不了;想做权限控制,粒度只能到整条记录;想做审计,只能记谁改了哪一行,改了什么根本看不出来。
我的建议是按聚合来切,而不是按“能不能存”来切。判断一个对象该不该独立成聚合,看三条:它会不会被独立引用、会不会被独立查询、会不会被独立授权。三条里中两条以上,就该独立。
具体到表单这个场景:FormDefinition是聚合根,FieldDefinition是聚合内部的实体,一般不独立成表,序列化在表单聚合里存着就行。因为字段很少被跨表单独立查询,它的生命周期完全依附于表单。而RuleDefinition(校验规则、联动规则)如果允许跨表单复用,就该独立成聚合,因为它会被多处引用。
| 对象 | 是否独立聚合 | 存储方式 | 原因 |
|---|---|---|---|
| 表单定义 | 是 | 主表 + 版本表 | 独立生命周期、独立授权 |
| 字段定义 | 否 | 内嵌 JSON | 依附表单,无独立引用 |
| 校验规则 | 视情况 | 可复用则独立表 | 跨表单引用时才独立 |
| 页面布局 | 是 | 独立表 | 独立编辑、独立发布 |
| 数据源配置 | 是 | 独立表 | 有连接信息,需独立加密 |
再说一个更隐蔽的坑:不要把“业务数据”和“元数据”混在一张表里。有些平台为了省事,把表单实例数据也存成 JSON 放在元数据表旁边,觉得“都是 JSON 嘛”。结果就是元数据查询和数据查询互相拖累,数据量一上来,改个字段定义都卡。这两类数据的生命周期、访问模式、性能要求完全不同,必须物理隔离。
3.2 版本、继承与租户覆盖的组织方式
低代码平台几乎必然要处理“三层覆盖”:平台内置的模板 → 租户的定制 → 用户的个人调整。比如一个标准请假单,A 公司改了两个字段的必填性,B 公司的某个员工又自己调整了列表列宽。
很多人第一反应是用继承:TenantFormDefinition extends BaseFormDefinition,然后在子类里覆盖。这个思路在代码里成立,在数据里会崩,因为覆盖的粒度是字段级的,而继承的粒度是对象级的。你没法让一个对象“继承父对象,但只覆盖第 3 个字段的必填性”。
正确做法是分层合并:三层定义各自独立存储,编译期按 key 逐层合并,生成一份最终快照。
合并策略要提前定好,不然会出各种诡异问题:
- 标量属性用“后者覆盖前者”,但要有明确的“未设置”与“显式设置为空”的区分。用
null表示未设置,用特殊标记(比如tombstone: true)表示显式删除。 - 集合属性(比如字段列表)按业务 key 匹配,不能按下标合并。按下标合并的话,模板里插一个字段,所有租户的定制全错位。
- 删除用显式的 tombstone 记录,不要靠“合并后不存在”来表达。否则租户想恢复一个被删的字段,你没法知道它是被删了还是从来就没有。
版本这块我想多说一句,因为它是低代码平台稳定性的关键。发布动作应该产出不可变快照,而不是原地改定义。用户正在填的表单,不能因为管理员改了定义就突然变了字段,那会出数据事故。做法是:定义表有版本号,发布时把当前所有层的定义合并、编译,产出一个带版本号的快照,运行期直接按快照版本执行。新请求进来拿最新快照,老请求继续用老快照跑完。
这套机制还能顺便解决“灰度发布”的需求:租户 A 用新版本快照,租户 B 继续用老版本,改出问题只影响一部分人,回滚也只需要把指针指回去。
4. 解释执行层:把万能引擎拆成可测试的小零件
4.1 编译期与运行期的职责切割
我见过最拖性能的低代码后端,是在请求链路上现场解析元数据:每个请求进来,去数据库读定义 JSON,JSON.parse一遍,遍历字段做类型判断,拼 SQL,执行。一个请求光解析就花 30 毫秒,QPS 一上去数据库直接被打穿。
这个问题的本质不是性能优化没做,而是职责放错了位置。解析元数据是低频动作,执行请求是高频动作,把低频动作塞进高频路径,怎么优化都是治标。
正确的切割是:编译期做重活,运行期只做轻活。
编译期负责:读三层定义、校验合法性、合并、把表达式编译成 AST、把字段映射预计算好、把查询条件模板预生成、产出一个不可变的快照对象。运行期负责:从缓存取快照、绑参数、执行、返回。运行期不做任何字符串解析、不做任何类型判断、不碰定义表。
快照对象的设计有几个要点,我用一段代码示意:
public final class FormSnapshot { private final String formId; private final long version; private final List<FieldSnapshot> fields; // 已按渲染顺序排好 private final Map<String, FieldSnapshot> fieldIndex; // 按 key 索引,O(1) private final Map<String, CompiledRule> rules; // 表达式已编译成 AST private final CompiledQuery query; // 查询片段已生成 private final PermissionPlan permissionPlan; // 权限判定计划已展开 }这里最重要的是final。快照是不可变的,因为它要被多线程共享、被缓存、被长期持有。一旦快照里有可变字段,你就得考虑并发问题,而缓存里的可变对象是灾难的开始——A 线程改了它,B 线程读到的就是脏数据,而且这种 bug 极难复现。
缓存 key 的设计也要注意,至少要包含:租户、表单标识、版本号、环境标识。少一个都可能串数据。我见过一个事故是缓存 key 忘了带租户,结果 A 公司的表单配置被 B 公司读到了,虽然字段名一样,但校验规则不一样,导致 B 公司的数据大批量入库失败。
4.2 表达式求值和策略注册表替代 if 链
低代码后端里最典型的坏味道,是那种越写越长的类型判断:
if ("text".equals(field.getType())) { // ... } else if ("number".equals(field.getType())) { // ... } else if ("date".equals(field.getType())) { // ... }这段代码刚写的时候只有三种类型,半年后变成二十种,再过一年变成四十种,每次加类型都要回来改这个if链,而且这个链可能在五六个地方各有一份。改漏一处就是线上 bug。
解决办法是策略注册表。定义一个处理接口,每种类型一个实现,注册到表里,运行时按类型查表:
public interface FieldTypeHandler { String type(); Object normalize(FieldSnapshot field, Object raw); List<ValidationError> validate(FieldSnapshot field, Object value); String toSqlType(FieldSnapshot field); }注册可以用依赖注入容器收集所有实现,也可以用 SPI 扫描。关键是:新增一种字段类型,等于新增一个类加一行注册,不改任何已有代码。在低代码这个领域,开闭原则不是教科书上的教条,是刚需,因为字段类型一定会持续增长,而且增长的需求往往来自客户,你拦不住。
表达式引擎这块我要单独提醒。很多平台为了“灵活”,直接让用户写脚本,后端用脚本引擎eval。这条路看起来最省事,实际上是给自己埋了三颗雷:
第一颗是安全。用户写的脚本能访问运行时上下文,稍不注意就能读到不该读的对象、调用不该调的方法。 第二颗是性能。脚本每次执行都要解析,哪怕有缓存,也比预编译的 AST 慢一个数量级。 第三颗是可控性。脚本里出了错,报错信息往往指向引擎内部,业务人员完全看不懂,最后全变成你的工单。
我的建议是自己实现一个受限的表达式语言:只支持字段引用、常量、有限的运算符、白名单函数。编译期解析成 AST,运行期只做求值。函数白名单严格控制,需要新函数就显式注册。这样做前期多花两周,后期省两百个工单。
5. 扩展点设计:低代码平台唯一能长期活着的原因
5.1 扩展点颗粒度的判断标准
扩展点是低代码平台的命门。没有扩展点,平台只能做demo级的简单场景;扩展点设计得烂,平台会被插件拖垮。
先说颗粒度怎么定。我见过两种极端:一种是扩展点太粗,比如只给一个beforeSave,参数是原始请求,你想干啥都行。这种扩展点等于没有约束,每个插件都在里面重写一遍保存逻辑,最后平台的“一致性”完全丧失,你根本不知道数据是怎么进库的。另一种是太细,每个字段的解析、每个校验的每个阶段都给钩子,结果一个保存请求触发两百次插件调用,性能直接崩。
我的判断标准是:按业务语义边界切,而不是按代码执行顺序切。换句话说,扩展点的名字应该是一个业务动作,不是一个技术步骤。beforeSave、afterQuery、resolveFieldValue、checkPermission这些是业务语义;beforeFieldParseStep2这种就是技术细节泄漏。
按这个标准,一个够用的扩展点集合大概长这样:
| 扩展点 | 触发时机 | 能否中断 | 典型用途 |
|---|---|---|---|
beforeSave | 数据落库前 | 能 | 跨表校验、补全字段 |
afterSave | 落库成功后 | 不能 | 发通知、写日志、触发外部同步 |
beforeQuery | 查询条件构建后 | 能 | 追加数据权限条件 |
resolveFieldValue | 字段取值时 | 能 | 计算字段、关联带出 |
checkPermission | 权限判定时 | 能 | 自定义授权规则 |
customAction | 自定义按钮点击 | 能 | 完全自定义的业务动作 |
这张表里的扩展点,第一版发布之后就是契约。改签名、改语义、改调用时机,都属于破坏性变更。所以第一版宁可少列几个,也不要为了“以后可能用得上”先占坑。占坑的成本极高,因为一旦有人在插件里依赖了这个钩子,你就再也删不掉了。
还有一个细节:扩展点必须有超时和异常隔离。一个第三方插件写得烂,死循环了,不能把整个平台的保存接口拖死。做法是给钩子调用加超时,异常吞掉但记录完整上下文,然后按配置决定是降级继续还是中断。
5.2 插件加载与热更新的现实约束
插件机制这块,我得说点泼冷水的话,因为宣传和现实差距太大。
元数据可以热更新,插件代码基本不能。
元数据热更新好做,因为快照是不可变对象,发布新版就换指针,旧请求跑完自然释放,中间不需要停服务。这套机制我在前面 3.2 说过,实现成本可控。
插件热更新是另一回事。JVM 上要做类的热替换,需要自定义类加载器、需要处理类卸载、需要解决依赖冲突。而且插件一旦持有静态变量、开了线程、注册了监听器,卸载时资源根本释放不干净。你换了一版插件,旧版本的类还在内存里,线程还在跑,最后就是内存泄漏加行为诡异。
所以现实的做法是分级:
| 变更类型 | 能否热生效 | 实现方式 |
|---|---|---|
| 元数据定义 | 能 | 快照替换 + 缓存失效 |
| 插件配置参数 | 能 | 配置中心推送 + 监听刷新 |
| 插件业务代码 | 不能 | 滚动重启、灰度实例 |
| 插件依赖库 | 不能 | 版本化打包、重启加载 |
插件打包这块有个坑要提前防:依赖冲突。两个插件分别依赖同一个库的不同版本,放在同一个类加载器里必然打架。做法是每个插件独立类加载器,平台自己的类通过父加载器共享,插件之间的类互相隔离。代价是插件之间不能直接调方法,必须通过 api 层的接口通信——这其实是好事,强制了解耦。
再一个坑是插件版本的兼容性。插件依赖 api 模块,api 一升级,老插件可能跑不起来。所以 api 模块必须严格遵循语义化版本,加接口可以,改接口不行,删接口更不行。删接口这种操作,正确做法是标记废弃,保留至少两个大版本,同时在启动时对使用废弃接口的插件给出告警。
6. 几个我反复踩、也看别人反复踩的坑
6.1 元数据缓存导致的“改了不生效”
这个问题的表象特别有迷惑性:运营在后台改了表单的一个校验规则,前端刷新,规则还是旧的。重启服务就好了。于是大家的第一反应是“缓存没清”,然后加了一句手动清缓存的代码,问题暂时压下去了,过两周又复发。
排查这类问题,我总结了一条固定的链路,从后往前查,别跳步:
第一步,查数据库里的定义版本号有没有变。这一步排除“保存根本没成功”的可能,我遇到过好几次是保存接口静默失败,前端没报错,实际根本没落库。
第二步,查发布动作有没有真的产生新快照。有时候是发布了,但编译失败被吞了异常,快照指针还指向旧版本。
第三步,查分布式缓存的 key。低代码平台通常有本地缓存加集中缓存两层,key 里如果少带了版本号,旧值会一直命中。这里最容易出错的是“缓存 key 用表单 id,没用版本号”,因为表单 id 是不变的。
第四步,查本地缓存。多实例部署时,发布动作只通知了部分实例,其他实例的本地缓存还在。解决办法是发布时广播失效消息,或者给本地缓存设一个很短的 TTL 兜底,比如 10 秒。
第五步,查编译期缓存。有些平台会把编译结果缓存到文件或者独立服务里,这一层最容易被忘掉。
这套链路跑下来,基本能定位。我后来在项目里干脆加了个诊断接口,输入表单 id,返回当前所有缓存层的版本号,一眼就能看出哪层没更新。这个小工具花了两小时写,省下来的排查时间大概有几十小时。
6.2 多人协作下的元数据冲突
这个问题在平台用户变多之后一定会出现:两个管理员同时编辑同一个表单,一个改了字段 A 的必填,一个改了字段 B 的选项,后保存的把前面的覆盖了。用户投诉“我改的东西不见了”,你去查日志,确实两条保存记录都在,但最终状态只保留了后一条。
解决思路有两个方向,选哪个取决于团队规模和使用强度。
悲观锁方案:打开设计器时锁定表单,其他人只能看不能改,或者进入排队。实现简单,逻辑清晰,问题是没有并发编辑能力,大团队里体验差。但对大多数中等规模的使用场景,这个方案完全够用,而且它避免了所有合并冲突的复杂度。
乐观锁加字段级 diff:保存时带版本号,冲突则拒绝;同时在服务端做字段级差异计算,把两个人的改动尝试合并,冲突的字段单独提示。这个方案体验好,但实现成本高得多——你得定义清楚“字段级合并”的语义,集合类型怎么合,删除和修改怎么区分,合并失败怎么回滚。
我的建议是小团队先上悲观锁,等真的出现排队影响了效率,再考虑第二个方案。别一上来就做字段级合并,那个坑很深,尤其是集合类型(比如字段列表)的合并,边界情况极多,写完你会发现测试用例比实现代码还长。
6.3 代码生成物与手写代码的边界
最后一件事,是关于“低代码平台到底解不解释执行”这个根本选择。有些平台走的是代码生成路线:根据元数据生成一套 CRUD 代码,编译打包部署。这条路的好处是性能好、调试方便,坏处是重新生成会覆盖手写代码。
如果你选了这条路,第一天就必须把规则定死,因为后期改成本极高。我的做法是三条:
第一,生成物单独放一个目录,每个文件头部打上明确的标记,CI 里加检查,有人在生成目录里手改直接构建失败。这条规则不近人情,但必须守,否则下次生成全没了。
第二,用“生成骨架加扩展类”的模式。生成器只生成抽象基类和骨架,具体的业务实现放在一个单独命名的扩展类里,生成器发现扩展类已存在就跳过不覆盖。这个约定需要生成器配合实现,但实现起来不难。
第三,生成和手写的边界要能讲清楚。任何一个人看代码,都能立刻判断这个文件是“机器生成的,不要动”还是“人写的,可以改”。判断不了,说明目录规划有问题。
如果你选的是解释执行路线,那就没有这个问题,但你要接受运行时的性能开销和调试难度。这两条路没有绝对的对错,关键是一旦选定就别摇,更不要两条路各走一半——我见过一个平台,一部分表单走生成,一部分走解释,结果两套逻辑对不上,同一个校验在两条路径里行为不一样,查了三天才找到原因。
现在我看一个低代码后端工程,第一件事不是看它的功能列表有多长,而是打开 runtime 那个包,看里面有没有出现具体的业务名字。如果里面躺着LeaveFormService、ExpenseFormValidator这种东西,我就知道这个平台迟早要重构——因为它把业务写进了平台,而业务是会变的,平台不应该跟着变。
第二件事是看快照对象是不是不可变的。这事看着小,实际上反映了作者有没有想清楚“编译期和运行期”的边界。一个在运行时还在解析元数据的平台,性能问题只是表象,真正的问题是它压根没分层。
第三件事是看扩展点接口发布多久了,改过几次。如果一个扩展点接口三个月改了四版,说明设计的时候没想清楚,后面每加一个客户就改一次,插件方会被拖死。
这三件事花不了十分钟,但能看出一个平台的组织水平。代码组织这事,从来不是写给人看的整洁,而是给未来的自己留的退路。