可分解的巨石:巨石长存,服务永存!

导航到

这是我思考了一段时间的想法,我很想知道其他人对此的看法。现在大家都在谈论微服务,但在大多数项目中,都有一个从巨石到基于服务的架构的过渡点。我认为对于新的项目,你应该几乎总是从巨石开始。七年前,当我在《用Ruby进行面向服务的架构设计》中提到这一点时,我就是这么说的:如果你有任何避免服务的方法,就构建一个巨石应用。然而,后来转向服务可能相当困难,因此在这篇文章中,我将探讨巨石的优点,并提出一种可以结构化新项目的方法,使其以后可以分解为服务。我称这种设计/架构模式为“可分解的巨石”。为了帮助说明这个想法,我们将通过使用Go作为实现语言和下一代数据平台(例如用于时序数据的平台)作为应用程序的例子来阐述。

巨石的论点

在大多数新的软件项目中,你面临的最大风险是构建出没有人想要的东西。要么它不符合需求,要么你没有真正理解用户的需求,所以一开始就提出了错误的需求。在项目的早期阶段,功能速度和与用户的迭代比几乎任何其他因素都重要。即使规模对项目很重要,但如果你的功能和功能组合不正确,所有规模都将无关紧要。

单体应用程序的开发和迭代速度要快得多。例如,考虑一个被拆分为三个服务与一个单体应用程序相比的情况。在单体应用程序中,当你更新任何部分的代码和逻辑时,只需部署和测试一个东西。而在基于服务的设计中,你必须更新服务的API,测试它,更新客户端库,然后更新使用该服务的核心应用程序以使用更新的库。最后,你需要运行一个完整的集成测试套件,将所有东西连接在一起。将这种复杂性乘以你正在处理的可能具有变化需求的服务的数量。任何不完整且未冻结的功能的服务都将成为一个需要迭代和解决的问题的新层。

单体应用程序可以减少团队内部的通信和文档负担。使用服务时,你必须对客户端、API和服务中的代码进行文档编制和沟通,而单体应用程序中只有代码。拥有独立的服务和存储库会形成隔阂和沟通边界。在大型成熟项目中,尤其是有众多开发者的项目中,这些隔阂是一种优势。它们成为扩展团队和代码库的同时,在组件之间实现一些隔离的方式。然而,在一个需要功能速度和快速迭代以梳理需求的新项目中,通信负担和隔阂成为一种负担,会拖慢你的进度。

最后,单体应用程序的优势在于它们通常更容易设置、测试和调试。开发者可以启动单个项目并快速运行。如果有错误,他们不需要跳过分布式系统和多个存储库的复杂性来修复。虽然跟踪可能在基于服务的架构中帮助这种调试,但这有一定的实施成本和开销。

设计可分解的单体应用程序

在我们设计下一代InfluxData平台时,我开始思考这种模式。我们将把InfluxDBKapacitor(处理、监控、警报)和Chronograf(用户界面、仪表板、管理)整合成一个统一的整体(《Telegraf》将仍作为卫星收集代理)。从一开始,它将被设计为一个多租户系统,不仅提供存储和查询时间序列数据的数据库,还将提供更多功能。

产品面向用户的部分将是一个API,通过REST或gRPC提供,还有一个简洁的Web用户界面。背后的实现方式取决于部署场景。在云端,它看起来像一个由许多服务组成的复杂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进行集成

既然我们已经概述了在服务模型中我们希望迁移到的基础知识,让我们创建一个代码组织结构,以便以后我们可以将其分解。这个代码将仅仅是一个想法的草图。我将主要使用接口和结构体,中间有一些大致的描述。关于如何组织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 {}

在一个完全功能的示例中,当然会有更多的代码和更多的包,但这个骨架应该足以说明想法。服务器结构体是我们与API和业务逻辑相关的所有代码的存放地点。

请注意,对于初始化它,我们采用在此包中定义的接口。这是Go概念之一,我花了一点时间才真正理解:消费者应该定义接口,而不是实现。这与在Java中思考事物的方式相反。采用这个概念,我们可以为这些接口提供不同的实现。

在早期的单体中,只会使用内存或本地磁盘实现。我添加了内存,以显示你可以有一个简单的测试实现,该实现不持久化任何内容。或者在像tasker这样的某些情况下,你可能想在单体应用程序中使用内存,因为定期任务可以在启动时从KVStore重新加载。

对于接受时间序列数据的写入接口,本地tsdb可以同时作为写入器和查询器接口。稍后,当将它们拉入服务时,每个部分可以分别实现。Kafka的薄包装器可以作为写入器,而水平可扩展的IFQL查询服务可以作为查询器。

这个仓库中这些单个组件的实现不会实现服务。它们只负责包装服务客户端,以确保它符合接口,并添加我们需要的任何其他类型的监控和可观察性钩子。

设计目标之一是拥有更低级别的构建块服务。这使得使用etcd、Nats.io等知名项目的薄包装器,或者AWS、Google Cloud或Azure等云服务变得更容易。在cmd目录中,每个不同的运行程序都会使用使用进程内单服务器实现、服务或某些其他混合的实现来初始化服务器实例。

结论

在设计单体时提前考虑服务,可以使你能够构建一个单体,以后可以扩展到服务。我认为这可能在早期带来更好的特性速度,并具有后期同时拥有两种实现的优势。

缺点是,一旦创建了基于服务的解决方案,就需要维护任务来保持单体更新。我们目前正在评估使用下一代平台和云产品进行类似操作,但这还处于早期阶段。然而,能够在同一代码库中同时拥有单体应用程序(许多用户希望拥有)和更可扩展的服务是很有吸引力的。

我的同事中有位在审阅这篇帖子时指出,这与其说是将单体应用程序拆分,不如说是基于API的设计。您创建一个用户友好的API,它是有用的且具有固定的合同,这样就可以在表面之下自由迭代。然而,我对这件事的看法略有不同。通常,当您替换单体应用程序时,您几乎在API级别以下进行完全的重写。我采用这种方法的目标是在事前考虑组件如何被抽离为服务,并设计单体应用程序的代码库,以便API之下的底层组件和库可以用服务来替换。