Microservices陷阱: 实践篇(服务建模和集成)

总的来说,微服务并不是指某一个具体的技术。推崇者认为它得益于Eric Evans的领域驱动设计、持续交付、Alistair Cockburn的六边形架构、基础设施可视平台和小型全功能团队等众多近年内不断涌现的高效实践。ThoughtWorks的Sam Newman认为,微服务包含两方面含义:小且只专注做一件事,自治性。

1.服务建模

在概念篇中我们已经提到,服务其实就是系统中的组件。容易理解的是,好的服务设计也就是常说的“高内聚、松耦合”。意味着多个服务之间的低依赖性、以及服务内部的行为紧相关性。这种特征使得代码变更带来的影响面尽可能小,同时实现快速简单部署。那么服务建模的目的就在于寻找问题域中的边界,划分出相关行为,同时保证边界间通信的低依赖性。 微服务推荐DDD的设计思想,鼓励在构建系统时参考真实世界中的领域,而不是套用分层架构。其中最重要的概念就是边界上下文——即任何领域都可以被认为是由许多边界上下文构成,其内部容纳着具体的物(模型),其中有的物无需和外界进行通信,有的物则与其它边界上下文共享。每个边界上下文都包含一个显式接口,决定哪些物共享出去的。

边界上下文的另一个简单解释就是“一种被显式边界强化的特定职责”。当你想和边界上下文内部进行通信,或是请求其内部的某些功能时,你需要携带模型和它的显式边界进行通信。Evan把边界上下文比喻为细胞,而决定物质进出的细胞膜就是显式边界。微服务把边界上下文和服务紧密联系在一起,尽管边界上下文可以是任何一种组件或模块,其可以存在于单一系统中,也可以存在于独立进程中。而后者也就是微服务的例子。

然而过早确定边界是不明智的,特别是在初期,一旦边界不稳定,就会造成更多的修改和成本增加。比较好的方法是在一个成熟codebase上划分边界,而非一开始就这么做。

在划分边界上下文时,需要注意该上下文对领域内其余部分提供的业务能力,而这种业务能力通过共享模型实现信息交换。而如果对单纯的CRUD操作划分边界,失去了它的业务意义,则完全没有必要。

在实践中,一般是自顶向下划分出边界上下文,粒度也应当是由粗到细。然而在演进的过程中,是否独立出内嵌的上下文,则取决于设计和团队等因素,如果某个团队同时负责若干个上下文,则可能会把它们组合成一个较大的上下文而非全部分离出来。这种内嵌上下文的另一个好处在于,较粗的架构能简化测试,特别是针对庞大复杂的end-to-end测试。

2.服务集成

服务集成是微服务架构中最重要、技术最相关的部分。一般存在两种风格:编配和编排,编配是指通过一个中心服务以同步通信的方式管理其它服务,编排则通过事件队列实现异步通信,相关服务自己监听相应事件。前者的优点是结构明确、易于开发,缺点是变更成本高、难以维护;后者则实现了松耦合,缺点是难以调试,需要额外的监控代价。 无论采用何种风格,都避免不了集成方法的选择。一般可用的集成方式有SOAP、XML-RPC、REST、Protocol Buffer等,但这些方法有一定的针对性。值得注意的是,编配风格的架构更易于构建,特别是带语义的请求/响应类型的通信,同步方式是最简洁的实现。相比之下,编排风格的架构就需要额外的callback机制处理响应数据。

请求/响应类

目前请求/响应类通信存在两种流行的方式:RPC和REST,其特点各不相同。

RPC包括多种技术,如SOAP、Java RMI、Thrift、Protocol Buffer等。其中SOAP采用XML格式的协议构建消息,其余则为纯二进制形式。 一些RPC技术仅限于某种特定领域,如Java RMI,必须运行在JVM中。尽管有些不限定语言或平台,但依然会带来或多或少的集成限制。 RPC的另一个缺点是过于“底层”,除SOAP外,几乎都直接基于TCP/UDP实现,然而由于网络环境的限制,应用不得不考虑可靠性、容错性、语义性。 在设计上,RPC方式如Java RMI过度依赖于服务接口和Model实现,任意变更都很可能影响服务端、客户端以及相关测试代码,并继而影响后续的测试和部署,这就是lock-step release。当然Thrift和Protocol Buffer为此改进了许多。

与RPC从底层构建不同的是,现代REST提供了更为抽象的架构方式。一般来说,REST over HTTP是最易于实现的一种REST,但不是唯一途径,本文只就REST over HTTP展开讨论。

