当一个 Java 方法被多个布尔参数控制时,分支数量会快速增长,调用方也难以读懂。本文以订单导出为例,演示如何用命令对象、处理器注册表和不可变请求模型拆分流程,并讨论模式边界与常见误区。
问题背景:参数不多,流程却越来越难读
业务系统里经常有这样的导出方法:既支持 CSV,也支持 Excel;既可以直接下载,也可以发送邮件;有时还要包含敏感字段。最初只有一个参数,继续加 if 似乎没什么问题,直到方法变成这样:
public ExportResult exportOrders(
List<Order> orders,
boolean excel,
boolean sendEmail,
boolean includeSensitiveData) {
if (!includeSensitiveData) {
orders.forEach(order -> order.setCustomerPhone("***"));
}
byte[] content;
String fileName;
if (excel) {
content = buildExcel(orders);
fileName = "orders.xlsx";
} else {
content = buildCsv(orders);
fileName = "orders.csv";
}
if (sendEmail) {
emailService.send(fileName, content);
return ExportResult.emailed(fileName);
}
return ExportResult.download(fileName, content);
}它的问题不只是代码长。调用处的语义也很模糊:
exportOrders(orders, true, false, true);三个布尔值分别代表什么,必须回到方法定义才能确认。更麻烦的是,布尔参数会形成组合:三个开关理论上有八种情况。以后再增加“是否压缩”,组合会变成十六种,而测试通常无法覆盖所有路径。
此外,示例代码还直接修改了传入的订单对象。调用方如果在导出后继续使用这些对象,可能拿到已经脱敏的数据。这类副作用隐藏在流程内部,很难从方法签名看出来。
第一步:先用明确类型替代布尔参数
重构不必一开始就套用设计模式。先让输入表达业务含义:
public enum ExportFormat {
CSV, EXCEL
}
public enum DeliveryMethod {
DOWNLOAD, EMAIL
}
public record ExportRequest(
ExportFormat format,
DeliveryMethod deliveryMethod,
boolean includeSensitiveData,
String email) {
public ExportRequest {
Objects.requireNonNull(format, "format");
Objects.requireNonNull(deliveryMethod, "deliveryMethod");
if (deliveryMethod == DeliveryMethod.EMAIL
&& (email == null || email.isBlank())) {
throw new IllegalArgumentException("邮件交付必须提供邮箱地址");
}
}
}调用代码随即清楚很多:
ExportRequest request = new ExportRequest(
ExportFormat.EXCEL,
DeliveryMethod.EMAIL,
false,
"ops@example.com"
);这里使用 record 表达不可变请求,适用于 Java 16 及以上版本。较低版本可以改用普通的不可变类。构造阶段完成基本校验,也能避免无效参数一直流入核心流程。
不过,明确参数只能解决可读性问题。格式生成和交付方式仍是两个独立变化维度,如果继续写在一个方法里,分支依旧会增长。
用命令接口隔离格式生成
先把“如何生成文件”抽象为一个独立命令:
public interface ExportCommand {
ExportFormat supports();
ExportFile execute(List<OrderExportRow> rows);
}
public record ExportFile(String fileName, byte[] content) {
public ExportFile {
Objects.requireNonNull(fileName, "fileName");
content = Arrays.copyOf(
Objects.requireNonNull(content, "content"),
content.length
);
}
@Override
public byte[] content() {
return Arrays.copyOf(content, content.length);
}
}每种格式只负责自己的生成逻辑:
public final class CsvExportCommand implements ExportCommand {
@Override
public ExportFormat supports() {
return ExportFormat.CSV;
}
@Override
public ExportFile execute(List<OrderExportRow> rows) {
StringBuilder csv = new StringBuilder("id,customer,phone,amount\n");
for (OrderExportRow row : rows) {
csv.append(row.id()).append(',')
.append(escape(row.customer())).append(',')
.append(escape(row.phone())).append(',')
.append(row.amount()).append('\n');
}
return new ExportFile(
"orders.csv",
csv.toString().getBytes(StandardCharsets.UTF_8)
);
}
private String escape(String value) {
String safe = value == null ? "" : value.replace("\"", "\"\"");
return "\"" + safe + "\"";
}
}ExcelExportCommand 可以使用项目已经选定的 Excel 库实现,但它仍然只暴露相同的 execute 接口。这样,主流程不需要了解工作簿、单元格或 CSV 转义规则。
用交付处理器拆开下载与邮件发送
文件生成后,还需要决定如何交付:
public interface DeliveryHandler {
DeliveryMethod supports();
ExportResult deliver(ExportFile file, ExportRequest request);
}
public final class EmailDeliveryHandler implements DeliveryHandler {
private final EmailService emailService;
public EmailDeliveryHandler(EmailService emailService) {
this.emailService = emailService;
}
@Override
public DeliveryMethod supports() {
return DeliveryMethod.EMAIL;
}
@Override
public ExportResult deliver(ExportFile file, ExportRequest request) {
emailService.send(request.email(), file.fileName(), file.content());
return ExportResult.emailed(file.fileName());
}
}
public final class DownloadDeliveryHandler implements DeliveryHandler {
@Override
public DeliveryMethod supports() {
return DeliveryMethod.DOWNLOAD;
}
@Override
public ExportResult deliver(ExportFile file, ExportRequest request) {
return ExportResult.download(file.fileName(), file.content());
}
}最终的编排服务只负责选择组件和组织步骤:
public final class OrderExportService {
private final Map<ExportFormat, ExportCommand> commands;
private final Map<DeliveryMethod, DeliveryHandler> handlers;
public OrderExportService(
List<ExportCommand> commandList,
List<DeliveryHandler> handlerList) {
this.commands = indexCommands(commandList);
this.handlers = indexHandlers(handlerList);
}
public ExportResult export(List<Order> orders, ExportRequest request) {
List<OrderExportRow> rows = orders.stream()
.map(order -> toRow(order, request.includeSensitiveData()))
.toList();
ExportCommand command = requireCommand(request.format());
ExportFile file = command.execute(rows);
DeliveryHandler handler = requireHandler(request.deliveryMethod());
return handler.deliver(file, request);
}
private OrderExportRow toRow(Order order, boolean includeSensitiveData) {
String phone = includeSensitiveData ? order.getCustomerPhone() : "***";
return new OrderExportRow(
order.getId(),
order.getCustomerName(),
phone,
order.getAmount()
);
}
private ExportCommand requireCommand(ExportFormat format) {
ExportCommand command = commands.get(format);
if (command == null) {
throw new IllegalArgumentException("不支持的导出格式:" + format);
}
return command;
}
private DeliveryHandler requireHandler(DeliveryMethod method) {
DeliveryHandler handler = handlers.get(method);
if (handler == null) {
throw new IllegalArgumentException("不支持的交付方式:" + method);
}
return handler;
}
private Map<ExportFormat, ExportCommand> indexCommands(
List<ExportCommand> commandList) {
return commandList.stream().collect(Collectors.toUnmodifiableMap(
ExportCommand::supports,
Function.identity(),
(left, right) -> {
throw new IllegalStateException("导出格式处理器重复:" + left.supports());
}
));
}
private Map<DeliveryMethod, DeliveryHandler> indexHandlers(
List<DeliveryHandler> handlerList) {
return handlerList.stream().collect(Collectors.toUnmodifiableMap(
DeliveryHandler::supports,
Function.identity(),
(left, right) -> {
throw new IllegalStateException("交付处理器重复:" + left.supports());
}
));
}
}这里的命令模式不是为了消灭所有 if,而是把可独立变化、可单独测试的行为封装起来。新增 PDF 格式时,只需增加一个 PdfExportCommand 并完成注册,原有 CSV、Excel 和交付逻辑都不需要修改。
测试也从组合测试变成组件测试
重构后,可以分别验证脱敏、格式选择和交付行为:
@Test
void shouldMaskPhoneWithoutChangingOriginalOrder() {
Order order = new Order(1L, "张三", "13800000000", new BigDecimal("99.00"));
ExportRequest request = new ExportRequest(
ExportFormat.CSV,
DeliveryMethod.DOWNLOAD,
false,
null
);
ExportResult result = service.export(List.of(order), request);
assertThat(new String(result.content(), StandardCharsets.UTF_8))
.contains("***")
.doesNotContain("13800000000");
assertThat(order.getCustomerPhone()).isEqualTo("13800000000");
}这个测试特意检查原始对象没有被修改。相比只断言文件生成成功,它守住了更重要的副作用边界。
常见坑
第一,不要把每个三五行的方法都做成命令类。只有当行为存在多个实现、需要独立测试,或者未来确实会独立变化时,抽象才有价值。固定且简单的逻辑留在服务中往往更容易维护。
第二,注册表必须检查重复项。直接使用 Map.put 会让后注册的实现悄悄覆盖前一个实现,最终表现取决于装配顺序。启动阶段明确失败,比运行时偶尔选择错误实现安全得多。
第三,不要让命令重新依赖整个编排服务,否则会形成循环职责。导出命令负责生成文件,交付处理器负责发送或返回,编排服务负责组织流程,边界应当保持单向。
第四,大文件导出不适合始终使用 byte[]。当数据规模可能明显增长时,应评估临时文件、输出流或对象存储,避免文件内容和中间对象同时占用堆内存。是否改为流式接口,应依据真实文件规模决定,而不是为了“通用”提前复杂化。
实践建议
面对带有多个布尔参数的方法,可以按三个问题逐步判断:参数能否替换成有业务含义的枚举或请求对象;不同分支是否代表独立变化的行为;拆分后能否减少共享状态和副作用。先改善模型,再引入模式,通常比直接创建大量接口更稳妥。
设计模式的价值不在于代码看起来更高级,而在于让新增变化发生在明确的位置。一次有效的重构,应让调用处更容易读懂,让各组件能够独立测试,也让错误配置尽可能早地暴露。如果拆分之后仍要同时修改许多实现,说明真正的变化边界可能还没有找对。