元数据驱动:我让 CRM 新增字段不用改代码、不用发版
去年我决定自己写一个 CRM。原因不复杂:市面上大多数企业级管理系统的"自定义字段"是假的。你在界面上看着是拖拖拽拽,实际上最后还是得提工单给厂商,等他们改代码、发版、重新部署。一个字段走完流程,两周算快的。
我想要的东西很简单:加字段、改表单、配审批流,全程不动一行代码,也不发一次版。这件事最后做成了,过程里踩了不少坑,这里记录一下。
先看传统方案为什么不行
给 CRM 做可自定义字段,常见做法有三种。
| 方案 | 做法 | 问题 |
|---|---|---|
| EAV 模式 | 实体、属性、值三张表,字段当行存 | 查询要疯狂 JOIN,复杂条件写起来很痛苦 |
| JSON 列 | 自定义字段全塞进一个 JSON 大字段 | 没法建索引,过滤、排序、聚合都受限 |
| 硬编码改库 | 每个字段写死在代码和表结构里 | 加字段必须发版,谈不上运行时扩展 |
这三条路我都不满意,走了第四条:把实体和字段本身当成数据,存进数据库。改结构就是改几行元数据,剩下的交给系统同步到物理表。
实体和字段,都是数据
第一步,把"什么是实体、有哪些字段"从代码里搬出来,存进两张表:
meta_entity:实体名、物理表名、主键字段、显示名字段这些
meta_field:字段名、物理列名、字段类型、是不是引用其他实体
一张元数据记录长这样:
json
{
"name": "Customer",
"label": "客户",
"entityCode": 2001,
"idField": "customerId",
"nameField": "customerName",
"table": "T__CUSTOMER",
"fields": [
{
"name": "customerName",
"label": "客户名称",
"column": "F__CUSTOMER_NAME",
"fieldType": "text"
},
{
"name": "ownerUser",
"label": "负责人",
"column": "F__OWNER_USER",
"fieldType": "cite",
"cited": "User"
}
]
}
几个设计细节值得说一下。
第一个,命名分了三层。name 是逻辑名,代码和前端用;column 是物理列名;label 是显示名,用户看。三者彻底解耦。
第二个,物理名带前缀。表统一加 T__,字段加 F__,一眼能看出哪些是系统预置、哪些是用户自定义。
第三个,逻辑名不用用户起。用户填中文标签"客户",系统用拼音转成 customer,用户全程不用管英文命名。
字段类型一共有 17 种:文本、长文本、数字、小数、布尔、日期、单选、多选、引用、多引用、文件、图片、位置、电话、序列号,等等。
新增实体,就是建一张表
核心逻辑在 saveEntity 里:
java
public EntityInfoDTO saveEntity(EntityInfoDTO entityInfoDTO) {
boolean isCreate = entityInfoDTO.getEntityCode() == 0;
if (isCreate) {
// 中文标签转拼音逻辑名,再拼物理表名
entityInfoDTO.setName(PinyinUtil.getPinyin(entityInfoDTO.getLabel(), ""));
entityInfoDTO.setPhysicalName("T__" + entityInfoDTO.getName().toUpperCase());
entityInfoDTO.setEntityCode(getNextEntityCode()); // 分配实体类型编码
}
saveEntityRecord(entityInfoDTO); // 写 meta_entity 表
if (isCreate) {
fieldService.createDefaultSystemMetaField(...); // 注入系统字段
schemaService.createTable(entityInfoDTO); // 真正执行 CREATE TABLE
}
linkerApplication.reloadMetaEntity(entityInfoDTO.getEntityCode()); // 热加载到内存
return entityInfoDTO;
}
createTable 会真的执行 CREATE TABLE,统一建好审计列(创建人、更新人、所属部门、创建更新时间、软删除标记)和索引。这里没有用 EAV,也没有用 JSON 列,建的是货真价实的物理表。
实体类型编码单独说一下。每个实体分配一个 entityCode,系统实体从 1005 开始,用户自定义实体从 2000 起。分配算法是紧凑分配,遍历已用编码找第一个空洞填进去,避免编码一直涨。
建完表之后调一次 reloadMetaEntity(entityCode),把元数据刷进内存缓存。从这一刻起系统就能识别这个新实体了,没有重启服务。
新增字段是同样的路子,ALTER TABLE ADD COLUMN,加完再热加载。字段类型到 SQL 类型的映射是关键:
java
String columnType = switch (fieldType) {
case TEXT, SERIALNUMBER, PHONE -> "VARCHAR(255)";
case TEXTAREA, FILE, IMAGE -> "TEXT";
case RADIO, CITE -> "BIGINT"; // 单选、引用存外键 ID
case CHECKBOX, CITE_LIST -> "VARCHAR(2500)"; // 多值字段存 JSON 数组
case DECIMAL -> "DECIMAL(18,6)";
case BOOL -> "TINYINT";
case DATE, DATETIME -> "DATETIME";
default -> "VARCHAR(50)";
};
// 可过滤的字段自动加索引,序列号字段加唯一索引
多值字段(多选、多引用)这里做了个取舍:没建关联表,直接用 VARCHAR 存 JSON 数组。好处是结构简单,躲开了 EAV 的 JOIN 灾难;代价是查询时要翻译成 MySQL 的 JSON_CONTAINS,这个后面说。
真正的难点在查询
动态 DDL 只是第一关。更麻烦的问题是:字段是运行期才有的,业务代码怎么写查询?你没法写死 getCustomerName(),也没法写死物理表名和列名。
我的办法是自研一套类 SQL DSL。业务代码写逻辑实体名和逻辑字段名,底层用编译器翻译成物理 SQL:
java
// 业务代码里写的(Customer 是逻辑名,不是物理表名)
List<Record> records = linkerDB.records(
"select customerName, &ownerUser from Customer where createTime > TODAY"
);
这套 DSL 背后是一套 ANTLR 编译器,干三件事:把逻辑名翻译成物理名,查询时自动拼上数据权限和软删除过滤,再支持一些原生 SQL 没有的语法糖。
最实用的一个语法是 & 前缀。&ownerUser 表示取这个引用字段的显示值。select * from Customer 查出来 ownerUser 是 1001,但 select &ownerUser from Customer 会多返回一列显示名"张三"。列表页渲染人名、部门名,一次查询就搞定,前端不用再发第二个请求。
还有两个用得上的扩展:
java
// {{{field}}} 位运算:一个 int 字段同时编码多种触发时机
"select * from EventExecutor where sourceEntity = ? and {{{eventTiming}} & 2 > 0} order by priority"
// {{json_contains(...)}}:多值字段的等值查询,翻译成 MySQL 原生 JSON_CONTAINS
"select * from Customer where {json_contains({{tags}}, '重点客户')}"
这套 DSL 大概是我在这个系统里花时间最多的一块。因为元数据驱动能不能落地,最终卡在查询这一环,建表自由了,查询跟不上,整件事就白搭。
这条路也有代价
没有任何架构是免费的。动态 DDL 换来了运行时扩展的自由,代价也摆在那。
ALTER TABLE 会锁表,大表加字段可能卡住线上,所以这套方案更适合字段变更没那么频繁、单表数据量可控的场景。自研 DSL 有学习成本,新人要熟悉扩展语法,出问题调试也比标准 ORM 费劲。还有个安全上的坑:字段是动态拼进 SQL 的,字段名必须严格校验,值更要走参数化,这块最容易埋雷。
我写这些不是为了劝退,而是想说清楚这套方案不是万能药。它适合"每个客户都要改一点业务逻辑"的企业管理系统,不适合字段固定、高并发的互联网业务。如果你面对的是前者,元数据驱动的收益会远大于它的代价。
这套引擎我做成了产品
上面说的就是 Linker 的核心引擎,一个元数据驱动的企业级管理系统。除了动态字段,还有可视化表单布局、配置化的审批流、不用写代码的事件执行器,以及把元数据能力暴露给 AI 的 MCP 工具层。
如果你也在做类似的动态系统,或者想找一个不用等厂商改代码的 CRM,欢迎在评论区聊。