为什么说微服务架构或单体架构仅仅是实现上的细节

之前公众号已经发过该文的翻译,但由于微信无法修改已发布文章,导致上篇中无法插入下篇的链接,因此将两篇内容合并为一长篇

如今,我们经常会听人说“单体架构(Monolith)已经过时,它是 IT 中的万恶之源”。我们也会经常听到微服务(Microservices)架构是解决所有这些庞然大物的“银弹”。但其实 IT 中几乎没有银弹,每个决策都需要取舍。

微服务架构最受欢迎的优势之一是良好的模块分离。您可以独立地部署每个服务,而且服务更容易伸缩。此外,每个团队都可以拥有自己的代码库,并使用自己选择的技术。我们可以很容易、很快地重写整个服务。相应的,采用微服务架构后,我们可能会遇到网络、延迟、有限的带宽等问题。您必须处理通信层中的潜在错误,从开发人员和运维人员的角度来看,调试和维护非常困难。我们还必须与服务 API 的兼容性问题作斗争。此外还有一些潜在的性能缺陷。许多领域的总体复杂性更大,从开发到管理都是如此。

总之,不管有什么优点,构建好的微服务体系结构是很困难的。如果你不相信我的意见,那么你应该可以相信Martin Fowler[1]:

我听说过的从零开始构建微服务系统的案例,几乎都以严重的问题告终。
—— Martin Fowler

几乎所有成功的微服务都是从单体架构开始的,这个单体架构变得过于庞大,最终拆分被拆分。
—— Martin Fowler

让我们假设一个疯狂的想法:我们正在开发一个新的应用程序,我们想从一个单体架构开始,这是否意味着我们必须忍受所有单体架构的缺点?

并非这样。事实上,许多这样的问题通常不是因为单体架构,而是因为缺乏良好的编程实践和设计。

让我们看看与微服务架构版本相比,一个设计良好的示例单体应用程序看起来如何。在本文中,我们将把它命名为“整洁单体”。两者都是使用 整洁架构 的规则构建的。

整洁架构

简单来说,整洁架构(Clean architecture)假设您的服务应该分为4层:

  • 领域层(Domain) – 我们的领域逻辑和实体
  • 应用层(Application) – 像领域层的胶水一样。例如,从存储层获取实体,调用该实体上的某个方法,将该实体保存在存储层中。此外,它还应该对所有横切关注点(如日志、事务、监控等)负责,它还负责提供视图。
  • 接口层(Interfaces) – 该层允许我们使用应用程序,例如 REST API、 CLI Interface、 Queue 等。
  • 基础设施层(Infrastructure) – 数据库适配器, REST客户端, 通常实现Domain/Application的接口。
为什么说微服务架构或单体架构仅仅是实现上的细节

整洁架构是一个非常类似六边形架构/端口和适配器架构/洋葱架构的概念,其中的一些你可能已经听说过。

整洁架构的关键概念是任何层都不能知道外层的信息。例如,领域层不应该知道应用层或基础设施层(例如,它不知道领域实体在 MySQL 中是持久化的)。此外,应用层也不应该知道调用的细节(REST API、 CLI、 Queue listener等等)。

但如何实现呢? 答案是控制反转(IoC)。简而言之: 将实现细节隐藏在接口之后。

鲍勃大叔[2]已经描述得很好了。在文章的底部,我将提供一个整洁架构的完整解释的链接。

单体架构 vs 微服务架构

我们使用一个简单的商店程序作为示例。这个商店程序将允许我们: 列出产品,下订单,在远程支付提供商中初始化付款,接收有关付款的通知并标记订单付款。让我们看一下架构图。

我为微服务架构和单体架构分别创建了图表,它们都是基于整洁架构设计的。

微服务架构图:为什么说微服务架构或单体架构仅仅是实现上的细节译注:如果图片不清晰,可查看原图
https://threedots.tech/media/microservices-or-monolith/microservices.png

通常来说,单体架构看起来像大泥球:

为什么说微服务架构或单体架构仅仅是实现上的细节

但当我们以整洁架构的精神来设计时,它是这样的:

