文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
在本文中你可以看到一套较为完整的仓储层 => 领域层 => 表现层的 Golang 代码实现,但是肯定不会覆盖全部 DDD 概念,各位可以将它看作一种 Golang 中 DDD 的最佳实践来参考。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
01 背景
因为业务需求,我们当时正在用 Golang 从 0 到 1 构建一个Web 系统,由于是个新系统,所以很多东西要自己摸索,当时摆在面前的实际问题是如何更好的组织数据访问层和核心领域模型的代码。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
相比 Java 构建的系统,代码组织方式、分层等最佳实践已经固化在 Sprint Boot、Sofa Boot 等框架中,Golang 缺乏这种标准化的最佳实践。这个时候我们想到领域驱动设计(DDD),看起来可以解决我们的问题。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
02整体设计
指导思想(DDD):重点借鉴了 DDD 中的表现层(User Interface)、领域层(Domain)和基础设施层(Infrastructure)解决我们的问题。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
03代码实现
领域层
领域层(Domain)是系统的核心,负责表达业务概念,业务状态信息以及业务规则,即包含了该领域(问题域)所有复杂的业务知识抽象和规则定义。该层主要精力要放在领域对象分析上,可以从实体,值对象,聚合(聚合根),领域服务,领域事件,仓储,工厂等方面入手;文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
本文中放置核心模型定义(实体)和仓储抽象。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
什么是实体(Entity)?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
一个实体是一个唯一的东西,并且可以在相当长的一段时间内持续地变化。唯一的身份标识和可变性(mutability)特征将实体对象和值对象(Value Object)区分开来。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
——《实现领域驱动设计》文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
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)接口定义文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
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方面内容,一是为领域模型提供持久化机制,当软件需要持久化能力时候才需要进行规划;一是对其他层提供通用的技术支持能力,如消息通信,通用工具,配置等的实现。本篇中主要负责放置仓储实现。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
仓储(Repository)接口实现文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
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
// ......
}
数据对象文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
由于 Entity 领域层的定义,是流通在领域层的结构,所以不适合直接用于和数据库直接打交道,所以还需要一个和数据库表完全映射的数据对象(DO),参考社区的做法,找到一种最佳实践,即:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
https://hindenbug.io/the-power-of-generics-in-go-the-repository-pattern-for-gorm-7f8891df0934文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
具体思路是定义一个数据库对象,比如数据源(Source)对应的数据库对象是 SourceModel,它具有 FromEntity 和 ToEntity 方法,用于进行领域实体和数据库对象之间的相互转换。同时它也实现了 GORM 的 TableName 方法,用于指定对应的数据库表名。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
SourceModel 实现:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
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
}
事务文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
// 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):用户界面层,或者表现层,负责向用户显示解释用户命令。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
RESTFul API 实现文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
比较薄,纯逻辑编排,包括检验、CURD 等。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
// @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文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
作用:自动生成 Swagger 接口文档 + 多语言 SDK;文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
效果:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
Swagger 接口文档:
LiveDemo:https://petstore.swagger.io/?_ga=2.268933875.36106021.1681113818-3234578.1681113817文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
多语言 SDK:https://openapi-generator.tech/文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
04 一些技巧
分享本 PR 中使用到的一些 Golang 奇巧:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
检查结构体是否实现了某接口的防御代码文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
如下图示例,结构体 sourceRepository 如果没有实现 repository.SourceRepository 接口,IDE 会直接飘红,或者在编译阶段报错。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
写法一:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
// 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{}
写法二:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
var _ repository.SourceRepository = (*sourceRepository)(nil)
适应于 Go Web 应用的错误处理文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
Golang 标准库中的 error 类型只包含错误信息,我们自定义了适应于 Go Web 应用的错误包(elliotxx/errors),可以包含更多错误信息,比如错误堆栈、错误码、错误原因等;文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
示例1:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
if err := c.ShouldBindJSON(&requestPayload); err != nil {
return errcode.ErrDeserializedParams.Causewf(err, "failed to decode json")
}
示例2:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
// 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)
}
对象拷贝文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
大部分字段相似的对象可以使用社区的 copier 轮子直接深度拷贝,一行搞定,不用写多行赋值语句了。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
// Overwrite non-zero values in request entity to existed entity
copier.CopyWithOption(updatedEntity, requestEntity, copier.Option{IgnoreEmpty: true})
05 心得分享
当 Coding 时遇到迷茫,第一选择应该是到社区寻找最佳实践,比如到 Github、SourceGraph 等平台搜索代码实现,不要闭门造车;文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
代码实现的时候可以尽量考虑开放性,比如引入 OpenAPI 声明接口;文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html
DDD 不是银弹,需要结合实际项目需要按需结合,生搬硬套容易事倍功半。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/arc/38101.html