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

相关推荐
小酒星小杜4 天前
【AI+Gpt-Image2.5】我偷偷把同事做成了虚拟角色,结果被发现了
人工智能·程序员·产品
Wenkil4 天前
当 PRD 里只有一句“支持语音识别”:我如何用 AI 把它补成可开发规格
产品
乱码三千5 天前
关于让我的 AI 智能体去当三陪帮我打工赚钱这件事,需要从长计议
人工智能·开源·产品
小哈里6 天前
【知识】从学科到实践:自然・工程・社科・人文|算法・研发・产品・品牌设计
程序人生·算法·产品·设计·工程
爱勇宝9 天前
《道德经》第 11 章:真正有用的,常常是你没写出来的那部分
前端·后端·产品
孟陬10 天前
对话流量也能变现?Circeus 收购 Dondy 加速布局,嵌入全渠道场景推送购物车、发货提醒;逐步实现下单、退款自主闭环,全靠聊天指令驱动
产品·投资·资讯
知了一笑12 天前
职场丨岗位减少,职责增加
大数据·人工智能·职场·产品·业务
码农胖大海13 天前
我的第一个产品,只有一段提示词
前端·ai编程·产品
星期一研究室13 天前
豆包工作+飞书,打开“松弛工作”的100种方式
产品·豆包marscode