27、实战案例五:数据处理管道重构 - 提升可读性与可维护性

数据处理管道,说白了就是把一堆数据从一个形态变成另一个形态的流水线。我在项目中见过太多这样的代码——一个函数里塞了七八个步骤,每个步骤之间用临时变量传递,最后返回一个结果。嗯,这种代码能跑,但改起来真要命。

今天这个案例,是我从一个数据报表系统中抽出来的。当时接手的时候,整个管道有200多行,全写在一个方法里。我花了整整一个下午才理清楚它到底在干什么。重构之后,代码量没少多少,但可读性提升了不止一个档次。

重构前的代码长什么样

先看看原始代码。这是一个处理用户行为数据的管道,从原始日志到最终报表,中间经历了过滤、清洗、聚合、格式化四个阶段。

public class DataProcessor {
    public List<ReportRow> process(List<RawLog> logs) {
        // 第一步:过滤无效数据
        List<RawLog> validLogs = new ArrayList<>();
        for (RawLog log : logs) {
            if (log.getUserId() != null && log.getAction() != null
                    && log.getTimestamp() > 0) {
                validLogs.add(log);
            }
        }

        // 第二步:清洗数据,补全缺失字段
        List<CleanedLog> cleanedLogs = new ArrayList<>();
        for (RawLog log : validLogs) {
            CleanedLog cleaned = new CleanedLog();
            cleaned.setUserId(log.getUserId());
            cleaned.setAction(log.getAction());
            cleaned.setTimestamp(log.getTimestamp());
            if (log.getDuration() == null) {
                cleaned.setDuration(0);
            } else {
                cleaned.setDuration(log.getDuration());
            }
            cleanedLogs.add(cleaned);
        }

        // 第三步:按用户分组聚合
        Map<String, List<CleanedLog>> grouped = new HashMap<>();
        for (CleanedLog log : cleanedLogs) {
            grouped.computeIfAbsent(log.getUserId(), k -> new ArrayList<>()).add(log);
        }

        // 第四步:计算每个用户的统计指标
        List<ReportRow> result = new ArrayList<>();
        for (Map.Entry<String, List<CleanedLog>> entry : grouped.entrySet()) {
            ReportRow row = new ReportRow();
            row.setUserId(entry.getKey());
            row.setTotalActions(entry.getValue().size());
            row.setAvgDuration(entry.getValue().stream()
                    .mapToInt(CleanedLog::getDuration).average().orElse(0));
            result.add(row);
        }

        return result;
    }
}

这段代码的问题很明显:所有逻辑混在一起,没有边界。你想想看,如果哪天要改过滤规则,你得在200行代码里找到对应的for循环。如果哪天要加一个排序步骤,你都不知道该插在哪里。

核心问题总结:

  • 单一职责原则被打破——一个方法干了四件事
  • 步骤之间通过临时变量耦合,修改一个步骤可能影响后续所有步骤
  • 没有明确的输入输出定义,每个步骤的边界模糊
  • 无法单独测试某个步骤,必须跑完整条管道

重构思路:把管道拆成独立步骤

我个人习惯的做法是:先画图,再写代码。把数据处理流程可视化出来,你就能清楚地看到每个步骤的输入和输出是什么。

数据处理管道重构 - 步骤分解 过滤 RawLog → RawLog 清洗 RawLog → CleanedLog 聚合 CleanedLog → Map 格式化 Map → ReportRow 输入: List<RawLog> 输出: List<ReportRow> 每个步骤的职责: 1. 过滤:剔除无效数据(userId为空、action为空、timestamp非法) 2. 清洗:补全缺失字段(duration为null时设为0) 3. 聚合:按userId分组,为后续统计做准备 4. 格式化:计算每个用户的统计指标,生成报表行

你看,这张图把整个流程拆成了四个清晰的步骤。每个步骤只做一件事,输入输出都很明确。这就是重构的目标——让代码结构跟流程图一样清晰

重构后的代码

我建议的做法是:每个步骤封装成一个独立的方法,方法名直接说明它在做什么。这样读代码的人一眼就能看到整个管道的骨架。

public class DataProcessor {
    public List<ReportRow> process(List<RawLog> logs) {
        List<RawLog> validLogs = filterInvalidLogs(logs);
        List<CleanedLog> cleanedLogs = cleanLogs(validLogs);
        Map<String, List<CleanedLog>> grouped = groupByUser(cleanedLogs);
        return formatReport(grouped);
    }