REST over HTTP的好处在于,二者拥有许多能够相互对应的概念,如动词GET、POST和PUT,其含义是十分明确的。此外,HTTP拥有极为完整的生态系统,从缓存代理Varnish、负载均衡modproxy、以及监控系统,这就使得构建系统变得十分高效。另外,HTTP还支持较完整的安全控制机制,从基本验证到客户端证书等等。 值得一提的是,SOAP同样基于HTTP,然而却抛弃了大量HTTP中极富内涵的概念,使得学习成本、系统复杂度都大大增加。 REST over HTTP也存在缺点,一、生成客户端stub的成本较高,超媒体控制客户端很可能以共享库的方式在微服务中广泛使用,从而导致变更困难;二、HTTP动词并没有得到服务器端的良好支持,但是通常有绕过的办法解决该问题;三、HTTP连接是基于TCP的,因此即使是JSON或二进制数据传输,性能也比不上Thrift这种纯二进制协议,对于低延迟的应用就更是如此。

异步事件类

事件处理需要考虑两方面内容:服务端发送和客户端接收。 传统的消息队列如RabbitMQ,试图一次解决上述问题。发送者通过API向队列中发送事件,队列处理针对这些事件的订阅,向接收者发出通知,甚至还能处理接收者状态,例如帮助跟踪历史消息。然而类似的中间件系统目前变的越来越臃肿,厂商向其注入了更多智能化组件,这不得不让人联想到ESB的情形。因此,时刻保持哑管道和智能终端,是引入中间件技术时必须要考虑的问题。 除此之外还有一个特殊选择:ATOM。ATOM本身就是基于REST的服务发布协议,由于开发库较完备,事实上也可被当作事件发布/订阅工具。同样,基于HTTP的架构具有良好的扩展性,却不适用低延迟应用。

尽管事件驱动架构具有诸如松耦合、可扩展等优点,但其带来的系统复杂性也随之大幅提高。或者至少应当着重注意监控过程,特别是跟踪跨服务边界的请求。Enterprise Integration Patterns是目前服务集成领域的权威著作。

3. 服务集成需要注意的问题

服务即状态机

无论是否采用REST,服务即状态机都是一种强大的思维模式。其核心在于,构建具有尽可能丰富功能的服务,而非将部分功能划分至服务外,例如管道甚至消费者一边,这会丧失高内聚的特性。举个例子,当消费者向提供者发送更新请求时,应由提供者一方决定是否接受这次修改,而非由消费者决定,从而保证数据和行为的一致性。

重新思考DRY

我们都清楚DRY的含义,并且了解DRY带来的好处。然而,在微服务架构中,服务间的代码共享可能会导致修改灾难,一般而言,应当允许一定的重复代码存在于不同服务中,而非使用统一的共享库。当然,微服务内部应遵循DRY原则。 一个例外是对客户端库的设计,客户端库能够方便保持整个系统的可靠性和可扩展性,例如引入服务发现、请求失败、日志等通用特性,这种DRY是有益的。但应注意保证客户端库不被具体的服务逻辑污染这一原则。

引用访问

当在服务请求间传递领域实体时,需要考虑值传递或引用传递两种方式。一般而言,消费者并不需要保证自己获取的资源是最新的。但某些特殊需求可能会有此限制,这时就需要在获取资源时同时取得该资源的引用方式,从而使消费者时刻能获取其最新的状态。当然,频繁的资源请求也会导致系统负载增加,这里我们需要考虑是否一定要实现实时更新,如果不是,则可以利用HTTP自身的缓存控制机制减轻负载;或者根据不同需求只传递需要的资源属性。

版本化

版本化会引入更多复杂性,因此在对服务接口进行变更时,尽量避免采用版本机制。如果一定要采用,应分配语义化版本,一般遵循MAJOR.MINOR.PATCH即可。这种令不同版本接口共存的做法,能够尽量减轻消费者的迁移压力。当然如果项目足够小,只需要保持新旧接口同时存在就好,版本化就太过了。

用户界面集成

用户界面集成几乎是任何系统都必须要考虑的问题,微服务中尤为如此。传统上,不同服务可能会有各自的UI组件,并嵌入在页面相应位置。然而该方法需要考虑用户体验的一致性,同时,由于要适应不同设备的需求,各UI组件还需要考虑响应式的问题。此外,有些服务并不能以单个UI组件的方式提供给用户,而可能存在与其它服务协作的情况。因此,用户界面最有效的构建方法就是前端负责UI,后端负责提供API,互不干涉。 整块前端的做法并不利于降低开发的复杂度。为了解决这一问题,采用API网关技术,针对不同设备的请求返回不同的内容聚集。然而API网关可能会导致该层过于复杂,有时会适得其反。一种轻量的解决方法是前端+特定后端技术,即不采用整块API网关,而是针对不同设备构建特定的后端支持系统,尽量减轻复杂度。

集成第三方应用

采用第三方应用有时会显著降低成本,最简单的就是直接集成到现有微服务系统中,通过暴露应用API从而向其它角色提供服务。更进一步,如果能够抽象出系统角色,就能借助角色层连接微服务和该第三方应用。

Comments