在本文中你可以看到一套较为完整的仓储层 => 领域层 => 表现层的 Golang 代码实现,但是肯定不会覆盖全部 DDD 概念,各位可以将它看作一种 Golang 中 DDD 的最佳实践来参考。

01背景

因为业务需求,我们当时正在用 Golang 从 0 到 1 构建一个Web 系统,由于是个新系统,所以很多东西要自己摸索,当时摆在面前的实际问题是如何更好的组织数据访问层和核心领域模型的代码。

相比 Java 构建的系统,代码组织方式、分层等最佳实践已经固化在 Sprint Boot、Sofa Boot 等框架中,Golang 缺乏这种标准化的最佳实践。这个时候我们想到领域驱动设计(DDD),看起来可以解决我们的问题。

02整体设计

指导思想(DDD):重点借鉴了 DDD 中的表现层(User Interface)、领域层(Domain)和基础设施层(Infrastructure)解决我们的问题。

03代码实现

领域层

领域层(Domain)是系统的核心,负责表达业务概念,业务状态信息以及业务规则,即包含了该领域(问题域)所有复杂的业务知识抽象和规则定义。该层主要精力要放在领域对象分析上,可以从实体,值对象,聚合(聚合根),领域服务,领域事件,仓储,工厂等方面入手;

本文中放置核心模型定义(实体)和仓储抽象。

什么是实体(Entity)?

一个实体是一个唯一的东西,并且可以在相当长的一段时间内持续地变化。唯一的身份标识和可变性(mutability)特征将实体对象和值对象(Value Object)区分开来。

——《实现领域驱动设计》

package entity


import (
  "fmt"
  "net/url"
  "time"
)


// Source represents the specific configuration code source,
// which should be a specific instance of the source provider.
type Source struct {
  // ID is the id of the source.
  ID uint
  // SourceProvider is the type of the source provider.
  SourceProvider SourceProviderType
  // Remote is the source URL, including scheme.
  Remote *url.URL
  // CreatedAt is the timestamp of the created for the source.
  CreatedAt time.Time
  // CreatedAt is the timestamp of the updated for the source.
  UpdatedAt time.Time
}
仓储(Repository)接口定义
package repository


import (
  "context"
  "github.com/elliotxx/ddd-demo/pkg/domain/entity"
)


// SourceRepository is an interface that defines the repository operations for sources.
// It follows the principles of domain-driven design (DDD).
type SourceRepository interface {
  // Create creates a new source.
  Create(ctx context.Context, source *entity.Source) error
  // Delete deletes a source by its ID.
  Delete(ctx context.Context, id uint) error
  // Update updates an existing source.
  Update(ctx context.Context, source *entity.Source) error
  // Get retrieves a source by its ID.
  Get(ctx context.Context, id uint) (*entity.Source, error)
  // Find returns a list of specified sources.
  Find(ctx context.Context, query Query) ([]*entity.Source, error)
  // Count returns the total of sources.
  Count(ctx context.Context) (int, error)
}
基础设施层

基础设施层(Infrastructure)主要有2方面内容,一是为领域模型提供持久化机制,当软件需要持久化能力时候才需要进行规划;一是对其他层提供通用的技术支持能力,如消息通信,通用工具,配置等的实现。本篇中主要负责放置仓储实现。

仓储(Repository)接口实现
package persistence
import (
  "context"
  "github.com/elliotxx/ddd-demo/pkg/domain/entity"
  "github.com/elliotxx/ddd-demo/pkg/domain/repository"
  "github.com/elliotxx/errors"
  "gorm.io/gorm"
)
// The sourceRepository type implements the repository.SourceRepository interface.
// If the sourceRepository type does not implement all the methods of the interface,
// the compiler will produce an error.
var _ repository.SourceRepository = &sourceRepository{}


// sourceRepository is a repository that stores sources in a gorm database.
type sourceRepository struct {
  // db is the underlying gorm database where sources are stored.
  db *gorm.DB
}


// NewSourceRepository creates a new source repository.
func NewSourceRepository(db *gorm.DB) repository.SourceRepository {
  return &sourceRepository{db: db}
}


// Create saves a source to the repository.
func (r *sourceRepository) Create(ctx context.Context, dataEntity *entity.Source) error {
    // ......
    // do something
    // ......
}


// Delete removes a source from the repository.
func (r *sourceRepository) Delete(ctx context.Context, id uint) error {
    // ......
    // do something
    // ......
}


// Update updates an existing source in the repository.
func (r *sourceRepository) Update(ctx context.Context, dataEntity *entity.Source) error {
    // ......
    // do something
    // ......
}


// Find retrieves a source by its ID.
func (r *sourceRepository) Get(ctx context.Context, id uint) (*entity.Source, error) {
    // ......
    // do something
    // ......
}


// Find returns a list of specified sources in the repository.
func (r *sourceRepository) Find(ctx context.Context, query repository.Query) ([]*entity.Source, error) {
    // ......
    // do something
    // ......
}


// Count returns the total of sources.
func (r *sourceRepository) Count(ctx context.Context) (int, error) {
    // ......
    // do something
    // ......
}
数据对象

由于 Entity 领域层的定义,是流通在领域层的结构,所以不适合直接用于和数据库直接打交道,所以还需要一个和数据库表完全映射的数据对象(DO),参考社区的做法,找到一种最佳实践,即:

https://hindenbug.io/the-power-of-generics-in-go-the-repository-pattern-for-gorm-7f8891df0934

具体思路是定义一个数据库对象,比如数据源(Source)对应的数据库对象是 SourceModel,它具有 FromEntity 和 ToEntity 方法,用于进行领域实体和数据库对象之间的相互转换。同时它也实现了 GORM 的 TableName 方法,用于指定对应的数据库表名。

