Go DDD Lite:当微服务不足以解决问题时

当我开始写Go时,社区对DDD(Domain-Driven Design – 领域驱动设计)和整洁架构等技术并不看好。我听到过很多次:“不要在Go里写Java!”,“我在Java里见过这个,请不要这样做!”。

此时,我已经有了近10年的PHP和Python经验,我已经在那里看到了太多糟糕的事情。我见过有8千行代码的方法😉和没人愿意维护的应用程序。

我检查了这些屎山的git历史,它们在开始时都看起来无害。但随着时间的推移,那些小问题开始变得越来越大,越来越严重。我也看到过DDD和Clean Architecture是如何解决这些问题的。

也许Go是不同的?也许用Go写微服务可以解决这些问题?

它本应很美丽

在与很多人进行了交流并阅读了大量的代码后,我的观点比3年前更加清晰明确了。但我现在依旧不认为仅仅使用Go和微服务就能解决我们之前提出的问题。

因为代码库相对较小,诞生时间短,并且得益于Go的设计,所以这个问题还不太明显。但我相信,随着时间的推移,会有越来越多没有人愿意去维护的遗留Go程序。

幸运的是,3年前,尽管受到了各种冷嘲热讽,但我并没有放弃。我决定尝试使用DDD和相关的技术,与Milosz一起,我们领导的团队在3年内都成功地使用了DDD、整洁架构以及所有相关的、在Go中并不流行的技术。这些东西让我们有能力以恒定的速度开发我们的应用程序和产品,无论代码的”年龄”如何。

从其他技术中1:1的移植模式是行不通的。最重要的是,我们没有放弃惯用的Go代码和微服务架构——它们完美地结合在一起!

今天我想和大家分享第一个最直接的技术–DDD lite。

Go中的DDD现状

在写这篇文章之前,我在谷歌上查了几篇关于Go中的DDD的文章。我发现它们都忽略了DDD发挥作用的最关键的部分。我想象了一下,如果自己在没有任何DDD知识的情况下阅读这些文章,我绝对不会鼓励我的团队使用它们。这种肤浅的方法也可能是DDD在Go界仍未普及开来的原因。(译注: 这是一篇2020年的文章)

在这个系列中,我们试图以最务实的方式展示所有必要的技术。

我相信,我们可以改变Go界对这些技术的接受程度。我们相信,它们是实施复杂商业项目的最佳方式。我相信我们会帮助确立Go的地位,使之成为一种不仅可以构建基础设施,而且可以构建商业软件的伟大语言。 (译注:好宏大的愿景…)

你需要走得慢一点,才能走得更快

以最简单的方式实现你所做的项目是很诱人的,特别是当你感受到来自”高层”的压力时,这就更加诱人了。我们是否正在使用微服务?如果需要的话,我们会重写服务吗?我听过很多次类似的故事,但很少有一个圆满的结局😉。走捷径的确可以节省一些时间,但只是在短期内。

让我们考虑一下测试的例子。你可以在项目开始的时候跳过写测试,这显然会节省你一些时间,管理层也会很高兴,项目会交付得更快。

但从长远来看,这种捷径是不值得的。当项目增长时,你的团队会开始害怕做任何改变。最后,你所花费的时间总和会比从一开始就写测试还要高。从长远来看,你会慢下来,因为你从一开始就牺牲了质量来换取开发的速度。 但凡事没有绝对,如果一个项目不是很关键,需要快速构建,你可以跳过测试。这应该是一个务实的决定,而不是 “我们很清楚,我们不会制造BUG“。

对于DDD来说,情况也是如此。当你想使用DDD时,你在开始时需要花费更多的时间,但长期看会节省你很多时间。不过也不是每个项目都复杂到可以使用DDD这样的高级技术。

不存在质量与速度的权衡。如果你想长期快速发展,你就需要保持较高的质量。

