在本文中,我将讨论我正在从事的项目如何从大型整体转变为一组微服务。
该项目的历史始于很久以前,即 2000 年初。第一个版本是用 Visual Basic 6 编写的。随着时间的推移,很明显,这种语言的开发在未来将难以支持,因为 IDE而且语言本身还很不发达。 2000 年代末,决定转向更有前途的 C#。 新版本是与旧版本的修订版并行编写的,逐渐越来越多的代码是用.NET编写的。 C#后端最初专注于服务架构,但在开发过程中,使用了具有逻辑的公共库,并在单个进程中启动服务。 结果是我们称之为“服务单体”的应用程序。
这种组合的少数优点之一是服务能够通过外部 API 相互调用。 过渡到更正确的服务以及未来的微服务架构有明确的先决条件。
我们在 2015 年左右开始了分解工作。 我们还没有达到理想的状态——大型项目中仍然有一些部分很难被称为单体,但它们看起来也不像微服务。 尽管如此,进展还是显着的。
我会在文章中讲到。

内容
现有解决方案的架构和问题
最初,该体系结构看起来是这样的:UI 是一个单独的应用程序,整体部分是用 Visual Basic 6 编写的,.NET 应用程序是一组与相当大的数据库一起工作的相关服务。
先前解决方案的缺点
单点故障
我们遇到了单点故障:.NET 应用程序在单个进程中运行。 如果任何模块失败,整个应用程序就会失败并且必须重新启动。 由于我们为不同的用户自动化了大量的流程,由于其中一个流程出现故障,每个人都无法工作一段时间。 如果出现软件错误,即使备份也无济于事。
改进队列
这个缺点是相当有组织性的。 我们的应用程序有很多客户,他们都希望尽快改进它。 以前,这是不可能并行进行的,所有顾客都要排队。 这个过程对企业来说是负面的,因为他们必须证明他们的任务是有价值的。 开发团队花了很多时间来组织这个队列。 这花费了大量的时间和精力,并且产品最终无法像他们希望的那样快速改变。
资源利用欠佳
当在单个进程中托管服务时,我们总是将配置从一个服务器完全复制到另一个服务器。 我们希望将负载最重的服务分开放置,以免浪费资源并更灵活地控制我们的部署方案。
现代技术难以实施
所有开发人员都熟悉的一个问题:希望将现代技术引入到项目中,但没有机会。 对于大型整体解决方案,当前库的任何更新(更不用说过渡到新库)都会变成一项相当重要的任务。 需要很长时间才能向队长证明这会带来比浪费精力更多的奖金。
发布变更困难
这是最严重的问题 - 我们每两个月发布一次版本。
尽管开发人员进行了测试和努力,但每个版本都对银行来说是一场真正的灾难。 该企业了解到,在本周初,其部分功能将无法运行。 开发商明白,接下来一周的严重事件正在等待着他们。
每个人都渴望改变现状。
对微服务的期望
准备好后发出组件。 准备好后通过分解解决方案并分离不同的流程来交付组件。
小型产品团队。 这很重要,因为在旧的整体上工作的大型团队很难管理。 这样的团队被迫按照严格的流程工作,但他们想要更多的创造力和独立性。 只有小团队才能负担得起。
单独进程中的服务隔离。 理想情况下,我想将其隔离在容器中,但大量用 .NET Framework 编写的服务只能在 Windows 上运行。 基于.NET Core的服务现在已经出现,但还很少。
部署灵活性。 我们希望按照我们需要的方式组合服务,而不是代码强制的方式。
使用新技术。 这对任何程序员来说都很有趣。
过渡问题
当然,如果很容易将整体分解为微服务,则无需在会议上讨论它并撰写文章。 这个过程中有很多陷阱;我将描述阻碍我们的主要陷阱。
第一个问题 大多数单体应用的典型特征是:业务逻辑的一致性。 当我们编写一个整体时,我们希望重用我们的类,以免编写不必要的代码。 当迁移到微服务时,这成为一个问题:所有代码都非常紧密耦合,并且很难分离服务。
工作开始时,该存储库拥有超过 500 个项目和超过 700 万行代码。 这是一个相当重大的决定 第二个问题。 不可能简单地将其划分为微服务。
第三个问题 ——缺乏必要的基础设施。 事实上,我们手动将源代码复制到服务器。
如何从整体迁移到微服务
配置微服务
首先,我们立即确定微服务的分离是一个迭代的过程。 我们总是被要求并行开发业务问题。 我们如何在技术上实现这一点已经是我们的问题了。 因此,我们准备了一个迭代过程。 如果您有一个大型应用程序并且最初尚未准备好重写,那么它不会以任何其他方式工作。
我们用什么方法来隔离微服务?
第一种方法 — 将现有模块作为服务移动。 在这方面,我们很幸运:已经有使用 WCF 协议运行的注册服务。 它们被分成单独的组件。 我们单独移植它们,为每个版本添加一个小型启动器。 它是使用精彩的 Topshelf 库编写的,它允许您将应用程序作为服务和控制台运行。 这很方便调试,因为解决方案中不需要额外的项目。
这些服务根据业务逻辑进行连接,因为它们使用通用程序集并使用通用数据库。 它们很难被称为纯粹形式的微服务。 但是,我们可以在不同的流程中单独提供这些服务。 仅此一点就可以减少它们之间的影响,减少并行开发和单点故障的问题。
与主机的汇编只是 Program 类中的一行代码。 我们将 Topshelf 的工作隐藏在辅助类中。
namespace RBA.Services.Accounts.Host
{
internal class Program
{
private static void Main(string[] args)
{
HostRunner<Accounts>.Run("RBA.Services.Accounts.Host");
}
}
}
第二种分配微服务的方式是: 创建它们来解决新问题。 如果同时巨石没有增长,那就已经很好了,这意味着我们正在朝着正确的方向前进。 为了解决新问题,我们尝试创建单独的服务。 如果有这样的机会,那么我们创建了更多“规范”服务,完全管理自己的数据模型,一个单独的数据库。
和许多人一样,我们从身份验证和授权服务开始。 他们非常适合这个。 它们是独立的,通常,它们有单独的数据模型。 他们本身不与巨石互动,只是巨石求助于他们来解决一些问题。 使用这些服务,您可以开始过渡到新的架构,调试它们上的基础设施,尝试一些与网络库相关的方法等。 我们组织中没有任何团队无法创建身份验证服务。
微服务的第三种分配方式我们使用的那个对我们来说有点特殊。 这是从 UI 层移除业务逻辑。 我们的主要 UI 应用程序是桌面;它与后端一样,都是用 C# 编写的。 开发人员定期犯错误,并将部分本应存在于后端并被重用的逻辑转移到 UI。
如果您从 UI 部分的代码中查看一个真实的示例,您可以看到该解决方案的大部分内容都包含在其他流程中有用的真实业务逻辑,而不仅仅是用于构建 UI 表单。

