95. 使用可插拔架构

您可能会遇到以其他格式(例如 YAML,RAML 或 PACT)定义 Contract 的情况。在那些情况下,您仍然想从自动生成测试和存根中受益。您可以添加自己的实现以生成测试和存根。另外,您可以自定义测试的生成方式(例如,可以生成其他语言的测试)和存根的生成方式(例如,可以为其他 HTTP 服务器实现生成存根)。

95.1 自定义 Contract 转换器

ContractConverter界面使您可以注册自己的 Contract 结构转换器的实现。以下代码清单显示了ContractConverter界面:

package org.springframework.cloud.contract.spec

/**
 * Converter to be used to convert FROM {@link File} TO {@link Contract}
 * and from {@link Contract} to {@code T}
 *
 * @param <T> - type to which we want to convert the contract
 *
 * @author Marcin Grzejszczak
 * @since 1.1.0
 */
interface ContractConverter<T> extends ContractStorer<T> {

	/**
	 * Should this file be accepted by the converter. Can use the file extension
	 * to check if the conversion is possible.
	 *
	 * @param file - file to be considered for conversion
	 * @return - {@code true} if the given implementation can convert the file
	 */
	boolean isAccepted(File file)

	/**
	 * Converts the given {@link File} to its {@link Contract} representation
	 *
	 * @param file - file to convert
	 * @return - {@link Contract} representation of the file
	 */
	Collection<Contract> convertFrom(File file)

	/**
	 * Converts the given {@link Contract} to a {@link T} representation
	 *
	 * @param contract - the parsed contract
	 * @return - {@link T} the type to which we do the conversion
	 */
	T convertTo(Collection<Contract> contract)
}

您的实现必须定义启动转换的条件。另外,您必须定义如何在两个方向上执行该转换。

Tip

创建实施后,您必须创建一个/META-INF/spring.factories文件,在其中提供实施的完全限定名称。

以下示例显示了一个典型的spring.factories文件:

org.springframework.cloud.contract.spec.ContractConverter=\
org.springframework.cloud.contract.verifier.converter.YamlContractConverter

95.1.1 协议转换器

Spring Cloud Contract 包括对第Pact4 版之前的 Contract 的支持。代替使用 Groovy DSL,可以使用 Pact 文件。在本节中,我们介绍如何为您的项目添加 Pact 支持。但是请注意,并非所有功能都受支持。从 v3 开始,您可以为同一个元素组合多个匹配器。您可以将匹配器用于正文,Headers,请求和路径;您可以使用价值生成器。 Spring Cloud Contract 当前仅支持使用 AND 规则逻辑组合的多个匹配器。除此之外,在转换过程中将跳过请求和路径匹配器。当使用具有给定格式的日期,时间或日期时间值生成器时,将跳过给定格式,并使用 ISO 格式。

为了正确支持 Spring Cloud Contract 与 Pact 进行消息传递的方式,您必须提供一些其他元数据条目。您可以在下面找到此类条目的列表:

  • 要定义消息发送到的目的地,您必须在 Pact 文件中设置一个metaData条目,键sentTo等于消息发送到的目的地。例如。 "metaData": { "sentTo": "activemq:output" }

95.1.2 契约 Contract

考虑以下契约 Contract 示例,该 Contract 是src/test/resources/contracts文件夹下的文件。

{
  "provider": {
    "name": "Provider"
  },
  "consumer": {
    "name": "Consumer"
  },
  "interactions": [
    {
      "description": "",
      "request": {
        "method": "PUT",
        "path": "/fraudcheck",
        "headers": {
          "Content-Type": "application/vnd.fraud.v1+json"
        },
        "body": {
          "clientId": "1234567890",
          "loanAmount": 99999
        },
        "generators": {
          "body": {
            "$.clientId": {
              "type": "Regex",
              "regex": "[0-9]{10}"
            }
          }
        },
        "matchingRules": {
          "header": {
            "Content-Type": {
              "matchers": [
                {
                  "match": "regex",
                  "regex": "application/vnd\\.fraud\\.v1\\+json.*"
                }
              ],
              "combine": "AND"
            }
          },
          "body" : {
            "$.clientId": {
              "matchers": [
                {
                  "match": "regex",
                  "regex": "[0-9]{10}"
                }
              ],
              "combine": "AND"
            }
          }
        }
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/vnd.fraud.v1+json;charset=UTF-8"
        },
        "body": {
          "fraudCheckStatus": "FRAUD",
          "rejectionReason": "Amount too high"
        },
        "matchingRules": {
          "header": {
            "Content-Type": {
              "matchers": [
                {
                  "match": "regex",
                  "regex": "application/vnd\\.fraud\\.v1\\+json.*"
                }
              ],
              "combine": "AND"
            }
          },
          "body": {
            "$.fraudCheckStatus": {
              "matchers": [
                {
                  "match": "regex",
                  "regex": "FRAUD"
                }
              ],
              "combine": "AND"
            }
          }
        }
      }
    }
  ],
  "metadata": {
    "pact-specification": {
      "version": "3.0.0"
    },
    "pact-jvm": {
      "version": "3.5.13"
    }
  }
}

关于使用 Pact 的本节的其余部分将参考前面的文件。

95.1.3 生产者公约

在生产者端,您必须在插件配置中添加两个其他依赖项。一个是 Spring Cloud Contract Pact 支持,另一个是您使用的当前 Pact 版本。

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>
	</configuration>
	<dependencies>
		<dependency>
			<groupId>org.springframework.cloud</groupId>
			<artifactId>spring-cloud-contract-pact</artifactId>
			<version>${spring-cloud-contract.version}</version>
		</dependency>
	</dependencies>
</plugin>

Gradle.

classpath "org.springframework.cloud:spring-cloud-contract-pact:${findProperty('verifierVersion') ?: verifierVersion}"

当您执行应用程序的构建时,将生成一个测试。生成的测试可能如下:

@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("['rejectionReason']").isEqualTo("Amount too high");
	// and:
		assertThat(parsedJson.read("$.fraudCheckStatus", String.class)).matches("FRAUD");
}

相应的生成的存根可能如下:

{
  "id" : "996ae5ae-6834-4db6-8fac-358ca187ab62",
  "uuid" : "996ae5ae-6834-4db6-8fac-358ca187ab62",
  "request" : {
    "url" : "/fraudcheck",
    "method" : "PUT",
    "headers" : {
      "Content-Type" : {
        "matches" : "application/vnd\\.fraud\\.v1\\+json.*"
      }
    },
    "bodyPatterns" : [ {
      "matchesJsonPath" : "$[?(@.['loanAmount'] == 99999)]"
    }, {
      "matchesJsonPath" : "$[?(@.clientId =~ /([0-9]{10})/)]"
    } ]
  },
  "response" : {
    "status" : 200,
    "body" : "{\"fraudCheckStatus\":\"FRAUD\",\"rejectionReason\":\"Amount too high\"}",
    "headers" : {
      "Content-Type" : "application/vnd.fraud.v1+json;charset=UTF-8"
    },
    "transformers" : [ "response-template" ]
  },
}

95.1.4Consumer 契约

在生产者方面,必须将两个其他依赖项添加到项目依赖项中。一个是 Spring Cloud Contract Pact 支持,另一个是您使用的当前 Pact 版本。

Maven.

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

Gradle.

testCompile "org.springframework.cloud:spring-cloud-contract-pact"

95.2 使用自定义测试生成器

如果要针对 Java 以外的语言生成测试,或者对验证程序构建 Java 测试的方式不满意,则可以注册自己的实现。

SingleTestGenerator界面可让您注册自己的实现。以下代码清单显示了SingleTestGenerator界面:

