一个页面支持自定义字段,后端该怎么设计数据库?

29 阅读16分钟

最近在实习公司做了一个新需求。

页面里本身有两个固定字段,但除此之外,还要求支持根据实际需要,自定义新增字段。
而且这些新增字段不是一次性的,后续还可能继续扩展。

这个需求一开始看起来像是普通的增删改查,但真正做的时候会发现,核心难点其实不是 CRUD,而是下面这个问题:

当页面既有固定字段,又有动态字段时,如何在不频繁修改数据库表结构的前提下,把这套功能做出来?

如果按照最直接的思路做,每新增一个字段,就去改一次数据库表结构。
这种做法短期是快,但长期问题会越来越明显:

  • 表结构会被越改越乱
  • 实体类要跟着不断新增属性
  • 前后端都要频繁改动
  • 扩展性和通用性都很差

所以这次我最后采用的方案是:三张表拆分固定字段、字段配置和字段值。

这篇文章就把这次实现的整体思路、表设计、增删改查过程,以及一些容易忽略的细节,完整整理一下。


一、需求本质:固定字段稳定,动态字段可扩展

先说一下这个需求的本质。

页面中有两类数据:

一种是固定字段。
比如“机组编码”“机组名称”这种,业务上长期存在,不会轻易变化。

另一种是动态字段。
这些字段不是固定写死的,而是可能根据业务需要新增,比如:

  • 额定功率
  • 厂家
  • 投运日期
  • 备注信息

这类字段的特点是:

  • 数量不固定
  • 后续可能继续扩展
  • 不适合每次都往主表里加列

所以这里就有一个关键思路:

固定字段和动态字段,不应该混在同一张表里处理。

固定字段应该继续留在主表中。
动态字段则要单独拆出来管理。


二、整体设计:三张表拆分职责

为了实现这个需求,我最后采用了三张表的设计方式。


1. 主表 t_unit

主表只负责存固定字段。

也就是说,页面里那两个固定字段,就正常存在这张表中。
这张表不去管动态新增的字段。

这样做的好处很明显:

  • 主表结构稳定
  • 不会因为动态需求不断改表
  • 固定字段的查询和维护依旧简单

一句话概括就是:

固定字段进主表,动态字段不进主表。


2. 字段配置表 t_fieldConfig

这张表负责记录:当前业务下,定义了哪些动态字段。

注意,它不存具体值,只存字段定义信息。

主要字段包括:

  • business_type:区分属于哪张业务表的字段,考虑通用性
  • fieldCode:字段编码,也可以理解为字段名
  • fieldName:字段中文名,给前端展示用

这张表的作用就是:

前端如果想知道当前页面有哪些可配置字段,可以直接查这张表。

这样字段定义就不需要写死在前端,也不需要写死在后端。


3. 字段值表 t_fieldValue

这张表负责存动态字段的具体值。

主要字段包括:

  • relationId:关联主表 ID,表示这条值属于哪一行
  • fieldCode:表示这条值属于哪一个动态字段
  • fieldValue:字段的具体值

其实这张表可以理解成一个“坐标表”。

因为:

  • relationId 决定这一行是谁
  • fieldCode 决定这一列是什么字段
  • fieldValue 就是这个坐标下实际存的内容

所以从设计角度看,它很像这样一组关系:

(relationId, fieldCode) -> fieldValue

这个思路一旦想清楚,整个动态字段模型就会很顺。


三、为什么这套方案比“直接改表”更合适

如果把所有动态字段都塞到主表里,会有几个明显问题:

第一,字段会越来越多。
页面今天加一个字段,明天又加一个字段,主表会不断膨胀。

第二,很多字段长期为空。
不同业务场景需要的字段不一样,结果主表会充满大量空列。

第三,每次新增字段都要改数据库、改实体类、改接口。
这种方案在长期维护时会非常难受。

而拆成三张表之后,结构会清晰很多:

  • 主表负责稳定字段
  • 配置表负责字段定义
  • 值表负责字段内容

这就让“字段定义”和“字段取值”解耦了。

再加上 business_type 这个字段以后,这套方案甚至不只可以服务 t_unit,理论上还可以扩展到别的业务表。

也就是说,这次做的其实不只是一个页面功能,而是一套可以复用的动态字段设计思路。


四、接口层怎么接收和返回动态字段

表设计清楚之后,下一步就是接口层的数据结构设计。

我这里的做法是在 Vo 层增加一个字段:

