spring cloud contract的应用实现与概念理解-服务请求者一侧的落地-细节较多避免踩坑卡壳

目录

持续集成,为了微服务

自动测试,为了持续集成

自动测试,自动集成测试

集成测试、测试驱动开发、契约

Spring contract的stub桩的例子

1,编制契约,创建stub桩

2,利用stub桩支持服务消费者实现的测试

后记与理解


持续集成,为了微服务

        笔者的经验认为,微服务的出现是为了应对传统SOA架构在多服务背景下的疲软,本质上是SOA的进一步衍生。微服务是基于领域实现的隔离,从而在更大的粒度上取得高内聚(内部一致而连贯)、外部松耦合的好处。微服务是一种治理服务的手段。而微服务之所以能够解决传统SOA、单块大单体程序的问题,有个基本的前提是微服务自身的健壮性、灵活性、可扩展性和持续敏捷。100个微服务就是100个闯祸点,从这个角度而言,服务的稳健性、正确性、一致性比以前的要求还要高。其实就一个系统的整个生命周期而言,微服务架构也带来了很多复杂性为代价的。这就需要通过持续集成这样的手段来使得这些相对传统系统架构所不常见的复杂性和不确定性是可控的,或者说是通过快速的、持续的试错、去除糟粕保留精华而持续精进。

自动测试,为了持续集成

        持续集成的逻辑是:在为应对变化(主动也罢、被动也罢)所开展的持续修改、完善中,过程是尽量的顺滑的,低摩擦、低返工的,通过小步快跑、多跑避免各种各样的潜在问题导致卡停。这种选择主要是为了应对软件开发的老短板,也就是修改本身可能导致故障的自然现象,所谓“牵一发动全身”的一般现实。不管什么原因,修改代码就有可能导致原有的功能或服务失效、错误或者与原有期望不一致。修改后代码的可靠性,需要的是明确、清晰的底气而不是运气。有问题早发现、早解决,将极大的减少资源浪费,降低无止尽的返工。另外,打铁要趁热,不是口号,而是现实。《敏捷革命》中作者曾经举个一个例子,一个问题当时发现当时解决和1个月以后发现再解决,解决的时间相差是24倍!!

        持续集成需要用定点测试结合回归测试来落实。而人工端到端测试在效率上是灾难的,不可能低成本(资源也罢、时间也罢)做到回归测试。因此,只有自动测试才可能支撑真正有效果、有效率的持续集成。 做项目也罢,开发软件也罢,都是有问题和缺陷的,尽早发现,尽早处理,这就是自动测试的价值和意义,也决定了自动测试实际上是持续集成和最终确保微服务质量的重要内容和基石。

        在我以前的很多项目中,往往一到后期,就是各种BUG在SIT\UAT阶段纠缠。这些问题的产生,很大程度上是因为开发阶段放弃了开发人员自测而将希望延后到测试人员身上(效率很低、扯皮严重)。这种项目,往往走向漫不经心的持续扯皮、士气低落、团队崩塌。这不该是技术工作可以有和应该有的样子。正因如此,在读到《敏捷革命》一书时,对杰夫·萨瑟兰所说的:想要找到方法以“指导团队变得更高效、更愉快、更具有相互扶持精神、更有乐趣以及更加令人向往”,对这样高尚同时睿智、务实的动机,我深以为然,为了“更开心、更愉快的工作”。事实上,不管是《领域驱动设计》、还是《微服务架构设计模式》、《敏捷革命》等书以及马丁·福勒都一直在强调自动测试,这不是巧合,而是因为自动测试确实是当下软件行业进化的一个重要手段。通过自动测试才可能实现可信的小步快跑,使得机会、资源和精力合理匹配。

自动测试,自动集成测试

        事实上,很早就已经有各种框架在支持自动测试,对于java语言而言就是junit,对于微服务而言比较主流的就是spring cloud contract。

        spring cloud contract 是spring cloud的组件,不过完全可以与eruake服务发现这类核心组件一样分开看待,单独作为面向服务(如restful形式的服务)的集成测试框架来使用。事实上单元测试而言,不需要contract支持。contract的存在意义和价值在于集成测试和组件测试(验收测试一般在需要最接近真实生产的完整链条背景下做端到端测试,这个环节反倒是维持传统的人工测试更合理些)。事实上,contract也是有效实现所谓测试驱动开发(TDD)的一种框架。contract的本质就是为服务的消费者(我喜欢称之为服务请求者、客户端client)提供后台服务的模拟(stub),也可以为服务的提供者(服务端 server)提供前端请求的模拟(mock)。后台服务的模拟桩(stub)使服务消费者可以踏实的集中精力开发自身业务逻辑(或者说领域模型)并测试是否正确的调用了后台服务(也就是下游服务),因为服务和消费者双方都要遵守契约、也都会遵守契约。相应的,请求的模拟(mock)使得服务提供者可以集中精力开发自身的领域模型,并可以在没有客户端实际参与支持的情况下自行验证提供的服务是否符合契约中的承诺,好修订错误。

        在具体实用中,对于后端的服务和服务之间此种集成测试是完全覆盖的。但如果服务请求者是其他技术实现的前端,这种情况下spring cloud contract的价值只在于服务提供者一方自身的开发测试支持了。

