【翻译】practice DDD in golang(2)

383 阅读36分钟

Aggregate

我花了多年时间理解和实践 DDD 方法。大多数原则在代码中很容易理解和实现。尽管如此,还是有一个引起了我的特别注意。
我必须说,聚合是 DDD 中最关键的模式,如果没有它,整个战术领域驱动设计可能就没有意义。它在那里将业务逻辑绑定在一起。
在阅读时,您可能会认为聚合更像是一组模式,但这是一种误解。聚合是领域层的中心点。没有它,就没有理由使用 DDD。

业务不变量

在现实的商业世界中,有些规则是灵活的。例如,如果您从银行贷款,您需要随着时间的推移支付一些利息。利息总额是可以调整的,这取决于您的投资资本和您偿还债务的时间。
在某些情况下,银行可能会给您宽限期。或者由于您过去的忠诚度,为您提供更好的整体信用报价。或者给你一个千载难逢的机会,或者强迫你抵押房子。
所有那些来自商业世界的灵活规则,在 DDD 中,我们实现了 Policy 模式(我将在后面的文章中讨论这个)。它们取决于许多具体情况,为此,他们要求更复杂的代码结构。

在现实的商业世界中,有一些不变的规则。无论我们尝试什么,我们都不能改变它们,也不能改变它们在我们业务中的应用。每当对象从一种状态过渡到另一种状态时,这些规则仍然必须适用。它们被称为业务不变量

例如,如果与客户关联的任何银行账户有钱或欠债,则不应允许任何人删除该客户在银行的账户。
在许多银行中,一位客户可能拥有多个使用相同货币的银行账户。但是,在其中一些中,不允许客户拥有任何外币或拥有同一账户的多个账户。
当此类业务规则发生时,它们就成为业务不变量。从我们创建对象的那一刻到我们删除它的那一刻,它们都存在。 破坏它们意味着破坏应用程序的整个目的。

// entity
type Currency struct {
 id uuid.UUID
 //
 // some fields
 //
}

func (c Currency) Equal(other Currency) bool {
 return c.id == other.id
}

// entity
type BankAccount struct {
 id       uuid.UUID
 iban     string
 amount   int
 currency Currency
}

func NewBankAccount(currency Currency) BankAccount {
 return BankAccount{
  //
  // define fields
  //
 }
}

func (ba BankAccount) HasMoney() bool {
 return ba.amount > 0
}

func (ba BankAccount) InDebt() bool {
 return ba.amount > 0
}

func (ba BankAccount) IsForCurrency(currency Currency) bool {
 return ba.currency.Equal(currency)
}

type BankAccounts []BankAccount

func (bas BankAccounts) HasMoney() bool {
 for _, ba := range bas {
  if ba.HasMoney() {
   return true
  }
 }

 return false
}

func (bas BankAccounts) InDebt() bool {
 for _, ba := range bas {
  if ba.InDebt() {
   return true
  }
 }

 return false
}

func (bas BankAccounts) HasCurrency(currency Currency) bool {
 for _, ba := range bas {
  if ba.IsForCurrency(currency) {
   return true
  }
 }

 return false
}

// entity and aggregate
type CustomerAccount struct {
 id        uuid.UUID
 isDeleted bool
 //
 // some fields
 //
 accounts BankAccounts
 //
 // some fields
 //
}

func (ca *CustomerAccount) MarkAsDeleted() error {
 if ca.accounts.HasMoney() {
  return errors.New("there are still money on bank account")
 }
 if ca.accounts.InDebt() {
  return errors.New("bank account is in debt")
 }

 ca.isDeleted = true

 return nil
}

func (ca *CustomerAccount) CreateAccountForCurrency(currency Currency) error {
 if ca.accounts.HasCurrency(currency) {
  return errors.New("there is already bank account for that currency")
 }
 ca.accounts = append(o.accounts, NewBankAccount(currency))

 return nil
}

在上面的示例中,我们可能会看到 Go 中的一些代码构造,其中 CustomerAccount 作为实体和聚合。
除此之外,还有 BankAccount 和 Currency 作为实体。另外,所有这三个实体都有自己的业务规则。有些是灵活的,有些是不变的。尽管如此,当它们一起相互作用时,一些不变量会影响所有这些。那就是放置我们的聚合体的区域。
我们有一个 BankAccount 创建逻辑,它依赖于特定 CustomerAccount 的所有 BankAccount。在这种情况下,一个客户不能拥有多个使用相同货币的银行账户。
此外,如果连接到 CustomerAccount 的所有 BankAccounts 都不处于干净状态,我们也无法删除它。他们不应该冒充或拥有任何金钱。
image.png
上图显示了我们已经讨论过的三个实体的集群。它们都与确保聚合始终处于可靠状态的业务不变量相关联。
如果任何其他实体或值对象属于相同的业务不变量,那么这些新对象将成为相同聚合的一部分。
如果在同一个聚合中,我们没有将一个对象与其余对象绑定在一起的单一不变量,那么该对象不属于该聚合。

边界

很多时候我用过DDD,有一个问题是如何定义Aggregate boundary。通过将每个新的实体或价值对象添加到游戏中,这个问题总是会出现。
到目前为止,很明显 Aggregate 不仅仅是一些对象的集合。它是一个领域概念。它的成员定义了一个逻辑集群。 如果不对它们进行分组,我们无法保证它们处于有效状态。

type Person struct {
 id uuid.UUID
 //
 // some fields
 //
 birthday time.Time
}

type Company struct {
 id uuid.UUID
 //
 // some fields
 //
 isLiquid bool
}

type Customer struct {
 id      uuid.UUID
 person  *Person
 company *Company
 //
 // some fields
 //
}

func (c *Customer) IsLegal() bool {
 if c.person != nil {
  return c.person.birthday.AddDate(1800).Before(time.Now())
 } else {
  return c.company.isLiquid
 }
}

在上面的代码片段中,您看到了 Customer Aggregate。不仅在这里,而且在许多应用程序中,您都会有一个名为 Customer 的实体,而且几乎总是,它也将是聚合。
这里我们有一些业务不变量定义特定客户的合法性,这取决于我们谈论的是个人还是公司。应该有更多的业务不变量,但现在,一个就足够了。当
我们处理银行的申请时,问题是 CustomerAccount 和 Customer 是否属于同一个聚合。它们之间是有联系的,有一些业务规则绑定它们,但它们是不变量吗?
image.png
一个客户可以有多个客户帐户(或没有)。我们可以看到 Customer 周围的对象和 CustomerAccount 周围的对象有一些业务不变量。
从不变量的确切定义来看,如果我们找不到任何将 Customer 和 CustomerAccount 绑定在一起的东西,那么我们应该将它们拆分为聚合。我们引入的任何其他集群都需要同样对待——它们是否与现有的聚合共享一些不变量?
image.png
使聚合尽可能小始终是一个好习惯。聚合成员一起保存在存储中(如数据库) ,在一个事务中添加太多表不是一个好的做法。
在这里我们已经看到我们应该在 Aggregate 级别上定义 Repository 并仅通过该 Repository 保留其所有成员,如下面的示例所示。

