存储库模式: 简化 Go 业务逻辑

我见过很多复杂的代码,通常是因为业务逻辑与数据库逻辑耦合在一起。将业务逻辑与数据库逻辑放在一起会使应用程序更加复杂、难以测试和维护。

已经有一个经过验证的简单模式可以解决这些问题,这个模式能让你将业务逻辑与数据库逻辑进行分离。它能使你的代码更加简单并且易于添加新的功能。另外,您还可以推迟选择数据库解决方案和模式等重要决策。这种方法还会带来一个额外的好处:我们从一开始就不会和特定的数据库绑定。

它就是存储库模式(Repository pattern)。

当我回想曾经做过的应用程序,我记得很难记得它们是如何工作的。我总是害怕修改那些代码——你永远不知道改动它后会有什么意想不到的、糟糕的副作用。 当应用业务逻辑与数据库实现混合在一起时,很难理解它,并且会产生很多重复的代码。

一些补救措施可能是建立端到端(end-to-end)测试[1]。但这只能掩盖问题,而不能真正解决问题。拥有大量的端到端测试不仅速度慢、不稳定,而且难以维护。有时,它们甚至会阻碍我们开发新功能。

在本文中,我将教你如何在 Go 中以实用、优雅、直接的方式应用这种模式。我还将深入介绍一个经常被忽略的话题–干净的事务处理

为了证明这一点,我准备了3种不同的数据库实现:Firestore、MySQL 和内存存储。

废话少说,我们来直接看实际的例子:

存储库接口

使用存储库模式的想法是:

通过接口来定义与数据库的交互方式,从而抽象我们的数据库实现。不管使用什么数据库实现,您都应该能够使用该接口,这意味着该接口应不受任何数据库实现细节的影响。

让我们从重构trainer[2]服务开始。目前,该服务允许我们通过 HTTP API[3]gRPC[4] 获取有关小时可用性的信息。我们还可以通过 HTTP API[5]gRPC[6] 更改小时的可用性。

译注: 该项目是一个培训系统,记录每天每个小时的培训时间安排。教练可以对某个空闲的小时安排培训,学员也可以选择某个时间的培训进行参加。

上一篇文章中,我们使用 DDD Lite 方法重构了 Hour。有了它,我们就不需要考虑保留何时更新 Hour 的规则了。我们的domain层确保我们不会做任何 “愚蠢 “的事情。我们也不需要考虑任何验证,我们只需使用对应的类型并执行必要的操作即可。

我们需要能够从数据库中获取并保存Hour的当前状态。此外,如果有两个人希望同时安排培训,则在一个小时内,只能有一个人安排培训。

让我们在接口中反映我们的需求:

// 完整源码见: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/domain/hour/repository.go#L8
package hour

type Repository interface {
    GetOrCreateHour(ctx context.Context, hourTime time.Time) (*Hour, error)
    UpdateHour(
        ctx context.Context,
        hourTime time.Time,
        updateFn func(h *Hour) (*Hour, error),
    ) error
}

我们将使用 GetOrCreateHour 获取数据,并使用 UpdateHour 保存数据。

我们将接口定义在Hour类型相同的包里,这样如果在许多模块中使用该接口,可以避免重复(根据我的经验,这种情况可能经常发生)。这也是一种类似于 io.Writer 的模式,io 包定义了接口,而所有的实现都分散在不同的包中。

那么如何实现该接口呢?

读取数据

大多数数据库驱动程序都可以使用 ctx context.Context进行cancel、tracing等操作。这不是哪个数据库所特有的(它是 Go 的一个通用概念),因此您不必担心会破坏该domain😉

在大多数情况下,我们使用 UUID 或 ID 而不是 time.Time 来查询数据。但在我们的例子中,这样做是没有问题的,因为小时在设计上是唯一的。我构想一个场景:我们希望支持多个培训师。在这种情况下,这种假设就不成立了。改用 UUID/ID很简单。但现在,YAGNI[7](大概意思就是你不需要它)!

为了清晰起见,基于 UUID 的接口可能是这样的:

func GetOrCreateHour(ctx context.Context, hourUUID string) (*Hour, error)

