当我开始在Go工作时,社区并不看好DDD(领域驱动设计)和清洁架构等技术。我听过很多次:“不要在Golang做Java !”,“我在Java中看到过,请不要!”
这些时候,我已经有将近10年的PHP和Python经验。我在那里已经见过太多糟糕的事情了。我记得所有这些“Eight-thousanders”(+8k行代码的方法😉)和没有人想要维护的应用程序。我检查了git历史中丑陋代码,他们一开始看起来都无害。但随着时间的推移,小的、无害的问题开始变得越来越重要和严重。我还看到DDD和整洁架构是如何解决这些问题的。
也许Golang是不同 ?也许用Golang写microservices可以解决这些问题?
本来应该很漂亮的
现在,在与许多人交换经验并能够看到许多代码库之后,我的观点比3年前清晰了一些。不幸的是,我现在还不认为仅仅使用Golang和微服务就能避免我之前遇到的所有这些问题。我开始回忆过去的糟糕时光。
由于相对较年轻的代码库,它不太明显。由于Golang的设计,它不太显眼。但我相信,随着时间的推移,我们会有越来越多没有人愿意维护的Golang遗留应用程序。
幸运的是,3年前,尽管受到冷遇,我没有放弃。我决定尝试使用DDD和之前在Golang中对我有用的相关技术。和米沃什一起,我们领导了3年的团队,他们都成功地使用DDD,清洁架构,以及所有相关的,在Golang不太流行的技术。们让我们能够以恒定的速度开发我们的应用程序和产品,而不管代码的年代。
从一开始就很明显,将其他技术的模式1:1移动是行不通的。最重要的是,我们没有放弃惯用的Go代码和微服务架构——它们完美地结合在一起!
天我想与你们分享的第一个,最简单的技术- DDD lite。
Golang DDD设计模式
在写这篇文章之前,我查看了几篇关于在谷歌中Go中DDD的文章。在此我将更加残酷:他们都错过了使DDD发挥作用的最关键的点。如果我在没有任何DDD知识的情况下阅读这些文章,我就不会被鼓励在我的团队中使用它们。这种肤浅的方法也可能是DDD在Go社区还没有社会化的原因。
在本系列中,我们将尝试展示所有基本技术,并以最实用的方式实现。在描述任何模式之前,我们先问一个问题:它给了我们什么?这是挑战我们现有思维的绝佳方式。
我确信我们可以改变Go社区对这些技术的接受程度。我们相信,它们是执行复杂业务项目的最佳方式。我相信,我们将帮助确立Go作为一种伟大语言的地位,不仅用于构建基础设施,也用于商业软件。
你得慢一点,也得快一点
用最简单的方式实现你的项目可能很诱人。当你感到来自“高层”的压力时,这更具有诱惑力。但是,我们正在使用微服务吗?如果需要,我们会重写服务吗?这个故事我听过很多次,但很少有皆大欢喜的结局。的确,走捷径可以节省一些时间。但这只是短期的。
让我们考虑任何类型的测试的例子。您可以跳过在项目开始时编写测试。你显然会节省一些时间,管理人员也会很高兴。计算似乎很简单——项目交付速度更快。
但从长远来看,这种捷径是不值得的。当项目增长时,您的团队将开始害怕进行任何更改。最后,您将花费的时间总和将比一开始实现测试还要多。长期来看,你会放慢速度,因为在一开始你会牺牲质量来快速提升性能。另一方面,如果项目不是关键的,并且需要快速创建,您可以跳过测试。这应该是一个务实的决定,而不仅仅是“我们知道得更好,我们不会制造bug”。
DDD的情况也一样。当你想使用DDD时,一开始你需要多一点时间,但长期节省是巨大的。然而,并不是每个项目都复杂到可以使用DDD这样的高级技术。
没有质量和速度的权衡。如果你想长期快速发展,你就需要保持高质量。
如果你在两年前问我这个问题,我会说:“嗯,我觉得它更好用!”但仅仅相信我的话可能还不够。有许多教程展示了一些愚蠢的想法,并声称它们在没有任何证据的情况下可以工作——让我们不要盲目地相信它们!
提醒一下:如果某人在Twitter上有几千名粉丝,这并不是信任他的理由!
幸运的是,2年前《Accelerate: The Science of Lean Software and DevOps: Building and Scaling High performance Technology Organizations》发布了。简而言之,这本书描述了影响开发团队性能的因素。但这本书之所以出名,是因为它不仅仅是一套未经验证的观点,而是基于科学研究。
我最感兴趣的是如何让团队成为最优秀的团队。这本书展示了一些明显的事实,比如介绍了DevOps、CI/CD和松散耦合架构,这些都是高性能团队的基本因素。
如果像DevOps和CI/CD这样的东西对你来说不是很明显,你可以从这两本书开始:The Phoenix Project和The DevOps Handbook。
Accelerate告诉我们关于优秀团队的什么信息?
我们发现,只要系统和构建和维护它们的团队是松散耦合的,所有类型的系统都有可能实现高性能。
这个关键的体系结构属性使团队能够轻松地测试和部署单个组件或服务,即使组织及其操作的系统数量在增长——也就是说,它允许组织随着规模的扩大而提高其生产力。
所以让我们使用微服务,我们就完成了吗?如果足够的话,我是不会写这篇文章的。
- 对他们的系统设计进行大规模的更改,而不依赖于其他团队对他们的系统进行更改或为其他团队创造重要的工作
- 在不与团队之外的人沟通协调的情况下完成他们的工作
- 根据需要部署和发布他们的产品或服务,而不考虑它所依赖的其他服务
- 在正常的业务时间内执行部署,而停机时间可以忽略不计
不幸的是,在现实生活中,许多所谓的面向服务的体系结构不允许彼此独立地测试和部署服务,因此不能使团队获得更高的性能。
如果忽略这些特征,使用部署在容器上的最新的微服务架构并不能保证更高的性能。为了实现这些特征,设计系统是松散耦合的——也就是说,可以相互独立地更改和验证。
仅使用微服务体系结构和将服务分割成小块是不够的。如果以错误的方式进行,就会增加额外的复杂性并降低团队的速度。DDD可以帮助我们。
我多次提到DDD项。DDD到底是什么?
什么是DDD(领域驱动设计)
让我们从维基百科的定义开始:
领域驱动设计(DDD)是指代码的结构和语言(类名、类方法、类变量)应该与业务域匹配的概念。例如,如果您的软件处理贷款申请,它可能有像LoanApplication和Customer这样的类,以及像AcceptOffer和Withdraw这样的方法。
这不是最完美的。😅它仍然缺少一些最重要的要点
值得一提的是,DDD是在2003年推出的。那是很久以前的事了。一些蒸馏可能有助于把DDD放在2020和Go的背景下。
如果您对DDD创建时间的历史背景感兴趣,您应该检查DDD创建者Eric Evans的《解决软件核心中的复杂性》
我简单的DDD定义是:确保以最优的方式解决有效的问题。然后,以一种您的业务能够理解的方式实现解决方案,而无需从所需的技术语言进行任何额外的翻译。
如何实现呢?
编程是一场战争,要想赢就需要策略!
我喜欢说“5天的编码可以节省15分钟的计划时间”。
在开始编写任何代码之前,您应该确保您正在解决一个有效的问题。这听起来是显而易见的,但从我的经验来看,在实践中并不像听起来那么容易。通常情况下,工程师创建的解决方案并没有真正解决业务请求的问题。在这个领域帮助我们的一组模式称为战略DDD模式。
根据我的经验,DDD战略模式经常被跳过。原因很简单:我们都是开发人员,我们喜欢编写代码,而不是与“业务人员”交谈。不幸的是,当我们关在地下室里,不与任何商业人士交谈时,这种方法有很多缺点。缺乏业务方面的信任,缺乏系统如何工作的知识(从业务和工程方面),解决错误的问题-这些只是一些最常见的问题。
好消息是,在大多数情况下,它是由于缺乏适当的技术,如事件风暴。它们可以给双方带来好处。同样令人惊讶的是,与业务交谈可能是工作中最愉快的部分之一!
除此之外,我们将从应用于代码的模式开始。他们可以给我们一些DDD的优势。它们也会更快地对你有用。
如果没有战略模式,我想说的是你只有DDD的30%的优势。我们将在下一篇文章中回到策略模式。
DDD Lite in Go
经过相当长的介绍之后,终于到了接触一些代码的时候了!在本文中,我们将介绍一些Go战术领域驱动设计模式的基础知识。请记住,这只是开始。还需要另外两篇文章来覆盖整个主题。
战术DDD最关键的部分之一是试图在代码中直接反映领域逻辑。
但它仍然是一些非特定的定义-在这一点上不需要它。我也不想从描述什么是值对象,实体,聚合开始。让我们最好从实际例子开始。
野外训练
我还没有提到,特别是在这些文章中,我们创建了一个名为Wild Workouts的整个应用程序。有趣的是,我们在这个应用程序中引入了一些微妙的问题,以便进行重构。如果野外锻炼看起来像一个应用程序,你正在工作-更好地与我们呆一会儿
这不是另一篇带有随机代码片段的文章。
这篇文章是一个更大系列的一部分,我们将展示如何构建易于开发、维护和长期使用的有趣的Go应用程序。我们通过分享经过验证的技术来做到这一点,这些技术是基于我们与领导团队和科学研究所做的许多实验。
你可以通过与我们一起构建一个功能完整的Go web应用程序示例——Wild Workouts来学习这些模式。
我们做了一件不同的事——我们在最初的Wild Workouts实现中加入了一些微妙的问题。我们这么做是不是疯了?还没有。这些问题在许多Go项目中都很常见。从长远来看,这些小问题变得至关重要,并停止添加新功能。
这是高级或主要开发人员的基本技能之一;你总是需要记住长期的影响。
我们将通过重构Wild Workouts来修复它们。通过这种方式,您将很快理解我们分享的技术。
你知道在阅读了一篇关于一些技术的文章并尝试执行它后,却被指南中跳过的一些问题所阻碍的感觉吗?减少这些细节可以使文章更短,增加页面浏览量,但这不是我们的目标。我们的目标是创建内容,提供足够的专门知识来应用所提供的技术。如果您还没有阅读本系列之前的文章,我们强烈建议您阅读。
我们认为,在某些领域,没有捷径可走。如果您想以一种快速而有效的方式构建复杂的应用程序,您需要花一些时间来了解这一点。如果它很简单,我们就不会有大量可怕的遗留代码。
以下是迄今为止发布的14篇文章的完整列表。
野生训练的完整源代码可以在GitHub上找到。别忘了给我们的项目留下一颗星!
重构培训师服务
我们要重构的第一个(微)服务是培训师。其他的服务我们现在不做,以后再做。
该服务负责保持教练员的日程安排,确保我们在一个小时内只能安排一次训练。它还保存有关可用时间(教练员的时间表)的信息。
最初的实施并不是最好的。即使没有很多逻辑,代码的某些部分也开始变得混乱。我也有一些基于经验的感觉,随着时间的推移,情况会变得更糟。
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
即使它不是最糟糕的代码,它提醒我,当我检查git历史的代码我所看到的。我可以想象,一段时间后,一些新功能将到来,它将更糟。
这里也很难模拟依赖关系,因此也没有单元测试。
第一条规则——从字面上反映您的业务逻辑
在实现您的域时,您应该停止考虑类似于虚拟数据结构或“ORM类”实体的结构,这些实体具有setter和getter列表。相反,你应该将它们视为具有行为的类型。
当您与业务利益相关者交谈时,他们会说“我将在13:00安排培训”,而不是“我将属性状态设置为13:00小时安排培训”。
它们也不会说:“你不能将属性状态设置为‘training_scheduled’”。而是:“如果时间不够,你就不能安排培训。”如何将其直接放入代码中?
func (h *Hour) ScheduleTraining() error {
if !h.IsAvailable() {
return ErrHourNotAvailable
}
h.availability = TrainingScheduled
return nil
}
一个可以帮助我们实现的问题是:“如果不进行任何技术术语的额外翻译,业务是否能够理解我的代码?”你可以从这个片段中看到,即使不是技术人员也能理解你何时可以安排培训。
这种方法的成本不高,有助于解决复杂性,使规则更容易理解。即使变化不大,我们也摆脱了这堵未来会变得更加复杂的“如果”墙。
我们现在还能够轻松地添加单元测试。什么是好的-我们不需要在这里嘲笑任何事情。这些测试也是帮助我们理解Hour行为的文档。
func TestHour_ScheduleTraining(t *testing.T) {
h, err := hour.NewAvailableHour(validTrainingHour())
require.NoError(t, err)
require.NoError(t, h.ScheduleTraining())
assert.True(t, h.HasTrainingScheduled())
assert.False(t, h.IsAvailable())
}
func TestHour_ScheduleTraining_with_not_available(t *testing.T) {
h := newNotAvailableHour(t)
assert.Equal(t, hour.ErrHourNotAvailable, h.ScheduleTraining())
}
现在,如果有人问“我什么时候可以安排培训”,你可以快速回答这个问题。在一个更大的系统中,这类问题的答案就更不明显了——我多次花了好几个小时试图找到某些物品以意想不到的方式使用的所有地方。下一条规则对我们更有帮助。
测试助手
在创建域实体的测试中使用一些助手是很有用的。例如:newExampleTrainingWithTime, newCanceledTraining等。它还使我们的域测试更具可读性。
自定义断言,如assertTrainingsEquals也可以节省很多重复。Github.com/google/go-cmp库对于比较复杂的结构非常有用。它允许我们将域类型与私有字段进行比较,跳过一些字段验证或实现自定义验证函数。
func assertTrainingsEquals(t *testing.T, tr1, tr2 *training.Training) {
cmpOpts := []cmp.Option{
cmpRoundTimeOpt,
cmp.AllowUnexported(
training.UserType{},
time.Time{},
training.Training{},
),
}
assert.True(
t,
cmp.Equal(tr1, tr2, cmpOpts...),
cmp.Diff(tr1, tr2, cmpOpts...),
)
}
提供经常使用的构造函数的Must版本也是一个好主意,例如MustNewUser。与普通构造函数相比,如果参数无效,它们会惊慌失措(对于测试来说,这不是问题)。
func NewUser(userUUID string, userType UserType) (User, error) {
if userUUID == "" {
return User{}, errors.New("missing user UUID")
}
if userType.IsZero() {
return User{}, errors.New("missing user type")
}
return User{userUUID: userUUID, userType: userType}, nil
}
func MustNewUser(userUUID string, userType UserType) User {
u, err := NewUser(userUUID, userType)
if err != nil {
panic(err)
}
return u
}
第二条规则:在内存中始终保持有效状态
我意识到我的代码将以我无法预料的方式被使用,以它未曾设计过的方式被使用,并且使用时间比它预想的要长。
如果每个人都能考虑到这句话,世界将会更美好。我也不是没有过错。
根据我的观察,当你确定你使用的对象总是有效的,它有助于避免许多如果和错误。您也会更加自信,知道您不能使用当前代码做任何愚蠢的事情。
我有很多回忆,我害怕做一些改变,因为我不确定它的副作用。如果没有正确使用代码的信心,开发新特性会非常缓慢!
我们的目标是只在一个地方(好的DRY)进行验证,并确保没有人可以改变Hour的内部状态。对象的唯一公共API应该是描述行为的方法。没有愚蠢的getter和setter !我们还需要将类型放在单独的包中,并将所有属性设置为私有。
type Hour struct {
hour time.Time
availability Availability
}
// ...
func NewAvailableHour(hour time.Time) (*Hour, error) {
if err := validateTime(hour); err != nil {
return nil, err
}
return &Hour{
hour: hour,
availability: Available,
}, nil
}
我们还应该确保没有违反类型内部的任何规则。
不好的例子:
h := hour.NewAvailableHour("13:00")
if h.HasTrainingScheduled() {
h.SetState(hour.Available)
} else {
return errors.New("unable to cancel training")
}
好的例子:
func (h *Hour) CancelTraining() error {
if !h.HasTrainingScheduled() {
return ErrNoTrainingScheduled
}
h.availability = Available
return nil
}
// ...
h := hour.NewAvailableHour("13:00")
if err := h.CancelTraining(); err != nil {
return err
}
第三条规则-域需要与数据库无关
这里有多种说法——有些人认为,让数据库客户端影响域是没问题的。从我们的经验来看,严格地保持域而不受数据库的影响效果最好。
最重要的原因是:
- 域类型不是由所使用的数据库解决方案决定的——它们应该只由业务规则决定
- 我们可以以更优的方式在数据库中存储数据
- 由于Go的设计和缺少像注释这样的“魔法”,ORM或任何数据库解决方案的影响更加显著
Domain-First方法
如果项目足够复杂,我们甚至可以花2-4周的时间在域层上工作,只需要在内存中实现数据库。在这种情况下,我们可以更深入地探讨这个想法,并推迟选择数据库的决定。我们所有的实现都只是基于单元测试。
我们尝试了几次这种方法,总是很有效。在这里设置一些时间框也是一个好主意,不要花太多时间。
请记住,这种方法需要良好的关系和来自企业的大量信任!如果你与企业的关系远不是很好,战略DDD模式将改善这一点。我去过那里,做过那个!
为了不让本文太长,让我们只介绍Repository接口并假设它能工作。我将在下一篇文章中更深入地讨论这个主题。
type Repository interface {
GetOrCreateHour(ctx context.Context, time time.Time) (*Hour, error)
UpdateHour(
ctx context.Context,
hourTime time.Time,
updateFn func(h *Hour) (*Hour, error),
) error
}
你可能会问为什么’ UpdateHour ‘有’ updateFn func(h Hour) (Hour, error) ‘——我们将使用它来很好地处理事务。您可以在本文中了解更多关于存储库的内容。
使用域对象
我对gRPC端点做了一个小的重构,以提供一个更“面向行为”的API,而不是CRUD。它更好地反映了该领域的新特性。从我的经验来看,维护多个小方法也比维护一个简单得多,“上帝”方法允许我们更新所有内容。
--- a/api/protobuf/trainer.proto
+++ b/api/protobuf/trainer.proto
@@ -6,7 +6,9 @@ import "google/protobuf/timestamp.proto";
service TrainerService {
rpc IsHourAvailable(IsHourAvailableRequest) returns (IsHourAvailableResponse) {}
- rpc UpdateHour(UpdateHourRequest) returns (EmptyResponse) {}
+ rpc ScheduleTraining(UpdateHourRequest) returns (EmptyResponse) {}
+ rpc CancelTraining(UpdateHourRequest) returns (EmptyResponse) {}
+ rpc MakeHourAvailable(UpdateHourRequest) returns (EmptyResponse) {}
}
message IsHourAvailableRequest {
@@ -19,9 +21,6 @@ message IsHourAvailableResponse {
message UpdateHourRequest {
google.protobuf.Timestamp time = 1;
-
- bool has_training_scheduled = 2;
- bool available = 3;
}
message EmptyResponse {}
实现现在简单得多,也更容易理解。我们这里也没有逻辑,只是一些编配。我们的gRPC处理器现在有18行,没有域逻辑!
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
}
不再Eight-thousanders
我记得以前,许多8000实际上是控制器,在HTTP控制器中有很多域逻辑。
通过在我们的域类型中隐藏复杂性并保持我所描述的规则,我们可以防止在这个地方出现不受控制的增长
今天就到这里
我不想把这篇文章写得太长,让我们一步一步来吧!
如果你等不及,可以在GitHub上找到重构的全部工作差异。在下一篇文章中,我将介绍这里没有解释的区别中的一部分:存储库。
即使它仍然是开始,我们的代码中的一些简化是可见的。
目前的模型实现也不完美——这很好!你永远不可能从一开始就实现完美的模型。最好能准备好轻松地改变这个模式,而不是浪费时间去完善它。在添加了模型测试并将其与应用程序的其他部分分离之后,我可以毫无畏惧地更改它。
我可以把我知道的DDD写在我的简历上吗?
在我听说DDD后,我花了3年时间才把所有的点都联系起来(那是在我听说Go之前)。😉之后,我看到了我们将在下一篇文章中描述的所有技术为何如此重要。但在把这些点连接起来之前,需要一些耐心,并相信它会奏效。这是值得的!你不会像我一样需要3年的时间,但是我们目前计划了10篇关于战略和战术模式的文章。😉在Wild Workouts中有很多新特性和部件需要重构!
我知道现在有很多人承诺,只要看10分钟的文章或视频,你就能成为某个领域的专家。如果有可能,这个世界会很美好,但事实上,它并没有那么简单。
幸运的是,我们分享的大部分知识是通用的,可以应用于多种技术,而不仅仅是围棋。你可以把这些学习作为对你职业生涯和长期心理健康的投资。😉没有什么比解决正确的问题而不与不可维护的代码斗争更好的了!
你在Go中使用DDD的经验是什么?是好还是坏?这和我们现在做的有什么不同吗?你认为DDD在你的项目中有用吗?请在评论中告诉我们!
你认为有同事可能对这个话题感兴趣吗?请与他们分享这篇文章!即使他们不在Go里工作。