真正的 UI 逻辑只存在于最后几行。 我们把它转移到服务器上,以便可以重用,从而减少UI并实现正确的架构。
第四种也是最重要的微服务隔离方式,这使得减少整体成为可能,即通过处理删除现有服务。 当我们按原样取出现有模块时,结果并不总是符合开发人员的喜好,并且自功能创建以来业务流程可能已经过时。 通过重构,我们可以支持新的业务流程,因为业务需求不断变化。 我们可以改进源代码,消除已知缺陷,并创建更好的数据模型。 有很多好处。
将服务与处理分离与有界上下文的概念密不可分。 这是领域驱动设计的概念。 它意味着领域模型的一部分,其中唯一定义了单一语言的所有术语。 让我们以保险和账单为例。 我们有一个整体应用程序,我们需要使用保险账户。 我们希望开发人员在另一个程序集中找到现有的 Account 类,从 Insurance 类中引用它,然后我们将获得工作代码。 DRY 原则将得到尊重,使用现有代码可以更快地完成任务。
结果表明,账户和保险的背景是相关的。 随着新需求的出现,这种耦合将干扰开发,增加本已复杂的业务逻辑的复杂性。 要解决这个问题,您需要找到代码中上下文之间的边界并消除它们的违规行为。 例如,在保险领域,20 位中央银行帐号和开户日期很可能就足够了。
为了将这些有界上下文相互分离,并开始将微服务与整体解决方案分离,我们使用了一种方法,例如在应用程序中创建外部 API。 如果我们知道某个模块应该成为微服务,并在流程中进行某种修改,那么我们立即通过外部调用来调用属于另一个有限上下文的逻辑。 例如,通过 REST 或 WCF。
我们坚定地决定,我们不会避免需要分布式事务的代码。 在我们的例子中,事实证明遵循这条规则非常容易。 我们还没有遇到真正需要严格分布式事务的情况——模块之间的最终一致性就已经足够了。
我们来看一个具体的例子。 我们有协调器的概念——处理“应用程序”实体的管道。 他依次创建了一个客户、一个账户和一张银行卡。 如果客户和账户创建成功,但创建卡失败,申请不会进入“成功”状态,仍处于“未创建卡”状态。 将来,后台活动将拾取它并完成它。 一段时间以来,系统一直处于不一致的状态,但我们对此总体感到满意。
如果出现需要持续保存部分数据的情况,我们很可能会整合服务,以便在一个流程中处理它。
让我们看一个分配微服务的示例。 如何才能相对安全地将其投入生产? 在此示例中,我们有系统的一个单独部分 - 工资服务模块,我们希望将其代码部分之一制作为微服务。

