CDC 之 Spring Cloud Contract 看完就會(huì)用

先上個(gè)導(dǎo)圖


Spring Cloud Contract

一 什么是 CDC


消費(fèi)者驅(qū)動(dòng)的契約測(cè)試(Consumer-Driven Contracts梳玫,簡(jiǎn)稱CDC)涧至,是指從消費(fèi)者業(yè)務(wù)實(shí)現(xiàn)的角度出發(fā)斤讥,驅(qū)動(dòng)出契約候生,再基于契約,對(duì)提供者驗(yàn)證的一種測(cè)試方式绽昼。

通常在開(kāi)發(fā)過(guò)程中主要是由服務(wù)提供方來(lái)提供約定接口唯鸭,雖然提供方架構(gòu)和接口進(jìn)行調(diào)整后會(huì)通知消費(fèi)者,但還是會(huì)存在風(fēng)險(xiǎn)的硅确。而在微服務(wù)架構(gòu)中目溉,不同的服務(wù)可能會(huì)由不同的團(tuán)隊(duì)維護(hù),這種情況下對(duì)接口的開(kāi)發(fā)和維護(hù)也將會(huì)帶來(lái)一些問(wèn)題菱农。

為了解決這些問(wèn)題呢缭付,Ian Robinson提出了一個(gè)以服務(wù)消費(fèi)者定義契約為驅(qū)動(dòng)的開(kāi)發(fā)模式:“Consumer-Driver Contracts(CDC)”,就是:消費(fèi)者驅(qū)動(dòng)契約循未。而 CDC 的總體流程是陷猫,消費(fèi)者定義了它們期望的 API/消息 是什么樣子,這種期望就被稱為契約的妖,提供者需要編寫驗(yàn)證這些契約并生成 stubs 供生產(chǎn)者重復(fù)使用绣檬。

Spring Cloud Contract 則是 CDC 的一種具體實(shí)現(xiàn)。

二 微服務(wù)架構(gòu)下測(cè)試存在的一些問(wèn)題


multiple microservices

假設(shè)我們有一個(gè)由多個(gè) services 組成的系統(tǒng)嫂粟,我們?cè)跊](méi)有 CDC 的情況下對(duì) v1 這個(gè) service 進(jìn)行測(cè)試:

  1. 對(duì)于端到端的測(cè)試
  • 為了測(cè)試一個(gè) service娇未,我們需要將所有的 services 和對(duì)應(yīng)的 databases 都部署起來(lái),一旦我們有很多的 service 后星虹,這個(gè)成本非常大
  1. 對(duì)于單元/集成測(cè)試
  • 在編寫測(cè)試時(shí)候需要 mock 大量的數(shù)據(jù)
  • 很難應(yīng)對(duì)服務(wù)端調(diào)整架構(gòu)或接口后帶來(lái)的問(wèn)題

三 Spring Cloud Contract 的解決方案


Spring Cloud Contract 可以為我們生成一個(gè)可被驗(yàn)證的 Stub Runner零抬,這樣我們就可以在不啟動(dòng)其它 services 的同時(shí)及時(shí)的獲取反饋信息镊讼,因?yàn)樵陔p方都準(zhǔn)守契約的情況下這個(gè) Stub Runner 就相當(dāng)于我們啟動(dòng)了對(duì)應(yīng)的 service

stubs

好處:

  1. 確保 WireMock/message stubs 是完全按照實(shí)際的服務(wù)端實(shí)現(xiàn)的
  2. 推廣 ATDD 方法和為服務(wù)架構(gòu)
  3. 當(dāng)提供者發(fā)布最新的契約時(shí),雙方都可以快速發(fā)現(xiàn)
  4. 既可以對(duì)自身服務(wù)進(jìn)行測(cè)試平夜,也可以生成 stub 提供給其它 service 進(jìn)行調(diào)用

四 Quickly Start


server side

Spring Cloud Contract 采用 Groovy DSL 來(lái)定義契約蝶棋,當(dāng)然了你也可以采用 yml 來(lái)編寫。現(xiàn)在我們來(lái)實(shí)現(xiàn)一個(gè)簡(jiǎn)單的 stub 褥芒,包含兩個(gè) API嚼松,根據(jù) id 獲取用戶信息以及添加一條用戶信息

1 編寫 contracts

add user


import org.springframework.cloud.contract.spec.Contract

