1. 引言
在 PHP 项目里,随着业务代码不断膨胀,对象的创建与共享往往会变成一团乱麻:new 散落在各处、数据库连接被反复实例化、日志对象到处传递。设计模式正是解决这类问题的成熟经验总结。
本文聚焦 PHP 中最常用、也最容易被误用的两类创建型模式------工厂模式 与单例模式,并给出一个可落地的日志记录器案例。
2. 为什么需要工厂模式
直接 new 一个对象看似简单,但会带来几个问题:
- 耦合:调用方必须知道具体类名与构造参数。
- 难以替换 :想从
FileLogger换成RedisLogger,需要改动所有调用点。 - 构造复杂:对象初始化需要多步配置时,重复代码会迅速蔓延。
工厂模式的核心思想是:把「创建对象」这件事从业务逻辑中抽离出来,由专门的工厂负责。
用一张图概括「直接 new」与「工厂创建」的差别:
3. 简单工厂
简单工厂(Simple Factory)并不是 GoF 23 种模式之一,但它是理解工厂家族的最佳起点。
php
<?php
interface Logger
{
public function log(string $message): void;
}
class FileLogger implements Logger
{
public function __construct(private string $path) {}
public function log(string $message): void
{
file_put_contents($this->path, $message . PHP_EOL, FILE_APPEND);
}
}
class EchoLogger implements Logger
{
public function log(string $message): void
{
echo $message . PHP_EOL;
}
}
class LoggerFactory
{
public static function create(string $type): Logger
{
return match ($type) {
'file' => new FileLogger(__DIR__ . '/app.log'),
'echo' => new EchoLogger(),
default => throw new InvalidArgumentException("未知的日志类型: {$type}"),
};
}
}
$logger = LoggerFactory::create('file');
$logger->log('用户登录成功');
优点:调用方只需关心类型字符串,创建逻辑集中在一处。
缺点 :新增类型必须修改 LoggerFactory,违反开闭原则;工厂类会随着类型增多而膨胀。
4. 工厂方法
工厂方法(Factory Method)把「创建哪个具体产品」的决定权下放给子类,从而让新增产品无需修改已有工厂。
php
<?php
abstract class LoggerFactory
{
abstract protected function createLogger(): Logger;
public function log(string $message): void
{
$this->createLogger()->log($message);
}
}
class FileLoggerFactory extends LoggerFactory
{
protected function createLogger(): Logger
{
return new FileLogger(__DIR__ . '/app.log');
}
}
class EchoLoggerFactory extends LoggerFactory
{
protected function createLogger(): Logger
{
return new EchoLogger();
}
}
$factory = new FileLoggerFactory();
$factory->log('订单创建成功');
优点:新增日志类型只需新增一个工厂子类,符合开闭原则。
缺点:类的数量成倍增长,每增加一个产品就要增加一个工厂。
5. 抽象工厂模式
当产品不止一个维度时(例如「日志记录器 + 日志格式化器」),抽象工厂(Abstract Factory)就派上用场了。它用于创建一系列相关或相互依赖的对象。
php
<?php
interface Formatter
{
public function format(string $message): string;
}
class JsonFormatter implements Formatter
{
public function format(string $message): string
{
return json_encode(['msg' => $message, 'time' => date('c')], JSON_UNESCAPED_UNICODE);
}
}
class PlainFormatter implements Formatter
{
public function format(string $message): string
{
return '[' . date('Y-m-d H:i:s') . '] ' . $message;
}
}
interface LoggerAbstractFactory
{
public function createLogger(): Logger;
public function createFormatter(): Formatter;
}
class FileLoggerAbstractFactory implements LoggerAbstractFactory
{
public function createLogger(): Logger
{
return new FileLogger(__DIR__ . '/app.log');
}
public function createFormatter(): Formatter
{
return new PlainFormatter();
}
}
class EchoLoggerAbstractFactory implements LoggerAbstractFactory
{
public function createLogger(): Logger
{
return new EchoLogger();
}
public function createFormatter(): Formatter
{
return new JsonFormatter();
}
}
优点:保证同一产品族内的对象彼此兼容,切换产品族只需替换工厂。
缺点:扩展新的产品维度(比如再加一个「日志输出目标」)需要修改所有工厂接口,代价较高。
抽象工厂的产品族关系可以这样理解:
6. 单例模式实现与弊端
单例模式(Singleton)保证一个类只有一个实例,并提供全局访问点。
php
<?php
class Config
{
private static ?Config $instance = null;
private array $data = [];
private function __construct() {}
private function __clone() {}
public function __wakeup()
{
throw new RuntimeException('禁止反序列化单例');
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function set(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
}
Config::getInstance()->set('app_name', 'demo');
echo Config::getInstance()->get('app_name');
实现要点:
- 构造函数私有化,禁止外部
new。 - 私有化
__clone,防止克隆。 - 禁止反序列化,避免绕过单例约束。
- 静态属性持有唯一实例。
弊端:
- 隐藏依赖 :类内部直接调用
Config::getInstance(),依赖关系不可见,测试时难以替换。 - 全局状态:任何地方都能修改单例,容易产生难以追踪的副作用。
- 并发与生命周期问题:在常驻内存的运行时(如 Swoole、RoadRunner)中,单例会跨请求存活,可能造成数据污染。
- 违反单一职责:类既要负责业务,又要负责自身的实例管理。
单例的获取流程与常见破坏入口如下:
7. 依赖注入替代单例
依赖注入(Dependency Injection)把「获取依赖」变成「接收依赖」,让依赖关系显式化,同时保留「只创建一次」的能力------由容器负责生命周期管理。
php
<?php
class Config
{
public function __construct(private array $data = []) {}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
}
class UserService
{
public function __construct(private Config $config) {}
public function register(string $name): string
{
$appName = $this->config->get('app_name', 'app');
return "{$appName}: 用户 {$name} 注册成功";
}
}
// 简易容器:同一个 key 只创建一次,等价于「受控的单例」
class Container
{
private array $instances = [];
private array $bindings = [];
public function bind(string $id, callable $resolver): void
{
$this->bindings[$id] = $resolver;
}
public function get(string $id): mixed
{
if (isset($this->instances[$id])) {
return $this->instances[$id];
}
if (!isset($this->bindings[$id])) {
throw new RuntimeException("未注册的服务: {$id}");
}
return $this->instances[$id] = ($this->bindings[$id])($this);
}
}
$container = new Container();
$container->bind(Config::class, fn () => new Config(['app_name' => 'demo']));
$container->bind(UserService::class, fn (Container $c) => new UserService($c->get(Config::class)));
echo $container->get(UserService::class)->register('张三');
对比单例,依赖注入的优势在于:
- 依赖显式声明在构造函数中,阅读代码即可知道类需要什么。
- 单元测试可以直接传入 mock 对象,无需反射修改静态属性。
- 生命周期由容器统一管理,常驻内存场景下可按请求重建。
8. 实际案例:日志记录器
把前面的内容串起来,做一个支持多驱动、可注入、可测试的日志记录器。
php
<?php
interface Logger
{
public function log(string $level, string $message): void;
}
class FileLogger implements Logger
{
public function __construct(private string $path) {}
public function log(string $level, string $message): void
{
$line = sprintf('[%s] %s: %s%s', date('Y-m-d H:i:s'), strtoupper($level), $message, PHP_EOL);
file_put_contents($this->path, $line, FILE_APPEND);
}
}
class NullLogger implements Logger
{
public function log(string $level, string $message): void
{
// 测试环境静默
}
}
class LoggerFactory
{
public static function create(string $driver, array $options = []): Logger
{
return match ($driver) {
'file' => new FileLogger($options['path'] ?? __DIR__ . '/app.log'),
'null' => new NullLogger(),
default => throw new InvalidArgumentException("不支持的日志驱动: {$driver}"),
};
}
}
class OrderService
{
public function __construct(private Logger $logger) {}
public function create(int $orderId): void
{
// 业务逻辑
$this->logger->log('info', "订单 {$orderId} 创建成功");
}
}
// 组装:工厂负责创建,容器负责共享
$container = new Container();
$container->bind(Logger::class, fn () => LoggerFactory::create('file', ['path' => __DIR__ . '/order.log']));
$container->bind(OrderService::class, fn (Container $c) => new OrderService($c->get(Logger::class)));
$container->get(OrderService::class)->create(1001);
这个案例体现了三者的分工:
- 工厂 :屏蔽
FileLogger的构造细节,调用方只传驱动名。 - 容器(受控单例):保证整个请求内只创建一个日志实例,避免重复打开文件句柄。
- 依赖注入 :
OrderService不关心日志从哪来,测试时注入NullLogger即可。
整个案例的组装与调用链路如下:
9. 总结
| 模式 | 解决的问题 | 适用场景 | 主要代价 |
|---|---|---|---|
| 简单工厂 | 集中创建逻辑 | 类型少、变化不频繁 | 违反开闭原则 |
| 工厂方法 | 新增产品不改旧代码 | 产品类型会持续扩展 | 类数量膨胀 |
| 抽象工厂 | 创建产品族 | 多维度、需保证兼容 | 扩展维度成本高 |
| 单例 | 全局唯一实例 | 无状态工具、配置读取 | 隐藏依赖、难测试 |
实践建议:
- 优先使用依赖注入,把单例降级为容器中的「共享实例」。
- 产品类型少时用简单工厂,类型会持续增长时再演进到工厂方法。
- 只有在确实需要全局唯一、且无状态时,才考虑单例,并做好防克隆、防反序列化。
最后用一张决策图收束全文的选型思路: