Java URL类踩坑指南

背景介绍

最近再做一个RSS阅读工具给自己用,其中一个环节是从服务器端获取一个包含了RSS源列表的json文件,再根据这个json文件下载、解析RSS内容。核心代码如下:

class PresenterImpl(val context: Context, val activity: MainActivity) : IPresenter {
    private val URL_API = "https://vimerzhao.github.io/others/rssreader/RSS.json"

    override fun getRssResource(): RssSource {
        val gson = GsonBuilder().create()
        return gson.fromJson(getFromNet(URL_API), RssSource::class.java)
    }

    private fun getFromNet(url: String): String {
        val result = URL(url).readText()
        return result
    }

    ......
}

之前一直执行地很好,直到前两天我购买了一个vimerzhao.top的域名,并将原来的域名vimerzhao.github.io重定向到了vimerzhao.top。这个工具就无法使用了,但在浏览器输入URL_API却能得到数据:

那为什么URL.readText()没有拿到数据呢?

不支持重定向

可以通过下面代码测试:

import java.net.*;
import java.io.*;

public class TestRedirect {
    public static void main(String args[]) {
        try {
            URL url1 = new URL("https://vimerzhao.github.io/others/rssreader/RSS.json");
            URL url2 = new URL("http://vimerzhao.top/others/rssreader/RSS.json");
            read(url1);
            System.out.println("=--------------------------------=");
            read(url2);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static void read(URL url) {
        try {
            BufferedReader in = new BufferedReader(
                    new InputStreamReader(url.openStream()));

            String inputLine;
            while ((inputLine = in.readLine()) != null) {
                System.out.println(inputLine);
            }
            in.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

得到结果如下:

<html>
<head><title>301 Moved Permanently</title></head>
<body bgcolor="white">
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx</center>
</body>
</html>
=--------------------------------=
{"theme":"tech","author":"zhaoyu","email":"[email protected]","version":"0.01","contents":[{"category":"综合版块","websites":[{"tag":"门户网站","url":["http://geek.csdn.net/admin/news_service/rss","http://blog.jobbole.com/feed/","http://feed.cnblogs.com/blog/sitehome/rss","https://segmentfault.com/feeds","http://www.codeceo.com/article/category/pick/feed"]},{"tag":"知名社区","url":["https://stackoverflow.com/feeds","https://www.v2ex.com/index.xml"]},{"tag":"官方博客","url":["https://www.blog.google/rss/","https://blog.jetbrains.com/feed/"]},{"tag":"个人博客-行业","url":["http://feed.williamlong.info/","https://www.liaoxuefeng.com/feed/articles"]},{"tag":"个人博客-学术","url":["http://www.norvig.com/rss-feed.xml"]}]},{"category":"编程语言","websites":[{"tag":"Kotlin","url":["https://kotliner.cn/api/rss/latest"]},{"tag":"Python","url":["https://www.python.org/dev/peps/peps.rss/"]},{"tag":"Java","url":["http://www.codeceo.com/article/category/develop/java/feed"]}]},{"category":"行业动态","websites":[{"tag":"Android","url":["http://www.codeceo.com/article/category/develop/android/feed"]}]},{"category":"乱七八遭","websites":[{"tag":"Linux-综合","url":["https://linux.cn/rss.xml","http://www.linuxidc.com/rssFeed.aspx","http://www.codeceo.com/article/tag/linux/feed"]},{"tag":"Linux-发行版","url":["https://blog.linuxmint.com/?feed=rss2","https://manjaro.github.io/feed.xml"]}]}]}

HTTP返回码301,即发生了重定向。可在浏览器上这个过程太快以至于我们看不到这个301界面的出现。这里需要说明的是URL.readText()是Kotlin中一个扩展函数,本质还是调用了URL类的openStream方法,部分源码如下:

.....
/**
 * Reads the entire content of this URL as a String using UTF-8 or the specified [charset].
 *
 * This method is not recommended on huge files.
 *
 * @param charset a character set to use.
 * @return a string with this URL entire content.
 */
@kotlin.internal.InlineOnly
public inline fun URL.readText(charset: Charset = Charsets.UTF_8): String = readBytes().toString(charset)

/**
 * Reads the entire content of the URL as byte array.
 *
 * This method is not recommended on huge files.
 *
 * @return a byte array with this URL entire content.
 */
public fun URL.readBytes(): ByteArray = openStream().use { it.readBytes() }

所以上面的测试代码即说明了URL.readText()失败的原因。
不过URL不支持重定向是否合理?为什么不支持?还有待探究。

不稳定的equals方法

首先看下equals的说明(URL (Java Platform SE 7 )):

Compares this URL for equality with another object.
If the given object is not a URL then this method immediately returns false.
Two URL objects are equal if they have the same protocol, reference equivalent hosts, have the same port number on the host, and the same file and fragment of the file.
Two hosts are considered equivalent if both host names can be resolved into the same IP addresses; else if either host name can‘t be resolved, the host names must be equal without regard to case; or both host names equal to null.
Since hosts comparison requires name resolution, this operation is a blocking operation.
Note: The defined behavior for equals is known to be inconsistent with virtual hosting in HTTP.

接下来再看一段代码:

import java.net.*;
public class TestEquals {
    public static void main(String args[]) {
        try {
            // vimerzhao的博客主页
            URL url1 = new URL("https://vimerzhao.github.io/");
            // zhanglanqing的博客主页
            URL url2 = new URL("https://zhanglanqing.github.io/");
            // vimerzhao博客主页重定向后的域名
            URL url3 = new URL("http://vimerzhao.top/");
            System.out.println(url1.equals(url2));
            System.out.println(url1.equals(url3));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

根据定义输出结果是什么呢?运行之后是这样:

true
false

你可能猜对了,但如果我把电脑断网之后再次执行,结果却是:

false
false

但其实3个域名的IP地址都是相同的,可以ping一下:

[email protected] ~/Project $ ping vimezhao.github.io
PING sni.github.map.fastly.net (151.101.77.147) 56(84) bytes of data.
64 bytes from 151.101.77.147: icmp_seq=1 ttl=44 time=396 ms
^C
--- sni.github.map.fastly.net ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 396.692/396.692/396.692/0.000 ms
[email protected] ~/Project $ ping zhanglanqing.github.io
PING sni.github.map.fastly.net (151.101.77.147) 56(84) bytes of data.
64 bytes from 151.101.77.147: icmp_seq=1 ttl=44 time=396 ms
^C
--- sni.github.map.fastly.net ping statistics ---
2 packets transmitted, 1 received, 50% packet loss, time 1000ms
rtt min/avg/max/mdev = 396.009/396.009/396.009/0.000 ms
[email protected] ~/Project $ ping vimezhao.top
ping: unknown host vimezhao.top
[email protected] ~/Project $ ping vimerzhao.top
PING sni.github.map.fastly.net (151.101.77.147) 56(84) bytes of data.
64 bytes from 151.101.77.147: icmp_seq=1 ttl=44 time=409 ms
^C
--- sni.github.map.fastly.net ping statistics ---
2 packets transmitted, 1 received, 50% packet loss, time 1001ms
rtt min/avg/max/mdev = 409.978/409.978/409.978/0.000 ms

首先看一下有网络连接的情况,vimerzhao.github.iozhanglanqing.github.io是我和我同学的博客,虽然内容不一样但是指向相同的IP,协议、端口等都相同,所以相等了;而vimerzhao.github.io虽然和vimerzhao.top指向同一个博客,但是一个是https一个是http,协议不同,所以判断为不相等。相信这和大多数人的直觉是相背的:指向不同博客的URL相等了,但指向相同博客的URL却不相等!
再分析断网之后的结果:首先查看URL的源码:


    public boolean equals(Object obj) {
        if (!(obj instanceof URL))
            return false;
        URL u2 = (URL)obj;

        return handler.equals(this, u2);
    }

再看handler对象的源码:


    protected boolean equals(URL u1, URL u2) {
        String ref1 = u1.getRef();
        String ref2 = u2.getRef();
        return (ref1 == ref2 || (ref1 != null && ref1.equals(ref2))) &&
               sameFile(u1, u2);
    }

sameFile源码:


    protected boolean sameFile(URL u1, URL u2) {
        // Compare the protocols.
        if (!((u1.getProtocol() == u2.getProtocol()) ||
              (u1.getProtocol() != null &&
               u1.getProtocol().equalsIgnoreCase(u2.getProtocol()))))
            return false;

        // Compare the files.
        if (!(u1.getFile() == u2.getFile() ||
              (u1.getFile() != null && u1.getFile().equals(u2.getFile()))))
            return false;

        // Compare the ports.
        int port1, port2;
        port1 = (u1.getPort() != -1) ? u1.getPort() : u1.handler.getDefaultPort();
        port2 = (u2.getPort() != -1) ? u2.getPort() : u2.handler.getDefaultPort();
        if (port1 != port2)
            return false;

        // Compare the hosts.
        if (!hostsEqual(u1, u2))
            return false;// 无网络连接时会触发这一句

        return true;
    }

最后是hostsEqual的源码:


    protected boolean hostsEqual(URL u1, URL u2) {
        InetAddress a1 = getHostAddress(u1);
        InetAddress a2 = getHostAddress(u2);
        // if we have internet address for both, compare them
        if (a1 != null && a2 != null) {
            return a1.equals(a2);
        // else, if both have host names, compare them
        } else if (u1.getHost() != null && u2.getHost() != null)
            return u1.getHost().equalsIgnoreCase(u2.getHost());
         else
            return u1.getHost() == null && u2.getHost() == null;
    }

在有网络的情况下,a1a2都不是null所以会触发return a1.equals(a2),返回true;而没有网络时则会触发return u1.getHost().equalsIgnoreCase(u2.getHost());即第二个判断,显然url1hostvimerzhao.github.io)和url2hostzhanglanqing.github.io)不等,所以返回false,导致if (!hostsEqual(u1, u2))判断为真,return false执行。
可见,URL类的equals方法不仅违反直觉还缺乏一致性,在不同环境会有不同结果,十分危险!

耗时的equals方法

此外,equals还是个耗时的操作,因为在有网络的情况下需要进行DNS解析,hashCode()同理,这里以hashCode()为例说明。URL类的hashCode()源码:

    public synchronized int hashCode() {
        if (hashCode != -1)
            return hashCode;

        hashCode = handler.hashCode(this);
        return hashCode;
    }

handler对象的hashCode()方法:


    protected int hashCode(URL u) {
        int h = 0;

        // Generate the protocol part.
        String protocol = u.getProtocol();
        if (protocol != null)
            h += protocol.hashCode();

        // Generate the host part.
        InetAddress addr = getHostAddress(u);
        if (addr != null) {
            h += addr.hashCode();
        } else {
            String host = u.getHost();
            if (host != null)
                h += host.toLowerCase().hashCode();
        }

        // Generate the file part.
        String file = u.getFile();
        if (file != null)
            h += file.hashCode();

        // Generate the port part.
        if (u.getPort() == -1)
            h += getDefaultPort();
        else
            h += u.getPort();

        // Generate the ref part.
        String ref = u.getRef();
        if (ref != null)
            h += ref.hashCode();

        return h;
    }

其中getHostAddress()会消耗大量时间。所以,如果在基于哈希表的容器中存储URL对象,简直就是灾难。下面这段代码,对比了URLURI在存储50次时的表现:

import java.net.*;
import java.util.*;

public class TestHash {
    public static void main(String args[]) {
        HashSet<URL> list1 = new HashSet<>();
        HashSet<URI> list2 = new HashSet<>();
        try {
            URL url1 = new URL("https://vimerzhao.github.io/");
            URI url2 = new URI("https://zhanglanqing.github.io/");
            long cur = System.currentTimeMillis();
            int cnt = 50;
            for (int i = 0; i < cnt; i++) {
                list1.add(url1);
            }
            System.out.println(System.currentTimeMillis() - cur);
            cur = System.currentTimeMillis();
            for (int i = 0; i < cnt; i++) {
                list2.add(url2);
            }
            System.out.println(System.currentTimeMillis() - cur);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

输出为:

271
0

所以,基于哈希表实现的容器最好不要用URL

TrailingSlash的作用

所谓TrailingSlash就是域名结尾的斜杠。比如我们在浏览器看到vimerzhao.top,复制后粘贴发现是http://vimerzhao.top/。首先用下面代码测试:

import java.net.*;
import java.io.*;

public class TestTrailingSlash {
    public static void main(String args[]) {
        try {
            URL url1 = new URL("https://vimerzhao.github.io/");
            URL url2 = new URL("https://vimerzhao.github.io");
            System.out.println(url1.equals(url2));
            outputInfo(url1);
            outputInfo(url2);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static void outputInfo(URL url) {
        System.out.println("------" + url.toString() + "----------");
        System.out.println(url.getRef());
        System.out.println(url.getFile());
        System.out.println(url.getHost());
        System.out.println("----------------");
    }
}

得到结果如下:

false
------https://vimerzhao.github.io/----------
null
/
vimerzhao.github.io
----------------
------https://vimerzhao.github.io----------
null

vimerzhao.github.io
----------------

其实,无论用前面的read()方法读或者地址栏直接输入url,url1url2内容都是相同的,但是加/表示这是一个目录,不加表示这是一个文件,所以二者getFile()的结果不同,导致equals判断为false。在地址栏输入时甚至不会觉察到这个TrailingSlash,所返回的结果也一样,但equals判断竟然为false,真是防不胜防!
这里还有一个问题就是:一个是文件,令一个是目录,为什么都能得到相同结果?
调查一番后发现:其实再请求的时候如果有/,那么就会在这个目录下找index.html文件;如果没有,以vimerzhao.top/tags为例,则会先找tags,如果找不到就会自动在后面添加一个/,再在tags目录下找index.html文件。如图:

这里有一个有趣的测试,编写两段代码如下:

import java.net.*;
import java.io.*;

public class TestTrailingSlash {
    public static void main(String args[]) {
        try {
            URL urlWithSlash = new URL("http://vimerzhao.top/tags/");
            int cnt = 5;
            long cur = System.currentTimeMillis();
            for (int i = 0; i < cnt; i++) {
                read(urlWithSlash);
            }
            System.out.println(System.currentTimeMillis() - cur);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static void read(URL url) {
        try {
            BufferedReader in = new BufferedReader(
                    new InputStreamReader(url.openStream()));

            String inputLine;
            while ((inputLine = in.readLine()) != null) {
                //System.out.println(inputLine);
            }
            in.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

import java.net.*;
import java.io.*;

public class TestWithoutTrailingSlash {
    public static void main(String args[]) {
        try {
            URL urlWithoutSlash = new URL("http://vimerzhao.top/tags");
            int cnt = 5;
            long cur = System.currentTimeMillis();
            for (int i = 0; i < cnt; i++) {
                read(urlWithoutSlash);
            }
            System.out.println(System.currentTimeMillis() - cur);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static void read(URL url) {
        try {
            BufferedReader in = new BufferedReader(
                    new InputStreamReader(url.openStream()));

            String inputLine;
            while ((inputLine = in.readLine()) != null) {
                //System.out.println(inputLine);
            }
            in.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

使用如下脚本测试:

#!/bin/sh
for i in {1..20}; do
    java TestTrailingSlash > out1
    java TestWithoutTrailingSlash > out2
done

将输出的时间做成表格:

可以发现,添加了/的速度更快,这是因为省去了查找是否有tags文件的操作。这也给我们启发:URL结尾的/最好还是加上!

以上,本周末发现的一些坑。

参考

时间: 2024-10-10 09:14:45

Java URL类踩坑指南的相关文章

Microsoft SQL Server on Linux 踩坑指南

微软用 SQL Server 在 2016 年的时候搞了一个大新闻,宣传 Microsoft ?? Linux 打得一众软粉措手不及.但是这还是好事情,Linux 上也有好用的 SQL Server 啦,但是从预览版开始 SQL Server on Linux 的配置要求蜜汁高,大部分云主机用户都望而却步.另外,SQL Server on Linux 对于文件系统有限制,仅支持 Ext3 以及 XFS 文件格式系统,对于某些云服务商默认提供的云镜像限制 Ext3 文件格式系统的用户而言也是足够让

appium连接夜神浏览器,踩坑指南。

之前安装了最新版本的appium,怎么都配置不好,连接不成功,后来看了一个博主的帖子换成了“AppiumForWindows_1.4.16.1.zip”,配置环境变量后才成功,分享给踩坑的你,以及为下一次踩坑备份. 原帖请戳:1.https://www.cnblogs.com/fighter007/p/9224773.html    2.https://www.cnblogs.com/fighter007/p/9226730.html 下面截取我自己需要的一部分: 一.安装 appium 工具

tensorflow 1.8, ubuntu 16.04, cuda 9.0, nvidia-390,安装踩坑指南。

被tensorflow 1.8, ubuntu 16.04, cuda 9.0, nvidia-390折磨了5天,终于上坑,留下指南,造福后人. 1.先把依赖搞清楚: tensorflow 1.8依赖cuda 9.0,cuda 9.0依赖nvidia-390. 2.坑: ubuntu 16.04源里只有nvidia-384,nvidia-390太新还没支持. 怎么办? 如果你不在天朝以下可以在个人源中安装nvidia-390 sudo add-apt-repository ppa:graphic

EDM模板编写踩坑指南(非响应式,纯table有源码)

如果问你table布局,你肯定会嗤之以鼻?什么table布局?不是早已经淘汰了吗?但是如果让你写EDM邮件模板,table布局相对来说是最好的选择. 如果让你立刻写EDM,你在网上搜的话,得到的信息相对较少,但是又很懵的话,建议你看看这篇文章,让你30分钟之内入门并开始写, 需要源码再私我吧~ 或许有人会问EDM是什么,Email Direct Marketing的缩写,即电子邮件营销.EDM模板就是你邮箱中的广告邮件,其实就是在邮件中写网页.但是EDM模板怎么书写.如果你在网上搜,或许你会搜到

『OGG 01』Win7 配置 Oracle GoldenGate 踩坑指南

安装 Oracle 安装 Oracle11g 32位[Oracle 32位的话,OGG 也必须是 32位,否则会有0xc000007b无法正常启动 错误] 安装目录为 D:\oracle\product\11.1.0\db1 [这个目录要设置为 环境变量 ORACLE_HOME] 设置环境变量 JAVAHOME C:\Program Files\Java\jdk1.8.0121 ORACLEHOME D:\oracle\product\11.1.0\db1 ORACLE_SID ORCL 如何查

spring cloud踩坑指南

版本信息: spring cloud 版本Greenwich.SR2 spring boot 版本2.1.8.RELEASE gateway报错 DefaultDataBuffer cannot be cast to org.springframework.core.io.buffer.NettyDataBuffer 解决方式: springcloud的gateway使用的是webflux,默认使用netty,所以从依赖中排除 tomcat相关的依赖 ,就可以了. 我的问题: 排除了依赖还是报错

树莓派4B踩坑指南 - (14)用cups建立家庭局域网共享中心

树莓派在家中至少三个作用:家庭资源共享中心.无线打印服务器.下载服务器. 无线打印服务器用苹果开发的cups实现打印机无线共享. 安装准备 确认自己的打印机型号,稍后会用到.然后照例先更新源. sudo apt-get update 安装 sudo apt-get install cups # 安装cups sudo usermod -a -G lpadmin pi # 将用户pi设为管理员 sudo cupsctl --remote-any # 开启远程访问权限 # cups安装完毕 配置 打

nuxtjs踩坑指南

1.nuxt引入问题:Can't resolve 'stylus-loader' 原因在于没有安装stylus,安装即可:npm install stylus stylus-loader --save-dev 2.nuxt生命周期: 众所周知,Vue的生命周期全都跑在客户端(浏览器),而Nuxt的生命周期有些在服务端(Node),客户端,甚至两边都在 生命周期流程图,红框内的是Nuxt的生命周期(运行在服务端),黄框内同时运行在服务端&&客户端上,绿框内则运行在客户端 (1)红框.黄框内的

pandas之dataframe踩坑指南(一)---apply(func)

import pandas as pd data = pd.read_csv(r"test数据.csv", engine="python", encoding="utf-8") def pprint(row): row["extra"]=1 print(row) return row data = data.apply(lambda x: pprint(x), axis=1) print(data) apply在第一列/行上调