未来会有篇文章使用UUID Repo 结合DDD,CQRS和整洁架构的文章

应用程序中如何使用该接口?

// 完整代码见: github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainer/grpc.go
import (
    // ...
   "github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainer/domain/hour"
    // ...
)

type GrpcServer struct {
   hourRepository hour.Repository
}

// ...

func (g GrpcServer) IsHourAvailable(ctx context.Context, request *trainer.IsHourAvailableRequest) (*trainer.IsHourAvailableResponse, error) {
    trainingTime, err := protoTimestampToTime(request.Time)
    if err != nil {
        return nil, status.Error(codes.InvalidArgument, "unable to parse time")
    }

    h, err := g.hourRepository.GetOrCreateHour(ctx, trainingTime)
    if err != nil {
        return nil, status.Error(codes.Internal, err.Error())
    }

    return &trainer.IsHourAvailableResponse{IsAvailable: h.IsAvailable()}, nil
}

没什么黑科技,我们获取 hour.Hour,然后检查是否可用。你能猜到我们用的是什么数据库吗?猜不到,这就是关键所在!

正如我所提到的,我们可以避免与特定的数据库绑定,并能轻松切换数据库。如果能切换数据库,就说明你正确实施了存储库模式。在实践中,更换数据库的情况很少发生😉。如果您使用的不是自托管解决方案(如 Firestore),降低风险并与特定的数据库解耦就显得更为重要。

这样还有个”副作用”,我们可以推迟决定使用哪种数据库实现方式。我将这种方法称为 “领域优先”(Domain First)。我在上一篇文章中对此进行了深入介绍。在项目开始时,推迟决定数据库可以节省一些时间。有了更多的信息和背景,我们也可以做出更好的决定。

当我们使用 “领域优先”方法时,第一个也是最简单的存储库实现可能是内存实现。

内存实现示例

我们的内存实现示例使用一个简单的map进行实现,getOrCreateHour 只有 5 行(不含注释和换行符)! 😉

// 完整源码见: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_memory_repository.go#L11
type MemoryHourRepository struct {
   hours map[time.Time]hour.Hour
   lock  *sync.RWMutex

   hourFactory hour.Factory
}

func NewMemoryHourRepository(hourFactory hour.Factory) *MemoryHourRepository {
   if hourFactory.IsZero() {
      panic("missing hourFactory")
   }

   return &MemoryHourRepository{
      hours:       map[time.Time]hour.Hour{},
      lock:        &sync.RWMutex{},
      hourFactory: hourFactory,
   }
}

func (m MemoryHourRepository) GetOrCreateHour(_ context.Context, hourTime time.Time) (*hour.Hour, error) {
   m.lock.RLock()
   defer m.lock.RUnlock()

   return m.getOrCreateHour(hourTime)
}

func (m MemoryHourRepository) getOrCreateHour(hourTime time.Time) (*hour.Hour, error) {
   currentHour, ok := m.hours[hourTime]
   if !ok {
      return m.hourFactory.NewNotAvailableHour(hourTime)
   }

   // 我们不把小时数作为指针存储,而是作为值存储 
   // 正因为如此,我们可以确保所有人都需要使用UpdateHour才能修改小时
   return &currentHour, nil
}

不幸的是,内存实现有一些缺点。最大的问题是它在重新启动后不保存服务的数据😕。对于功能性的 pre-alpha 版本来说,这已经足够了。为了使我们的应用程序适应生产环境,我们需要一些更持久的东西。

MySQL实现示例

我们已经知道了我们model的样子[8]行为[9]。在此基础上,让我们定义 SQL:

