可分解的单体架构:单体架构万岁,服务万岁!
作者:Paul Dix / 开发者
2017 年 12 月 21 日
导航至
这是我思考了一段时间的想法,我很想知道其他人对此有何看法。现在每个人似乎都在谈论微服务,但在大多数项目中,都存在从单体架构到基于服务的设计的过渡点。我坚持认为,对于新项目,您几乎总是应该从单体架构开始。七年前,当我写到 使用 Ruby 进行面向服务的设计 时,我就说过:如果可以避免服务,请构建单体应用程序。但是,稍后转换为服务可能会非常棘手,因此在这篇文章中,我将探讨单体架构的优势,同时提出一种构建新项目的方法,该方法有助于稍后将其分解为服务。我将这种设计/架构模式称为可分解的单体架构。为了帮助说明这个想法,我们将通过一个示例,使用 Go 作为实现语言,并将下一代数据平台(例如用于时间序列数据)作为应用程序。
单体架构的理由
对于大多数新的软件项目,您面临的最大风险是构建没有人需要的东西。要么它不符合要求,要么您不了解用户真正需要什么,因此一开始就提出了错误的要求。在项目的早期阶段,功能迭代速度和与用户的迭代比几乎任何其他因素都重要。即使规模在您的项目中很重要,但如果您没有正确的功能和功能组合,那么再大的规模也无关紧要。
单体应用程序的开发和迭代速度要快得多。例如,考虑一个应用程序,它被分解为三个服务与一个单体应用程序。在单体架构中,当您更新任何部分背后的代码和逻辑时,只有一个东西需要交付和测试。在基于服务的设计中,您必须更新服务中的 API,对其进行测试,更新客户端库,然后更新使用该服务的主应用程序以使用更新后的库。最后,您需要运行一套完整的集成测试,将所有内容联系在一起。将这种复杂性乘以您正在处理的可能具有变化要求的服务数量。任何未完成且未冻结的功能服务都会成为一个新的迭代和处理层。
单体架构减少了团队内部的沟通和文档开销。使用服务,您必须记录和沟通关于客户端、API 和服务中的代码的信息,而单体架构只有代码。拥有独立的服务和存储库会造成孤岛和沟通障碍。在大型成熟的项目中,特别是那些拥有许多开发人员的项目中,这些孤岛是一种优势。它们成为扩展团队和代码库的一种方式,同时在组件之间实现某种隔离。但是,在一个需要功能迭代速度和快速迭代以梳理需求的新项目中,沟通开销和孤岛成为一种阻碍您前进的负担。
最后,单体架构的优点是它们通常更容易设置、测试和调试。开发人员可以运行单个东西并开始工作。如果出现错误,他们不必跳过分布式系统和多个存储库的复杂性来修复。虽然跟踪可能有助于在基于服务的设计中进行这种调试,但它具有实现成本和开销。
设计可分解的单体架构
在设计下一代 InfluxData 平台 时,我一直在思考这种模式。我们将把 InfluxDB、Kapacitor(处理、监控、警报)和 Chronograf(UI、仪表板、管理)整合到一个有凝聚力的整体中(Telegraf 仍将充当卫星收集代理)。从一开始,它将被设计为一个多租户系统,它提供的不仅仅是一个用于存储和查询时间序列数据的数据库。
该产品的面向用户部分将是一个 API,通过 REST 或 gRPC 呈现,以及一个简洁的 Web UI。这些面向用户部分背后的内容取决于部署场景。在云中,它将看起来像一个复杂的 SaaS 应用程序架构,由许多服务组成。在开发人员的笔记本电脑上,它可能看起来像一个单一的二进制单体应用程序,它公开相同的 API 和 UI。在企业内部部署中,它可能是其他一些架构。
这个想法是,云平台将解决可扩展性和处理多个租户的问题,而单体架构将为开发人员或用户提供一致的体验,无论是在他们的笔记本电脑、单个部署的服务器还是我们的云平台上工作。理想情况下,他们应该能够无缝地将数据从一个地方移动到另一个地方,并从 API 的角度以相同的方式处理数据。支持这种部署场景是我最初提出这种结构的动机。这可能只是创建用户 API 并从那里向下设计的问题,但我也在尝试思考如何在库、服务和测试套件中获得最佳的代码重用以支持两者。
好的,让我们深入研究一个示例,以便我们可以开始思考如何设计这个东西。首先,我们从我们想要支持的 API 开始。我们将使用 gRPC 规范,仅包含服务定义。我使用 gRPC 是因为它是一种很好的声明式 API 定义方式。将其转换为 RESTful 接口是稍后的练习。这是服务
// The user facing service definition
service API {
// Some of the multi-tenant stuff
rpc CreateOrganization(Organization) returns (Response) {}
rpc DeleteOrganization(Organization) returns (Response) {}
rpc CreateDB(DBRequest) returns (Response) {}
rpc DropDB(DBRequest) returns (Response) {}
// InfluxDB storage/query API
rpc Write(WriteRequest) returns (Response) {}
rpc Query(QueryRequest) returns (stream QueryResponse) {}
// Kapacitor batch tasks
rpc RunPeriodicQuery(PeriodicQueryRequest) returns (Response) {}
rpc StopPeriodicaQuery(PeriodicQueryRequest) returns (Response) {}
rpc GetPeriodicQueryStatus(PeriodicQueryRequest) returns (PeriodicQueryStatus) {}
// Kapacitor streaming tasks
rpc RunStreamQuery(StreamQueryRequest) returns (Response) {}
rpc StopStreamQuery(StreamQueryRequest) returns (Response) {}
rpc GetStreamQueryStatus(StreamQueryRequest) returns (StreamQueryStatus) {}
}
出于本示例的目的,我们实际上不需要考虑请求和响应中的消息。这只是我们将要做的总体工作的一个子集,但足以说明这些概念。
我们有组织、数据库、写入、查询和在后台运行的 Kapacitor 任务的概念。如果我们正在构建一个单体应用程序,我们可以简单地从这些项目中获取库并将它们拼凑成一个公开此 API 的二进制文件。
但是,这个单体架构的目标是我们希望能够灵活地稍后将其分解为分布式服务。因此,与其直接跳到实现单体架构 API,我们可以考虑面向服务的设计可能是什么样子。这是一个可能看起来像什么样的图表
在这个例子中,我创建了一些作为基本构建块的服务,例如持久写入队列、一致的键/值存储和任务队列。然后还有一些特定于域的服务,例如时间序列存储,以及三种不同类型的查询处理。API 和特定于域的服务使用共享的构建块将所有内容组合在一起。
更传统的服务设计可能如下所示
但是,我认为有时最好考虑提供更多原子构建块的服务,这些构建块可以被 API 和其他更特定于域的服务使用。我认为这就是为什么许多 AWS 服务如此成功的原因。
回到第一个图表,让我们深入了解它可能如何工作的一些细节。写入将进入写入队列(类似于 Kafka,它经常被用作分布式数据库和用例的预写日志)。API 层将存储组织信息、存在哪些流式查询和定期查询以及一致的键值存储中存在哪些数据库。这些数据将可以通过 API 访问其他服务,而不是直接访问键值存储。
此图表中的任务队列只是一个简单的内存队列,用于将定期查询处理的作业分发给任意数量的工作人员。
此图表中的查询处理层与存储层解耦。这使其具有水平可扩展性、高弹性,并使我们能够更好地控制以实现租户和资源隔离,以用于查询工作负载(所有这些都是 我们新的 IFQL 查询引擎的设计目标)。这也使得将用户发出的交互式查询与定期运行的后台查询隔离开来变得容易。
在 Go 中组合在一起
现在我们已经 laid out 了我们认为我们想要在服务模型中移动到的基本知识,让我们一起构建一个代码组织结构,使我们能够在以后分解事物。这段代码将仅仅是这个想法的草图。我将主要坚持使用接口和结构,并在中间进行一些挥手。有关组织 Go 代码库的优秀入门知识,请阅读 Ben Johnson 关于在 Go 中构建应用程序的优秀文章。
我们将应用程序分解为如下目录/包结构
influx/
cmd/
influx-mono/
main.go
influx-service-based/
main.go
tsdb/
in-memory/
local-disk/
service/
kvstore/
in-memory/
bolt/
etcd/
taskq
in-memory/
nats/
stream
in-memory/
service/
server.go
api.proto
我们有 influx 作为顶级包。架构图中有一些概念的子包。cmd 包是我们将在其中保留主运行程序的地方,这些运行程序初始化 API 的每个实现。服务的实现和运行程序将存在于它们自己的存储库中。同时,此存储库中的服务目录(如 tsdb/service)将导入该服务的客户端库,并实现 API 需要的任何包装器,以与预期的接口保持一致。这些内部 API 的一个设计考虑因素是,导出的函数将有望从进程内单体架构实现转移到服务。因此,请考虑它们可能会传递网络边界,并且需要考虑它们的聊天程度。
API 定义、服务器和所有其他与实现此 API 相关的逻辑(无论使用哪个服务)都将存在于顶级目录中。让我们看一下接口和一些将进入 service.go 的 shell 代码
// Time series storage interfaces
// Writer writes time series data to the store
type Writer interface {
Write(context.Context, WriteRequest) error
}
// Querier will execute a query against the store
type Querier interface {
Query(context.Context, QueryRequest) (<-chan QueryResponse, error)
}
// Note that the functions on all interfaces take context objects.
// This is intentional because they could either be in-process/in-memory
// implementations or they could be network services.
// Key/value storage interface
type Key []byte
type Value []byte
type Pair struct {
Key Key
Value Value
}
type KVStorer interface {
Put(context.Context, []Pair) error
Get(context.Context, Key) (Value, error)
Delete(context.Context, Key) error
RangeScan(context.Context, Key) (KVScanner, error)
}
type KVScanner interface {
Next() ([]Pair, error)
}
// Tasker is an interface for running periodic query tasks
type Tasker interface {
Queue(context.Context, PeriodicQuery) error
Stop(context.Context, PeriodicQuery) error
Status(context.Context, PeriodicQuery) (PeriodicQueryStatus, error)
}
// Server implements the gRPC API
type Server struct{}
// NewServer takes interfaces for initialization so they can
// be replaced with local in process implementations or services
// that call over the network.
func NewServer(w Writer, q Querier, store KVStorer) *Server {}
在一个功能齐全的示例中,肯定会有更多的代码和更多的包,但是这个骨架应该足以说明这个想法。server 结构是我们所有用于处理 API 和业务逻辑的代码所在的地方。
请注意,为了初始化它,我们采用了此包中定义的接口。这是 Go 概念之一,我花了一段时间才将其内化:消费者应该定义接口,而不是实现。这与您在 Java 中考虑事物的方式是相反的。采用这个概念,我们可以为这些接口提供不同的实现。
在早期的单体架构中,只会使用内存中或本地磁盘实现。我添加了内存中以表明您可以拥有一个简单的测试实现,它不持久化任何东西。或者在某些情况下,例如 tasker,您可能希望在单体应用程序中使用内存中,因为定期任务可以在启动时从 KVStore 重新加载。
对于采用时间序列数据的 writer 接口,本地 tsdb 可以同时充当 writer 和 querier 接口。稍后,当将事物拉入服务时,每个部分都可以单独实现。Kafka 的一个薄包装器可以充当 writer,而 IFQL 查询服务的水平可扩展服务可以用于 querier。
这些单独组件在此存储库中的实现不会实现服务。它们将仅负责包装服务的客户端,以确保它符合接口并添加我们在分布式系统中需要的任何其他类型的监控和可观察性挂钩。
设计目标之一是拥有作为较低级别构建块的服务。这使得更容易在其他知名项目(如 etcd、Nats.io 甚至 AWS、Google Cloud 或 Azure 中提供的基于云的服务)周围使用薄包装器。在 cmd 目录中,每个不同的运行程序都将使用使用进程内单服务器实现或服务或其他混合的实现来初始化服务器实例。
结论
在预先思考服务的同时设计您的单体架构,可以使您能够构建一个单体架构,以便稍后扩展到服务。我认为这可能有助于早期更好的功能迭代速度,并且具有稍后同时拥有两种实现的优势。
缺点是一旦您创建了基于服务的解决方案,保持单体架构的更新就变成了一项维护任务。我们目前正在评估在我们的下一代平台和云产品中做类似的事情,但这仍然非常早期。然而,在同一代码库中同时拥有单体应用程序(我们的许多用户都想要)和更可扩展的服务应用程序的能力非常吸引人。
一位审阅此帖子的同事指出,这似乎不像分解单体架构,而更像是 API 驱动的设计。您创建一个有用的且具有固定契约的面向用户的 API,这使您可以自由地在表面之下进行迭代。但是,我对这个问题的看法略有不同。通常,当您替换单体架构时,您几乎会在 API 级别以下进行完全重写。我采用这种方法的目的是提前思考组件将如何被拉入服务,并设计单体架构的代码库,以便 API 下方的较低级别组件和库可以被服务替换。