Go DDD Lite:当微服务不足以解决问题时
高质量的软件是否值得花费时间 – https://martinfowler.com/articles/is-quality-worth-cost.html

听起来还不错,但你有证据证明它确实有效吗?

如果你两年前问我这个问题,我会说:”嗯,我觉得它的效果很好!”。但只听我这样说肯定是不行的,要有能够支撑我观点的证明。有许多教程展示了一些愚蠢的想法,并在没有任何论证的情况下声称它们有效–我们不要盲目地相信它们!

我想提醒的是:如果某人有几千个Twitter粉丝,仅此一点并不能成为信任他们的理由[1]

幸运的是,2年前《加速:企业数字化转型的24项核心能力》[2]一书发布。这本书描述了影响开发团队效率的因素。这本书之所以出名,是因为它不是一套未经验证的想法,而是有科学研究做支撑的

我最感兴趣的部分是如何让团队成为效率最高,最棒的顶级团队。这本书展示了一些显而易见的东西,比如介绍了DevOps、CI/CD和松散耦合的架构,这些都是高绩效团队的一个基本因素。

如果像DevOps和CI/CD这样的东西对你来说并不”明显”,你可以从这些书开始:《凤凰项目:一个IT运维的传奇故事》[3]《DevOps实践指南》[4]

《加速》告诉了我们关于高效团队的什么?

我们发现,只要系统以及构建和维护它们的团队是松散耦合的,那么所有类型的系统都可以实现高性能。

这个关键的架构属性使团队能够轻松地测试和部署单个组件或服务,即使组织和它所运营的系统数量在增长–也就是说,它使组织在扩大规模时能够提高其生产力。

所以,使用微服务就完事了?如果这就行了,我就不会写这篇文章了😉。

  • 在不使其他团队对其系统进行更改 或 为其他团队做大量工作的前提下,对系统的设计进行大规模更改
  • 在不与团队以外的人沟通和协调的情况下完成工作
  • 根据需要部署和发布产品或服务,而不需要考虑所依赖的其他服务
  • 在不需要集成测试环境的情况下按需执行大部分测试。在正常的工作时间内进行部署,停机时间可以忽略不计

不幸的是,在现实生活中,许多所谓的面向服务的架构并不允许测试和部署相互独立的服务,因此不会使团队获得更高的性能。

[…]如果你忽略了这些特性,即使采用最新的、部署在容器上的”时髦”的微服务架构也并不能保证获得更高的性能。[…]为了实现这些特性,设计的系统需要是松散耦合的,即可以相互独立地更改和验证。

只使用微服务架构,将服务拆分成小块是不够的。如果方式不对,甚至会增加额外的复杂度,拖慢团队的速度。而DDD可以帮助我们。

我多次提到DDD这个概念,DDD到底是什么?

什么是DDD(Domain-Driven Design)

我们先来看一下维基百科是怎么说的:

领域驱动设计(DDD)的概念是: 你代码的结构和语言(类名、类方法、类变量)应该与业务领域相匹配。例如,如果你的软件处理贷款申请,它可能有LoanApplicationCustomer这样的类,以及AcceptOfferWithdraw这样的方法。

Go DDD Lite:当微服务不足以解决问题时

嗯,这个叙述不是很完美😅,它仍然缺少一些重要的点。

对了,DDD是在2003年推出的,那已经是很久之前的事了。一些提炼过的内容可能有助于将DDD放在2020年和Go的上下文中。

如果你对DDD创建的历史背景感兴趣,你可以看看DDD创建者Eric Evans的《解决软件核心的复杂性》[5]

Go DDD Lite:当微服务不足以解决问题时

我对DDD的简单定义是:确保你以最佳的方式解决有效的问题。之后,以你的企业能够理解的方式来实施解决方案,而不需要任何额外的专业术语。

如何才能实现这一目标呢?

编码是一场战争,要想取胜,你需要一个策略!

