绕过java.net.URL的弃用

9

我正在将我的代码迁移到Java 20。

在这个版本中,java.net.URL#URL(java.lang.String)被弃用了。不幸的是,我有一个类,在旧的URL构造函数中找不到任何替代品。

package com.github.bottomlessarchive.loa.url.service.encoder;

import io.mola.galimatias.GalimatiasParseException;
import org.springframework.stereotype.Service;

import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Optional;

/**
 * This service is responsible for encoding existing {@link URL} instances to valid
 * <a href="https://en.wikipedia.org/wiki/Internationalized_Resource_Identifier">resource identifiers</a>.
 */
@Service
public class UrlEncoder {

    /**
     * Encodes the provided URL to a valid
     * <a href="https://en.wikipedia.org/wiki/Internationalized_Resource_Identifier">resource identifier</a> and return
     * the new identifier as a URL.
     *
     * @param link the url to encode
     * @return the encoded url
     */
    public Optional<URL> encode(final String link) {
        try {
            final URL url = new URL(link);

            // We need to further validate the URL because the java.net.URL's validation is inadequate.
            validateUrl(url);

            return Optional.of(encodeUrl(url));
        } catch (GalimatiasParseException | MalformedURLException | URISyntaxException e) {
            return Optional.empty();
        }
    }

    private void validateUrl(final URL url) throws URISyntaxException {
        // This will trigger an URISyntaxException. It is needed because the constructor of java.net.URL doesn't always validate the
        // passed url correctly.
        new URI(url.getProtocol(), url.getUserInfo(), url.getHost(), url.getPort(), url.getPath(), url.getQuery(), url.getRef());
    }

    private URL encodeUrl(final URL url) throws GalimatiasParseException, MalformedURLException {
        return io.mola.galimatias.URL.parse(url.toString()).toJavaURL();
    }
}

幸运的是,我也为这个类编写了测试:

package com.github.bottomlessarchive.loa.url.service.encoder;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import java.net.MalformedURLException;
import java.net.URL;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

class UrlEncoderTest {

    private final UrlEncoder underTest = new UrlEncoder();

    @ParameterizedTest
    @CsvSource(
            value = {
                    "http://www.example.com/?test=Hello world,http://www.example.com/?test=Hello%20world",
                    "http://www.example.com/?test=ŐÚőúŰÜűü,http://www.example.com/?test=%C5%90%C3%9A%C5%91%C3%BA%C5%B0%C3%9C%C5%B1%C3%BC",
                    "http://www.example.com/?test=random word £500 bank $,"
                            + "http://www.example.com/?test=random%20word%20%C2%A3500%20bank%20$",
                    "http://www.aquincum.hu/wp-content/uploads/2015/06/Aquincumi-F%C3%BCzetek_14_2008.pdf,"
                            + "http://www.aquincum.hu/wp-content/uploads/2015/06/Aquincumi-F%C3%BCzetek_14_2008.pdf",
                    "http://www.aquincum.hu/wp-content/uploads/2015/06/Aquincumi-F%C3%BCzetek_14 _2008.pdf,"
                            + "http://www.aquincum.hu/wp-content/uploads/2015/06/Aquincumi-F%C3%BCzetek_14%20_2008.pdf"
            }
    )
    void testEncodeWhenUsingValidUrls(final String urlToEncode, final String expected) throws MalformedURLException {
        final Optional<URL> result = underTest.encode(urlToEncode);

        assertThat(result)
                .contains(new URL(expected));
    }

    @ParameterizedTest
    @CsvSource(
            value = {
                    "http://промкаталог.рф/PublicDocuments/05-0211-00.pdf"
            }
    )
    void testEncodeWhenUsingInvalidUrls(final String urlToEncode) {
        final Optional<URL> result = underTest.encode(urlToEncode);

        assertThat(result)
                .isEmpty();
    }
}

它使用的唯一依赖是galamatias URL库。

有没有人有任何想法,如何在保持功能不变的情况下删除new URL(link)代码片段?

我尝试了各种方法,比如使用java.net.URI#create,但它没有产生与以前的解决方案完全相同的结果。例如,包含非编码字符(如http://www.example.com/?test=Hello world中的空格)的URL会导致IllegalArgumentException。这个问题被URL类解析而没有出错(而我的数据中有很多这样的问题)。而且,像http://промкаталог.рф/PublicDocuments/05-0211-00.pdf这样无法转换为URL的链接可以通过URI.create成功转换。


@ Hulk URLs 中包含未编码字符,如 "http://www.example.com/?test=Hello world" 中的空格。这些被 URL 类解析时不会出现错误(我的数据中有很多这样的情况)。而且,像 "http://промкаталог.рф/PublicDocuments/05-0211-00.pdf" 这样无法转换的链接,使用 URI.create 方法可以成功转换为 URI。 - Lakatos Gyula
你是对的。我更新了我的问题。 - Lakatos Gyula
旧方法中空格是如何编码的?我有时在URL中看到%20代表空格。你可以自己添加这种替换。在新方法中,是否需要设置一些参数以涵盖更多字符? - rossum
@rossum 正如在测试类中所看到的那样。其中一些已经正确进行了 URL 编码,而其他一些则没有。:/ URL 的构建未通过 URL 编码验证。然后使用以下方式创建 URI 进行进一步验证:new URI(url.getProtocol(), url.getUserInfo(), url.getHost(), url.getPort(), url.getPath(), url.getQuery(), url.getRef()); - Lakatos Gyula
1个回答