集成测试、测试驱动开发、契约

        contract的中文含义是契约,这就是经常会听到的“契约编程”的一种实质落地。服务提供者和服务消费者的团队(或者人)坐下来讨论,根据消费者的需求、提供者的条件和承诺,经过谈判形成服务的协议,其实就是接口(或者说服务API)的定义(一般表现为:一个请求和响应的例子,包括通信方式、消息格式、消息组成及其意思和确切的内容)。也许因为提供者太忙或者其他考虑,就是懒得管消费者的具体需求,只给用现有接口,最多给你的接口定义文档和调试支持,爱用不用。这样的情况就是DDD中所谓的conformist-跟随者模式,所以这个谈判是必然的,或者说有条件的话最好谈判清楚。

        在contract框架中,契约也就是接口需要由双方协商确定后,编写一个定义文件(我喜欢叫做contract脚本文件)。一般是groovy语言的文件,也可以是yaml的。关于该文件是由服务提供者还是服务请求者编辑?其实都可以,看合作模式或者说文化了。这个脚本文件内容的有关细节在后续的内容中,我再详细描述。基于这个contract脚本文件,contract框架可以生成一个stub(桩),这个桩的目的在于给服务请求者提供后台服务某个API接口的模拟(stub)条件。如何生成,以及如何让这个桩工作起来,后续内容再详细描述。有了这个桩(stub),服务请求者就可以在test类中编辑测试方法,调用本地业务逻辑处理方法(或者说本地服务,这些服务其实就是当前开发的目的或者说目标代码)。值得具体指出的是,整理的本地业务逻辑处理方法才是测试真正想要验证、考验的目标,contract契约是为此测试提供的外部服务环境的模拟支撑。在这些业务逻辑处理方法中,调用的下游服务API是遵守contract定义的、将按照契约定义的预期值进行响应。在测试模式下,发起的方法调用实际上请求是发到stub桩,而不是真正的下游服务,从而既验证调用的方式的正确性,又使得业务逻辑处理逻辑的开发实现能够得到验证(比如对响应回来的数据进行在加工以后的结果是否符合预期),基于测试方法中的断言,程序可以在编译打包或者专门的测试任务执行时提示有关的实现是否正确,从而能够在靠前的阶段就发现和修正错误,避免BUG沉淀到后面(比如用户验收阶段,甚至生产运行阶段)才暴露出来。还是那句话,早发现,早解决。(另:由于篇幅和复杂度的控制,本章只讲述spring contract对于服务消费者一侧的意义和使用,服务提供者一侧在以后的文章中提供)

 下面是我所理解的过程序列和概念示意图来表达这个过程的逻辑。

事实上,以前我自己曾经利用python开发过一个挡板程序,用于支持集成测试,作为模拟的服务提供者,基本逻辑也是和contract差不多,不过在编码上不一样,而且定位还是更倾向于挡板,不具备直接、自然的支持契约化开发的能力。这也体现了微服务的一种特征,由于采取了进程间通信、接口化对接,从而在开发语言和运行平台上自然而然的是不排他的。(这种特征是微服务的一项典型优势,技术栈的捆绑不那么紧)

Spring contract的stub桩的例子

下面我根据官网的文档开发的一个例子,并说明一下其中踩过的坑和细节上的理解。

1,编制契约,创建stub桩

不管是官网还是很多其他网友的文章中,关于contract脚本由谁来编制,似乎并没有一个统一的说法。在《微服务架构设计模式》中,作者提倡,如果服务消费者权利比较大且可以驱使服务提供者的背景下,提倡由服务消费者团队来开发contract然后把版本提供给服务提供者。事实上,也有些地方提倡由服务提供者来编制contract(契约-模拟),因为如果服务提供者团队具有更大的权威或者风险管控压力的话,那就不可能把主动权和版本修订权提供给服务请求者的团队。

下面的例子,我是按照官网的做法开展的。

以下代码的基本假设和约定:服务提供者、服务请求者都是基于spring boot进行开发的Java应用,通过maven进行制品(或者说构件)管理。

