乐者为王

Do one thing, and do it well.

共享代码的风险

英文原文:https://www.innoq.com/en/blog/the-perils-of-shared-code/

通往地狱的道路往往是由良好的意愿铺就。在各种软件项目中,我看到人们走在这样的道路上,他们在微服务之间借助库共享代码。在几乎每个组织支持微服务架构的项目中,各个团队和开发者都期望以某些核心库为基础构建他们的微服务。显然,即使可能带来的问题已经被知道很长时间了,很多人仍然不知道它们。在这篇博文中,我想研究为什么使用这样的库可能起初听起来有吸引力,为什么可能会出现问题,以及如何能够减轻这些问题。

共享代码的目的

通过库来共享代码有两个主要目的:共享领域逻辑和共享基础设施层中的抽象。

  1. 共享的领域模型:领域模型的特定部分在两个或多个有界上下文之间是共同的,因此,作为三番五次实现它的替换,你消除了重复的需要和引入该领域逻辑的不一致实现的可能性。通常,人们想要像那样共享的领域模型的部分是核心领域或一个或多个通用子领域。在领域驱动设计的行话中,这也被称为共享内核。通常,你可以在这里找到像会话和身份验证逻辑这样的概念,但不限于此。一套相关的方法是规范数据模型。

  2. 基础设施层抽象:你想避免一次又一次地实现基础设施层的有用抽象,因此你把它们放进一个库里。通常,这些库在数据库访问、消息传递和序列化等方面提供一套统一的方法。

两者的动机是相同的——避免重复,也就是说,遵循DRY原则(Don’t repeat yourself!)。一旦实现这些逻辑有几个好处:

你不需要花费宝贵的时间致力于那些已经被解决的问题。

有一套统一的方式做消息传递、数据库访问等。这意味着,当开发者需要去阅读和修改其他开发者最初创建的微服务中的代码时,他们很容易找到他们的方式。

关于彼此行为略有不同的业务逻辑或基础设施关注点,你不想有不同的实现。取而代之的是,有一套做正确事情的规范实现。

共享代码的问题

在理论上听起来很棒的东西不会没有自己的问题,而且这些问题可能比你试图用你的库解决的问题更令人痛苦。Stefan Tilkov已经详细解释了为什么你应该避免规范的数据模型。除此之外,让我指出一些其它的问题。

分布式单体

通常,似乎存在一个隐含的假设,将东西放入库意味着你永远不必担心使用错误或过时的实现构成的服务,因为他们只需要更新其对库的依赖关系到最新版本。

每当你依靠通过将所有的微服务更新到同样的新版本库,来对所有微服务的某些行为作出一致的改变时,你就会在它们之间引入强耦合。你失去了微服务的一个主要优点,即它们彼此独立地演进和部署的能力。

我见过这样的案例,所有的服务必须同时部署,以便服务仍能正常工作。如果你达到这种状态,不可否认,你实际上构建了一个分布式的单体。

一个流行的示例是使用代码生成,例如,基于服务API的Swagger描述,以便为你的服务提供一个客户端库。比你想象的更多,开发者可能会滥用此种方式进行重大变更,因为依赖服务“只”需要使用新版本的客户端库。这不是你如何演进一个分布式系统

依赖地狱

库,尤其是那些旨在为基础设施关注点提供通用解决方案的库,往往有个额外的问题:它们会附上它们依赖的一整套额外的库。你的库的传递依赖树越大,它导致俗称为依赖地狱的噩梦的可能性就越高。因为你的微服务可能需要自己的额外的依赖,它们同样具有传递依赖性,直到它们中的某些库间接地拉进一些库的冲突版本,这只是个时间问题,只在不同版本之间选择是不可能的,因为它们是二进制不兼容的。

当然,你的解决方案也许只是提供微服务可能需要的所有库作为你的核心库的依赖。那仍然意味着你的微服务不能独立地演进,例如通过升级到它们依赖的唯一的特定库的更高版本——它们都与核心库的发布周期步调一致。除此之外,为什么你要强制每个服务接受一整堆的依赖,当它们实际上可能只需要依赖中的一些时?

自顶而下的库设计

通常情况下,我见过的库被一个或多个架构师强加于开发者,采用自顶而下的方法进行库设计。

通常,在这种情况下发生的是,由库暴露的API太受限制和不灵活,或者使用了错误的抽象级别,因为它们是由不够熟悉广泛的不同的真实世界用例的人设计的。这样的库经常导致不得不使用它的开发者遭受挫折,以及导致人们试图绕过库的限制。

单语言解决一切

强制使用库的最明显的缺陷之一是,这使得它更难以切换到不同的编程语言(或者平台,比如JVM或.NET),再次失去了微服务架构的一个优势,即选择最适合给定问题的技术的能力。如果你后来意识到,你终究需要这种语言或者平台的多样性,你必须创造各种奇怪的支持。例如,Netflix提出的Prana,一个同时运行非JVM服务的附加件,为他们提供到Netflix技术栈的一套HTTP API。

我们能不能做得更好?

由于所有的问题都是通过库共享代码引入的,最极端的解决方案是根本没有这样的库。如果你这样做,你将不得不做一些复制和粘贴或者为新的微服务提供一个模板项目,以便从前面所述的步调一致中释放你的服务。基础设施代码以及领域模型的共享内核中都可以这么做。事实上,Eric Evans在他的关于领域驱动设计的经典蓝皮书中提到,“通常各个团队只是在各自的内核备份上改动,每隔一定时间才会与其他团队集成”[1]。共享内核不一定要是库。

如果你不喜欢复制和粘贴的想法,那也很好。毕竟,如上所述,通过库共享代码有一定的优势。在这种情况下,这里有一些重要的事情需要考虑:

最少依赖的小型库

尝试将大的共享库分成一组非常小的、高度集中的库,每个库解决一个特定的问题。试着让这些库成为零依赖库,只依靠语言的标准库。是的,仅仅针对语言的标准库来编程并不总是令人愉快的,但是对于你公司的所有团队的巨大好处(甚至超出你的公司,如果你的馆是开源的)显然大于这个微小的不便。

当然,零依赖并不总是可能的,特别是对于基础设施关注点。对于这些,通过你的每个小型库最小化所需的依赖。另外,有时可以独立于库的核心,提供与别的库的绑定或集成作为单独的工件。

留下选择余地

不要指望服务将在特定时间点更新到共享库的最新版本的事实。换句话说,不要强制团队进行库更新,而是让他们可以按照自己的节奏自由更新。这可能需要你以向后和向前兼容的方式修改库,但它会解耦你的服务,不仅给你微服务架构的运营成本,而且还有一些优势。

如果可能,不仅要避免强制库更新,还要使库本身的使用可选。

自底而上的库设计

最后,如果你想拥有共享库,我见过的获得成功的项目是使用自底而上的方法。让你的团队实现他们的微服务,而不是让象牙塔架构师设计在现实世界中几乎不可用的库,而当在多个服务的生产中已经证明它们自己的一些常见模式出现时,将它们提取到库中。

[1] Evans, Eric: Domain-Driven Design: Tackling Complexity in the Heart of Software, p. 355

Comments