走在前沿
VMware 提供培训和认证,助您快速提升。
了解更多我谨代表 Spring Cloud 团队,很高兴地宣布名为 Spring Cloud Contract 的新 Spring Cloud 项目的 1.0.0.M1 版本发布。您可以在 Spring 的里程碑存储库 中获取它,或者更好的是 - 访问 start.spring.io 并从那里选择它。
微服务方法有很多好处,但也带来了复杂性。这是分布式系统工作不可避免的结果:随着复杂性的增加,不可避免地会提出更多问题。在本文中,我们将展示如何使用消费者驱动契约方法来测试微服务并创建更好的 API。为了使微服务测试更容易,我们很高兴在 Spring Cloud 项目系列中引入一个新项目 - Spring Cloud Contract。该项目为 Spring 应用程序中的消费者驱动契约和服务模式提供支持,涵盖了编写测试、将测试作为资产发布、断言生产者和消费者遵守契约以及针对 HTTP 和基于消息的交互的一系列选项。
本文是另一篇关于 如何使用数据库进行零停机部署 的近期文章的补充。
在我们深入细节之前,让我们先了解一些理论。与分布式系统相关的最大挑战之一是在节点之间传递的消息结构上达成一致(“消息”指的是任何格式良好的非流式数据,因此适用于传统的 HTTP API 和基于事件的微服务)。当我们考虑消息结构时,会产生以下几个问题
消费者如何知道生产者已更改其 API?
生产者一方如何知道它是否会破坏消费者?
如果消费者正在使用存根进行测试,谁应该创建这些存根?
如何确保存根的质量?
解决这些问题的方法之一是引入契约
的概念。契约是在提供者和消费者之间关于其通信应如何进行的协议。问题仍然存在于谁应该驱动 API 的更改、契约应该存储在哪里以及契约应该包含什么内容。
在这篇博文中,我们将介绍称为“消费者驱动契约”的方法以及名为 Spring Cloud Contract Verifier 的新 Spring Cloud 项目(以前称为 Accurest,由 Codearte 公司 托管)。
让我们想象一下以下场景
生产方团队完成了他们的冲刺,并通过引入新版本的 API 更改了他们的应用程序。由于时间紧迫,日程安排紧张,因此没有与 API 的消费者进行协商。消费者在他们的测试中模拟了所有生产方的集成,并且由于没有人通知他们有任何更改,因此他们没有更新这些模拟。这就是为什么所有单元和集成测试仍然是绿色的,但端到端测试却惨败的原因。当消费者注意到生产方 API 已更改时,他们不得不投入大量时间来调整其生产和测试代码,以便发送和接收新的所需数据。消费者团队认为这几乎是不可能的(由于时间紧迫和更改的复杂性),因此他们开始向生产方团队提交问题,以调整其 API。生产方团队回复说“没有时间”并且他们可以“与他们的产品负责人谈谈,让他将该需求放入他们的积压工作中”。
如果您查看两个团队的回顾会议,您会看到以下内容。
对于消费者一方
引入了重大更改,但没有人通知我们
生产方团队没有协商他们的 API 更改 - 新的 API 无法使用
我们的集成测试没有捕获生产方 API 的更改
在调整他们的 API 方面,我们完全被生产方团队忽略了
对于生产方
每个人都对我们生气,但我们必须交付业务价值
当我们更改 API 时,我们无法更新每个消费者的测试
有共鸣吗?别担心,有一些方法可以改变这种方法,让每个人都少一些烦恼。
上述场景中出现了一些问题
API 更改是在没有与消费者协商的情况下进行的
存根过程由消费者拥有,因此没有反映生产方的任何更改
让我们关注第一个问题。您还记得测试驱动开发 (TDD) 方法吗?您从测试形式的期望开始,然后编写实现以使测试通过,最后重构以使代码看起来更漂亮。“红、绿、重构” - 失败的测试、通过的测试、重构的代码。TDD 是关于犯错的。与您代码 API 应该如何显示的假设相关的错误。这是一个迭代过程,允许您提高代码的质量。开发人员是代码 API 更改的驱动因素。他是它的用户,他知道他想实现什么,所以他更改 API 直到他对结果感到满意。现在,让我们想象一下我们将这种方法转移到 API 设计级别…
由于消费者是 API 的使用者,因此他们应该是 API 更改的驱动因素。这与 TDD 的主要区别在于,这里有两个团队参与了这个过程 - 消费者和生产者。这就是您可以从消费者驱动契约 (CDC) 方法中获益的地方。它的几个特点是
生产者 API 由消费者与生产者团队共同设计(沟通至关重要!)
契约被写下来,并且必须适合双方
工作可以解耦 - 一旦契约被记录下来,两个团队就可以独立工作
生产者一方拥有契约(这是具体人员的严格定义的责任)
Spring Cloud 团队希望拥有一个工具,使我们能够
以可读但灵活的方式定义契约
使契约显示一些用例,而不仅仅是呈现消息的结构
生成测试以自动根据生产者一方验证契约
从契约自动生成存根,以便消费者可以重用它
使这种方法适用于基于 HTTP 和消息的通信
Spring Cloud Contract Verifier 通过提供自动化的解决方案来确保创建的契约及其存根的质量和可靠性,从而解决了此问题。它包含以下主要功能
库的核心部分为您提供了契约的概念
验证器由生产者使用(通常通过构建插件)
Spring Cloud Contract Verifier Maven/Gradle 插件为您提供了将契约转换为测试和 WireMock 存根的功能(WireMock 是一个 HTTP 服务器存根)
Spring Cloud Contract Stub Runner 允许消费者自动下载上游生产者的存根,并在您的集成测试中启动内存中的 HTTP 存根服务器
Spring Cloud Contract Stub Runner 还允许消费者发送和接收契约中描述的消息(通过 Spring Integration、Spring Cloud Stream 或 Apache Camel)。
让我们看看以下分步示例,如何在 HTTP 通信的情况下使用此工具。
让我们以欺诈检测和贷款发放流程为例。业务场景是这样的:我们希望向人们发放贷款,但不想让他们从我们这里偷钱。我们系统目前的实现向所有人发放贷款。
假设“贷款发放”是“欺诈检测”服务器的客户端。在当前冲刺中,我们需要开发一项新功能 - 如果客户想要借太多钱,我们就将其标记为欺诈。
技术说明 - 欺诈检测的构件 ID 为 http-server
,贷款发放的构件 ID 为 http-client
,两者都具有组 ID com.example
。
社交说明 - 消费者和生产者开发团队需要直接沟通,并在整个过程中讨论变更。CDC 就是关于沟通的。
作为贷款发放服务(欺诈检测服务器的消费者)的开发人员
通过为您的功能编写测试来开始进行 TDD
@Test
public void shouldBeRejectedDueToAbnormalLoanAmount() {
// given:
LoanApplication application = new LoanApplication(new Client("1234567890"),
99999);
// when:
LoanApplicationResult loanApplication = sut.loanApplication(application);
// then:
assertThat(loanApplication.getLoanApplicationStatus())
.isEqualTo(LoanApplicationStatus.LOAN_APPLICATION_REJECTED);
assertThat(loanApplication.getRejectionReason()).isEqualTo("Amount too high");
}
我们刚刚编写了新功能的测试。如果收到大量贷款申请,我们应该拒绝该贷款申请并附带一些说明。
编写缺失的实现
在某个时间点,您需要向欺诈检测服务发送请求。假设我们希望发送包含客户 ID 和他想要从我们这里借款金额的请求。我们希望通过 PUT
方法将其发送到 /fraudcheck
URL。
ResponseEntity<FraudServiceResponse> response =
restTemplate.exchange("https://127.0.0.1:" + port + "/fraudcheck", HttpMethod.PUT,
new HttpEntity<>(request, httpHeaders),
FraudServiceResponse.class);
为简单起见,我们已将欺诈检测服务的端口硬编码为 8080
,我们的应用程序在 8090
上运行。
如果我们启动编写的测试,它显然会中断,因为我们在端口 8080
上没有运行的服务。
在本地克隆欺诈检测服务存储库
我们将开始使用生产者端。这就是为什么我们需要先克隆它。
git clone https://your-git-server.com/server.git local-http-server-repo
在欺诈检测服务存储库中本地定义契约
作为消费者,我们需要定义我们到底想实现什么。我们需要制定我们的期望。因此,我们编写了以下契约。
package contracts
org.springframework.cloud.contract.spec.Contract.make {
request { // (1)
method 'PUT' // (2)
url '/fraudcheck' // (3)
body([ // (4)
clientId: value(consumer(regex('[0-9]{10}'))),
loanAmount: 99999
])
headers { // (5)
header('Content-Type', 'application/vnd.fraud.v1+json')
}
}
response { // (6)
status 200 // (7)
body([ // (8)
fraudCheckStatus: "FRAUD",
rejectionReason: "Amount too high"
])
headers { // (9)
header('Content-Type': value(
producer(regex('application/vnd.fraud.v1.json.*')),
consumer('application/vnd.fraud.v1+json'))
)
}
}
}
/*
Since we don't want to force on the user to hardcode values of fields that are dynamic
(timestamps, database ids etc.), one can provide parametrize those entries by using the
`value(consumer(...), producer(...))` method. That way what's present in the `consumer`
section will end up in the produced stub. What's there in the `producer` will end up in the
autogenerated test. If you provide only the regular expression side without the concrete
value then Spring Cloud Contract will generate one for you.
From the Consumer perspective, when shooting a request in the integration test:
(1) - If the consumer sends a request
(2) - With the "PUT" method
(3) - to the URL "/fraudcheck"
(4) - with the JSON body that
* has a field `clientId` that matches a regular expression `[0-9]{10}`
* has a field `loanAmount` that is equal to `99999`
(5) - with header `Content-Type` equal to `application/vnd.fraud.v1+json`
(6) - then the response will be sent with
(7) - status equal `200`
(8) - and JSON body equal to
{ "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
(9) - with header `Content-Type` equal to `application/vnd.fraud.v1+json`
From the Producer perspective, in the autogenerated producer-side test:
(1) - A request will be sent to the producer
(2) - With the "PUT" method
(3) - to the URL "/fraudcheck"
(4) - with the JSON body that
* has a field `clientId` that will have a generated value that matches a regular expression `[0-9]{10}`
* has a field `loanAmount` that is equal to `99999`
(5) - with header `Content-Type` equal to `application/vnd.fraud.v1+json`
(6) - then the test will assert if the response has been sent with
(7) - status equal `200`
(8) - and JSON body equal to
{ "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
(9) - with header `Content-Type` matching `application/vnd.fraud.v1+json.*`
*/
契约使用静态类型化的 Groovy DSL 编写。您可能想知道那些 value(consumer(…), producer(…))
部分是什么。通过使用此表示法,Spring Cloud Contract 允许您定义 JSON/URL/等的动态部分。对于标识符或时间戳,您不希望硬编码值。您希望允许一些不同的值范围。因此,对于消费者端,您可以设置与这些值匹配的正则表达式。您可以通过映射表示法或带有插值的字符串来提供主体。查阅文档以获取更多信息。我们强烈建议使用映射表示法!
上述契约是双方之间的一项协议,即
如果发送带有以下内容的 HTTP 请求:
在端点 /fraudcheck
上使用 PUT
方法
JSON 主体,其中 clientId
匹配正则表达式 [0-9]{10}
,loanAmount
等于 99999
以及 Content-Type
标头,其值为 application/vnd.fraud.v1+json
那么会将 HTTP 响应发送到消费者,该响应:
状态为 200
包含 JSON 主体,其中 fraudCheckStatus
字段包含值 FRAUD
,rejectionReason
字段的值为 Amount too high
以及 Content-Type
标头,其值为 application/vnd.fraud.v1+json
一旦我们准备好实践中在集成测试中检查 API,我们只需要在本地安装存根即可
将 Spring Cloud Contract Verifier 插件添加到服务器端
我们可以添加 Maven 或 Gradle 插件 - 在此示例中,我们将展示如何添加 Maven。首先,我们需要添加 Spring Cloud Contract
BOM。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-dependencies</artifactId>
<version>${spring-cloud-contract.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
接下来,是 Spring Cloud Contract Verifier
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>com.example.fraud.MvcTest</baseClassForTests>
</configuration>
</plugin>
由于插件已添加,因此我们获得了 Spring Cloud Contract Verifier
功能,这些功能从提供的契约中:
生成并运行测试
生成并安装存根
我们不想生成测试,因为我们作为消费者只想使用存根。因此,我们需要跳过测试的生成和执行。当我们执行以下操作时:
cd local-http-server-repo
./mvnw clean install -DskipTests
在日志中,我们将看到类似以下内容:
[INFO] --- spring-cloud-contract-maven-plugin:1.0.0.BUILD-SNAPSHOT:generateStubs (default-generateStubs) @ http-server ---
[INFO] Building jar: /some/path/http-server/target/http-server-0.0.1-SNAPSHOT-stubs.jar
[INFO]
[INFO] --- maven-jar-plugin:2.6:jar (default-jar) @ http-server ---
[INFO] Building jar: /some/path/http-server/target/http-server-0.0.1-SNAPSHOT.jar
[INFO]
[INFO] --- spring-boot-maven-plugin:1.4.0.BUILD-SNAPSHOT:repackage (default) @ http-server ---
[INFO]
[INFO] --- maven-install-plugin:2.5.2:install (default-install) @ http-server ---
[INFO] Installing /some/path/http-server/target/http-server-0.0.1-SNAPSHOT.jar to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT.jar
[INFO] Installing /some/path/http-server/pom.xml to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT.pom
[INFO] Installing /some/path/http-server/target/http-server-0.0.1-SNAPSHOT-stubs.jar to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT-stubs.jar
此行极其重要
[INFO] Installing /some/path/http-server/target/http-server-0.0.1-SNAPSHOT-stubs.jar to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT-stubs.jar
它确认 http-server
的存根已安装在本地存储库中。
运行集成测试
为了利用 Spring Cloud Contract Stub Runner 的自动存根下载功能,您需要在我们的消费者端项目(贷款申请服务
)中执行以下操作。
添加 Spring Cloud Contract
BOM
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-dependencies</artifactId>
<version>${spring-cloud-contract.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
添加对 Spring Cloud Contract Stub Runner
的依赖项
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-wiremock</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
<scope>test</scope>
</dependency>
为 Stub Runner 提供组 ID 和构件 ID 以下载协作者的存根。还提供脱机工作开关,因为您正在与协作者脱机工作(可选步骤)。
stubrunner:
work-offline: true
stubs.ids: 'com.example:http-server:+:stubs:8080'
使用 @AutoConfigureStubRunner
注释您的测试类
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureStubRunner
public class LoanApplicationServiceTests {
现在,如果您运行测试,您将看到类似以下内容:
2016-07-19 14:22:25.403 INFO 41050 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Desired version is + - will try to resolve the latest version
2016-07-19 14:22:25.438 INFO 41050 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Resolved version is 0.0.1-SNAPSHOT
2016-07-19 14:22:25.439 INFO 41050 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Resolving artifact com.example:http-server:jar:stubs:0.0.1-SNAPSHOT using remote repositories []
2016-07-19 14:22:25.451 INFO 41050 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Resolved artifact com.example:http-server:jar:stubs:0.0.1-SNAPSHOT to /path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT-stubs.jar
2016-07-19 14:22:25.465 INFO 41050 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Unpacking stub from JAR [URI: file:/path/to/your/.m2/repository/com/example/http-server/0.0.1-SNAPSHOT/http-server-0.0.1-SNAPSHOT-stubs.jar]
2016-07-19 14:22:25.475 INFO 41050 --- [ main] o.s.c.c.stubrunner.AetherStubDownloader : Unpacked file to [/var/folders/0p/xwq47sq106x1_g3dtv6qfm940000gq/T/contracts100276532569594265]
2016-07-19 14:22:27.737 INFO 41050 --- [ main] o.s.c.c.stubrunner.StubRunnerExecutor : All stubs are now running RunningStubs [namesAndPorts={com.example:http-server:0.0.1-SNAPSHOT:stubs=8080}]
这意味着 Stub Runner 已找到您的存根并在端口 8080
上为组 ID 为 com.example
、构件 ID 为 http-server
、版本为 0.0.1-SNAPSHOT
的存根应用程序启动了一个服务器。
提交 PR
我们到目前为止所做的都是一个迭代过程。我们可以使用契约,在本地安装它并在消费者端进行工作,直到我们对契约满意为止。
一旦我们对结果感到满意且测试通过,则向生产者端发布 PR。此时,消费者端的工作已完成。
作为欺诈检测服务器(贷款发放服务的消息生产者)的开发人员
初始实现
提醒一下,您可以在此处查看初始实现
@RequestMapping(
value = "/fraudcheck",
method = PUT,
consumes = FRAUD_SERVICE_JSON_VERSION_1,
produces = FRAUD_SERVICE_JSON_VERSION_1)
public FraudCheckResult fraudCheck(@RequestBody FraudCheck fraudCheck) {
return new FraudCheckResult(FraudCheckStatus.OK, NO_REASON);
接管 PR
git checkout -b contract-change-pr master
git pull https://your-git-server.com/server-side-fork.git contract-change-pr
您必须添加自动生成测试所需的依赖项
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-verifier</artifactId>
<scope>test</scope>
</dependency>
在 Maven 插件的配置中,我们传递了 baseClassForTests
属性
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>${spring-cloud-contract.version}</version>
<extensions>true</extensions>
<configuration>
<baseClassForTests>com.example.fraud.MvcTest</baseClassForTests>
</configuration>
</plugin>
这是因为所有生成的测试都将扩展该类。在那里,您可以设置 Spring 上下文或任何必要的设置。在我们的例子中,我们使用 Rest Assured MVC 来启动生产者端 FraudDetectionController
。
package com.example.fraud;
import com.example.fraud.FraudDetectionController;
import com.jayway.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.Before;
public class MvcTest {
@Before
public void setup() {
RestAssuredMockMvc.standaloneSetup(new FraudDetectionController());
}
public void assertThatRejectionReasonIsNull(Object rejectionReason) {
assert rejectionReason == null;
}
}
现在,如果您运行 ./mvnw clean install
,您将看到类似以下内容:
Results :
Tests in error:
ContractVerifierTest.validate_shouldMarkClientAsFraud:32 » IllegalState Parsed...
这是因为您有一个新的契约,从中生成了一个测试,并且它失败了,因为您尚未实现该功能。自动生成的测试如下所示:
@Test
public void validate_shouldMarkClientAsFraud() throws Exception {
// given:
MockMvcRequestSpecification request = given()
.header("Content-Type", "application/vnd.fraud.v1+json")
.body("{\"clientId\":\"1234567890\",\"loanAmount\":99999}");
// when:
ResponseOptions response = given().spec(request)
.put("/fraudcheck");
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).matches("application/vnd.fraud.v1.json.*");
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).field("fraudCheckStatus").matches("[A-Z]{5}");
assertThatJson(parsedJson).field("rejectionReason").isEqualTo("Amount too high");
}
如您所见,契约中 value(consumer(…), producer(…))
块中存在的 producer()
部分已注入到测试中。
这里需要注意的重要一点是,在生产者端,我们也正在进行 TDD。我们以测试的形式拥有期望。此测试向我们自己的应用程序的 URL、标头和主体(在契约中定义)发出请求。它还期望响应中存在非常精确定义的值。换句话说,您拥有的是 红
、绿
和 重构
的 红
部分。是时候将 红
转换为 绿
了。
编写缺失的实现
现在,既然我们知道预期输入和预期输出是什么,让我们编写缺失的实现。
@RequestMapping(
value = "/fraudcheck",
method = PUT,
consumes = FRAUD_SERVICE_JSON_VERSION_1,
produces = FRAUD_SERVICE_JSON_VERSION_1)
public FraudCheckResult fraudCheck(@RequestBody FraudCheck fraudCheck) {
if (amountGreaterThanThreshold(fraudCheck)) {
return new FraudCheckResult(FraudCheckStatus.FRAUD, AMOUNT_TOO_HIGH);
}
return new FraudCheckResult(FraudCheckStatus.OK, NO_REASON);
}
如果我们再次执行 ./mvnw clean install
,测试将通过。由于 Spring Cloud Contract Verifier
插件将测试添加到 generated-test-sources
中,因此您实际上可以从您的 IDE 中运行这些测试。
部署您的应用程序
完成工作后,是时候部署您的更改了。首先合并分支
git checkout master
git merge --no-ff contract-change-pr
git push origin master
然后我们假设您的 CI 将运行类似 ./mvnw clean deploy
的操作,这将发布应用程序和存根构件。
作为贷款发放服务(欺诈检测服务器的消费者)的开发人员
将分支合并到主分支
git checkout master
git merge --no-ff contract-change-pr
在线工作
现在,您可以为 Spring Cloud Contract Stub Runner 禁用脱机工作,并提供放置存根存储库的位置。此时,生产者端的存根将自动从 Nexus/Artifactory 下载。
stubrunner.stubs:
ids: 'com.example:http-server:+:stubs:8080'
repositoryRoot: http://repo.spring.io/libs-snapshot
就是这样!
在这个例子中,您可以看到如何使用Spring Cloud Contract Verifier
来实现消费者驱动契约的方式。通过这种方式,我们实现了
一个适合消费者和生产者的API
可读的契约,并且已经针对生产者进行了测试
经过验证的存根,所有消费者都可以在其集成测试中使用
消费者端工具,可以自动下载最新的存根并为您设置存根