    private List<RawLog> filterInvalidLogs(List<RawLog> logs) {
        return logs.stream()
                .filter(log -> log.getUserId() != null)
                .filter(log -> log.getAction() != null)
                .filter(log -> log.getTimestamp() > 0)
                .collect(Collectors.toList());
    }

    private List<CleanedLog> cleanLogs(List<RawLog> logs) {
        return logs.stream()
                .map(log -> {
                    CleanedLog cleaned = new CleanedLog();
                    cleaned.setUserId(log.getUserId());
                    cleaned.setAction(log.getAction());
                    cleaned.setTimestamp(log.getTimestamp());
                    cleaned.setDuration(log.getDuration() != null ? log.getDuration() : 0);
                    return cleaned;
                })
                .collect(Collectors.toList());
    }

    private Map<String, List<CleanedLog>> groupByUser(List<CleanedLog> logs) {
        return logs.stream()
                .collect(Collectors.groupingBy(CleanedLog::getUserId));
    }

    private List<ReportRow> formatReport(Map<String, List<CleanedLog>> grouped) {
        return grouped.entrySet().stream()
                .map(entry -> {
                    ReportRow row = new ReportRow();
                    row.setUserId(entry.getKey());
                    row.setTotalActions(entry.getValue().size());
                    row.setAvgDuration(entry.getValue().stream()
                            .mapToInt(CleanedLog::getDuration)
                            .average()
                            .orElse(0));
                    return row;
                })
                .collect(Collectors.toList());
    }
}

小技巧:方法名用动词+名词的形式,比如 filterInvalidLogs、cleanLogs。这样读代码的时候,你甚至不需要看方法体,光看名字就知道它在干什么。我在项目中一直坚持这个命名习惯,效果很好。

重构带来的好处

重构之后,代码的可读性和可维护性都有了质的提升。具体来说:

维度 重构前 重构后
可读性 200行代码混在一起,需要逐行阅读才能理解 主方法5行,每个步骤一目了然
可维护性 修改一个步骤可能影响其他步骤 每个步骤独立,修改互不影响
可测试性 只能测试整个process方法 每个步骤都可以单独写单元测试
可复用性 逻辑耦合在方法内部,无法复用 每个方法都可以被其他管道调用

避坑指南:我曾经在重构一个类似的管道时,把每个步骤都做成了独立的类,还用了策略模式。结果代码变得非常复杂,反而更难维护了。后来我意识到,对于这种简单的线性管道,方法级别的拆分就足够了。不要为了设计模式而设计模式,过度设计比不设计更可怕。

进一步优化:引入管道模式

如果你的管道需要频繁调整步骤顺序,或者需要动态组合不同的步骤,可以考虑引入管道模式。不过我个人建议,先保持简单,等确实需要了再升级

// 定义一个函数式接口
@FunctionalInterface
interface PipeStep<T, R> {
    R execute(T input);
}

// 管道类
public class DataPipeline {
    private List<PipeStep> steps = new ArrayList<>();

    public DataPipeline addStep(PipeStep step) {
        steps.add(step);
        return this;
    }

    public Object execute(Object input) {
        Object result = input;
        for (PipeStep step : steps) {
            result = step.execute(result);
        }
        return result;
    }
}

// 使用示例
DataPipeline pipeline = new DataPipeline();
pipeline.addStep((List<RawLog> logs) -> filterInvalidLogs(logs))
        .addStep((List<RawLog> logs) -> cleanLogs(logs))
        .addStep((List<CleanedLog> logs) -> groupByUser(logs))
        .addStep((Map<String, List<CleanedLog>> grouped) -> formatReport(grouped));

List<ReportRow> result = (List<ReportRow>) pipeline.execute(logs);

你看,管道模式让步骤的组合变得更加灵活。你可以随时插入、删除、替换某个步骤,而不需要修改任何现有代码。不过说实话,在大多数业务场景下,方法级别的拆分已经够用了。管道模式更适合那些需要动态配置管道的场景。

总结

数据处理管道的重构,核心就一句话:把一个大方法拆成多个小方法,每个方法只做一件事。这样做的好处是显而易见的——代码更容易读、更容易改、更容易测。

嗯,最后再啰嗦一句:重构不是炫技,而是为了让代码更清晰。如果你重构完之后,代码反而更难懂了,那说明你走偏了。保持简单,保持清晰,这才是重构的真谛。


公众号:蓝海资料掘金营,微信deep3321