8

问题

主要问题似乎是UrlEncoder服务处理的URL同时包含编码、未编码和部分编码的混合。更重要的是,没有一个好的方法可以知道哪个是哪个。

这导致了歧义,因为某些字符在编码和未编码时可能具有不同的含义。例如,对于部分编码的URL,很难确定像'&'这样的字符是查询参数的一部分(因此应该进行编码),还是作为分隔符(因此不应进行编码):

https://www.example.com/test?firstQueryParam=hot%26cold&secondQueryParam=test

为了雪上加霜,由于历史/向后兼容性的原因,Java的URI实现与RFC 3986和RFC 3987有所偏离。这里有一篇关于一些URI怪癖的有趣阅读:更新JDK中对RFC 3986和RFC 3987的URI支持

通过重新编码而没有正确了解原始URL来“修复”不正确编码的URL并不是一个简单的问题。使用充满怪癖的编码器和解码器来修复不正确编码的URL甚至更难。一个足够好的“尽力而为”启发式方法将是我的建议。

一个简单的尽力而为解决方案

好消息是,我已经成功实施了一个解决方案,通过了以上所有的测试。所用的解决方案利用了Spring Web的UriUtilsUriComponentsBuilder。最令人高兴的是,你可能不再需要galimatias

这是代码:

public class UrlEncoder {

    public Optional<URL> encode(final String link) {
        try {
            final URI validatedURI = reencode(link).parseServerAuthority();
            return Optional.of(validatedURI.toURL());
        } catch (MalformedURLException | URISyntaxException e) {
            return Optional.empty();
        }
    }

    private URI reencode(String url) { // best effort
        final String decodedUrl = UriUtils.decode(url, StandardCharsets.UTF_8);
        return UriComponentsBuilder.fromHttpUrl(decodedUrl)
                .encode()
                .build()
                .toUri();;
    }
}

以下是要点:
  • reencode → 尝试通过解码和重新编码来“修复”URL编码
  • parseServerAuthority() → 作为前一个validateUrl(url)方法的替代方案。

对&符号和其他特殊字符进行双重编码

如先前所述,尽管上面的代码通过了所有测试。但很容易构造出一个“损坏”的测试案例。例如,将上述URL运行通过编码器将导致:

https://www.example.com/test?firstQueryParam=hot&cold&secondQueryParam=test

这是一个完全有效的URL,但可能不是人们正在寻找的内容。
我们现在进入了危险的领域,但有办法实施更加“主观”的重新编码算法。例如,下面的代码通过确保%26不被解码来处理和符号:
private final char PERCENT_SIGN = '%';
private final String ENCODED_PERCENT_SIGN = "25";
private final String[] CODES_TO_DOUBLE_ENCODE = new String[]{
        "26" // code for '&'
};

private URI reencode(String url) throws URISyntaxException {
    final String urlWithDoubleEncodedSpecialCharacters = doubleEncodeSpecialCharacters(url);
    final String decodedUrl = UriUtils.decode(urlWithDoubleEncodedSpecialCharacters, StandardCharsets.UTF_8);
    final String encodedUrl = UriComponentsBuilder.fromHttpUrl(decodedUrl).toUriString();
    final String encodedUrlWithSpecialCharacters = decodeDoubleEncodedSpecialCharacters(encodedUrl);

    return URI.create(encodedUrlWithSpecialCharacters);
}

private String doubleEncodeSpecialCharacters(String url) {
    final StringBuilder sb = new StringBuilder(url);
    for (String code : CODES_TO_DOUBLE_ENCODE) {
        final String codeString = PERCENT_SIGN + code;
        int index = sb.indexOf(codeString);
        while (index != -1) {
            sb.insert(index + 1, ENCODED_PERCENT_SIGN);
            index = sb.indexOf(codeString, index + 3);
        }
    }
    return sb.toString();
}

private String decodeDoubleEncodedSpecialCharacters(String url) {
    final StringBuilder sb = new StringBuilder(url);
    for (String code : CODES_TO_DOUBLE_ENCODE) {
        final String codeString = PERCENT_SIGN + ENCODED_PERCENT_SIGN + code;
        int index = sb.indexOf(codeString);
        while (index != -1) {
            sb.delete(index + 2, index + 4);
            index = sb.indexOf(codeString, index + 5);
        }
    }
    return sb.toString();
}

上面的解决方案可以修改以处理其他转义序列(例如,处理所有RFC 3986的保留字符),并且可以使用更复杂的启发式算法(例如,对查询参数和路径参数采取不同的处理方式)。
然而,作为一个之前深入研究过这个问题的人,我可以告诉你,一旦你知道你正在处理无法控制的错误编码的URL,就没有完美的解决方案。

1
哇,非常感谢!这是我在这个网站上得到的最好的答案之一!我会在24小时内尽快授予你200点赏金。 - Lakatos Gyula
感谢您的赞美和慷慨奖励 :)。 - Anthony Accioly
我有一个类似的问题:https://dev59.com/mvX8pIgB1922wOYJtSCi 请问您能否提供一个不需要Spring的解决方案?Jakarta EE也有UriBuilder:https://javadoc.io/doc/jakarta.platform/jakarta.jakartaee-api/latest/jakarta/ws/rs/core/UriBuilder.html - gouessej

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接