1. 不兼容的接口到底不兼容在哪儿:三个真实场景
先从一个我自己的翻车现场说起。前些天复查一份订单对接代码,发现调用方为了兼容两个来源的订单数据,在核心业务方法里写了大量isinstance判断、字段别名映射和类型转换。每来一家新数据源,调用方就要多一层分支,代码越来越像一座违章建筑,而当初我引入它只是为了对接一个第三方支付SDK。
这件事如果换一种做法,结局会完全不一样。适配器模式(Adapter Pattern)就是专门处理这种局面的:它把“客户期望的接口”翻译成“服务方实际提供的接口”,让两个原本没有交集的类能一起工作。用更生活化的说法——你在国内用两脚插头,酒店却只提供三脚插座,适配器就是那个能让你直接插上去的小转接头。它没有改变电器的内部结构,也没有改变插座本身的电压,只是在两者之间做了形态转换。
适配器模式在 Python 里尤其好用,因为 Python 是动态语言,讲究鸭子类型,你甚至不需要严格定义一个接口基类,只要你的对象有调用方需要的方法和属性,它就能被当作目标接口使用。这意味着我们可以在不修改旧模块、不修改第三方库的前提下,用自己的业务接口把它们“包”起来,让全部调用方统一走一套代码路径。
这篇文章适合谁看?如果你正在对接外部 API、准备给遗留系统写新调用层、或者手头有多个功能同类但接口各异的组件要统一,适配器模式都能帮上大忙。下面不打算只给理论定义,我会用完整的实战例子,把几种不兼容场景、不同实现方式、容易踩的坑全部过一遍。
不兼容的“不”通常体现在三个层次,它们是三种很典型的真实场景:
方法名和参数结构不同。比如本地订单服务有一个
get_order(order_id)方法,返回Order对象;而某个第三方支付SDK提供的是query_purchase(transaction_id),返回的是字段名完全不同的字典。调用方没法直接用同一个函数处理两种返回值,这就是最典型的签名不兼容。数据格式和语义不同。即使方法名相同,字段命名也天差地别。同一个金额,本地系统叫
amount,存储的是数字100.50;对方叫total_money,返回的是字符串"100.50"。不转换就直接用,后期一定会出现类型错误或者货币比较上的笑话。异常和状态机制不同。本地系统查询订单失败时会抛一个
OrderNotFoundException;第三方SDK失败时只是返回status=-1,还附带一个错误码。适配器如果不把这些差异翻译成调用方熟悉的约定,调用方的统一异常处理逻辑就形同虚设。
所以适配器模式的核心价值不是“多写一层类”,而是把“不匹配的接口契约”在边界处一次性翻译干净。这样,上游的业务代码只看自己熟悉的接口,底层换什么供应商、升级什么SDK,上游都不需要跟着改。
2. 第一个适配器这样写:对象适配器与类适配器的取舍逻辑
2.1 对象适配器:持有被适配者,换出目标接口
先看最常用、我也最推荐的做法——对象适配器。它的核心思想是组合:适配器内部持有一个被适配对象(Adaptee),对外实现目标接口,内部把调用转发给被适配对象,并把返回结果翻译成目标接口的约定。
假设本地系统有一个统一接口,所有订单数据源都需要返回一个OrderInfo对象:
from dataclasses import dataclass @dataclass class OrderInfo: order_id: str amount: float currency: str status: str第三方支付SDK的返回却是字典,字段名完全不同:
class ThirdPartyOrderSDK: def query_purchase(self, transaction_id: str) -> dict: # 模拟一次网络请求 return { "trade_no": "T20240101001", "total_money": "100.50", "money_unit": "CNY", "order_state": 1 # 1 表示支付成功 }现在写一个适配器,让调用方可以完全用本地OrderInfo和get_order的方式去访问:
class PaymentOrderAdapter: def __init__(self, sdk: ThirdPartyOrderSDK): self._sdk = sdk def get_order(self, order_id: str) -> OrderInfo: raw = self._sdk.query_purchase(order_id) return OrderInfo( order_id=raw["trade_no"], amount=float(raw["total_money"]), currency=raw["money_unit"], status="paid" if raw["order_state"] == 1 else "unpaid", )调用方只认get_order和OrderInfo,底层是哪个SDK并不关心。有个细节值得注意:amount在第三方SDK里是字符串"100.50",适配器把它转成了float。这种翻译不是可选项,如果不做类型转换,OrderInfo.amount就会变成字符串,后续任何数学运算都会爆炸。
2.2 类适配器:用继承换接口,Python里为什么慎用
类适配器是 GoF 书里的另一个经典做法:适配器同时继承目标接口类和被适配者类,利用继承天然获得两边的方法,再根据需要覆盖实现。在 Python 中,由于支持多重继承,写起来是这样的:
class OrderInterface: def get_order(self, order_id: str) -> OrderInfo: raise NotImplementedError class ClassPaymentAdapter(ThirdPartyOrderSDK, OrderInterface): def get_order(self, order_id: str) -> OrderInfo: raw = self.query_purchase(order_id) return OrderInfo(...)这个方法确实能用,但我在实际项目中几乎不推荐。理由有三条:
- 多重继承会引入 MRO 复杂度。一旦类体系再叠加 mixin 或者别的基类,
__mro__的顺序一旦混乱,方法解析就变得非常难排查。 - 类适配器会把被适配者的全部方法裸露给调用方。如果被适配者有一个
upload_log之类的内部方法,调用方拿到适配器也能直接调用,这会破坏“我只暴露目标接口”的边界。 - 继承关系是静态的、强耦合的。对象适配器还可以在运行时更换持有的源对象,类适配器不能。
Python 是动态语言,对象适配器写起来已经足够简洁,没有 Java 里implements接口声明那一堆样板代码的负担。所以我的默认选择非常明确:组合优于继承,一律先写对象适配器。
2.3 为什么动态语言下的适配器比静态语言更轻
还有一点值得说明:在 Python 中使用适配器模式,你通常不需要为“目标接口”定义一个抽象基类。因为调用方调用adapter.get_order(...)时,Python 只在意adapter有没有get_order方法,而不会去检查它是不是某个固定类型。这就是鸭子类型带来的自由度。
当然,如果团队里有多个适配器需要统一行为,我还是建议用一个 Protocol 来约束目标接口,配合类型检查工具使用。比如:
from typing import Protocol class OrderSource(Protocol): def get_order(self, order_id: str) -> OrderInfo: ...这样既能保持鸭子类型的灵活性,又能让静态类型检查工具在开发期发现问题。两全其美。
3. 进阶玩法:用__getattr__做动态适配,顺手解决字段映射
3.1 先分清__getattr__和__getattribute__
很多人在做适配器时,会发现被适配者方法特别多,一个一个转发太累了,于是想:“能不能搞一个自动转发?”这时候就要用到 Python 的__getattr__了。
先厘清一个常见混淆点:__getattribute__是每次访问属性都会触发的方法;__getattr__则只有在正常属性查找完全失败时才会被调用。这正好是“兜底代理”需要的语义——如果是指定的适配方法,就正常处理;如果是同名方法要透传,就交给__getattr__处理。
3.2 一个通用的透传适配器
下面给一个可复用的通用适配器骨架。它做的事有三件:
- 维护一个方法名映射表:把本地接口的方法名映射到被适配对象的方法名;
- 维护一个字段名映射表:把被适配对象返回的字段翻译成本地字段;
- 对未显式定义的其他属性,自动从被适配对象上取。
class GenericAdapter: def __init__(self, adaptee, method_map: dict, field_map: dict = None): self._adaptee = adaptee self._method_map = method_map self._field_map = field_map or {} def _translate(self, result): if isinstance(result, dict) and self._field_map: return { self._field_map.get(key, key): value for key, value in result.items() } return result def __getattr__(self, name): # 只处理未在类中显式定义的属性,避免覆盖实例构造方法 if name.startswith("_"): raise AttributeError(name) if name in self._method_map: method_name = self._method_map[name] def wrapper(*args, **kwargs): result = getattr(self._adaptee, method_name)(*args, **kwargs) return self._translate(result) return wrapper return getattr(self._adaptee, name)用法就是实例化时传入方法名映射:
adapter = GenericAdapter( ThirdPartyOrderSDK(), method_map={"get_order": "query_purchase"}, field_map={ "trade_no": "order_id", "total_money": "amount", "money_unit": "currency", "order_state": "status", } )之后调用方照常adapter.get_order("T20240101001")即可。字段映射表集中在一处,以后第三方SDK改字段名,只需改映射,业务层完全无感。
3.3 动态适配的代价:内省会变得不可靠
这个写法看起来很爽,但有几个代价你必须知道。
第一,IDE 和静态检查工具认不出你的目标接口,自动补全会失效。要想弥补,可以同时声明 Protocol,或者用__dir__返回期望的属性列表。
第二,hasattr(adapter, "anything")几乎总是返回 True,因为__getattr__会“现造”属性。如果外部代码用hasattr来判断 capability,就会误判。规避办法是在__getattr__里维护一个允许透传的属性白名单,白名单之外主动抛AttributeError。
第三,千万别在__init__里用self.xxx赋值时触发奇怪的递归。解决办法是在__getattr__开头拦截下划线开头的内部属性,避免适配器内部状态访问被拦截。
动态适配适合用在“适配层规模很大,但方法结构高度规律”的场景,比如多个厂商SDK的方法名规律都是query_xxx之类。如果只是两三个方法要转,老老实实手写转发反而更清晰,别为了一招炫技引入内省层面的风险。
4. 适配器、装饰器、外观模式,到底差在哪
和很多做架构评审的朋友聊天时发现,适配器、装饰器、外观模式这三者经常被搞混。尤其在实际代码里,最终实现的形状可能都差不多——都是在原对象外面包一层类。但它们的意图完全不同,选错了会让后面的人读代码时非常困惑。
我把它们放在一张表里做对照:
| 方面 | 适配器模式 | 装饰器模式 | 外观模式 |
|---|---|---|---|
| 目标 | 更换接口,让不兼容对象能协作 | 不换接口,在原有接口上增强行为 | 为复杂子系统提供统一简化入口 |
| 是否修改接口签名 | 是,适配后调用方按新接口调用 | 否,保持原签名 | 否,本质上是一个易用的门面 |
| 关注范围 | 通常是两个类/两组接口之间 | 通常是单一对象成员方法 | 多个子系统、多个对象协作 |
| 典型案例 | 第三方支付SDK转成本地订单服务 | 给查询接口加缓存、加日志 | 短信、邮件、日志的统一封装入口 |
行为很容易区分。装饰器给get_order加了个 Redis 缓存的逻辑,但调用方仍然是get_order,返回结构也没变,这是装饰器。适配器把query_purchase“改名”成get_order,还把字典转换成OrderInfo,这是适配器。外观模式则是把“校验登录态、组装查询参数、调用三个合同服务、拼装结果”这一大串操作缩成一个fetch_order_report(order_id),它不是在两个不兼容的接口之间做翻译,而是在简化一个流程。
有一种情况比较容易误判:被适配的对象本身是一个复杂子系统,适配器同时用到了它的好几个方法。这时适配器看起来很像外观模式。分辨标准还是问一个问题——“调用方原本期待的接口是什么?”如果调用方早就定义好了目标接口,只是因为底层对象不符合这个接口,你在做的就是把底层对象翻译成目标接口,这是适配器。如果你可以自由设计一个新接口来简化整个流程,让下游不再接触底层多个对象,那是门面。
另外在实际项目里,我发现最常用到的组合是“适配器 + 工厂”。因为当你有多个后端实现时,调用方不应该自己去new具体适配器,否则适配器又和具体实现耦合了。一个简单写法就是用字典注册:
class OrderSourceFactory: _registry = {} @classmethod def register(cls, source_type: str): def decorator(adapter_cls): cls._registry[source_type] = adapter_cls return adapter_cls return decorator @classmethod def create(cls, source_type: str, adaptee): adapter_cls = cls._registry[source_type] return adapter_cls(adaptee)业务代码只需要OrderSourceFactory.create("third_party", sdk).get_order(...)。以后新增数据源,只需要实现新的适配器并注册,调用方一行都不用改。这其实已经触碰到了依赖倒置的味道:上游定义接口,适配器负责满足接口,工厂负责装配。
5. 我踩过适配器的坑:异常、状态、内省和过度设计
5.1 异常体系没有透传
有一次在做适配器时,我只转发到了成功路径,完全没处理第三方SDK的异常模型。对方SDK失败时返回一个error_code字典,我的适配器直接原样抛了一个KeyError,调用方的统一异常处理完全派不上用场。
后来我是这样修正的:适配器要把被适配方的失败信号翻译成调用方熟悉的异常。比如对方返回status=-1时,适配器主动抛OrderNotFoundException,这样上游的except OrderNotFoundException就能正常工作:
class PaymentOrderAdapter: def get_order(self, order_id: str) -> OrderInfo: raw = self._sdk.query_purchase(order_id) if raw.get("error_code"): raise OrderNotFoundException(order_id) ...这里的原则是:适配层要负责“语义翻译”,包括错误语义。被适配方的错误码,不应该一路裸奔到业务层。
5.2 返回值只转发不转换,埋下隐患
我在 2.1 节里提过金额字符串的问题,这里再强调一次。适配器的职责是“接口翻译”,不只是“方法转发”。很多新手写适配器只做了return self._adaptee.xxx(*args),返回值类型完全没处理。如果被适配方返回字符串、列表、字典,而目标接口要求 float、对象或结构化数据,就必须转换。
我甚至建议在写适配器时,先明确目标对象的“数据契约”,再动手写转换逻辑。一个简单的做法就是定义 dataclass 作为返回类型,让 mypy 之类工具帮你盯住类型不匹配。
5.3 资源生命周期没人管
适配器持有第三方客户端时,很容易忽略一个问题:适配器被销毁,底层的连接、session 却没有关闭。第三方支付SDK通常有close()或者session.close()。最稳妥的做法是适配器显式透传资源管理方法,或者让它实现上下文管理器:
class PaymentOrderAdapter: def close(self): self._sdk.close()调用方如果有统一清理逻辑,可以通过close来释放;如果适配器内部用了requests.Session,也要考虑是不是要让__enter__/__exit__接管。否则每次测试跑完都留下一堆 TIME_WAIT 连接,时间长了就是线上隐患。
5.4hasattr和isinstance的误判
使用__getattr__动态适配后,hasattr(adapter, "anything")几乎永远返回 True,这个问题我在 3.3 节提过。它会直接污染调用方的能力探测逻辑。比如有的代码会写if hasattr(adapter, "get_pdf"): ...,在动态适配下,这个判断永远走 True,但真正调用时可能直接炸掉。
我的解决方案是维护一个__allowed_attrs__集合,让__getattr__只处理允许透传的成员,其他一律raise AttributeError。不要因为是 Python 动态语言就让代码彻底放飞,边界清晰永远是维护性的第一保障。
5.5 防止过度适配:为两个方法加二十行映射就是滥用
最后说一个更高层的教训:适配器不是万能的胶水,不能什么都往里塞。
我见过有人为了让新系统兼容旧接口,写了一个一百多行的适配器,把大量完全无关的方法统统转发。结果适配器越改越厚,语义越来越模糊,最后变成维护黑洞。遇到这种情况,正确的做法是重新审视边界:如果旧接口已经不具备继续兼容的价值,不如直接在调用点做一次替换,反而比掩盖差异的适配器更简单。
一个判断标准:适配器应该像一层保鲜膜,薄而透明地贴在接口差异上,而不是像棉被一样把所有东西都包进去。如果适配器需要记录状态、管理多个对象生命周期、还要拼接复杂结果,很可能你需要的其实是外观模式或者干脆重构,而不是一层适配。
最后再分享一个我个人的实操心得:写适配器之前,先写调用方想要的目标接口,用 Protocol 把方法签名和返回类型定死,然后再拿第三方SDK去“凑”这个接口。这个顺序看起来微不足道,但能避免你自己被被适配者的接口牵着鼻子走。适配器最好的状态是让调用方忘了底层长什么样,而不是让调用方在适配器里探路。