Contract.make {
    name 'should_return_status_created' //最終生成測(cè)試時(shí)的方法名,默認(rèn)是文件名
    request {
        method 'post'
        url '/users'
        body([
                id      : 1,
                username: 'acey'
        ])
        headers {
            contentType(applicationJsonUtf8())
        }
    }
    response {
        status 201
    }
}

get user

import org.springframework.cloud.contract.spec.Contract

Contract.make {
    name 'should_return_user'  //最終生成測(cè)試時(shí)的方法名锰扶,默認(rèn)是文件名
    request {
        method 'GET'
        url value(
                consumer(regex('/users/\\d+')), 
                producer('/users/1'))
    }
    response {
        status 200
        body([
                id      : 1L,
                username: "acey",
        ])
        testMatchers {
            jsonPath('$.id', byRegex(number()))
            jsonPath('$.username',byRegex(onlyAlphaUnicode()))
        }
        headers {
            contentType(applicationJsonUtf8())
        }
    }
}

其中:

  url value(
                consumer(regex('/users/\\d+')), 
                producer('/users/1'))

當(dāng)生成 stub 后献酗,

  • consumer 表示客戶端在發(fā)送該請(qǐng)求時(shí)可以進(jìn)行正則匹配,只需要保證
    users 后跟的是一個(gè)數(shù)字即可
  • producer 表示當(dāng)客服端發(fā)送了符合正則的請(qǐng)求后坷牛,所以符合的請(qǐng)求都將調(diào)用 /users/1 這個(gè)接口

而對(duì)于 response 也是類似

response {
        status 200
        body([
                id      : 1L,
                username: "acey",
        ])
        testMatchers {
            jsonPath('$.id', byRegex(number()))
            jsonPath('$.username',byRegex(onlyAlphaUnicode()))
        }
        headers {
            contentType(applicationJsonUtf8())
        }
    }

body 里面的數(shù)據(jù)是客戶端在請(qǐng)求后返回的具體的結(jié)果
testMatchers 則是針對(duì)服務(wù)端在自身測(cè)試時(shí)進(jìn)行一個(gè)模糊驗(yàn)證

2 生成/運(yùn)行測(cè)試

使用 ./gradlew generateContractTests 生成 contracts 對(duì)應(yīng)的測(cè)試罕偎,當(dāng)然你也可以直接 build / test 。生成的測(cè)試長(zhǎng)這樣


public class UserTest extends UserBase {

    @Rule
    public Base base = new Base();

    @Test
    public void validate_should_return_status_created() throws Exception {
        // given:
            MockMvcRequestSpecification request = given()
                    .header("Content-Type", "application/json;charset=UTF-8")
                    .body("{\"id\":1,\"username\":\"acey\"}");

        // when:
            ResponseOptions response = given().spec(request)
                    .post("/users");

        // then:
            assertThat(response.statusCode()).isEqualTo(201);
    }

    @Test
    public void validate_should_return_user() throws Exception {
        // given:
            MockMvcRequestSpecification request = given();

        // when:
            ResponseOptions response = given().spec(request)
                    .get("/users/1");

        // then:
            assertThat(response.statusCode()).isEqualTo(200);
            assertThat(response.header("Content-Type")).matches("application/json;charset=UTF-8.*");
        // and:
            DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
        // and:
            assertThat(parsedJson.read("$.id", String.class)).matches("-?\\d*(\\.\\d+)?");
            assertThat(parsedJson.read("$.username", String.class)).matches("[\\p{L}]*");
    }
}

有必要再來(lái)看一下目錄結(jié)構(gòu)


root path

更多配置信息

3 生成 stub

執(zhí)行 ./gradlew install 會(huì)在本機(jī)的 .m2/repository 下生成一個(gè) **-stub.jar
當(dāng)然你也可以發(fā)布發(fā)布到一個(gè) remote repository

client side

當(dāng)有了 stub 之后京闰,在 client side 去調(diào)用就比較簡(jiǎn)單了颜及,在這就只演示下 get user 這個(gè) API (當(dāng)然你也可以采用契約測(cè)試進(jìn)行測(cè)試)

UserServiceTest.class

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureStubRunner(ids = "com.acey:server:+:stubs:10001", 
      workOffline = true)
@ActiveProfiles("test")
public class UserServiceTest {
    @Autowired
    private UserService userService;

    @Test
    public void getUserInfo() throws Exception {
        ResponseEntity userInfo = userService.getUserInfo(1L);
        Map user = (Map) userInfo.getBody();
        assertEquals(HttpStatus.OK, userInfo.getStatusCode());
        assertEquals("acey", user.get("username"));
    }
}