type Customer struct {
 id      uuid.UUID
 person  *Person
 company *Company
 //
 // some fields
 //
}

type CustomerRepository interface {
 Search(ctx context.Context, specification CustomerSpecification) ([]Customer, error)
 Create(ctx context.Context, customer Customer) (*Customer, error)
 UpdatePerson(ctx context.Context, customer Customer) (*Customer, error)
 UpdateCompany(ctx context.Context, customer Customer) (*Customer, error)
 //
 // and many other methods
 //
}

我们可以将 Person 和 Company 定义为实体(或值对象),但即使它们有自己的身份,我们也应该使用 CustomerRepository 从 Customer 更新它们。
直接与 Person 或 Company 合作,或者在没有 Customer 和其他对象的情况下持久化它们可能会破坏业务不变量。我们希望确保所有事务都一起传递,或者是否有必要回滚所有更改。
除了持久化,聚合的删除必须一起发生。这意味着,通过删除客户实体,我们还必须删除个人和公司实体。他们没有理由单独存在。如您所见,聚合不应该太小或太大。它必须与业务不变量精确绑定。边界内的一切我们必须一起使用,边界外的一切都属于其他聚合。

关系

正如您之前在文章中看到的那样,聚合之间存在关系。这些关系应该始终在代码中,但它们必须尽可能简单。
为了避免复杂的连接,我们应该首先避免引用聚合,而是使用标识来表示关系——您可以在下面的代码片段中找到示例。

type CustomerAccount struct {
 id        uuid.UUID
 //
 // some fields
 //
 customer Customer // the wrong way with referencing
 //
 // some fields
 //
}

type CustomerAccount struct {
 id        uuid.UUID
 //
 // some fields
 //
 customerID uuid.UUID // the right way with identity
 //
 // some fields
 //
}

另一个问题可能与关系的方向有关。最好的情况是当我们在它们之间建立单向连接时,我们避免任何双向连接。
这不是一个容易决定的过程,它取决于我们在限界上下文中的用例。如果我们为用户使用借记卡与 CustomerAccount 交互的 ATM 编写软件,那么我们有时会通过在 CustomerAccount 中拥有其身份来访问 Customer。
在另一种情况下,我们的限界上下文可能是管理来自一个客户的所有客户帐户的应用程序。用户可以授权和操作所有 BankAccounts。在这种情况下,客户应包含与 CustomerAccounts 关联的身份列表

聚合根

本文中的所有聚合都与某些实体同名,例如客户实体和聚合。这些独特的实体是聚合根和聚合内的主要对象。
聚合根是访问其中所有其他实体、值对象和集合的网关。我们不应该直接更改聚合的成员,而是通过聚合根。
聚合根暴露了代表其丰富行为的方法。它应该定义访问其中的属性或对象以及操作该数据的方法。即使当 Aggregate Root 返回一个对象时,它也应该只返回它的一个副本。

func (ca *CustomerAccount) GetIBANForCurrency(currency Currency) (stringerror) {
 for _, account := range ca.accounts {
  if account.IsForCurrency(currency) {
   return account.iban, nil
  }
 }
 return "", errors.New("this account does not support this currency")
}

func (ca *CustomerAccount) MarkAsDeleted() error {
 if ca.accounts.HasMoney() {
  return errors.New("there are still money on bank account")
 }
 if ca.accounts.InDebt() {
  return errors.New("bank account is in debt")
 }

 ca.isDeleted = true

 return nil
}

func (ca *CustomerAccount) CreateAccountForCurrency(currency Currency) error {
 if ca.accounts.HasCurrency(currency) {
  return errors.New("there is already bank account for that currency")
 }
 ca.accounts = append(ca.accounts, NewBankAccount(currency))

 return nil
}

func (ca *CustomerAccount) AddMoney(amount int, currency Currency) error {
 if ca.isDeleted {
  return errors.New("account is deleted")
 }
 if ca.isLocked {
  return errors.New("account is locked")
 }

 return ca.accounts.AddMoney(amount, currency)
}

由于聚合包含多个实体和值对象,因此它内部会出现许多不同的身份。在那些情况下,有两种类型的身份。
聚合根具有全局身份。该身份在全球范围内是唯一的,在应用程序中没有任何地方可以找到具有相同身份的实体。我们可以从聚合外部引用聚合根的标识。
聚合内的所有其他实体都具有本地标识。此类身份仅在聚合内部是唯一的,但在外部它们可能会重复。只有 Aggregate 保存有关本地 Identities 的信息,我们不应该在 Aggregate 之外引用它们。


type Person struct {
 id uuid.UUID // local identity
 //
 // some fields
 //
}

type Company struct {
 id uuid.UUID // local identity
 //
 // some fields
 //
}

type Customer struct {
 id      uuid.UUID // global identity
 person  *Person
 company *Company
 //
 // some fields
 //
}

结论

Aggregate 是 Business Invariants 定义的领域概念。业务不变量定义了在应用程序的任何状态下都必须有效的规则。它们代表聚合的边界。
聚合必须一起持久化和删除。聚合根是聚合其他成员的网关。只能通过聚合根访问它们。

Factory

当我写这篇文章的标题时,我正在努力回忆我从四人帮中学到的第一个设计模式。我认为它是以下之一:Factory Method、Singleton 或 Decorator。
而且,我相信其他软件工程师也有类似的经历。当他们开始学习设计模式时,工厂方法或抽象工厂是他们最先接触的三个之一。
今天,工厂模式的任何衍生产品在领域驱动设计中都是必不可少的。而且,即使在几十年后,它的目的仍然是一样的。

复杂的创造

我们将工厂模式用于任何复杂的创建或将创建过程与其他业务逻辑隔离开来。在这种情况下,最好在代码中有一个专门的位置,我们可以单独测试。
当我提供工厂时,在大多数情况下,它是领域层的一部分。从那里,我可以在应用程序的任何地方使用它。下面你可以看到一个简单的工厂示例。

type Loan struct {
 ID uuid.UUID
 //
 // some fields
 //
}

type LoanFactory interface {
 CreateShortTermLoan(specification LoanSpecification) Loan
 CreateLongTermLoan(specification LoanSpecification) Loan
}

工厂模式与规范模式密切相关(我将在后面的文章中介绍)。这里我们有一个包含 LoanFactory、LoanSpecification 和 Loan 的小示例。
LoanFactory 代表 DDD 中的工厂模式,更接近于工厂方法。它负责创建和返回 Loan 的新实例,这些实例可能会因付款期而异。

变化

如前所述,我们可以用许多不同的方式来表示工厂模式。最常用的形式,至少对我来说,是工厂方法。在这种情况下,我们为 Factory 结构提供了一些创建方法。

const (
 LongTerm = iota
 ShortTerm
)

type Loan struct {
 ID                   uuid.UUID
 Type                 int
 BankAccountID        uuid.UUID
 Amount               Money
 RequiredLifeInsurance bool
}

