java写url库造轮子现场实录

来源:互联网 发布:书店管理系统完整源码 编辑:程序博客网 时间:2024/06/14 04:13

0x01 初窥门道

起因是我最近在弄网络爬虫(系列 - java网络爬虫开发笔记),结果在url解析这个方向上碰到一大堆问题,搞得我头昏脑涨,疲惫不堪。
为了让各位也能真切地体会到我的感受,我先列一堆“href属性里面写的东西”和“浏览器最终访问的东西”(chrome 55, win10 x64, 系统语言正版中文)的对照表,大家体会一下:

href属性里面写的东西 浏览器最终访问的东西 http://www.example.com/新年快乐 http://www.example.com/%e6%96%b0%e5%b9%b4%e5%bf%ab%e4%b9%90 http://www.example.com/新年 快乐 http://www.example.com/%E6%96%B0%E5%B9%B4%20%E5%BF%AB%E4%B9%90 http://www.example.com/新年快乐?有钱吗 http://www.example.com/%E6%96%B0%E5%B9%B4%E5%BF%AB%E4%B9%90?%D3%D0%C7%AE%C2%F0 http://www.example.com/%e6%96%b0%e5%b9%b4%e5%bf%ab%e4%b9%90 http://www.example.com/%e6%96%b0%e5%b9%b4%e5%bf%ab%e4%b9%90 http://www.example.com/新年?快=乐 http://www.example.com/%E6%96%B0%E5%B9%B4?%BF%EC=%C0%D6 http://www.example.com/新?年=快#乐 吗 http://example.com/%E6%96%B0?%C4%EA=%BF%EC http://www.example.com/%%%% http://www.example.com/%%%% http://www.example.com/%29%%29% http://www.example.com/%29%%29%

我们可以从例子中总结,将“href属性中的内容”到“http请求的地址”的映射关系可以分为以下5类:

  1. 常见可打印字符(按照url标准是a-zA-z!$&’()*+,-./:;=?@_~):不变。
  2. 大于U+007F的特殊字符(比如中文,其实标准里面有一张表,不过很多实现都不管):变成%XX%XX的形式。
  3. (原来就是)%XX%XX的形式(学名叫“百分号编码的字节”):不变。
  4. 单独的百分号%:不变。
  5. 空白符号(尤其是空格):变成%XX%XX的形式(空格是%20)。

列成一张表:

可打印符号 特殊符号 百分号编码 单独百分号 空白符号 不变 %XX 不变 不变 %XX

然而实际应用中碰到的问题又是什么呢?这就得说道java自带的java.net.URLjava.net.URI两个类了。

0x02 小试牛刀