private Map<String, Object> dynamicFields;

这样做的原因很直接。

因为前端传过来的动态字段,本质上就是一个天然适合 key-value 表示的结构。
例如:

{
  "unitCode": "U001",
  "unitName": "1号机组",
  "dynamicFields": {
    "ratedPower": "300MW",
    "manufacturer": "东方电气",
    "installDate": "2024-01-01"
  }
}

这里:

  • key 对应 fieldCode
  • value 对应具体字段值

这样后端在处理时就不需要为每个动态字段单独写属性,也不需要频繁修改 Vo

而前端如果需要字段中文名,则可以单独去查询 t_fieldConfig
因为字段名本身属于“配置”,不属于“值”。

所以这里职责就很清晰:

  • t_fieldConfig 负责字段定义
  • t_fieldValue 负责字段值
  • dynamicFields 负责接口层收发

五、实现前先把思路理顺:真正要操作的是哪几张表

这类需求一旦开始写代码,很容易写着写着就乱掉。

我后来觉得最重要的一步其实不是写代码,而是先理清一个问题:

在业务数据增删改查时,真正需要直接操作的是哪几张表?

答案是:

  • 业务数据保存时,主要操作 t_unitt_fieldValue
  • t_fieldConfig 更多是字段定义层面的配置,不是每次保存业务数据都要动

这个边界一旦清晰,后面的逻辑会简单很多。


六、新增:先存主表,再存动态字段值

新增时的核心思路是:

  1. 先保存主表
  2. 拿到主表生成的 ID
  3. dynamicFields 中的数据转换成 List<TFieldValue>
  4. 给每一条动态字段值补上 relationId
  5. 最后批量保存

这里最关键的一点是:

动态字段值表依赖主表 ID,所以主表必须先存。


新增代码

@Override
@Transactional(rollbackFor = Exception.class)
public Boolean saveUnitWithValues(TUnitVo vo) {
    //1.存储主表
    TUnit tUnit = new TUnit();
    BeanUtils.copyProperties(vo,tUnit);
    this.save(tUnit);

    //2.配置fieldValue表
    if (vo.getDynamicFields() != null && !vo.getDynamicFields().isEmpty()) {
        List<TFieldValue> valueList = new ArrayList<>();
        vo.getDynamicFields().forEach((key,value)->{
            TFieldValue fv = new TFieldValue();
            fv.setBusinessType("t_unit");
            fv.setRelationId(tUnit.getId()); // 刚刚保存出来的主键
            fv.setFieldCode(key);
            fv.setFieldValue(String.valueOf(value));
            valueList.add(fv);
        });

        //3.存储fieldValue表
        if(!valueList.isEmpty()){
            fieldValueService.saveBatch(valueList);
        }
    }

    return true;
}

新增逻辑拆解

第一步,先存主表固定字段。
因为只有主表先存成功,才能拿到数据库生成的 ID。

第二步,把前端传过来的 Map<String, Object> 转成 List<TFieldValue>
这一点本质上就是把接口层结构,转成数据库落表结构。

比如:

{
    "ratedPower": "300MW",
    "manufacturer": "东方电气"
}

最终会被拆成:

  • relationId = 1, fieldCode = ratedPower, fieldValue = 300MW
  • relationId = 1, fieldCode = manufacturer, fieldValue = 东方电气

第三步,批量保存动态字段值。
这样一条完整数据就存完了。

七、查询列表:先批量查,再内存组装

查询列表的时候,难点在于:

主表和动态字段值表是分开的,前端最终要拿到的是一条“完整数据”。

如果处理不好,就很容易写成循环查值表,最终变成 N+1 查询。

所以这里的正确思路应该是:

  1. 先查主表列表
  2. 提取出所有主表 ID
  3. 批量查询这些 ID 对应的动态字段值
  4. relationId 分组
  5. 再把每一行对应的动态字段回填到 dynamicFields

这个思路的核心就是:

一次性查值表,然后在内存里组装。


查询列表代码

