Docs Vault

上一节课我们实现了 Store 层依赖的数据结构。本节课,我们就可以来实现 Store 层的代码。


Store 层代码用来跟数据库或者其他第三方微服务进行交互,实现 Store 层的思路如下:

  1. 定义一个 Store 层接口,用来定义 Store 层需要实现的方法。在 Go 开发中接口通常会被命名为 XXXer、XXXInterface、IXXX,其中 XXX 是接口的功能描述符。这里,将 Store 层的接口命名为 IStore。Biz 层的接口同样使用了该命名方式,命名为 IBiz,以保持整个项目接口命名方式的统一;
  2. 创建一个结构体,该结构体用来实现 Store 层的核心方法。通常会在该结构体中包含一个*gorm.DB 对象,用于与数据库交互进行 CRUD 操作;
  3. 实现一个 NewStore 函数,用于创建 Store 层的实例;
  4. 为了方便直接通过 store 包引用 Store 层实例,我们可以设置一个包级别的 Store 实例。访问 Store 层的全局实例在项目开发中很少,也不建议使用。但在某些场景下还是挺好用的。这里,保留灵活的扩展能力;
  5. 为了避免实例被重复创建,通常需要使用 sync.Once 来确保实例只被初始化一次。


IStore 接口定义及实现


Store 层的代码实现位于 feature/s17 分支的 internal/apiserver/store/ 目录下。核心实现位于 internal/apiserver/store/store.go 文件中,代码如代码清单 9-3 所示。

代码清单 9-3 Store 层代码实现

import (
    "context"
    "sync"

    "gorm.io/gorm"

    "github.com/onexstack/onexstack/pkg/store/where"
)

var (
    once sync.Once
    // 全局变量,方便其它包直接调用已初始化好的 datastore 实例.
    S *datastore
)

// IStore 定义了 Store 层需要实现的方法.
type IStore interface {
    // 返回 Store 层的 *gorm.DB 实例,在少数场景下会被用到.
    DB(ctx context.Context, wheres ...where.Where) *gorm.DB
    TX(ctx context.Context, fn func(ctx context.Context) error) error

    User() UserStore
    Post() PostStore
}

// transactionKey 用于在 context.Context 中存储事务上下文的键.
type transactionKey struct{}

// datastore 是 IStore 的具体实现.
type datastore struct {
    core *gorm.DB

    // 可以根据需要添加其他数据库实例
    // fake *gorm.DB
}

// 确保 datastore 实现了 IStore 接口.
var _ IStore = (*datastore)(nil)

// NewStore 创建一个 IStore 类型的实例.
func NewStore(db *gorm.DB) *datastore {
    // 确保 S 只被初始化一次
    once.Do(func() {
        S = &datastore{db}
    })

    return S
}

// DB 根据传入的条件(wheres)对数据库实例进行筛选.
// 如果未传入任何条件,则返回上下文中的数据库实例(事务实例或核心数据库实例).
func (store *datastore) DB(ctx context.Context, wheres ...where.Where) *gorm.DB {
    db := store.core
    // 从上下文中提取事务实例
    if tx, ok := ctx.Value(transactionKey{}).(*gorm.DB); ok {
        db = tx
    }

    // 遍历所有传入的条件并逐一叠加到数据库查询对象上
    for _, whr := range wheres {
        db = whr.Where(db)
    }
    return db
}

// TX 返回一个新的事务实例.
// nolint: fatcontext
func (store *datastore) TX(ctx context.Context, fn func(ctx context.Context) error) error {
    return store.core.WithContext(ctx).Transaction(
        func(tx *gorm.DB) error {
            ctx = context.WithValue(ctx, transactionKey{}, tx)
            return fn(ctx)
        },
    )
}

// Users 返回一个实现了 UserStore 接口的实例.
func (store *datastore) User() UserStore {
    return newUserStore(store)
}

// Posts 返回一个实现了 PostStore 接口的实例.
func (store *datastore) Post() PostStore {
    return newPostStore(store)
}
在代码清单9-3中,定义了一个IStore接口,接口定义如下:
// IStore 定义了 Store 层需要实现的方法.
type IStore interface {
    // 返回 Store 层的 *gorm.DB 实例,在少数场景下会被用到.
    DB(ctx context.Context, wheres ...where.Where) *gorm.DB
    TX(ctx context.Context, fn func(ctx context.Context) error) error

    User() UserStore
    Post() PostStore
}


DB 方法会尝试从 context 中获取事务实例,如果没有则直接返回 *gorm.DB 类型的实例。TX 方法则将*gorm.DB 类型的实例注入到 context 中。通过 DB 和 TX 在 Biz 层实现事务,在 Store 层执行事务。