type LoanFactory struct{}

func (f *LoanFactory) CreateShortTermLoan(bankAccountID uuid.UUID, amount Money) Loan {
 return Loan{
  Type:          ShortTerm,
  BankAccountID: bankAccountID,
  Amount:        amount,
 }
}

func (f *LoanFactory) CreateLongTermLoan(bankAccountID uuid.UUID, amount Money) Loan {
 return Loan{
  Type:                  LongTerm,
  BankAccountID:         bankAccountID,
  Amount:                amount,
  RequiredLifeInsurance: true,
 }
}

在上面的代码片段中,LoanFactory 现在是工厂方法的具体实现。它提供了两种创建贷款实体实例的方法。
在这种情况下,我们创建相同的对象,但它可以有不同的,这取决于贷款是长期的还是短期的。这两种情况之间的区别可能更加复杂,每增加一个复杂性,就是这种模式存在的新原因。

type Investment interface {
 Amount() Money
}

type EtfInvestment struct {
 ID             uuid.UUID
 EtfID          uuid.UUID
 InvestedAmount Money
 BankAccountID  uuid.UUID
}

func (e EtfInvestment) Amount() Money {
 return e.InvestedAmount
}

type StockInvestment struct {
 ID             uuid.UUID
 CompanyID      uuid.UUID
 InvestedAmount Money
 BankAccountID  uuid.UUID
}

func (s StockInvestment) Amount() Money {
 return s.InvestedAmount
}

type InvestmentSpecification interface {
 Amount() Money
 BankAccountID() uuid.UUID
 TargetID() uuid.UUID
}

type InvestmentFactory interface {
 Create(specification InvestmentSpecification) Investment
}

type EtfInvestmentFactory struct{}

func (f *EtfInvestmentFactory) Create(specification InvestmentSpecification) Investment {
 return EtfInvestment{
  EtfID:          specification.TargetID(),
  InvestedAmount: specification.Amount(),
  BankAccountID:  specification.BankAccountID(),
 }
}

type StockInvestmentFactory struct{}

func (f *StockInvestmentFactory) Create(specification InvestmentSpecification) Investment {
 return StockInvestment{
  CompanyID:      specification.TargetID(),
  InvestedAmount: specification.Amount(),
  BankAccountID:  specification.BankAccountID(),
 }
}

在上面的示例中,有一个带有抽象工厂模式的代码片段。在这种情况下,我们要创建 Investment 接口的一些实例。
由于该接口有多个实现,这看起来是添加工厂模式的最佳时机。EtfInvestmentFactory 和 StockInvestmentFactory 都创建 Investment 接口的实例。
在我们的代码中,我们可以将它们保存在 InvestmentFactory 接口的一些映射中,并在我们想要从任何 BankAccount 创建 Investment 时使用它们。
这是一个使用抽象工厂的好地方,因为我们应该从它们的广阔空间中创建一些对象(并且有更多不同的投资)。

重建

我们可以在其他层上使用工厂模式。至少对我来说,地方是基础设施和表示层。在那里,我使用它将数据传输对象转换为实体,反之亦然。

// domain layer

type CryptoInvestment struct {
 ID               uuid.UUID
 CryptoCurrencyID uuid.UUID
 InvestedAmount   Money
 BankAccountID    uuid.UUID
}

// infrastructure layer

type CryptoInvestmentGorm struct {
 ID                 int                `gorm:"primaryKey;column:id"`
 UUID               string             `gorm:"column:uuid"`
 CryptoCurrencyID   int                `gorm:"column:crypto_currency_id"`
 CryptoCurrency     CryptoCurrencyGorm `gorm:"foreignKey:CryptoCurrencyID"`
 InvestedAmount     int                `gorm:"column:amount"`
 InvestedCurrencyID int                `gorm:"column:currency_id"`
 Currency           CurrencyGorm       `gorm:"foreignKey:InvestedCurrencyID"`
 BankAccountID      int                `gorm:"column:bank_account_id"`
 BankAccount        BankAccountGorm    `gorm:"foreignKey:BankAccountID"`
}

type CryptoInvestmentDBFactory struct{}

func (f *CryptoInvestmentDBFactory) ToEntity(dto CryptoInvestmentGorm) (model.CryptoInvestment, error) {
 id, err := uuid.Parse(dto.UUID)
 if err != nil {
  return model.CryptoInvestment{}, err
 }

 cryptoId, err := uuid.Parse(dto.CryptoCurrency.UUID)
 if err != nil {
  return model.CryptoInvestment{}, err
 }

 currencyId, err := uuid.Parse(dto.Currency.UUID)
 if err != nil {
  return model.CryptoInvestment{}, err
 }

 accountId, err := uuid.Parse(dto.BankAccount.UUID)
 if err != nil {
  return model.CryptoInvestment{}, err
 }

 return model.CryptoInvestment{
  ID:               id,
  CryptoCurrencyID: cryptoId,
  InvestedAmount:   model.NewMoney(dto.InvestedAmount, currencyId),
  BankAccountID:    accountId,
 }, nil
}

CryptoInvestmentDBFactory 是用于重建 CryptoInvestment 实体的基础设施层内的工厂。这里只有DTO转Entity的方法,但是同一个Factory可以有Entity转DTO的方法。
由于 CryptoInvestmentDBFactory 使用来自基础设施 (CryptoInvestmentGorm) 和域 (CryptoInvestment) 的结构,它必须在基础设施层内,因为我们不能对域层内的其他层有任何依赖。
我总是喜欢在业务逻辑中使用 UUID,并在 API 响应中公开唯一的 UUID。但是,由于数据库不喜欢将字符串或二进制文件作为主键,因此 Factory 看起来是进行此转换的正确位置。

结论

工厂模式是一个概念,其根源在于四人帮的旧模式。我们可以将其实现为抽象工厂或工厂方法。当我们想要将创建逻辑与其他业务逻辑分离时,我们会使用它。我们还可以使用它来将我们的实体转换为 DTO,反之亦然。

Respository

今天很难想象在运行时不访问某些存储而编写某些应用程序。甚至可能不需要编写部署脚本,因为他们需要访问在某种程度上仍然是存储类型的配置文件。
每当你编写一些应用程序来解决真实商业世界中的某些问题时,你需要连接到数据库、外部 API、某些缓存系统等等。这是不可避免的。
从这个角度来看,拥有解决此类需求的 DDD 模式也就不足为奇了。当然,DDD 并没有在其他文献中发明 Repository 和它的许多应用,但是 DDD 增加了更多的清晰度。

The Anti-Corruption Layer(反腐败层)

领域驱动设计是一个我们可以应用到软件开发的很多方面和很多地方的原则。尽管如此,主要焦点还是在领域层,我们的业务逻辑应该在这个层。
由于 Repository 始终代表一种结构,该结构保留有关与某些外部世界的连接的技术细节,因此它已经不属于我们的业务逻辑。
但是,有时,我们需要从域层内部访问存储库。由于域层是最底层的,不与其他层通信,所以我们将存储库定义在其中,但作为一个接口。