单体架构图
为什么说微服务架构或单体架构仅仅是实现上的细节

原图地址 https://threedots.tech/media/microservices-or-monolith/monolith.png

你看出来这个例子中两种架构的不同之处了吗?如果仔细观察,可以发现只有在接口层/基础设施层中有些细微的差异。领域层和应用层基本上是相同的(这是我们使用 DDD[3] 时所期望的)。我用橙色箭头标记了不同之处,以使其更加明显;)

微服务可以避免这种情况,因为我们在服务之间有一个天然的屏障(例如,因为代码库是分离的) ,所以我们通过设计来强制模块分离。但是我们也可以通过单体应用程序和整洁架构实现一些分离,因为我们只能在基础设施和接口层之间进行通信。例如,我们不允许在我们的限界上下文[4]中使用来自其他限界上下文的领域层/应用层/基础设施层的任何内容。

使这条规则有效是极其重要的。实施它的最佳方法是拥有一个可以验证它并将其插入 CI 的工具。

我已经开发了一个工具,可以在Go中做到这一点: go-cleanarch[5]

前段时间我发现了一个类似的 PHP 工具,但是我找不到它了。类似这样的工具有可能存在于其他语言中。如果你知道任何像这样的工具,请发送到我的 Twitter:@roblaszczak,我会把它放在这里:)

不幸的是,有模块并不意味着我们的架构是良好的。良好的模块分离对于正常工作的单体应用和微服务应用是至关重要的。为了使它更好,我们应该检查限界上下文的概念(在文章的底部)。而另一个伟大的工具 —— 事件风暴,将向你展示你的限界上下文在哪里(物理上!),展示你的领域是如何工作的。

为什么说微服务架构或单体架构仅仅是实现上的细节Futuramo[6] 上制造事件风暴。

有很多人说持久性只是一个实现上的细节[7](框架和驱动部分)。他们还说,应该实现持久层,以便在不影响基础设施以外的任何其他层的情况下替换驱动。

让我们再进一步: 假设应用程序是微服务架构还是单体架构只是一个实现上的细节。

如果这是真的,我们可以用单体架构来开发我们的应用程序,当时机来临,迁移到微服务架构也不会有太多的工作,不涉及接口层/基础设施层以外的层级。类似的,我们可以选择在文件系统或内存中实现数据库驱动程序,以推迟有关选择数据库的决定。

当然,在迁移到微服务时可能仍然需要一些优化,但与整体重构(或者更糟糕的重新实现)相比,这将是相当容易的。

我不想只是毫无根据的瞎说,下面是该应用程序源代码的一些片段。

Show me the code

在这个例子中,我们将遵循简单的工作流程,其中包括: 下订单,初始化支付和模拟异步支付接收。

下订单应该是同步(synchronous)的,初始化和收到付款是异步(async)的。

下订单

在接口中没有什么特别的,只是解析 HTTP 请求并在应用层执行一个命令。

在每个代码片段的顶部,您可以查看它在存储库中的位置。
您还可以从中读取限界上下文和层,例如pkg/orders/interface/public/http/something.goorders限界上下文中的inferface层。

// pkg/orders/interfaces/public/http/orders.go

func (o ordersResource) Post(w http.ResponseWriter, r *http.Request) {
    req := PostOrderRequest{}
    if err := render.Decode(r, &req); err != nil {
        _ = render.Render(w, r, common_http.ErrBadRequest(err))
        return
    }

    cmd := application.PlaceOrderCommand{
        OrderID:   orders.ID(uuid.NewV1().String()),
        ProductID: req.ProductID,
        Address:   application.PlaceOrderCommandAddress(req.Address),
    }
    if err := o.service.PlaceOrder(cmd); err != nil {
        _ = render.Render(w, r, common_http.ErrInternal(err))
        return
    }

    w.WriteHeader(http.StatusOK)
    render.JSON(w, r, PostOrdersResponse{
        OrderID: string(cmd.OrderID),
    })
}

