用 Protocol 与类型收窄设计可替换的 Python 边界
Python 的 Protocol 用结构而不是继承声明一条边界:对象只要提供需要的方法和属性,就能被静态类型检查器接受。它非常适合把业务逻辑与 HTTP、数据库、时钟或消息系统分开。但 Protocol 不是运行时输入验证,也不是越大越好的“通用接口”。高质量边界只描述一个调用方真正使用的最小能力。
协议由使用方定义
订单服务只需读取价格,就定义一个只含 get_price 的协议,而不是依赖供应商客户端的二十个方法:
from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol
class PriceReader(Protocol):
def get_price(self, sku: str) -> Decimal: ...
@dataclass(frozen=True)
class LineTotal:
sku: str
quantity: int
amount: Decimal
def calculate_line(reader: PriceReader, sku: str, quantity: int) -> LineTotal:
if quantity <= 0:
raise ValueError("quantity must be positive")
price = reader.get_price(sku)
return LineTotal(sku, quantity, price * quantity)
生产 adapter 和测试 fake 都无需继承 PriceReader。静态检查器根据签名判断兼容,这减少了基础设施类型对领域层的渗透。协议应靠近 calculate_line 所在模块,因为是调用方决定需要什么。
返回类型也是领域合同
若真实客户端返回 float | None,不能为了满足 Protocol 直接撒一个 cast。adapter 应处理缺失、货币精度和供应商错误,再返回 Decimal 或抛出领域异常。类型检查帮助暴露不匹配,却不会替你决定语义。
异步边界应在协议中明确 async def。不要为了让同步 fake 更容易而让生产代码接受同步或异步联合返回,那会把分支扩散到每个调用点。测试 fake 同样可以实现 async 方法。
收窄来自可证明条件
外部 JSON 先是 object 或结构不明的数据。用普通检查逐步验证,或用类型谓词把已经完成的运行时判断告诉检查器。Python 3.12 可使用 TypeGuard;Python 3.13 还提供 TypeIs,后者要求被收窄类型与输入兼容,并能更自然地收窄真假分支。
from typing import TypeGuard
def is_string_map(value: object) -> TypeGuard[dict[str, str]]:
return (
isinstance(value, dict)
and all(isinstance(k, str) and isinstance(v, str)
for k, v in value.items())
)
def read_region(value: object) -> str:
if not is_string_map(value):
raise ValueError("expected string mapping")
return value.get("region", "unknown")
谓词声明是一份证明责任。若函数只检查 dict,却声称每个键值都是字符串,类型检查器会信任错误承诺并让运行时错误进入后续代码。为收窄函数写正反例测试,包括空值、嵌套数据和自定义映射。
runtime_checkable 要谨慎
@runtime_checkable 允许对 Protocol 做有限 isinstance 检查,但它主要确认属性存在,不会完整检查方法签名或泛型含义。插件发现可以用它做粗筛,真正使用仍需调用边界的错误处理。业务代码不应因为对象通过 runtime protocol 就认定远程服务、权限和数据格式可靠。
避免协议碎片与巨型协议
每个函数一个单方法 Protocol 会制造大量名字和 adapter;一个拥有整个 SDK 的 Protocol 又让 fake 难写。按稳定协作关系分组,例如读取目录、提交付款、发布事件。若两个调用方只共享一个偶然方法名但语义不同,就保留两个协议。
协议属性的可变性也重要。可写属性往往产生不安全协变问题;优先暴露只读 @property 或命令方法。泛型 variance 应由实际输入输出方向决定,不要通过忽略类型检查掩盖。
验证静态与运行时两条线
CI 运行选定的类型检查器并固定配置,防止本地与服务端规则不同。单元测试仍验证 adapter 转换、异常、超时和 fake 行为;契约测试可以让多个实现通过同一组输入输出案例。类型通过不代表网络正确,运行时测试通过也不代表所有调用组合类型安全。
结论
Protocol 的价值是让依赖方向围绕调用方能力,而不是供应商类。保持接口小,在 adapter 中完成语义转换,用经过真实检查的 TypeGuard 或 TypeIs 收窄外部数据,并把运行时协议视为有限工具。静态类型缩短反馈路径,运行时验证保护真实边界,两者结合才让实现可以安全替换。