可读、无泄漏的 API,具备零成本抽象
Source: Dev.to
编写代码带来的日常挑战
我们遇到的挑战之一是设计一个 后端交易服务库。
该库为用户提供预订交易的 API。下面的故事展示了我们如何遇到一个最初看似机械的设计问题——即如何将数据传递给 适配器 和 构建器——但很快它显露出更深层的领域和边界问题。
该库公开的 API 允许用户与其他实体创建交易。内部,库会编排对下游服务的多个 I/O 调用,聚合它们的结果,并生成最终响应。每个下游依赖都要求自己的数据形状,因此进入的用户输入必须在系统流转过程中反复转换。
我们的最初做法是传统的:使用适配器和构建器将用户输入转换为每个下游服务所需的结构。表面上这看起来很直接,但实际操作中它暴露了围绕 合同狭窄性——更重要的是 有界上下文违规——的反复设计张力。
问题
- 面向用户的主要输入对象
TradeInformation包含 近 100 个字段。 - 任意给定的适配器通常只需要其中 不到 10 个字段。
直接将 TradeInformation 传入适配器看起来“干净”,因为它保持了方法签名的简洁和声明式。但这种干净是具有欺骗性的。
从 领域驱动设计(DDD) 的角度来看,这种做法把多个有界上下文压缩成了一个。适配器的实际领域是小而具体的——它并不是在抽象意义上操作“交易”,而是处理与特定集成或工作流相关的、明确定义的交易数据子集。将一个庞大、全包的对象传入,就让适配器能够访问其有界上下文之外的信息,无论它是否打算使用这些信息。
这会产生三个具体问题:
- 隐式依赖 –
TradeInformation在没有任何签名层面摩擦的情况下悄然扩展了合同。 - 有界上下文侵蚀 – 上下文相互渗透,使模型变得嘈杂。
- 测试摩擦作为症状 –
TradeInformation对象表明适配器与系统的耦合程度远超其应有的范围。
评估显而易见的替代方案
考虑三种可能的 Builder API:
// 1. Declarative but misleading
Deal buildDeal(const TradeInformation& request);
// 2. Brutally explicit
Deal buildDeal(
int makerId,
int takerId,
double amount,
const std::string& tradeIdentifierInternal,
const std::string& tradeIdentifierExternal,
Venue::Enum tradingVenue,
const MakerDetails& makerDetails
);
// 3. “Compromise” struct
struct DealStruct {
int makerId;
int takerId;
double amount;
const std::string& tradeIdentifierInternal;
const std::string& tradeIdentifierExternal;
Venue::Enum tradingVenue;
const MakerDetails& makerDetails;
};
Deal buildDeal(const DealStruct&);
每种方式都体现了不同的哲学。
| 选项 | 优点 | 缺点 |
|---|---|---|
| 1. Declarative | 调用处代码简洁、可读性好;签名短。 | 隐藏了真实的依赖;Builder 看似需要“交易信息”,但实际上只需要特定的投影。 |
| 2. Explicit | 依赖关系一目了然;单元测试非常简单。 | 调用方必须自行进行领域抽取逻辑;冗长的参数列表把领域语言扁平化。 |
| 3. Struct | 将参数聚合为单一类型。 | 该结构体不携带行为或不变式;它仅是伪装的参数列表,增加了一个并未表达其用途的类型。 |
这三种方案都因同一个原因而失败:它们没有建模正确的上下文。Builder 实际需要的并不是“交易信息”,也不是“七个原始类型”,而是与此上下文相关的、特定领域视图的交易。
历史上,团队通常通过基于继承的适配器来解决:引入一个窄接口,让大型输入对象实现它。虽然这种做法有效,但会紧耦合不相关的领域并引入僵硬的层次结构——这是许多团队如今正确避免的做法。
解决方案:视图
我们最终通过引入 视图——对更大对象的只读投影(类似于 std::string_view 的精神)——解决了这个问题。
视图代表一种 上下文特定的契约:
- 它恰好提供 API 在该有界上下文中所需的内容。
- 它使用该有界上下文的语言。
- 它不会在不改变自身类型的情况下意外扩展。
关键是,视图本身是 DDD(领域驱动设计)构件。它形式化了上下文之间的边界,并在该边界强制进行转换。
示例:PartiesView
对我们而言,视图标记了一组最小的领域概念,这比使用 TradeInformation 的声明式 API 更不泄漏,同时也更易读。下面是一个具体示例,提供了交易各方的详细信息。
#include <string>
#include <concepts>
template <typename V>
concept PartiesViewLike = requires(const V& v) {
{ v.makerId() } -> std::convertible_to<int>;
{ v.takerId() } -> std::convertible_to<int>;
{ v.amount() } -> std::convertible_to<double>;
{ v.internalId() } -> std::same_as<const std::string&>;
{ v.externalId() } -> std::same_as<const std::string&>;
{ v.venue() } -> std::same_as<Venue>;
{ v.makerDetails() } -> std::same_as<const MakerDetails&>;
};
class PartiesView {
public:
explicit PartiesView(const TradeInformation& ti) : d_trade(ti) {}
int makerId() const { return d_trade.makerId(); }
int takerId() const { return d_trade.takerId(); }
double amount() const { return d_trade.amount(); }
const std::string& internalId() const { return d_trade.internalId(); }
const std::string& externalId() const { return d_trade.externalId(); }
Venue venue() const { return d_trade.venue(); }
const MakerDetails& makerDetails() const { return d_trade.makerDetails(); }
private:
const TradeInformation& d_trade;
};
PartiesView 是一个 只读投影,仅暴露适配器处理方信息所需的数据。任何接受 PartiesViewLike 的适配器都可以确信它在正确的有界上下文中工作,编译器也会强制执行该契约。
要点
- 绝不要将“上帝对象”(如
TradeInformation)传递给只需要其中一部分的组件。 - 建模每个组件真正需要的契约——优先使用显式的、领域特定的类型,而不是原始参数列表。
- 使用视图对象 创建轻量、只读的投影,作为有界上下文之间的明确边界。
- 利用 concepts(或接口) 使这些边界在编译时即可被强制执行。
通过采用视图,我们恢复了清晰的领域边界,消除了隐式依赖,使测试变得直接——而且调用点的可读性并未受到影响。
// 示例:在构建器模式中使用视图适配器
class Builder1 {
public:
// 构建器使用只读的交易信息视图。
// 该视图仅暴露构建器实际需要的数据。
template <typename TradeView>
Deal buildDeal(const TradeView& v) const {
Deal d;
// ... 使用 v.makerId(), v.internalId() 等
return d;
}
private:
const TradeInformation& d_trade;
};
class Builder2 {
public:
// 第二个构建器需要不同的数据子集。
template <typename V>
Deal buildDeal(const V& v) const {
Deal d;
// ... 使用 v.makerId(), v.internalId() 等
return d;
}
};
为什么使用视图适配器?
- 构建器仅依赖它们真正需要的内容——不多也不少。
- 调用点保持简洁,因为视图可以从已有输入廉价构造。
- 单元测试更简单、更聚焦;视图可以用最小的数据实例化。
- 上下文泄漏变得可见且是有意为之,而不是意外的。
ccidental.
好处
- 重新引入 Adapter 模式 的优势,而无需继承,也不会扩大合约范围。
- 如果适配器只需要 10 个字段,就不应该看到 100 个字段。
- 痛苦的单元测试表明你的边界可能有问题。
- 为了“整洁”而隐藏依赖,往往会削弱领域模型。
最终要点
窄合约不仅仅是审美或测试便利的问题。
它们尊重 有界上下文,并随时间保持 领域完整性。