假设服务请求团队在与服务提供者团队讨论后,确定了接口的输入输出(也就是服务的接口需求规格),通信基于HTTP,REST API风格的同步响应模式(事实上contract也支持消息队列等其他格式,不过鉴于控制学习的复杂度,我们放弃在这里讨论学习其他通信方式)。

首先,消费者团队将服务提供者的工程下载到本地(通过GIT工具访问服务提供者工程的库取得,或者其他野蛮一点的方法,拷贝工程代码然后maven指定本地位置的方式都可以)。然后在该工程的src/test/resource/contract/目录中(此位置可以通过在application.yml等配置文件中修改参数contractsDslDir来改变,不设置的话默认是这个目录)创建一个contract脚本文件,该文件的名字不重要,可以随意取,比如:service_sample_contract.grovvy,该脚本文件内容如下:

package contracts

org.springframework.cloud.contract.spec.Contract.make {
    // 预期的请求报文
    request {
        method 'PUT' //HTTP PUT方法
        url '/fraudcheck' //请求的URL地址
        body([
               "client_id": $(regex('[0-9]{10}')),
               loanAmount: 99999
        ])
        headers { //HTTP 头中 文档类型为 JSON
            contentType('application/json')
        }
    }
    // 预期的响应报文
    response {
        status OK() //HTTP 响应 200 成功
        body([ //响应的报文体内容 两字段 checkStuats 、reason
               checkStuats: "FRAUD",
               "reason": "Amount too high"
        ])
        headers {
            contentType('application/json')
        }
    }
}

说明:本实例是参照官网start文档开发,绝大部分代码也是参照官网编写。因而,具体的业务领域模型(或者说提供的功能,解决的问题)也如原网所述,即一个对某笔贷款是否过大(too high)进行判断并以此认定是否有欺诈(FRAUD)嫌疑的业务服务。

深入学习 脚本怎么写 可以到官网学习:Spring Cloud Contract

如上所示,可以看出脚本本质上就是明确写死了请求和响应的内容,可以对HTTP的方法、头和体等元素进行定制,和我们自己开发一个外部挡板没有太大区别。

到此,为了创建一个stub(测试模拟桩)所需要进行的编码就完成了。相比其他文章里面的更神秘的流水描述,我倾向于特别声明一下,桩的建立所需要的编码确实就完成了。为了创建这个stub接下来需要做的工作就是配置spring contract依赖,修改POM.XML文件,将contract组件置入工程中。

首先,由于contract本质上需要依赖cloud的支撑,工程需要专门加入cloud的dependency管理

	<dependencyManagement>
		<dependencies>
			<dependency>
				<groupId>org.springframework.cloud</groupId>
				<artifactId>spring-cloud-dependencies</artifactId>
				<version>${spring-cloud-release.version}</version>
				<type>pom</type>
				<scope>import</scope>
			</dependency>
		</dependencies>
	</dependencyManagement>

这里所依赖的版本 有关配置如下:

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<spring-cloud-release.version>2021.0.3</spring-cloud-release.version>
		<spring-cloud-contract.version>3.1.3</spring-cloud-contract.version>
	</properties>

然后,在build元素中加入 contract 的maven插件和打包依赖

<plugin>
				<groupId>org.springframework.cloud</groupId>
				<artifactId>spring-cloud-contract-maven-plugin</artifactId>
				<version>${spring-cloud-contract.version}</version>
				<extensions>true</extensions>
				<configuration>
					<baseClassForTests>toni.com.cn.BaseTestClass</baseClassForTests>
				</configuration>
				<!-- if additional dependencies are needed e.g. for Pact -->
				<dependencies>
					<dependency>
						<groupId>org.springframework.cloud</groupId>
						<artifactId>spring-cloud-contract-pact</artifactId>
						<version>${spring-cloud-contract.version}</version>
					</dependency>
				</dependencies>
			</plugin>

        需要特别再重申的是,以上的代码和配置修改,都不是服务消费者一侧的工程,而是在服务提供者一侧的工程中。

        所以,一般而言,如果契约是服务消费者团队编辑,则该团队需要有服务提供者一方的代码或者说需要有该工程的版本访问权限,比如GIT库的访问权。很多文章中没有强调这一点,也许是因为很多时候开发的前后端并没有隔离,就是相同的两三个人,个个都是root型王者,没有权限问题。

        从这个角度来判断,把契约编辑权限交给服务消费者团队编辑是有问题的,因为微服务的本质是相互隔离,把复杂度隔离开、是分解,把权利和责任分派出去。如果没有彼此,耦合度必然会有意无意的越来越紧,混淆在一起。当然,也有可能,服务消费者团队根本不具备服务提供者团队工程的代码权限,其实也可以做到对上面创建的模拟stub桩的使用(因为,实际上甚至不需要把stub桩的实现 纳入到服务提供者工程中,完全可以单独做一个工程),这我在后面会提到。

        然后,在该工程下,执行 mvnw clean install -Dmaven.test.skip=true 就可以生成stubs.jar包了。生成成功的话,可以看到类似如下的console输出:

