8.1 从直接构造到工厂族
导入任务开始只读文本文件,后来又要支持别的格式;赛事服务在本地使用内存存储,上线后需要数据库适配器。调用者如果到处选择实现,每加一种配置就要满项目找 new。
对象创建本身没有问题。只有当调用者被迫知道不属于自己的选择、装配顺序或兼容规则时,才需要把创建逻辑提升为边界。创建型模式的价值,是让“使用什么”与“怎样得到它”保持适当距离。
先从最简单的构造方式开始
选择创建方式前,依次考虑:
| 情况 | 优先方案 |
|---|---|
| 构造参数少、语义明确 | 公开构造器 |
| 同一类型有多种命名清楚的创建语义 | 静态命名工厂 |
| 调用者只知道抽象,装配处选择具体实现 | 简单工厂/注册表/依赖注入 |
| 父类流程需要让子类替换某个创建步骤 | Factory Method |
| 必须一起切换一族兼容产品 | Abstract Factory |
| 对象有许多可选项或分步校验 | Builder |
| 从现有配置派生新对象比重新构造更自然 | Prototype/显式复制 |
如果 new Money(amount, currency) 已经清楚,不要为了模式目录再加 MoneyFactoryFactory。
静态命名工厂:先把创建语义说清楚
本节完整类型使用 Java 21,每个 public 类型放入同名 .java 文件。依赖项目类型的装配片段会另行注明。
public record MatchId(java.util.UUID value) {
public MatchId {
java.util.Objects.requireNonNull(value, "value");
}
public static MatchId newId() {
return new MatchId(java.util.UUID.randomUUID());
}
public static MatchId parse(String text) {
java.util.Objects.requireNonNull(text, "text");
java.util.UUID value = java.util.UUID.fromString(text);
if (!value.toString().equalsIgnoreCase(text)) {
throw new IllegalArgumentException("expected canonical UUID");
}
return new MatchId(value);
}
}newId() 与 parse() 的名字表达不同来源,还能隐藏规范化、缓存或具体返回类型。它们并不是 GoF Factory Method:这里只是类型自身提供静态创建入口,没有通过子类覆盖一个创建步骤。
本例 parse() 只接受完整的 8-4-4-4-12 UUID 写法,允许大小写字母;null 抛出 NullPointerException,格式错误抛出 IllegalArgumentException。回写比较明确拒绝缩短分组的输入,不依赖解析器是否宽容。
简单工厂:把运行期选择集中在装配边缘
下面的项目片段假设已有 RankingStrategy 接口及 Elo、Points 两个实现;未知名称被拒绝,空名称抛出 NullPointerException。
public final class RankingStrategyFactory {
public RankingStrategy create(String configuredType) {
java.util.Objects.requireNonNull(configuredType, "configuredType");
return switch (configuredType) {
case "elo" -> new EloRankingStrategy();
case "points" -> new PointsRankingStrategy();
default -> throw new IllegalArgumentException(
"unsupported ranking strategy: " + configuredType);
};
}
}这个 switch 并不天然是坏设计。系统总要有一个地方把外部配置映射到具体实现;把它集中在装配边缘,比让业务代码到处分支更容易审查。
也可以用名称到创建函数的注册表。下面是固定、不可修改的注册表,还没有实现动态插件发现:
java.util.Map<String, java.util.function.Supplier<RankingStrategy>> providers =
java.util.Map.of(
"elo", EloRankingStrategy::new,
"points", PointsRankingStrategy::new);查找时要先处理未知键,再调用 Supplier.get();否则拼错配置会变成一条难懂的空指针异常。这里两个构造器引用每次创建新策略,但 Supplier 接口本身不承诺返回新对象。若由独立模块注册,可在启动期收集、拒绝重复键,再冻结映射;对象复用、关闭责任和启动失败仍由装配契约规定。Java Supplier 文档 明确允许复用结果。
Factory Method:子类覆盖流程中的创建钩子
Factory Method 的典型结构是:父类拥有一段稳定流程,其中某一步创建的产品由子类决定。
public interface RecordReader extends AutoCloseable {
java.util.List<String> readAll() throws java.io.IOException;
@Override
void close() throws java.io.IOException;
}
public record ImportReport(java.util.List<String> records) {
public ImportReport {
records = java.util.List.copyOf(records);
}
}
public abstract class ImportJob {
public final ImportReport run(java.nio.file.Path source)
throws java.io.IOException {
RecordReader reader = java.util.Objects.requireNonNull(
createReader(source), "reader");
try (reader) {
return new ImportReport(reader.readAll());
}
}
protected abstract RecordReader createReader(java.nio.file.Path source)
throws java.io.IOException;
}
public final class LineRecordReader implements RecordReader {
private final java.io.BufferedReader reader;
public LineRecordReader(java.nio.file.Path source)
throws java.io.IOException {
reader = java.nio.file.Files.newBufferedReader(
source, java.nio.charset.StandardCharsets.UTF_8);
}
@Override
public java.util.List<String> readAll() throws java.io.IOException {
var lines = new java.util.ArrayList<String>();
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
}
return lines;
}
@Override
public void close() throws java.io.IOException { reader.close(); }
}
public final class LineImportJob extends ImportJob {
@Override
protected RecordReader createReader(java.nio.file.Path source)
throws java.io.IOException {
return new LineRecordReader(source);
}
}例如源文件包含两行 alpha、报名,new LineImportJob().run(path).records() 返回 [alpha, 报名];空文件返回空列表。这里按 UTF-8 读取物理文本行,不处理 CSV 引号等语法,而且 readAll() 会把文件内容全部留在内存中。
父类负责读取、生成不可修改的报告快照并关闭 reader,子类只选择 reader。读取抛出异常时仍会关闭;若读取和关闭都失败,关闭异常记入原异常的 getSuppressed()。若创建过程在返回 reader 前失败,工厂必须清理自己已经取得的资源,父类拿不到它们。Java 的资源关闭规则 定义了这套异常处理行为。
如果父类除了调用 createReader() 没有任何稳定流程,这套层次可能只是把简单工厂绕远了。此时构造器传入 RecordReaderFactory 的组合方式更直接。
Abstract Factory:整体切换一族产品
假设赛事应用可以运行在“本地演示”和“生产”两种配置下。下面是项目装配片段,仓储、事件端口及其内存实现由项目提供:
public interface TournamentInfrastructure {
TournamentRepository tournaments();
EventPublisher events();
}
public final class InMemoryInfrastructure
implements TournamentInfrastructure {
private final InMemoryTournamentRepository repository =
new InMemoryTournamentRepository();
private final InMemoryEventPublisher events =
new InMemoryEventPublisher();
@Override
public TournamentRepository tournaments() { return repository; }
@Override
public EventPublisher events() { return events; }
}另一个 PostgresKafkaInfrastructure 可以提供生产适配器。应用装配选择一次配置,就得到整族相关对象。示例工厂复用自己持有的两个实例,并非每调用一次就新建一套。把 PostgreSQL 和 Kafka 放进同一个工厂也不会获得跨系统原子提交;可靠事件发布仍需事务协调或 Outbox。
Abstract Factory 最适合“产品族维度稳定、具体家族会整体切换”的问题。它有一个明显代价:给产品族增加全新产品(例如再加 AuditStore)通常要修改工厂接口和所有家族实现。它方便增加整套家族,却让新增产品种类更昂贵。
“兼容产品”仍需类型与测试保证
同一个工厂返回两个对象,不会自动让它们兼容。契约应说明:
- 是否共享事务、连接或生命周期;
- 一个对象关闭后是否影响另一个;
- 事件与存储的数据版本是否匹配;
- 是否允许跨工厂家族混用;
- 创建失败时已创建资源怎样释放。
对资源型产品,工厂还要明确谁拥有关闭责任。创建对象只是生命周期的开始,不是全部。
工厂、DI 容器与 Service Locator
依赖注入容器可以在系统入口选择并装配具体实现,承担一部分工厂工作。业务对象仍应通过构造器声明依赖,不应在方法里调用全局容器:
下面用假设的容器 API 表示这种隐藏查找:
// 隐藏依赖,不推荐
var repository = Container.resolve(TournamentRepository.class);这种 Service Locator 让依赖从签名中消失,测试和影响分析都更困难。容器属于 composition root,不应扩散成业务 API。
这一节的选择问题
- 调用者是否真的不该知道具体实现?
- 变化发生在单个产品、父类流程的创建步骤,还是整个产品族?
- 谁选择实现,选择发生在编译期、启动期还是请求期?
- 对象是否拥有外部资源,谁负责关闭?
- 新增实现是否来自独立模块,值得使用注册表?
- 一个简单构造器或静态命名工厂是否已经足够?
下一节处理对象内部组装、复制语义和共享实例范围。