对于不熟悉 Go 的人: 在 Go 中,你不需要显示的实现某个接口,你只需要实现该接口所有的方法,编译器就认为你实现了这个接口。
(译注: 原文中作者使用了一些代码对Go中的接口进行了解释和说明,为了不影响阅读体验并控制篇幅,这部分内容没有放进本文,感兴趣的同学可以阅读原文查看。)

在application中的pkg/orders/application/orders.go更有趣:
首先,productsService(从 Shop 限界上下文获取产品数据) 和 paymentsService(在 Payments 限界上下文中初始化支付)的接口:

// pkg/orders/application/orders.go

type productsService interface {
    ProductByID(id orders.ProductID) (orders.Product, error)
}

type paymentsService interface {
    InitializeOrderPayment(id orders.ID, price price.Price) error
}

最后是Application,这对于单体和微服务来说是完全一样的。我们只是注入不同的 productsServicepaymentsService 实现。

在这里我们使用领域对象(domain objects)和仓储(repository)来保存数据库中的 Order (在我们的示例中,我们使用内存实现,但它也是一个细节,可以更改为任何存储方式)。

// pkg/orders/application/orders.go

type OrdersService struct {
    productsService productsService
    paymentsService paymentsService

    ordersRepository orders.Repository
}

// ...

func (s OrdersService) PlaceOrder(cmd PlaceOrderCommand) error {
    address, err := orders.NewAddress(
        cmd.Address.Name,
        cmd.Address.Street,
        cmd.Address.City,
        cmd.Address.PostCode,
        cmd.Address.Country,
    )
    if err != nil {
        return errors.Wrap(err, "invalid address")
    }

    product, err := s.productsService.ProductByID(cmd.ProductID)
    if err != nil {
        return errors.Wrap(err, "cannot get product")
    }

    newOrder, err := orders.NewOrder(cmd.OrderID, product, address)
    if err != nil {
        return errors.Wrap(err, "cannot create order")
    }

    if err := s.ordersRepository.Save(newOrder); err != nil {
        return errors.Wrap(err, "cannot save order")
    }

    if err := s.paymentsService.InitializeOrderPayment(newOrder.ID(), newOrder.Product().Price()); err != nil {
        return errors.Wrap(err, "cannot initialize payment")
    }

    log.Printf("order %s placed", cmd.OrderID)

    return nil
}

productsService实现

微服务架构

在微服务版本中,我们使用 HTTP (REST)接口来获取产品信息。我已经将 REST API 分为 private (内部)和 public (例如,通过前端访问)。

// pkg/orders/infrastructure/shop/http.go

import (
    // ...
    http_interface "github.com/ThreeDotsLabs/monolith-shop/pkg/shop/interfaces/private/http"
    // ...
)

func (h HTTPClient) ProductByID(id orders.ProductID) (orders.Product, error) {
    resp, err := http.Get(fmt.Sprintf("%s/products/%s", h.address, id))
    if err != nil {
        return orders.Product{}, errors.Wrap(err, "request to shop failed")
    }

    // ...
    productView := http_interface.ProductView{}
    if err := json.Unmarshal(b, &productView); err != nil {
        return orders.Product{}, errors.Wrapf(err, "cannot decode response: %s", b)
    }

    return OrderProductFromHTTP(productView)
}

Shop 限界上下文中的 REST endpoint如下所示:

// pkg/shop/interfaces/private/http/products.go

type productsResource struct {
    repo products_domain.Repository
}

// ...

func (p productsResource) Get(w http.ResponseWriter, r *http.Request) {
    product, err := p.repo.ByID(products_domain.ID(chi.URLParam(r, "id")))

    if err != nil {
        _ = render.Render(w, r, common_http.ErrInternal(err))
        return
    }

    render.Respond(w, r, ProductView{
        string(product.ID()),
        product.Name(),
        product.Description(),
        priceViewFromPrice(product.Price()),
    })
}

我们还有一个用于 HTTP 响应的简单类型。理论上,我们可以将 Domain 类型序列化为 JSON,但是如果我们这样做的话,每个域的更改都会改变我们的 API 协议,每个 API 协议更改的请求都会改变域。听起来不太好,不符合 DDD 和 整洁架构。

