本文要点
- 在微服务体系结构中,许多服务可能同时在(相对)独立地演化,而且通常非常迅速。要获得这种架构风格的全部价值,服务必须能够独立发布。
- 通常很难验证新服务(或服务的新版本)没有对当前的应用程序造成任何破坏,即API、载荷或响应性能的变化导致回归。
- “分接比较(Tap compare)”是一种测试技术,它使你可以通过把新服务的结果与旧服务进行比较来测试新服务的行为和性能。本文提供了一个使用新开源工具Diferencia的示例,通过在新旧服务之间镜像生产流量来比较结果的差异。
- Diferencia是一个用Go编写的开源工具(遵循Apache v2许可),它与JUnit 4、JUnit 5或AssertJ等Java测试框架紧密集成,让你可以使用分接比较测试技术来验证服务的两个实现在语法上是否兼容。
DevOps在过去几年中越来越受欢迎,特别是在那些希望在不影响质量的情况下,将交付时间从月/年减少到日/周的(软件)公司中。除了其他模式和技术外,这还导致了基于微服务的架构的采用。
在微服务架构中,许多服务可能同时演化,而且通常非常迅速。然而,更重要的是,它们必须以一种孤立的方式单独发布,这就意味着发布不是在服务之间协调进行的。
因此,如果你采用微服务架构(包括它所包含的所有内容),那么你每天可以发布多次,但是这又带来了另一个问题:很难验证新服务(或服务的新版本)会不会破坏当前应用程序中的任何内容。
让我们看一个示例,你可能会因为一个服务的更新而中断另一个服务。
微服务发布编排面临的挑战
假设我们有一个消费者服务A(v1)和一个提供者服务B(v1)。服务B(v1)提供一个JSON文档作为输出,其中一个字段名为name,服务A (v1)使用该字段。
现在,创建一个服务B(v2),它将字段从name改为fullname。然后,你修复服务B(v2)的所有测试,使它们不会因为这个修改而失败。因为理论上,任何服务都可以独立发布,你将这个新版本部署到生产环境,当然,服务B(v2)的行为没有问题,但服务(v1)将会立即开始失败,因为它没有获得预期的数据(例如,服务A希望得到字段name却接收fullname)。
所以你可以看到,单元测试(在这里是服务B)和一般测试可以帮助获得信心,相信我们正在做的事情是对的,但这并不涵盖整个系统的总体逻辑(即我们无意中破坏了依赖B的服务A)。
一种潜在的解决方案:引入分接比较
“分接比较(Tap compare)”是一种测试技术,它允许你将新服务的结果与旧服务进行比较,从而测试新服务的行为/性能。
它被用来检测不同类型的回归,例如,请求/响应格式回归(新服务破坏了与消费者的向后兼容性)、性能回归(新服务表现低于旧服务),或者仅仅是代码缺陷(通过比较两个服务的响应)。
分接比较方法不需要开发人员创建复杂的测试脚本,其他类型的测试通常需要,如集成测试或端到端测试。在分接比较方法中,你可以使用镜像流量技术或捕获(跟踪)部分公共流量,并在服务的新版本上重放。这些技术超出了这篇文章的范围,简单起见,作为分接比较技术的入门指南,我们通过一个测试“模拟”镜像流量的方法。
为什么是分接比较?
分接比较并不是要直接代替任何其他测试技术——你仍然需要编写其他类型的测试,如单元测试、组件测试或契约测试。不过,它可以帮助你发现回归,这样你对开发的新版本的服务的质量就更有信心。
但是,分接比较的一个重要特点是,它为你的服务提供了一个新的质量层。借助单元测试、集成测试和契约测试,作为一名开发人员,你可以根据你对系统的理解进行功能验证,还有你在测试开发过程中所提供的输入和输出。在分接比较测试中,有些完全不同的东西。这里,服务验证使用了生产请求,或者是从生产环境捕获一组请求然后对新服务重放,或者是使用镜像流量技术(克隆)生产流量同时发送给旧版本(生产版本)和新版本,并比较结果。在这两种情况下,作为一个开发者,你都不需要编写测试脚本(提供输入或输出)来进行服务验证——用于验证目的是真实的流量。
分接比较工作在“生产环境”中;你是用生产流量和生产实例来验证同样部署到生产环境中的新服务,因此,你是在生产环境中添加质量检验关,而其他测试技术重点是在部署之前验证软件(单元或组件测试)。
Diferencia
Diferencia是什么?
Diferencia是一个使用Go编写的开源工具(遵循Apachev2许可),与Java JUnit 4、Junit 5或AssertJ这样的框架进行了紧密地集成,让你可以使用分接比较测试验证服务的两种实现的兼容(例如,服务不会破坏交互协议方面的向后兼容性),让我们可以确信变更不会造成回归。
Diferencia背后的思想是充当代理,收到的每个请求会多路发送给服务的多个版本。当每个服务响应都返回后,比较响应并对它们进行检查,看它们是否“相似”。如果对一定数量的请求重复此操作后,所有(或大多数)的响应都“相似”,那么你可以认为新服务未造成回归。
在下一节中,你会看到为什么我使用“相似”这个词而不是相等。
Diferencia也可以用Docker镜像(lordofthejars/ Diferencia)的形式发布,该镜像基于Alpine镜像,可用于Kubernetes或OpenShift集群。
写这篇文章的时候,Diferencia的版本是0.6.0。
Diferencia的工作机制
Diferencia充当请求和正在验证的服务的两个版本之间的代理。默认情况下,Diferencia使用两个不同的服务实例:
- 现有版本(生产环境中的版本),即主版本;
- 新版本(发布过程中的版本),即候选版本。
每个请求都以广播的方式发送给两个服务,然后对两个实例的响应进行比较。如果响应相等,则Diferencia代理会向调用者返回一个HTTP状态码200 OK。另一方面,如果请求响应不相等,则会向调用者返回一个HTTP状态码412 “前提条件失败”。前提是具有相同参数的相同请求应该产生相同的响应。Diferencia还在内部存储每个请求的结果,以供稍后查询。
重要的是要注意,Diferencia并不像一个标准的代理,所以如果不显式设置的话,它返回的不是原始内容。Diferencia在启动时可以使用镜像流量选项,这使得Diferencia可以将来自主要部分的响应重新发送出去。
然而,这只是最简单的情况。当JSON文档中的有一些值有本质的不同(或不确定性),例如,一个计数器、一个日期或随机数?尽管响应可能是完全有效的,因为唯一的区别是一个字段的值,两个文档是不相等的,因此就不能保证这种变化是否是回归的原因。
为了避免这个问题(也称为“噪声”),一个自动噪声检测函数会识别包含噪声值的字段,并消除响应中的噪声。这样,噪声值就从比较逻辑中删除了,每个响应在进行比较时就像没有噪声一样了。
要进行自动噪声检测,你需要三个运行的服务实例:
- 现有版本(生产环境中的版本),称为主版本;
- 现有版本(生产环境中的版本),它是主版本的另一个实例,称为辅助版本;
- 新版本(正在发布过程中的版本),称为候选版本。
首先,在比较主版本和候选版本的响应时禁用噪声检测。然后,比较主版本和辅助版本的响应。因为这两个版本是一样的,响应应该是相同的,它们之间的任何差异都被认为是噪声。最后,在比较主版本和候选版本时将噪声移除,就可以确认两个响应彼此相等。
重要的是要注意,在默认情况下,Diferencia将忽略任何非安全操作,如POST、PUT、PATCH等等,因为它们可能对服务产生副作用。可以使用–unsafe标识禁用此行为。
Diffy还是Diferencia
Diferencia的理念来自另一个名为OpenDiffy的分接比较框架,但它们之间有一些差异。Diferencia是:
- 用Go编写的,提供容器的轻量级体验;
- 准备在Kubernetes和OpenShift集群中使用;
- 它可以用来镜像流量;
- 将结果暴露为Rest API,但也以Prometheus格式;
- 与Istio集成;
- 支持Postel定律(后面会详细介绍)。
Diferencia Java
Diferencia-Java是一个Diferencia包装器,它提供了Java API让你可以在Java中使用它,而不会注意到它是用Go实现的。Diferencia-Java提供了以下特性:
- Diferencia可以自动安装,你不需要手动安装任何东西;
- 在启动/停止Diferencia时,你不需要直接和CLI打交道;
- 提供特定的HttpClient用于连接Diferencia Rest API,从而对它进行配置或获取结果;
- 它可以作为普通的Java使用;
- 与JUnit4和JUnit5集成;
- 与AssertJ库集成,使测试可读。
Java示例
在这个例子中,我们使用一种简单的方法,用一个简单的Rest API展示Diferencia的所有功能。
该服务是使用MicroProfile规范开发的,如下所示:
@Path(\u0026quot;/user\u0026quot;)public class HelloWorldEndpoint { @GET @Produces(\u0026quot;application/json\u0026quot;) public Response getUserInformation() { final JsonObject doc = Json.createObjectBuilder() .add(\u0026quot;name\u0026quot;, \u0026quot;Alex\u0026quot;) .build(); return Response.ok(doc.toString()) .build(); }
让我们看一下,在这个服务演化为不同版本的过程中如何使用Diferencia。简单起见,我们设定以下前提:
- 服务在本地主机上运行;
- 主服务运行在端口9090上;
- 辅助服务运行在端口9091上;
- 候选服务运行在端口9092上。
Java测试
这个示例使用JUnit 5开发测试代码,运行Diferencia并检测回归。基本上,这个测试是读取一个文件中指定的URL并向Diferencia发送请求。最后,如果有回归,它会发出告警。
接下来,依赖项必须包含在类路径中,应该在构建工具中注册:
\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.lordofthejars.diferencia\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;diferencia-java-junit5\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${version.diferencia}\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.lordofthejars.diferencia\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;diferencia-java-assertj\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${version.diferencia}\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.junit.jupiter\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit-jupiter-engine\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${version.junitJupiter}\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.assertj\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;assertj-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;${version.assertj}\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt;
编写一个JUnit测试,从一个文件中读取URL:
@ExtendWith(DiferenciaExtension.class)@DiferenciaCore(primary = \u0026quot;http://localhost:9090\u0026quot;, candidate = \u0026quot;http://localhost:9092\u0026quot;)public class DiferenciaTest { private final OkHttpClient client = new OkHttpClient(); @Test public void should_detect_any_possible_regression(Diferencia diferencia) throws IOException { // Given final String diferenciaUrl = diferencia.getDiferenciaUrl(); // When Files.lines(Paths.get(\u0026quot;src/test/resources/links.txt\u0026quot;)) .forEach((path) -\u0026gt; sendRequest(diferenciaUrl, path)); // Then assertThat(diferencia) .hasNoErrors(); } private void sendRequest(String diferenciaUrl, String path) { final Request request = new Request.Builder() .addHeader(\u0026quot;Content-Type\u0026quot;, \u0026quot;application/json\u0026quot;) .url(diferenciaUrl + path) .build(); try { client.newCall(request).execute(); } catch (IOException e) { throw new IllegalArgumentException(e);
当你运行这个测试时,一个*/user请求会发送到Diferencia代理,这是由JUnit扩展自己启动的。当links.txt*文件中定义的所有请求处理完成,就可以断言Diferencia代理中没有任何错误,这意味着新服务中没有回归。
因为现在两个服务实例完全相同但运行在不同的端口上,一切顺利。
在更复杂的情况下,这个文件应该是由捕获公共流量生成的,或者只是将公共流量使用镜像技术重定向给Diferencia代理。正如上文所言,这超出了本文的范围。
现在,让我们做个修改,把name字段改为fullname,破坏新服务的向后兼容性 。
finalJsonObjectdoc= Json.createObjectBuilder() .add(\u0026quot;fullname\u0026quot;, \u0026quot;Alex\u0026quot;) .build();
然后,部署这个新版本,再次运行测试,你会发现路径*/user*上有一个回归。
是时候看看噪声检测的作用了。修改现有服务和新服务,让它们包含一个随机数,并再次部署它们。
final JsonObject doc = Json.createObjectBuilder() .add(\u0026quot;name\u0026quot;, \u0026quot;Alex\u0026quot;) .add(\u0026quot;sequence\u0026quot;, new Random().nextInt()) .build();
再次运行测试。显然,你会失败,因为sequence字段包含一个随机生成的值。
这是一个完美的自动噪声检测用例,所以你需要在端口9091上部署一个辅助服务,并让Diferencia使用噪声检测。
@DiferenciaCore(primary = \u0026quot;http://localhost:9090\u0026quot;, candidate = \u0026quot;http://localhost:9092\u0026quot;, config = @DiferenciaConfig(secondary = \u0026quot;http://localhost:9091\u0026quot;, noiseDetection = true))
再次运行测试,你将会看到测试通过。自动噪声检测会识别出,sequence字段的值是噪声,并从比较逻辑中移除。
到目前为止,你已经看到,Diferencia可用于检测回归,但还有一个重要用例需要提及,就是如何在服务的新版本中正确地重命名字段而不引发回归。
子集模式
要重命名响应中的一个字段,消费者和提供者都应该遵循Postel法则或进行消息序列化和反序列化。Postel法则(意译)说,“严以律己,宽以待人”。
如果你想把字段name重命名为fullname,你需要先提供这两个字段,这样,就不会对任何消费者造成破坏。
在前面的例子里,新版本的服务应该是下面这个样子:
final JsonObject doc = Json.createObjectBuilder() .add(\u0026quot;name\u0026quot;, \u0026quot;Alex\u0026quot;) .add(\u0026quot;fullname\u0026quot;, \u0026quot;Alex\u0026quot;) .add(\u0026quot;sequence\u0026quot;, new Random().nextInt()) .build();
现在消费者仍兼容新版本,所以没有引入回归…好吧,让我们部署新服务并运行Diferencia测试。你会失败,因为主版本和候选版本不相等;新版本有一个旧版本没有的字段。为解决这种假阳性,Diferencia提供了子集模式。这种模式使Diferencia不会失败,它就是为了处理这种情况,即旧版本的响应是新版本的响应子集。
修改测试,使Diferencia以子集模式启动。
@DiferenciaCore(primary = \u0026quot;http://localhost:9090\u0026quot;, candidate = \u0026quot;http://localhost:9092\u0026quot;, config = @DiferenciaConfig(secondary = \u0026quot;http://localhost:9091\u0026quot;, noiseDetection = true, differenceMode = DiferenciaMode.SUBSET))
再次运行测试,测试通过,因此,即使在这种情况下,Diferencia也可以用于检测任何回归问题。
更多特性
在这篇文章中,你已经了解了如何使用Diferencia Java,但是请记住,Diferencia是用Go编写的,这意味着它可以独立地应用在任何语言中。
此外,Diferencia还提供了以下特性:
- HTTPS支持;
- 公开结果供REST API或Prometheus使用;
- 可视化仪表板;
- 主版本调用和候选版本调用的平均耗时。
契约测试
分接比较测试不能代替契约测试,但是它们可以充当“监护人”,保证任何未被契约验证测试覆盖的东西(即契约中未指定的操作)不会在新服务发布到生产环境时引入回归。
重要的是要注意,契约测试技术需要大量的技术知识才能有效地实现(特别是在消费者驱动的契约开发的情况下),需要项目的所有团队做出巨大的让步。
在契约测试中,有一个步骤涉及契约的生成,因此,我们还需要自动化这个过程,保持更新或防止任何可能的错误在这个(可能)手动步骤中被引入。
结论
分接比较是一种很好的测试技术,你可以添加到你的工具箱中用于验证服务的新版本没有引入回归,而无需管理和维护一个测试脚本。你可以捕获现有的生产流量并稍后回放,或者使用镜像流量技术克隆请求并同时发送给新版本和旧版本的服务。
在这篇文章中,我重点介绍了Diferencia及其与Java的集成,但是,它可以作为一个独立的服务,不需要使用Java(或者任何JVM语言)。
如果你想提高应用程序的质量,并添加一个守卫,防止在新版本中出现回归,那么分接比较技术可以为你带来帮助。
关于作者
Alex Soto是Red Hat开发组的软件工程师。他热爱Java世界和软件自动化,信任开源软件模型。Alex Soto 是NoSQLUnit和Diferencia项目的创建者、JSR374专家组成员(用于JSON处理的Java API)、Testing Java Microservices 一书的作者之一(Manning出版)以及几个开源项目的贡献者。自2017年以来,他成为Java冠军和国际演讲者,他介绍新的微服务测试技术和21世纪的持续交付。你可以通过Twitter(@alexsotob)找到他。