一个让所有后端工程师隐隐头疼的需求:Excel 导入导出 。 文件解析不难、列对应不难,难的是------Excel 是扁平的,数据库是多表的。 中间那层"翻译工作"每张表都要做一遍,量不大,却足以把一个高级工程师的下午磨成体力活。 这篇文章讲一个被低估的解法:让"字段位置"从代码里消失,变成 Bean 的元数据。
一、先看一段你写过一百遍的代码
业务说:"导入用户 Excel,存到三张表:user / profile / address。"
你写出来大概长这样:
java
// 导入:扁平 → 三张表
User user = new User();
user.setName(row.getName());
userMapper.insert(user);
Profile profile = new Profile();
profile.setUserId(user.getId());
profile.setPhone(row.getPhone());
profile.setWechat(row.getWechat());
profileMapper.insert(profile);
Address address = new Address();
address.setUserId(user.getId());
address.setDetail(row.getAddress());
addressMapper.insert(address);
导出时,再反着写一遍:
java
// 导出:三张表 → 扁平
User user = userMapper.selectById(id);
Profile profile = profileMapper.selectByUserId(id);
Address address = addressMapper.selectByUserId(id);
ExcelRow row = new ExcelRow();
row.setName(user.getName());
row.setPhone(profile.getPhone());
row.setWechat(profile.getWechat());
row.setAddress(address.getDetail());
这就是字段搬运工的标配姿势。
每个字段写四次:导入 set、导出 get、单元测试两次。模板加一列,要改 4 处。模板换一版,要改 8 处。两年下来,每个 Service 都长出一坨"扁平 ↔ 嵌套"的翻译代码------没有技术含量、漏改即出 bug、每次都要重做一遍。
二、问题的根源不是 Excel,是"结构不对齐"
先承认一件事:Excel 扁平不是缺点。它就是给人看的------能改、能看、能传,目标从来不是优雅。
| 视角 | 结构 |
|---|---|
| Excel | 一行一行的扁平表 |
| 业务对象 | 嵌套的领域模型 |
| 数据库 | 多张表 + JOIN |
这三个视角都合理,只是不对齐。
每次导入导出,本质上都在做同一件翻译工作 :把 Excel 的扁平结构和数据库的多表结构互相映射。如果这件事每次都靠手写 set/get,那它就是一项永远做不完的体力活。
三、把"字段位置"写在 Bean 上,而不是代码里
我现在的做法是把"哪个字段属于哪张表"这件事直接声明在 Bean 上:
java
@Data
public class UserExcelRow {
@ExcelProperty("姓名")
private String name;
@ExcelProperty("手机号")
@SetValue("profile")
private String phone;
@ExcelProperty("微信号")
@SetValue("profile")
private String wechat;
@ExcelProperty("地址")
@SetValue("address")
private String detail;
}
注意两个注解共生在同一个字段上:
@ExcelProperty管"列名"(横向)@SetValue管"嵌套位置"(纵向)
一份 Bean,一份注解定义,导入导出共用。
导入:一次转换,自动拆分
java
UserExcelRow row = readFromExcel();
// 一次性把扁平 Bean 转成多级 JSONMap
JSONMap json = new JSONMap();
BeanUtil.copyAsSource(row, json, false);
// json = {
// "name": "张三",
// "profile": {"phone": "138...", "wechat": "dk_test"},
// "address": {"detail": "上海浦东"}
// }
// 各表分别取值
User user = json.as(User.class);
Profile profile = json.getObj("profile", Profile.class);
Address address = json.getObj("address", Address.class);
userMapper.insert(user);
profile.setUserId(user.getId()); profileMapper.insert(profile);
address.setUserId(user.getId()); addressMapper.insert(address);
导出:组装后一次扁平化
java
User user = userMapper.selectById(id);
Profile profile = profileMapper.selectByUserId(id);
Address address = addressMapper.selectByUserId(id);
JSONMap json = new JSONMap(user);
json.put("profile", profile);
json.put("address", address);
UserExcelRow row = new UserExcelRow();
BeanUtil.copyAsTarget(json, row, false);
// row.name / row.phone / row.wechat / row.detail 全部自动填充
导入导出各一行 copy 调用,多表关系完全收敛在 Bean 的注解里。
四、加一个字段试试看(杀伤性最强的对比)
判断一个映射方案值不值得引入,我有个简单的压力测试:让业务加一个字段,看你要改几处。
业务说:"Excel 模板再加一列邮箱,也存到 profile 表里。"
| 方案 | 要改几处 | 具体改动 |
|---|---|---|
| 传统手写 | 4 处 | DTO 加字段 / 导入加 setter / 导出加 setter / 测试加 case |
@SetValue |
1 处 | Bean 上加一行:@ExcelProperty("邮箱") @SetValue("profile") private String email; |
导入导出代码一个字都不用动 ------因为它们调的是 copyAsSource / copyAsTarget,这两个方法压根不知道有几个字段。
这才是 @SetValue 真正的杠杆:变更成本从 O(N) 降到 O(1)。
不是少敲几行代码,而是改动只发生在一个地方------你不必在四个文件之间来回跳,也不必担心漏改一处。
五、它和 BeanUtils.copyProperties 不是一回事
写到这里,估计有人会想:这不就是 BeanUtils 高级版?
不是,差的是范式。
BeanUtils.copyProperties |
@SetValue |
|
|---|---|---|
| 范式 | 命令式(你告诉它怎么做) | 声明式(你声明字段位置) |
| 嵌套结构 | 不支持 | 任意深度(a.b.c.d) |
| 字段位置定义 | 散落在 set/get 调用里 | 集中在 Bean 注解里 |
| 双向映射 | 写两段代码 | 同一份注解通用 |
| 一个 Bean 复用场景数 | 1(同名拷贝) | N(Excel / API / DB / JSON 字段...) |
BeanUtils.copyProperties 是命令式 :你告诉它"把 A 拷贝到 B",它按同名字段干一件事,嵌套结构它管不了。
@SetValue 是声明式 :你在 Bean 上声明"这个字段在结构里的位置是 profile.phone"------谁来读、谁来写、哪个方向,框架自己推。
我手上有些 Bean 同时出现在四个地方:Excel 导入、Excel 导出、HTTP 接口返回、数据库 JSON 字段读写。命令式要写 8 段代码(4 场景 × 进出两个方向),@SetValue 是 0 段。
字段位置不再是代码的一部分,而是 Bean 元数据的一部分。 它不是工具,是声明。
六、用这个模型审判主流方案
| 方案 | 嵌套支持 | 双向通用 | 加字段成本 | Bean 复用 | 评价 |
|---|---|---|---|---|---|
| 手写 set/get | ✅ 但要写多遍 | ❌ 双份代码 | O(N) | 1 个场景 | 体力活之王 |
| BeanUtils.copyProperties | ❌ 只支持扁平 | 部分 | O(N) | 1 个场景 | Excel 派不上用场 |
| MapStruct | ✅ 强 | ✅ 但写两个 mapper | O(N) | 1 个场景 | 适合复杂转换,重 |
| Jackson 反序列化 | ✅ | 部分(视图复杂) | O(N) | 2 个场景 | 解决不了 Excel 列名 |
@SetValue |
✅ 任意深度 | ✅ 一份注解 | O(1) | N 个场景 | 三件事都做对 |
主流方案里能同时做到"嵌套支持 + 双向通用 + O(1) 变更成本"的,几乎只有声明式映射这一类。
七、它为什么在长期项目里更稳
Excel 导入导出不是一次性需求。一旦进入企业项目,它会带来:
- 模板版本变化
- 字段名调整
- 多业务线复用(不同的表组合)
- 导出和导入都要支持
- 表结构变更(加表、改关联)
- 配合校验、审计、错误提示
如果多表映射关系散落在很多 Service 方法里,后面只会越来越难维护------改一个字段,全项目搜字符串。
把映射关系集中放在 Bean 上,至少得到三个直接好处:
- 哪些字段属于哪张表,肉眼可见
- 修改表结构时,不用满项目搜字符串
copyAsSource/copyAsTarget只调一次,不会漏字段
它不是在减少"编码动作",而是在减少"多表关系知识的扩散"。
八、它的边界在哪
我也不会只 靠 @SetValue 处理所有 Excel 导入导出。下面这些场景,仍然要写显式流程:
- 导入时有非常复杂的多行合并逻辑(一行拆多行 / 多行合一行)
- 一列要拆成多个业务规则(比如 "上海浦东" 拆成省/市/区)
- 不同模板版本需要完全不同的转换策略
- 强校验和复杂错误提示
@SetValue 擅长**"稳定的结构映射",不擅长"复杂的业务分支编排"**。
守住这个边界,它会很好用;守不住,转换逻辑会被藏得太深,反而难调试。
九、一个更容易维护的分层方式
我推荐把 Excel 导入导出拆成三层:
| 层 | 关注点 | 推荐方案 |
|---|---|---|
| 1️⃣ Excel 列 ↔ 扁平字段 | 列名映射 | @ExcelProperty |
| 2️⃣ 扁平字段 ↔ 多表 | 位置映射 | @SetValue |
| 3️⃣ 业务规则 ↔ 校验 | 业务逻辑 | 显式代码 |
真正容易失控的项目,往往是把这三层揉成了一层------一个"导入手机号"的小需求,最后要同时改表头、改目标表、改关联关系、改落库。
第二层是最值得抽出来的------它是纯粹的"位置翻译",没有业务语义,最适合声明式表达。
十、把它带走
如果你只能记一句话:
字段位置不该写在代码里,应该写在 Bean 上。
如果你只能记一张图:
css
Excel 扁平世界 ←─[ @SetValue 的桥 ]─→ 数据库多表世界
│ │
@ExcelProperty 多表 Mapper
(列名映射) (持久化)
└────────── 一份 Bean,两个世界 ──────────┘
如果你想总结这套方法:
@SetValue真正解决的不是 Excel,而是"多表适配的重复劳动"。它让 Excel 世界继续扁平,数据库的多表关系继续存在,中间那座桥不用每次手搓。
最后
Excel 导入导出之所以总排在"谁都不想接"的需求列表前排,不是技术难度高,而是重复劳动太多、成就感太少。
把这层重复劳动拿走,团队才有余力去解决真正难的校验和流程问题------这时候导入导出才不会总像一场体力活。
人也才不会天然排斥这类需求。
这其实很重要。
💬 Excel 导入导出,你们觉得最烦的是哪个环节?是文件解析、列对应、还是结构适配?
我个人觉得最磨人的是"扁平和嵌套之间的翻译"------量不大,但每张表都要来一遍。你们有没有更优雅的方案?或者有没有被 Excel 需求"追着跑"的经历?
文中提到的工具:
- 项目:
dlz-kit - Maven:
top.dlzio:dlz-kit - GitHub:
https://github.com/dingkui/dlz-kit - Gitee:
https://gitee.com/dlzio/dlz-kit