消費者驅動契約 (CDC) 逐步指南:以生產者端的契約

考慮一個詐欺偵測和貸款發放流程的範例。業務情境是我們想要向人們發放貸款,但不希望他們從我們這裡偷竊。我們系統目前的實作是向所有人發放貸款。

假設 貸款發放詐欺偵測 伺服器的用戶端。在目前的 sprint 中,我們必須開發一個新功能:如果客戶想要借太多錢,我們會將該客戶標記為詐欺。

技術說明

  • 詐欺偵測的 artifact-idhttp-server

  • 貸款發放的 artifact-idhttp-client

  • 兩者的 group-id 均為 com.example

  • 為了這個範例,Stub 儲存 是 Nexus/Artifactory。

社群說明

  • 用戶端和伺服器開發團隊都需要直接溝通,並在進行過程中討論變更。

  • CDC 完全是關於溝通。

伺服器端程式碼可在 Spring Cloud Contract 的儲存庫 samples/standalone/dsl/http-server 路徑下取得,而用戶端程式碼可在 Spring Cloud Contract 的儲存庫 samples/standalone/dsl/http-client 路徑下取得。

在這種情況下,生產者擁有契約。實際上,所有契約都在生產者的儲存庫中。

技術注意事項

如果您使用 SNAPSHOT、Milestone 或 Release Candidate 版本,您需要將以下章節新增至您的建置中

  • Maven

<repositories>
	<repository>
		<id>spring-snapshots</id>
		<name>Spring Snapshots</name>
		<url>https://repo.spring.io/snapshot</url>
		<snapshots>
			<enabled>true</enabled>
		</snapshots>
	</repository>
	<repository>
		<id>spring-milestones</id>
		<name>Spring Milestones</name>
		<url>https://repo.spring.io/milestone</url>
		<snapshots>
			<enabled>false</enabled>
		</snapshots>
	</repository>
</repositories>
<pluginRepositories>
	<pluginRepository>
		<id>spring-snapshots</id>
		<name>Spring Snapshots</name>
		<url>https://repo.spring.io/snapshot</url>
		<snapshots>
			<enabled>true</enabled>
		</snapshots>
	</pluginRepository>
	<pluginRepository>
		<id>spring-milestones</id>
		<name>Spring Milestones</name>
		<url>https://repo.spring.io/milestone</url>
		<snapshots>
			<enabled>false</enabled>
		</snapshots>
	</pluginRepository>
</pluginRepositories>
Gradle
repositories {
	mavenCentral()
	mavenLocal()
	maven { url "https://repo.spring.io/snapshot" }
	maven { url "https://repo.spring.io/milestone" }
}

為了簡化,我們使用以下縮寫

  • 貸款發放 (LI):HTTP 用戶端

  • 詐欺偵測 (FD):HTTP 伺服器

  • SCC:Spring Cloud Contract

消費者端(貸款發放)

作為貸款發放服務(詐欺偵測伺服器的消費者)的開發人員,您可能會執行以下步驟

  1. 透過為您的功能編寫測試來開始進行 TDD。

  2. 編寫遺失的實作。

  3. 在本機複製詐欺偵測服務儲存庫。

  4. 在本機詐欺偵測服務的儲存庫中定義契約。

  5. 新增 Spring Cloud Contract (SCC) 外掛程式。

  6. 執行整合測試。

  7. 提交 Pull Request。

  8. 建立初始實作。

  9. 接手 Pull Request。

  10. 編寫遺失的實作。

  11. 部署您的應用程式。

  12. 在線上工作。

我們先從貸款發放流程開始,以下 UML 圖表顯示了該流程

getting-started-cdc-client

透過為您的功能編寫測試來開始進行 TDD

以下列表顯示了我們可能用來檢查貸款金額是否過大的測試

@Test
public void shouldBeRejectedDueToAbnormalLoanAmount() {
	// given:
	LoanApplication application = new LoanApplication(new Client("1234567890"),
			99999);
	// when:
	LoanApplicationResult loanApplication = service.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://#:" + port + fraudCheck(), HttpMethod.PUT,
		new HttpEntity<>(request, httpHeaders), FraudServiceResponse.class);

為了簡化,詐欺偵測服務的連接埠設定為 8080,而應用程式在 8090 上執行。

如果您在此時啟動測試,它會中斷,因為目前沒有服務在連接埠 8080 上執行。

在本機複製詐欺偵測服務儲存庫

您可以從試用伺服器端契約開始。為此,您必須先複製它,方法是執行以下命令

$ git clone https://your-git-server.com/server-side.git local-http-server-repo

在本機詐欺偵測服務的儲存庫中定義契約

作為消費者,您需要定義您想要達成的目標。您需要制定您的期望。為此,請編寫以下契約

將契約放置在 src/test/resources/contracts/fraud 資料夾中。fraud 資料夾很重要,因為生產者的測試基底類別名稱參考了該資料夾。

以下範例顯示了我們的契約,Groovy 和 YAML 兩種格式