IStore 接口中的 User() 方法和 Post() 方法,分别返回 User 资源的 Store 层方法和 Post 资源的 Store 层方法。这种设计方式其实是软件开发中的抽象工厂模式。通过使用不同的方法,创建不同的对象,对象之间相互独立,以此提高代码的可维护性。另外使用这种方法,也能有效提高资源接口的标准化。标准化的资源接口更易理解和使用。


代码清单 9-3 中,使用以下代码创建 *datastore 类型的实例:

// NewStore 创建一个 IStore 类型的实例.
func NewStore(db *gorm.DB) *datastore {
    // 确保 S 只被初始化一次
    once.Do(func() {
        S = &datastore{db}
    })

    return S
}

上述代码使用了 sync.Once 来确保实例只被初始化一次。实例创建完成后,将其赋值给包级变量 S,以便通过 store.S.User().Create() 来调用 Store 层接口。在 Go 的最佳实践中,建议尽量减少使用包级变量,因为包级变量的状态通常难以感知,会增加维护的复杂度。然而,这并非绝对规则,可以根据实际需要选择是否设置包级变量来简化开发。


UserStore 接口定义及实现


为了提高代码的可维护性,将 User 资源的 Store 代码单独存放在 internal/apiserver/store/user.go 文件中。UserStore 接口定义如下:

// UserStore 定义了 user 模块在 store 层所实现的方法.
type UserStore interface {
    Create(ctx context.Context, obj *model.UserM) error
    Update(ctx context.Context, obj *model.UserM) error
    Delete(ctx context.Context, opts *where.Options) error
    Get(ctx context.Context, opts *where.Options) (*model.UserM, error)
    List(ctx context.Context, opts *where.Options) (int64, []*model.UserM, error)

    UserExpansion
}

// UserExpansion 定义了用户操作的附加方法.
type UserExpansion interface{}


UserStore 接口中的方法分为两大类:资源标准 CURD 方法和扩展方法。通过将方法分类,可以进一步提高接口中方法的可维护性和代码的可读性。将方法分为标准方法和扩展方法的开发技巧,在 Kuberentes client-go 项目中被大量使用,miniblog 的设计思路正是来源于 client-go 的设计。


在 Go 项目开发中,我习惯将资源标准方法中的方法按固定的接口顺序排序定义:Create、Update、Delete、Get、List。在 miniblog 项目中,Store 层其他资源的方法定义、Biz 层的 UserBiz 接口、PostBiz 接口的方法定义以及 Handler 层的方法定义、Protobuf 文件中的服务接口定义、请求/返回参数定义等,均采用了一致的方法顺序。通过将方法顺序标准化,可以在一定程度上提高代码的可阅读性和可维护性。


创建用户:Create 方法实现


userStore 结构体实现了 UserStore 接口。userStore 结构体的 Create 方法实现如下:

// userStore 是 UserStore 接口的实现.
type userStore struct {
    store *datastore
}

// 确保 userStore 实现了 UserStore 接口.
var _ UserStore = (*userStore)(nil)

// newUserStore 创建 userStore 的实例.
func newUserStore(store *datastore) *userStore {
    return &userStore{store}
}

// Create 插入一条用户记录.
func (s *userStore) Create(ctx context.Context, obj *model.UserM) error {
    if err := s.store.DB(ctx).Create(&obj).Error; err != nil {
        log.Errorw("Failed to insert user into database", "err", err, "user", obj)
        return errno.ErrDBWrite.WithMessage(err.Error())
    }

    return nil
}

在 Create 方法中,会调用 s.store.DB(ctx) 方法尝试从 context 中获取事务实例,如果没有则直接返回*gorm.DB 类型的实例,并调用 *gorm.DB 提供的 Create 方法,完成数据库表记录的插入操作。在 Create 方法中,如果插入失败,根据本课程第 14 讲的 miniblog 错误返回规范,直接返回了 errorsx.ErrorX 类型的错误 errno.ErrDBWrite,并设置了自定义错误消息:err.Error()。在 Go 项目开发中,直接返回 gorm 包的错误,可能会暴露一些敏感信息,但绝大部分项目中,可以直接返回 gorm 包的报错信息。将 gorm 包的报错信息,直接在接口中返回,可以大幅提高接口失败时的排障效率。


删除用户:Delete 方法实现


userStore 结构体的 Delete 接口代码实现如下:

// Delete 根据条件删除用户记录.
func (s *userStore) Delete(ctx context.Context, opts *where.Options) error {
    err := s.store.DB(ctx, opts).Delete(new(model.UserM)).Error
    if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
        log.Errorw("Failed to delete user from database", "err", err, "conditions", opts)
        return errno.ErrDBWrite.WithMessage(err.Error())
    }

    return nil
}

