三年多以前写过一个 HTTP 请求类,然后又将其改进为“链式风格”的调用方式。虽然目标上可以实现需求,大致也没用重复的逻辑,但是编码上总是觉得怪怪的,当时又说不上哪里不对劲,总之尽管逻辑没错能实现,然而就是感觉谈不上“优雅”。限于当时水平就那样,想不出办法也就没去专研了。
应该说,现在的 Java 8 的函数式风格给予了我完全不一样的灵感。使用 lambda(匿名函数),与使用普通 Java 函数(方法),首先它更轻量级的,更灵活,于是能够易于表达“我想做什么”,而至于“我怎么做”那部分,能省则省,不要我重复写,也不要然让我去啰嗦调用(当然前提你要封装好,使用 FP 这“武器”来封装),好比简单的 for 语句,当使用函数式风格之后,封装了 for 逻辑,允许 for 中间部分的逻辑形成于 lambda,这一部分的 lambda 即是属于“我想做什么”,而代表“我怎么做”的那个 for 部分,却被封装起来,外界不会容易看到,而且 lambda 本身语句精简,不会造成 Java 语句冗长啰嗦。
理论上,即使在 Java 8 之前,上述目的都可以通过写就一个个 interface,然后传入一个个回调函数来完成,好比 Swing/Android 的事件处理,乃典型 interface 应用。但那实在太啰嗦,敲代码的成本太高,没人会如此干的。Java 需要一个更简练的语法去做 interface 的事情,于是 FP 的 lambda 被提出并加入到 Java 8 了,同时那也是大趋势使然。实事求是地说,与其说 lambda 是代替品,不如说是新思想的落地实践(当然 FP 思想 N 久之前在学术上已经被提出来了)。而且 Java 8 的函数接口,是类型系统与 FP 一次不错的“联婚”,能较好地对 lambda 进行类型约束,加之泛型的使用,虽有约束但也不失灵活——“一柔一刚”——这是在弱类型的 FP 语言(如 JavaScript)所不能体验的。
总之,FP 带来的好处多多,令 Java 语言更精炼而不是“啰嗦”,而且,我个人收获的价值,某个程度来说也能消灭代码重复。
上面说了那么多,现在才进入“实战环节”。发起 HTTP 请求,是 HttpURLConnection 干的事情,至于底层 Socket 怎么干,我们就不管啦。
/**
* HttpURLConnection 工厂函数
*
* @param url 请求目的地址
* @return HttpURLConnection 对象
*/
public static HttpURLConnection initHttpConnection(String url) {
URL httpUrl = null;
try {
httpUrl = new URL(url);
} catch (MalformedURLException e) {
LOGGER.warning(e, "初始化连接出错!URL {0} 格式不对!", url);
}
try {
return (HttpURLConnection) httpUrl.openConnection();
} catch (IOException e) {
LOGGER.warning(e, "初始化连接出错!URL {0}。", url);
}
return null;
}
拿到 HttpURLConnection,我们可以对其施加配置,例如下面一堆 lambda,
/**
* 设置请求方法
*/
public final static BiConsumer setMedthod = (conn, method) -> {
try {
conn.setRequestMethod(method);
} catch (ProtocolException e) {
LOGGER.warning(e);
}
};
/**
* 设置 cookies
*/
public final static BiConsumer> setCookies = (conn, map) -> conn.addRequestProperty("Cookie", MapTool.join(map, ";"));
/**
* 请求来源
*/
public final static BiConsumer setReferer = (conn, url) -> conn.addRequestProperty("Referer", url); // httpUrl.getHost()?
/**
* 设置超时 (单位:秒)
*/
public final static BiConsumer setTimeout = (conn, timeout) -> conn.setConnectTimeout(timeout * 1000);
/**
* 客户端识别
*/
public final static BiConsumer setUserAgent = (conn, url) -> conn.addRequestProperty("User-Agent", url);
/**
* 默认的客户端识别
*/
public final static Consumer setUserAgentDefault = conn -> setUserAgent.accept(conn, "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.4; en-US; rv:1.9.2.2) Gecko/20100316 Firefox/3.6.2");
/**
* HTTP Basic 用户认证
*/
public final static BiConsumer setBasicAuth = (conn, auth) -> {
String username = auth[0], password = auth[1];
String encoding = Encode.base64Encode(username + ":" + password);
conn.setRequestProperty("Authorization", "Basic " + encoding);
};
/**
* 设置启动 GZip 请求
*/
public final static Consumer setGizpRequest = conn -> conn.addRequestProperty("Accept-Encoding", "gzip, deflate");
这些正是“函数接口”的实现,可把一个个函数视作为一个个变量,作为参数参与到方法中,或者立刻执行。当然写作普通 Java 方法也行,可以通过 ClassFoo::Method
视作变量传递,只是代码行数会多一点,——样样都多一点,加起来就很多的啦。
配置好连接对象之后,就可以发送请求了。发送的时机是执行 conn.getInputStream();
的时候。
/**
* 发送请求,返回响应信息
*
* @param conn 链接对象
* @param isEnableGzip 是否需要 GZip 解码
* @param callback 回调里面请记得关闭 InputStream
* @return
*/
public static T getResponse(HttpURLConnection conn, Boolean isEnableGzip, Function callback) {
try {
InputStream in = conn.getInputStream();// 发起请求,接收响应
// 是否启动 GZip 请求
// 有些网站强制加入 Content-Encoding:gzip,而不管之前的是否有 GZip 的请求
boolean isGzip = isEnableGzip || "gzip".equals(conn.getHeaderField("Content-Encoding"));
if (isGzip)
in = new GZIPInputStream(in);
int responseCode = conn.getResponseCode();
if (responseCode >= 400) {// 如果返回的结果是400以上,那么就说明出问题了
RuntimeException e = new RuntimeException(responseCode < 500 ? responseCode + ":客户端请求参数错误!" : responseCode + ":抱歉!我们服务端出错了!");
LOGGER.warning(e);
}
if (callback == null) {
in.close();
} else
return callback.apply(in);
} catch (IOException e) {
LOGGER.warning(e);
}
return null;
}
基本上要对响应的 HTTP code 检查一下,告知基本的响应情况,是 4xx 客户端错误还是 5XX 服务端的责任。有时候无须获取内容的,只要获取响应头(Response Head)即可,例如 HEAD 请求。
得到响应后至于要干什么,具体是 Function
干的事情,表示这个函数输入的参数是 InputStream 类型,返回的是 T 类型,也就是说,这个 lambda 返回什么,getResponse 就返回什么。我们必不限定必须返回 String,甚至一个特定的 JSON/XML 类型也可以,——显然,这是灵活性的一个体现。
下面 方法整合了上述 initHttpConnection()
和 getResponse()
,
/**
* GET 请求,返回文本内容
*
* @param url
* @return
*/
public static String get(String url, boolean isGzip) {
HttpURLConnection conn = initHttpConnection(url);
if (isGzip)
setGizpRequest.accept(conn);
return getResponse(conn, isGzip, NetUtil::byteStream2stringStream);
}
NetUtil::byteStream2stringStream
是一个方法引用,此刻最能体现“函数作为变量传来传去”之意味——它只是引用却没用马上执行,与 NetUtil.byteStream2stringStream(xx)
明显不同的。有括号的表示立刻执行。虽然没用显示参数,但实际上是有“函数接口”作类型约束的,不是什么函数都可以传入给 getResponse()
。
byteStream2stringStream 原型是 public static String byteStream2stringStream(InputStream in)
,读输入的字节流转换到字符流,将其转换为文本(多行)的字节流转换为字符串。注意 HTTP 请求的原始数据多为流(Stream)。
get() 方法是返回文本 String,如果想将响应的内容保存文件,那就不是 byteStream2stringStream,且看下载文件方法:
public static String download(String url, String saveDir, String newFileName) {
HttpURLConnection conn = initHttpConnection(url);
setUserAgentDefault.accept(conn);
conn.setDoInput(true);
conn.setDoOutput(true);
String fileName = newFileName == null ? IoHelper.getFileNameFromUrl(url) : newFileName;
String newlyFilePath = getResponse(conn, false, in -> {
File file = IoHelper.createFile(saveDir, fileName);
try (OutputStream out = new FileOutputStream(file);) {
IoHelper.write(in, out, true);
return file.toString();
} catch (IOException e) {
LOGGER.warning(e);
} finally {
try {
in.close();
} catch (IOException e) {
LOGGER.warning(e);
}
}
return null;
});
return newlyFilePath;
}
可见输入流导入到 输出流 FileOutputStream 中,不再是转换为文本,而是把得到的字节流保存成为文件。
当前而言,上述 download 方法写死了一个最简单的方案,如果有新的需求,例如要 HTTP Basic Auth 认证的,就要在发起请求之前对 conn 进行额外的配置,对此我们不妨加入一个符合 Consumer
接口的函数对象,其实现就是进行 Basic 认证。甚至地,不止一个 Consumer
,可以多个对 conn 进行配置,那么就改为可变长的参数 Consumer
,遍历一下执行 fn 即可。这思路的代码没有在库里面实现,读者可以自己尝试写一下。
基本上文给了一个完整思路,而围绕一个完整的 HTTP 库应该还有其它的诸如 POST 的请求,时间关系我就不逐一展开分析了,基本都是对 HTTP Connection 进行配置,当然得对 HTTP 协议有一定了解才行。本文主要讲的是编码风格的一种提倡,即 Java 8 的 lambda,——它好处不少,也与之前编码风格不太一样,如果你在学习 Java FP,那么欢迎你结合本文来探讨。笔者说的不一定对,有错的地方请多多包涵并祈望告之,谢谢喔。
库源码:
https://gitee.com/sp42_admin/ajaxjs/blob/master/ajaxjs-base/src/main/java/com/ajaxjs/net/http/NetUtil.java