org.springframework.cloud.contract.spec.Contract.make {
	request { // (1)
		method 'PUT' // (2)
		url '/fraudcheck' // (3)
		body([ // (4)
			   "client.id": $(regex('[0-9]{10}')),
			   loanAmount : 99999
		])
		headers { // (5)
			contentType('application/json')
		}
	}
	response { // (6)
		status OK() // (7)
		body([ // (8)
			   fraudCheckStatus  : "FRAUD",
			   "rejection.reason": "Amount too high"
		])
		headers { // (9)
			contentType('application/json')
		}
	}
}

/*
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 `client.id` 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/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/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 `client.id` 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/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/json.*`
 */
yaml
request: # (1)
  method: PUT # (2)
  url: /yamlfraudcheck # (3)
  body: # (4)
    "client.id": 1234567890
    loanAmount: 99999
  headers: # (5)
    Content-Type: application/json
  matchers:
    body:
      - path: $.['client.id'] # (6)
        type: by_regex
        value: "[0-9]{10}"
response: # (7)
  status: 200 # (8)
  body:  # (9)
    fraudCheckStatus: "FRAUD"
    "rejection.reason": "Amount too high"
  headers: # (10)
    Content-Type: application/json


#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 "/yamlfraudcheck"
#(4) - with the JSON body that
# * has a field `client.id`
# * has a field `loanAmount` that is equal to `99999`
#(5) - with header `Content-Type` equal to `application/json`
#(6) - and a `client.id` json entry matches the regular expression `[0-9]{10}`
#(7) - then the response will be sent with
#(8) - status equal `200`
#(9) - and JSON body equal to
# { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
#(10) - with header `Content-Type` equal to `application/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 "/yamlfraudcheck"
#(4) - with the JSON body that
# * has a field `client.id` `1234567890`
# * has a field `loanAmount` that is equal to `99999`
#(5) - with header `Content-Type` equal to `application/json`
#(7) - then the test will assert if the response has been sent with
#(8) - status equal `200`
#(9) - and JSON body equal to
# { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
#(10) - with header `Content-Type` equal to `application/json`

YML 契約非常簡單明瞭。但是,當您查看使用靜態型別 Groovy DSL 編寫的契約時,您可能會想知道 value(client(…​), server(…​)) 部分是什麼。透過使用此符號,Spring Cloud Contract 可讓您定義 JSON 區塊、URL 或其他動態結構的部分。對於識別碼或時間戳記的情況,您不需要硬式編碼值。您想要允許一些不同的值範圍。若要啟用值範圍,您可以設定符合消費者端值的正則運算式。您可以透過地圖符號或帶有插補的 String 來提供主體。我們強烈建議使用地圖符號。

若要設定契約,您必須了解地圖符號。請參閱關於 JSON 的 Groovy 文件

