在微服务架构中,应用程序是由多个互连的服务组成的,所有的这些服务一起工作,完成所需的业务功能。因此,典型的企业微服务架构如下所示:
每个服务都依赖其他服务,其中一些由数据库作为支撑。每个服务必须有自己的一组测试(单元、组件、协议等等)以验证功能的正确性,例如,在进行变更之后。
我们关注一个服务,它处于图的中央,如果看一下它的内部,大致如下图所示:
服务通常包含这些层的其中一些(如果不是全部的话),概括起来可以描述如下:
我们可以通过模拟网关层来测试顶层的逻辑。例如, 使用 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 =newSecurityResource(); securityResource.worldClockServiceGateway = worldClockServiceGateway; // When boolean access = securityResource.isAccessAllowed(); // Then assertThat(access).isFalse(); }
当然,我们仍然需要验证网关类(WorldClockServiceGateway)是否按预期运行:
为了测试所有的这些点,我们可能会考虑运行网关与之通信的服务,并针对实际服务运行测试。看起来这像是个好的解决方案,但是,这样做有一些问题:
我们可能会想到的解决方案之一就是忽略这类测试,因为它们通常不稳定的,并且由于它们需要启动整个系统来运行一个简单的网关测试,所以执行起来需要大量时间。但是,微服务架构中的通信部分是核心部分,它是与系统进行所有交互的地方, 因此,进行测试以验证其行为是否符合预期非常重要。
这个问题的解决方案是服务虚拟化。
服务虚拟化技术能够用来模拟服务依赖项的行为。尽管服务虚拟化通常会与基于 REST API 的服务关联到一起,但是,同样的概念可以应用于任何其他类型的依赖项,如数据库、ESB、JMS 等等。
除了帮助测试内部服务,服务虚拟化还有助于测试不受我们控制的服务、修复导致这类服务变得不稳定的一些常见问题。其中包括:
有了服务虚拟化,我们可以避免所有这些问题,原因是,我们没有调用实际的服务,而是调用了虚拟的服务。
但是,服务虚拟化不仅仅可以用于测试愉快路径(happy path,或称为理想路径)的场景,很多开发人员和测试人员发现它真正的威力在于实际一些边缘场景,这些场景很难针对实际服务进行测试,比如在低延迟响应的情况下或出现意外错误时服务的行为方式。
我们回顾一下是如何在单体架构中测试组件的,我们采用的其实是一种类似的方式,只不过在单体架构,交互是对象之间的,我们将其成为 mock。在使用 mock 的时候,我们通过提供方法调用的预设答案来模拟对象的行为。在使用服务虚拟化时,我们在做类似的事情,但这里不是模拟对象的行为,而是提供远程服务的预设回答。基于这个原因,服务虚拟化有时被称为企业级的 mock。
在下图中,我们可以看到服务虚拟化是如何工作的:
在这个具体案例中,服务之间的通信是通过 HTTP 协议进行的,因此,一个瘦 HTTP 服务器负责消费来自网关类的请求,并提供预设的答案。
通常来说,服务虚拟化有两种模式:
根据实现的不同,它们可能包含其他模块,但是,所有的模块都应该包含这两种模式。
Hoverfly 是开源、轻量级的服务虚拟化 API 模拟工具,它是用 Go 语言编写的,能够与 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>
除了回放(Playback,在 Hoverfly 中被称为模拟)和记录(Record,在 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,它提供了完整的时间信息。
网关类负责与服务的通信,并返回当前时间,如下所示:
复制代码
publicclassExternalWorldClockServiceGateway implements WorldClockServiceGateway { privateOkHttpClient client; publicExternalWorldClockServiceGateway(){ this.client =newOkHttpClient.Builder() .connectTimeout(20, TimeUnit.SECONDS) .writeTimeout(20, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build(); } @Override public LocalTime getTime(Stringtimezone){ final Request request =newRequest.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) { thrownewIllegalStateException(e); } } }
这里比较重要的地方在于,URL 不是参数。我知道,在实际示例中,该信息将来自配置参数,但是,为了简单起见,同时有助于我们可以发现 Hoverfly 在代理所有网络通信,所以该 URL 是硬编码的。
让我们开始来看一些可能的场景,以及如何测试这个 ExternalWorldClockGateway 类。
如果还没开发 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 请求时,将流量转发出本地主机。
如果服务已经在运行,那么,我们可以使用捕获模式来生成初始模拟数据集,而不必手动生成它们。
复制代码
@ExtendWith(HoverflyExtension.class) @HoverflyCapture(path="target/hoverfly",filename="simulation.json") publicclassExternalWorldClockServiceGatewayTest { @Test public void should_get_time_from_external_service(){ // Given final WorldClockServiceGateway worldClockServiceGateway =newExternalWorldClockServiceGateway(); // 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)) publicclassExternalWorldClockServiceGatewayTest{ @Test publicvoidshould_get_time_from_external_service() { // Given finalWorldClockServiceGateway worldClockServiceGateway =newExternalWorldClockServiceGateway(); // 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 模式一次,或每周一次,或我们准备进行最终发布的时候进行一次测试。
这个验证任务的典型工作流如下所示:
在发回响应前,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 还有一些其他功能在这里没有展示,在某些情况下它们可能会非常有用:
复制代码
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