这段时间正在用java上非常著名的网络库HttpClient,其中的请求类HttpGet有两个构造方法:

    public HttpGet(final String url) {    public HttpGet(final URI uri) {

其中以String为参数的构造器的内部也是调用了URI.create(url)罢了,那么URI又是何方神圣呢?

URI是java.net包下的一个库,其与java.net.URL之间的关系可谓为“微妙”。
从stack overflow上的回答和Oracle自家的javadoc看来,URL负责的是很简单的url字符串分块,也就是大致做一些简单的url解析工作,大概不过从中拆出url scheme,host,path,query这些而已,并不进行严格的url正确性验证(比如不验证字符集是否准确)。而URI负责的则是更加精确而严谨的url字符串处理,这其中就包括了最令人头疼的escape和正确性验证。
这两个类之间的关系,和其名称所说的URI和URL之间的关系并不相似,不要混淆。(关于URI和URL这两个概念之间的关系,可以看stack overflow上的这篇回答)

我们现在想要达到的效果是将“href中的属性”转换成“http请求头中的url地址”,考虑到原来的href可能并不标准(比如http://www.example.com/an invalid url就不标准,url中不能有空格),我们需要把它变成标准的形式(这也是浏览器能够完美做到的,把它变成比如http://www.example.com/an%20invalid%20url),而java.net.URL本身并不能负责编码的工作,于是我们想当然地去诉诸于java.net.URI

那么URI能不能完成我们想要他完成的工作呢?
来看URI中的几个构造函数:

    public URI(String str) throws URISyntaxException {    public URI(String scheme,            String userInfo, String host, int port,            String path, String query, String fragment)        throws URISyntaxException    public URI(String scheme,            String authority,            String path, String query, String fragment)        throws URISyntaxException

其中第三个就是第二个的一个简单版,把缺少的参数用默认值null来代替,我们要关注的只是第一个和第二个之间的区别。有关这一点,javadoc上是这么写的(是理解不是翻译):

两者对于特殊字符(比如中文)都会进行编码,输出的都是%XX%XX的形式,对于可打印字符都不变,但是对于其他几种就有区别。第一个基本不进行escape编码,认为输入的字符串就应该是正确的url,将其直接送入正确性验证。第二个会对认定输入的是完全未经编码的字符串,将作为参数的各个部分通通进行escape编码,比如空格就会变成%20等等。

等等,嗯。……等等,所以这两个函数和浏览器的正确行为到底相不相符呢?我们再来列表看看:

方法 可打印符号 特殊符号 百分号编码 单独百分号 空白符号 浏览器 不变 %XX 不变 不变 %XX 第一个 不变 %XX 不变 不变 报错 第二个 不变 %XX 变成%25XX 变成%25 %XX

(注:为什么说浏览器的才是正确行为,因为:1. url标准中明确定义了,空格(U+0020)和中文全角空格(U+3000)是算在default encode set中的,在编码的时候应该变成percent-encoded byte(即%XX的形式),而不是一旦Character.isSpaceChar(int)返回true就报错。2. url标准中也明确定义了碰到百分号%时的处理方法,如果发现百分号后面没有两个十六进制数码应该报syntax violation但不退出而是继续运行,又因为百分号%不在default encode set里面所以应该保持不变。所以你们这样子不尊重标准真的好吗……)

好了,这下麻烦了。没一个靠谱的。

0x03 渐入佳境

准确的说,最自然的想法是手动将空白符号编码成%XX的形式,然后再扔给第一个URI(String)的构造器自己搞定就行了,然而这里就不得不提到另一个问题了:

各位有没有想过,%XX式编码的本质是什么?准确的说,%XX编码的学名是“percent-encoded bytes”,其核心是byte,也就是字节。换句话说,%XX编码的内容并不一定要是utf-8或者unicode什么的,它其实可以代表任何一个字节,代表字符串在任意一个编码下的字节表示。
就拿中文举例,utf-8对于大部分中文字的编码都是3个字节,比如“我”被编码成“%e6%88%91”,然而gb2312/GBK中的中文文字编码都是两个字节,比如“我”的编码是“%ce%d2”。

再看到前面的示例,http://www.example.com/新年?快=乐被编码成为了http://www.example.com/%E6%96%B0%E5%B9%B4?%BF%EC=%C0%D6,怪哉!为什么“新年”的编码都是三个字节,而“快乐”的编码确实两个字节?
这是因为我的电脑用的是中文版win10系统,默认的编码就是GBK,而我做测试的这个页面并没有指定其字符集(我是在about:blank里面开了调试用js添加的链接),于是浏览器默认我的字符集就应该是GBK,而按照url标准的说法:

code points in the URL-query string that are higher than U+007F will be converted to percent-encoded bytes using the document’s encoding.
URL query中大于U+007F的字符(也就是US-ASCII之外的字符)在编码时遵照整个文档的字符集。

所以在测试用的链接中的query部分这儿,中文字符就被编码成了两字节的GBK。

麻烦就在于,java.net.URI在编码时,是强制编码成utf-8的,有代码为证(URI.java):

    String ns = Normalizer.normalize(s, Normalizer.Form.NFC);    ByteBuffer bb = null;    try {        bb = ThreadLocalCoders.encoderFor("UTF-8").encode(CharBuffer.wrap(ns));    } catch (CharacterCodingException x) {        assert false;    }

这里的utf-8是硬编码的,是码多少字也改变不了的,那么如果我解析的过程中碰到一个gb2312编码的页面,结果进行url escaped的时候通通给我变成了utf-8编码咋办?根本没办法。
这根本就不是一个URI类的编写不符合标准的问题。这是java.net这个包的根本设计问题——它根本就没想过要为这种情况服务。

这下倒好,走进死胡同了。
想要简简单单地做几个替换就解决问题?你们啊,还是naive!

0x04 峰回路转

轮子哥vczh有句话说的好(误),有问题怎么办,所有的库都解决不了问题怎么办,造个轮子不就都解决了嘛!

造轮子的过程就不多赘述了,一个不太大的java库,总共四千多行,大概就是把整个url标准通通实现了一遍,写了两天,所以java爬虫那个系列也就没有更新,各位见谅。
github链接:StdURL - std4453,求star,求follow,求pull,求issue,请便。

大概的功能都有了,贴一段最简单的测试代码:

package test;import org.stdurl.URL;import org.stdurl.parser.BasicURLParser;/** * */public class Example {    public static void main(String[] args) {        boolean passed =                test("https:example.org", null, "https://example.org/") &                        test("https://////example.com///", null,                                "https://example.com///") &                        test("https://example.com/././foo", null,                                "https://example.com/foo") &                        test("hello:world", "https://example.com", "hello:world") &                        test("https:example.org", "https://example.com",                                "https://example.com/example.org") &                        test("\\example\\..\\demo/.\\", "https://example.com",                                "https://example.com/demo/") &                        test("example", "https://example.com/demo",                                "https://example.com/example") &                        test("example", null, null) &                        test("https://example.com:demo", null, null) &                        test("http://[www.example.com]/", null, null) &                        test("http://www.example.com/新年快乐", null,                                "http://www.example" +                                        ".com/%E6%96%B0%E5%B9%B4%E5%BF%AB%E4%B9%90") &                        test("http://www.example.com/新年 快乐", null,                                "http://www.example" +                                        ".com/%E6%96%B0%E5%B9%B4%20%E5%BF%AB%E4%B9%90") &                        test("http://www.example.com/%%%%", null,                                "http://www.example.com/%%%%") &                        test("http://www.example.com/%29%%29%", null,                                "http://www.example.com/%29%%29%");        if (passed)            System.out.println("All passed.");    }    private static boolean test(String url, String base, String result) {        URL u = parse(url, base);        boolean passed;        if (result == null)            passed = u == null || u.isFailure();        else passed = result.equals(u.toString());        if (!passed) {            System.err.printf("\tTest not passed for url \"%s\" and base \"%s\":\n" +                            "\tExpected: \"%s\", Got: \"%s\"\n",                    url, base == null ? "NO BASE" : base,                    result == null ? "FAILURE" : result, u.toString());        } else {            System.out.printf("\tTest passed for url \"%s\" and base \"%s\":\n" +                            "\tResult is: \"%s\"\n",                    url, base == null ? "NO BASE" : base,                    result == null ? "FAILURE" : result);        }        return passed;    }    private static URL parse(String url, String base) {        if (base == null) {            return BasicURLParser.parse(url);        } else {            URL baseUrl = BasicURLParser.parse(base);            if (baseUrl == null || baseUrl.isFailure()) return URL.failure;            return BasicURLParser.parse(url, baseUrl);        }    }}

(可能自动换行的排版有点乱)

运行结果:

Syntax violation while parsing URL: “https:example.org”
Current state: 8, Violation index: 6
Message: Slash should be followed by another slash.
Test passed for url “https:example.org” and base “NO BASE”:
Result is: “https://example.org/”
Test passed for url “https://////example.com///” and base “NO BASE”:
Result is: “https://example.com///”
Syntax violation while parsing URL: “https://////example.com///”
Current state: 9, Violation index: 8
Message: Slash or backslash unexpected.
Syntax violation while parsing URL: “https://////example.com///”
Current state: 9, Violation index: 9
Message: Slash or backslash unexpected.
Syntax violation while parsing URL: “https://////example.com///”
Current state: 9, Violation index: 10
Message: Slash or backslash unexpected.
Syntax violation while parsing URL: “https://////example.com///”
Current state: 9, Violation index: 11
Message: Slash or backslash unexpected.
Test passed for url “https://example.com/././foo” and base “NO BASE”:
Result is: “https://example.com/foo”
Test passed for url “hello:world” and base “https://example.com“:
Result is: “hello:world”
Test passed for url “https:example.org” and base “https://example.com“:
Result is: “https://example.com/example.org”
Test passed for url “\example..\demo/.\” and base “https://example.com“:
Syntax violation while parsing URL: “hello:world”
Result is: “https://example.com/demo/”
Current state: 19, Violation index: 6
Message: ‘%’ is not followed by two hex digits.
Syntax violation while parsing URL: “hello:world”
Current state: 19, Violation index: 7
Message: ‘%’ is not followed by two hex digits.
Syntax violation while parsing URL: “hello:world”
Current state: 19, Violation index: 8
Message: ‘%’ is not followed by two hex digits.
Syntax violation while parsing URL: “hello:world”
Current state: 19, Violation index: 9
Message: ‘%’ is not followed by two hex digits.
Syntax violation while parsing URL: “hello:world”
Current state: 19, Violation index: 10
Message: ‘%’ is not followed by two hex digits.
Syntax violation while parsing URL: “hello:world”
Current state: 19, Violation index: 11
Message: ‘%’ is not followed by two hex digits.
Syntax violation while parsing URL: “https:example.org”
Current state: 4, Violation index: 6
Message: Should start with “//”.
Syntax violation while parsing URL: “\example..\demo/.\”
Current state: 6, Violation index: 0
Message: Backslash should be slash.
Syntax violation while parsing URL: “\example..\demo/.\”
Current state: 18, Violation index: 8
Message: Backslash should be slash.
Syntax violation while parsing URL: “\example..\demo/.\”
Current state: 18, Violation index: 11
Message: Backslash should be slash.
Syntax violation while parsing URL: “\example..\demo/.\”
Current state: 18, Violation index: 18
Message: Backslash should be slash.
Test passed for url “example” and base “https://example.com/demo“:
Result is: “https://example.com/example”
Syntax violation while parsing URL: “example”
Current state: 3, Violation index: 0
Message: Must have a base URL or begin with ‘#’.
Test passed for url “example” and base “NO BASE”:
Result is: “FAILURE”
Test passed for url “https://example.com:demo” and base “NO BASE”:
Result is: “FAILURE”
Test passed for url “http://[www.example.com]/” and base “Syntax violation while parsing URL: “https://example.com:demo”
Current state: 13, Violation index: 20
Message: Character ‘d’ unexpected.
Syntax violation while parsing Host: “www.example.com”
Violation index: 0, Message: Character ‘w’ unexpected.
NO BASE”:
Result is: “FAILURE”
Test passed for url “http://www.example.com/新年快乐” and base “NO BASE”:
Result is: “http://www.example.com/%E6%96%B0%E5%B9%B4%E5%BF%AB%E4%B9%90”
Test passed for url “http://www.example.com/新年 快乐” and base “NO BASE”:
Result is: “http://www.example.com/%E6%96%B0%E5%B9%B4%20%E5%BF%AB%E4%B9%90”
Test passed for url “http://www.example.com/%%%%” and base “NO BASE”:
Result is: “http://www.example.com/%%%%”
Test passed for url “http://www.example.com/%29%%29%” and base “NO BASE”:
Result is: “http://www.example.com/%29%%29%”
All passed.
Syntax violation while parsing URL: “http://www.example.com/新年 快乐”
Current state: 18, Violation index: 25
Message: Character ’ ’ unexpected.
Syntax violation while parsing URL: “http://www.example.com/%%%%”
Current state: 18, Violation index: 23
Message: ‘%’ is not followed by two hex digits.
Syntax violation while parsing URL: “http://www.example.com/%%%%”
Current state: 18, Violation index: 24
Message: ‘%’ is not followed by two hex digits.
Syntax violation while parsing URL: “http://www.example.com/%%%%”
Current state: 18, Violation index: 25
Message: ‘%’ is not followed by two hex digits.
Syntax violation while parsing URL: “http://www.example.com/%%%%”
Current state: 18, Violation index: 26
Message: ‘%’ is not followed by two hex digits.
Syntax violation while parsing URL: “http://www.example.com/%29%%29%”
Current state: 18, Violation index: 26
Message: ‘%’ is not followed by two hex digits.
Syntax violation while parsing URL: “http://www.example.com/%29%%29%”
Current state: 18, Violation index: 30
Message: ‘%’ is not followed by two hex digits.

(System.out居然和System.err混到一起去了。。)
大意:运行正常,测试全部通过。鼓掌!

0x05 柳暗花明

(其实就是后记嗯)
这个库还没有完全开发完毕,单元测试也没写,里面谁知道有多少没抓出来的bug,还有IDNA的一小块没有完成,各位也可以多多关注。再次:求star,求follow,求pull,求issue,谢谢大家!

话说知乎上有没有叫做“过年打代码是种怎样的体验”的问题,我倒是可以回答回答。这两天每天打上十个小时的代码,两天就码了四千多,一方面是挺累的,另一方面也挺有成就感的。所谓的“痛并快乐着”就是这样吧。

写的差不多了,我也可以解决我爬虫里的一个比较大的问题了,预计后天就会发新的爬虫文章,继续更新。过年归过年,代码归代码,这两者并不冲突。

最后,祝大家新年快乐,鸡年大吉吧!

0 0
原创粉丝点击