先前顯示的契約是雙方之間的協議,即

  • 如果傳送了符合以下所有條件的 HTTP 請求

    • /fraudcheck 端點上的 PUT 方法

    • JSON 主體,其中 client.id 符合正則運算式 [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 時,您需要在本機安裝 Stub。

新增 Spring Cloud Contract Verifier 外掛程式

我們可以新增 Maven 或 Gradle 外掛程式。在此範例中,我們示範如何新增 Maven。首先,我們新增 Spring Cloud Contract BOM,如下列範例所示

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

接下來,新增 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>
					<packageWithBaseClasses>com.example.fraud</packageWithBaseClasses>
<!--					<convertToYaml>true</convertToYaml>-->
				</configuration>
			</plugin>

由於已新增外掛程式,您將獲得 Spring Cloud Contract Verifier 功能,這些功能來自提供的契約

  • 產生並執行測試

  • 產生並安裝 Stub

您不想要產生測試,因為作為消費者,您只想使用 Stub。您需要略過測試產生和調用。為此,請執行以下命令

$ 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.5.5.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 的 Stub 已安裝在本機儲存庫中。

執行整合測試

為了從 Spring Cloud Contract Stub Runner 自動 Stub 下載功能中獲益,您必須在消費者端專案(貸款申請服務)中執行以下操作

  1. 新增 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>
    		<dependency>
    			<groupId>org.springframework.cloud</groupId>
    			<artifactId>spring-cloud-stream-dependencies</artifactId>
    			<version>${spring-cloud-stream.version}</version>
    			<type>pom</type>
    			<scope>import</scope>
    		</dependency>
    	</dependencies>
    </dependencyManagement>
  2. 新增對 Spring Cloud Contract Stub Runner 的相依性,如下所示

    <dependency>
    	<groupId>org.springframework.cloud</groupId>
    	<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
    	<scope>test</scope>
    </dependency>
  3. 使用 @AutoConfigureStubRunner 註解您的測試類別。在註解中,提供 group-idartifact-id,讓 Stub Runner 下載您的協作者的 Stub。

    @SpringBootTest(webEnvironment = WebEnvironment.NONE)
    @AutoConfigureStubRunner(ids = {
    		"com.example:http-server-dsl:0.0.1:stubs"}, stubsMode = StubRunnerProperties.StubsMode.LOCAL)
    public class LoanApplicationServiceTests {
  4. (可選)由於您正在離線試用協作者,您也可以提供離線工作切換(StubRunnerProperties.StubsMode.LOCAL)。

現在,當您執行測試時,您會在日誌中看到類似以下的輸出

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 已找到您的 Stub,並為您的應用程式啟動伺服器,其群組 ID 為 com.example,構件 ID 為 http-server,Stub 版本為 0.0.1-SNAPSHOT,分類器為 stubs,連接埠為 8080

提交 Pull Request

您到目前為止所做的是一個迭代過程。您可以試用契約,在本機安裝它,並在消費者端工作,直到契約如您所願地運作。

一旦您對結果感到滿意且測試通過,您就可以將 Pull Request 發佈到伺服器端。目前,消費者端的工作已完成。

生產者端(詐欺偵測伺服器)

作為詐欺偵測伺服器(貸款發放服務的伺服器)的開發人員,您可能想要

  • 接手 Pull Request

  • 編寫遺失的實作

  • 部署應用程式

以下 UML 圖表顯示了詐欺偵測流程

getting-started-cdc-server

接手 Pull Request

提醒一下,以下列表顯示了初始實作

@RequestMapping(value = "/fraudcheck", method = PUT)
public FraudCheckResult fraudCheck(@RequestBody FraudCheck fraudCheck) {
return new FraudCheckResult(FraudCheckStatus.OK, NO_REASON);
}

然後您可以執行以下命令

$ 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 外掛程式的組態中,您必須傳遞 packageWithBaseClasses 屬性,如下所示

			<plugin>
				<groupId>org.springframework.cloud</groupId>
				<artifactId>spring-cloud-contract-maven-plugin</artifactId>
				<version>${spring-cloud-contract.version}</version>
				<extensions>true</extensions>
				<configuration>
					<packageWithBaseClasses>com.example.fraud</packageWithBaseClasses>
<!--					<convertToYaml>true</convertToYaml>-->
				</configuration>
			</plugin>
此範例透過設定 packageWithBaseClasses 屬性來使用「基於慣例」的命名。這樣做表示最後兩個套件組合起來構成基底測試類別的名稱。在我們的案例中,契約放置在 src/test/resources/contracts/fraud 下。由於您沒有從 contracts 資料夾開始的兩個套件,因此僅選擇一個,即 fraud。新增 Base 後綴並將 fraud 大寫。這會產生 FraudBase 測試類別名稱。

所有產生的測試都會擴充該類別。在該處,您可以設定您的 Spring Context 或任何必要的內容。在這種情況下,您應該使用 Rest Assured MVC 來啟動伺服器端 FraudDetectionController。以下列表顯示了 FraudBase 類別

import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.jupiter.api.BeforeEach;

public class FraudBase {

	@BeforeEach
	public void setup() {
		RestAssuredMockMvc.standaloneSetup(new FraudDetectionController(),
				new FraudStatsController(stubbedStatsProvider()));
	}

	private StatsProvider stubbedStatsProvider() {
		return fraudType -> {
			switch (fraudType) {
			case DRUNKS:
				return 100;
			case ALL:
				return 200;
			}
			return 0;
		};
	}

	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("{\"client.id\":\"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("['rejection.reason']").isEqualTo("Amount too high");
}

如果您使用 Groovy DSL,您可以看到 Contract 中存在的所有 producer() 部分都已注入到測試中的 value(consumer(…​), producer(…​)) 區塊中。如果您使用 YAML,responsematchers 區段也會套用相同的規則。

請注意,在生產者端,您也在進行 TDD。期望以測試的形式表示。此測試會將請求傳送到我們自己的應用程式,其中包含契約中定義的 URL、標頭和主體。它也期望回應中具有精確定義的值。換句話說,您擁有 redgreenrefactor 中的 red 部分。現在是時候將 red 轉換為 green 了。

編寫遺失的實作

因為您知道預期的輸入和預期的輸出,所以您可以編寫遺失的實作,如下所示

@RequestMapping(value = "/fraudcheck", method = PUT)
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 的命令,這將發佈應用程式和 Stub 構件。

消費者端(貸款發放),最後一步

作為貸款發放服務(詐欺偵測伺服器的消費者)的開發人員,您需要

  • 將我們的功能分支合併到 master

  • 切換到線上工作模式

以下 UML 圖表顯示了流程的最終狀態

getting-started-cdc-client-final

將分支合併到 Master

以下命令顯示了使用 Git 將分支合併到 master 的一種方法

$ git checkout master
$ git merge --no-ff contract-change-pr

在線上工作

現在您可以停用 Spring Cloud Contract Stub Runner 的離線工作,並指示 Stub 儲存庫的位置。此時,伺服器端的 Stub 會自動從 Nexus/Artifactory 下載。您可以將 stubsMode 的值設定為 REMOTE。以下程式碼顯示了透過變更屬性來達成相同目的的範例

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

就是這樣。您已完成本教學課程。