使用Jenkins、Artifactory和Spring Cloud Contract持续集成测试REST/JSON

  消费驱动合约(Consumer Driven Contract:CDC)方式的测试是一种验证并确保各个应用之间实现良好集成的方法,在前后端分离的架构和工艺指导下,前端与后端是分别单独并行开发,他们之间是通过REST/JSON方式交互,如何交互?JSON的数据格式是什么?前后端只有通过这种验证才能对接。

  前后端交互的数量可能非常大,特别是如果采取基于微服务的前后端分离架构。假设每个微服务都是由不同的团队开发的,那么保证整个测试过程的自动化则是非常重要。这里像往常一样,我们可以使用Jenkins服务器在持续集成(CI)过程中运行前后端合约式的测试。

  假设我们有一个应用程序(person-service)暴露API由三个不同的应用程序消费使用。每个消费者是由不同的开发团队实施。因此,每个消费者程序都存储在单独的Git存储库中,并在Jenkins中配置专用管道用于构建,测试和部署。

  示例应用程序的源代码可以在GitHub的存储库sample-spring-cloud-contract-cihttps://github.com/piomin/sample-spring-cloud-contract-ci.git)中找到。我将所有示例微服务放在一个Git存储库中,这样做仅仅便于演示简化,实际上仍要将它们视为独立的微服务,独立开发和构建。

  在本文中,我使用Spring Cloud Contract进行CDC实现。它是用Spring Boot编写的JVM应用程序的首选解决方案。可以使用Groovy或YAML表示法定义合约。在服务生产者构建之后,Spring Cloud Contract能够生成带有stubs后缀的特殊JAR文件,该文件包含所有已定义的合约与JSON映射。这样的JAR文件可以在Jenkins上构建,然后在Artifactory上发布。合约的消费使用者也使用相同的Artifactory服务器,因此他们可以使用最新版本的存根文件。在我们这个案例中每个消费者程序需要person-service有不同的响应,我们必须在person-service这个生产者和三个消费者之间就会定义三种不同合约,也就是有不同的响应,这个三个消费者的名称分别是:bank-service、contact-service和letter-service。

  让我们分析一下示例场景。假设我们在公开的API person-service中执行了一些修改,并且我们也同时修改了生产者方面的合约,我们希望将它们发布在共享服务器上。首先,我们需要验证与生产者合约(下图中1),并且在成功的情况下发布存根到Artifactory (2)。使用此合约的消费程序的三个管道都能够触发使用此存根(3)构建的新版本JAR文件。最后,消费者验证确认最新版本的合约(4)。如果合同测试失败,管道将这个失败通知负责的团队。

 

1.预先要求

  在实施和运行代码之前,我们需要准备我们的环境。我们需要在本地计算机上启动Jenkins和Artifactory服务器。最合适的方法是通过Docker容器。以下是运行这些容器所需的命令。

1

2

$ docker run --name artifactory -d -p 8081:8081 docker.bintray.io/jfrog/artifactory-oss:latest

$ docker run --name jenkins -d -p 8080:8080 -p 50000:50000 jenkins/jenkins:lts

我不知道你是否熟悉Artifactory和Jenkins等工具,无论如何我们都需要进行一些配置。首先,需要为Artifactory初始化Maven存储库。首次启动后,将会被立即提示需要初始化,它会自动添加一个远程存储库:JCenter Bintrayhttps://bintray.com/bintray/jcenter),这对我们的构建来说已经足够了。Jenkins还附带了一组默认插件,可以在首次启动后立即安装(安装建议的插件)。对于此演示,您还必须安装插件以与Artifactory集成(https://wiki.jenkins.io/display/JENKINS/Artifactory+Plugin)。

2.建立合同

  首先我们从服务的生产者这边开始定义合约。生产者只公开一个GET /persons/{id}返回Person对象的方法。以下是Person类包含的字段。

public class Person {
 
    private Integer id;
    private String firstName;
    private String lastName;
    @JsonFormat(pattern = "yyyy-MM-dd")
    private Date birthDate;
    private Gender gender;
    private Contact contact;
    private Address address;
    private String accountNo;
 
    // ...
}

 

    下图说明三个消费组中哪些消费者使用Person对象中的哪些字段。某些字段在三个消费者之间共享,而其他一些字段仅由单个消费自己消费使用:

 

现在我们可以看看person-service和bank-service之间的合同定义。

import org.springframework.cloud.contract.spec.Contract
 
Contract.make {
    request {
        method 'GET'
        urlPath('/persons/1')
    }
    response {
        status OK()
        body([
            id: 1,
            firstName: 'Piotr',
            lastName: 'Minkowski',
            gender: $(regex('(MALE|FEMALE)')),
            contact: ([
                email: $(regex(email())),
                phoneNo: $(regex('[0-9]{9}$'))
            ])
        ])
        headers {
            contentType(applicationJson())
        }
    }
}

 

