You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Cloud Contract gRPC测试:WireMock请求匹配失败排查

Spring Cloud Contract gRPC契约测试请求匹配问题排查与解决

问题描述

基于Spring Cloud Contract构建gRPC+Protocol Buffers服务的集成契约测试套件,生产者端Stub已生成并可通过stub-runner正常使用,但消费者端调用Stub时,WireMock抛出请求不匹配错误,尽管使用了与契约定义完全一致的Protobuf请求消息。

错误日志

2022-08-23 08:40:23.211 ERROR [WireMock] 
                                               Request was not matched
                                               =======================

---------------------------------------------------------------------------------------------
| Closest stub                                             | Request                        |
---------------------------------------------------------------------------------------------
                                                           |
POST                                                       | POST
/service/method                                            | /service/method
                                                           |
Content-Type [matches] : application/grpc.*                | Content-Type: application/grpc
te: trailers                                               | te: trailers
                                                           |
AAAAGz0AAAAbOAAAABszEqYhWwogIHsKICAgICJAdHlwZSI6ICJRRUZpb  | [B@1a1a8bcc                                         
HRlclBpcGUiLAogICAgImZpbHRlckV4cHIiOiB7CiAgICAgICJAdHlwZS  | <<<<<< Body does not match
I6ICJFeHByRnVuY3Rpb24iLAogICAgICAiZnVuY3Rpb24iOiB7CiAgICA  |
...more base64 code...                                     |

注:Stub侧请求体以Base64格式展示,消费者侧仅显示字节数组默认toString结果,难以直接排查差异。

消费者测试代码

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, 
classes = ConsumerTest.TestConfiguration.class,
    properties = {
        "grpc.client.myGrpcService.address=static://localhost:5433",
        "grpc.client.myGrpcService.negotiationType=TLS"
    })
public class ConsumerTest {

  @GrpcClient(value = "myGrpcService", interceptorNames = "fixedStatusSendingClientInterceptor")
  MyGrpcServiceGrpc.MyGrpcServiceBlockingStub blockingStub;

  int port;

  @RegisterExtension
  static StubRunnerExtension rule = new StubRunnerExtension()
      .downloadStub("mygroupid", "myartifactid", "0.0.0", "stubs")
      .stubsMode(StubRunnerProperties.StubsMode.LOCAL)
      .withHttpServerStubConfigurer(MyWireMockConfigurer.class);

  @BeforeEach
  public void setupPort() {
    this.port = rule.findStubUrl("mygroupid", "myartifactid").getPort();
  }

  @Test
  void initialTest() {
    MyProtoMessage request = MyProtoMessage.newBuilder()
        [...]
        .build();

    Iterator<MyResponse> iterator = blockingStub.myMethod(request);
    // no element found, since the request does not match
    MyResponse current = iterator.next();
  }

  static class MyWireMockConfigurer extends WireMockHttpServerStubConfigurer {
    @Override
    public WireMockConfiguration configure(WireMockConfiguration httpStubConfiguration,
        HttpServerStubConfiguration httpServerStubConfiguration) {
      return httpStubConfiguration.dynamicHttpsPort()
          .httpsPort(5433);
    }
  }


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

    @Bean
    public GrpcChannelConfigurer keepAliveClientConfigurer() {
      return (channelBuilder, name) -> {
        if (channelBuilder instanceof NettyChannelBuilder) {
          try {
            ((NettyChannelBuilder) channelBuilder)
                .useTransportSecurity()
                .negotiationType(NegotiationType.TLS)
                .sslContext(GrpcSslContexts.forClient()
                    .trustManager(InsecureTrustManagerFactory.INSTANCE)
                    .build());
          } catch (SSLException e) {
            throw new IllegalStateException(e);
          }
        }
      };
    }

    @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) {
                  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);
            }
          };
        }
      };
    }
  }
}

契约定义

package contracts

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

Contract.make {
  description("Contract Test")
  request {
    method 'POST'
    url '/service/method'
    body(fileAsBytes("request.bin"))
    headers {
      contentType("application/grpc")
      header("te", "trailers")
    }
  }
  response {
    status 200
    body(value(producer($(anyNonEmptyString())), consumer(fileAsBytes("response.bin"))))
    headers {
      contentType("application/grpc")
      header("grpc-encoding", "identity")
      header("grpc-accept-encoding", "gzip")
    }
  }
  metadata([
    "verifierHttp": [
      "protocol": ContractVerifierHttpMetaData.Protocol.H2_PRIOR_KNOWLEDGE.toString()
    ]
  ])
}

解决方案

1. 开启WireMock的HTTP/2支持

gRPC基于HTTP/2协议运行,需确保WireMock启用HTTP/2:

static class MyWireMockConfigurer extends WireMockHttpServerStubConfigurer {
  @Override
  public WireMockConfiguration configure(WireMockConfiguration httpStubConfiguration,
      HttpServerStubConfiguration httpServerStubConfiguration) {
    return httpStubConfiguration.dynamicHttpsPort()
        .http2Enabled(true); // 新增启用HTTP/2
  }
}

2. 校验请求二进制数据一致性

对比消费者发送的Protobuf序列化字节与契约中request.bin的内容:

@Test
void verifyRequestBytes() throws IOException {
  MyProtoMessage request = MyProtoMessage.newBuilder()
      [...] // 填充与契约一致的字段
      .build();
  byte[] requestBytes = request.toByteArray();
  // 读取契约中的request.bin文件路径需根据实际项目调整
  byte[] expectedBytes = Files.readAllBytes(Paths.get("src/test/resources/contracts/request.bin"));
  assertArrayEquals(expectedBytes, requestBytes);
}

若存在差异,检查字段默认值、枚举值、嵌套结构是否与契约定义完全匹配。

3. 修正端口配置冲突

移除固定端口配置,动态获取Stub Runner分配的端口:

  1. 移除MyWireMockConfigurer中的.httpsPort(5433)
  2. 修改@SpringBootTest属性,移除固定端口配置
  3. 在setupPort中动态设置gRPC客户端地址:
@BeforeEach
public void setupPort() {
  this.port = rule.findStubUrl("mygroupid", "myartifactid").getPort();
  System.setProperty("grpc.client.myGrpcService.address", "static://localhost:" + this.port);
}

4. 排查自定义拦截器影响

暂时移除fixedStatusSendingClientInterceptor,测试请求是否匹配:

  • 注释@GrpcClient中的interceptorNames参数
  • 删除TestConfiguration中的拦截器Bean定义
    若请求匹配成功,再排查拦截器是否修改了请求内容或元数据。

5. 启用WireMock DEBUG日志

添加日志配置查看完整请求体:

logging.level.com.github.tomakehurst.wiremock=DEBUG

日志会输出消费者发送的完整二进制请求体,可与契约中的request.bin解码后对比。


内容的提问来源于stack exchange,提问作者Felix

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.21 14:48:16