// pkg/shop/interfaces/private/http/products.go

type ProductView struct {
    ID string `json:"id"`

    Name        string `json:"name"`
    Description string `json:"description"`

    Price PriceView `json:"price"`
}

type PriceView struct {
    Cents    uint   `json:"cents"`
    Currency string `json:"currency"`
}

您可以注意到 ProductView 是在 pkg/order/infrastructure/shop/http.go(上面的示例)中导入的,因为正如我前面所说的,在不同的限界上下文之间,将interfaces导入到infrastructure是没问题的。

单体架构

在单体版本中,它非常简单: 在 Orders 限界上下文中,我们只是从 Shop 限界上下文中调用函数(intraprocess.ProductInterface:ProductByID),而不是调用 REST API。

// pkg/orders/infrastructure/shop/intraprocess.go

import (
    "github.com/ThreeDotsLabs/monolith-shop/pkg/orders/domain/orders"
    "github.com/ThreeDotsLabs/monolith-shop/pkg/shop/interfaces/private/intraprocess"
)

type IntraprocessService struct {
    intraprocessInterface intraprocess.ProductInterface
}

func NewIntraprocessService(intraprocessInterface intraprocess.ProductInterface) IntraprocessService {
    return IntraprocessService{intraprocessInterface}
}

func (i IntraprocessService) ProductByID(id orders.ProductID) (orders.Product, error) {
    shopProduct, err := i.intraprocessInterface.ProductByID(string(id))
    if err != nil {
        return orders.Product{}, err
    }

    return OrderProductFromIntraprocess(shopProduct)
}

在Shop的限界上下文中:

// pkg/shop/interfaces/private/intraprocess/products.go

type ProductInterface struct {
    repo products.Repository
}

// ...

func (i ProductInterface) ProductByID(id string) (Product, error) {
    domainProduct, err := i.repo.ByID(products.ID(id))
    if err != nil {
        return Product{}, errors.Wrap(err, "cannot get product")
    }

    return ProductFromDomainProduct(*domainProduct), nil
}

您可以注意到,在 Orders 限界上下文中,我们不会导入 Shops 限界上下文之外的任何内容(正如 整洁架构 假设的那样)。所以,我们需要一些可以被导入Shops限界上下文的传输类型(transport type)。

type Product struct {
    ID          string
    Name        string
    Description string
    Price       price.Price
}

它可能看起来多余且重复,但在实践中,它有助于在限界上下文之间保持恒定的契约。例如,我们可以完全替换Application层和Domain层而不要修改这种类型。您需要记住,避免重复的成本随着规模的增加而增加。此外,数据的重复不等同于行为的重复。

您是否在微服务版本中看到类似于 ProductView 的内容?

初始化付款

在前面的示例中,我们用一个函数调用替换了 HTTP 调用,该函数调用是同步的。但是如何处理异步操作呢?看情况。在 Go 中,由于采用了并发原语,所以很容易实现。如果在你的语言中很难实现,你可以使用 Rabbit MQ。

与前面的示例一样,两个版本在应用程序和域层中看起来是相同的。

// pkg/orders/application/orders.go

type paymentsService interface {
    InitializeOrderPayment(id orders.ID, price price.Price) error
}


func (s OrdersService) PlaceOrder(cmd PlaceOrderCommand) error {
    // ..

    if err := s.paymentsService.InitializeOrderPayment(newOrder.ID(), newOrder.Product().Price()); err != nil {
        return errors.Wrap(err, "cannot initialize payment")
    }

    // ..
}
微服务

在微服务中,我们使用 RabbitMQ 发送消息:

// pkg/orders/infrastructure/payments/amqp.go

// ...

