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

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

页面里本身有两个固定字段,但除此之外,还要求支持根据实际需要,自定义新增字段。

而且这些新增字段不是一次性的,后续还可能继续扩展。

这个需求一开始看起来像是普通的增删改查,但真正做的时候会发现,核心难点其实不是 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 层增加一个字段:

typescript 复制代码
private Map<String, Object> dynamicFields;

这样做的原因很直接。

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

例如:

json 复制代码
{
  "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,所以主表必须先存。


新增代码

scss 复制代码
@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>

这一点本质上就是把接口层结构,转成数据库落表结构。

比如:

json 复制代码
{
    "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

这个思路的核心就是:

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


查询列表代码

less 复制代码
@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

查询单条代码

ini 复制代码
@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;
}

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

更新时,真正麻烦的不是主表,而是动态字段。

因为前端一般传过来的,不是"只改了哪一个字段",而是当前整条完整数据。

也就是说,它发过来的是一份"全量状态"。

在这种情况下,如果后端做差量更新,逻辑会比较复杂。

不仅要判断哪些字段改了,还要判断哪些字段被删掉了。

所以我这里采用的策略是:

先删除旧的动态字段值,再按前端传来的当前状态重新插入。

这样做的好处是:

  • 逻辑简单
  • 能处理新增
  • 能处理修改
  • 也能处理删除

只要前端传来的是完整状态,这种做法就很合适。


更新代码

scss 复制代码
@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. 更新主表
    TUnit unit = new TUnit();
    BeanUtils.copyProperties(vo, unit);

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

    // 3. 删除旧动态字段值
    fieldValueService.remove(new LambdaQueryWrapper<TFieldValue>()
            .eq(TFieldValue::getBusinessType, "t_unit")
            .eq(TFieldValue::getRelationId, unit.getId()));

    // 4. 插入新动态字段值
    if (vo.getDynamicFields() != null && !vo.getDynamicFields().isEmpty()) {
        List<TFieldValue> valueList = new ArrayList<>();

        vo.getDynamicFields().forEach((key, val) -> {
            if (val != null) {
                TFieldValue fv = new TFieldValue();
                fv.setBusinessType("t_unit");
                fv.setRelationId(String.valueOf(unit.getId()));
                fv.setFieldCode(key);
                fv.setFieldValue(String.valueOf(val));
                valueList.add(fv);
            }
        });

        if (!valueList.isEmpty()) {
            fieldValueService.saveBatch(valueList);
        }
    }

    return true;
}

为什么"先删再插"在这里是合理的

这种做法虽然不是最节省 SQL 次数的,但在这种动态字段场景下,非常稳。

举个例子:

原来数据库里有 3 个动态字段,前端编辑后删掉了 1 个。

如果你做差量更新,就必须判断哪个字段被删了,然后单独删除它。

而"先删再插"的方式就简单很多:

  • 先把旧的全删掉
  • 前端现在有什么,就重建什么

这样数据库最终状态一定和前端当前状态一致。

对这种"前端传全量数据"的更新场景来说,这种策略非常好用。


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

删除时的逻辑也很关键。

不能只删主表,因为动态字段值表里还有和主表 ID 关联的数据。

所以这里必须先清理附属数据,再删主数据。


删除单条代码

less 复制代码
@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);
}

批量删除代码

less 复制代码
@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

这样可以保证同一行、同一字段,不会重复存多条值。

否则后面组装数据时,虽然可以用:

rust 复制代码
(v1, v2) -> v2

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


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

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

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

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

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

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

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

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

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

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

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


总结

这次需求的目标是:

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

最终采用的方案是:

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

接口层通过:

typescript 复制代码
private Map<String, Object> dynamicFields;

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

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

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

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

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

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


结尾

这次算是把"固定字段 + 动态字段"的通用做法完整走了一遍。

表面上是一个页面需求,实际上练到的是数据建模能力。

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

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

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

相关推荐
隔壁家滴怪蜀黍1 小时前
AgentScope MsgHub 多智能体通信机制详解
后端
孟陬1 小时前
国外技术周刊 #3:“最差程序员”带动高效团队、不写代码的创业导师如何毁掉创新…
前端·后端·设计模式
Cosolar2 小时前
Transformer训练与生成背后的数学基础
人工智能·后端·开源
lay_liu2 小时前
Spring Boot 自动配置
java·spring boot·后端
程序员cxuan2 小时前
说点掏心窝子的话
后端·程序员
写Cpp的小黑黑2 小时前
WebSocket 连通性测试方法
后端
开心就好20252 小时前
Windows 上传 IPA 到 App Store 的步骤讲解
后端·ios
听风者就是我3 小时前
混合检索:关键词 + 向量的最佳组合
后端·ai编程
Memory_荒年3 小时前
当餐厅后厨也懂分布式:SpringBoot中的重试、限流、熔断与幂等的“四重奏”
java·后端·spring