为了比较不同消费组和生产者之间的不同合约,这里是person-service和letter-service之间的合约。

import org.springframework.cloud.contract.spec.Contract
 
Contract.make {
    request {
        method 'GET'
        urlPath('/persons/1')
    }
    response {
        status OK()
        body([
            id: 1,
            firstName: 'Piotr',
            lastName: 'Minkowski',
            address: ([
                city: $(regex(alphaNumeric())),
                country: $(regex(alphaNumeric())),
                postalCode: $(regex('[0-9]{2}-[0-9]{3}')),
                houseNo: $(regex(positiveInt())),
                street: $(regex(nonEmpty()))
            ])
        ])
        headers {
            contentType(applicationJson())
        }
    }
}

 

   两种合约在请求URL是一致的,响应内容体不一样,前者只需要email和phoneNo,后者需要更多的地址信息。

3.在生产者方实施测试

   现在我们有了三个不同的合同分配给一个公开共同端点person-service。我们要发布这些合约,以便消费者可以轻松获得这些合约。在这种情况下,Spring Cloud Contract提供了一个方便的解决方案。我们可以为同一种请求定义具有不同的响应合同,不需要在消费者方面进行任何合同的选择。所有这些合同定义都将在同一个JAR文件中发布。因为我们有三个消费者,我们放置在目录中有三种不同的合同bank-consumer,contact-consumer和letter-consumer。

所有合同都将使用单个基础测试类,我们需要在pom.xml提供Spring Cloud Contract这个插件名称。

<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <extensions>true</extensions>
    <configuration>
        <baseClassForTests>pl.piomin.services.person.BasePersonContractTest</baseClassForTests>
    </configuration>
</plugin>

 

  下面是我们的合同测试基类的完整定义。我们将使用与前面合约文件中相匹配的答案来测试模拟我们的存储库bean:PersonRepository。

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT)
public abstract class BasePersonContractTest {
 
    @Autowired
    WebApplicationContext context;
    @MockBean
    PersonRepository repository;
     
    @Before
    public void setup() {
        RestAssuredMockMvc.webAppContextSetup(this.context);
        PersonBuilder builder = new PersonBuilder()
            .withId(1)
            .withFirstName("Piotr")
            .withLastName("Minkowski")
            .withBirthDate(new Date())
            .withAccountNo("1234567890")
            .withGender(Gender.MALE)
            .withPhoneNo("500070935")
            .withCity("Warsaw")
            .withCountry("Poland")
            .withHouseNo(200)
            .withStreet("Al. Jerozolimskie")
            .withEmail("piotr.minkowski@gmail.com")
            .withPostalCode("02-660");
        when(repository.findById(1)).thenReturn(builder.build());
    }
     
}

 

Spring Cloud Contract Maven插件将负责从上面合同定义生成存根。在运行mvn clean install命令后将由Maven构建,构建是在Jenkins CI上执行。Jenkins管道负责更新远程Git存储库,拉取源代码并构建二进制文件,运行自动化测试,最后把包含存根的JAR文件发布到远程工件库 - Artifactory上。这是为合约生产者方(person-service)创建的Jenkins管道:

node {
  withMaven(maven:'M3') {
    stage('Checkout') {
      git url: 'https://github.com/piomin/sample-spring-cloud-contract-ci.git', credentialsId: 'piomin-github', branch: 'master'
    }
    stage('Publish') {
      def server = Artifactory.server 'artifactory'
      def rtMaven = Artifactory.newMavenBuild()
      rtMaven.tool = 'M3'
      rtMaven.resolver server: server, releaseRepo: 'libs-release', snapshotRepo: 'libs-snapshot'
      rtMaven.deployer server: server, releaseRepo: 'libs-release-local', snapshotRepo: 'libs-snapshot-local'
      rtMaven.deployer.artifactDeploymentPatterns.addInclude("*stubs*")
      def buildInfo = rtMaven.run pom: 'person-service/pom.xml', goals: 'clean install'
      rtMaven.deployer.deployArtifacts buildInfo
      server.publishBuildInfo buildInfo
    }
  }
}

 

我们还需要在生产者应用程序中配置spring-cloud-starter-contract-verifier依赖项以使用用Spring Cloud Contract Verifier。

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

 

