Hoverfly 教程:当服务虚拟化遇到 Java

作者:Alex Soto

阅读数:4747 2019 年 6 月 20 日

本文要点

  • 在微服务架构中,服务最重要的部分之一是负责与其他服务通信的模块
  • 我们常常需要以端到端的方式测试服务是如何与其他服务通信的。Mock 并不是有效的解决方案,因为它没有测试通信栈,并跳过了与网络协议(如 HTTP)相关的一切。运行依赖的服务也不是可选方案,因为每次都需要花精力准备相关的进程。
  • 服务虚拟化技术能够模拟依赖服务的行为,它是通过创建代理服务实现的,因此测试是针对服务运行的(也就是针对整个栈进行测试),但它不需要启动真正的服务
  • Hoverfly 是开源、轻量级的服务虚拟化 API 模拟工具,它是用 Go 语言编写的,能够与 Java 紧密集成。

在微服务架构中,应用程序是由多个互连的服务组成的,所有的这些服务一起工作,完成所需的业务功能。因此,典型的企业微服务架构如下所示:

每个服务都依赖其他服务,其中一些由数据库作为支撑。每个服务必须有自己的一组测试(单元、组件、协议等等)以验证功能的正确性,例如,在进行变更之后。

我们关注一个服务,它处于图的中央,如果看一下它的内部,大致如下图所示:

服务通常包含这些层的其中一些(如果不是全部的话),概括起来可以描述如下:

  • 资源:充当服务的入口点。它们解包(unmarshal)以任意协议(如 JSON)进入的消息并转化成领域对象。另外,它们还要验证输入参数是否有效,并负责把域对象转化成协议消息的打包(marshal)过程,以便于返回响应。例如,如果我们采用的技术是 Jakarta EE/MicroProfile,那么 JAX-RS 负责处理所有的这些事情,对于 Spring Boot 或任何其他技术同样如此。
  • 业务:它是业务逻辑,实现服务的业务规则,并编排服务的所有组成部分,如:持久性(Persisitence)和网关层(Gateway layer)。领域对象是该层的一部分。
  • 持久化:该部分把领域对象保存到“持久”后端(SQL 或 NoSQL 数据库)。如果我们的技术是 Jakarta EE/MicroProfile 的话,那么,这部分将由 JPA 来处理(在 SQL 数据库的情况下)。
  • 网关:到目前为止,前面的所有部分在任何其他架构中都是通用的,但是,网关是典型的分布式架构层。网关封装了所有逻辑,从消费者服务(网关实现的地方)到供应者服务(在底层协议与领域对象之间的打包 / 解包操作),以及网络客户端、超时、重试、弹性等等的配置。通常来讲,如果我们使用 Jakarta EE/MircoProfile 和 JSON 作为消息传递协议时,可能是会使用 JAX-RS 作为 Http 客户端与另一个服务通信。

我们可以通过模拟网关层来测试顶层的逻辑。例如, 使用 Mockito 来测试业务层的代码可能如下所示:

复制代码
@Mock
WorldClockServiceGateway worldClockServiceGateway;
@Test
public void should_deny_access_if_not_working_hour() {
// Given
when(worldClockServiceGateway.getTime("cet")).thenReturn(LocalTime.of(0,0));
SecurityResource securityResource = new SecurityResource();
securityResource.worldClockServiceGateway = worldClockServiceGateway;
// When
boolean access = securityResource.isAccessAllowed();
// Then
assertThat(access).isFalse();
}

当然,我们仍然需要验证网关类(WorldClockServiceGateway)是否按预期运行:

  • 它能够实现从特定协议到领域对象的打包 / 解包
  • (Http)客户端配置参数的正确性(如超时、重试、头信息等等)
  • 在网络错误的情况下表现符合预期

为了测试所有的这些点,我们可能会考虑运行网关与之通信的服务,并针对实际服务运行测试。看起来这像是个好的解决方案,但是,这样做有一些问题:

  • 我们需要知道如何从消费者服务的角度来启动供应者服务以及供应者服务传递依赖的其他服务,除此之外,还有所需的数据库。这似乎违反了单一职责原则,消费者服务应该只知道如何部署本身即可,不必关心它所依赖的服务。

  • 如果所有服务都需要数据库的话,那么就要准备数据集。
  • 启动多个服务也意味着,由于内部 / 网络错误,它们中的任何一个服务都可能失败,这样的话,测试失败的原因不是网关类中的错误而是基础设施,这会使得测试变得不稳定。
  • 此外,启动所有必需的服务(即使只有一个)要花费大量时间,因此,我们的测试套件无法提供快速反馈。

