好的架构可以更容易的支持业务演进,更容易修改,容错性更好。而糟糕的架构就像一团浆糊,一点小小的改动可能都会对原有功能造成破坏。本文从很高层的角度介绍了什么是糟糕的架构,什么好的架构,以及构建好的架构的一些常用参考模型。原文:Software Architecture — Principles, Practices & Styles[1]
为解决特定的问题设计正确的架构更像是一门艺术而不是科学,其实践在很大程度上依赖于对问题的陈述、环境以及我们对问题的理解。对于任何架构来说,最重要的是在面对业务和规模需求变化时的适应性。下面是我关于将不同架构风格、原则和方法如何结合在一起,以形成一个可以演进的架构的经验。
什么是糟糕的架构?如何识别出糟糕的架构?
为了提高开发速度,开发人员经常会偷懒写一堆乱七八糟的代码,也就是我们通常所说的意大利面条式代码。这些代码经常会隐藏很多 bug,时不时造成功能瘫痪,而重新构建代码的成本甚至比修复现有代码的成本更低。这些代码包含下述特征:
- 不必要的复杂性(Unnecessarily Complex)——具有讽刺意味的是,编写复杂的代码很容易,任何人都能做到,但编写简单的代码却很困难。
- 刚性/脆弱性(Rigid/Brittle)——因为代码复杂,所以不容易理解,因此维护很困难,即使是很小的代码更改也很容易出错。
- 不可测试性(Untestable)——代码耦合紧密,通常不会遵循单一责任原则,难以测试。
- 不可维护性(Unmaintainable)——测试覆盖率较低的脆弱代码演变成维护的噩梦。
什么是好的架构?有什么特征?
- 简单(Simple)——容易理解。
- 模块化/分层/清晰(Modularity/Layering/Clarity)——这点很重要,对某个层进行修改不会影响到其他层,层与层之间的耦合最小。
- 灵活/可扩展(Flexible/Extendable)——可以很容易适应新的演进需求。
- 可测试/可维护(Testable/Maintainable)——易于测试,方便添加自动化测试,鼓励 TDD 文化,因此更容易维护。
为什么要重视架构、原则和实践?
降低成本(Cost reduction)——虽然最初的开发速度可能会降低,但最终,构建和维护的总成本会降低。
构建最重要的东西(Build what is essential)——我们需要构建最重要和必要的部分。只在必要的时候构建必要的东西,这一点很重要。这种方法通过只构建必要的内容,从而减少代码维护的开销,有助于清理混乱。
优化(Optimization)——优化以获得更好的可维护性,对于开发者和用户来说,优化应该提前完成。
性能优化(Performance Optimization)——在规划和设计可以为性能而演进的系统时,请记住,针对性能的代码级优化应该推迟到 LRT(Last Responsible Time,最后一刻)。
最后负责时间(Last Responsible Time)——LRT 是从精益原则中借鉴的概念,在精益原则中,决策/变更被推迟到某个时间点,超过这个时间点,不做决策的成本将比做决策的成本更高。当需求不够紧迫/重要的时候,设计决策应该推迟到 LRT,这样我们就有足够的知识来做出合理的设计决策。
适应性/进化(Adaptability/Evolution)——当软件不断适应业务和规模的新需求时,总是遵循进化模式。
我们可以使用什么工具、方法和技术?
- 精益原则(Lean principles)——构建正确的东西,构建必要的内容。
- 敏捷方法(Agile methodology)——建立正确的方法,以敏捷、适应性强、快速响应不断变化的市场需求的方式构建软件。
- 测试驱动开发实践和自动化测试(Test-driven development practice & Automated Tests)——测试驱动代码实现,确保可测试的软件设计,支持测试左移,“尽早测试,经常测试”可以帮助实现可维护的代码,消除对无意中破坏现有功能的恐惧。
遵循什么架构风格?
一般来说,没有一种方法是万能的。对于某个问题的设计决策取决于环境,每个设计都是权衡。下面是一些最常用的架构风格,只有需要做出设计决策的人才知道什么组合是最适合的。
- 以领域为中心的架构(Domain Centric Architecture)
- 以应用为中心的架构(Application Centric Architecture)
- Screaming Architecture
- 微服务架构(Microservices architecture)
- 事件驱动架构(Event-Driven Architecture — EDA)
- 命令查询职责分离(Command Query Responsibility Segregation — CQRS)
以领域为中心的架构(Domain Centric Architecture)
领域是模型的中心,其他一切都围绕领域构建的,应用层、表示层、持久层、通知服务、web 服务等等,也就是说,领域是基本的,其他一切都只是一个可替换的实现细节。
这里的域表示系统用户的心智模型,是架构中最稳定的部分,很少发生变化。接下来是嵌入用例的应用程序层,这些用例定义了其他一切。
图片来源:https://images.app.goo.gl/cW5QmNMxn912DM4D6
有两种领域模型:六边形模型和洋葱模型。本质上,每一个外层都依赖于内层,而内层不知道外层。
图片来源:https://images.app.goo.gl/dRgpzwPR9w8u5u4t7
优点
- 支持领域驱动设计(DDD,Domain-Driven Design)的思想,重点关注域、用户和用例。
- 减少域(稳定且更改较少)和实现细节(变更频繁,如表示层、数据库)之间的耦合。
缺点
- 初始成本更高,因为必须将更多的时间/思考/讨论用于划分领域与应用层所需的单独模型。
- 因为需要更多思考,所以开发人员不喜欢,他们坚持旧的以数据库为中心的三层架构。
以应用为中心的架构(Application Centric Architecture)
一旦定义了域边界,接下来就是应用层。通过应用下面的 SOLID 原则,应用层将更加健壮。
图片来源:https://images.app.goo.gl/UwBEyStMHaVVJt7u5
抽象(做什么?)——应用程序应该以一种能够通过抽象容纳业务逻辑的方式构建,专注于想做的事情。
解耦(怎么做?)——架构已经完成,实现细节使用依赖注入(DI,dependency injection)插入。依赖注入不仅适用于使用各种设计模式注入业务逻辑,而且尤其适用于注入基础设施元素,如数据库、缓存、通知服务器、外部 web 服务等。
接口/契约(交互)——这种方式自动构建了分层体系结构,每个外部元素都有清晰的接口。职责分离与每一层拥有单一职责相结合可以减少耦合,反过来有助于构建易于测试的代码,这些代码也可以使用 mock 进行单元测试。
同样,应用层不应依赖于任何其他实现细节,只了解它所依赖的领域层。
代码的功能性组织(Screaming Architecture)
体系架构应该突出系统的意图——Uncle Bob
建筑设计图很好的阐明了这一点,每个房间的用途都一目了然。
图片来源:https://images.app.goo.gl/3am8cnt6BrzFzh3JA
对于后端层——我们可以通过目录结构根据功能进行代码的模块化,具有功能内聚性的代码放在一起。每个模块都可以有一个聚合根作为模块的单一入口,因此只要查看聚合根,我们就能够写出模块的所有用例,从而简化模块的功能意图。
图片来源:https://levelup.gitconnected.com/let-me-hear-you-screaming-architecture-3adcc02f2ca3
对于表示层——可能仍然需要遵循模型/视图/控制器的旧分类方法。表示层应该保持轻量级,没有业务逻辑。这有两个好处:首先,消除重复的逻辑。其次,这样的组织可以帮助初级 UI 开发人员专注于丰富 UI。
微服务架构
过去,我们用统一的领域模型来表示销售上下文和支持上下文中的客户或产品。例如,支持联系人和销售客户被建模为单个 Customer 模型。随着解决方案空间变得越来越大,拥有越来越多的域,我们添加了更多的参数、属性和验证规则,有些规则只适用于一个域,而不适用于另一个域,从而导致统一代码的不必要的复杂性开销。
图片来源:https://images.app.goo.gl/HHfv3ojn17B5L1dU7
有界上下文(Bounded Context)——识别特定上下文范围,在该范围内,领域模型的特定术语是有效且有意义的。
在新模型中,不必在支持域的“Customer”中定义“Contact”,而是在支持域中使用正确的术语“Contact”。当与 Sales 域对话时,可以使用定义良好的接口将“Contact”转换为“Customer”对象。这样可以提高内聚性,减少跨不同领域的耦合。
微服务定义(Microservices defined)——将单个系统划分为子系统,子系统作为服务承担单一职责,通过明确定义的接口相互通信。每个服务可以自主部署,有自己独立的数据库和支持服务。每个微服务都是独立的,可以选择最适合自己的技术栈、工具和实践。每个服务可以独立缩放。负责每个服务的团队规模相对较小,一个团队可能负责一个或多个微服务。每个团队只需要了解他们负责的微服务的领域知识,而不需要知道整个系统的所有内容。
缺点
- 初始成本较高。
- 必须有 DevOps 自动化和自动化部署。
- 这种分布式计算架构在处理延迟、负载均衡、日志记录、监控、处理最终一致性等方面需要付出额外的时间和成本。
事件驱动架构(Event-Driven Architecture — EDA)
微服务可以通过 REST 调用的请求/响应机制(如 JSON)相互通信,或者使用带有消息代理的事件驱动架构。现代体系架构更喜欢 EDA,这样服务的响应更快、延迟更少,可以提供更健壮、可容错、有保障的服务,并允许更好的可伸缩性。
在 EDA 中有三个参与者,即创建触发事件的生产者、以健壮的方式携带消息的消息代理和可以订阅特定/所有事件的消费者,这些构成了“反应式编程(Reactive Programing)”。这种模式对来自数据流的事件(触发器)做出反应,从而获得更快的响应时间和更低的延迟。
对于需要支持跨微服务事务最终一致性,具有 ACID 属性的微服务,可以使用 SAGA 模式,其支持显式回滚机制来处理错误回滚。不过这种设计会变得更复杂,应该谨慎使用。
EDA 还定义了事件源(Event Sourcing),作为将数据存储在 DB 中的新机制。在这里,DB 对象永远不会被更新。相反,要获取对象的当前状态,需要按照事件到达的顺序进行处理。在事件源中,通过以固定的时间间隔创建当前状态的快照来实现性能优化。
CQRS 模式-命令查询职责分离
微服务和 EDA 的出现也催生了 CQRS 模式,其中“命令”用于修改底层对象的状态,“查询”不修改对象,只是返回请求的对象子集。
这有什么用?以下是一些示例。
- 可以在不影响写的情况下提高读的可伸缩性。例如,通过在 MongoDB 中添加更多的辅助节点来满足读取需求,可以选择性地扩展读取能力。
- “命令”必须将更新请求发送到数据库,可以选择使用缓存来提供更快的读取。
- 有时对象可能属于另一个微服务,而每次查询另一个微服务的开销可能很大,因此可以使用缓存来满足查询需求。数据虽然有重复,但只要维护好数据,并且变化不是很快,就能够在很大程度上减少延迟。这样同时也提高了可用性,即使在其他微服务不可用的情况下,我们的微服务也可以继续正常工作。例如:在订单服务中缓存产品目录。
以上是一些常用的高层设计选择和实践,这些可以和其他一些底层设计(不同设计模式、原则、工具等的组合)一起使用的。所有这些都以一种有意义的方式组合在一起,从而定义一个敏捷的、适应性强的、可扩展的、可维护的、可测试的,当然最重要的是简单的解决方案。
References:[1] https://sarada-sastri.medium.com/software-architecture-principles-practices-styles-a0263aa11530