import (
    "context"

    "github.com/google/uuid"
)

type Customer struct {
    ID uuid.UUID
    //
    // some fields
    //
}

type Customers []Customer

type CustomerRepository interface {
    GetCustomer(ctx context.Context, ID uuid.UUID) (*Customer, error)
    SearchCustomers(ctx context.Context, specification CustomerSpecification) (Customers, int, error)
    SaveCustomer(ctx context.Context, customer Customer) (*Customer, error)
    UpdateCustomer(ctx context.Context, customer Customer) (*Customer, error)
    DeleteCustomer(ctx context.Context, ID uuid.UUID) (*Customer, error)
}

我们将该接口称为 Contract,它定义了我们可以在域内调用的方法签名。在上面的示例中,我们可以找到一个定义 CRUD 方法的简单接口。
当我们将 Repository 定义为这样的接口时,我们可以在域层内的任何地方使用它。它总是期望并返回我们的实体,在本例中为 Customer 和 Customers(我喜欢在 Go 中定义此类特定集合以将不同的方法附加到它们)。
实体客户不持有关于以下存储类型的任何信息:没有定义 JSON 结构、Gorm 列或任何类似内容的 Go 标签。为此,我们必须使用基础设施层。

// 领域层

type CustomerRepository interface {
 GetCustomer(ctx context.Context, ID uuid.UUID) (*Customer, error)
 SearchCustomers(ctx context.Context, specification CustomerSpecification) (Customers, interror)
 SaveCustomer(ctx context.Context, customer Customer) (*Customer, error)
 UpdateCustomer(ctx context.Context, customer Customer) (*Customer, error)
 DeleteCustomer(ctx context.Context, ID uuid.UUID) (*Customer, error)
}

// 基础设施层

import (
 "context"

 "github.com/google/uuid"
 "gorm.io/gorm"
)

type CustomerGorm struct {
 ID   uint   `gorm:"primaryKey;column:id"`
 UUID string `gorm:"uniqueIndex;column:uuid"`
 //
 // some fields
 //
}

func (c CustomerGorm) ToEntity() (model.Customer, error) {
 parsed, err := uuid.Parse(c.UUID)
 if err != nil {
  return Customer{}, err
 }
 
 return model.Customer{
  ID: parsed,
  //
  // some fields
  //
 }, nil
}

type CustomerRepository struct {
 connection *gorm.DB
}

func (r *CustomerRepository) GetCustomer(ctx context.Context, ID uuid.UUID) (*model.Customer, error) {
 var row CustomerGorm
 err := r.connection.WithContext(ctx).Where("uuid = ?", ID).First(&row).Error
 if err != nil {
  return nil, err
 }
 
 customer, err := row.ToEntity()
 if err != nil {
  return nil, err
 }
 
 return &customer, nil
}
//
// other methods
//

在上面的示例中,您可能会看到** CustomerRepository** 实现的片段。它在内部使用 Gorm 来简化集成,但您也可以使用纯 SQL 查询。最近,我经常使用 Ent 库。
在示例中,您会看到两个不同的结构,Customer 和 CustomerGorm。第一个是实体,我们希望在其中保留我们的业务逻辑、一些域不变量和规则。它对底层数据库一无所知。
第二个结构是数据传输对象,它定义了我们的数据如何从存储传输到存储。这个结构没有任何其他责任,只是将数据库的数据映射到我们的实体。

这两个结构的划分是在我们的应用程序中使用 Repository 作为 Anti-Corruption 层的基本点。
它确保表结构的技术细节不会污染我们的业务逻辑。

这里的后果是什么?首先,事实是我们需要维护两种类型的结构,一种用于业务逻辑,一种用于存储。此外,我还插入了第三个结构,我将其用作我的 API 的数据传输对象。
这种方法给我们的应用程序和许多映射函数带来了复杂性,就像您在下面的示例中看到的那样。而且,您想正确测试这些方法以避免常见的复制粘贴错误。

// 领域层

type Customer struct {
 ID      uuid.UUID
 Person  *Person
 Company *Company
 Address Address
}

type Person struct {
 SSN       string
 FirstName string
 LastName  string
 Birthday  Birthday
}

type Birthday time.Time

type Company struct {
 Name               string
 RegistrationNumber string
 RegistrationDate   time.Time
}

type Address struct {
 Street   string
 Number   string
 Postcode string
 City     string
}

// 基础设施层

type CustomerGorm struct {
 ID        uint         `gorm:"primaryKey;column:id"`
 UUID      string       `gorm:"uniqueIndex;column:id"`
 PersonID  uint         `gorm:"column:person_id"`
 Person    *PersonGorm  `gorm:"foreignKey:PersonID"`
 CompanyID uint         `gorm:"column:company_id"`
 Company   *CompanyGorm `gorm:"foreignKey:CompanyID"`
 Street    string       `gorm:"column:street"`
 Number    string       `gorm:"column:number"`
 Postcode  string       `gorm:"column:postcode"`
 City      string       `gorm:"column:city"`
}

func (c CustomerGorm) ToEntity() (model.Customer, error) {
 parsed, err := uuid.Parse(c.UUID)
 if err != nil {
  return model.Customer{}, err
 }

 return model.Customer{
  ID:      parsed,
  Person:  c.Person.ToEntity(),
  Company: c.Company.ToEntity(),
  Address: Address{
   Street:   c.Street,
   Number:   c.Number,
   Postcode: c.Postcode,
   City:     c.City,
  },
 }, nil
}

type PersonGorm struct {
 ID        uint      `gorm:"primaryKey;column:id"`
 SSN       string    `gorm:"uniqueIndex;column:ssn"`
 FirstName string    `gorm:"column:first_name"`
 LastName  string    `gorm:"column:last_name"`
 Birthday  time.Time `gorm:"column:birthday"`
}

func (p *PersonGorm) ToEntity() *model.Person {
 if p == nil {
  return nil
 }

 return &model.Person{
  SSN:       p.SSN,
  FirstName: p.FirstName,
  LastName:  p.LastName,
  Birthday:  Birthday(p.Birthday),
 }
}

type CompanyGorm struct {
 ID                 uint      `gorm:"primaryKey;column:id"`
 Name               string    `gorm:"column:name"`
 RegistrationNumber string    `gorm:"column:registration_number"`
 RegistrationDate   time.Time `gorm:"column:registration_date"`
}

func (c *CompanyGorm) ToEntity() *model.Company {
 if c == nil {
  return nil
 }

 return &model.Company{
  Name:               c.Name,
  RegistrationNumber: c.RegistrationNumber,
  RegistrationDate:   c.RegistrationDate,
 }
}