我们可能会想到的解决方案之一就是忽略这类测试,因为它们通常不稳定的,并且由于它们需要启动整个系统来运行一个简单的网关测试,所以执行起来需要大量时间。但是,微服务架构中的通信部分是核心部分,它是与系统进行所有交互的地方, 因此,进行测试以验证其行为是否符合预期非常重要。

这个问题的解决方案是服务虚拟化。

什么是服务虚拟化?

服务虚拟化技术能够用来模拟服务依赖项的行为。尽管服务虚拟化通常会与基于 REST API 的服务关联到一起,但是,同样的概念可以应用于任何其他类型的依赖项,如数据库、ESB、JMS 等等。

除了帮助测试内部服务,服务虚拟化还有助于测试不受我们控制的服务、修复导致这类服务变得不稳定的一些常见问题。其中包括:

  • 网络宕机,无法与外部服务通信;
  • 外部服务宕机,并且有一些意外错误;
  • API 方面有所限制。一些公共 API 在速率或每天访问的次数方面上有一些限制。如果我们达到这个限制水平,那么测试就会开始失败。

有了服务虚拟化,我们可以避免所有这些问题,原因是,我们没有调用实际的服务,而是调用了虚拟的服务。

但是,服务虚拟化不仅仅可以用于测试愉快路径(happy path,或称为理想路径)的场景,很多开发人员和测试人员发现它真正的威力在于实际一些边缘场景,这些场景很难针对实际服务进行测试,比如在低延迟响应的情况下或出现意外错误时服务的行为方式。

我们回顾一下是如何在单体架构中测试组件的,我们采用的其实是一种类似的方式,只不过在单体架构,交互是对象之间的,我们将其成为 mock。在使用 mock 的时候,我们通过提供方法调用的预设答案来模拟对象的行为。在使用服务虚拟化时,我们在做类似的事情,但这里不是模拟对象的行为,而是提供远程服务的预设回答。基于这个原因,服务虚拟化有时被称为企业级的 mock。

在下图中,我们可以看到服务虚拟化是如何工作的:

在这个具体案例中,服务之间的通信是通过 HTTP 协议进行的,因此,一个瘦 HTTP 服务器负责消费来自网关类的请求,并提供预设的答案。

运行模式

通常来说,服务虚拟化有两种模式:

  • 回放模式:使用模拟数据,目的是提供响应,而不必把请求转发给实际服务。模拟数据可以手动创建(假如实际服务还不存在),也可以使用捕获模式创建。
  • 记录模式:拦截服务之间的通信,并记录传出的请求和来自实际服务的传入响应。通常,我们会使用捕获模式作为创建初始模拟数据的起点。

根据实现的不同,它们可能包含其他模块,但是,所有的模块都应该包含这两种模式。

Hoverfly

什么是 Hoverfly?

Hoverfly是开源、轻量级的服务虚拟化 API 模拟工具,它是用 Go 语言编写的,能够与 Java 紧密集成。

Hoverfly Java

Hoverfly Java 是围绕 Hoverfly 的 Java 包装器,能够让我们不用关心 Hoverfly 的安装和生命周期的管理。Hoverfly Java 提供了 Java DSL,它能够以程序化生成模拟数据并且与 JUnit 和 JUnit5 实现了深度集成。

Hoverfly Java 通过设置网络 Java 系统属性来使用 Hoverfly 代理。这实际上意味着,Java 运行时和物理网络层之间的所有通信都将被 Hoverfly 代理拦截。这样的话,尽管我们的 HTTP Java 客户端可以指向一个外部站点,如:http://worldclockapi.com,但是,其连接将被 Hoverfly 拦截并转发。

请务必注意,如果我们的 Http 客户端不遵守该 Java 网络代理设置,那么就需要手动设置它。

从这里开始,我们所述的 Hoverfly 和 Hoverfly Java 指的均是 Hoverfly Java。

为了让 Hoverfly 能够和 JUnit5 协同使用,我们需要在构建工具上注册该依赖项:

复制代码
<dependency>
<groupId>io.specto</groupId>
<artifactId>hoverfly-java-junit5</artifactId>
<version>0.11.5</version>
<scope>test</scope>
</dependency>

Hoverfly 模式

