后端重构实录:我如何用 Tuple 一举消灭两个“导出”大 Bug

🚀 后端重构实录:我如何用 Tuple 一举消灭两个“导出”大 Bug

嘿,各位在代码世界里追求完美的伙伴们!👋

我们都曾面对过那些“会咬人”的代码——它们看起来能工作,但在特定场景或环境下,就会暴露出脆弱的本质。今天,我想分享一次我亲手完成的重构经历,讲述我是如何将一个问题缠身的“产品导出”功能,改造成一个在任何环境下都健壮如牛的优雅实现。

故事的起因是两个看似无关的 Bug:

  1. 线上 Bug 🐞:导出的 Excel 文件,表头正确,但数据列却“张冠李戴”,内容完全错乱。
  2. 本地 Bug 💥:同样的功能,在我的本地开发环境,甚至都无法运行,直接抛出一个冷冰冰的 SQL 错误!

最终,我通过引入 JPA (Java Persistence API, Java持久化API) 的一个“秘密武器”——Tuple,一举将这两个 Bug 彻底消灭。

案发现场:一段依赖“顺序”的脆弱代码

在重构之前,我们的数据获取和解析逻辑是这样的:

ProductSqlService.java (重构前):

// 1. 数据获取:SELECT 子句由工具类动态生成,顺序不明确
public List<HashMap<String, String>> exportList(...) {
    String cols = SqlUtil.sqlGenerate("p", ExportProductDto.class);
    sql.append("SELECT DISTINCT ").append(cols).append("...");
    // ...
    List result = query.getResultList(); // 返回 List<Object[]>
    return parseToExportHashMap(result);
}

// 2. 数据解析:强依赖于返回结果的列顺序
private List<HashMap<String, String>> parseToExportHashMap(List result) {
    for (Object o : result) {
        Object[] obj = (Object[]) o;
        HashMap<String, String> map = new HashMap<>();
        int i = 1;
        // 假设 Enum.values() 的顺序和 SELECT 的列顺序一致
        for (ProductExportFieldEnum fieldEnum : ProductExportFieldEnum.values()) {
            map.put(fieldEnum.getId() + "", obj[i] + "");
            i++;
        }
        list.add(map);
    }
    return list;
}

问题分析
这段代码犯了一个致命的错误:它建立在一个脆弱的“顺序假设”之上。它假设 SELECT 子句中字段的物理顺序,会和 ProductExportFieldEnum 中枚举常量的声明顺序永远保持一致

这直接导致了两个问题:

  1. 线上数据错位:只要有人调整了 SELECT 子句或枚举的顺序,数据映射就会立刻错乱,出现“品牌”列显示“品名”的尴尬情况。
  2. 本地 SQL (Structured Query Language, 结构化查询语言) 报错:我本地的 MySQL (My Structured Query Language) sql_mode 配置更严格,它要求 SELECT DISTINCT 查询的 ORDER BY 列必须出现在 SELECT 列表中。而动态生成的 cols 字符串恰好漏掉了 created_date,导致在本地直接报错。

重构开始:拥抱“别名”,告别“顺序”

要从根源上解决问题,我们必须打破对顺序的依赖,转向一种更明确、更健壮的映射方式——基于列别名 (Alias) 的映射。JPA 的 Tuple 接口正是实现这一目标的完美工具。

ProductSqlService.java (重构后):

import javax.persistence.Tuple; // 引入 Tuple

/**
 * 商品导出列表 (重构后)
 */
public List<HashMap<String, String>> exportList(Integer nowId, List<Integer> idList) {
    StringBuilder sql = new StringBuilder();

    // 1. 手动构建带明确别名的 SELECT 子句
    // 别名 (alias) 与 ProductExportFieldEnum 中的 property 属性名完全一致
    sql.append("SELECT DISTINCT ");
    sql.append("p.id as id, ");
    sql.append("b.name as brand, ");
    sql.append("p.name as name, ");
    // ... (列出所有需要的字段,并为它们起别名) ...
    // 确保 ORDER BY 的列也在这里,解决 sql_mode 问题
    sql.append("p.ranks as ranks, ");
    sql.append("p.created_date as createdDate ");
    
    // ... (FROM, WHERE, ORDER BY 子句保持不变)
    sql.append(" FROM product p ...");
    sql.append("ORDER BY p.ranks DESC, p.created_date DESC");

    // 2. 使用 Tuple.class 来接收结果
    Query query = em.createNativeQuery(sql.toString(), Tuple.class);
    // ... (设置参数)
    List<Tuple> result = query.getResultList();

    // 3. 调用新的、基于别名的解析方法
    return parseToExportHashMapUsingTuple(result);
}