首先,我们通过重写代码来创建一个微服务。 我们正在改进一些我们不满意的方面。 我们实施客户的新业务要求。 我们在 UI 和后端之间的连接中添加一个 API 网关,它将提供呼叫转发。

接下来,我们将此配置投入运行,但处于试点状态。 我们的大多数用户仍然使用旧的业务流程。 对于新用户,我们正在开发一个新版本的单体应用程序,不再包含此过程。 本质上,我们将单体应用和微服务结合起来作为试点。

通过成功的试点,我们了解到新的配置确实可行,我们可以从方程式中删除旧的整体,并用新的配置代替旧的解决方案。

总的来说,我们使用几乎所有现有的方法来拆分单体的源代码。 所有这些都允许我们减少应用程序各部分的大小并将它们转换为新的库,从而制作更好的源代码。
使用数据库
数据库的划分比源代码更糟糕,因为它不仅包含当前模式,还包含累积的历史数据。
与许多其他数据库一样,我们的数据库有另一个重要缺点 - 规模庞大。 该数据库是根据整体复杂的业务逻辑以及各种有界上下文的表之间积累的关系来设计的。
在我们的例子中,除了所有的麻烦(大型数据库、许多连接、有时表之间的边界不清晰)之外,还出现了许多大型项目中出现的问题:共享数据库模板的使用。 数据通过视图、复制从表中获取,然后传送到需要复制的其他系统。 因此,我们无法将这些表移动到单独的模式中,因为它们正在被积极使用。
对代码中有限上下文的同样划分有助于我们进行分离。 它通常让我们很好地了解如何在数据库级别分解数据。 我们了解哪些表属于一个有界上下文,哪些表属于另一个。
我们使用了两种全局的数据库分区方法:现有表分区和处理分区。
如果数据结构良好、满足业务需求并且每个人都对此感到满意,那么拆分现有表是一个很好的方法。 在这种情况下,我们可以将现有表分成单独的模式。
当业务模式发生很大变化,表格已经不能满足我们的时候,就需要一个有处理的部门。
拆分现有表。 我们需要确定要分离什么。 如果没有这些知识,什么都行不通,这里代码中限界上下文的分离将对我们有所帮助。 通常,如果您可以理解源代码中上下文的边界,那么哪些表应包含在部门列表中就会变得很清楚。
让我们想象一下,我们有一个解决方案,其中两个整体模块与一个数据库交互。 我们需要确保只有一个模块与分隔表的部分进行交互,而另一个模块开始通过 API 与其进行交互。 首先,仅通过 API 进行录制就足够了。 这是我们谈论微服务独立性的必要条件。 只要没有大问题,读取连接就可以保留。

下一步是我们可以将处理单独表的代码部分(无论是否经过处理)分离到单独的微服务中,并在单独的进程(容器)中运行它。 这将是一项单独的服务,可连接到整体数据库以及与其不直接相关的表。 整体仍然与可拆卸部分交互以进行读取。

稍后我们将删除此连接,即从单个应用程序中读取分离表的数据也将传输到 API。

接下来,我们将从通用数据库中选择仅新微服务适用的表。 我们可以将表移动到单独的模式,甚至移动到单独的物理数据库。 微服务和整体数据库之间仍然存在读取连接,但没有什么可担心的,在这种配置中它可以存活相当长的时间。

最后一步是完全删除所有连接。 在这种情况下,我们可能需要从主数据库迁移数据。 有时我们希望在多个数据库中重用从外部系统复制的一些数据或目录。 这种情况会定期发生在我们身上。

加工部。 此方法与第一种方法非常相似,只是顺序相反。 我们立即分配一个新的数据库和一个通过 API 与整体交互的新微服务。 但与此同时,仍然存在一组我们希望将来删除的数据库表。 我们不再需要它;我们在新模型中替换了它。

