可读、无泄漏的 API,具备零成本抽象

发布: (2025年12月16日 GMT+8 08:29)
10 min read
原文: Dev.to

Source: Dev.to

编写代码带来的日常挑战

我们遇到的挑战之一是设计一个 后端交易服务库
该库为用户提供预订交易的 API。下面的故事展示了我们如何遇到一个最初看似机械的设计问题——即如何将数据传递给 适配器构建器——但很快它显露出更深层的领域和边界问题。

该库公开的 API 允许用户与其他实体创建交易。内部,库会编排对下游服务的多个 I/O 调用,聚合它们的结果,并生成最终响应。每个下游依赖都要求自己的数据形状,因此进入的用户输入必须在系统流转过程中反复转换。

我们的最初做法是传统的:使用适配器和构建器将用户输入转换为每个下游服务所需的结构。表面上这看起来很直接,但实际操作中它暴露了围绕 合同狭窄性——更重要的是 有界上下文违规——的反复设计张力。

问题

  • 面向用户的主要输入对象 TradeInformation 包含 近 100 个字段
  • 任意给定的适配器通常只需要其中 不到 10 个字段。

直接将 TradeInformation 传入适配器看起来“干净”,因为它保持了方法签名的简洁和声明式。但这种干净是具有欺骗性的。

领域驱动设计(DDD) 的角度来看,这种做法把多个有界上下文压缩成了一个。适配器的实际领域是小而具体的——它并不是在抽象意义上操作“交易”,而是处理与特定集成或工作流相关的、明确定义的交易数据子集。将一个庞大、全包的对象传入,就让适配器能够访问其有界上下文之外的信息,无论它是否打算使用这些信息。

这会产生三个具体问题:

  1. 隐式依赖TradeInformation 在没有任何签名层面摩擦的情况下悄然扩展了合同。
  2. 有界上下文侵蚀 – 上下文相互渗透,使模型变得嘈杂。
  3. 测试摩擦作为症状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 的适配器都可以确信它在正确的有界上下文中工作,编译器也会强制执行该契约。

要点

  1. 绝不要将“上帝对象”(如 TradeInformation)传递给只需要其中一部分的组件。
  2. 建模每个组件真正需要的契约——优先使用显式的、领域特定的类型,而不是原始参数列表。
  3. 使用视图对象 创建轻量、只读的投影,作为有界上下文之间的明确边界。
  4. 利用 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 个字段。
  • 痛苦的单元测试表明你的边界可能有问题。
  • 为了“整洁”而隐藏依赖,往往会削弱领域模型。

最终要点

窄合约不仅仅是审美或测试便利的问题。
它们尊重 有界上下文,并随时间保持 领域完整性

Back to Blog

相关文章

阅读更多 »

认真地弃用

请提供您希望翻译的具体摘录或摘要文本,我才能为您进行翻译。