我经常说,”5天的编码时间可以节省15分钟的规划时间“。
(译注: 应该是反讽,只要多花5天进行编码,就可以节省15分钟的设计时间。正确的句子应该是: 15分钟的规划时间可以节省5天的编码时间)

在开始写任何代码之前,你应该确保你正在解决一个有效的问题。从我的实际经验来看,这并不像听起来的那么容易。通常情况下,工程师创建的解决方案实际上并没有解决企业要求的问题。在这个领域,有一套模式可以帮助我们,它被命名为DDD战略(Strategic)模式。

根据我的经验,DDD战略模式经常被跳过。原因很简单:我们都是开发人员,我们喜欢写代码,而不是与”业务人员“交谈😉。当我们把自己封闭在地下室,不与任何业务人员交谈时,我们会失去很多东西:企业的信任,缺乏对系统工作方式的了解(来自企业和工程方面),解决错误的问题等等。这只是一些最常见的问题。

好消息是,在大多数情况下,它是由缺乏适当的技术(如事件风暴)造成的。这些技术可以给双方都带来益处。更令人惊讶的是,与企业的对话可能是工作中最令人愉快的部分之一。

除此以外,我们将从适用于代码的模式开始,它们可以给我们带来DDD的一些优势,也会更快地对你产生价值。如果没有战略模式,你将只拥有DDD能给你的30%的优势。我们将在接下来的文章中再谈战略模式。

Go中的DDD Lite

经过了这么久的介绍之后,现在终于到了接触一些代码的时候了,在这篇文章中,我们将介绍Go中领域驱动设计战术(Tactical)的一些基础知识。请记住,这只是一个开始,之后还需要几篇文章来涵盖整个主题。

战术DDD最关键的部分之一是试着在代码中直接反映领域逻辑。

这仍然是一些抽象的定义,我不想从描述什么是值对象、实体、聚合开始,所以让我们从实际的例子开始。

Wild workouts

忘记说了,为了这些文章,我们创建了一个名为Wild Workouts[6]的应用程序。有趣的是,我们在这个应用程序中引入了一些微妙的问题,以便有东西可以重构。如果Wild Workouts看起来像你正在开发的一个应用程序,那最好和我们一起呆一会儿😉。

重构trainer服务

我们要重构的第一个(微)服务是trainer。我们现在不碰其他的服务,以后再来讨论它们。

这个服务负责保存培训师的时间表,确保我们在一个小时内只能有一个培训计划,它还保存了可用的时间信息(培训师的日程安排)。

最初的实现并不是最好的。即使没有多少逻辑,代码的某些部分也开始变得混乱。随着时间的推移,情况会越来越糟。

// 完整代码: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/ef6056fdeb39b89009127e07600f1b9ec87e717c/internal/trainer/grpc.go#L20
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历史时看到的情况。一段时间后,会有一些新的功能,到时候情况会更糟糕。

这里也很难模拟依赖关系,所以也没有单元测试。

第一条规则:从字面上反映你的业务逻辑

在实现你的domain时,你不应该把结构想成是假的数据结构或”类似ORM”的,有一串setter和getter的实体。相反,你应该把它们看成是有行为的类型

当你和你的业务利益相关者交谈时,他们会说”我正在安排13:00的培训”,而不是”我将属性状态设置为培训计划在13:00进行”。

他们也不会说:”你不能把属性状态设置为’training_scheduled'”。相反,他们会说”如果这个小时没有时间(被人占用了),你就不能安排培训”。那么如何直接将其反映在代码里?

// 完整代码在:https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/0249977c58a310d343ca2237c201b9ba016b148e/internal/trainer/domain/hour/availability.go#L58
func (h *Hour) ScheduleTraining() error {
    if !h.IsAvailable() {
        return ErrHourNotAvailable
    }

    h.availability = TrainingScheduled
    return nil
}