SourceModel 实现:

package persistence


import (
  "net/url"
  "github.com/elliotxx/ddd-demo/pkg/domain/entity"
  "github.com/elliotxx/errors"
  "gorm.io/gorm"
)


// SourceModel is a DO used to map the entity to the database.
type SourceModel struct {
  gorm.Model
  // SourceProvider is the type of the source provider.
  SourceProvider string
  // Remote is the source URL, including scheme.
  Remote string
}


// The TableName method returns the name of the database table that the struct is mapped to.
func (m *SourceModel) TableName() string {
  return "source"
}


// ToEntity converts the DO to a entity.
func (m *SourceModel) ToEntity() (*entity.Source, error) {
  if m == nil {
    m = &SourceModel{}
  }
    
  sourceProvider, err := entity.ParseSourceProviderType(m.SourceProvider)
  if err != nil {
    return nil, errors.Wrap(err, "failed to parse source provider type")
  }
    
  remote, err := url.Parse(m.Remote)
  if err != nil {
    return nil, errors.Wrap(err, "failed to parse remote into URL structure")
  }
    
  return &entity.Source{
    ID:             m.ID,
    SourceProvider: sourceProvider,
    Remote:         remote,
    CreatedAt:      m.CreatedAt,
    UpdatedAt:      m.UpdatedAt,
  }, nil
}


// FromEntity converts a entity to a DO.
func (m *SourceModel) FromEntity(e *entity.Source) error {
  if m == nil {
    m = &SourceModel{}
  }
  if err := e.Validate(); err != nil {
    return err
  }
  m.ID = e.ID
  m.SourceProvider = string(e.SourceProvider)
  m.Remote = e.Remote.String()
  m.CreatedAt = e.CreatedAt
  m.UpdatedAt = e.UpdatedAt
  return nil
}
事务
// Create saves a source to the repository.
func (r *sourceRepository) Create(ctx context.Context, dataEntity *entity.Source) error {
  // Map the data from Entity to DO
  var dataModel SourceModel
  err := dataModel.FromEntity(dataEntity)
  if err != nil {
    return err
  }
  return r.db.Transaction(func(tx *gorm.DB) error {
    // Create new record in the store
    err = tx.WithContext(ctx).Create(&dataModel).Error
    if err != nil {
      return err
    }
    // Map fresh record's data into Entity
    newEntity, err := dataModel.ToEntity()
    if err != nil {
      return err
    }
    *dataEntity = *newEntity
    return nil
  })
}
表现层

表现层(User Interface):用户界面层,或者表现层,负责向用户显示解释用户命令。

RESTFul API 实现

比较薄,纯逻辑编排,包括检验、CURD 等。

//  @Summary    Update source
//  @Description  Update the specified source
//  @Accept      json
//  @Produce    json
//  @Param      source  body    UpdateSourceRequest  true  "Updated source"
//  @Success    200    {object}  entity.Source    "Success"
//  @Failure    400    {object}  errors.DetailError  "Bad Request"
//  @Failure    401    {object}  errors.DetailError  "Unauthorized"
//  @Failure    429    {object}  errors.DetailError  "Too Many Requests"
//  @Failure    404    {object}  errors.DetailError  "Not Found"
//  @Failure    500    {object}  errors.DetailError  "Internal Server Error"
//  @Router      /api/v1/source [put]
func (h *Handler) UpdateSource(c *gin.Context, log logrus.FieldLogger) error {
    // ......
    // do something
    // ......
}
OpenAPI

作用:自动生成 Swagger 接口文档 + 多语言 SDK;

效果:

Swagger 接口文档:LiveDemo:https://petstore.swagger.io/?_ga=2.268933875.36106021.1681113818-3234578.1681113817

多语言 SDK:https://openapi-generator.tech/

04一些技巧

分享本 PR 中使用到的一些 Golang 奇巧:

检查结构体是否实现了某接口的防御代码

如下图示例,结构体 sourceRepository 如果没有实现repository.SourceRepository 接口,IDE 会直接飘红,或者在编译阶段报错。

写法一:

// The sourceRepository type implements the repository.SourceRepository interface.
// If the sourceRepository type does not implement all the methods of the interface,
// the compiler will produce an error.
var _ repository.SourceRepository = &sourceRepository{}

写法二:

var _ repository.SourceRepository = (*sourceRepository)(nil)
适应于 Go Web 应用的错误处理

Golang 标准库中的 error 类型只包含错误信息,我们自定义了适应于 Go Web 应用的错误包(elliotxx/errors),可以包含更多错误信息,比如错误堆栈、错误码、错误原因等;

示例1:

if err := c.ShouldBindJSON(&requestPayload); err != nil {
    return errcode.ErrDeserializedParams.Causewf(err, "failed to decode json")
}

示例2:

// Get the existed source by id
updatedEntity, err := h.repo.Get(context.TODO(), requestEntity.ID)
if err != nil {
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return errcode.NotFound.Causewf(err, "failed to update source")
    }
    return errcode.InvalidParams.Cause(err)
}
对象拷贝

大部分字段相似的对象可以使用社区的 copier 轮子直接深度拷贝,一行搞定,不用写多行赋值语句了。

// Overwrite non-zero values in request entity to existed entity
copier.CopyWithOption(updatedEntity, requestEntity, copier.Option{IgnoreEmpty: true})

05心得分享

当 Coding 时遇到迷茫,第一选择应该是到社区寻找最佳实践,比如到 Github、SourceGraph 等平台搜索代码实现,不要闭门造车;

代码实现的时候可以尽量考虑开放性,比如引入 OpenAPI 声明接口;

DDD 不是银弹,需要结合实际项目需要按需结合,生搬硬套容易事倍功半。