INFO] Installing D:\workspace\normal\contract-sample-server\target\contract-sample-server-0.0.1-SNAPSHOT.jar to D:\mavenrepo\repository\toni\com\cn\contract-sample-server\0.0.1-SNAPSHOT\contract-sample-server-0.0.1-SNAPSHOT.jar
[INFO] Installing D:\workspace\normal\contract-sample-server\pom.xml to D:\mavenrepo\repository\toni\com\cn\contract-sample-server\0.0.1-SNAPSHOT\contract-sample-server-0.0.1-SNAPSHOT.pom
[INFO] Installing D:\workspace\normal\contract-sample-server\target\contract-sample-server-0.0.1-SNAPSHOT-stubs.jar to D:\mavenrepo\repository\toni\com\cn\contract-sample-server\0.0.1-SNAPSHOT\contract-sample-server-0.0.1-SNAPSHOT-stubs.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

        这里故意用 -Dmaven.test.skip=true 跳过了生成安装环节的测试步骤。

        这个时候,如果contract是在服务提供者的工程中编制的(其实不是必须的),最终会生成两个jar包,一个是服务本身的jar包,一个是桩stubs.jar包。这个stubs.jar包就是给服务消费者开发时用的,作为所依赖目标服务的模拟。

本例子工程的基本信息如下:

    <groupId>toni.com.cn</groupId>
    <artifactId>contract-sample-server</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>
    <name>contract-sample-server</name>

此时,产生的包是:contract-sample-server-0.0.1-SNAPSHOT.jar 、测试桩的包是: contract-sample-server-0.0.1-SNAPSHOT-stubs.jar 。由于是install,因而落地的位置就在本地的maven库目录下。如果只是package打包,有关的jar包将在target目录中。

关于本地maven库的位置,可以在settings.xml文件中定义:<localRepository>D:/mavenrepo/repository</localRepository>

因而,生成的目标的两个包就在“D:\mavenrepo\repository\toni\com\cn\contract-sample-server\0.0.1-SNAPSHOT”下。了解这个机制是有意义的,后面会说明。

        需要注意的地方是,以上编译打包的指令中专门加入了“-Dmaven.test.skip=true”,这一点不能漏掉,因为stub的实现(创建)其实不需要依赖于任何测试类的存在,但是如果不省略测试过程的话,会在install阶段失败。报错类似如下:

ContractVerifierTest.java:[3,19] 找不到符号
[ERROR]   符号:   类 BaseTestClass
[ERROR]   位置: 程序包 toni.com.cn

这是因为spring contract 插件默认会创建一个ContractVerifierTest类,而这个类的基础来源是POM.XML文件中插件配置的“<baseClassForTests>toni.com.cn.BaseTestClass</baseClassForTests>”。这个类的源代码文件实际上不需要存在,我没有去编辑也不需要编辑,因为我们当前,只是需要一个服务消费者一侧用到的stub,就不需要专门编制这样的一个测试类,通过忽略测试错误就可以略过此步骤依赖。事实上,自己编辑一个这个类也是可以促成通过该检查的,只是那是在服务提供者一侧做mock进行Verifier验证测试的时候才有意义,而制作stub桩而言,不依赖此种类存在。

如果想要实现一个 BaseTestClass(再次声明不是必须的) 可以 参考如下:

package toni.com.cn.contract_sample;


import toni.com.cn.contract_sample.controller.FraudController;

import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;

import io.restassured.module.mockmvc.RestAssuredMockMvc;
import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification;
import io.restassured.response.ResponseOptions;

import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static io.restassured.module.mockmvc.RestAssuredMockMvc.*;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = toni.com.cn.contract_sample.App.class)
public class BaseTestClass {

    @BeforeEach
    public void setup() {
        RestAssuredMockMvc.standaloneSetup(new FraudController());
    }
    
    @Test
	public void validate_service_first_sample() throws Exception {
		// given:
			MockMvcRequestSpecification request = given()
					.header("Content-Type", "application/json")
					.body("{\"client.id\":\"9487139087\",\"loanAmount\":99999}");

		// when:
			ResponseOptions response = given().spec(request)
					.put("/fraudcheck");

		// then:
			assertThat(response.statusCode()).isEqualTo(200);
			assertThat(response.header("Content-Type")).matches("application/json.*");

		// and:
			DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
			assertThatJson(parsedJson).field("['checkStuats']").isEqualTo("FRAUD");
			assertThatJson(parsedJson).field("['Reason']").isEqualTo("Amount too high");
			System.out.println("test pass");
			System.out.println("响应报文如下:");
            System.out.println(response.getBody().asString());
	}
}

