跳到内容

第 6 卷 / 第 8 章 / 第 1 课

8.1 从直接构造到工厂族 ​

导入任务开始只读文本文件,后来又要支持别的格式;赛事服务在本地使用内存存储,上线后需要数据库适配器。调用者如果到处选择实现,每加一种配置就要满项目找 new。

对象创建本身没有问题。只有当调用者被迫知道不属于自己的选择、装配顺序或兼容规则时,才需要把创建逻辑提升为边界。创建型模式的价值,是让“使用什么”与“怎样得到它”保持适当距离。

先从最简单的构造方式开始 ​

选择创建方式前,依次考虑:

情况优先方案
构造参数少、语义明确公开构造器
同一类型有多种命名清楚的创建语义静态命名工厂
调用者只知道抽象,装配处选择具体实现简单工厂/注册表/依赖注入
父类流程需要让子类替换某个创建步骤Factory Method
必须一起切换一族兼容产品Abstract Factory
对象有许多可选项或分步校验Builder
从现有配置派生新对象比重新构造更自然Prototype/显式复制

如果 new Money(amount, currency) 已经清楚,不要为了模式目录再加 MoneyFactoryFactory。

静态命名工厂:先把创建语义说清楚 ​

本节完整类型使用 Java 21,每个 public 类型放入同名 .java 文件。依赖项目类型的装配片段会另行注明。

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。

java
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
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 的典型结构是:父类拥有一段稳定流程,其中某一步创建的产品由子类决定。

java
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:整体切换一族产品 ​

假设赛事应用可以运行在“本地演示”和“生产”两种配置下。下面是项目装配片段,仓储、事件端口及其内存实现由项目提供:

java
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 表示这种隐藏查找:

java
// 隐藏依赖,不推荐
var repository = Container.resolve(TournamentRepository.class);

这种 Service Locator 让依赖从签名中消失,测试和影响分析都更困难。容器属于 composition root,不应扩散成业务 API。

这一节的选择问题 ​

  1. 调用者是否真的不该知道具体实现?
  2. 变化发生在单个产品、父类流程的创建步骤,还是整个产品族?
  3. 谁选择实现,选择发生在编译期、启动期还是请求期?
  4. 对象是否拥有外部资源,谁负责关闭?
  5. 新增实现是否来自独立模块,值得使用注册表?
  6. 一个简单构造器或静态命名工厂是否已经足够?

下一节处理对象内部组装、复制语义和共享实例范围。

参考资料 ​

Built with VitePress | Software Systems Atlas