【问题标题】:Properly parse field containing '+' char正确解析包含“+”字符的字段
【发布时间】:2019-08-13 15:59:42
【问题描述】:

我遇到了一个奇怪的情况,我在https://github.com/lgueye/uri-parameters-behavior 中转载了

由于我们在 GET 方法中请求我们的后端之一时迁移到了 spring-boot 2spring 框架 5),因此我们运行了进入以下情况:所有具有 + 字符的字段在到达后端时都被更改为 (空白)字符

以下值已更改:

  • +412386789(电话号码)转为** 412386789**
  • 2019-03-22T17:18:39.621+02:00 (java8 ZonedDateTime) 变为 2019-03-22T17:18:39.621 02:00(导致 >org.springframework.validation.BindException

我在 stackoverflow (https://github.com/spring-projects/spring-framework/issues/14464#issuecomment-453397378) 和 github (https://github.com/spring-projects/spring-framework/issues/21577) 上花了很长时间

我已经实现了一个 mockMvc 单元测试和一个集成测试

单元测试正常运行 集成测试失败(就像我们的生产一样)

谁能帮我解决这个问题?我的目标显然是让集成测试通过。

感谢您的帮助。

路易

【问题讨论】:

    标签: spring-boot spring-mvc rfc3986


    【解决方案1】:

    整个错位来自这样一个事实,即如何将空间编码/解码为"+" 的非标准做法。

    可以说空间可以(正在)编码为"+""%20"

    例如 Google 对搜索字符串这样做:

    https://www.google.com/search?q=test+my+space+delimited+entry
    

    rfc1866, section-8.2.2 声明 GET 请求的查询部分应编码为 'application/x-www-form-urlencoded'

    所有表单的默认编码是`application/x-www-form-
    urlencoded'。表单数据集在此媒体类型中表示为
    如下:

    1. 表单字段名称和值被转义:空格 字符被替换为“+”

    另一方面,rfc3986 声明 URL 中的空格必须使用 "%20" 进行编码。

    这基本上意味着对空格进行编码有不同的标准,具体取决于它们在 URI syntax components 中的位置。

         foo://example.com:8042/over/there?name=ferret#nose
         \_/   \______________/\_________/ \_________/ \__/
          |           |            |            |        |
       scheme     authority       path        query   fragment
          |   _____________________|__
         / \ /                        \
         urn:example:animal:ferret:nose
    

    基于这些评论,我们可以说明在 URI 中的 GET http 调用中:

    • "?" 之前的空格需要编码为"%20"
    • 查询参数中"?"后面的空格需要编码为"+"
    • 这意味着"+"符号需要在查询参数中编码为"%2B"

    Spring 实现遵循 rfc 规范,这就是为什么当您在查询参数中发送 "+412386789" 时,"+" 符号被解释为空白字符,并以“412386789”

    看着:

    final URI uri = UriComponentsBuilder.fromHttpUrl("http://localhost")
                                        .port(port)
                                        .path("/events")
                                        .queryParams(params)
                                        .build()
                                        .toUri();
    

    你会发现:

    "foo#bar@quizz+foo-bazz//quir." 编码为"foo%23bar@quizz+foo-bazz//quir." 符合规范 (rfc3986)。

    因此,如果您希望查询参数中的 "+" 字符不被解释为空格,则需要将其编码为 "%2B"

    您发送到后端的参数应如下所示:

       params.add("id", id);
       params.add("device", device);
       params.add("phoneNumber", "%2B225697845");
       params.add("timestamp", "2019-03-25T15%3A09%3A44.703088%2B02%3A00");
       params.add("value", "foo%23bar%40quizz%2Bfoo-bazz%2F%2Fquir.");
    

    为此,您可以在将参数传递给地图时使用UrlEncoder。当心 UriComponentsBuilder 双重编码你的东西!

    您可以通过以下方式获得正确的 URL:

    final MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
    params.add("id", id);
    params.add("device", device);
    String uft8Charset = StandardCharsets.UTF_8.toString();
    params.add("phoneNumber", URLEncoder.encode(phoneNumber, uft8Charset));
    params.add("timestamp", URLEncoder.encode(timestamp.toString(), uft8Charset));
    params.add("value", URLEncoder.encode(value, uft8Charset));
    
    final URI uri = UriComponentsBuilder.fromHttpUrl("http://localhost")
                                        .port(port)
                                        .path("/events")
                                        .queryParams(params)
                                        .build(true)
                                        .toUri();
    

    请注意,将“true”传递给build() 方法会关闭编码,因此这意味着来自URI 部分的方案、主机等不会被UriComponentsBuilder 正确编码。

    【讨论】:

      【解决方案2】:

      在与这个问题进行了一番斗争后,我终于让它按照我们期望的方式在我们公司工作。

      有问题的组件不是 spring-boot 而是 UriComponentsBuilder

      我最初的失败测试如下所示:

          @Test
      public void get_should_properly_convert_query_parameters() {
          // Given
          final String device = UUID.randomUUID().toString();
          final String id = UUID.randomUUID().toString();
          final String phoneNumber = "+225697845";
          final String value = "foo#bar@quizz+foo-bazz//quir.";
          final Instant now = Instant.now();
          final ZonedDateTime timestamp = ZonedDateTime.ofInstant(now, ZoneId.of("+02:00"));
      
          final MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
          params.add("id", id);
          params.add("device", device);
          params.add("phoneNumber", phoneNumber);
          params.add("timestamp", timestamp.toString());
          params.add("value", value);
      
          final URI uri = UriComponentsBuilder.fromHttpUrl("http://localhost").port(port).path("/events").queryParams(params).build().toUri();
          final Event expected = Event.builder().device(device).id(id).phoneNumber(phoneNumber).value(value).timestamp(timestamp).build();
      
          // When
          final Event actual = restTemplate.exchange(uri, HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), Event.class).getBody();
      
          // Then
          assertEquals(expected, actual);
      }
      

      工作版本如下所示:

          @Test
      public void get_should_properly_convert_query_parameters() {
          // Given
          final String device = UUID.randomUUID().toString();
          final String id = UUID.randomUUID().toString();
          final String phoneNumber = "+225697845";
          final String value = "foo#bar@quizz+foo-bazz//quir.";
          final Instant now = Instant.now();
          final ZonedDateTime timestamp = ZonedDateTime.ofInstant(now, ZoneId.of("+02:00"));
          final Map<String, String> params = new HashMap<>();
          params.put("id", id);
          params.put("device", device);
          params.put("phoneNumber", phoneNumber);
          params.put("timestamp", timestamp.toString());
          params.put("value", value);
          final MultiValueMap<String, String> paramTemplates = new LinkedMultiValueMap<>();
          paramTemplates.add("id", "{id}");
          paramTemplates.add("device", "{device}");
          paramTemplates.add("phoneNumber", "{phoneNumber}");
          paramTemplates.add("timestamp", "{timestamp}");
          paramTemplates.add("value", "{value}");
      
          final URI uri = UriComponentsBuilder.fromHttpUrl("http://localhost").port(port).path("/events").queryParams(paramTemplates).encode().buildAndExpand(params).toUri();
          final Event expected = Event.builder().device(device).id(id).phoneNumber(phoneNumber).value(value).timestamp(ZonedDateTime.ofInstant(now, ZoneId.of("UTC"))).build();
      
          // When
          final Event actual = restTemplate.exchange(uri, HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), Event.class).getBody();
      
          // Then
          assertEquals(expected, actual);
      }
      

      注意 4 所需的差异:

      • 需要 MultiValueMap 参数模板
      • 需要映射参数值
      • 需要编码
      • 需要带有参数值的 buildAndExpand

      我有点难过,因为所有这些都非常容易出错且很麻烦(特别是 Map/MultiValueMap 部分)。我很乐意让它们从 java bean 生成。

      这对我们的解决方案影响很大,但恐怕我们别无选择。我们现在就接受这个解决方案。

      希望这可以帮助其他面临此问题的人。

      最好的,

      路易

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2012-05-04
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2012-11-09
        • 1970-01-01
        • 2011-12-19
        • 1970-01-01
        相关资源
        最近更新 更多