课程大纲 · 选择一课
在不丢失含义的前提下翻译源数据
构建一个适配器,保留标识符,校验明确的源契约,并返回稳定的领域类型。测试当文件变成 API 时哪些行为可以保持不变,哪些需要新的业务决策。
30 分钟
作者:FDEInterviews · 已更新
文字稿
这是《前沿部署工程基础》的第十课。将特定于源的翻译保留在具有明确契约的适配器之后。保留标识与含义,拒绝不支持的值,并测试你期望保持稳定的下游行为。
你已经能够阅读一个陌生的系统并定位其边界。现在你将编写客户订单数据源与一个小型报表工作流之间的边界,包括能揭示破坏性转换的测试。
重命名很容易。含义改变则需要决策。以一个虚构的货运集成为例。
一个每日导出使用三个源字段名:订单号、客户 ID 和状态。报表直接读取这些名称,一个汇总函数转换数量,而导出作业对状态九有自己的解释。然后第二个区域引入了零九。在决定这些记录是否含义相同之前,你现在有多个地方需要排查。
适配器收集这些源假设,并生成应用程序其余部分能够理解的类型。如果源模型与你的模型语义不同,这个翻译边界通常被称为防腐层。它可以存在于你的应用程序内部,不需要单独的服务。Microsoft 的模式指南也讨论了校验、一致性以及维护该层的成本。
这个边界不只是重命名字段。客户标识符就是标识符,即使它只包含数字。去掉它的前导零可能会把两个客户合并成一个。把文本转换为数字,恰好丢弃了区分这两个客户的那些字符,而一旦它们合并,任何下游代码都无法恢复原本指的是哪条记录。没有时区的时间戳不会因为方便就变成 UTC。而“已关闭”这个词在一个系统中可能表示财务上已结清,在另一个系统中却表示实物已送达。
适配器无法自行解决这些问题。它的职责包括让已商定的答案变得可见。
把源假设放在一起。本课的图示展示的是一个小型集成。更大的集成可能有多个适配器,分别对应不同的源契约。
测试、夹具和传输客户端也会提到源字段名。统计文件数量是一种有用的搜索技巧,但并没有规则说改动一个文件就证明架构良好,改动三个就证明架构糟糕。
使用一个更具体的审查问题。当源表示形式发生变化但含义不变时,你能否在不重写报表计算的情况下更新转换及其测试?而当含义发生变化时,哪些领域规则应随之改变?
在编写转换之前先写好契约。本契约及其数据是合成的训练材料,并非某个客户的已文档化 API。在实际项目中,应记录源数据所有者的确认和契约版本,且不可凭空捏造。
订单号是非空文本,两侧的 ASCII 空格属于传输填充。客户 ID 是非空文本,必须原样保留,不允许有两侧空白。订单日期是十位字符的业务日期,包含年、月、日。状态码 1 表示 open(开启),9 和 09 表示 closed(关闭),X 表示 cancelled(取消)。行项目是一个非空列表,每项包含一个 SKU 和一个正整数数量,数量以 ASCII 数字书写。额外字段会被本报表忽略。
每个字段还包含适配器必须避免的问题:不能删除内部字符或接受全为空格的订单号;不能将客户 ID 转换为整数或去除其前导零;不能猜测时区或随意截取时间戳;不能推断所有带前导零的状态码都等价;也不能接受布尔值、浮点数、负数数量或空白 SKU。同样,不应声称每种增量式变更对所有消费方都是安全的。
该工作流需要的是商品总数量,而非行级价格或库存预留。这就是领域类型中包含总数的原因。而一个履约工作流很可能需要各个行项目及其单位,届时这种类型将不够用。
完整的适配器仅使用 Python 标准库。其状态映射表列出了所有受支持的状态码,因此 09 映射到 closed 是因为有人明确写下了它。任何其他状态码都没有映射,适配器会抛出契约错误,而不是进行猜测。
客户 ID 保持为文本。它永远不会被去除前导零,也永远不会被转换为数字。如果它带有两侧空白,那就是契约错误,而不是可以修剪的内容。
每个数量必须是 ASCII 数字且大于零。布尔值、浮点数或负数都无法通过校验。只有通过校验后,才会将其累加到总数中。
在样例数据上运行适配器。预期输出为:订单号、带有三个前导零的客户 ID、closed,以及数量 5。前导零得以保留。09 的映射是显式的。日期是作为日期进行校验的,而不是从碰巧到达的任意文本中提取出来的。
这是一个针对有界的内存对象的示例适配器。一个已部署的传输层还需要响应大小限制、身份验证、分页、截止时间和受保护的诊断。抛出的错误会标明具体字段,而不会将客户数据复制到日志中。由调用方决定被拒绝的记录是否可以单独暂存,还是整个报表必须等待修正。
现在测试那个看起来无害的转换。第二个代码块与适配器并行运行。它构建一对仅客户 ID 不同的订单。
第一个保留样本中的 ID,带有三个前导零。第二个使用相同的数字但去掉前导零。适配器将它们视为两个不同的客户。而那个故意具有破坏性的去零表达式会把它们折叠成同一个值。两个源 ID 都包含数字,但看起来像数字的文本并不能确立数字上的同一性。这是一个正确性失败,即使所有下游的求和仍然能运行。
该代码块随后向适配器输入五个无效输入,并预期每次都抛出契约错误:状态零零九、不可能的日期二月三十日、用完整时间戳代替日期、带前导空格的客户 ID,以及布尔值 true 作为数量。它输出:同一性已保留,五个无效用例均被拒绝。
接下来,在不带走无法保留的数据的前提下进行离线开发。当合成夹具(如样本)能覆盖相应行为时,尽量使用它们。
如果需要一份经批准的捕获响应来复现缺陷,必须明确它存储在哪里、谁可以访问它、它如何被转换,以及何时必须删除。仅删除姓名可能仍会留下可识别的 ID、自由文本或关联记录,而且调用 API 的权限并不自动意味着允许将其响应提交到 Git。
有了经批准的夹具,即使 VPN 不可用,你也能继续测试纯翻译和报表逻辑。这并不能验证当天的身份认证、数据的新鲜度或分页。这些需要单独的集成检查。
最后,当夜间文件变成 API 时会发生什么变化?这取决于具体是哪种变更,而每一种变更在宣布兼容之前都需要各自的证据。
如果订单号字段被重命名但同一性规则完全相同,那属于源适配器范畴,由契约测试和成对映射测试来证明。一个文件变成分页 API 则落入传输和集成检查的范畴,需要完整枚举、稳定的续页行为、限制和重试策略。一个日期变成时间点则改变了领域契约和翻译,需要明确规定的业务时区和报表截止时间。
如果“已关闭”现在包含已取消的订单,那属于领域规则和报表的范畴,需要业务方就其含义达成一致,并修订预期结果。而数据变成每分钟可用一次则触及工作流和运维,以及传输,需要新鲜度度量、负载测试、访问审批和归属明确。
接缝只包含表示上的变化。它无法让不同的业务含义变得等价,也无法把未经批准的路径变成经批准的路径。在本课的最后一张图中,适配器是唯一被允许知晓源数据含义的地方。
请在此处暂停视频。制作一份单页适配器契约和一个可通过的脚本。先写契约表,然后添加一个状态和一个数量的边界情况,且不得削弱现有检查。将测试数据(fixture)的状态改为零零九。当前结果必须是拒绝。如果你判定它表示“已关闭”,请先写下新的源契约假设,然后添加一个显式映射和测试。接着,在你熟悉的某个集成中搜索原始状态码和字段名,将适配器、测试数据和测试中的预期出现,与依赖源表示形式的业务计算区分开来。描述一个你可以隔离的变更,以及一个必须传导至领域模型的语义变更。
回顾要点:将源表示形式置于显式翻译契约之后,保留标识符和业务含义,因为归一化需要证据。被拒绝的值需要在纯转换函数之外制定运维策略。离线测试数据需要妥善处理,且无法证明与线上环境的兼容性。传输层升级还可能改变数据新鲜度、访问方式、分页或业务语义。
以上就是第十课。在本系列的下一课中,你将学习如何基于结果证据和时间预算来选择重试策略。完整课程及其练习和演练请见 fdeinterviews.com。
TL;DR:将源系统特定的翻译逻辑放在带有显式契约的适配器之后。保留标识符和业务含义,拒绝不支持的值,并测试你期望保持稳定的下游行为。
当前进度:你已经能够阅读一个陌生系统并定位其边界。现在,你将编写客户订单数据源与一个小型报表工作流之间的边界,包括能暴露破坏性转换的测试。
重命名很简单;含义改变则需要决策
以一个虚构的货运集成为例。每日导出使用 ORDER_NBR、cust_id 和 sts;报表直接读取这些字段名,一个汇总函数会转换数量,而导出任务对状态 9 有自己的解读。随后第二个区域引入了 09,在断定这些记录是否含义相同之前,你有多个地方需要排查。
ORDER_NBR
,
cust_id
and
sts
; the report reads these names directly, a totals function converts quantities, and the export job has its own reading of status
9
. Then a second region introduces
09
, and you have several places to investigate before you can say whether the records mean the same thing.
适配器收集这些源系统假设,并生成应用程序其余部分能够理解的类型。当源模型与你的模型语义不同时,这个翻译边界通常被称为防腐层(anti-corruption layer),它可以存在于你的应用程序内部,而无需单独的服务。Microsoft 的模式指南也讨论了验证、一致性以及维护该层的成本。
边界的作用不只是重命名字段。一个客户标识符即使只包含数字,也仍然是标识符,去掉它的前导零可能会把两个客户合并成一个。把文本转换为数字,恰恰丢弃了区分这两个客户的那些字符;一旦它们合并,任何下游代码都无法恢复原本指的是哪条记录。没有时区的时间戳并不会因为 UTC 更方便就变成 UTC;而 closed 在一个系统里可能表示财务上已结清,在另一个系统里却表示实物已交付。
closed
may mean financially closed in one system but physically delivered in another.
适配器无法自行裁决这些问题,但它的部分职责就是让已商定的答案清晰可见。
## 在一处集中记录源系统的假设
图示展示的是一个小型集成;更大的集成可能针对不同的源契约设有多个适配器,而且测试、夹具(fixture)和传输客户端也会提到源字段名。统计文件数量是一种实用的搜索技巧,但并不存在“改一个文件就证明架构好、改三个文件就证明架构差”这样的规则。
不妨用一个更具体的评审问题:当源表示发生变化而含义不变时,你能否在不重写报表计算的前提下更新转换逻辑及其测试?当含义发生变化时,哪些领域规则应当随之改变?
## 转换之前的契约
以下契约和数据是合成的训练材料,并非某个客户的文档化 API。在实际项目中,应记录源系统所有者的确认意见和契约版本;两者都不可凭空捏造。
| 字段 | 本练习的契约 | 适配器必须避免的做法 |
|---|---|---|
| ORDER_NBR | 非空文本;两侧的 ASCII 空格是传输填充 | 删除内部字符或接受全为空格的 ID |
| cust_id | 非空文本,原样保留;无两侧空白 | 转换为整数或去除零 |
| order_dt | 十字符 YYYY-MM-DD 业务日期 | 猜测时区或随意截取时间戳 |
| sts | 1 表示 open,9 和 09 表示 closed,X 表示 cancelled | 推断所有零填充的代码都等价 |
ln_items | Nonempty list; each item has a SKU and positive integer quantity encoded as ASCII digits | Accepting booleans, floats, negative quantities or blank SKUs |
| ln_items | 非空列表;每项包含一个 SKU 和以 ASCII 数字编码的正整数数量 | 接受布尔值、浮点数、负数量或空白 SKU |
Save this complete example as
adapter.py
. It uses only Python's standard library.
from dataclasses import dataclass
from datetime import date
from enum import Enum
import re
class ContractError(ValueError):
pass
class OrderStatus(Enum):
OPEN = "open"
CLOSED = "closed"
CANCELLED = "cancelled"
@dataclass(frozen=True)
class Order:
number: str
customer_id: str
placed_on: date
status: OrderStatus
quantity: int
STATUS = {"1": OrderStatus.OPEN, "9": OrderStatus.CLOSED,
"09": OrderStatus.CLOSED, "X": OrderStatus.CANCELLED}
def text_field(raw, key):
value = raw.get(key)
if not isinstance(value, str) or not value:
raise ContractError(f"{key}: expected nonempty text")
return value
def to_order(raw):
if not isinstance(raw, dict):
raise ContractError("order: expected object")
number = text_field(raw, "ORDER_NBR").strip(" ")
if not number or number != number.strip():
raise ContractError("ORDER_NBR: invalid padding")
customer = text_field(raw, "cust_id")
if customer != customer.strip():
raise ContractError("cust_id: surrounding whitespace")
day = text_field(raw, "order_dt")
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", day):
raise ContractError("order_dt: expected business date")
try:
placed_on = date.fromisoformat(day)
except ValueError as error:
raise ContractError("order_dt: invalid date") from error
status = STATUS.get(text_field(raw, "sts"))
if status is None:
raise ContractError("sts: unsupported status")
lines = raw.get("ln_items")
if not isinstance(lines, list) or not lines:
raise ContractError("ln_items: expected nonempty list")
total = 0
for line in lines:
if not isinstance(line, dict):
raise ContractError("line: expected object")
sku = text_field(line, "sku")
if not sku.strip():
raise ContractError("sku: blank")
qty = text_field(line, "qty")
if not re.fullmatch(r"[0-9]+", qty) or int(qty) <= 0:
raise ContractError("qty: expected positive integer text")
total += int(qty)
return Order(number, customer, placed_on, status, total)
SAMPLE = {
"ORDER_NBR": " A-4471 ", "cust_id": "00099421",
"order_dt": "2026-03-14", "sts": "09",
"ln_items": [{"sku": "X1", "qty": "2"},
{"sku": "X2", "qty": "3"}],
}
if __name__ == "__main__":
order = to_order(SAMPLE)
print(order.number, order.customer_id, order.status.value, order.quantity)Run
python adapter.py
and expect
A-4471 00099421 closed 5
. The zeros survive, the mapping for
09
is explicit, and the date is validated as a date rather than sliced out of whatever text happened to arrive.
This is a teaching adapter for a bounded in-memory object, and a deployed transport also needs response-size limits, authentication, pagination, deadlines and protected diagnostics. The raised errors name fields without copying customer values into logs. The caller decides whether a rejected record can be held individually or whether the whole report must wait for correction.
try:
Run this second block beside
adapter.py
:
from copy import deepcopy
from adapter import SAMPLE, ContractError, to_order
first = deepcopy(SAMPLE)
second = deepcopy(SAMPLE)
second["cust_id"] = "99421"
assert to_order(first).customer_id != to_order(second).customer_id
assert len({first["cust_id"].lstrip("0"),
second["cust_id"].lstrip("0")}) == 1
for field, bad in [("sts", "009"), ("order_dt", "2026-02-30"),
("order_dt", "2026-03-14T00:00:00"),
("cust_id", " 00099421")]:
raw = deepcopy(SAMPLE)
raw[field] = bad
try:
to_order(raw)
except ContractError:
pass
else:
raise AssertionError(f"accepted invalid {field}")
raw = deepcopy(SAMPLE)
raw["ln_items"][0]["qty"] = True
try:
to_order(raw)
except ContractError:
pass
else:
raise AssertionError("accepted a boolean quantity")
print("identity preserved; five invalid cases rejected")lstrip
expression collapses them to one value, which is a correctness failure even though every downstream sum still runs.
在不保留不可保留数据的情况下离线工作
Use synthetic fixtures like
SAMPLE
whenever they cover the behavior. If an approved captured response is needed to reproduce a defect, establish where it may be stored, who may access it, how it is transformed and when it must be deleted. Removing names alone may leave identifying IDs, free text or linked records behind, and permission to call an API does not automatically permit committing its responses to Git.
有了经批准的夹具,即使 VPN 断开,你也可以继续测试纯翻译和报表逻辑。这与今天的身份验证、数据新鲜度或分页无关,这些需要单独的集成检查。
当夜间文件变成 API 时,会发生什么变化?
| 变化 | 可能的位置 | 在判定兼容之前需要的证据 |
|---|---|---|
| ORDER_NBR 变为 orderNumber,身份规则相同 | 源适配器 | 契约测试和成对映射测试 |
| 文件变为分页 API | 传输与集成检查 | 完整枚举、稳定的续页行为、限制与重试策略 |
| 日期变为时间点 | 领域契约与翻译 | 所需的业务时区和报表截止时间 |
| closed 现在包含已取消的订单 | 领域规则与报表 | 达成一致的业务含义和修订后的预期结果 |
| 数据变为每分钟可用 | 工作流与运维以及传输 | 新鲜度度量、负载、访问审批和所有权 |
接缝容纳的是表示形式的变化。它无法让不同的业务含义变得等价,也无法把未经批准的路径变成经批准的路径。
上面的主干描述的是一条记录穿越接缝时的行为,适配器被标记为唯一允许知晓源系统含义的地方。
在继续之前做这件事
产出一份一页的适配器契约和一个通过的脚本。从上面的表格开始,然后在不削弱现有检查的前提下,添加一个状态边界用例和一个数量边界用例。把夹具的状态改为 009,当前结果必须是拒绝。如果你决定它表示 closed,先写下新的源契约假设,然后添加显式映射和测试。
009
, and the current result must be a rejection. If you decide it means closed, first write the new source-contract assumption, then add an explicit mapping and test.
搜索一个你熟悉的集成,查找原始状态码和字段名。将适配器、测试夹具和测试中的预期出现情况,与依赖源数据表示的业务计算区分开来。描述一个你可以隔离的变更,以及一个必须传导到领域模型的语义变更。一份有用的交接文档应当把这两点都告诉下一位工程师。
深入探讨
- 可测试性与依赖注入讲解了如何在不改动业务计算的情况下,用一个基于测试夹具的数据源进行替换。
- 解析混乱数据涵盖了额外的转换,以及在规范化取值之前所需的证据。
- 数据质量将被拒绝的记录与明确的责任归属和修正流程联系起来。
- 跨越四十个连接器的企业级数据接入将边界问题扩展到多个源契约和传输方式。
- 无人负责的系统讲解了当责任分散时,如何调查数据源的行为。
核心要点
- 将源数据表示隐藏在明确的转换契约之后。
- 保留标识符和业务含义;规范化需要证据。
- 被拒绝的值需要在纯转换函数之外制定运维策略。
- 离线测试夹具需要恰当的处理,且无法证明线上兼容性。
- 传输层升级可能同时改变数据新鲜度、访问方式、分页或业务语义。
自我检测
先作答再看答案。回忆才能加深记忆;认出来不算。
- 1源数据中同时存在客户 ID 00099421 和 99421。什么依据可以证明去除前导零是合理的?
- 2API 用一个不带时区的时间戳替换了日期。仅取前十个字符是否足够?
- 3为什么从夜间文件切换到 API 的变更,其所需的工作量可能超出单个适配器?
免费注册以保存你的学习进度并返回本课程。