在 Delete 方法中,会判断删除记录时的错误类型是否是 gorm.ErrRecordNotFound,如果不是会返回自定义错误,如果是则返回删除成功,以此实现幂等删除。在 Go 项目开发中,删除行为在绝大部分项目中是一个幂等的操作。


查询用户列表:List 方法实现


userStore 结构体的 List 方法实现如下:

// List 返回用户列表和总数.
// nolint: nonamedreturns
func (s *userStore) List(ctx context.Context, opts *where.Options) (count int64, ret []*model.UserM, err error) {
    err = s.store.DB(ctx, opts).Order("id desc").Find(&ret).Offset(-1).Limit(-1).Count(&count).Error
    if err != nil {
        log.Errorw("Failed to list users from database", "err", err, "conditions", opts)
        err = errno.ErrDBRead.WithMessage(err.Error())
    }
    return
}

在 List 方法中,会调用 s.store.DB(ctx, opts) 方法,根据 opts 入参来配置 *gorm.DB 实例的查询条件。查询的记录会按数据库 id 字段降序排列(从大到小的顺序)。将最新的记录放在返回列表的最前面可以提高返回数据的阅读体验,也是大部分平台的默认排序规则。


整个 UserStore 接口中的方法实现较为简洁,仅直接对数据库记录进行增删改查操作,并未封装任何业务逻辑。Post 资源的 Store 层代码实现与 User 资源保持一致,故本课程不再解读 Post 资源的 Store 层实现。


在 Go 项目开发中,不少开发者会在 Store 层封装业务代码,为了实现不同的查询条件,会在 Store 层封装很多查询类方法,例如:ListUser、ListUserByName、ListUserByID 等。这都会使 Store 层代码变得臃肿,难以维护。其实 Store 层只需要对数据库记录进行简单的增删改查即可。对插入数据或查询数据的处理可以放在业务逻辑层。对查询条件的定制,可以通过提供灵活的查询参数来实现。


where 查询条件实现


为了实现在查询数据库记录时,配置灵活的查询参数,miniblog 项目开发了 where 包。where 包的代码实现放在 onexstack 项目中,包名为 github.com/onexstack/onexstack/pkg/store/where。整个 OneX 技术体系,都通过 where 包来定制化查询条件。where 包提供了以下自定义查询条件:

// Options 保存了GORM的Where查询条件的选项。  
type Options struct {  
    // Offset 定义了分页的起始位置。  
    // +optional  
    Offset int `json:"offset"`  
    // Limit 定义了返回结果的最大数量。  
    // +optional  
    Limit int `json:"limit"`  
    // Filters 包含用于过滤记录的键值对。  
    Filters map[any]any  
    // Clauses 包含要附加到查询中的自定义子句。  
    Clauses []clause.Expression  
}
Options结构体中的字段最后会通过以下方式,来为*gorm.DB类型的实例添加查询条件:
// Where applies the filters and clauses to the given gorm.DB instance.
func (whr *Options) Where(db *gorm.DB) *gorm.DB {
    return db.Where(whr.Filters).Clauses(whr.Clauses...).Offset(whr.Offset).Limit(whr.Limit)
}

where 包通过配置 where.Options 结构体,来配置 GORM 的查询条件。Offset 字段用来配置查询记录时分页的起始位置,Limit 字段用来配置返回结果的最大数量,Filters 字段用来配置查询时过滤记录的键值对,Clauses 字段直接用来指定 *gorm.DB 的查询子句。where 包提供了以下三种方式,用来配置Options:

  1. 通过 NewWhere 函数构造;
  2. 通过便捷函数直接创建;
  3. 通过链式调用。


通过 NewWhere 函数构造


where 包提供了 NewWhere 函数用来创建一个 *Options 类型的实例,在调用 NewWhere 函数时,通过传入不同的函数选项,来配置 *Options 结构体,具体实现如代码清单 9-4 所示。

代码清单 9-4 函数选项模式

// Option 定义了一个函数类型,用于修改 Options。  
type Option func(*Options)  

// WithOffset 使用给定的 offset 值初始化 Options 的 Offset 字段。  
func WithOffset(offset int64) Option {  
    return func(whr *Options) {  
        if offset < 0 {  
            offset = 0  
        }  
        whr.Offset = int(offset)  
    }  
}  

// WithLimit 使用给定的 limit 值初始化 Options 的 Limit 字段。  
func WithLimit(limit int64) Option {  
    return func(whr *Options) {  
        if limit <= 0 {  
            limit = defaultLimit  
        }  
        whr.Limit = int(limit)  
    }  
}  

