GRPC

GRPC 是一种建立在 HTTP/2 之上的 RPC 框架,Spring Cloud Contract 对其提供基本支持。

Spring Cloud Contract 对 gRPC 的基本用例有实验性的支持。不幸的是,由于 gRPC 调整了 HTTP/2 标头帧,因此无法声明 grpc-status 标头。

我们来看一下以下合同。

Groovy contract
package contracts.beer.rest


import org.springframework.cloud.contract.spec.Contract
import org.springframework.cloud.contract.verifier.http.ContractVerifierHttpMetaData

Contract.make {
	description("""
Represents a successful scenario of getting a beer

```
given:
	client is old enough
when:
	he applies for a beer
then:
	we'll grant him the beer
```

""")
	request {
		method 'POST'
		url '/beer.BeerService/check'
		body(fileAsBytes("PersonToCheck_old_enough.bin"))
		headers {
			contentType("application/grpc")
			header("te", "trailers")
		}
	}
	response {
		status 200
		body(fileAsBytes("Response_old_enough.bin"))
		headers {
			contentType("application/grpc")
			header("grpc-encoding", "identity")
			header("grpc-accept-encoding", "gzip")
		}
	}
	metadata([
			"verifierHttp": [
					"protocol": ContractVerifierHttpMetaData.Protocol.H2_PRIOR_KNOWLEDGE.toString()
			]
	])
}

Producer Side Setup

为了利用 HTTP/2 支持,您必须设置 CUSTOM 测试模式,如下所示。

Maven
<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>${spring-cloud-contract.version}</version>
    <extensions>true</extensions>
    <configuration>
        <testMode>CUSTOM</testMode>
        <packageWithBaseClasses>com.example</packageWithBaseClasses>
    </configuration>
</plugin>
Gradle
contracts {
	packageWithBaseClasses = 'com.example'
	testMode = "CUSTOM"
}

基类将设置在随机端口上运行的应用程序。它还将 HttpVerifier 实现设置为可以使用 HTTP/2 协议的实现。Spring Cloud Contract 附带 OkHttpHttpVerifier 实现。

Base Class
@SpringBootTest(classes = BeerRestBase.Config.class,
		webEnvironment = SpringBootTest.WebEnvironment.NONE,
		properties = {
				"grpc.server.port=0"
		})
public abstract class BeerRestBase {

	@Autowired
	GrpcServerProperties properties;

	@Configuration
	@EnableAutoConfiguration
	static class Config {

		@Bean
		ProducerController producerController(PersonCheckingService personCheckingService) {
			return new ProducerController(personCheckingService);
		}

		@Bean
		PersonCheckingService testPersonCheckingService() {
			return argument -> argument.getAge() >= 20;
		}

		@Bean
		HttpVerifier httpOkVerifier(GrpcServerProperties properties) {
			return new OkHttpHttpVerifier("localhost:" + properties.getPort());
		}

	}
}

Consumer Side Setup

GRPC 消费者端测试示例。由于 GRPC 服务器端的异常行为,存根无法在适当的时候返回 grpc-status 标头。这就是我们需要手动设置返回状态的原因。

Consumer Side Test
@SpringBootTest(webEnvironment = WebEnvironment.NONE, classes = GrpcTests.TestConfiguration.class, properties = {
		"grpc.client.beerService.address=static://localhost:5432", "grpc.client.beerService.negotiationType=TLS"
})
public class GrpcTests {

	@GrpcClient(value = "beerService", interceptorNames = "fixedStatusSendingClientInterceptor")
	BeerServiceGrpc.BeerServiceBlockingStub beerServiceBlockingStub;

	int port;

	@RegisterExtension
	static StubRunnerExtension rule = new StubRunnerExtension()
			.downloadStub("com.example", "beer-api-producer-grpc")
			// With WireMock PlainText mode you can just set an HTTP port
//			.withPort(5432)
			.stubsMode(StubRunnerProperties.StubsMode.LOCAL)
			.withHttpServerStubConfigurer(MyWireMockConfigurer.class);

	@BeforeEach
	public void setupPort() {
		this.port = rule.findStubUrl("beer-api-producer-grpc").getPort();
	}

	@Test
	public void should_give_me_a_beer_when_im_old_enough() throws Exception {
		Response response = beerServiceBlockingStub.check(PersonToCheck.newBuilder().setAge(23).build());

		BDDAssertions.then(response.getStatus()).isEqualTo(Response.BeerCheckStatus.OK);
	}

	@Test
	public void should_reject_a_beer_when_im_too_young() throws Exception {
		Response response = beerServiceBlockingStub.check(PersonToCheck.newBuilder().setAge(17).build());
		response = response == null ? Response.newBuilder().build() : response;

		BDDAssertions.then(response.getStatus()).isEqualTo(Response.BeerCheckStatus.NOT_OK);
	}

	// Not necessary with WireMock PlainText mode
	static class MyWireMockConfigurer extends WireMockHttpServerStubConfigurer {
		@Override
		public WireMockConfiguration configure(WireMockConfiguration httpStubConfiguration, HttpServerStubConfiguration httpServerStubConfiguration) {
			return httpStubConfiguration
					.httpsPort(5432);
		}
	}

	@Configuration
	@ImportAutoConfiguration(GrpcClientAutoConfiguration.class)
	static class TestConfiguration {

		// Not necessary with WireMock PlainText mode
		@Bean
		public GrpcChannelConfigurer keepAliveClientConfigurer() {
			return (channelBuilder, name) -> {
				if (channelBuilder instanceof NettyChannelBuilder) {
					try {
						((NettyChannelBuilder) channelBuilder)
								.sslContext(GrpcSslContexts.forClient()
										.trustManager(InsecureTrustManagerFactory.INSTANCE)
										.build());
					}
					catch (SSLException e) {
						throw new IllegalStateException(e);
					}
				}
			};
		}

		/**
		 * GRPC client interceptor that sets the returned status always to OK.
		 * You might want to change the return status depending on the received stub payload.
		 *
		 * Hopefully in the future this will be unnecessary and will be removed.
		 */
		@Bean
		ClientInterceptor fixedStatusSendingClientInterceptor() {
			return new ClientInterceptor() {
				@Override
				public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
					ClientCall<ReqT, RespT> call = next.newCall(method, callOptions);
					return new ClientCall<ReqT, RespT>() {
						@Override
						public void start(Listener<RespT> responseListener, Metadata headers) {
							Listener<RespT> listener = new Listener<RespT>() {
								@Override
								public void onHeaders(Metadata headers) {
									responseListener.onHeaders(headers);
								}

								@Override
								public void onMessage(RespT message) {
									responseListener.onMessage(message);
								}

								@Override
								public void onClose(Status status, Metadata trailers) {
									// TODO: This must be fixed somehow either in Jetty (WireMock) or somewhere else
									responseListener.onClose(Status.OK, trailers);
								}

								@Override
								public void onReady() {
									responseListener.onReady();
								}
							};
							call.start(listener, headers);
						}

						@Override
						public void request(int numMessages) {
							call.request(numMessages);
						}

						@Override
						public void cancel(@Nullable String message, @Nullable Throwable cause) {
							call.cancel(message, cause);
						}

						@Override
						public void halfClose() {
							call.halfClose();
						}

						@Override
						public void sendMessage(ReqT message) {
							call.sendMessage(message);
						}
					};
				}
			};
		}
	}
}