除了回放(Playback,在 Hoverfly 中被称为模拟)和记录(Record,在 Hoverfly 中被称为捕获)模式,Hoverfly 还实现了其他模式:

  • 监视(spy):如果模拟数据匹配请求时,则模拟外部 API,否则,把该请求转发给实际的 API。
  • 合成(Synthesize):不是在模拟数据中寻找响应,而是把请求直接传给中间件(定义好的可执行的文件),它将负责消费请求并生成所需的响应。
  • 修改(modify):在转发到目的地之前,请求被发到中间件。在返回到客户端前,响应也将传递给可执行文件。
  • 差异(diff):把请求转发给外部服务,并将响应与当前所存储的模拟进行比较。有了存储好的模拟响应和来自外部服务的实际响应,Hoverfly 就能够检测到两者之间的差异。这些差异被存储下来,供将来使用。

Hoverfly 示例

为了说明 Hoverfly 是如何运行的,我们假设我们有个服务 A(安全服务),该服务需要知道当前时间,这个时间是由部署在http://worldclockapi.com的另一个服务提供的。当我们向http://worldclockapi.com/api/json/cet/now发出 GET 请求时,将返回如下的 JSON 文件:

复制代码
{
"$id":"1",
"currentDateTime":"2019-03-12T08:10+01:00",
"utcOffset":"01:00:00",
"isDayLightSavingsTime":false,
"dayOfTheWeek":"Tuesday",
"timeZoneName":"Central Europe Standard Time",
"currentFileTime":131968518527863732,
"ordinalDate":"2019-71",
"serviceResponse":null
}

该文档中的重要字段是 currentFileTime,它提供了完整的时间信息。

网关类负责与服务的通信,并返回当前时间,如下所示:

复制代码
public class ExternalWorldClockServiceGateway implements WorldClockServiceGateway {
private OkHttpClient client;
public ExternalWorldClockServiceGateway() {
this.client = new OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
}
@Override
public LocalTime getTime(String timezone) {
final Request request = new Request.Builder()
.url("http://worldclockapi.com/api/json/"+ timezone + "/now")
.build();
try (Response response = client.newCall(request).execute()) {
final String content = response.body().string();
final JsonObject worldTimeObject = Json.parse(content).asObject();
final String currentTime = worldTimeObject.get("currentDateTime").asString();
final DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
LocalDateTime localDateTime = LocalDateTime.parse(currentTime, formatter);
return localDateTime.toLocalTime();
} catch(IOException e) {
throw new IllegalStateException(e);
}
}
}

这里比较重要的地方在于,URL 不是参数。我知道,在实际示例中,该信息将来自配置参数,但是,为了简单起见,同时有助于我们可以发现 Hoverfly 在代理所有网络通信,所以该 URL 是硬编码的。

让我们开始来看一些可能的场景,以及如何测试这个 ExternalWorldClockGateway 类。

尚未开发 World Clock Service

如果还没开发 WorldClockService,那么,我们需要在模拟模式中使用 Hoverfly,并提供预设答复。

复制代码
@ExtendWith(HoverflyExtension.class)
public class ExternalWorldClockServiceGatewayTest {
private static final String OUTPUT = "{\n"
+ " \"$id\":\"1\",\n"
+ " \"currentDateTime\":\"2019-03-12T10:54+01:00\",\n"
+ " \"utcOffset\":\"01:00:00\",\n"
+ " \"isDayLightSavingsTime\":false,\n"
+ " \"dayOfTheWeek\":\"Tuesday\",\n"
+ " \"timeZoneName\":\"Central Europe Standard Time\",\n"
+ " \"currentFileTime\":131968616698822965,\n"
+ " \"ordinalDate\":\"2019-71\",\n"
+ " \"serviceResponse\":null\n"
+ "}";
@Test
public void should_get_time_from_external_service(Hoverfly hoverfly) {
// Given
hoverfly.simulate(
SimulationSource.dsl(
HoverflyDsl.service("http://worldclockapi.com")
.get("/api/json/cet/now")
.willReturn(success(OUTPUT, "application/json"))
)
);
final WorldClockServiceGateway worldClockServiceGateway = new ExternalWorldClockServiceGateway();
// When
LocalTime time = worldClockServiceGateway.getTime("cet");
// Then
Assertions.assertThat(time.getHour()).isEqualTo(10);
Assertions.assertThat(time.getMinute()).isEqualTo(54);
}
}

在这里,重要的部分是模拟方法。该方法用于导入测试和远程 Hoverfly 代理之间交互的模拟。在这个具体示例中,它将 Hoverfly 代理配置为返回预设答复,而不是在它收到对端点的 GET 请求时,将流量转发出本地主机。

World Clock Service 已开发完成并在运行中

