最近在实习公司做了一个新需求。
页面里本身有两个固定字段,但除此之外,还要求支持根据实际需要,自定义新增字段。
而且这些新增字段不是一次性的,后续还可能继续扩展。
这个需求一开始看起来像是普通的增删改查,但真正做的时候会发现,核心难点其实不是 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对应fieldCodevalue对应具体字段值
这样后端在处理时就不需要为每个动态字段单独写属性,也不需要频繁修改 Vo。
而前端如果需要字段中文名,则可以单独去查询 t_fieldConfig。
因为字段名本身属于"配置",不属于"值"。
所以这里职责就很清晰:
t_fieldConfig负责字段定义t_fieldValue负责字段值dynamicFields负责接口层收发
五、实现前先把思路理顺:真正要操作的是哪几张表
这类需求一旦开始写代码,很容易写着写着就乱掉。
我后来觉得最重要的一步其实不是写代码,而是先理清一个问题:
在业务数据增删改查时,真正需要直接操作的是哪几张表?
答案是:
- 业务数据保存时,主要操作
t_unit和t_fieldValue t_fieldConfig更多是字段定义层面的配置,不是每次保存业务数据都要动
这个边界一旦清晰,后面的逻辑会简单很多。
六、新增:先存主表,再存动态字段值
新增时的核心思路是:
- 先保存主表
- 拿到主表生成的 ID
- 把
dynamicFields中的数据转换成List<TFieldValue> - 给每一条动态字段值补上
relationId - 最后批量保存
这里最关键的一点是:
动态字段值表依赖主表 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 = 300MWrelationId = 1, fieldCode = manufacturer, fieldValue = 东方电气
第三步,批量保存动态字段值。
这样一条完整数据就存完了。
七、查询列表:先批量查,再内存组装
查询列表的时候,难点在于:
主表和动态字段值表是分开的,前端最终要拿到的是一条"完整数据"。
如果处理不好,就很容易写成循环查值表,最终变成 N+1 查询。
所以这里的正确思路应该是:
- 先查主表列表
- 提取出所有主表 ID
- 批量查询这些 ID 对应的动态字段值
- 按
relationId分组 - 再把每一行对应的动态字段回填到
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>,塞回当前 Vo 的 dynamicFields。
最终返回给前端的每一条数据,就会变成"固定字段 + 动态字段"都齐全的结构。
八、查询单条:主表和动态字段一起组装返回
查询单条时,整体思路和列表查询一样,只不过不需要批量分组。
步骤就是:
- 根据 ID 查主表
- 根据
relationId查动态字段值 - 把固定字段和动态字段一起封装进
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_typerelation_idfield_code
这样可以保证同一行、同一字段,不会重复存多条值。
否则后面组装数据时,虽然可以用:
rust
(v1, v2) -> v2
做兜底覆盖,但这终究只是补救,不如从表约束层面解决。
十三、这次需求给我的一个核心感受
这次需求表面上看,是"做一个动态字段功能"。
但真正做下来,我最大的感受是:
这类需求最重要的不是先写代码,而是先把数据模型想清楚。
如果一开始只想着"先把接口写出来",很容易变成:
- 多一个字段,加一个数据库列
- 多一个字段,加一个实体类属性
- 多一个字段,加一个前端表单项
短期能跑,长期一定会越来越乱。
而这次只要把下面几个问题想清楚,后面代码其实就顺了:
- 主表到底存什么
- 字段配置表存什么
- 字段值表存什么
- 前端收发结构怎么设计
- 查询时怎么重新组装回去
所以这种需求,真正的关键不是 CRUD 本身,而是:
先把数据拆对,再去写接口。
总结
这次需求的目标是:
在一个页面中,同时支持固定字段和动态字段,并且尽量不频繁修改数据库表结构。
最终采用的方案是:
t_unit:存固定字段t_fieldConfig:存动态字段定义t_fieldValue:存动态字段具体值
接口层通过:
typescript
private Map<String, Object> dynamicFields;
来统一接收和返回动态字段。
在增删改查上的核心思路分别是:
- 新增:先存主表拿 ID,再批量保存动态字段值
- 列表查询 :先查主表,再批量查值表,按
relationId分组后回填 - 单条查询:主表和当前 ID 对应的动态字段一起组装返回
- 更新:先更新主表,再删除旧值,最后插入新值
- 删除:先删动态值,再删主表
整个方案做下来,我觉得最重要的一点是:
动态字段需求,不要一开始就想着往主表里继续塞字段,而是要先考虑字段定义和值能不能拆开。
只要这一步拆对了,后面的扩展性和维护性都会好很多。
结尾
这次算是把"固定字段 + 动态字段"的通用做法完整走了一遍。
表面上是一个页面需求,实际上练到的是数据建模能力。
后面如果我再把这套方案往下整理,还可以继续写两篇:
- 一篇写前端如何根据
fieldConfig动态渲染表单 - 一篇写这套方案在 Excel 导入导出里的落地方式
如果这篇对你也有帮助,说明这种"先拆模型,再写接口"的思路,确实是有价值的。