*
	 * @param properties                    - properties passed to the plugin
	 * @param listOfFiles                   - list of parsed contracts with additional metadata
	 * @param className                     - the name of the generated test class
	 * @param classPackage                  - the name of the package in which the test class should be stored
	 * @param includedDirectoryRelativePath - relative path to the included directory
	 * @return contents of a single test class
	 * @deprecated use {@link SingleTestGenerator#buildClass(ContractVerifierConfigProperties, Collection, String, GeneratedClassData)}
	 */
	@Deprecated
	abstract String buildClass(ContractVerifierConfigProperties properties,
			Collection<ContractMetadata> listOfFiles, String className, String classPackage, String includedDirectoryRelativePath)

	/**
	 * Creates contents of a single test class in which all test scenarios from
	 * the contract metadata should be placed.
	 *
	 * @param properties                    - properties passed to the plugin
	 * @param listOfFiles                   - list of parsed contracts with additional metadata
	 * @param generatedClassData            - information about the generated class
	 * @param includedDirectoryRelativePath - relative path to the included directory
	 * @return contents of a single test class
	 */
	String buildClass(ContractVerifierConfigProperties properties,
			Collection<ContractMetadata> listOfFiles, String includedDirectoryRelativePath, GeneratedClassData generatedClassData) {
		return buildClass(properties, listOfFiles, generatedClassData.className, generatedClassData.classPackage, includedDirectoryRelativePath)
	}

	/**
	 * Extension that should be appended to the generated test class. E.g. {@code .java} or {@code .php}
	 *
	 * @param properties - properties passed to the plugin
	 */
	abstract String fileExtension(ContractVerifierConfigProperties properties)

	static class GeneratedClassData {
		public final String className
		public final String classPackage
		public final java.nio.file.Path testClassPath

		GeneratedClassData(String className, String classPackage,
				java.nio.file.Path testClassPath) {
			this.className = className
			this.classPackage = classPackage
			this.testClassPath = testClassPath
		}
	}
}

同样,您必须提供一个spring.factories文件,如以下示例中所示:

org.springframework.cloud.contract.verifier.builder.SingleTestGenerator=/
com.example.MyGenerator

95.3 使用自定义存根生成器

如果要为 WireMock 以外的存根服务器生成存根,则可以插入自己的StubGenerator接口实现。以下代码清单显示了StubGenerator界面:

package org.springframework.cloud.contract.verifier.converter

import groovy.transform.CompileStatic
import org.springframework.cloud.contract.spec.Contract
import org.springframework.cloud.contract.verifier.file.ContractMetadata

/**
 * Converts contracts into their stub representation.
 *
 * @since 1.1.0
 */
@CompileStatic
interface StubGenerator {

	/**
	 * Returns {@code true} if the converter can handle the file to convert it into a stub.
	 */
	boolean canHandleFileName(String fileName)

	/**
	 * Returns the collection of converted contracts into stubs. One contract can
	 * result in multiple stubs.
	 */
	Map<Contract, String> convertContents(String rootName, ContractMetadata content)

	/**
	 * Returns the name of the converted stub file. If you have multiple contracts
	 * in a single file then a prefix will be added to the generated file. If you
	 * provide the {@link Contract#name} field then that field will override the
	 * generated file name.
	 *
	 * Example: name of file with 2 contracts is {@code foo.groovy}, it will be
	 * converted by the implementation to {@code foo.json}. The recursive file
	 * converter will create two files {@code 0_foo.json} and {@code 1_foo.json}
	 */
	String generateOutputFileNameForInput(String inputFileName)
}

同样,您必须提供一个spring.factories文件,如以下示例中所示:

# Stub converters
org.springframework.cloud.contract.verifier.converter.StubGenerator=\
org.springframework.cloud.contract.verifier.wiremock.DslToWireMockClientConverter

默认实现是 WireMock 存根生成。

Tip

您可以提供多个存根生成器实现。例如,从单个 DSL,您可以同时生成 WireMock 存根和 Pact 文件。

95.4 使用自定义存根运行器

如果决定使用自定义存根生成,则还需要使用自定义方式与其他存根提供程序一起运行存根。

假设您使用Moco来构建存根,并且已经编写了存根生成器并将存根放置在 JAR 文件中。

为了使 Stub Runner 知道如何运行您的存根,您必须定义一个自定义 HTTP Stub 服务器实现,该实现可能类似于以下示例:

package org.springframework.cloud.contract.stubrunner.provider.moco