有一个问题可以帮我们更好的实现代码:”如果不引入任何额外的技术术语,企业/业务会理解我的代码吗?“。你可以在这段代码中看到,即使不是技术人员也能理解你何时可以安排培训。

这种方法的成本并不高,而且有助于解决复杂性,使规则更容易理解。即使变化不大,我们也摆脱了这堵在未来会变得更加复杂的if之墙。

我们现在也能够轻松地添加单元测试,我们不需要在这里模拟任何东西。测试也是一个文档,帮助我们理解Hour的行为方式。

// 完整代码在: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/0249977c58a310d343ca2237c201b9ba016b148e/internal/trainer/domain/hour/availability_test.go#L41
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())
}

现在,如果有人问”我什么时候可以安排培训”的问题,你可以很快回答。在一个更大的系统中,这类问题的答案就不这么明显了——我经常会花很多时间试图找到一些对象被意外使用的所有地方。下一条规则将更好地帮助我们解决这个问题。

测试帮手

在测试时拥有一些帮助我们创建领域实体的辅助工具是很有用的。例如:newExampleTrainingWithTimenewCanceledTraining等。这也使得我们的领域测试更具有可读性。
自定义断言,如assertTrainingsEquals,也可以省去大量重复的工作。
github.com/google/go-cmp库对于比较复杂的结构非常有用。它允许我们将域类型与私有字段进行比较,跳过一些字段验证[7]或实现自定义验证[8]功能。

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。与普通构造函数相比,如果参数无效,它们会panic(对于测试来说,这不是问题)。

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
}

第二条规则:始终在记忆中保持有效状态

我认识到,我的代码将以我无法预料的方式被使用,以它没有被设计的方式被使用,而且使用的时间比它曾经被设计的时间更长。——The Rugged Manifesto[9]

如果每个人都能考虑到这句话,世界就会变得更好😉

根据我的观察,当你确信你所使用的对象总是有效的时候,它有助于避免很多的if和bug,你也会感到更加有信心,因为你知道你无法用当前的代码做任何愚蠢的事情。

过去,我经常害怕做出一些改变,因为我不确定它的副作用。开发新功能的速度要慢得多,因为我没有信心正确地使用了代码!

我们的目标是只在一个地方进行验证(良好的DRY),并确保没有人可以改变Hour的内部状态。该对象的唯一公共API应该是描述行为的方法。没有愚蠢的gettersetter!。我们还需要把我们的类型放到独立的包中,并使所有的属性成为私有。

// 完整代码: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/0249977c58a310d343ca2237c201b9ba016b148e/internal/trainer/domain/hour/hour.go#L9
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
}

第三条规则:domain需要与数据库无关

这里有多种流派,有些流派认为让domain受到数据库客户端的影响是可以的。根据我们的经验,严格保持域不受任何数据库影响是最好的。

最重要的原因是:

  • 域的类型不是由使用的数据库决定的,它们应该只由业务规则决定。
  • 我们可以以一种更理想的方式在数据库中存储数据。
  • 由于Go的设计以及缺乏像注解这样的”魔法”,ORM或任何数据库解决方案都会受到很大的影响。

领域优先的方法

如果项目足够复杂,我们甚至可以花2-4周的时间在领域层工作,只用内存数据库实现。在这种情况下,我们可以更深入地探索这个想法,并推迟决定选择数据库。我们所有的实现都只是基于单元测试。

我们试过几次这种方法,总是很顺利。在这里最好有一些时间限制,不要花费太多的时间。

请记住,这种方法需要一个良好的关系和企业的大量信任!如果你与企业的关系远非如此!如果你与企业的关系还不够好,战略DDD模式将改善这一状况:我们这样做过,并且成功了!

为了不使文章太长,我们只介绍一下Repository接口,并假设它能工作😉。我将在下一篇文章中更深入地介绍这个话题/

// 完整代码: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/0249977c58a310d343ca2237c201b9ba016b148e/internal/trainer/domain/hour/repository.go#L8
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
}

