元数据驱动:我让 CRM 新增字段不用改代码、不用发版

元数据驱动:我让 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,欢迎在评论区聊。

相关推荐
阿宸科普10 小时前
液冷机组冷却设备,接触器怎么选?三大系列参数与工程校核要点
产品
洞察万事局10 小时前
液冷机组冷却设备,接触器怎么选?一个电气设计的一线记录
产品
Mahut11 小时前
我在 Mac 上做了个本地剪辑器
javascript·架构·产品
colorknight14 小时前
FDE:深入客户,走向产品
产品
Nturmoils1 天前
有了 Parallels Desktop,我终于不用问别人借Windows电脑用了
产品
Jucai_in_AI2 天前
全球化培训平台多语言多时区引擎技术实现:自动语言探测、语种动态管理与本地化渲染方案
架构·产品
掘金012 天前
0 元把联想平板变成 MacBook 副屏:真扩展、支持触控、纯开源
程序员·产品
fxss4 天前
教孩子认字,一个字能讲出一整幅画|宝宝学识屋·学中文
产品
想要打 Acm 的小周同学呀9 天前
一些关于产品的思考-1
产品运营·产品
用户8311161846111 天前
光催化氧化循环水设备实景净化效果展示
产品