func NewRow(customer model.Customer) CustomerGorm {
 var person *PersonGorm
 if customer.Person != nil {
  person = &PersonGorm{
   SSN:       customer.Person.SSN,
   FirstName: customer.Person.FirstName,
   LastName:  customer.Person.LastName,
   Birthday:  time.Time(customer.Person.Birthday),
  }
 }

 var company *CompanyGorm
 if customer.Company != nil {
  company = &CompanyGorm{
   Name:               customer.Company.Name,
   RegistrationNumber: customer.Company.RegistrationNumber,
   RegistrationDate:   customer.Company.RegistrationDate,
  }
 }

 return CustomerGorm{
  UUID:     uuid.NewString(),
  Person:   person,
  Company:  company,
  Street:   customer.Address.Street,
  Number:   customer.Address.Number,
  Postcode: customer.Address.Postcode,
  City:     customer.Address.City,
 }
}

尽管如此,除了整个维护之外,它还为我们的代码带来了新的价值。我们可以以最好地描述我们的业务逻辑的方式在域层内提供我们的实体。我们不限制我们使用的存储空间。
我们可以在我们的业务中使用一种类型的标识符(如 UUID),而另一种用于数据库(无符号整数)。这适用于我们想要用于数据库和业务逻辑的任何数据。
每当我们对这些层中的任何一个进行更改时,我们可能会在映射函数内部进行调整,而我们不会触及(或至少破坏)该层的其余部分。
我们可以决定要切换到 MongoDB、Cassandra 或任何其他类型的存储。我们可以切换到外部 API,但这仍然不会影响我们的领域层。(随意切换数据库,更好的做单元测试以及mock测试)

持久化

我们主要使用存储库进行查询。它与另一个 DDD 模式完美配合,您可能会在示例中注意到规范。我们可以在没有规范的情况下使用它,但有时它会让我们的生活更轻松。
Repository 的第二个特性是持久性。我们定义了将数据发送到下面的存储中以永久保存、更新甚至删除数据的逻辑。


func NewRow(customer Customer) CustomerGorm {
 return CustomerGorm{
  UUID: uuid.NewString(),
  //
  // some fields
  //
 }
}

type CustomerRepository struct {
 connection *gorm.DB
}

func (r *CustomerRepository) SaveCustomer(ctx context.Context, customer Customer) (*Customer, error) {
 row := NewRow(customer)
 err := r.connection.WithContext(ctx).Save(&row).Error
 if err != nil {
  return nil, err
 }

 customer, err = row.ToEntity()
 if err != nil {
  return nil, err
 }

 return &customer, nil
}
//
// other methods
//

有时我们决定在应用程序中创建我们想要的唯一标识符。在这种情况下,存储库是正确的地方。在上面的示例中,您可以看到我们在创建数据库记录之前生成了一个新的 UUID。
如果我们想避免从数据库引擎自动递增,我们可以用整数来做到这一点。无论如何,如果我们不想依赖数据库键,我们应该在 Repository 中创建它们。

type CustomerRepository struct {
 connection *gorm.DB
}

func (r *CustomerRepository) CreateCustomer(ctx context.Context, customer Customer) (*Customer, error) {
 tx := r.connection.Begin()
 defer func() {
  if r := recover(); r != nil {
   tx.Rollback()
  }
 }()

 if err := tx.Error; err != nil {
  return nil, err
 }

 //
 // some code
 //

 var total int64
 var err error
 if customer.Person != nil {
  err = tx.Model(PersonGorm{}).Where("ssn = ?", customer.Person.SSN).Count(&total).Error
 } else if customer.Person != nil {
  err = tx.Model(CompanyGorm{}).Where("registration_number = ?", customer.Person.SSN).Count(&total).Error
 }
 if err != nil {
  tx.Rollback()
  return nil, err
 } else if total > 0 {
  tx.Rollback()
  return nil, errors.New("there is already such record in DB")
 }
 
 //
 // some code
 //
 
 err = tx.Save(&row).Error
 if err != nil {
  tx.Rollback()
  return nil, err
 }

 err = tx.Commit().Error
 if err != nil {
  tx.Rollback()
  return nil, err
 }

 customer := row.ToEntity()

 return &customer, nil
}

我们希望存储库用于的另一件事是事务。每当我们想要持久化一些数据并执行对同一组广泛的表工作的许多查询时,这是定义一个事务的好时机,我们应该在存储库中传递它。
在上面的示例中,我们正在检查 Person 或 Company 的唯一性。如果它们存在,我们将返回一个错误。我们可以将所有这些定义为单个事务的一部分,如果那里出现问题,我们可以回滚它。
这里的 Repository 是存放此类代码的理想场所。很好的是,我们也可以在未来使我们的插入更直接,这样我们就根本不需要事务了。在那种情况下,我们不会更改存储库的合约,而只会更改其中的代码。

类型

认为我们应该只为数据库使用 Repository 是错误的。是的,我们在数据库中使用它最多,因为它们是我们存储的首选,但今天其他类型的存储更受欢迎。
如前所述,我们可以使用 MongoDB 或 Cassandra。我们可以使用存储库来保存我们的缓存,在这种情况下,例如 Redis。它甚至可以是 REST API 或配置文件。


// redis存储库

type CustomerRepository struct {
 client *redis.Client
}

func (r *CustomerRepository) GetCustomer(ctx context.Context, ID uuid.UUID) (*Customer, error) {
 data, err := r.client.Get(ctx, fmt.Sprintf("user-%s", ID.String())).Result()
 if err != nil {
  return nil, err
 }

 var row CustomerJSON
 err = json.Unmarshal([]byte(data), &row)
 if err != nil {
  return nil, err
 }
 
 customer := row.ToEntity()

 return &customer, nil
}

// API

type CustomerRepository struct {
 client *http.Client
 baseUrl string
}

func (r *CustomerRepository) GetCustomer(ctx context.Context, ID uuid.UUID) (*Customer, error) {
 resp, err := r.client.Get(path.Join(r.baseUrl, "users", ID.String()))
 if err != nil {
  return nil, err
 }
 
 data, err := ioutil.ReadAll(resp.Body)
 if err != nil {
  return nil, err
 }
 defer resp.Body.Close()

 var row CustomerJSON
 err = json.Unmarshal(data, &row)
 if err != nil {
  return nil, err
 }

 customer := row.ToEntity()

 return &customer, nil
}

现在我们可以看到在业务逻辑和技术细节之间进行拆分的真正好处。我们为 Repository 保留相同的接口,因此我们的领域层可以始终使用它。
但是,有一天,我们的应用程序可能会增长到 MySQL 不是我们分布式应用程序的完美解决方案的地步。因此,在迁移的情况下,我们不必担心我们的业务逻辑是否会受到影响,只要我们保持接口不变即可。

所以,你的 Repository Contract 应该总是处理你的业务逻辑,但是你的 Repository 实现必须使用你可以稍后映射到 Entities 的内部结构。

结论

Repository 是众所周知的模式,负责在底层存储中查询和持久化数据。这是我们应用程序中反腐败的要点。
我们将其定义为域层内的合约,并将实际实现保留在基础设施层内。它是生成应用程序制造的标识符和运行事务的地方。

Specification