你可能会问为什么UpdateHourupdateFn func(h *Hour) (*Hour, error)–我们将会用它来很好地处理事务。你可以在之后关于Repository的文章中了解更多。

使用领域对象

我对我们的gRPC endpoint做了一个小的重构,以提供一个更”面向行为”的API,而不是CRUD[10]。它更好地反映了这个领域的新特点。根据我的经验,维护多个小方法比维护一个允许我们更新一切的”神”方法要容易得多.

// 完整代码: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/commit/0249977c58a310d343ca2237c201b9ba016b148e#diff-15fd9ad3f3992b0210090109b82c5594
--- 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行,没有领域逻辑

// 完整代码: 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
}

不再有八千行

在我的记忆中,许多八千行实际上是在HTTP Controller中带有大量的域逻辑。

通过将复杂性隐藏在我们的域类型里面,并保持我描述的规则,我们可以防止这个地方不受控制地增长。

这就是今天的所有内容

我不想把这篇文章写得太长,让我们一步一步来吧!

如果你等不及了,整个重构工作的差异可以在GitHub[11]上找到。在下一篇文章中,我将介绍本文中没有解释的部分:存储库模式(repositories)。

即使这只是个开始,但我们代码中的变化是肉眼可见的。

目前模型的实现也不完美,这很好! 你永远不可能从一开始就实现完美的模型。与其浪费时间让它变得完美,不如准备好轻松改变这个模型。在我添加了模型的测试并将其与应用程序的其他部分分开后,我可以毫无顾忌地修改它。

我是否已经可以在我的简历中写上我知道DDD?

还不行。

在我听到DDD之后,我花了3年的时间才能把所有的点连接起来(那是在我听说Go之前)😉
在那之后,我明白了为什么我们将在接下来的文章中描述的所有技术都如此重要。但是在连接这些点之前,需要一些耐心和信任,这是值得的!

你不会像我一样需要3年时间,但我们目前计划了大约10篇关于战略和战术模式的文章。😉 这些是在Wild Workouts中剩下的很多新功能和需要重构的部分!

我知道,现在很多人都承诺,只要看了一篇文章或一段10分钟的视频,你就能成为某个领域的专家。如果能做到这一点,世界会很美好。但现实并没那么简单。

幸运的是,我们所分享的知识有很大一部分是通用的,可以应用于多种技术,而不仅仅是Go。你可以把这些知识当作对你的职业生涯和心理健康的长期投资😉。没有什么比解决正确的问题并且不与不可维护的代码作斗争更好的事情了。

原文地址: https://threedots.tech/post/ddd-lite-in-go-introduction/

参考资料

[1]

权威偏见: https://en.wikipedia.org/wiki/Authority_bias

[2]

加速: https://book.douban.com/subject/35851994/

[3]

凤凰项目:一个IT运维的传奇故事: https://book.douban.com/subject/34820436/

[4]

DevOps实践指南: https://book.douban.com/subject/30186150/

[5]

解决软件核心的复杂性: https://www.youtube.com/watch?t=1109&v=dnUFEg68ESM&feature=youtu.be

[6]

Wild Workouts: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example?utm_source=about-wild-workouts

[7]

IgnoreFields: https://godoc.org/github.com/google/go-cmp/cmp/cmpopts#IgnoreFields

[8]

comparer: https://godoc.org/github.com/google/go-cmp/cmp#Comparer

[9]

The Rugged Manifesto: https://ruggedsoftware.org/

[10]

CRUD: https://en.wikipedia.org/wiki/Create,_read,_update_and_delete

[11]

Wild Workout: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/commit/0249977c58a310d343ca2237c201b9ba016b148e


原文始发于微信公众号(梦真日记):Go DDD Lite:当微服务不足以解决问题时

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

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

(0)
小半的头像小半

相关推荐

发表回复

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