import com.github.dreamhead.moco.bootstrap.arg.HttpArgs
import com.github.dreamhead.moco.runner.JsonRunner
import com.github.dreamhead.moco.runner.RunnerSetting
import groovy.util.logging.Commons

import org.springframework.cloud.contract.stubrunner.HttpServerStub
import org.springframework.util.SocketUtils

@Commons
class MocoHttpServerStub implements HttpServerStub {

	private boolean started
	private JsonRunner runner
	private int port

	@Override
	int port() {
		if (!isRunning()) {
			return -1
		}
		return port
	}

	@Override
	boolean isRunning() {
		return started
	}

	@Override
	HttpServerStub start() {
		return start(SocketUtils.findAvailableTcpPort())
	}

	@Override
	HttpServerStub start(int port) {
		this.port = port
		return this
	}

	@Override
	HttpServerStub stop() {
		if (!isRunning()) {
			return this
		}
		this.runner.stop()
		return this
	}

	@Override
	HttpServerStub registerMappings(Collection<File> stubFiles) {
		List<RunnerSetting> settings = stubFiles.findAll { it.name.endsWith("json") }
				.collect {
			log.info("Trying to parse [${it.name}]")
			try {
				return RunnerSetting.aRunnerSetting().withStream(it.newInputStream()).build()
			} catch (Exception e) {
				log.warn("Exception occurred while trying to parse file [${it.name}]", e)
				return null
			}
		}.findAll { it }
		this.runner = JsonRunner.newJsonRunnerWithSetting(settings,
				HttpArgs.httpArgs().withPort(this.port).build())
		this.runner.run()
		this.started = true
		return this
	}

	@Override
	String registeredMappings() {
		return ""
	}

	@Override
	boolean isAccepted(File file) {
		return file.name.endsWith(".json")
	}
}

然后,您可以将其注册到spring.factories文件中,如以下示例所示:

org.springframework.cloud.contract.stubrunner.HttpServerStub=\
org.springframework.cloud.contract.stubrunner.provider.moco.MocoHttpServerStub

现在,您可以使用 Moco 运行存根。

Tip

如果不提供任何实现,则使用默认(WireMock)实现。如果提供多个,则使用列表中的第一个。

95.5 使用自定义存根下载器

您可以通过创建StubDownloaderBuilder接口的实现来自定义存根的下载方式,如以下示例所示:

package com.example;

class CustomStubDownloaderBuilder implements StubDownloaderBuilder {