以上测试类本质上是在模拟外部请求(上游系统的请求),将发往真正的服务实现,验证真正的服务实现。

2,利用stub桩支持服务消费者实现的测试

接下来,就可以将此stub桩用于服务消费者的内部处理有关的测试用了。

本例子中,服务消费者一侧的工程基本信息是:

<groupId>toni.com.cn</groupId>
    <artifactId>contract-sample-client</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>contract-sample-client</name>
    <description>Demo project for Spring Boot</description>

    服务消费者一侧的业务逻辑是:假设只是直白的把已有用户的id号和贷款金额提交给服务提供者(至于取得欺诈与否的判断后做什么事情在本实例中忽略掉,避免我们的关注焦点在其他业务细节中丢掉)。当然,还可以有其他更复杂的各种逻辑,只是,本例子的目的是演示、体现测试的创建方式、使用方式和工作方式,所以不更编造更复杂的领域逻辑了。

以下主要是服务消费者的主要领域逻辑代码

例子的领域实现

创建 金融客户 实体类

package toni.com.cn.contract_sample_Client;
import lombok.Data;
@Data
public class Client {
	private String id;
	public Client(String id) {
		this.id=id;
	}
}

创建 申请贷款的 命令对象

package toni.com.cn.contract_sample_Client;

public class LoanRequire {
	public String client_id;
	public int loanAmount;
	public LoanRequire(Client client,int loanAmount) {
		this.client_id=client.getId();
		this.loanAmount = loanAmount;
	}
}

创建 响应对象-贷款的申请检查结果

package toni.com.cn.contract_sample_Client;

import lombok.Data;

@Data
public class LoanCheckResult {	
	private String checkStuats;
	private String reason;
}

创建 服务消费者自身的一个业务逻辑处理loanCeck(这里的源代码中也使用了service关键字,是因为这个对象是为本服务消费者工程中的客户service的,可能容易与被模拟的stub混淆)。在这个业务规则处理类 中,将调用被模拟的服务的桩(stub),实际上这个stub 已经通过配置POM.XML,纳入到本工程(也就是服务消费者)的依赖库中。具体配置在后面的章节中会描述。

在执行test测试时,模拟桩的那个stub jar包,将会和本工程一起启动,并占用8090端口,该端口的具体值可以在测试类中设定和修改(后面可以看到),不过最好保持与被模拟的服务一致,从而尽量保持测试时候的代码、配置和真实运行时一致。也可以联想得到,由于在同一个设备上运行,如果被测试的服务消费者也是一个提供HTTP服务的程序,那要注意需要本工程配置成非8090端口的监听才能启动得起来(避免与stub端口争抢)。

以下代码为将要被测试的本地服务

package toni.com.cn.contract_sample_Client;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