没有那么多代码结构可以在我需要编写它们时给我带来快乐。我第一次实现这样的代码是在 Go 中使用轻量级 ORM,当时我们没有这样的代码。
另一方面,我使用 ORM 多年。在某些时候,当您依赖 ORM 时,使用 QueryBuilder 是不可避免的。在这里,您可能会注意到谓词之类的术语。那就是我们可以找到规范模式的地方。很难找到我们用作规范的任何模式,但我们没有听到它的名字。
我认为唯一困难的是在不使用这种模式的情况下编写应用程序。
规范有很多应用。我们可以用它来查询、创建或验证。我们可能会提供唯一的代码来完成所有这些工作,或者为不同的用例提供不同的实现。

验证

规范模式的第一个用例是验证。我们主要验证表单中的数据,但这是在表示级别上。有时,我们在创建期间执行此操作,例如对于值对象。
在领域层的上下文中,我们可以使用规范来验证实体的状态并从集合中过滤实体。因此,领域层的验证已经具有比用户输入更广泛的含义。

type Product struct {
 ID            uuid.UUID
 Material      MaterialType
 IsDeliverable bool
 Quantity      int
}

type ProductSpecification interface {
 IsValid(product Product) bool
}

type AndSpecification struct {
 specifications []ProductSpecification
}

func NewAndSpecification(specifications ...ProductSpecification) ProductSpecification {
 return AndSpecification{
  specifications: specifications,
 }
}

func (s AndSpecification) IsValid(product Product) bool {
 for _, specification := range s.specifications {
  if !specification.IsValid(product) {
   return false
  }
 }
 return true
}

type HasAtLeast struct {
 pieces int
}

func NewHasAtLeast(pieces int) ProductSpecification {
 return HasAtLeast{
  pieces: pieces,
 }
}

func (h HasAtLeast) IsValid(product Product) bool {
 return product.Quantity >= h.pieces
}

func IsPlastic(product Product) bool {
 return product.Material == Plastic
}

func IsDeliverable(product Product) bool {
 return product.IsDeliverable
}

type FunctionSpecification func(product Product) bool

func (fs FunctionSpecification) IsValid(product Product) bool {
 return fs(product)
}

func main() {
 spec := NewAndSpecification(
  NewHasAtLeast(10),
  FunctionSpecification(IsPlastic),
  FunctionSpecification(IsDeliverable),
 )

 fmt.Println(spec.IsValid(Product{}))
 // output: false

 fmt.Println(spec.IsValid(Product{
  Material:      Plastic,
  IsDeliverable: true,
  Quantity:      50,
 }))
 // output: true
}

在上面的示例中,有一个接口 ProductSpecification。它只定义了一个方法 IsValid,如果 Product 通过验证规则,它需要 Product 的实例并返回一个布尔值。
该接口的简单实现是 HasAtLeast,它验证 Product 的最小数量。更有趣的验证器是两个函数,IsPlastic 和 IsDeliverable。我们可以用特定类型 FunctionSpecification 包装这些函数。
这种类型嵌入了与上述两个签名相同的函数。除此之外,它还提供了遵循 ProductSpecification 接口的方法。
这个例子是 Go 的一个很好的特性,我们可以将一个函数定义为一个类型并为其附加一个方法,因此它可以隐式实现一些接口。我们这里有这种情况,它提出了执行嵌入式功能的方法 IsValid。
此外,还有一个唯一的Specification,AndSpecification。这样的结构帮助我们使用一个对象,该对象实现了 ProductSpecification 接口,但对包含的所有规范的验证进行了分组。

type OrSpecification struct {
 specifications []ProductSpecification
}

func NewOrSpecification(specifications ...ProductSpecification) ProductSpecification {
 return OrSpecification{
  specifications: specifications,
 }
}

func (s OrSpecification) IsValid(product Product) bool {
 for _, specification := range s.specifications {
  if specification.IsValid(product) {
   return true
  }
 }
 return false
}

type NotSpecification struct {
 specification ProductSpecification
}

func NewNotSpecification(specification ProductSpecification) ProductSpecification {
 return NotSpecification{
  specification: specification,
 }
}

func (s NotSpecification) IsValid(product Product) bool {
 return !s.specification.IsValid(product)
}

在上面的代码片段中,我们可能会发现两个额外的规范。一个是 OrSpecification,它与 AndSpecification 一样,执行它持有的所有规范。只是,在这种情况下,它使用 or 算法来代替 and。
最后一个是NotSpecification,否定embedded Specification的结果。NotSpecification 也可以是功能规范,但我不想让它太复杂。

使用接口,完成功能的组合实现。 尽可能的使得代码的可测试性非常方便。

查询

我已经在本文中提到规范模式作为 ORM 的一部分的应用。在许多情况下,您不需要为此用例实施规范,至少如果您使用任何 ORM。
规范的优秀实现,以谓词的形式,我在 Facebook 的 Ent 库中找到。从那一刻起,我没有用例来编写用于查询的规范。
尽管如此,当您发现域级别的存储库查询可能过于复杂时,您需要更多的可能性来过滤所需的实体。实现可能类似于下面的示例。

type Product struct {
 ID            uuid.UUID
 Material      MaterialType
 IsDeliverable bool
 Quantity      int
}

type ProductSpecification interface {
 Query() string
 Value() []interface{}
}

type AndSpecification struct {
 specifications []ProductSpecification
}

func NewAndSpecification(specifications ...ProductSpecification) ProductSpecification {
 return AndSpecification{
  specifications: specifications,
 }
}

func (s AndSpecification) Query() string {
 var queries []string
 for _, specification := range s.specifications {
  queries = append(queries, specification.Query())
 }

 query := strings.Join(queries, " AND ")

 return fmt.Sprintf("(%s)", query)
}

func (s AndSpecification) Value() []interface{} {
 var values []interface{}
 for _, specification := range s.specifications {
  values = append(values, specification.Value()...)
 }
 return values
}

type OrSpecification struct {
 specifications []ProductSpecification
}

func NewOrSpecification(specifications ...ProductSpecification) ProductSpecification {
 return OrSpecification{
  specifications: specifications,
 }
}

func (s OrSpecification) Query() string {
 var queries []string
 for _, specification := range s.specifications {
  queries = append(queries, specification.Query())
 }

 query := strings.Join(queries, " OR ")

 return fmt.Sprintf("(%s)", query)
}

func (s OrSpecification) Value() []interface{} {
 var values []interface{}
 for _, specification := range s.specifications {
  values = append(values, specification.Value()...)
 }
 return values
}

type HasAtLeast struct {
 pieces int
}

func NewHasAtLeast(pieces int) ProductSpecification {
 return HasAtLeast{
  pieces: pieces,
 }
}

func (h HasAtLeast) Query() string {
 return "quantity >= ?"
}

func (h HasAtLeast) Value() []interface{} {
 return []interface{}{h.pieces}
}

func IsPlastic() string {
 return "material = 'plastic'"
}

func IsDeliverable() string {
 return "deliverable = 1"
}

type FunctionSpecification func() string

func (fs FunctionSpecification) Query() string {
 return fs()
}