	@Override
	public StubDownloader build(final StubRunnerOptions stubRunnerOptions) {
		return new StubDownloader() {
			@Override
			public Map.Entry<StubConfiguration, File> downloadAndUnpackStubJar(
					StubConfiguration config) {
				File unpackedStubs = retrieveStubs();
				return new AbstractMap.SimpleEntry<>(
						new StubConfiguration(config.getGroupId(), config.getArtifactId(), version,
								config.getClassifier()), unpackedStubs);
			}

			File retrieveStubs() {
			    // here goes your custom logic to provide a folder where all the stubs reside
			}
}

然后,您可以将其注册到spring.factories文件中,如以下示例所示:

# Example of a custom Stub Downloader Provider
org.springframework.cloud.contract.stubrunner.StubDownloaderBuilder=\
com.example.CustomStubDownloaderBuilder

现在,您可以选择包含存根源的文件夹。

Tip

如果不提供任何实现,则使用默认设置(扫描 Classpath)。如果提供stubsMode = StubRunnerProperties.StubsMode.LOCAL, stubsMode = StubRunnerProperties.StubsMode.REMOTE,则将使用 Aether 实现。如果提供多个,则将使用列表中的第一个。

95.6 使用 SCM 存根下载器

每当repositoryRoot以 SCM 协议开头(当前我们仅支持git://)时,存根下载器都会尝试克隆存储库,并将其用作生成测试或存根的 Contract 来源。

通过环境变量,系统属性,插件内部设置的属性或 Contract 存储库配置,您可以调整下载程序的行为。您可以在下面找到属性列表

表 95.1 SCM 存根下载器属性

Property 类型Property 名称Description
* git.branch(插件支持)


* stubrunner.properties.git.branch(系统道具)

* STUBRUNNER_PROPERTIES_GIT_BRANCH(env prop)| master |要结帐的分支|
| * git.username(插件支持)

* stubrunner.properties.git.username(系统道具)

* STUBRUNNER_PROPERTIES_GIT_USERNAME(env prop)| | Git 克隆用户名|
| * git.password(插件支持)

* stubrunner.properties.git.password(系统道具)

* STUBRUNNER_PROPERTIES_GIT_PASSWORD(env prop)| | Git 克隆密码|
| * git.no-of-attempts(插件支持)

* stubrunner.properties.git.no-of-attempts(系统道具)

* STUBRUNNER_PROPERTIES_GIT_NO_OF_ATTEMPTS(env prop)| 10 |将提交推送到origin的尝试次数|
| * git.wait-between-attempts(插件属性)

* stubrunner.properties.git.wait-between-attempts(系统道具)

* STUBRUNNER_PROPERTIES_GIT_WAIT_BETWEEN_ATTEMPTS(env prop)| 1000 |将提交推送到origin的尝试之间要 await 的毫秒数|

95.7 使用契约存根下载器

每当repositoryRoot以 Pact 协议开头(以pact://开头)时,存根下载器都将尝试从 Pact Broker 中获取 PactContract 定义。 pact://之后设置的任何内容都将解析为 Pact Broker URL。

通过环境变量,系统属性,插件内部设置的属性或 Contract 存储库配置,您可以调整下载程序的行为。您可以在下面找到属性列表

表 95.2. SCM 存根下载器属性

财产名称DefaultDescription
* pactbroker.host(插件支持)


* stubrunner.properties.pactbroker.host(系统道具)

* STUBRUNNER_PROPERTIES_PACTBROKER_HOST(env prop)| URL 传递给repositoryRoot的主机| Pact Broker 的 URL 是什么|
| * pactbroker.port(插件支持)

* stubrunner.properties.pactbroker.port(系统道具)

* STUBRUNNER_PROPERTIES_PACTBROKER_PORT(环境属性)| URL 传递给repositoryRoot的端口| Pact Broker 的端口是什么|
| * pactbroker.protocol(插件支持)

* stubrunner.properties.pactbroker.protocol(系统道具)

* STUBRUNNER_PROPERTIES_PACTBROKER_PROTOCOL(env prop)| URL 传递给repositoryRoot的协议| Pact Broker 的协议是什么|
| * pactbroker.tags(插件支持)

* stubrunner.properties.pactbroker.tags(系统道具)

* STUBRUNNER_PROPERTIES_PACTBROKER_TAGS(env prop)|存根的版本;如果版本为+,则为latest |应使用什么标签来获取存根|
| * pactbroker.auth.scheme(插件支持)

* stubrunner.properties.pactbroker.auth.scheme(系统道具)

* STUBRUNNER_PROPERTIES_PACTBROKER_AUTH_SCHEME(env prop)| Basic |应使用哪种身份验证连接到 Pact Broker |
| * pactbroker.auth.username(插件支持)

* stubrunner.properties.pactbroker.auth.username(系统道具)

* STUBRUNNER_PROPERTIES_PACTBROKER_AUTH_USERNAME(env prop)|传递给contractsRepositoryUsername(maven)或contractRepository.username(gradle)的用户名|用于连接到 Pact Broker 的用户名|
| * pactbroker.auth.password(插件支持)

* stubrunner.properties.pactbroker.auth.password(系统道具)

* STUBRUNNER_PROPERTIES_PACTBROKER_AUTH_PASSWORD(env prop)|传递给contractsRepositoryPassword(maven)或contractRepository.password(gradle)的密码|用于连接到 Pact Broker 的密码|
| * pactbroker.provider-name-with-group-id(插件支持)

* stubrunner.properties.pactbroker.provider-name-with-group-id(系统道具)

* STUBRUNNER_PROPERTIES_PACTBROKER_PROVIDER_NAME_WITH_GROUP_ID(env prop)| false |当true时,提供者名称将是groupId:artifactId的组合。如果false,则仅使用artifactId |