func (i AMQPService) InitializeOrderPayment(id orders.ID, price price.Price) error {
    order := payments_amqp_interface.OrderToProcessView{
        ID: string(id),
        Price: payments_amqp_interface.PriceView{
            Cents:    price.Cents(),
            Currency: price.Currency(),
        },
    }

    b, err := json.Marshal(order)
    if err != nil {
        return errors.Wrap(err, "cannot marshal order for amqp")
    }

    err = i.channel.Publish(
        "",
        i.queue.Name,
        false,
        false,
        amqp.Publishing{
            ContentType: "application/json",
            Body:        b,
        })
    if err != nil {
        return errors.Wrap(err, "cannot send order to amqp")
    }

    log.Printf("sent order %s to amqp", id)

    return nil
}

并接收消息:

// pkg/orders/interfaces/public/http/orders.go

// ...

type PaymentsInterface struct {
    conn    *amqp.Connection
    queue   amqp.Queue
    channel *amqp.Channel

    service application.PaymentsService
}

// ...

func (o PaymentsInterface) Run(ctx context.Context) error {
    // ...

    for {
        select {
        case msg := <-msgs:
            err := o.processMsg(msg)
            if err != nil {
                log.Printf("cannot process msg: %s, err: %s", msg.Body, err)
            }
        case <-done:
            return nil
        }
    }
}

func (o PaymentsInterface) processMsg(msg amqp.Delivery) error {
    orderView := OrderToProcessView{}
    err := json.Unmarshal(msg.Body, &orderView)
    if err != nil {
        log.Printf("cannot decode msg: %s, error: %s"string(msg.Body), err)
    }

    orderPrice, err := price.NewPrice(orderView.Price.Cents, orderView.Price.Currency)
    if err != nil {
        log.Printf("cannot decode price for msg %s: %s"string(msg.Body), err)

    }

    return o.service.InitializeOrderPayment(orderView.ID, orderPrice)
}
单体架构

在单体版本发送到channel,简单到不能更简单了

// pkg/orders/infrastructure/payments/intraprocess.go

type IntraprocessService struct {
    orders chan <- intraprocess.OrderToProcess
}

func NewIntraprocessService(ordersChannel chan <- intraprocess.OrderToProcess) IntraprocessService {
    return IntraprocessService{ordersChannel}
}

func (i IntraprocessService) InitializeOrderPayment(id orders.ID, price price.Price) error {
    i.orders <- intraprocess.OrderToProcess{string(id), price}
    return nil
}

以及接收(我删除了关机(关闭)支持,以免代码过于复杂) :

// pkg/payments/interfaces/intraprocess/orders.go

// ...

type PaymentsInterface struct {
    orders            <-chan OrderToProcess
    service           application.PaymentsService
    orderProcessingWg *sync.WaitGroup
    runEnded          chan struct{}
}

// ..

func (o PaymentsInterface) Run() {
    // ...

    for order := range o.orders {
        go func(orderToPay OrderToProcess) {
            // ...

            if err := o.service.InitializeOrderPayment(orderToPay.ID, orderToPay.Price); err != nil {
                log.Print("Cannot initialize payment:", err)
            }
        }(order)
    }
}

更多…

将订单标记为已支付几乎与下订单一样(REST API/函数调用)。如果你好奇它是如何工作的,请查看完整的源代码。

完整的代码可以在这里找到: https://github.com/ThreeDotsLabs/monolith-microservice-shop

我已经实现了一些验收测试,这些测试将检查所有流程对于单体和微服务的工作方式是否完全相同。测试可以在tests/acceptance_test.go中找到。

您可以在 README.md 中找到关于如何运行项目和测试的更多信息。

还有一些这里没有提到的代码,如果你想更深入地了解这些代码,请在 Twitter 上关注我(@roblaszczak)或订阅我们,当文章准备好时,我们会通知你。如果你还不知道 Go语言 的一些基本概念,你可以学习一下,而我也将使这个代码更生产级。

我们还计划撰写一些DevOps文章(Packer,Terraform,Ansible)

总结