其中

com.acey:server:+:stubs:10001

運(yùn)行的時(shí)候會(huì)在我們本地 `.m2/repository` 目錄下去找 com.acey:server:+:stubs 這個(gè)這個(gè) jar 文件
并 run 起來(lái),端口是 10001蹂楣,`+` 表示會(huì)自動(dòng)找最新的版本

UserService.class


@Service
public class UserService {
    @Value("${userCenter}")
    private String userCenterUrl;

    public ResponseEntity getUserInfo(Long userId) {
        String getUsersUrl = userCenterUrl + "/users/" + userId;

        RestTemplate template = new RestTemplate();
        ResponseEntity<Map> result = template.getForEntity(getUsersUrl, Map.class);
        return result;
    }
}

application-test.yml

userCenter: http://localhost:10001

五 獨(dú)立部署

1 首先下載stub-runner.jar

wget -O stub-runner.jar 'https://search.maven.org/remotecontent?filepath=org/springframework/cloud/spring-cloud-contract-stub-runner-boot/2.1.1.RELEASE/spring-cloud-contract-stub-runner-boot-2.1.1.RELEASE.jar'

2. 在同路徑創(chuàng)建配置文件application.yml俏站,并添加stubrunner的配置,如下

stubrunner:
  ids:
    # - [groupId]:artifactId:[version]:[classify]:[port]
    # 指定運(yùn)行在8081端口
    - com.jtj.cloud:spring-contract-example:1.0.0:+:8081
  # 該模式下會(huì)從遠(yuǎn)程maven倉(cāng)庫(kù)緩存到本地
  stubs-mode: local[remote 每次拉取遠(yuǎn)程]

3. 通過(guò)java -jar stub-runner.jar運(yùn)行痊土,訪問(wèn)對(duì)應(yīng)端口請(qǐng)求數(shù)據(jù)

完整 Demo 地址

參考:

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末肄扎,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子赁酝,更是在濱河造成了極大的恐慌犯祠,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,602評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件酌呆,死亡現(xiàn)場(chǎng)離奇詭異衡载,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)隙袁,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,442評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門痰娱,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人藤乙,你說(shuō)我怎么就攤上這事猜揪。” “怎么了坛梁?”我有些...
    開(kāi)封第一講書人閱讀 152,878評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵而姐,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我划咐,道長(zhǎng)拴念,這世上最難降的妖魔是什么钧萍? 我笑而不...
    開(kāi)封第一講書人閱讀 55,306評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮政鼠,結(jié)果婚禮上风瘦,老公的妹妹穿的比我還像新娘。我一直安慰自己公般,他們只是感情好万搔,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,330評(píng)論 5 373
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著官帘,像睡著了一般瞬雹。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上刽虹,一...
    開(kāi)封第一講書人閱讀 49,071評(píng)論 1 285
  • 那天酗捌,我揣著相機(jī)與錄音,去河邊找鬼涌哲。 笑死胖缤,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的阀圾。 我是一名探鬼主播哪廓,決...
    沈念sama閱讀 38,382評(píng)論 3 400
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼初烘!你這毒婦竟也來(lái)了撩独?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書人閱讀 37,006評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤账月,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后澳迫,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體局齿,經(jīng)...
    沈念sama閱讀 43,512評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,965評(píng)論 2 325
  • 正文 我和宋清朗相戀三年橄登,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了抓歼。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,094評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡拢锹,死狀恐怖谣妻,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情卒稳,我是刑警寧澤蹋半,帶...
    沈念sama閱讀 33,732評(píng)論 4 323
  • 正文 年R本政府宣布,位于F島的核電站充坑,受9級(jí)特大地震影響减江,放射性物質(zhì)發(fā)生泄漏染突。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,283評(píng)論 3 307
  • 文/蒙蒙 一辈灼、第九天 我趴在偏房一處隱蔽的房頂上張望份企。 院中可真熱鬧,春花似錦巡莹、人聲如沸司志。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 30,286評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)骂远。三九已至,卻和暖如春钉鸯,著一層夾襖步出監(jiān)牢的瞬間吧史,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 31,512評(píng)論 1 262
  • 我被黑心中介騙來(lái)泰國(guó)打工唠雕, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留贸营,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,536評(píng)論 2 354
  • 正文 我出身青樓岩睁,卻偏偏與公主長(zhǎng)得像钞脂,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子捕儒,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,828評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容