Excel 这东西,业务一提就两个字:简单。 真到你接手的时候,就不是“导个表”了,是日期乱了、金额精度丢了、表头改名了、导出把内存打满了,最后锅还得你背。
我以前也写过那种“拿到 MultipartFile,for 一把,塞数据库,完事”的导入代码。跑小数据没事,一上生产就露馅:1000 行里混 3 行脏数据,你到底是整批回滚,还是跳过继续?用户传上来的“手机号”那一列,有时是字符串,有时是数字,Excel 再给你补个.0,你查半天还以为是库里有鬼。
后来见过公司一个老哥封的 Excel 组件,我第一眼就知道这人不是只会写 demo 的。
他没把 Excel 导入当“文件解析”,而是当“数据入站”。顺序也很稳:先校验模板,再逐行解析,再收集错误,最后才入库。导出也一样,不是 controller 里直接write(response.getOutputStream()),而是把列定义、字典翻译、样式、分页查询全拆开。
先看导入这块,核心不是读,而是“别让脏数据直接碰业务代码”。
public interface RowValidator<T> {
List<String> validate(T row, int rowNum);
}
public class UserImportRow {
private String name;
private String phone;
private BigDecimal salary;
private LocalDate entryDate;
}
public class UserImportValidator implements RowValidator<UserImportRow> {
@Override
public List<String> validate(UserImportRow row, int rowNum) {
List<String> errors = new ArrayList<>();
if (row.getName() == null || row.getName().isBlank()) {
errors.add("第" + rowNum + "行:姓名不能为空");
}
if (row.getPhone() == null || !row.getPhone().matches("^1\\d{10}$")) {
errors.add("第" + rowNum + "行:手机号格式不对");
}
if (row.getSalary() != null && row.getSalary().scale() > 2) {
errors.add("第" + rowNum + "行:薪资最多两位小数");
}
return errors;
}
}
注意这个味道就不一样了。 不是一边解析一边saveBatch,而是先把“能不能进系统”这件事说清楚。
再往下一层,他连表头都不信。
public class ExcelHeadChecker {
public static void check(List<String> actualHeads, List<String> expectedHeads) {
if (!actualHeads.equals(expectedHeads)) {
throw new IllegalArgumentException("导入模板不对,请下载最新模板后重试");
}
}
}
这一步看着土,其实很值钱。很多线上事故不是数据错,是运营自己改了列名,或者把“入职日期”挪到前面了。你要是不先拦,后面整列错位,导进去才真难收。
真正让我觉得“妙”的,是他不追求一次性全成功,而是给用户一份能看懂的失败结果。 比如 5000 行数据,错了 23 行,不是直接扔个“导入失败”,而是把错误行号、错误原因重新导出成一个失败文件。业务拿着这个文件改,比你在群里解释半小时都省事。
public class ImportResult<T> {
private int successCount;
private int failCount;
private List<T> successRows;
private List<String> errorMessages;
}
导入服务大概是这么个写法:
public ImportResult<UserImportRow> importUsers(List<UserImportRow> rows) {
List<UserImportRow> passed = new ArrayList<>();
List<String> errors = new ArrayList<>();
int rowNum = 2;
for (UserImportRow row : rows) {
List<String> rowErrors = validator.validate(row, rowNum++);
if (!rowErrors.isEmpty()) {
errors.addAll(rowErrors);
continue;
}
passed.add(row);
}
if (!passed.isEmpty()) {
userService.batchUpsert(passed);
}
ImportResult<UserImportRow> result = new ImportResult<>();
result.setSuccessCount(passed.size());
result.setFailCount(errors.size());
result.setSuccessRows(passed);
result.setErrorMessages(errors);
return result;
}
这里还有个细节,batchUpsert比batchInsert更像线上代码。 因为导入这事,重复数据是常态,不是异常。员工编号重复、商品编码已存在、部门数据之前导过一次,这些都很正常。你非要让业务先去库里清空,再来导,这系统基本已经输了。
导出这边更能看出老程序员的脾气。
一般人写导出,喜欢在接口里查全量,再一把写 Excel。数据量一大,JVM 先给你上课。真正稳的做法是列定义和数据查询分开,分页拉,流式写。先把“这一列叫什么、怎么取值、要不要格式化”定义好。
public class ExportColumn<T> {
private String title;
private Function<T, Object> valueGetter;
public ExportColumn(String title, Function<T, Object> valueGetter) {
this.title = title;
this.valueGetter = valueGetter;
}
}
List<ExportColumn<UserDTO>> columns = List.of(
new ExportColumn<>("姓名", UserDTO::getName),
new ExportColumn<>("手机号", UserDTO::getPhone),
new ExportColumn<>("状态", x -> x.getStatus() == 1 ? "启用" : "停用"),
new ExportColumn<>("入职日期", x -> x.getEntryDate() == null ? "" : x.getEntryDate().toString())
);
这种写法的好处很直接:导出规则收口了。 你不用在 8 个地方各写一份“状态 1 转启用,0 转停用”,也不用每次新增一列就去改一坨 if else。
再说个很多人容易忽略的坑:导出不是功能问题,是资源问题。 10 万行往上,别信什么“应该没事”。我一般先看两件事:是不是分页查,响应流是不是及时刷。很多导出慢,不是 Excel 库慢,是你 SQL 一次捞太狠,顺手还连表补了几个字典,最后导出还没开始写,库先喘上了。
所以这种封装,表面看是 Excel,实际上是在替业务挡脏活: 模板别乱改,数据别乱进,错误要能回显,大数据量别把机器拖死。
说到底,Excel 导入导出最怕的不是代码写不出来,是写得太轻松。