为了让这个计划发挥作用,我们可能需要一个过渡期。
那么有两种可能的方法。
第一:我们复制新旧数据库中的所有数据。 在这种情况下,我们可能会出现数据冗余和同步问题。 但我们可以接受两个不同的客户。 一个将使用新版本,另一个将使用旧版本。
第二:我们根据一些业务标准来划分数据。 例如,我们的系统中有 5 个产品存储在旧数据库中。 我们将第六个任务放入新数据库中的新业务任务中。 但我们需要一个 API 网关来同步这些数据并向客户端显示从哪里获取数据以及从哪里获取数据。
两种方法都有效,根据情况选择。
在我们确定一切正常后,可以禁用与旧数据库结构一起使用的整体部分。

最后一步是删除旧的数据结构。

总而言之,我们可以说我们的数据库存在问题:与源代码相比,使用它很困难,共享更困难,但可以而且应该这样做。 我们已经找到了一些方法,可以让我们非常安全地做到这一点,但数据仍然比源代码更容易出错。
使用源代码
这就是我们开始分析单体项目时源代码图的样子。

大致可以分为三层。 这是启动的模块、插件、服务和单独活动的层。 事实上,这些是整体解决方案中的入口点。 它们都被一层普通层紧紧密封着。 它具有服务共享的业务逻辑和大量连接。 每个服务和插件最多使用 10 个或更多通用程序集,具体取决于它们的大小和开发人员的良心。
我们很幸运拥有可以单独使用的基础设施库。
有时会出现一些常见对象实际上并不属于这一层,而是基础设施库的情况。 通过重命名解决了这个问题。
最令人担忧的是有界上下文。 碰巧 3-4 个上下文混合在一个 Common 程序集中,并在相同的业务功能中相互使用。 有必要了解可以在哪里进行划分、沿着什么边界进行划分,以及下一步如何将这种划分映射到源代码程序集中。
我们为代码分割过程制定了几条规则。
第一:我们不再希望在服务、活动和插件之间共享业务逻辑。 我们希望使业务逻辑在微服务中独立。 另一方面,微服务理想地被认为是完全独立存在的服务。 我认为这种方法有点浪费,而且很难实现,因为,例如,C# 中的服务无论如何都会通过标准库连接。 我们的系统是用C#编写的;我们还没有使用其他技术。 因此,我们决定有能力使用通用的技术组件。 最主要的是它们不包含任何业务逻辑片段。 如果您在正在使用的 ORM 上有一个方便的包装器,那么将其从一个服务复制到另一个服务的成本非常昂贵。
我们的团队是领域驱动设计的粉丝,因此洋葱架构非常适合我们。 我们服务的基础不是数据访问层,而是带有领域逻辑的组件,它只包含业务逻辑,与基础设施没有任何联系。 同时我们可以独立修改领域组件来解决框架相关的问题。
在这个阶段,我们遇到了第一个严重的问题。 服务必须引用一个域程序集,我们希望逻辑独立,而 DRY 原则在这里极大地阻碍了我们。 开发人员希望重用相邻程序集中的类以避免重复,因此,域开始再次链接在一起。 我们分析了结果,认为问题可能还出在源代码存储设备的区域。 我们有一个包含所有源代码的大型存储库。 整个项目的解决方案很难在本地机器上组装。 因此,为项目的各个部分创建了单独的小型解决方案,并且没有人禁止向其中添加一些公共或域程序集并重用它们。 唯一不允许我们这样做的工具是代码审查。 但有时也失败了。
然后我们开始转向具有单独存储库的模型。 业务逻辑不再从一个服务流向另一个服务,域已经真正变得独立。 有界上下文得到更明确的支持。 我们如何重用基础设施库? 我们将它们分离到一个单独的存储库中,然后将它们放入 Nuget 包中,然后将其放入 Artifactory 中。 对于任何更改,组装和发布都会自动发生。

我们的服务开始以与外部基础设施包相同的方式引用内部基础设施包。 我们从 Nuget 下载外部库。 为了与放置这些包的 Artifactory 一起使用,我们使用了两个包管理器。 在小型存储库中,我们还使用 Nuget。 在具有多个服务的存储库中,我们使用 Paket,它提供了模块之间更多的版本一致性。

因此,通过处理源代码、稍微改变架构并分离存储库,我们使我们的服务更加独立。
基础设施问题
迁移到微服务的大部分缺点都与基础设施相关。 您将需要自动化部署,您将需要新的库来运行基础设施。
环境中手动安装
最初,我们手动安装了环境解决方案。 为了自动化此过程,我们创建了 CI/CD 管道。 我们选择持续交付流程是因为从业务流程的角度来看,持续部署对于我们来说尚不可接受。 因此,操作发送是使用按钮进行的,测试发送是自动进行的。

