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

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

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

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

这个需求一开始看起来像是普通的增删改查,但真正做的时候会发现,核心难点其实不是 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 导入导出里的落地方式

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

相关推荐
IT_陈寒3 小时前
闭包陷阱让我加了两天班,JavaScript你真行
前端·人工智能·后端
易协同低代码4 小时前
通达OA核心类库TD类深度解析
后端
Gopher_HBo5 小时前
Go语言学习笔记(十八)Gin处理Session
后端
谭光志5 小时前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..5 小时前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects6 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰6 小时前
一些基础和问题解决
后端
陈随易6 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN6 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp
Imchendiana6 小时前
《狂人日记NO.9》— 前后端一把梭,我的全栈实录
前端·后端