hutool/hutool-http/src/main/java/cn/hutool/http/HttpResponse.java

707 lines
20 KiB
Java
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package cn.hutool.http;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.io.FastByteArrayOutputStream;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IORuntimeException;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.io.StreamProgress;
import cn.hutool.core.io.resource.BytesResource;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.ReUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import cn.hutool.http.cookie.GlobalCookieManager;
import java.io.Closeable;
import java.io.EOFException;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpCookie;
import java.nio.charset.Charset;
import java.util.List;
import java.util.Map.Entry;
/**
* Http响应类<br>
* 非线程安全对象
*
* @author Looly
*/
public class HttpResponse extends HttpBase<HttpResponse> implements Closeable {
/**
* Http配置
*/
protected HttpConfig config;
/**
* 持有连接对象
*/
protected HttpConnection httpConnection;
/**
* Http请求原始流
*/
protected InputStream in;
/**
* 是否异步异步下只持有流否则将在初始化时直接读取body内容
*/
private volatile boolean isAsync;
/**
* 响应状态码
*/
protected int status;
/**
* 是否忽略读取Http响应体
*/
private final boolean ignoreBody;
/**
* 从响应中获取的编码
*/
private Charset charsetFromResponse;
/**
* 构造
*
* @param httpConnection {@link HttpConnection}
* @param config Http配置
* @param charset 编码,从请求编码中获取默认编码
* @param isAsync 是否异步
* @param isIgnoreBody 是否忽略读取响应体
* @since 3.1.2
*/
protected HttpResponse(HttpConnection httpConnection, HttpConfig config, Charset charset, boolean isAsync, boolean isIgnoreBody) {
this.httpConnection = httpConnection;
this.config = config;
this.charset = charset;
this.isAsync = isAsync;
this.ignoreBody = isIgnoreBody;
initWithDisconnect();
}
/**
* 获取状态码
*
* @return 状态码
*/
public int getStatus() {
return this.status;
}
/**
* 请求是否成功判断依据为状态码范围在200~299内。
*
* @return 是否成功请求
* @since 4.1.9
*/
public boolean isOk() {
return this.status >= 200 && this.status < 300;
}
/**
* 同步<br>
* 如果为异步状态则暂时不读取服务器中响应的内容而是持有Http链接的{@link InputStream}。<br>
* 当调用此方法时异步状态转为同步状态此时从Http链接流中读取body内容并暂存在内容中。如果已经是同步状态则不进行任何操作。
*
* @return this
*/
public HttpResponse sync() {
return this.isAsync ? forceSync() : this;
}
// ---------------------------------------------------------------- Http Response Header start
/**
* 获取内容编码
*
* @return String
*/
public String contentEncoding() {
return header(Header.CONTENT_ENCODING);
}
/**
* 获取内容长度,以下情况长度无效:
* <ul>
* <li>Transfer-Encoding: Chunked</li>
* <li>Content-Encoding: XXX</li>
* </ul>
* 参考https://blog.csdn.net/jiang7701037/article/details/86304302
*
* @return 长度,-1表示服务端未返回或长度无效
* @since 5.7.9
*/
public long contentLength() {
long contentLength = Convert.toLong(header(Header.CONTENT_LENGTH), -1L);
if (contentLength > 0 && (isChunked() || StrUtil.isNotBlank(contentEncoding()))) {
//按照HTTP协议规范在 Transfer-Encoding和Content-Encoding设置后 Content-Length 无效。
contentLength = -1;
}
return contentLength;
}
/**
* 是否为gzip压缩过的内容
*
* @return 是否为gzip压缩过的内容
*/
public boolean isGzip() {
final String contentEncoding = contentEncoding();
return "gzip".equalsIgnoreCase(contentEncoding);
}
/**
* 是否为zlib(Deflate)压缩过的内容
*
* @return 是否为zlib(Deflate)压缩过的内容
* @since 4.5.7
*/
public boolean isDeflate() {
final String contentEncoding = contentEncoding();
return "deflate".equalsIgnoreCase(contentEncoding);
}
/**
* 是否为Transfer-Encoding:Chunked的内容
*
* @return 是否为Transfer-Encoding:Chunked的内容
* @since 4.6.2
*/
public boolean isChunked() {
final String transferEncoding = header(Header.TRANSFER_ENCODING);
return "Chunked".equalsIgnoreCase(transferEncoding);
}
/**
* 获取本次请求服务器返回的Cookie信息
*
* @return Cookie字符串
* @since 3.1.1
*/
public String getCookieStr() {
return header(Header.SET_COOKIE);
}
/**
* 获取Cookie
*
* @return Cookie列表
* @since 3.1.1
*/
public List<HttpCookie> getCookies() {
return GlobalCookieManager.getCookies(this.httpConnection);
}
/**
* 获取Cookie
*
* @param name Cookie名
* @return {@link HttpCookie}
* @since 4.1.4
*/
public HttpCookie getCookie(String name) {
List<HttpCookie> cookie = getCookies();
if (null != cookie) {
for (HttpCookie httpCookie : cookie) {
if (httpCookie.getName().equals(name)) {
return httpCookie;
}
}
}
return null;
}
/**
* 获取Cookie值
*
* @param name Cookie名
* @return Cookie值
* @since 4.1.4
*/
public String getCookieValue(String name) {
final HttpCookie cookie = getCookie(name);
return (null == cookie) ? null : cookie.getValue();
}
// ---------------------------------------------------------------- Http Response Header end
// ---------------------------------------------------------------- Body start
/**
* 获得服务区响应流<br>
* 异步模式下获取Http原生流同步模式下获取获取到的在内存中的副本<br>
* 如果想在同步模式下获取流,请先调用{@link #sync()}方法强制同步<br>
* 流获取后处理完毕需关闭此类
*
* @return 响应流
*/
public InputStream bodyStream() {
if (isAsync) {
return this.in;
}
return null == this.body ? null : this.body.getStream();
}
/**
* 获取响应流字节码<br>
* 此方法会转为同步模式
*
* @return byte[]
*/
@Override
public byte[] bodyBytes() {
sync();
return super.bodyBytes();
}
/**
* 设置主体字节码<br>
* 需在此方法调用前使用charset方法设置编码否则使用默认编码UTF-8
*
* @param bodyBytes 主体
* @return this
*/
public HttpResponse body(byte[] bodyBytes) {
sync();
if (null != bodyBytes) {
this.body = new BytesResource(bodyBytes);
}
return this;
}
/**
* 获取响应主体
*
* @return String
* @throws HttpException 包装IO异常
*/
public String body() throws HttpException {
return HttpUtil.getString(bodyBytes(), this.charset, null == this.charsetFromResponse);
}
/**
* 将响应内容写出到{@link OutputStream}<br>
* 异步模式下直接读取Http流写出同步模式下将存储在内存中的响应内容写出<br>
* 写出后会关闭Http流异步模式
*
* @param out 写出的流
* @param isCloseOut 是否关闭输出流
* @param streamProgress 进度显示接口,通过实现此接口显示下载进度
* @return 写出bytes数
* @since 3.3.2
*/
public long writeBody(OutputStream out, boolean isCloseOut, StreamProgress streamProgress) {
Assert.notNull(out, "[out] must be not null!");
final long contentLength = contentLength();
try {
return copyBody(bodyStream(), out, contentLength, streamProgress, this.config.ignoreEOFError);
} finally {
IoUtil.close(this);
if (isCloseOut) {
IoUtil.close(out);
}
}
}
/**
* 将响应内容写出到文件<br>
* 异步模式下直接读取Http流写出同步模式下将存储在内存中的响应内容写出<br>
* 写出后会关闭Http流异步模式
*
* @param targetFileOrDir 写出到的文件或目录
* @param streamProgress 进度显示接口,通过实现此接口显示下载进度
* @return 写出bytes数
* @since 3.3.2
*/
public long writeBody(File targetFileOrDir, StreamProgress streamProgress) {
Assert.notNull(targetFileOrDir, "[targetFileOrDir] must be not null!");
final File outFile = completeFileNameFromHeader(targetFileOrDir);
return writeBody(FileUtil.getOutputStream(outFile), true, streamProgress);
}
/**
* 将响应内容写出到文件-避免未完成的文件<br>
* 异步模式下直接读取Http流写出同步模式下将存储在内存中的响应内容写出<br>
* 写出后会关闭Http流异步模式<br>
* 来自https://gitee.com/chinabugotech/hutool/pulls/407<br>
* 此方法原理是先在目标文件同级目录下创建临时文件,下载之,等下载完毕后重命名,避免因下载错误导致的文件不完整。
*
* @param targetFileOrDir 写出到的文件或目录
* @param tempFileSuffix 临时文件后缀,默认".temp"
* @param streamProgress 进度显示接口,通过实现此接口显示下载进度
* @return 写出bytes数
* @since 5.7.12
*/
public long writeBody(File targetFileOrDir, String tempFileSuffix, StreamProgress streamProgress) {
Assert.notNull(targetFileOrDir, "[targetFileOrDir] must be not null!");
File outFile = completeFileNameFromHeader(targetFileOrDir);
if (StrUtil.isBlank(tempFileSuffix)) {
tempFileSuffix = ".temp";
} else {
tempFileSuffix = StrUtil.addPrefixIfNot(tempFileSuffix, StrUtil.DOT);
}
// 目标文件真实名称
final String fileName = outFile.getName();
// 临时文件名称
final String tempFileName = fileName + tempFileSuffix;
// 临时文件
outFile = new File(outFile.getParentFile(), tempFileName);
long length;
try {
length = writeBody(outFile, streamProgress);
// 重命名下载好的临时文件
FileUtil.rename(outFile, fileName, true);
} catch (Throwable e) {
// 异常则删除临时文件
FileUtil.del(outFile);
throw new HttpException(e);
}
return length;
}
/**
* 将响应内容写出到文件<br>
* 异步模式下直接读取Http流写出同步模式下将存储在内存中的响应内容写出<br>
* 写出后会关闭Http流异步模式
*
* @param targetFileOrDir 写出到的文件
* @param streamProgress 进度显示接口,通过实现此接口显示下载进度
* @return 写出的文件
* @since 5.6.4
*/
public File writeBodyForFile(File targetFileOrDir, StreamProgress streamProgress) {
Assert.notNull(targetFileOrDir, "[targetFileOrDir] must be not null!");
final File outFile = completeFileNameFromHeader(targetFileOrDir);
writeBody(FileUtil.getOutputStream(outFile), true, streamProgress);
return outFile;
}
/**
* 将响应内容写出到文件<br>
* 异步模式下直接读取Http流写出同步模式下将存储在内存中的响应内容写出<br>
* 写出后会关闭Http流异步模式
*
* @param targetFileOrDir 写出到的文件或目录
* @return 写出bytes数
* @since 3.3.2
*/
public long writeBody(File targetFileOrDir) {
return writeBody(targetFileOrDir, null);
}
/**
* 将响应内容写出到文件<br>
* 异步模式下直接读取Http流写出同步模式下将存储在内存中的响应内容写出<br>
* 写出后会关闭Http流异步模式
*
* @param targetFileOrDir 写出到的文件或目录的路径
* @return 写出bytes数
* @since 3.3.2
*/
public long writeBody(String targetFileOrDir) {
return writeBody(FileUtil.file(targetFileOrDir));
}
// ---------------------------------------------------------------- Body end
@Override
public void close() {
IoUtil.close(this.in);
this.in = null;
// 关闭连接
this.httpConnection.disconnectQuietly();
}
@Override
public String toString() {
StringBuilder sb = StrUtil.builder();
sb.append("Response Headers: ").append(StrUtil.CRLF);
for (Entry<String, List<String>> entry : this.headers.entrySet()) {
sb.append(" ").append(entry).append(StrUtil.CRLF);
}
sb.append("Response Body: ").append(StrUtil.CRLF);
sb.append(" ").append(this.body()).append(StrUtil.CRLF);
return sb.toString();
}
/**
* 从响应头补全下载文件名
*
* @param targetFileOrDir 目标文件夹或者目标文件
* @return File 保存的文件
* @since 5.4.1
*/
public File completeFileNameFromHeader(File targetFileOrDir) {
if (false == targetFileOrDir.isDirectory()) {
// 非目录直接返回
return targetFileOrDir;
}
// 从头信息中获取文件名
String fileName = getFileNameFromDisposition(null);
if (StrUtil.isBlank(fileName)) {
final String path = httpConnection.getUrl().getPath();
// 从路径中获取文件名
fileName = StrUtil.subSuf(path, path.lastIndexOf('/') + 1);
if (StrUtil.isBlank(fileName)) {
// 编码后的路径做为文件名
fileName = URLUtil.encodeQuery(path, charset);
} else {
// issue#I4K0FS@Gitee
fileName = URLUtil.decode(fileName, charset);
}
}
return FileUtil.file(targetFileOrDir, fileName);
}
/**
* 从Content-Disposition头中获取文件名
*
* @return 文件名empty表示无
*/
public String getFileNameFromDisposition() {
return getFileNameFromDisposition(null);
}
/**
* 从Content-Disposition头中获取文件名以参数名为`filename`为例,规则为:
* <ul>
* <li>首先按照RFC5987规范检查`filename*`参数对应的值,即:`filename*="example.txt"`,则获取`example.txt`</li>
* <li>如果找不到`filename*`参数,则检查`filename`参数对应的值,即:`filename="example.txt"`,则获取`example.txt`</li>
* </ul>
* 按照规范,`Content-Disposition`可能返回多个,此处遍历所有返回头,并且`filename*`始终优先获取,即使`filename`存在并更靠前。<br>
* 参考https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Disposition
*
* @param paramName 文件参数名,如果为{@code null}则使用默认的`filename`
* @return 文件名empty表示无
*/
public String getFileNameFromDisposition(String paramName) {
paramName = ObjUtil.defaultIfNull(paramName, "filename");
final List<String> dispositions = headerList(Header.CONTENT_DISPOSITION.getValue());
String fileName = null;
if (CollUtil.isNotEmpty(dispositions)) {
// filename* 采用了 RFC 5987 中规定的编码方式,优先读取
fileName = getFileNameFromDispositions(dispositions, StrUtil.addSuffixIfNot(paramName, "*"));
if ((!StrUtil.endWith(fileName, "*")) && StrUtil.isBlank(fileName)) {
fileName = getFileNameFromDispositions(dispositions, paramName);
}
}
return fileName;
}
// ---------------------------------------------------------------- Private method start
/**
* 从Content-Disposition头中获取文件名
*
* @param dispositions Content-Disposition头列表
* @param paramName 文件参数名
* @return 文件名empty表示无
*/
private static String getFileNameFromDispositions(final List<String> dispositions, String paramName) {
// 正则转义
paramName = StrUtil.replace(paramName, "*", "\\*");
String fileName = null;
for (final String disposition : dispositions) {
fileName = ReUtil.getGroup1(paramName + "=([^;]+)", disposition);
if (StrUtil.isNotBlank(fileName)) {
break;
}
}
return getRfc5987Value(fileName);
}
/**
* 获取rfc5987标准的值标准见https://www.rfc-editor.org/rfc/rfc5987#section-3.2.1<br>
* 包括:
*
*<ul>
* <li>Non-extended无双引号包裹的值</li>
* <li>Non-extended双引号包裹的值</li>
* <li>Extended notation编码'语言'值</li>
*</ul>
*
* @param value 值
* @return 结果值
*/
private static String getRfc5987Value(final String value){
final List<String> split = StrUtil.split(value, '\'');
if(3 == split.size()){
return split.get(2);
}
// 普通值
return StrUtil.unWrap(value, '"');
}
/**
* 初始化Http响应并在报错时关闭连接。<br>
* 初始化包括:
*
* <pre>
* 1、读取Http状态
* 2、读取头信息
* 3、持有Http流并不关闭流
* </pre>
*
* @return this
* @throws HttpException IO异常
*/
private HttpResponse initWithDisconnect() throws HttpException {
try {
init();
} catch (HttpException e) {
this.httpConnection.disconnectQuietly();
throw e;
}
return this;
}
/**
* 初始化Http响应<br>
* 初始化包括:
*
* <pre>
* 1、读取Http状态
* 2、读取头信息
* 3、持有Http流并不关闭流
* </pre>
*
* @return this
* @throws HttpException IO异常
*/
private HttpResponse init() throws HttpException {
// 获取响应状态码
try {
this.status = httpConnection.responseCode();
} catch (IOException e) {
if (false == (e instanceof FileNotFoundException)) {
throw new HttpException(e);
}
// 服务器无返回内容,忽略之
}
// 读取响应头信息
try {
this.headers = httpConnection.headers();
} catch (IllegalArgumentException e) {
// ignore
// StaticLog.warn(e, e.getMessage());
}
// 存储服务端设置的Cookie信息
GlobalCookieManager.store(httpConnection);
// 获取响应编码
final Charset charset = httpConnection.getCharset();
this.charsetFromResponse = charset;
if (null != charset) {
this.charset = charset;
}
// 获取响应内容流
this.in = new HttpInputStream(this);
// 同步情况下强制同步
return this.isAsync ? this : forceSync();
}
/**
* 强制同步,用于初始化<br>
* 强制同步后变化如下:
*
* <pre>
* 1、读取body内容到内存
* 2、异步状态设为false变为同步状态
* 3、关闭Http流
* 4、断开与服务器连接
* </pre>
*
* @return this
*/
private HttpResponse forceSync() {
// 非同步状态转为同步状态
try {
this.readBody(this.in);
} catch (IORuntimeException e) {
//noinspection StatementWithEmptyBody
if (e.getCause() instanceof FileNotFoundException) {
// 服务器无返回内容,忽略之
} else {
throw new HttpException(e);
}
} finally {
if (this.isAsync) {
this.isAsync = false;
}
this.close();
}
return this;
}
/**
* 读取主体忽略EOFException异常
*
* @param in 输入流
* @throws IORuntimeException IO异常
*/
private void readBody(InputStream in) throws IORuntimeException {
if (ignoreBody) {
return;
}
// issue#ICB1B8如果用户定义忽略contentLength头则不读取
final long contentLength = config.ignoreContentLength ? -1 : contentLength();
final FastByteArrayOutputStream out = new FastByteArrayOutputStream((int) contentLength);
copyBody(in, out, contentLength, null, this.config.ignoreEOFError);
this.body = new BytesResource(out.toByteArray());
}
/**
* 将响应内容写出到{@link OutputStream}<br>
* 异步模式下直接读取Http流写出同步模式下将存储在内存中的响应内容写出<br>
* 写出后会关闭Http流异步模式
*
* @param in 输入流
* @param out 写出的流
* @param contentLength 总长度,-1表示未知
* @param streamProgress 进度显示接口,通过实现此接口显示下载进度
* @param isIgnoreEOFError 是否忽略响应读取时可能的EOF异常
* @return 拷贝长度
*/
private static long copyBody(InputStream in, OutputStream out, long contentLength, StreamProgress streamProgress, boolean isIgnoreEOFError) {
if (null == out) {
throw new NullPointerException("[out] is null!");
}
long copyLength = -1;
try {
copyLength = IoUtil.copy(in, out, IoUtil.DEFAULT_BUFFER_SIZE, contentLength, streamProgress);
} catch (IORuntimeException e) {
//noinspection StatementWithEmptyBody
if (isIgnoreEOFError
&& (e.getCause() instanceof EOFException || StrUtil.containsIgnoreCase(e.getMessage(), "Premature EOF"))) {
// 忽略读取HTTP流中的EOF错误
} else {
throw e;
}
}
return copyLength;
}
// ---------------------------------------------------------------- Private method end
}