@Service
public class ServiceForLoan {
	@Autowired
	private RestTemplate restTemplate;
	//@Value(${ServerAdress})
	private String ServerAdress="localhost";//这个地方建议参数ServerAdress配置化,这样避免开发测试环境和生产,
	public LoanCheckResult loanCheck(LoanRequire request) throws RestClientException {
		// TODO Auto-generated method stub
		LoanCheckResult result=new LoanCheckResult();
		HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
		ResponseEntity<String> response = restTemplate.exchange("http://"+ServerAdress+":8090/fraudcheck", 
				HttpMethod.PUT,
		        new HttpEntity<>(request, headers), String.class);
		if(response.getStatusCode()==HttpStatus.OK)
		{
			try {
				ObjectMapper objectMapper = new ObjectMapper();
				result = objectMapper.readValue(response.getBody(), LoanCheckResult.class);
			} catch (JsonProcessingException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
		return result;
	}
}

说明:出于当前例子contract测试的基本逻辑,这里访问的url是指向本地的,实际工作中应参数化,并根据环境改变(对于不同的后端服务实例、模拟桩?真实服务?)。不过,如何根据环境或者版本目标去动态的改变此参数是另外一个话题了。值得一提的是,从编码和配置来看,似乎也完全可以把stubs.jar工程放在另一个设备上,启动起来(比如:jar -jar contract-sample-server-0.0.1-SNAPSHOT-stubs.jar)。但实际试验发现,并不能这样,当尝试单独启动 这个 stub jar包时,会报错:“contract-sample-server-0.0.1-SNAPSHOT-stubs.jar中没有主清单属性”。解开此jar包可以看出,起内部根本没有class文件,都是脚本文件和配置文件,所以,由此看出,必须集成在spring contract的工程中来启动才行。所以,只能在具体的被测试工程中,随测试用力一并启动。

注意,以上实质上都是业务领域编码的范畴,实上不管有没有contract测试都是要写的工作代码。正是如此,我们也可以看到,测试、测试依赖、模拟(stub)和真正交付的工作代码(或说领域模型代码)是完全分开的,工作代码自身的运行根本不依赖、也不需要知道测试桩、测试类的存在。

测试类的编写

下面是contract测试类(在src/test/java的子目录中)和测试方法内容

package toni.com.cn.contract_sample_Client;


import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;


import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner;
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties.StubsMode;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;

//@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@AutoConfigureStubRunner(ids = {"toni.com.cn:contract-sample-server:0.0.1-SNAPSHOT:stubs:8090"}, stubsMode = StubsMode.LOCAL)

public class LoanApplicationServiceTests {
	
	@Autowired
	ServiceForLoan service;
	
    
	@Test
	public void shouldBeRejectedDueToAbnormalLoanAmount() {
	    // given:
		LoanRequire request = new LoanRequire(new Client("1234567890"),
	            99999);
	    // when:
	    LoanCheckResult loanresult = service.loanCheck(request);
	    // then:
	    assertThat(loanresult);
        assertThat(loanresult.getCheckStuats())
                .isEqualTo("FRAUD");
        assertThat(loanresult.getReason()).isEqualTo("Amount too high");
        System.out.println("看到这里,说明测试通过了");
	}
}

源码中可以看到 此测试类需要被如下注解修饰

@AutoConfigureStubRunner(ids = {"toni.com.cn:contract-sample-server:0.0.1-SNAPSHOT:stubs:8090"}, stubsMode = StubsMode.LOCAL)

测试类的依赖

以上注解表明本测试类正是依赖于contract的stub runner实现(照理,此时服务请求者一侧工程的POM.XML文件中需要加入有关dependency,但实际上不需要),具体就是要依赖前面文章中所创建的contract-sample-server-0.0.1-SNAPSHOT-stubs.jar来落地。由于测试类中已经通过注解,将stubRunner的位置信息表达出来了。通过ids就是有关的group-id:artifact-id:version:stub classifier :port 的组合来指定,这里我们设定为前面代码写死的8090向对应保持一致,同时强调了stubsMode 是本地的,表明test时不需要像其他pom中的依赖项一样的到maven的远程公共库去取目标 contract-sample-server-0.0.1-SNAPSHOT-stubs.jar包,而是在本地的maven库中取得。

比如本例中,就是到 “C:\maven\repository\toni\com\cn\contract-sample-server\0.0.1-SNAPSHOT”目录中取得,因为前面的stub包生成到此目录下的

显然,这也隐含要求了,测试时服务消费者依赖的maven配置settings.xml和之前创建桩时所使用的是同样的配置,并且是同一个电脑上。在其他文章,包括官网文章中都没有提到过这个细节。

在具体实践中,如果没有设置StubsMode.LOCAL,可能在测试时 报错类似如下:

“java.lang.IllegalStateException: Exception occurred while trying to download a stub for group [toni.com.cn] module [contract-sample-server] and classifier [stubs] in []”。

按照官网的说法,如果要采取远端的stub runner,那需要在application.yml中 加入  配置 类似如下

stubrunner:
  ids: 'com.example:http-server-dsl:+:stubs:8080'
  repositoryRoot: https://repo.spring.io/libs-snapshot

这样,测试时程序将尝试到 https://repo.spring.io/libs-snapshot 去取这个stub(桩)程序。这里的repositoryRoot 是 远程maven库的地址。(没有尝试过)

如果不设置,那假设有关的stub.jar制品已经上传到远程的maven库中,照理应该也可以正常工作,也就是contract将会触发自动下载有关jar制品、实现在本地的服务模拟。

另外,值得注意的,这个stubs.jar制品并不需要在pom.xml中去配置相关的依赖(或说位置),而是由contract框架自己依据设置的ids去本地maven库找到和启用。这就可以看出,contract的做法就是使得目标代码和测试代码尽量分开,不产生过多的相互嵌入,确保测试后,尽量少修改代码和配置,就流入到持续集成的后续环节中。而且,也表明,实际上stub桩完全可以不必须和被模拟的下游服务同名(在@AutoConfigureStubRunner中进行相应的配置就可以了),关键是接口规范和预期内容一致即可。

测试类开发完毕后(当然开发测试类前编制也可以),需要在服务消费者工程的POM.XML中,加入contract的依赖配置。

<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		
		<dependency>
			<groupId>org.springframework.cloud</groupId>
			<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
			<scope>test</scope>
		</dependency>

注意:作为服务消费者的测试需求而言,不需要加入“spring-cloud-starter-contract-verifier”依赖,加了也没有关系,不过并不必须。

加入cloud配置依赖

<dependencyManagement>
		<dependencies>
			<dependency>
				<groupId>org.springframework.cloud</groupId>
				<artifactId>spring-cloud-dependencies</artifactId>
				<version>${spring-cloud-release.version}</version>
				<type>pom</type>
				<scope>import</scope>
			</dependency>
		</dependencies>
	</dependencyManagement>

cloud的版本配置,依然是:

<spring-cloud.version>2021.0.3</spring-cloud.version>

到这里,就可以进行测试验证了

通过eclipse选择LoanApplicationServiceTests进行测试了。此时直接执行测试,可能仍然会失败,报错:java.lang.IllegalStateException: Exception occurred while trying to download a stub for group [toni.com.cn] module [contract-sample-server] and classifier [stubs] in []

这是因为elipse在执行测试的时候,其使用的默认maven配置不能完全被contract测试类正确读取。导致根本就找不到本地库位置,所以就自然找不到目标 contract-sample-server-0.0.1-SNAPSHOT-stubs.jar。此时,在eclipse“测试配置”中修改一下 enviorement 环境参数,增加一个参数 org.apache.maven.user-settings 指向 settings.xml文件即可解决问题。类似如下图:

 执行测试后,可以看到如下结果:

... ...

2022-09-02 17:19:39.203  INFO 16824 --- [           main] t.c.c.c.LoanApplicationServiceTests      : Starting LoanApplicationServiceTests using Java 1.8.0_191 on DESKTOP-95SBFOT with PID 16824 (started by longlongago in D:\workspace\normal\contract-sample-client)
2022-09-02 17:19:39.205  INFO 16824 --- [           main] t.c.c.c.LoanApplicationServiceTests      : No active profile set, falling back to 1 default profile: "default"
2022-09-02 17:19:41.268  INFO 16824 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.cloud.contract.stubrunner.spring.StubRunnerConfiguration' of type [org.springframework.cloud.contract.stubrunner.spring.StubRunnerConfiguration] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2022-09-02 17:19:41.299  INFO 16824 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'stubrunner-org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties' of type [org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2022-09-02 17:19:41.318  INFO 16824 --- [           main] o.s.c.c.s.AetherStubDownloaderBuilder    : Will download stubs and contracts via Aether
2022-09-02 17:19:41.400  INFO 16824 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Remote repos not passed but the switch to work offline was set. Stubs will be used from your local Maven repository.
2022-09-02 17:19:41.732  INFO 16824 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved version is [0.0.1-SNAPSHOT]
2022-09-02 17:19:41.763  INFO 16824 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved artifact [toni.com.cn:contract-sample-server:jar:stubs:0.0.1-SNAPSHOT] to D:\mavenrepo\repository\toni\com\cn\contract-sample-server\0.0.1-SNAPSHOT\contract-sample-server-0.0.1-SNAPSHOT-stubs.jar
2022-09-02 17:19:41.765  INFO 16824 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Unpacking stub from JAR [URI: file:/D:/mavenrepo/repository/toni/com/cn/contract-sample-server/0.0.1-SNAPSHOT/contract-sample-server-0.0.1-SNAPSHOT-stubs.jar]
2022-09-02 17:19:41.786  INFO 16824 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Unpacked file to [C:\Users\LONGLO~1\AppData\Local\Temp\contracts-1662110381764-0]
... ...
2022-09-02 17:19:51.026  INFO 16824 --- [  qtp8039120-25] w.o.e.j.s.h.ContextHandler.__admin       : RequestHandlerClass from context returned com.github.tomakehurst.wiremock.http.AdminRequestHandler. Normalized mapped under returned 'null'
2022-09-02 17:19:51.082  INFO 16824 --- [  qtp8039120-25] WireMock                                 : Admin request received:
127.0.0.1 - POST /mappings

... ...

2022-09-02 17:19:51.262  INFO 16824 --- [  qtp8039120-24] WireMock                                 : Admin request received:
127.0.0.1 - POST /mappings

... ...

2022-09-02 17:19:56.124  INFO 16824 --- [  qtp8039120-25] WireMock                                 : Request received:
127.0.0.1 - PUT /fraudcheck

Accept: [text/plain, application/json, application/*+json, */*]
Content-Type: [application/json]
User-Agent: [Java/1.8.0_191]
Host: [localhost:8090]
Connection: [keep-alive]
Content-Length: [45]
{"client_id":"1234567890","loanAmount":99999}


Matched response definition:
{
  "status" : 200,
  "body" : "{\"checkStuats\":\"FRAUD\",\"reason\":\"Amount too high\"}",
  "headers" : {
    "Content-Type" : "application/json"
  },
  "transformers" : [ "response-template", "spring-cloud-contract" ]
}

Response:
HTTP/1.1 200
Content-Type: [application/json]
Matched-Stub-Id: [b78c3d70-98b5-45e4-8d0f-9f8837d1760e]


看到这里,说明测试通过了
2022-09-02 17:19:56.911  WARN 16824 --- [           main] .StubRunnerWireMockTestExecutionListener : You've used fixed ports for WireMock setup - will mark context as dirty. Please use random ports, as much as possible. Your tests will be faster and more reliable and this warning will go away
2022-09-02 17:19:56.942  INFO 16824 --- [           main] w.o.e.jetty.server.AbstractConnector     : Stopped NetworkTrafficServerConnector@7f977fba{HTTP/1.1, (http/1.1, h2c)}{0.0.0.0:8090}
2022-09-02 17:19:56.945  INFO 16824 --- [           main] w.o.e.j.server.handler.ContextHandler    : Stopped w.o.e.j.s.ServletContextHandler@34045582{/,null,STOPPED}
2022-09-02 17:19:56.946  INFO 16824 --- [           main] w.o.e.j.server.handler.ContextHandler    : Stopped w.o.e.j.s.ServletContextHandler@41def031{/__admin,null,STOPPED}

就表明测试通过了。

实际上如果修改请求中的参数,会发现测试不通过,提示参数与预期不一致。这就起到了验证服务请求是否符合接口规格的作用。

后记与理解

        通过合理的调整测试方法和断言就可以验证服务消费者自身的业务逻辑处理是否符合预期设计了。SPRING CONTRACT就提供了不依赖于服务已经实现的条件。不需要服务已经存在的环境下,服务消费者一侧依然可以开展自身的开发和验证(模拟的)。这就是测试驱动开发(TDD)的基本逻辑。这样做的好处,就是在契约约定的基础条件下,服务消费者和服务提供者双方可以并行的各自开展开发工作,并且不用担心大家开发的接口假设完全不一致了。当然,这也说明,如果接口发生变化了,一定要通知stub的开发者修订接口脚本,重新发布stub程序。

        另外,由于contract是写死的请求和响应,也就是服务的响应是幂等的,因此在以后编码中,自动测试可以查看今后的代码修改中,是否对原来已经完成的功能产生了影响从而导致在同样的响应下却产生了和预期不一致的行为,这样就可以尽早发现修改带来的衍生BUG了,也就是自动进行回归测试。事实上,基于spring contract编制了契约的接口,可以通过命令生成自动测试类,具体命令是:“mvn spring-cloud-contract:generateTests”,这样在 "target\generated-test-sources\contracts\"t的相应目录中将产生 相应的标准的测试类,可以自行拷贝到工程的test目录并根据自己的喜好和需要进行修改和采用。

        然后,为了让服务提供者和服务提供者之间的契约保持同步、一致,如果契约发生了变化,应及时pull提交到远程的maven库中,而不要单纯的依赖口口相传来传递新契约(接口定义)。这一步而言,实际上stub在开发启动时就可以也应该生成和发布出来,不需要在服务消费者自身的开发完成的基础上进行,不过应想办法确保stub中契约(接口)的定义是正确、准确的(因为在生成stub的时候,我们有意选择了忽略测试错误)。

        最后,我认为,关于stub的开发,前面提到不一定非要具备服务提供者版本的权限。因为,实际上stubs.jar包是一个独立于服务端jar包的存在。解压contract-sample-server-0.0.1-SNAPSHOT-stubs.jar包,我们就可以发现,其实该包中根本没有任何的class文件,只有META-INF目录下的contract脚本文件和自动生成的映射文件。本质上还是一个很纯粹的挡板而已,所以理论上,完全可以不依赖服务提供者的程序而独立去做这个stub桩的。不过,如果采取此种做法,为了开发人员更容易解读、理解(或者说以后猜到此stub的用途),保持stub桩和被模拟服务的artifactId的一致(也就是可以新创建一个空程序)是有意义的,也就是stub桩的工程POM.XML文件中设置和被模拟的服务提供者工程的artifactId一致.片段如下:

    <groupId>toni.com.cn</groupId>
	<artifactId>contract-sample-server</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<packaging>jar</packaging>
    <name>contract-sample-server</name>

不过,要是能够做到stub本身可以单独在目标工程体外,独立作为一个运行程序启动就好了,成为一个干净的外部挡板,这样不是java技术的前端也可以利用这个挡板程序来进行联调辅助开发了。知道如何做的朋友,希望能告诉我。

另外,关于锲约的编辑,官网上有一些教学说明:

Spring Cloud Contract Features :: Spring Cloud Contract

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

toni_liao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值