@Override
public List<TUnitVo> queryList(TUnitVo query) {
    List<TUnitVo> unitList = mapper.queryList(query);

    //1. 如果查不到数据,直接返回
    if (unitList == null || unitList.isEmpty()) {
        return unitList;
    }

    //2. 获取所有主表ID
    List<String> unitIds = unitList.stream()
            .map(TUnitVo::getId)
            .collect(Collectors.toList());

    //3. 批量查询动态字段值
    List<TFieldValue> allValues = fieldValueService.list(new LambdaQueryWrapper<TFieldValue>()
            .eq(TFieldValue::getBusinessType,"t_unit")
            .in(TFieldValue::getRelationId,unitIds));

    //4. 按relationId分组
    Map<String,List<TFieldValue>> mapValues = allValues.stream()
            .collect(Collectors.groupingBy(TFieldValue::getRelationId));

    //5. 回填dynamicFields
    for (TUnitVo vo : unitList) {
        List<TFieldValue> myValues = mapValues.get(vo.getId());

        if (myValues != null && !myValues.isEmpty()) {
            Map<String, Object> dynamicMap = myValues.stream()
                    .collect(Collectors.toMap(
                            TFieldValue::getFieldCode,
                            TFieldValue::getFieldValue,
                            (v1, v2) -> v2
                    ));
            vo.setDynamicFields(dynamicMap);
        }
    }

    return unitList;
}

这段代码真正做了什么

第一步,查主表,把固定字段先查出来。
第二步,把所有主表 ID 提出来,目的是后续批量查动态字段值。
第三步,一次性把这些 ID 对应的动态字段值全查出来。
第四步,通过 relationId 分组,得到“每个主表 ID 对应哪些动态字段值”。
第五步,再把这些值重新组装成 Map<String, Object>,塞回当前 VodynamicFields

最终返回给前端的每一条数据,就会变成“固定字段 + 动态字段”都齐全的结构。


八、查询单条:主表和动态字段一起组装返回

查询单条时,整体思路和列表查询一样,只不过不需要批量分组。

步骤就是:

  1. 根据 ID 查主表
  2. 根据 relationId 查动态字段值
  3. 把固定字段和动态字段一起封装进 Vo

查询单条代码

@Override
public TUnitVo getInfoById(String id) {
    TUnit unit = this.getById(id);
    if(unit == null){
        return null;
    }

    List<TFieldValue> fieldValues = fieldValueService.list(new LambdaQueryWrapper<TFieldValue>()
            .eq(TFieldValue::getBusinessType,"t_unit")
            .eq(TFieldValue::getRelationId,id));

    TUnitVo vo = new TUnitVo();
    BeanUtils.copyProperties(unit,vo);

    if(fieldValues != null && !fieldValues.isEmpty()){
        Map<String , Object> map = fieldValues.stream().collect(Collectors.toMap(
                TFieldValue::getFieldCode,
                TFieldValue::getFieldValue,
                (v1, v2) -> v2
        ));
        vo.setDynamicFields(map);
    } else {
        vo.setDynamicFields(new HashMap<>());
    }

    return vo;
}

九、更新:主表正常更新,动态字段采用“先删再插”

一开始我这里采用的是一种比较直接的做法:

先根据 relationId 删除当前这条数据对应的所有动态字段值,再把前端传来的最新动态字段重新插入。

这种方式的优点是实现简单,逻辑也顺,能同时处理新增、修改、删除三种情况。
但是后来我再回头看,觉得这种更新方式还是不够好。

因为前端很多时候,可能只改了一个字段,结果后端却把这一行的所有动态字段全删了再重建。
这样做虽然能跑通,但有几个明显问题:

  • 更新语义比较粗暴
  • 数据库操作次数偏多
  • 不利于后续做字段级修改日志
  • 如果后面要做审计或变更记录,不太方便

所以这里更合理的方式,应该是:

先查出旧数据,再和前端传来的新数据做对比,最后只更新真正发生变化的字段。


更新代码

