前言
适配器模式(Adapter Pattern)解决的是一个非常具体的问题:你需要的接口,和手上已有的类的接口对不上。它通过增加一个中间类,把一个类的接口「翻译」成客户端期望的另一种接口。生活中最贴切的类比是电源转接头:笔记本要的是某个形状的插头,墙上只有另一种插座,转接头不改变电,只改变「接口形状」。
围绕它有三个常见误解:
- 把适配器当成装饰器(Decorator)。装饰器是在保持同一接口的前提下增强行为(比如给日志加一层「同时写文件」);适配器改变接口形态,让不兼容的东西能对接。判断标准很简单:客户端代码调用前后的方法签名是否一致——一致是装饰,不一致是适配。
- 以为适配器可以随便加。适配器是「隔离层」,它存在的意义是把不可控的外部依赖挡在一个角落。如果给自己的每个类都加一层适配器,那只是把代码量翻倍,并没有隔离任何风险。
- 以为必须继承被适配的类。这类「类适配器」在 PHP 里存在明显短板(下面有对比表),实际项目里用的是对象适配器——持有被适配对象的实例,把调用委托过去。
适配器最常见的真实战场是接入第三方 SDK:不同厂商的方法名、参数顺序、返回结构、错误码各不相同,而业务代码不应该认识它们。本文用「第三方日志库」和「短信服务商」两个例子,讲清对象适配器的写法与边界。
一、三个角色
| 角色 | 职责 | 本文示例 |
|---|
| 目标接口(Target) | 业务代码期望的接口,由你自己的项目定义 | AppLogger |
| 适配者(Adaptee) | 已有的、接口不兼容的类,通常来自第三方 | VendorLogger |
| 适配器(Adapter) | 实现目标接口,把调用翻译成对适配者的调用 | VendorLoggerAdapter |
关键约束是:业务代码只依赖目标接口,适配者与适配器都藏在边界之外。这样替换第三方时,只需要改适配器(甚至只换一个配置项),业务代码不动。
二、对象适配器实战:把第三方日志库接进项目
假设项目内部约定了一个日志接口,而引入的第三方日志库方法名、级别命名、参数结构都不一样:
<?php // 适用于 PHP 8.0+(match 需要 8.0)
declare(strict_types=1);
// 【目标接口】项目内部统一使用它
interface AppLogger
{
public function log(string $level, string $message, array $context = []): void;
}
// 【适配者】第三方库:方法名不同、没有 context 参数、级别用 warn/error 这类词
final class VendorLogger
{
public function writeLine(string $severity, string $text): void
{
printf("[%s] %s\n", strtoupper($severity), $text);
}
}
// 【适配器】实现目标接口,把调用翻译后委托给适配者
final class VendorLoggerAdapter implements AppLogger
{
private VendorLogger $vendor;
public function __construct(VendorLogger $vendor)
{
$this->vendor = $vendor;
}
public function log(string $level, string $message, array $context = []): void
{
// 1) 翻译级别:把项目内的级别词映射到第三方认识的那几个
$severity = match (strtolower($level)) {
'warning' => 'warn',
'error', 'critical', 'alert', 'emergency' => 'error',
default => 'info',
};
// 2) 翻译参数:第三方不支持结构化 context,用 JSON 拼进文本
if ($context !== []) {
$message .= ' ' . json_encode($context, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
// 3) 委托:真正的活由适配者干
$this->vendor->writeLine($severity, $message);
}
}
// 业务代码只认识 AppLogger
function registerUser(AppLogger $logger, string $name): void
{
$logger->log('warning', '库存不足', ['sku' => 'A-100', 'left' => 2]);
$logger->log('info', '用户注册', ['name' => $name]);
}
registerUser(new VendorLoggerAdapter(new VendorLogger()), 'ann');如果项目跑在 PHP 7.x 上,把match换成switch即可(PHP 7 没有match):
<?php // 适用于 PHP 7.0+ 的等价写法
$severity = 'info';
switch (strtolower($level)) {
case 'warning':
$severity = 'warn';
break;
case 'error':
case 'critical':
case 'alert':
case 'emergency':
$severity = 'error';
break;
}三、对象适配器 vs 类适配器
| 维度 | 类适配器(继承适配者) | 对象适配器(持有适配者) |
|---|
| 实现方式 | extends VendorLogger implements AppLogger | 构造函数注入适配者,内部委托 |
| PHP 里的可行度 | 低——单继承,继承位置被占掉 | 高,项目里基本都用这种 |
| 适配多个适配者 | 做不到 | 可以,组合多个对象 |
| 适配适配者的子类 | 被具体类绑死 | 可以,注入不同实现 |
| 测试友好度 | 差(无法替换被继承的行为) | 好(可注入替身对象) |
PHP 不支持多重继承,因此「类适配器」只在极少数场景下可用。这也是为什么在 PHP 里谈适配器,默认指的都是对象适配器。顺带澄清:trait是代码复用机制,不是「多继承的替代品」,用它来实现接口适配只会让方法来源变得难以追踪。
四、统一多个厂商:适配器最典型的用法
同一个能力接入多家供应商时,适配器的价值最明显。业务代码只面向统一的接口,每家厂商的差异都被适配器吃掉:
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
// 【目标接口】统一契约:成功返回消息 ID,失败抛异常
interface SmsSender
{
public function send(string $phone, string $text): string;
}
// 【适配者 A】第三方 SDK:数组进、数组出,用 ok 字段表示成败
final class VendorASdk
{
/** @return array{ok: bool, id?: string, err?: int} */
public function sendSms(array $params): array
{
// 真实 SDK 会发 HTTP 请求,这里用固定结构示意
return ['ok' => true, 'id' => 'A-10086'];
}
}
// 【适配器 A】把数组返回值翻译成「字符串或异常」
final class VendorASender implements SmsSender
{
public function __construct(private VendorASdk $sdk)
{
}
public function send(string $phone, string $text): string
{
$result = $this->sdk->sendSms([
'to' => $phone,
'body' => $text,
'format' => 'json',
]);
if (($result['ok'] ?? false) !== true) {
// 翻译错误:抛出项目自己的异常,不把第三方的返回结构泄漏出去
throw new \RuntimeException('短信发送失败,厂商错误码: ' . ($result['err'] ?? 'unknown'));
}
return (string)$result['id'];
}
}
// 业务代码:完全不知道背后是哪家厂商
function notify(SmsSender $sender, string $phone): string
{
return $sender->send($phone, '您的验证码是 123456');
}
echo notify(new VendorASender(new VendorASdk()), '13800000000'), PHP_EOL;新增厂商时,只需要再写一个VendorBSender implements SmsSender,业务代码一行都不用改——这就是适配器在「供应商替换」场景里最实际的价值。
常见坑点
- ❌ 把适配器当装饰器用:让适配器实现同一个接口,然后在里面加缓存、加重试。
✅ 装饰器保持接口不变并增强行为,适配器负责转换接口;两种意图混在一起会让调用方分不清「谁在做什么」。要加缓存或重试,再套一层装饰器。
- ❌ 在适配器里写业务判断(比如「金额超过 100 就要人工审核」)。
✅ 适配器只做「翻译 + 委托」;业务规则属于领域层,混进来会让适配器无法复用到别的场景。
- ❌ 目标接口的方法签名里出现第三方的类型:
public function send(VendorAResult $r): VendorAResponse。
✅ 目标接口只使用自己项目的类型(字符串、数组、自定义异常);一旦泄漏第三方类型,隔离层就失效了。
- ❌ 适配器里
catch (\Throwable $e) { return null; }把第三方的失败吞掉。
✅ 应把第三方异常翻译成项目自己的异常再抛出,保留足够定位问题的信息(错误码、请求标识),但要避免把敏感信息写进日志。
- ❌ 在适配器内部直接
new VendorASdk(),或者用单例硬编码第三方对象。
✅ 通过构造函数注入(如上面的__construct(private VendorASdk $sdk)),测试时可以注入替身对象。
- ❌ 为了「解耦」给项目自己的每个类都套一层适配器。
✅ 适配器应该只出现在不可控的边界上(第三方 SDK、老系统接口、外部队列);内部类之间用接口直接协作即可。
- ❌ 认为适配器里应该顺手升级第三方库的调用方式。
✅ 适配器正是升级的隔离点:第三方换代时,改动被限制在适配器内部。但升级要连带更新翻译逻辑(方法名、错误码、字段名都可能变),并补上对应的测试。
- ❌ 用已经废弃或移除的扩展去实现底层能力(例如仍在用
mcrypt_*做加解密)。
✅mcrypt_*从 PHP 7.2 起废弃、已不适合新项目;可逆加密改用sodium_*或带随机 nonce 的openssl_encrypt(),消息认证用hash_hmac()配合hash_equals()。适配器要适配的是接口,不是把过时实现换个包装继续用。
总结
| 关注点 | 结论 |
|---|
| 解决什么 | 让接口不兼容的两方协作,把第三方差异隔离在边界上 |
| 三个角色 | 目标接口(自己定义)、适配者(第三方)、适配器(实现目标并委托) |
| 首选形态 | 对象适配器(持有 + 委托),PHP 单继承下类适配器基本不实用 |
| 与装饰器的区别 | 装饰器保持接口不变并增强;适配器改变接口做转换 |
| 与工厂的关系 | 工厂负责「创建哪个适配器」,适配器负责「怎么转换」 |
| 适配器的边界 | 只出现在不可控的外部依赖处,内部类之间不需要 |
适配器的价值来自一句话:把「会变的东西」关进一个类里。第三方的 SDK 会升级、错误码会变、供应商会被替换,而业务代码只依赖自己定义的接口——这个接口就是那道墙,适配器则是墙上唯一的门。用它的时候要守住两条线:目标接口里不出现第三方类型,适配器里不写业务逻辑。