/**
 * 转化为产品数据 (重构后,使用 Tuple,不再依赖顺序)
 */
private List<HashMap<String, String>> parseToExportHashMapUsingTuple(List<Tuple> result) {
    // 创建一个从 属性名(property) 到 枚举ID 的映射,用于快速查找
    Map<String, Integer> propertyToIdMap = new HashMap<>();
    for (ProductExportFieldEnum fieldEnum : ProductExportFieldEnum.values()) {
        propertyToIdMap.put(fieldEnum.getProperty(), fieldEnum.getId());
    }

    List<HashMap<String, String>> list = new ArrayList<>();
    for (Tuple tuple : result) {
        HashMap<String, String> map = new HashMap<>();
        // 遍历 Tuple 的所有元素(列)
        tuple.getElements().forEach(element -> {
            String alias = element.getAlias(); // 获取列的别名 (e.g., "standardPrice")
            Integer fieldId = propertyToIdMap.get(alias); // 根据别名查找字段ID
            if (fieldId != null) {
                Object value = tuple.get(alias);
                map.put(fieldId.toString(), value == null ? "" : value.toString());
            }
        });
        list.add(map);
    }
    return list;
}

重构亮点解析 ✨

  1. 明确的 SELECT 和别名:我们放弃了动态生成 SELECT 子句的“黑盒”,改为明确地手写所有需要的字段,并为每个字段赋予一个与业务(枚举中的 property)强相关的别名
  2. 引入 Tuple:我们告诉 JPA (em.createNativeQuery(..., Tuple.class)),不要再返回模糊的 Object[],而是返回一个 Tuple 对象的列表。Tuple 允许我们通过字符串别名 (tuple.get("brand")) 来获取列数据,彻底摆脱了对索引位置的依赖。
  3. 基于别名的解析:新的 parseToExportHashMapUsingTuple 方法变得极其健壮。它的逻辑是“根据列名找到对应的业务ID,再存入Map”,无论 SELECT 子句的顺序如何调整,只要别名不变,映射就永远是正确的。
  4. 一石二鸟:在手动构建 SELECT 子句时,我们顺便将 p.ranksp.created_date 也加了进去,一举解决了本地环境因 sql_mode 严格而导致的 DISTINCT + ORDER BY 报错问题。

结论:编写面向“契约”而非“顺序”的代码

这次重构的成功,核心在于我们转变了编程思想:从隐式的、基于顺序的脆弱约定,转向了显式的、基于别名(契约)的健壮设计

  • 脆弱的设计:依赖于多个部分之间心照不宣的“顺序”约定。
  • 健壮的设计:通过“别名”建立起 SQL 列与业务字段之间明确的、不会轻易改变的“契约”。

这次重构之后,我们的导出功能不仅修复了所有已知 Bug,代码的可读性和可维护性也得到了质的飞跃。未来再有字段增删或顺序调整的需求,我们也可以自信地、安全地快速响应。

希望这次的实战分享,能为你今后的代码设计带来一些启发。

Happy Coding! 💻💖


总结与图表分析 📊

📝 重构前后对比总结表
方面重构前 (Before)重构后 (After)效果 (Effect)
SQL SELECT动态生成,顺序不明确手动编写,列带明确别名可控性 & 兼容性
结果集类型List<Object[]>List<Tuple>灵活性
数据解析依赖列索引 (脆弱)依赖列别名 (健壮)健壮性 & 可维护性
本地报错存在 (sql_mode 问题)已解决环境兼容
线上数据错位存在 (顺序不匹配)已解决数据准确
🗺️ 流程图:两种数据解析逻辑的对比
重构后: 健壮的别名映射
重构前: 脆弱的顺序依赖
遍历 Tuple 中的每一列 (Element)
SQL 返回 Tuple 列表
获取列的别名 (Alias)
根据别名查找对应的业务ID
将 (业务ID -> 列值) 存入Map
映射永远正确
按 Enum 顺序遍历
SQL 返回 Object[]
按递增索引 i
从 Object[] 中取值
SQL列顺序 == Enum顺序?
映射正确
数据张冠李戴!
🔄 时序图:重构后的数据处理流程
ProductSqlServiceEntityManager数据库createNativeQuery(sqlWithAlias, Tuple.class)执行带明确别名的 SQL返回结果集返回 List<Tuple>parseToExportHashMapUsingTuple对每个Tuple, 遍历其Elements, 通过 getAlias() 获取列名, 进行精确映射loop[遍历每个 Tuple]ProductSqlServiceEntityManager数据库
🚦 状态图:一个数据字段在导出过程中的状态
存储在数据库
被SQL查询并赋予别名
被按别名解析并映射
被写入Excel单元格
InDatabase
InTuple
InHashMap
InExcel
🏛️ 类图:核心类与JPA Tuple的关系
"使用"
"创建"
"处理"
"包含"
1
*
ProductSqlService
-EntityManager em
+exportList() : List<HashMap>
-parseToExportHashMapUsingTuple(List<Tuple>)
«Interface»
EntityManager
+createNativeQuery(sql, Tuple.class) : Query
«Interface»
Tuple
+getElements() : List<TupleElement>
+get(alias) : Object
«Interface»
TupleElement
+getAlias() : String
🔗 实体关系图:SQL查询涉及的核心表
PRODUCTintidPKintbrand_idFKintcategory_idFKvarcharnamedatetimecreated_dateBRANDintidPKvarcharnamevarcharenglish_nameCATEGORYintidPKPRODUCT_ADMIN_MAPPINGintidPKintproduct_idFKintadmin_idFK属于属于拥有权限
🧠 思维导图 (Markdown Format)
  • 后端重构实录:修复导出Bug
    • 🎯 现象 (Bugs)
      • 线上: 导出Excel数据列“张冠李戴”
      • 本地: 执行导出时直接报 SQL (Structured Query Language) 错误
    • 🤔 根本原因
      • 数据错位: parseToExportHashMap 方法依赖脆弱的列顺序进行映射
      • 本地报错: 本地 MySQL sql_mode 更严格,SELECT DISTINCT 查询的 ORDER BY 列必须在 SELECT 列表中
    • 💡 重构方案:拥抱“契约”而非“顺序”
      • 核心技术: 引入 JPA (Java Persistence API) Tuple
      • 步骤一: 修改 SQL 查询 (exportList)
        • 动作: 手动编写 SELECT 子句,为每一列都赋予一个与业务字段对应的明确别名 (Alias)
        • 效果:
            1. 解决了 sql_mode 报错问题 (将 ORDER BY 的列加入 SELECT)
            1. 为基于别名的解析提供了“契约”
      • 步骤二: 修改结果集类型
        • 动作: 调用 em.createNativeQuery(sql, Tuple.class)
        • 效果: 查询结果不再是 Object[],而是 List<Tuple>
      • 步骤三: 重构解析逻辑 (parseToExportHashMapUsingTuple)
        • 动作:
          • 遍历 List<Tuple>
          • 对每个 Tuple,遍历其 TupleElement
          • 通过 element.getAlias() 获取列别名
          • 根据别名查找对应的业务字段 ID,并将(ID -> 值)存入 HashMap
        • 效果: 映射关系不再依赖顺序,变得极其健壮
    • ✅ 最终收益
      • Bug修复: 同时解决了线上数据错位和本地SQL报错
      • 代码质量提升:
        • 健壮性: 不再受 SELECTEnum 顺序调整的影响
        • 可读性: SQL 意图更明确,解析逻辑更清晰
        • 可维护性: 未来增删字段,只需同步修改 SELECTEnum,不易出错

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值