4.在消费者方实施测试

     要在消费者端启用Spring Cloud Contract,我们需要包含spring-cloud-starter-contract-stub-runner在项目依赖项中。

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

 

    接下来就是构建JUnit测试,它通过OpenFeign客户端调用来验证我们的合同。该注释的内部提供了测试的配置@AutoConfigureStubRunner。我们person-service通过在ids参数的version部分中设置+加号来选择最新版本的存根工件。因为我们在person-service内部定义了多个合同,我们需要通过设置consumer-name参数来选择当前服务应该消费的合约。所有合同定义都从Artifactory服务器下载,因此我们将stubsMode参数设置为REMOTE。必须使用repositoryRootproperty 设置Artifactory服务器的地址。

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@AutoConfigureStubRunner(ids = {"pl.piomin.services:person-service:+:stubs:8090"}, consumerName = "letter-consumer",  stubsPerConsumer = true, stubsMode = StubsMode.REMOTE, repositoryRoot = "http://192.168.99.100:8081/artifactory/libs-snapshot-local")
@DirtiesContext
public class PersonConsumerContractTest {
 
    @Autowired
    private PersonClient personClient;
     
    @Test
    public void verifyPerson() {
        Person p = personClient.findPersonById(1);
        Assert.assertNotNull(p);
        Assert.assertEquals(1, p.getId().intValue());
        Assert.assertNotNull(p.getFirstName());
        Assert.assertNotNull(p.getLastName());
        Assert.assertNotNull(p.getAddress());
        Assert.assertNotNull(p.getAddress().getCity());
        Assert.assertNotNull(p.getAddress().getCountry());
        Assert.assertNotNull(p.getAddress().getPostalCode());
        Assert.assertNotNull(p.getAddress().getStreet());
        Assert.assertNotEquals(0, p.getAddress().getHouseNo());
    }
     
}

 

下面负责调用person-service端点的Feign客户端实现 :

@FeignClient("person-service")
public interface PersonClient {
 
    @GetMapping("/persons/{id}")
    Person findPersonById(@PathVariable("id") Integer id);
     
}

 

5.持续集成过程的设置

   现在我们已经定义了练习所需的所有合同。我们还构建了一个管道,负责在生产者端(person-service)创建和发布存根。它始终发布来自源代码生成的最新版本的存根。我们的目标是为每个新的存根通过生产者管道发布到Artifactory服务器,需要分别为三个消费者序定义的不同管道

   最好的解决方案是在部署工件时触发Jenkins构建。为了实现它,我们使用名为URLTrigger的 Jenkins插件,可以配置为监视某个URL的更改,在这种情况下,Artifactory需要为其选定的存储库路径公开其REST API端点。

   安装URLTrigger插件后,我们必须为所有使用者管道启用它。您可以配置它监视所有从Artifactory文件列表REST API中返回JSON文件中的任何更改,该文件可通过以下URI访问:

http://192.168.99.100:8081/artifactory/api/storage/%5BPATH_TO_FOLDER_OR_REPO%5D/

   在每次将新版本的应用程序部署到Artifactory时maven-metadata.xml都将更改。我们可以监控最近两次拉取中内容的变化。最后一个必须是Schedule。如果您将其设置为* * * * *它将每分钟轮询一次更改。

 

我们为消费者应用准备了三条管道。第一轮比赛圆满结束。

如果你已经构建了person-service应用程序并将存根发布到Artifactory,那么会在libs-snapshot-local存储库中看到以下结构。我已经部署了三个不同版本的API person-service。每次我发布新版本的合同时,都会触发所有相关管道进行验证。

带有合同的JAR文件在分类器下stubs发布:

 

Spring Cloud Contract Stub Runner试图寻找最新版本的合同:

2018-07-04 11:46:53.273  INFO 4185 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Desired version is [+] - will try to resolve the latest version
2018-07-04 11:46:54.752  INFO 4185 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved version is [1.3-SNAPSHOT]
2018-07-04 11:46:54.823  INFO 4185 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved artifact [pl.piomin.services:person-service:jar:stubs:1.3-SNAPSHOT] to /var/jenkins_home/.m2/repository/pl/piomin/services/person-service/1.3-SNAPSHOT/person-service-1.3-SNAPSHOT-stubs.jar

 

6.测试合同的变更

   至此我们已经准备好合同并配置了我们的CI环境。现在,让我们对公开的API person-service进行更改。我们只需更改一个字段的名称:accountNoto改为 accountNumber。

   此更改需要在生产者端修改合同的定义,如果你修改好person-service的字段名称并成功构建,新版本的合同将发布到Artifactory。由于所有其他管道都在侦听具有存根的最新版JAR文件中的更改,因此将自动启动构建。由于微服务letter-service和contact-service不使用字段accountNo,因此它们的管道不会发现修改导致的失败,而bank-service因为会使用accountNo,所以其管道将会报告合同中出现错误。

   如果你被告知有关person-service和bank-service的最新合约验证失败,你就可以对消费者端进行相应的改变了。

 

英文原文

Spring Cloud专题