您很可能接触过其中某种项目:
-
有一个难以理解和更改的、无法维护的巨大数据模型 -
并行开发新的功能时受到重重限制 -
项目很难进行优化扩展
但通常往往是三件坏事一起发生的,有这些问题的服务并不少见。
要解决这些问题,首先想到的是什么?让我们把它拆分成更多的微服务!
遗憾的是,如果不进行适当的研究和规划,盲目重构后的情况可能会比之前更糟:
-
业务逻辑和流程可能变得更加难以理解–复杂的逻辑如果集中在一处,往往更容易理解 -
分布式事务–有时某些东西放在一起是有原因的;在一个数据库中进行大事务处理比在多个服务中进行分布式事务处理要快得多,也不那么复杂 -
在进行新的更改时可能需要额外的协调,如果其中一个服务属于另一个团队的话
先说好,我不是微服务的敌人。我只是反对盲目地应用微服务,因为这样会带来不必要的复杂性和混乱,而不是让我们的生活更轻松。
另一种方法是将 CQRS(Command Query Responsibility Segregation)与之前介绍的 Clean Architecture 和 DDD Lite 结合使用。它能以更简单的方式解决上述问题。
CQRS 不是一项复杂的技术吗?
CQRS 难道不是 C#/Java/über 这些企业模式之一吗? 它们很难实现,并且会在代码中造成很大的混乱?许多书籍、演示文稿和文章都将 CQRS 描述为一种非常复杂的模式。但事实并非如此。
实际上,CQRS 是一种非常简单的模式,它可以很轻易地使用更复杂的技术进行扩展,如事件驱动(event-driven)架构、事件源(event-sourcing)或混合持久化(polyglot persistence)。 但也并不是一定要使用这些技术,即使不应用任何额外的模式,CQRS 也能提供更好的解耦和更易于理解的代码结构。
何时不在 Go 中使用 CQRS?如何充分发挥 CQRS 的优点?您可以在今天的文章中了解到这一切😉
像往常一样,我将通过重构 Wild Workouts 应用程序[1]来实现这一点。
如何在Go中实现基本的CQRS
CQRS(Command Query Responsibility Segregation)最初是由 Greg Young[2] 描述的。它有一个简单的假设:与其为读取和写入建立一个大模型,不如建立两个独立的模型,一个用于写,一个用于读。它还引入了命令(command)和查询(query)的概念,并将应用服务分成两种不同的类型:命令处理程序和查询处理程序。
命令和查询(Command vs Query)
简单来说就是:查询不应修改任何内容,只需返回数据。命令则恰恰相反:它应该在系统内进行修改,但不返回任何数据。 因此,我们可以更有效地缓存查询,并降低命令的复杂性。
这听起来像是一个限制,但实际上并非如此。我们执行的大多数操作都是读取或写入,极少数情况下,两者兼而有之。
与大多数规则一样,只要你完全理解为什么要引入这些规则以及你需要做出哪些权衡,那么打破这些规则也是可以的。实际上,你很少需要打破这些规则,我将在文章最后举例说明。
在实践中最基本的实现是什么样的呢?在上一篇文章中,Miłosz介绍了training
服务,让我们先把这项服务分成单独的”命令处理程序”和”查询处理程序”。
ApproveTrainingReschedule 命令
以前,重新安排培训是由TrainingService
批准的。
// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/commit/8d9274811559399461aa9f6bf3829316b8ddfb63#diff-ddf06fa26668dd91e829c7bfbd68feaeL127
- func (c TrainingService) ApproveTrainingReschedule(ctx context.Context, user auth.User, trainingUUID string) error {
- return c.repo.ApproveTrainingReschedule(ctx, trainingUUID, func(training Training) (Training, error) {
- if training.ProposedTime == nil {
- return Training{}, errors.New("training has no proposed time")
- }
- if training.MoveProposedBy == nil {
- return Training{}, errors.New("training has no MoveProposedBy")
- }
- if *training.MoveProposedBy == "trainer" && training.UserUUID != user.UUID {
- return Training{}, errors.Errorf("user '%s' cannot approve reschedule of user '%s'", user.UUID, training.UserUUID)
- }
- if *training.MoveProposedBy == user.Role {
- return Training{}, errors.New("reschedule cannot be accepted by requesting person")
- }
-
- training.Time = *training.ProposedTime
- training.ProposedTime = nil
-
- return training, nil
- })
- }
那里面有一些神奇的验证,现在它们都是在domain层中完成的。我还发现,我们忘了调用外部trainer
服务来移动培训。让我们用 CQRS 方法重构一下吧。
由于 CQRS 与遵循领域驱动设计(Domain-Driven Design)的应用程序配合得最好,因此在重构 CQRS 的过程中,我也将现有模型重构为 DDD Lite。DDD Lite 在之前的文章中有更详细的介绍。
我们从定义command结构体开始,该结构体提供了执行命令所需的所有数据。如果命令只有一个字段,可以跳过结构,直接将其作为参数传递。
最好在命令中使用由domain定义的类型,例如 training.User
。这样我们就不需要进行任何转换,而且还能确保类型安全,可以避免很多字符串参数传递顺序错误的问题。
// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainings/app/command/approve_training_reschedule.go#L10
package command
// ...
type ApproveTrainingReschedule struct {
TrainingUUID string
User training.User
}
第二部分是命令处理程序(command handler),它知道如何执行命令:
// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainings/app/command/approve_training_reschedule.go#L39
package command
// ...
type ApproveTrainingRescheduleHandler struct {
repo training.Repository
userService UserService
trainerService TrainerService
}
// ...
func (h ApproveTrainingRescheduleHandler) Handle(ctx context.Context, cmd ApproveTrainingReschedule) (err error) {
defer func() {
logs.LogCommandExecution("ApproveTrainingReschedule", cmd, err)
}()
return h.repo.UpdateTraining(
ctx,
cmd.TrainingUUID,
cmd.User,
func(ctx context.Context, tr *training.Training) (*training.Training, error) {
originalTrainingTime := tr.Time()
if err := tr.ApproveReschedule(cmd.User.Type()); err != nil {
return nil, err
}
err := h.trainerService.MoveTraining(ctx, tr.Time(), originalTrainingTime)
if err != nil {
return nil, err
}
return tr, nil
},
)
}
现在流程更容易理解了。您可以清楚地看到,我们批准了重新安排*training.Training
的操作(tr.ApproveReschedule
),如果成功,我们将调用外部trainer
服务。由于使用了 DDD Lite 文章中描述的技术,命令处理程序无需知道何时可以执行此操作。这一切都由我们的domain层处理。
这种清晰的流程在更复杂的命令中更加明显。幸运的是,当前的实现非常简单。我们的目标不是创建复杂的软件,而是创建简单的软件。
如果 CQRS 成为团队中构建应用程序的标准方法,它还能加快不了解该服务的团队成员学习该服务的速度。您只需要一个可用命令和查询的列表,并快速了解其执行方式。不需要在代码中随意跳转。
这就是我团队中最复杂的一个服务的情况:
您可能会问——难道不应该将它们拆分到多个服务里吗?实际上,这是一个糟糕的想法,这里的很多操作都需要事务。如果将其分割成不同的服务,就会涉及到一些分布式事务(Sagas)。这将使流程变得更加复杂,更难维护和调试,因此它不是最好的选择。
在这里,复杂性可以很好地横向扩展。我们很快就会更深入地介绍拆分微服务这个极其重要的话题。我之前提到过,我们在 Wild Workouts 中故意把它弄得一团糟。
但让我们回到我们的命令。是时候在 HTTP 中使用它了。它可以通过注入的 Application
结构在 HttpServer
中使用,该结构包含我们所有的命令和查询处理程序。
// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainings/app/app.go#L8
package app
import (
"github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app/command"
"github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app/query"
)
type Application struct {
Commands Commands
Queries Queries
}
type Commands struct {
ApproveTrainingReschedule command.ApproveTrainingRescheduleHandler
CancelTraining command.CancelTrainingHandler
// ...
// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainings/ports/http.go#L160
type HttpServer struct {
app app.Application
}
// ...
func (h HttpServer) ApproveRescheduleTraining(w http.ResponseWriter, r *http.Request) {
trainingUUID := chi.URLParam(r, "trainingUUID")
user, err := newDomainUserFromAuthUser(r.Context())
if err != nil {
httperr.RespondWithSlugError(err, w, r)
return
}
err = h.app.Commands.ApproveTrainingReschedule.Handle(r.Context(), command.ApproveTrainingReschedule{
User: user,
TrainingUUID: trainingUUID,
})
if err != nil {
httperr.RespondWithSlugError(err, w, r)
return
}
}
命令处理程序可以通过这种方式从任何入口调用:HTTP、gRPC 或 CLI。它对于执行迁移和加载固定数据[3]也很有用(我们在 Wild Workouts 中已经这样做了)。
RequestTrainingReschedule 命令
有些命令处理程序可能非常简单:
// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainings/app/command/request_training_reschedule.go#L32
func (h RequestTrainingRescheduleHandler) Handle(ctx context.Context, cmd RequestTrainingReschedule) (err error) {
defer func() {
logs.LogCommandExecution("RequestTrainingReschedule", cmd, err)
}()
return h.repo.UpdateTraining(
ctx,
cmd.TrainingUUID,
cmd.User,
func(ctx context.Context, tr *training.Training) (*training.Training, error) {
if err := tr.UpdateNotes(cmd.NewNotes); err != nil {
return nil, err
}
tr.ProposeReschedule(cmd.NewTime, cmd.User.Type())
return tr, nil
},
)
}
在这种比较简单的情况下,跳过这一层可能会节省很多模板代码。的确如此,但您需要记住,编写代码总是比维护要简单得多。添加这个简单的类型只需要 3 分钟的工作。日后阅读和扩展这些代码的人会感谢你的努力。
AvailableHoursHandler查询
应用层中的查询通常非常枯燥。我们需要编写一个读取模型接口(AvailableHoursReadModel
)来定义如何查询数据。
命令和查询也是所有交叉问题(如日志和监控)的一个很好的解决方案。有了这些,我们就能确保无论从 HTTP 还是 gRPC 入口调用,都能以相同的方式衡量性能。
// 完整链接 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainer/app/query/available_hours.go#L11
package query
// ...
type AvailableHoursHandler struct {
readModel AvailableHoursReadModel
}
type AvailableHoursReadModel interface {
AvailableHours(ctx context.Context, from time.Time, to time.Time) ([]Date, error)
}
// ...
type AvailableHours struct {
From time.Time
To time.Time
}
func (h AvailableHoursHandler) Handle(ctx context.Context, query AvailableHours) (d []Date, err error) {
start := time.Now()
defer func() {
logrus.
WithError(err).
WithField("duration", time.Since(start)).
Debug("AvailableHoursHandler executed")
}()
if query.From.After(query.To) {
return nil, errors.NewIncorrectInputError("date-from-after-date-to", "Date from after date to")
}
return h.readModel.AvailableHours(ctx, query.From, query.To)
}
我们还需要定义查询返回的数据类型,在本例中是query.Date
// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainer/app/query/types.go
package query
import (
"time"
)
type Date struct {
Date time.Time
HasFreeHours bool
Hours []Hour
}
type Hour struct {
Available bool
HasTrainingScheduled bool
Hour time.Time
}
我们的查询模型比domain中的hour.Hour
类型更复杂。这是很常见的情况。通常,这是由网站的用户界面驱动的,在后台生成最优的响应会更有效率。
随着应用程序的增长,domain模型和查询模型之间的差异可能会越来越大。通过分离和解耦,我们可以独立地对这两个模型进行更改,这对于保持长期快速开发至关重要。
// 完整源码 https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainer/domain/hour/hour.go#L11
package hour
type Hour struct {
hour time.Time
availability Availability
}
但 AvailableHoursReadModel
从哪里获取数据呢?对于应用层来说,这完全是透明的,并不相关。这样,我们就可以在未来添加性能优化功能,而这只是应用程序的一部分。
如果您不熟悉ports和adapters的概念,我强烈建议您阅读我们关于Go中的Clean Architecture的文章。
在实践中,当前实现从我们的写模型数据库中获取数据。您可以在adapters层中找到DatesFirestoreRepository
的AllTrainings
读取模型实现和测试用例。
如果您之前阅读过CQRS,通常建议使用由事件构建的单独数据库进行查询。这可能是一个好主意,但在非常具体的情况下,我将在“未来优化” 部分对此进行描述。在我们的例子中,只从写模型数据库中获取数据就足够了。
HourAvailabilityHandler查询
我们不需要为每个查询添加读模型接口,使用domain repository并选择我们需要的数据也是可以的。
// 完整源码: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainer/app/query/hour_availability.go#L22
import (
"context"
"time"
"github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainer/domain/hour"
)
type HourAvailabilityHandler struct {
hourRepo hour.Repository
}
func (h HourAvailabilityHandler) Handle(ctx context.Context, time time.Time) (bool, error) {
hour, err := h.hourRepo.GetHour(ctx, time)
if err != nil {
return false, err
}
return hour.IsAvailable(), nil
}
命名
命名是软件开发中最具挑战性也是最重要的部分之一。在《DDD Lite 简介》一文中,我介绍了一条规则,即应坚持使用尽可能接近非技术人员(通常称为 “业务人员”)交谈方式的语言,这同样适用于为命令和查询命名。
应避免使用 “Create training(创建培训)”或 “Delete training(删除培训)”这样的名称。
这不是业务人员和用户理解你领域的方式。您应该使用”Schedule training(安排培训)”和 “Cancel training(取消培训)”来命名。
我们将在一篇关于通用语言(Ubiquitous Language)的文章中更深入地讨论这个话题。在那之前,只需要去找你的业务人员,听听他们是如何称呼这些操作的。如果您的任何命令名真的需要以“创建/删除/更新”开头,请三思。
未来的优化
基本的 CQRS 有一些优点,如更好地组织代码、解耦和简化模型。还有一个更重要的优势,那就是可以用更强大、更复杂的模式来扩展 CQRS。
异步命令
有些命令天生就很慢,它们可能正在进行一些外部调用或一些繁重的计算。在这种情况下,我们可以引入异步命令总线(Asynchronous Command Bus),让它在后台执行命令。
使用异步命令有一些额外的基础设施要求,比如有一个队列或pub/sub。幸运的是,Watermill库[4]可以帮助您在Go中处理这个问题。您可以在Watermill CQRS文档[5]中找到更多详细信息。(顺便说一句,我们也是Watermill的作者😉 如果有什么不清楚的地方,请随时联系我们!)
用于查询的单独数据库
我们当前的实现使用同一个数据库进行读取(查询)和写入(命令)。如果我们需要提供更复杂的查询或更快速的查询,我们可以使用混合持久化(polyglot persistence)技术。其想法是在另一个数据库中以更优的格式复制查询的数据。例如,我们可以使用ElasticSearch对一些可以更容易地搜索和过滤的数据进行索引。
这种情况下,数据同步可以通过事件来完成。这种方法最重要的是最终一致性。你可以问问自己,这种方案在你的系统中是否是一个可以接受的折中方案。如果您不确定,您可以在没有混合持久性的情况下开始,以后再进行迁移,推迟做出这样的关键决定是件好事。
Watermill CQRS文档[6]中有一个示例实现。也许随着时间的推移,我们也会在Wild Workouts中介绍它,谁知道呢?
原文链接 https://threedots.tech/post/basic-cqrs-in-go/
参考资料
wild-workouts-go-ddd-exampl: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/
[2]Greg Young: https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf
[3]loading fixtures: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/22c0a25b67c4669d612a2fa4a434ffae8e35e65a/internal/trainer/fixtures.go#L62
[4]watermill库: https://github.com/ThreeDotsLabs/watermill
[5]Watermill CQRS文档: https://watermill.io/docs/cqrs/?utm_source=introducing-cqrs-art
[6]watermill示例实现: https://watermill.io/docs/cqrs/?utm_source=cqrs-art#building-a-read-model-with-the-event-handler
原文始发于微信公众号(梦真日记):通过重构Go项目来介绍基本的CQRS
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/167766.html