Go 接口设计的两种模式:权衡与选择

Go 接口设计的两种模式:权衡与选择

在 Go 语言开发中,接口设计是一个经常被讨论的话题。今天,我想深入探讨两种常见但略有不同的接口设计模式:经典的”Accept Interfaces, Return Structs”模式和”Ports and Adapters”架构中的接口设计模式。这两种模式各有其适用场景,理解它们的差异对于写出更好的 Go 代码至关重要。

Accept Interfaces, Return Structs

让我们先用一个图表来直观地理解这种模式:

classDiagram
    class ProductFinder {
        <<interface>>
        +Find() error
    }
    class ProductRepository {
        +Find() error
        +Save() error
        +Update() error
        +Delete() error
    }
    class Service {
        +NewService(finder ProductFinder)
    }
    ProductRepository ..|> ProductFinder : implements
    Service --> ProductFinder : uses

这种设计模式最早由 Jack Lindamood 提出,并在 Go 社区得到广泛认可。让我们先看一个典型例子:

// in db/products.go
type ProductRepository struct {}

func NewProductRepository() *ProductRepository {}
func (r ProductRepository) Find() error {}
func (r ProductRepository) Save() error {}
func (r ProductRepository) Update() error {}
func (r ProductRepository) Delete() error {}

// elsewhere in services.go
type ProductFinder interface {
    Find() error
}

func NewService(finder ProductFinder) *Service { }

这种模式的核心特点是:

  1. 接口由消费者定义:接口定义靠近使用方,而不是实现方
  2. 接口保持小巧:通常只包含必需的方法
  3. 松耦合:实现方可能并不知道自己实现了哪些接口
  4. 灵活性高:容易适配新的实现,因为接口小而聚焦

Ports and Adapters 模式下的接口设计

先看一下这种架构模式的结构示意图:

graph TB
    subgraph "Application Core"
        B[Domain Layer]
        C[Application Layer]
        D[Ports/Interfaces]
    end

    subgraph "Adapters"
        E[Repository Impl]
        F[HTTP Handler]
        G[gRPC Handler]
    end

    E -->|implements| D
    F -->|implements| D
    G -->|implements| D
    D -->|defines| C
    C -->|uses| B

    style Adapters fill:#bbf,stroke:#333,stroke-width:2px

再看一下具体的类关系:

classDiagram
    namespace ApplicationCore {
        class Repository {
            <<interface>>
            +Find() error
            +Save() error
            +Update() error
            +Delete() error
        }
        class DomainService {
            +DoBusinessLogic()
        }
    }

    class RepositoryImpl {
        +Find() error
        +Save() error
        +Update() error
        +Delete() error
    }

    DomainService --> Repository : uses
    RepositoryImpl ..|> Repository : implements

相比之下,在 Ports and Adapters(也称为六边形架构)中的接口设计有着不同的考量:

// in core/contracts/repository.go
type Repository interface {
    Find() error
    Save() error
    Update() error
    Delete() error
}

// in infrastructure/db/repository.go
type repositoryImpl struct {}

// 使用接口检查确保实现
var _ Repository = (*repositoryImpl)(nil)

func NewRepository() Repository {
    return &repositoryImpl{}
}

这种模式的特点是:

  1. 接口集中定义:接口通常定义在应用核心或领域层
  2. 接口相对较大:包含更完整的契约定义
  3. 明确的契约:实现方明确知道需要实现哪些接口
  4. 架构边界清晰:接口作为架构层之间的边界

设计选择的权衡

这两种模式各有其优势和适用场景:

Accept Interfaces, Return Structs

  • 优势:
    • 更高的灵活性
    • 更容易进行单元测试
    • 符合 Go 的隐式接口特性
    • 降低了包之间的耦合
  • 适用场景:
    • 标准库风格的 API 设计
    • 需要最大化复用性的公共库
    • 单一责任的小型接口

Ports and Adapters

  • 优势:
    • 更清晰的架构边界
    • 契约更明确
    • 更容易维护架构完整性
    • 适合领域驱动设计
  • 适用场景:
    • 企业级应用开发
    • 复杂的业务领域
    • 需要清晰架构边界的系统

两种模式的对比

让我们用一个图来直观地对比这两种模式的差异:

graph TB
    subgraph "Accept Interfaces, Return Structs"
        A1[Consumer Package] -->|defines| B1[Small Interface]
        C1[Implementation Package] -.->|implicitly implements| B1
    end

    subgraph "Ports and Adapters"
        A2[Core Package] -->|defines| B2[Port Interface]
        C2[Adapter Package] -->|explicitly implements| B2
    end

    style A1 fill:#f9f,stroke:#333
    style A2 fill:#bbf,stroke:#333
    style B1 fill:#ff9,stroke:#333
    style B2 fill:#9ff,stroke:#333
    style C1 fill:#f99,stroke:#333
    style C2 fill:#9f9,stroke:#333

实践建议

  1. 关注接口的位置
   // 推荐:接口靠近使用处
   package service

   type Repository interface { ... }
   func NewService(repo Repository) *Service { ... }

  1. 使用接口检查确保实现
   var _ Repository = (*repositoryImpl)(nil)

  1. 避免过大的接口: 即使在 Ports and Adapters 模式下,也要警惕接口膨胀。考虑将大接口分解为多个小接口。

  2. 根据场景选择

    • 如果是编写库,倾向于使用”Accept Interfaces, Return Structs”
    • 如果是构建企业应用,考虑使用 Ports and Adapters 的方式

总结

接口设计没有放之四海而皆准的规则,关键是要理解不同模式的优劣和适用场景。在实际开发中,我们常常需要:

  1. 评估项目的具体需求和约束
  2. 考虑团队的开发习惯和能力
  3. 权衡不同模式带来的好处和成本
  4. 在保持一致性的同时保持适度的灵活性

选择合适的接口设计模式,将直接影响代码的可维护性、灵活性和可测试性。而理解这些模式的差异和应用场景,是写出优质 Go 代码的关键之一。


通过以上分析,我们可以看到 Go 语言接口设计的两种主要模式各有其优势和适用场景。在实际开发中,我们不必教条地遵循某一种模式,而是应该根据具体情况做出明智的选择。最重要的是保持代码的清晰、可维护,并服务于项目的长期目标。

参考资料

  1. [[Preemptive Interface Anti-Pattern in Go]] - Jack Lindamood 的原创文章,详细讲解了”Accept Interfaces, Return Structs”模式的由来和思考。
  2. Hexagonal Architecture - Alistair Cockburn 关于六边形架构(Ports and Adapters)的经典文章。

原文地址:https://juejin.cn/post/7447701240643420195