# 完整代码见 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/sql/schema.sql#L1
CREATE TABLE `hours`
(
    hour         TIMESTAMP                                                 NOT NULL,
    availability ENUM ('available''not_available''training_scheduled'NOT NULL,
    PRIMARY KEY (hour)
);

当我使用 SQL 数据库时,我的默认选择是:

  • sqlx[10] – 对于较简单的数据模型,它提供了一些有用的函数,可以使用结构体来unmarshal查询结果。当查询因为关系和多个模型而变得更加复杂时,就需要…
  • SQLBoiler[11] – 对于有许多字段和关系的更复杂模型来说是非常好的选择,因为它是基于代码生成的。因此它的运行速度非常快,你也不必担心你传递了无效的interface{}。 生成的代码基于SQL,因此可以避免许多重复。

我们目前只有一个表, 因此sqlx对我们来说就够了,我们使用”传输类型(transport type)”来表示数据库模型。

// 完整代码见: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_mysql_repository.go#L17
type mysqlHour struct {
   ID           string    `db:"id"`
   Hour         time.Time `db:"hour"`
   Availability string    `db:"availability"`
}

你可能会问,为什么不在 hour.Hour 中添加 db 属性呢?根据我的经验,最好将域类型与数据库完全分开。这样更易于测试,不会重复的做校验,也不会引入大量的模板。如果模型有任何变化,我们只需在我们的存储库实现中进行修改,而不用在项目的另一半中修改。Miłosz 在“DRY”一文中描述了类似的情况。我在之前关于 DDD Lite 的文章中也对该规则进行了更深入的描述。

我们应该如何使用这个结构体?

// 完整源码见: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_mysql_repository.go#L40
// sqlContextGetter 是由事务和标准数据库连接提供的接口
type sqlContextGetter interface {
   GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error
}

func (m MySQLHourRepository) GetOrCreateHour(ctx context.Context, time time.Time) (*hour.Hour, error) {
   return m.getOrCreateHour(ctx, m.db, time, false)
}

func (m MySQLHourRepository) getOrCreateHour(
   ctx context.Context,
   db sqlContextGetter,
   hourTime time.Time,
   forUpdate bool,
)
 (*hour.Hour, error)
 {
   dbHour := mysqlHour{}

   query := "SELECT * FROM `hours` WHERE `hour` = ?"
   if forUpdate {
      query += " FOR UPDATE"
   }

   err := db.GetContext(ctx, &dbHour, query, hourTime.UTC())
   if errors.Is(err, sql.ErrNoRows) {
      // 实际上这个时间是存在的,只不过它没有被持久化
      return m.hourFactory.NewNotAvailableHour(hourTime)
   } else if err != nil {
      return nil, errors.Wrap(err, "unable to get hour from db")
   }

   availability, err := hour.NewAvailabilityFromString(dbHour.Availability)
   if err != nil {
      return nil, err
   }

   domainHour, err := m.hourFactory.UnmarshalHourFromDatabase(dbHour.Hour.Local(), availability)
   if err != nil {
      return nil, err
   }

   return domainHour, nil
}

使用 SQL 实现很简单,因为我们不需要保持向后兼容性。在之前的文章中,我们使用 Firestore 作为主数据库,让我们在此基础上准备实现,保持向下兼容。

Firestore实现

当您希望重构遗留应用程序时,抽象数据库可能是一个很好的起点。

有时,应用程序是以数据库为中心构建的。在我们的例子中,是以 HTTP 响应为中心的方式:我们的数据库模型是基于 Swagger 生成的模型。这会阻止我们抽象数据库吗?当然不会!只需要一些额外的代码来处理unmarshal即可。

如果采用领域优先的方法,我们的数据库模型会更好,就像 SQL 的实现一样。 但我们现在的情况就是这样。让我们一步一步地砍掉这些旧的遗留物吧。我还感觉到,CQRS 将在这方面帮助我们。😉

实际上,只要不直接通过数据库集成其他服务,数据迁移可能很简单。

遗憾的是,当我们使用传统的以响应/数据库为中心的服务或 CRUD 服务时,这是一个乐观的假设……

// 完整代码见 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_firestore_repository.go#L31
func (f FirestoreHourRepository) GetOrCreateHour(ctx context.Context, time time.Time) (*hour.Hour, error) {
   date, err := f.getDateDTO(
      // getDateDTO 应该同时用于事务性查询和非事务性查询,
      // 最好的方法是使用闭包
      func() (doc *firestore.DocumentSnapshot, err error) {
         return f.documentRef(time).Get(ctx)
      },
      time,
   )
   if err != nil {
      return nil, err
   }

   hourFromDb, err := f.domainHourFromDateDTO(date, time)
   if err != nil {
      return nil, err
   }

   return hourFromDb, err
}
// 完整代码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_firestore_repository.go#L120
// 目前我们保留了向后兼容性,因此有点混乱和复杂。
// TODO --我们稍后将使用 CQRS 对其进行整理 :-)
func (f FirestoreHourRepository) domainHourFromDateDTO(date Date, hourTime time.Time) (*hour.Hour, error) {
   firebaseHour, found := findHourInDateDTO(date, hourTime)
   if !found {
      // in reality this date exists, even if it's not persisted
      return f.hourFactory.NewNotAvailableHour(hourTime)
   }

   availability, err := mapAvailabilityFromDTO(firebaseHour)
   if err != nil {
      return nil, err
   }

   return f.hourFactory.UnmarshalHourFromDatabase(firebaseHour.Hour.Local(), availability)
}

不幸的是,Firebase 用于事务查询和非事务查询的接口并不完全兼容。为了避免重复,我创建了 getDateDTO,它可以通过传递 getDocumentFn 来处理这种差异。

// 完整代码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_firestore_repository.go#L97
func (f FirestoreHourRepository) getDateDTO(
   getDocumentFn func()
 (doc *firestore.DocumentSnapshot, err error),

   dateTime time.Time,
) (Date, error) {
   doc, err := getDocumentFn()
   if status.Code(err) == codes.NotFound {
      // in reality this date exists, even if it's not persisted
      return NewEmptyDateDTO(dateTime), nil
   }
   if err != nil {
      return Date{}, err
   }

   date := Date{}
   if err := doc.DataTo(&date); err != nil {
      return Date{}, errors.Wrap(err, "unable to unmarshal Date from Firestore")
   }

   return date, nil
}

即使需要一些额外的代码,它也不错,而且可以很容易地进行测试。

更新数据

正如我之前提到的,确保一个小时内只能有一个人安排培训是至关重要的。要处理这种情况,我们需要使用乐观锁和事务

乐观并发控制假设许多事务可以不断完成,而不会相互干扰。在运行过程中,事务使用数据资源但不对这些资源上锁。在提交之前,每个事务都要验证是否有其他事务修改了它所读取的数据。如果检查发现有冲突的修改,提交的事务就会回滚。
——wikipedia.org[12]

从技术上讲,事务处理并不复杂。我所面临的最大挑战是:如何以一种简洁的方式管理事务,这种方式不会对应用程序的其他部分造成太大影响,也不依赖于具体的实现,清晰又快速。

我尝试过很多想法,比如通过 context.Context 传递事务、在 HTTP/gRPC/消息中间件级别上传递事务等。我尝试过的所有方法都存在许多主要问题–它们有点取巧、不明确,而且在某些情况下速度很慢。

目前,我最喜欢的一种方法是基于传递给更新函数的闭包。

// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/domain/hour/repository.go#L8
type Repository interface {
   // ...
   UpdateHour(
      ctx context.Context,
      hourTime time.Time,
      updateFn func(h *Hour) (*Hour, error),
   ) error
}

基本原理是,当我们执行 UpdateHour 时,我们需要提供可以更新指定小时的updateFn

因此,在实际操作中,我们需要在一个事务中:

  • 根据提供的 UUID 或任何其他参数(在我们的例子中为 hourTime time.Time),获取并提供 updateFn 的所有参数(在我们的例子中为 h *Hour)。
  • 执行闭包(本例中为 updateFn
  • 保存返回值(本例中为*Hour,如果需要,我们可以返回更多返回值)
  • 在闭包返回错误时执行回滚

实际应该如何使用?

// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/0249977c58a310d343ca2237c201b9ba016b148e/internal/trainer/grpc.go#L20
func (g GrpcServer) MakeHourAvailable(ctx context.Context, request *trainer.UpdateHourRequest) (*trainer.EmptyResponse, error) {
   trainingTime, err := protoTimestampToTime(request.Time)
   if err != nil {
      return nil, status.Error(codes.InvalidArgument, "unable to parse time")
   }

   if err := g.hourRepository.UpdateHour(ctx, trainingTime, func(h *hour.Hour) (*hour.Hour, error) {
      if err := h.MakeAvailable(); err != nil {
         return nil, err
      }

      return h, nil
   }); err != nil {
      return nil, status.Error(codes.Internal, err.Error())
   }

   return &trainer.EmptyResponse{}, nil
}

如您所见,我们从某个(未知的!)数据库中获取了Hour实例。然后,我们让这个小时Available。如果一切正常,我们将返回该小时并保存它。作为上一篇文章的一部分,所有验证都转移到了domain级别,因此我们可以确保不会做任何 “愚蠢”的事情。这也大大简化了代码。

// 完整代码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/commit/0249977c58a310d343ca2237c201b9ba016b148e#diff-5e57cb39050b6e252711befcf6fb0a89L20
+func (g GrpcServer) MakeHourAvailable(ctx context.Context, request *trainer.UpdateHourRequest) (*trainer.EmptyResponse, error) {
@ ...
-func (g GrpcServer) UpdateHour(ctx context.Context, req *trainer.UpdateHourRequest) (*trainer.EmptyResponse, error) {
- trainingTime, err := grpcTimestampToTime(req.Time)
if err != nil {
-     return nil, status.Error(codes.InvalidArgument, "unable to parse time")
- }
-
- date, err := g.db.DateModel(ctx, trainingTime)
if err != nil {
-     return nil, status.Error(codes.Internal, fmt.Sprintf("unable to get data model: %s", err))
- }
-
- hour, found := date.FindHourInDate(trainingTime)
if !found {
-     return nil, status.Error(codes.NotFound, fmt.Sprintf("%s hour not found in schedule", trainingTime))
- }
-
if req.HasTrainingScheduled && !hour.Available {
-     return nil, status.Error(codes.FailedPrecondition, "hour is not available for training")
- }
-
if req.Available && req.HasTrainingScheduled {
-     return nil, status.Error(codes.FailedPrecondition, "cannot set hour as available when it have training scheduled")
- }
if !req.Available && !req.HasTrainingScheduled {
-     return nil, status.Error(codes.FailedPrecondition, "cannot set hour as unavailable when it have no training scheduled")
- }
- hour.Available = req.Available
-
if hour.HasTrainingScheduled && hour.HasTrainingScheduled == req.HasTrainingScheduled {
-     return nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("hour HasTrainingScheduled is already %t", hour.HasTrainingScheduled))
- }
-
- hour.HasTrainingScheduled = req.HasTrainingScheduled
if err := g.db.SaveModel(ctx, date); err != nil {
-     return nil, status.Error(codes.Internal, fmt.Sprintf("failed to save date: %s", err))
- }
-
return &trainer.EmptyResponse{}, nil
-}

在我们的例子中,updateFn 只返回 (*Hour, error) – 如果需要,您可以返回更多的值。您可以返回事件源、读取模型等。

理论上,我们也可以使用与 updateFn 相同的 hour.*Hour。我决定不这么做。使用返回值可以让我们更加灵活(如果需要,我们可以替换不同的 hour.*Hour 实例)。

此外,创建多个类似 UpdateHour 的函数并保存额外的数据也没什么不好。在底层,实现应该重用相同的代码而不需要大量的重复。

type Repository interface {
   // ...
   UpdateHour(
      ctx context.Context,
      hourTime time.Time,
      updateFn func(h *Hour) (*Hour, error),
   ) error

    UpdateHourWithMagic(
      ctx context.Context,
      hourTime time.Time,
      updateFn func(h *Hour) (*Hour, *Magic, error),
   ) error
}

内存事务的实现

内存的实现也是最简单的。我们需要获取当前值、执行闭包并保存结果。

最重要的是,我们在 map 中存储的是副本而不是指针。因此,我们可以肯定,如果事务没有 “提交”(m.hours[hourTime] = *updatedHour),我们的值不会被保存。我们将在测试中再次确认这一点。

// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_memory_repository.go#L48
func (m *MemoryHourRepository) UpdateHour(
   _ context.Context,
   hourTime time.Time,
   updateFn func(h *hour.Hour)
 (*hour.Hour, error),

) error {
   m.lock.Lock()
   defer m.lock.Unlock()

   currentHour, err := m.getOrCreateHour(hourTime)
   if err != nil {
      return err
   }

   updatedHour, err := updateFn(currentHour)
   if err != nil {
      return err
   }

   m.hours[hourTime] = *updatedHour

   return nil
}

Firestore事务实现

Firestore 的实现要复杂一些,但这同样与向后兼容性有关。当我们的数据模型变得更好时,getDateDTOdomainHourFromDateDTOupdateHourInDataDTO 函数可能会被省略。这是不使用 “以数据库为中心/以响应为中心” 方法的另一个原因!

// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_firestore_repository.go#L52
func (f FirestoreHourRepository) UpdateHour(
   ctx context.Context,
   hourTime time.Time,
   updateFn func(h *hour.Hour)
 (*hour.Hour, error),

) error {
   err := f.firestoreClient.RunTransaction(ctx, func(ctx context.Context, transaction *firestore.Transaction) error {
      dateDocRef := f.documentRef(hourTime)

      firebaseDate, err := f.getDateDTO(
         // getDateDTO should be used both for transactional and non transactional query,
         // the best way for that is to use closure
         func() (doc *firestore.DocumentSnapshot, err error) {
            return transaction.Get(dateDocRef)
         },
         hourTime,
      )
      if err != nil {
         return err
      }

      hourFromDB, err := f.domainHourFromDateDTO(firebaseDate, hourTime)
      if err != nil {
         return err
      }

      updatedHour, err := updateFn(hourFromDB)
      if err != nil {
         return errors.Wrap(err, "unable to update hour")
      }
      updateHourInDataDTO(updatedHour, &firebaseDate)

      return transaction.Set(dateDocRef, firebaseDate)
   })

   return errors.Wrap(err, "firestore transaction failed")
}

如您所见,我们获取 *hour.Hour,调用 updateFn,并在 RunTransaction 中保存结果。

尽管有一些额外的复杂度,这个实现仍然是清晰的,易于理解和测试。

MySQL 事务实现

让我们将其与 MySQL 实现进行比较,在 MySQL 实现中,我们以更好的方式设计了模型。即使实现方式相似,但在处理事务的方式上却有很大的不同。

在 SQL 驱动中,事务由 *db.Tx 表示。我们使用这个特定对象调用所有查询,并进行回滚和提交。为了确保我们不会忘记关闭事务,我们在defer中进行回滚和提交。

// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_mysql_repository.go#L82
func (m MySQLHourRepository) UpdateHour(
   ctx context.Context,
   hourTime time.Time,
   updateFn func(h *hour.Hour)
 (*hour.Hour, error),

) (err error) {
   tx, err := m.db.Beginx()
   if err != nil {
      return errors.Wrap(err, "unable to start transaction")
   }

   // defer在函数退出前执行。
   // 有了defer,我们就能确保始终正确关闭事务。
   defer func() {
      // 在 `UpdateHour` 中,我们使用命名返回值`(err error)`。
      // 多亏了它,我们可以检查函数退出时是否有错误
      //
      // 即使函数退出时没有错误,commit 也可能返回错误。
      // 在这种情况下,我们可以将 nil 覆盖为 err `err = m.finish...`。
      err = m.finishTransaction(err, tx)
   }()

   existingHour, err := m.getOrCreateHour(ctx, tx, hourTime, true)
   if err != nil {
      return err
   }

   updatedHour, err := updateFn(existingHour)
   if err != nil {
      return err
   }

   if err := m.upsertHour(tx, updatedHour); err != nil {
      return err
   }

   return nil
}

在这种情况下,我们还可以通过向 getOrCreateHour 传递 forUpdate == true 来获取小时数。这个标志为我们的查询添加了 FOR UPDATE 语句。FOR UPDATE 语句非常重要,因为如果没有它,我们将允许并行事务更改小时数。

SELECT … FOR UPDATE 对于搜索到的索引记录,会锁定记录和任何相关的索引项,就像为这些记录执行 UPDATE 语句一样,其他事务将被阻止更新这些记录。
dev.mysql.com[13]

// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_mysql_repository.go#L48
func (m MySQLHourRepository) getOrCreateHour(
   ctx context.Context,
   db sqlContextGetter,
   hourTime time.Time,
   forUpdate bool,
)
 (*hour.Hour, error)
 {
   dbHour := mysqlHour{}

   query := "SELECT * FROM `hours` WHERE `hour` = ?"
   if forUpdate {
      query += " FOR UPDATE"
   }

    // ...

如果不对这样的代码进行自动测试,我就睡不好觉。我们稍后再解决这个问题

finishTransaction 会在 UpdateHour 退出时执行。当提交或回滚失败时,我们也可以覆盖返回的错误信息。

// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_mysql_repository.go#L149
// finishTransaction 在出现错误时回滚事务。
// 如果 err 为空,事务将提交。
//
// 如果回滚失败,我们将使用 multierr 库添加回滚失败的错误信息。
// 如果提交失败,将返回提交错误。
func (m MySQLHourRepository) finishTransaction(err error, tx *sqlx.Tx) error {
   if err != nil {
      if rollbackErr := tx.Rollback(); rollbackErr != nil {
         return multierr.Combine(err, rollbackErr)
      }

      return err
   } else {
      if commitErr := tx.Commit(); commitErr != nil {
         return errors.Wrap(err, "failed to commit tx")
      }

      return nil
   }
}
// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/hour_mysql_repository.go#L122
// 如果数据库中已经存在 hour,则 upsertHour 会更新 hour。
// 如果不存在,则插入。
func (m MySQLHourRepository) upsertHour(tx *sqlx.Tx, hourToUpdate *hour.Hour) error {
   updatedDbHour := mysqlHour{
      Hour:         hourToUpdate.Time().UTC(),
      Availability: hourToUpdate.Availability().String(),
   }

   _, err := tx.NamedExec(
      `INSERT INTO 
         hours (hour, availability) 
      VALUES 
         (:hour, :availability)
      ON DUPLICATE KEY UPDATE 
         availability = :availability`
,
      updatedDbHour,
   )
   if err != nil {
      return errors.Wrap(err, "unable to upsert hour")
   }

   return nil
}

总结

即使存储库模式会增加一些代码,也完全值得进行这样的投资。在实践中,你可能需要多花 5 分钟来完成这项工作,而这项投资应该很快就能得到回报。

在本文中,我们忽略了一个重要部分–测试。现在添加测试应该容易多了,但如何正确地添加测试可能仍不明显。

为了不至于写成一篇 “怪物 “文章,我将在接下来的 1-2 周内介绍这部分内容。🙂 这次重构的全部差异(包括测试)都可以在 GitHub[14] 上找到。

提醒一下–你可以用一个命令[15]运行该程序,并在 GitHub[16] 上找到整个源代码!

Miłosz在Introducing Clean Architecture一文中介绍了另一种非常有效的技术–简洁/六边形架构(Clean/Hexagonal architecture)。

原文地址 https://threedots.tech/post/repository-pattern-in-go/
翻译: 赵不贪

参考资料

[1]

end-to-end test: https://martinfowler.com/articles/microservice-testing/#testing-end-to-end-introduction

[2]

trainer service: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/tree/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer

[3]

trainer 获取小时信息 HTTP API: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/http.go#L17

[4]

trainer 获取小时信息 gRPC: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/grpc.go#L75

[5]

trainer 修改小时信息HTTP API: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/http.go#L12

[6]

trainer 修改小时信息gRPC: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/grpc.go#L16

[7]

YAGNI: https://en.wikipedia.org/wiki/You_aren%27t_gonna_need_it

[8]

Hour的外观: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/domain/hour/hour.go#L11

[9]

Hour的行为: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb/internal/trainer/domain/hour/availability.go#L63

[10]

sqlx: https://github.com/jmoiron/sqlx

[11]

SQLBoiler: https://github.com/volatiletech/sqlboiler

[12]

乐观并发控制: https://en.wikipedia.org/wiki/Optimistic_concurrency_control

[13]

innodb-locking-reads: https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html

[14]

本次提交: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/commit/34c74e9d2cbc80160b4ff26e59818a89d10aa1eb

[15]

一个命令: https://threedots.tech/post/serverless-cloud-run-firebase-modern-go-application/#running

[16]

wild-workouts: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example


原文始发于微信公众号(梦真日记):存储库模式: 简化 Go 业务逻辑

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/167779.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!