@Override
@Transactional(rollbackFor = Exception.class)
public Boolean updateUnitWithValues(TUnitVo vo) throws Exception {
    // === 1. 安全校验 ===
    TUnit oldUnit = this.getById(vo.getId());
    if (oldUnit == null) {
        throw new Exception("数据不存在,无法更新");
    }

    // 机组编码禁止修改
    if (StringUtils.isNotBlank(vo.getUnitCode()) 
            && !oldUnit.getUnitCode().equals(vo.getUnitCode())) {
        throw new Exception("机组编码 [UnitCode] 禁止修改!");
    }

    // === 2. 更新主表 t_unit ===
    TUnit unit = new TUnit();
    BeanUtils.copyProperties(vo, unit);

    if (unit.getId() == null) {
        throw new Exception("更新操作必须携带主键ID");
    }
    this.updateById(unit);

    // === 3. 查询旧的动态字段值 ===
    List<TFieldValue> oldValues = fieldValueService.list(new LambdaQueryWrapper<TFieldValue>()
            .eq(TFieldValue::getBusinessType, "t_unit")
            .eq(TFieldValue::getRelationId, unit.getId()));

    // === 4. 转成旧Map:fieldCode -> TFieldValue ===
    Map<String, TFieldValue> oldMap = oldValues.stream()
            .collect(Collectors.toMap(
                    TFieldValue::getFieldCode,
                    fv -> fv,
                    (v1, v2) -> v2
            ));

    // 前端传来的新动态字段
    Map<String, Object> newMap = vo.getDynamicFields();
    if (newMap == null) {
        newMap = new HashMap<>();
    }

    // === 5. 准备三类操作集合 ===
    List<TFieldValue> insertList = new ArrayList<>();
    List<TFieldValue> updateList = new ArrayList<>();
    List<String> deleteFieldCodes = new ArrayList<>();

    // === 6. 先处理新增和修改 ===
    for (Map.Entry<String, Object> entry : newMap.entrySet()) {
        String fieldCode = entry.getKey();
        String newValue = entry.getValue() == null ? null : String.valueOf(entry.getValue());

        TFieldValue oldFieldValue = oldMap.get(fieldCode);

        // 6.1 数据库里没有,说明是新增
        if (oldFieldValue == null) {
            TFieldValue fv = new TFieldValue();
            fv.setBusinessType("t_unit");
            fv.setRelationId(String.valueOf(unit.getId()));
            fv.setFieldCode(fieldCode);
            fv.setFieldValue(newValue);
            insertList.add(fv);
        } 
        // 6.2 数据库里有,但值变了,说明是更新
        else if (!Objects.equals(oldFieldValue.getFieldValue(), newValue)) {
            oldFieldValue.setFieldValue(newValue);
            updateList.add(oldFieldValue);
        }
        // 6.3 值没变,不处理
    }

    // === 7. 再处理删除 ===
    for (String oldFieldCode : oldMap.keySet()) {
        if (!newMap.containsKey(oldFieldCode)) {
            deleteFieldCodes.add(oldFieldCode);
        }
    }

    // === 8. 执行新增 ===
    if (!insertList.isEmpty()) {
        fieldValueService.saveBatch(insertList);
    }

    // === 9. 执行更新 ===
    if (!updateList.isEmpty()) {
        fieldValueService.updateBatchById(updateList);
    }

    // === 10. 执行删除 ===
    if (!deleteFieldCodes.isEmpty()) {
        fieldValueService.remove(new LambdaQueryWrapper<TFieldValue>()
                .eq(TFieldValue::getBusinessType, "t_unit")
                .eq(TFieldValue::getRelationId, unit.getId())
                .in(TFieldValue::getFieldCode, deleteFieldCodes));
    }

    return true;
}

为什么差量更新比“先删再插”更合理

1. 更新语义更准确

原来前端只改一个字段,后端却全删再插。
现在则是:

  • 改哪个字段,就只更新哪个字段
  • 没变的字段不动
  • 新增的字段才新增
  • 删除的字段才删除

这样数据库操作更符合真实业务行为,尤其当动态字段比较多时,差量更新可以减少很多不必要的删除和插入,也更有利于后续做日志和审计。

十、删除:一定是先删值表,再删主表

删除时的逻辑也很关键。

不能只删主表,因为动态字段值表里还有和主表 ID 关联的数据。
所以这里必须先清理附属数据,再删主数据。


删除单条代码

@Override
@Transactional(rollbackFor = Exception.class)
public Boolean removeUnitWithValues(String id) {
    //1. 先删fieldValue表
    fieldValueService.remove(new LambdaQueryWrapper<TFieldValue>()
            .eq(TFieldValue::getBusinessType,"t_unit")
            .eq(TFieldValue::getRelationId,id));

    //2. 再删主表
    return this.removeById(id);
}

批量删除代码

@Override
@Transactional(rollbackFor = Exception.class)
public Boolean deleteBatchUnitsWithValues(List<String> ids) {
    fieldValueService.remove(new LambdaQueryWrapper<TFieldValue>()
            .eq(TFieldValue::getBusinessType,"t_unit")
            .in(TFieldValue::getRelationId,ids));

    return this.removeByIds(ids);
}

为什么删除顺序不能反

因为 t_fieldValue 里的数据依赖主表 ID 存在。
如果主表先删了,而值表没清掉,就容易留下孤儿数据。