如果服务已经在运行,那么,我们可以使用捕获模式来生成初始模拟数据集,而不必手动生成它们。

复制代码
@ExtendWith(HoverflyExtension.class)
@HoverflyCapture(path = "target/hoverfly", filename = "simulation.json")
public class ExternalWorldClockServiceGatewayTest {
@Test
public void should_get_time_from_external_service() {
// Given
final WorldClockServiceGateway worldClockServiceGateway = new ExternalWorldClockServiceGateway();
// When
LocalTime time = worldClockServiceGateway.getTime("cet");
// Then
Assertions.assertThat(time).isNotNull();

在这个测试用例中,Hoverfly 以捕获模式启动 。这意味着,请求要通过实际的服务,并且,该请求和响应会在本地存储,以便于在模拟模式下的重用。在之前的测试中,模拟数据放置在 target/hoverfly 目录中。

存储了模拟数据之后,我们就可以转换到模拟模式,不再与实际服务进一步通信。

复制代码
@ExtendWith(HoverflyExtension.class)
@HoverflySimulate(source =
@HoverflySimulate.Source(value = "target/hoverfly/simulation.json",
type = HoverflySimulate.SourceType.FILE))
public class ExternalWorldClockServiceGatewayTest {
@Test
public void should_get_time_from_external_service() {
// Given
final WorldClockServiceGateway worldClockServiceGateway = new ExternalWorldClockServiceGateway();
// When
LocalTime time = worldClockServiceGateway.getTime("cet");
// Then
Assertions.assertThat(time).isNotNull();

HoverflySimulate 注解允许我们从文件、classpath 或 URL 导入模拟数据。

我们可以把 HoverflyExtension 设置为在模拟和捕获模式之间自动切换。如果没有找到源,那么,它将运行于捕获模式,否则,使用模拟模式。这意味着,我们不需要在使用 @HoverflyCapture 和 @HoverflySimulate 之间手动地切换。这个 Hoverfly 功能非常简单但非常强大。

复制代码
@HoverflySimulate(source =
@HoverflySimulate.Source(value = "target/hoverfly/simulation.json",
type = HoverflySimulate.SourceType.FILE),
enableAutoCapture=true)

检测过时的模拟数据

在使用服务虚拟化时,我们面临的一个问题是,如果我们的模拟数据是陈旧的,尽管所有的测试都通过,在针对实际服务运行代码时,我们可能会遇到故障,那该怎么办?

为了检测这个问题,Hoverfly 实现了 Diff 模式,该模式将请求转发给远程服务,并与本地所存的模拟数据的响应进行比较。当 Hoverfly 结束两个响应的比较后,存储两者的差异,并向传入的请求提供来自远程服务的实际响应。在此之后,利用 Hoverfly Java 时,我们可以假定不会再发现差异。

复制代码
@HoverflyDiff(
source = @HoverflySimulate.Source(value = "target/hoverfly/simulation.json",
type = HoverflySimulate.SourceType.CLASSPATH))

通常,我们不希望一直运行 Diff 模式。实际上,这取决于一系列因素,例如,我们是否能够控制远程服务、它是否进行了大量开发。根据情况,我们会希望每天测试 Diff 模式一次,或每周一次,或我们准备进行最终发布的时候进行一次测试。

这个验证任务的典型工作流如下所示:

  1. 运行 Diff 模式测试。
  2. 如果发生失败的话,那么删除过时的模拟数据。
  3. 触发一个新的服务构建任务,迫使 Hoverfly 重新捕获模拟数据。
  4. 构建可能失败,原因是新捕获的数据与之前捕获的数据不同。然后,开发人员观察到服务出现故障,他们可以开始修复代码以适应新的服务输出。

延迟响应

在发回响应前,Hoverfly 允许我们添加一些延迟。这允许我们模拟延迟并测试我们的服务是否正确地处理了延迟。要配置它,我们只需要使用模拟 DSL 来设置要应用的延迟。

复制代码
hoverfly.simulate(
SimulationSource.dsl(
HoverflyDsl.service("http://worldclockapi.com")
.get("/api/json/cet/now")
.willReturn(success(OUTPUT, "application/json")
.withDelay(1, TimeUnit.MINUTES)
)
));

验证

我已经提到过,我们可以把服务虚拟化看作是企业级的 mock。mock 最常用的模拟功能之一就是验证在测试过程中是否真正调用了具体的方法。

借助 Hoverfly,我们可以执行完全相同的操作,验证是否已经向远程服务端点发出特定请求。

复制代码
hoverfly.verify(
HoverflyDsl.service("http://worldclockapi.com")
.get("/api/json/cet/now"), HoverflyVerifications.times(1));

该代码片段会验证网关类是否从主机 worldclockapi.com 访问过 /api/json/cet/now 端点。

更多功能

Hoverfly 还有一些其他功能在这里没有展示,在某些情况下它们可能会非常有用:

  • 请求字段匹配器:默认情况下,DSL 请求构建器假定在传入字符串时,是需要完全匹配的。我们也可以传递一个匹配器,这样,匹配就不会那么严格(如 service(matches(“www.*-test.com”)))
  • 响应模板:如果我们需要根据请求数据动态地构建一个响应,那么我们可以利用模板来实现。
  • SSL:当请求传给 Hoverfly 时,它需要把请求解密以持久化(捕获模式)或实施匹配。因此,最终的效果是,我们在 Hoverfly 和远程服务之间使用 SSL,在客户端和 Hoverfly 之间同样也使用 SSL。为了让这个过程更加顺利,Hoverfly 附带自己的自签名证书,当我们把它实例化时,它将自动受到信任。
  • 有状态模拟:有时候,根据之前的请求,我们需要给响应添加一些状态。例如,在发送 delete 请求后,如果查询被删除的元素,我们可能收到 404 状态错误。
复制代码
SimulationSource.dsl(
service("www.example-booking-service.com")
.get("/api/bookings/1")
.willReturn(success("{\"bookingId\":\"1\"}", "application/json"))
.delete("/api/bookings/1")
.willReturn(success().andSetState("Booking", "Deleted"))
.get("/api/bookings/1")
.withState("Booking", "Deleted")
.willReturn(notFound())

在前面的代码中,当使用 DELETE HTTP 方式把请求发送到 /api/bookings/1 时,Hoverfly 把状态设置为 Deleted(已删除)。当使用 GET HTTP 方式把请求发送到 /api/bookings/1 时,由于状态是 Deleted,因此返回没有找到的错误信息。

契约测试

服务虚拟化是一个有价值的工具,可以帮助我们编写测试,但是,它不能替代契约测试。

契约测试的主要目的是,验证消费者和服务供应者是否能够从业务的角度正确地通信,双方要遵守它们达成一致的契约。

另一方面,服务虚拟化可以应用于:

  • 测试已创建但尚未制定任何契约的服务
  • 测试与我们没有契约的第三方服务的集成
  • 测试一些边缘场景(延迟、无效输入等等)
  • 有助于编写集成测试,在这种情况下,客户端的更改通常比依赖的服务更频繁
  • 在依赖服务不可用或负载测试成本太高的时候能够进行测试
  • 优化服务,在本地能够最大限度地减少内存占用
  • 与契约测试类似,测试(及其数据)易于创建或维护。

结论

服务虚拟化(及 Hoverfly)是我们测试服务的又一个工具,特别适用于服务之间的通信,我们可以使用某种“单元”测试的方法来实现。我用“单元”这个词的原因是,我们的测试没有实际运行远程服务,因此我们只是测试系统的一个单元而已。这允许我们测试通信层,而不必运行整个系统,我们不需要初始化整个堆栈。请注意,从处于测试状态中的消费者服务的角度来看,它对服务供应者一无所知,因此,虚拟服务和实际服务没什么不同。反过来,这意味着消费者服务不知道它是否存在于模拟中。

随着微服务架构的出现,服务虚拟化是避免与其他服务通信时出现意外的必要工具,在具有大量依赖项的企业环境中工作的时候更是如此。服务虚拟化还可以用于在测试阶消除对第三方服务的依赖;测试应用程序在遇到延迟或其他网络问题时的行为。最后,服务虚拟化还可以用于遗留项目,在这种项目中单体应用程序如果需要请求访问第三方服务,采用其他手段很难进行模拟。

作者简介

Alex Soto 是红帽开发团队的软件工程师。他对 Java 领域和软件自动化充满热情,并相信开源软件模型。Alex 是 NoSQLUnit 和 Diferencia 项目的创建者,是 JSR374(Java API for JSON Processing)专家组的成员,是《Testing Java Microservices by Manning》的合著者,同时是一些开源项目的贡献者。从 2017 年以来,他一直是 Java Champion,也是国际会议的演讲者,他所谈论的话题包括微服务的新测试技术以及 21 世纪的持续交付。他的推特账号是 @alexsotob。

查看英文原文:Service Virtualization Meets Java: Hoverfly Tutorial

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论