我们使用 Atlassian、Bitbucket 进行源代码存储,使用 Bamboo 进行构建。 我们喜欢在 Cake 中编写构建脚本,因为它与 C# 相同。 现成的包到达 Artifactory,Ansible 自动到达测试服务器,之后可以立即进行测试。

单独记录
曾经,单体应用的想法之一是提供共享日志记录。 我们还需要了解如何处理磁盘上的各个日志。 我们的日志被写入文本文件。 我们决定使用标准 ELK 堆栈。 我们没有直接通过提供程序写入 ELK,而是决定最终确定文本日志,并将跟踪 ID 作为标识符写入其中,添加服务名称,以便稍后可以解析这些日志。

借助 Filebeat,我们可以从以下位置收集日志: 服务器然后转换这些数据,使用 Kibana 在用户界面中构建查询,并查看服务之间的调用路由情况。跟踪 ID 对此非常有用。
测试、调试相关服务
最初,我们并不完全了解如何调试正在开发的服务。 对于单体应用来说一切都很简单;我们在本地机器上运行它。 起初他们尝试对微服务做同样的事情,但有时要完全启动一个微服务,您需要启动其他几个微服务,这很不方便。 我们意识到我们需要迁移到一个模型,在该模型中,我们只将我们想要调试的一个或多个服务留在本地计算机上。 其余服务从与 prod 配置相匹配的服务器使用。 调试后,在测试过程中,对于每个任务,仅将更改的服务发布到测试服务器。 因此,该解决方案以未来在生产中出现的形式进行测试。
有些服务器只运行生产版本的服务。 需要这些服务器以防发生事故、在部署前检查交付情况以及进行内部培训。
我们使用流行的 Specflow 库添加了自动化测试流程。 从 Ansible 部署后,测试立即使用 NUnit 自动运行。 如果任务覆盖是全自动的,那么就不需要手动测试。 尽管有时仍然需要额外的手动测试。 我们使用 Jira 中的标签来确定针对特定问题运行哪些测试。
此外,负载测试的需求也有所增加;以前仅在极少数情况下进行。 我们使用 JMeter 运行测试,使用 InfluxDB 存储测试,使用 Grafana 构建流程图。
我们取得了什么成就?
首先,我们摆脱了“发布”的概念。 当这个庞然大物部署在生产环境中、暂时扰乱业务流程时,两个月的巨大发布已经一去不复返了。 现在我们平均每1,5天部署一次服务,并分组,因为它们需要经过批准才能运行。
我们的系统不存在致命故障。 如果我们发布一个有错误的微服务,那么与之相关的功能将被破坏,并且所有其他功能都不会受到影响。 这极大地提高了用户体验。
我们可以控制部署模式。 如有必要,您可以从解决方案的其余部分中单独选择服务组。
此外,我们通过大量改进显着减少了这个问题。 我们现在拥有独立的产品团队,独立处理某些服务。 Scrum 流程已经很适合这里了。 特定团队可能有一个单独的产品负责人,负责向其分配任务。
总结
- 微服务非常适合分解复杂的系统。 在这个过程中,我们开始了解我们的系统中有什么,有哪些有限的上下文,它们的边界在哪里。 这使您可以在模块之间正确分配改进并防止代码混乱。
- 微服务提供组织效益。 它们通常仅作为架构来讨论,但任何架构都需要解决业务需求,而不是单独存在。 因此,我们可以说,鉴于Scrum现在非常流行,微服务非常适合解决小团队的问题。
- 分离是一个迭代过程。 您不能将应用程序简单地划分为微服务。 最终的产品不太可能发挥作用。 在奉献微服务时,重写现有的遗留物是有益的,即将其变成我们喜欢的、在功能和速度上更好地满足业务需求的代码。
一个小警告: 迁移到微服务的成本相当可观。 仅解决基础设施问题就花了很长时间。 因此,如果您有一个不需要特定扩展的小型应用程序,除非您有大量客户争夺您团队的注意力和时间,那么微服务可能不是您今天所需要的。 这是相当昂贵的。 如果您从微服务开始该流程,那么最初的成本将高于通过开发单体应用开始同一项目的成本。
PS 一个更感性的故事(就好像针对你个人而言) - 根据 .
这是报告的完整版本。
来源: habr.com