所以删除时最好始终记住一句话:

先删附属表,再删主表。

这个原则不仅适用于动态字段场景,很多业务删除也是一样的。


十一、这套方案的优点

这次需求做完以后,我觉得这套设计最大的优点主要有下面几个。


1. 主表结构稳定

动态字段不再进主表,所以后续新增字段时,不需要频繁改数据库结构。
这一点对后期维护非常重要。


2. 扩展性强

只要前端能根据 t_fieldConfig 渲染字段,后端就能按 dynamicFields 接收和存储数据。
字段扩展时,不需要大动主流程。


3. 通用性更好

由于设计里加了 business_type,这套动态字段模型理论上不只适合 t_unit,以后别的业务表也可以接进来复用。


4. 前后端职责清晰

  • 前端负责根据字段配置渲染动态表单
  • 后端负责保存和回填动态字段值
  • 配置表负责字段定义,不和字段值混在一起

这样的职责拆分,后面维护起来会更清晰。


十二、这套方案的不足和注意点

当然,这套方案也不是没有代价。


1. 查询会比单表复杂

因为固定字段和动态字段值是分开存的,所以查询时一定要做组装。
特别是列表查询,必须考虑批量查值表再回填。


2. 动态字段不适合做特别复杂的统计分析

比如按某个动态字段做区间查询、排序、聚合分析,这种时候它就不如固定字段方便。

所以这套方案更适合的场景是:

动态字段主要用于录入、展示、简单查询,而不是特别高频的复杂分析。


3. 更新时“先删再插”足够稳,但不是最省操作

这套更新策略逻辑最简单,维护成本也低。
但如果动态字段特别多,或者更新量非常大,SQL 次数上会有一些额外开销。

不过对大多数普通业务系统来说,这点代价通常是可以接受的。


4. 最好加唯一约束

从设计上看,t_fieldValue 最好增加一个唯一约束,例如:

  • business_type
  • relation_id
  • field_code

这样可以保证同一行、同一字段,不会重复存多条值。
否则后面组装数据时,虽然可以用:

(v1, v2) -> v2

做兜底覆盖,但这终究只是补救,不如从表约束层面解决。


十三、这次需求给我的一个核心感受

这次需求表面上看,是“做一个动态字段功能”。

但真正做下来,我最大的感受是:

这类需求最重要的不是先写代码,而是先把数据模型想清楚。

如果一开始只想着“先把接口写出来”,很容易变成:

  • 多一个字段,加一个数据库列
  • 多一个字段,加一个实体类属性
  • 多一个字段,加一个前端表单项

短期能跑,长期一定会越来越乱。

而这次只要把下面几个问题想清楚,后面代码其实就顺了:

  1. 主表到底存什么
  2. 字段配置表存什么
  3. 字段值表存什么
  4. 前端收发结构怎么设计
  5. 查询时怎么重新组装回去

所以这种需求,真正的关键不是 CRUD 本身,而是:

先把数据拆对,再去写接口。


总结

这次需求的目标是:

在一个页面中,同时支持固定字段和动态字段,并且尽量不频繁修改数据库表结构。

最终采用的方案是:

  • t_unit:存固定字段
  • t_fieldConfig:存动态字段定义
  • t_fieldValue:存动态字段具体值

接口层通过:

private Map<String, Object> dynamicFields;

来统一接收和返回动态字段。

在增删改查上的核心思路分别是:

  • 新增:先存主表拿 ID,再批量保存动态字段值
  • 列表查询:先查主表,再批量查值表,按 relationId 分组后回填
  • 单条查询:主表和当前 ID 对应的动态字段一起组装返回
  • 更新:先更新主表,再删除旧值,最后插入新值
  • 删除:先删动态值,再删主表

整个方案做下来,我觉得最重要的一点是:

动态字段需求,不要一开始就想着往主表里继续塞字段,而是要先考虑字段定义和值能不能拆开。

只要这一步拆对了,后面的扩展性和维护性都会好很多。


结尾

这次算是把“固定字段 + 动态字段”的通用做法完整走了一遍。
表面上是一个页面需求,实际上练到的是数据建模能力。

后面如果我再把这套方案往下整理,还可以继续写两篇:

  • 一篇写前端如何根据 fieldConfig 动态渲染表单
  • 一篇写这套方案在 Excel 导入导出里的落地方式

如果这篇对你也有帮助,说明这种“先拆模型,再写接口”的思路,确实是有价值的。