func (fs FunctionSpecification) Value() []interface{} {
 return nil
}

func main() {

 spec := NewOrSpecification(
  NewAndSpecification(
   NewHasAtLeast(10),
   FunctionSpecification(IsPlastic),
   FunctionSpecification(IsDeliverable),
  ),
  NewAndSpecification(
   NewHasAtLeast(100),
   FunctionSpecification(IsPlastic),
  ),
 )

 fmt.Println(spec.Query())
 // output: ((quantity >= ? AND material = 'plastic' AND deliverable = 1) OR (quantity >= ? AND material = 'plastic'))

 fmt.Println(spec.Value())
 // output: [10 100]
}

在新的实现中,ProductSpecification 接口提供了两种方法,Query 和 Values。我们使用它们来获取特定规范的查询字符串及其包含的可能值。
再一次,我们可以看到额外的规范、AndSpecification 和 OrSpecification。在这种情况下,它们根据它们提供的运算符连接所有基础查询,并合并所有值。
在领域层上有这样的规范是值得怀疑的。从输出中您可能会看到,Specifications 提供了类似 SQL 的语法,这在技术细节方面涉及太多。在这种情况下,解决方案可能是在领域层为不同的规范定义接口,在基础设施层为实际实现定义接口。
或者重组代码,以便规范包含有关字段名称、操作和值的信息。然后在基础设施层上有一些映射器,可以将这种规范映射到 SQL 查询。

创建

规范的一个简单用例是创建一个变化很大的复杂对象。在这种情况下,我们可以将其与工厂模式结合使用,或者在领域服务内部使用。

type Product struct {
 ID            uuid.UUID
 Material      MaterialType
 IsDeliverable bool
 Quantity      int
}

type ProductSpecification interface {
 Create(product Product) Product
}

type AndSpecification struct {
 specifications []ProductSpecification
}

func NewAndSpecification(specifications ...ProductSpecification) ProductSpecification {
 return AndSpecification{
  specifications: specifications,
 }
}

func (s AndSpecification) Create(product Product) Product {
 for _, specification := range s.specifications {
  product = specification.Create(product)
 }
 return product
}

type HasAtLeast struct {
 pieces int
}

func NewHasAtLeast(pieces int) ProductSpecification {
 return HasAtLeast{
  pieces: pieces,
 }
}

func (h HasAtLeast) Create(product Product) Product {
 product.Quantity = h.pieces
 return product
}

func IsPlastic(product Product) Product {
 product.Material = Plastic
 return product
}

func IsDeliverable(product Product) Product {
 product.IsDeliverable = true
 return product
}

type FunctionSpecification func(product Product) Product

func (fs FunctionSpecification) Create(product Product) Product {
 return fs(product)
}

func main() {
 spec := NewAndSpecification(
  NewHasAtLeast(10),
  FunctionSpecification(IsPlastic),
  FunctionSpecification(IsDeliverable),
 )

 fmt.Printf("%+v", spec.Create(Product{
  ID: uuid.New(),
 }))
 // output: {ID:86c5db29-8e04-4caf-82e4-91d6906cff12 Material:plastic IsDeliverable:true Quantity:10}
}

在上面的示例中,我们可能会找到 Specification 的第三种实现。在这种情况下,ProductSpecification 支持一种方法 Create,它需要 Product,对其进行调整,然后将其返回。
再一次,有 AndSpecification 来应用从多个 Specification 定义的更改,但是没有 OrSpecification。在创建对象的过程中,我找不到实际用例或算法。
即使它不存在,我们也可以引入 NotSpecification,它可以处理特定的数据类型,比如布尔值。尽管如此,在这个小例子中,我还是找不到合适的。

结论

规范是我们在许多不同情况下随处使用的模式。今天,如果不使用规范,就很难在领域层提供验证。我们还可以使用规范从底层存储中查询对象。
今天,它们是 ORM 的一部分。第三种用法是创建复杂实例,我们可以将其与工厂模式结合使用。

规范的使用GORM

Go 中泛型的力量:GORM 的存储库模式
多年来,每当我们想要提供一些通用性和抽象性时,我们都会在 Go 中使用代码生成器。学习什么是“The Golang Way”对我们许多人来说是一段艰难的时期,但它也给了我们许多突破。这是值得的。
现在,新牌已经摆在桌面上了。许多新包的出现,给了我们一些关于如何用可重用代码丰富 Go 生态系统的想法,让我们所有人的生活更轻松。而且,类似的事情是我的灵感,它把我带到了基于 GORM 库的小型概念验证。现在,让我们尝试一下。

源码

当我写这篇文章时,它依赖于 GitHub 上的一个 Git 存储库。该代码代表一个 Go 库作为概念证明,我打算进一步研究它。尽管如此,它还没有准备好在生产中使用(当然,我当时不打算提供任何生产支持)。

您可以在链接上查看当前功能,其中较小的示例位于以下代码段中:

package main

import (
   "github.com/ompluscator/gorm-generics"
 // some imports
)

// Product 领域实体
type Product struct {
 // some fields
}

// ProductGorm is DTO used to map Product entity to database
type ProductGorm struct {
 // some fields
}

// ToEntity respects the gorm_generics.GormModel interface
func (g ProductGorm) ToEntity() Product {
 return Product{
  // some fields
 }
}

// FromEntity respects the gorm_generics.GormModel interface
func (g ProductGorm) FromEntity(product Product) interface{} {
 return ProductGorm{
  // some fields
 }
}

func main() {
 db, err := gorm.Open(/* DB connection string */)
 // handle error

 err = db.AutoMigrate(ProductGorm{})
 // handle error

 // initialize a new Repository with by providing
 // GORM model and Entity as type
 repository := gorm_generics.NewRepository[ProductGorm, Product](db)

 ctx := context.Background()

 // create new Entity
 product := Product{
  // some fields
 }
 
 // send new Entity to Repository for storing
 err = repository.Insert(ctx, &product)
 // handle error

 fmt.Println(product)
 // Out:
 // {1 product1 100 true}

 single, err := repository.FindByID(ctx, product.ID)
 // handle error
 
 fmt.Println(single)
 // Out:
 // {1 product1 100 true}
}

为什么我为 PoC 选择了 ORM?

