我见过很多复杂的代码,通常是因为业务逻辑与数据库逻辑耦合在一起。将业务逻辑与数据库逻辑放在一起会使应用程序更加复杂、难以测试和维护。
已经有一个经过验证的简单模式可以解决这些问题,这个模式能让你将业务逻辑与数据库逻辑进行分离。它能使你的代码更加简单并且易于添加新的功能。另外,您还可以推迟选择数据库解决方案和模式等重要决策。这种方法还会带来一个额外的好处:我们从一开始就不会和特定的数据库绑定。
它就是存储库模式(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 ¤tHour, 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 的实现要复杂一些,但这同样与向后兼容性有关。当我们的数据模型变得更好时,getDateDTO
、domainHourFromDateDTO
、updateHourInDataDTO
函数可能会被省略。这是不使用 “以数据库为中心/以响应为中心” 方法的另一个原因!
// 完整源码 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/
翻译: 赵不贪
参考资料
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