// WithPage 是一个糖函数,用于将 page 和 pageSize 转换为 Options 中的 limit 和 offset。  
// 此函数通常用于业务逻辑中以简化分页操作。  
func WithPage(page int, pageSize int) Option {  
    return func(whr *Options) {  
        if page == 0 {  
            page = 1  
        }  
        if pageSize == 0 {  
            pageSize = defaultLimit  
        }  

        whr.Offset = (page - 1) * pageSize  
        whr.Limit = pageSize  
    }  
}  

// WithFilter 使用给定的过滤条件初始化 Options 的 Filters 字段。  
func WithFilter(filter map[any]any) Option {  
    return func(whr *Options) {  
        whr.Filters = filter  
    }  
}  

// WithClauses 将指定的条件子句追加到 Options 的 Clauses 字段中。  
func WithClauses(conds ...clause.Expression) Option {  
    return func(whr *Options) {  
        whr.Clauses = append(whr.Clauses, conds...)  
    }  
}  

// NewWhere 构建一个新的 Options 对象,并应用所给定的 Option 修改。  
func NewWhere(opts ...Option) *Options {  
    whr := &Options{  
        Offset:  0,  
        Limit:   defaultLimit,  
        Filters: map[any]any{},  
        Clauses: make([]clause.Expression, 0),  
    }  

    for _, opt := range opts {  
        opt(whr) // 将每个 Option 应用于 Options。  
    }  

    return whr  
}

在代码清单 9-4 中,使用了函数选项模式来配置 *Options 结构体。函数选项模式是软件开发中高频使用的设计模式,该设计模式允许开发者根据需要为函数传递不同的选项参数,以定制函数的行为。


通过便捷函数直接创建


where 包还提供了一些便捷函数,用来快速创建一个指定了某类查询条件的 *Options 结构体实例,例如 O、L、P、C 等函数:

// O 用于创建带有 offset 的新 Options 的便捷函数。  
func O(offset int) *Options {  
    return NewWhere().O(offset)  
}  

// L 用于创建带有 limit 的新 Options 的便捷函数。  
func L(limit int) *Options {  
    return NewWhere().L(limit)  
}  

// P 用于创建带有页码和每页大小的分页 Options 的便捷函数。  
func P(page int, pageSize int) *Options {  
    return NewWhere().P(page, pageSize)  
}  

O、L、P、C 等便捷函数命名均以 Option 结构体中字段名的大写首字母命名。这种命名方式牺牲一点函数名可读性,但能有效减少代码折行的概率,有利于提高代码的可读性和简洁度。


通过链式调用


先调用 NewWhere 初始化空的 *Options 对象,然后通过链式调用设置分页、过滤条件或子句等内容,例如:

opts := NewWhere().  
    O(10). // 设置 Offset 为 10  
    L(20). // 设置 Limit 为 20  
    F("name", "John", "status", "active"). // 添加过滤条件  
    C(clause.OrderBy{Columns: []clause.OrderByColumn{  
        {Column: clause.Column{Name: "created_at"}, Desc: true},  
    }}).  
    P(2, 10) // 设置分页:第2页,每页10条数据

链式调用(chaining)是一种通过方法返回自身实例的特性,实现连续调用的编程风格。链式调用具有简洁、灵活、语义化强等特点,特别适合对象的初始化、配置构建以及动态逻辑调整。它广泛应用于查询条件的组合、领域特定语言的设计等场景。通过链式调用,可以构建更加流畅的 API,提升代码可读性和开发体验,是很多现代框架与工具等普遍采用的设计模式。


至此,Store 层代码开发完成,完整代码见 feature/s17 分支。


小结(AI 自动生成并人工审核)


本文详细讲解了 miniblog 项目中 Store 层的实现方法及其设计思想。Store 层的主要职责是与数据库或其他第三方服务进行交互,执行数据的增删改查操作,同时为 Biz 层提供接口支持。通过定义统一的 IStore 接口,Store 层规范化了其方法和资源管理方式,例如通过抽象工厂模式提供 User 和 Post 资源的具体方法实例,从而提升可维护性。


Store 层利用 GoLang 的 sync.Once 确保实例的单例模式,避免重复创建。此外,篇幅还深入解析了如何实现 UserStore 接口的核心功能,如用户的创建、删除和列表查询,并使用了 where 包来灵活配置查询条件,为复杂筛选需求提供了优雅的解决方案。


文中强调了将数据库逻辑分离并保持 Store 层的整洁性,同时通过链式调用、函数选项模式等技巧提高了代码可读性和开发效率。这种设计不仅增强了代码的稳定性与扩展性,还为后续业务逻辑层的开发奠定了坚实基础。