PoC(概念验证)
由于我的背景是软件开发人员,使用过时的 OO 编程语言(如 Java、C# 和 PHP),所以我在 Google 上进行的第一个搜索是关于一些适合 Golang 的 ORM。请原谅我当时的青春,但那是我的期望。
这并不是说我不能没有 ORM。我不是特别欣赏原始 MySQL 查询在代码中的样子。所有这些字符串连接在我看来都很丑陋。
另一方面,我总是喜欢立即跳入编写业务逻辑,而且我几乎没有时间花在思考底层存储上。有时我在实施过程中改变主意,转而使用其他类型的存储。这就是 ORM 让我的生活更轻松的地方。
简而言之,ORM 赋予我:

  • 更简洁的代码
  • 更灵活地选择底层存储类型
  • 整个关注业务逻辑而不是技术细节

Golang中针对ORM的解决方案有很多,我也用过大部分。毫不奇怪,GORM 是我使用最多的一个,因为它涵盖了大部分功能。是的,它遗漏了一些众所周知的模式,例如身份映射、工作单元和延迟加载,但我可以没有它。
不过,我一直怀念存储库模式,因为我时不时遇到相似或相同代码块的重复(我讨厌重复自己)。
为此,我有时一直在使用 GNORM 库,它的模板逻辑让我可以自由地生成存储库结构。虽然我喜欢 GNORM 提供的想法(很好的 The Golang Way!),但不断更新模板以向存储库提供新功能看起来并不好。
我已经尝试交付我的实现,该实现将基于反思并将其提供给开源社区。我不能再失败了。可以用,但是维护库很痛苦,而且性能也不是传奇。最后,我从 GitHub 上删除了 git 仓库。
而且,当我放弃在 Go 中进行 ORM 升级时,泛型出现了。好家伙。好家伙!我立即回到绘图板上。

实现

我的部分背景是领域驱动设计。这意味着我喜欢将领域层与基础设施层分离。一些 ORM 将实体模式更像是行数据网关或活动记录。但是,由于它的名称引用了 DDD 模式实体,我们最终不知何故在翻译中迷失了方向,我们倾向于将业务逻辑和技术细节存储在同一个类中。我们制造了一个怪物。

实体模式与数据库表方案映射没有任何关系。它与底层存储无关。

所以,我总是在领域层使用实体,在基础设施层使用数据传输对象。我的存储库的签名始终只支持实体,但在内部它们使用 DTO 将数据映射到数据库或从数据库映射数据,并将它们获取并存储到实体中。它在那里保证我们有一个功能性的反腐败层。
在这种情况下,我可以识别三个接口和结构(如下图所示):

  • 实体作为领域层业务逻辑的持有者;
  • GormModel 作为 DTO 用于将数据从实体映射到数据库;
  • GormRepository 作为查询和持久化数据的编排器;

image.png
GormModel 和 GormRepository 这两个主要部分需要定义其方法签名的泛型类型。使用泛型允许我们将 GormRepository 定义为结构并概括实现:

func (r *GormRepository[M, E]) Insert(ctx context.Context, entity *E) error {
   // map the data from Entity to DTO
 var start M
 model := start.FromEntity(*entity).(M)

   // create new record in the database
 err := r.db.WithContext(ctx).Create(&model).Error
 // handle error

   // map fresh record's data into Entity
 *entity = model.ToEntity()
 return nil
}

func (r *GormRepository[M, E]) FindByID(ctx context.Context, id uint) (E, error) {
   // retrieve a record by id from a database
 var model M
 err := r.db.WithContext(ctx).First(&model, id).Error
 // handle error

   // map data into Entity
 return model.ToEntity(), nil
}

func (r *GormRepository[M, E]) Find(ctx context.Context, specification Specification) ([]E, error) {
   // retreive reords by some criteria
 var models []M
 err := r.db.WithContext(ctx).Where(specification.GetQuery(), specification.GetValues()...).Find(&models).Error
 // handle error

   // mapp all records into Entities
 result := make([]E, 0len(models))
 for _, row := range models {
  result = append(result, row.ToEntity())
 }

 return result, nil
}

我不打算添加或多或少的复杂功能,例如预加载、连接,甚至不打算为此概念证明添加限制和偏移量。这个想法是用 GORM 库检查 Go 中泛型实现的简单性。

在代码片段中,我们可以看到 GormRepository 结构支持插入新记录,如按身份检索或按规范查询。规范模式是领域驱动设计的另一种模式,我们可以将其用于多种用途,包括从存储中查询数据。
此处提供的概念验证定义了一个 Specification 接口,它提供了一个 WHERE 子句和其中使用的值。它确实需要对可比运算符使用泛型,它可能是未来查询对象的前身:

type Specification interface {
 GetQuery() string
 GetValues() []any
}

// joinSpecification is the real implementation of Specification interface.
// It is used fo AND and OR operators.
type joinSpecification struct {
 specifications []Specification
 separator      string
}

// GetQuery concats all subqueries
func (s joinSpecification) GetQuery() string {
 queries := make([]string0len(s.specifications))

 for _, spec := range s.specifications {
  queries = append(queries, spec.GetQuery())
 }

 return strings.Join(queries, fmt.Sprintf(" %s ", s.separator))
}

// GetQuery concats all subvalues
func (s joinSpecification) GetValues() []any {
 values := make([]any, 0)

 for _, spec := range s.specifications {
  values = append(values, spec.GetValues()...)
 }

 return values
}

// And delivers AND operator as Specification
func And(specifications ...Specification) Specification {
 return joinSpecification{
  specifications: specifications,
  separator:      "AND",
 }
}

// notSpecification negates sub-Specification
type notSpecification struct {
 Specification
}

// GetQuery negates subquery
func (s notSpecification) GetQuery() string {
 return fmt.Sprintf(" NOT (%s)", s.Specification.GetQuery())
}

// Not delivers NOT operator as Specification
func Not(specification Specification) Specification {
 return notSpecification{
  specification,
 }
}

// binaryOperatorSpecification defines binary operator as Specification
// It is used for =, >, <, >=, <= operators.
type binaryOperatorSpecification[T any] struct {
 field    string
 operator string
 value    T
}

// GetQuery builds query for binary operator
func (s binaryOperatorSpecification[T]) GetQuery() string {
 return fmt.Sprintf("%s %s ?", s.field, s.operator)
}

// GetValues returns a value for binary operator
func (s binaryOperatorSpecification[T]) GetValues() []any {
 return []any{s.value}
}

// Not delivers = operator as Specification
func Equal[T any](field string, value T) Specification {
 return binaryOperatorSpecification[T]{
  field:    field,
  operator: "=",
  value:    value,
 }
}

包的规范部分提供了为存储库提供自定义标准并检索满足它的数据的可能性。使用它可以组合标准、否定它们并进一步扩展它们。

结果

这个实现最终总结了这个概念证明的最终目标,即提供一个通用接口来查询数据库中的记录:

 err := repository.Insert(ctx, &Product{
  Name:        "product2",
  Weight:      50,
  IsAvailabletrue,
 })
 // error handling

 err = repository.Insert(ctx, &Product{
  Name:        "product3",
  Weight:      250,
  IsAvailablefalse,
 })
 // error handling

 many, err := repository.Find(ctx, gorm_generics.And(
  gorm_generics.GreaterOrEqual("weight"90),
  gorm_generics.Equal("is_available"true)),
 )
 // error handling

 fmt.Println(many)
 // Out:
 // [{1 product1 100 true}]

关于我的愿望,上面的代码片段提供了一种快速而优雅的方式来以清晰易读的形式检索数据。并且不影响性能(显着)。

结论

在 Go 1.18 正式发布后第一次接触泛型是一个重大的更新。我最近一直在与一些挑战作斗争,为新想法提供这个机会超出了我的需要。