微服务的另一个优势是什么?您可以独立地部署单体应用,但它的灵活性将会降低,您必须使用不同的流程。例如,您可以使用特性分支,其中主分支将用于生产,未发布更改的提交应该合并到这个特性分支中。这些分支应该在临时环境中使用。单体更难以扩展,这是事实,因为您必须扩展整个应用程序,而不是单个模块。但在许多情况下,这已经足够好了。在这种情况下,我们不能为每个代码库分配一个团队,但幸运的是,如果模块分离得很好,就不会有太多冲突。唯一的冲突将发生在负责跨模块通信的层中,但同样的冲突也发生在 REST/Queue API 中。在单体中,它将有编译检查,不像在微服务中,您必须验证API兼容性,需要额外的工作。此外,使用单体架构,您将收到共享类型的编译检查。模块(微服务架构中的微服务)的快速重写是一个很好的模块分离问题-如果模块被正确地分离和设计,您可以在不触碰其他任何东西的情况下替换它。

我再重复一遍: 没有什么银弹。但在大多数情况下,使用整洁单体来开始已经足够了。如果设计良好,转向微服务架构将不是什么问题。
在某些情况下(例如小型项目) ,为接口层/基础设施层编写这些额外的代码可能有些过分。但什么是小项目呢?看情况…

在开始实施之前要做好计划。没有“缺乏设计”,只有好的设计和坏的设计。事件风暴是启动项目的一个好主意。

我知道我已经介绍了很多技巧,其中很大一部分对你来说可能是新的。好消息是,要拥有好的架构,您不需要同时学习所有这些技术。同时也很难正确理解这些技术。我建议从 Clean Architecture 开始,然后查看一些 CQRS 的基础知识,然后就可以进入 DDD 了。在本文的最后,我将提供一些有用的资源,这些资源是我在学习这些技术时使用过的。

如果你有任何问题,请在 Twitter 上私信我。

延伸阅读

整洁架构

鲍勃大叔的文章:

  • https://8thlight.com/blog/uncle-bob/2011/11/22/Clean-Architecture.html
  • https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html

事件风暴

  • 这项技术的创造者的电子书,还没有完成,但已经足够让你理解这项技术,并在实践中使用它: https://leanpub.com/introducing_eventstorming

DDD

  • 很好的讲座来理解 DDD 的基础知识,对于非技术人员来说也是一个很好的方向: https://www.amazon.com/Domain-Driven-Design-Distilled-Vaughn-Vernon/dp/0134434420
  • 如果您了解 DDD 的理论基础,您可以看到如何实现它的实际例子: https://www.amazon.com/Implementing-Domain-Driven-Design-Vaughn-Vernon/dp/0321834577

Go

Go是一门非常简单的语言,我建议通过以下例子来学习: https://gobyexample.com/。不管你信不信,一个晚上就足够了解Go了。

以及一些其他的内容…

  • 关于微服务(以及单体服务)的一些重要(经常被遗忘)的想法——数据: http://blog.christianposta.com/microservices/the-hardest-part-about-microservices-data/
  • 这是一篇很棒的文章,有助于理解 DDD、 Clean Architecture 和 CQRS 应该如何协同工作: https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/
  • 斯蒂芬•蒂尔科夫(Stefan Tilkov)认为,为什么我们不应该从单体开始,这说明了为什么即使我们想要构建一个单体应用,耦合也是不好的: https://martinfowler.com/articles/dont-start-monolith.html

原文链接: https://threedots.tech/post/microservices-or-monolith-its-detail/
原作者: Robert Laszczak
译者: 赵不贪

参考资料

[1]

Martin Fowler: 一名英国的软件工程师,也许你看过他出版的其中一本书:《重构:改善既有代码的设计》,豆瓣评分9.3。

[2]

鲍勃大叔: 著有《代码整洁之道》《架构整洁之道》等

[3]

Domain Driven Design: 领域驱动设计

[4]

限界上下文: Bounded Context,领域驱动设计内的概念

[5]

go-cleanarch: https://github.com/roblaszczak/go-cleanarch

[6]

Futuramo: https://futuramo.com/

[7]

整洁架构: https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html


原文始发于微信公众号(梦真日记):为什么说微服务架构或单体架构仅仅是实现上的细节

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

文章由半码博客整理,本文链接:https://www.bmabk.com/index.php/post/167788.html

(0)
小半的头像小半

相关推荐

发表回复

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