⽂件上传下载原理:http协议分析及实现
  我们现在⽤得⾮常多互联⽹下载⽂件,⾮常直观。有⼀个下载按钮,然后我点击了下载,然后⽂件慢慢就下载到本地了。就好像是⼀个复制的过程。
  ⽽既然是互联⽹,那么必然会是使⽤⽹络进⾏传输的。那么到底是怎样传输的呢?
  当然,下载⽂件有两种⽅式:⼀是直接针对某个⽂件资源进⾏下载,⽆需应⽤开发代码;⼆是应⽤代码临时⽣成需要的内容⽂件,然后输出给到下载端。
  其中,直接下载资源⽂件的场景给我们感觉是下载就是针对这个⽂件本⾝的⼀个操作,和复制⼀样没有什么疑义。⽽由应⽤代码进⾏下载⽂件时,⼜当如何处理呢?
1. 上传下载⽂件demo
  在⽹上你可以⾮常容易地到相应的模板代码,然后处理掉。基本的样⼦就是设置⼏个头信息,然后将数据写⼊到response中。
demo1. 服务端接收⽂件上传,并同时输出⽂件到客户端
@PostMapping("fileUpDownTest")
@ResponseBody
public Object fileUpDownTest(@ModelAttribute EncSingleDocFileReqModel reqModel,
MultipartFile file,
HttpServletResponse response) {
// 做两件事:1. 接收上传的⽂件; 2. 将⽂件下载给到上传端;
// 即向双向⽂件的传输,下载的⽂件可以是你处理之后的任意⽂件。
String tmpPath = saveMultipartToLocalFile(file);
outputEncFileStream(tmpPath, response);
System.out.println("path:" + tmpPath);
return null;
}
/
**
* 保存⽂件到本地路径
*
* @param file ⽂件流
* @return本地存储路径
*/
private String saveMultipartToLocalFile(MultipartFile file) {
try (InputStream inputStream = InputStream()){
// 往临时⽬录写⽂件
String fileSuffix = OriginalFilename().OriginalFilename().lastIndexOf('.'));
File tmpFile = Name(), ".tmp" + fileSuffix);
CanonicalPath();
}
catch (Exception e){
<("【加密⽂件】⽂件流处理失败:" + Name(), e);
throw new EncryptSysException("0011110", "⽂件接收失败");
}
}
/**
* 输出⽂件流数据
*
* @param encFileLocalPath ⽂件所在路径
* @param response servlet io 流
*/
private void outputEncFileStream(String encFileLocalPath, HttpServletResponse response) {
File outFile = new File(encFileLocalPath);
OutputStream os = null;
InputStream inputStream = null;
try {
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
//            response.setHeader("Content-Length", ContentLength()+"");
String outputFileName = encFileLocalPath.substring(encFileLocalPath.lastIndexOf('/') + 1);
response.setHeader("Content-Disposition", String.format("attachment; filename=%s", de(outputFileName, "UTF-8")));
response.setContentType("application/octet-stream; charset=utf-8");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
inputStream = new FileInputStream(outFile);
//写⼊信息
os = adInputStream(inputStream, OutputStream());
}
catch (Exception re) {
<("输出⽂件流失败,", re);
throw new RuntimeException("0011113: 输出加密后的⽂件失败");
}
finally {
if (os != null) {
try {
os.flush();
os.close();
}
catch (IOException e) {
<("输出流⽂件失败", e);
}
}
if(inputStream != null) {
try {
inputStream.close();
}
catch (IOException e) {
<("加密⽂件输⼊流关闭失败", e);
}
}
}
}
  我们在做开发时,⾯对的仅仅是 Request, Response 这种什么都有对象,直接问其要相关信息即可。给我们提供⽅便的同时,也失去了了解真相的机会。
demo2.  服务端转发⽂件到另⼀个服务端,并同接收处理响应回来的⽂件流数据
/**
* 使⽤本地⽂件,向加密服务器请求加密⽂件,并输出到⽤户端
*
* @param localFilePath 想要下载的⽂件
* @return⽂件流
*/
@GetMapping("transLocalFileToEnc")
public Object transLocalFileToEnc(@ModelAttribute EncSingleDocFileReqModel reqModel,
@RequestParam String localFilePath,
HttpServletResponse response) {
File localFileIns = new File(localFilePath);
if(!ists()) {
return ResponseInfoBuilderUtil.fail("指定⽂件未到");
}
try(InputStream sourceFileInputStream = new FileInputStream(localFileIns);) {
//这个url是要上传到另⼀个服务器上接⼝,此处模拟向本机发起加密请求
String url = "localhost:8082/encrypt/testEnc";
int lastFileSeparatorIndex = localFilePath.lastIndexOf('/');
String filename = lastFileSeparatorIndex == -1
localFilePath.substring(localFilePath.lastIndexOf('\\'))
:
localFilePath.substring(lastFileSeparatorIndex);
Object object = null;
// 创建HttpClients实体类
CloseableHttpClient aDefault = ateDefault();
try  {
HttpPost httpPost = new HttpPost(url);
MultipartEntityBuilder builder = ate();
//使⽤这个,另⼀个服务就可以接收到这个file⽂件了
builder.addBinaryBody("file", sourceFileInputStream, ate("multipart/form-data"), de(filename, "utf-8"));
builder.addTextBody("systemCode", "self");
String encOutputFilename = filename;
builder.addTextBody("encOutputFileName", encOutputFilename);
HttpEntity entity  =  builder.build();
httpPost.setEntity(entity);
ResponseHandler<Object> rh = new ResponseHandler<Object>() {
@Override
public  Object handleResponse(HttpResponse re) throws IOException {
HttpEntity entity = re.getEntity();
ContentType().toString().contains("application/json")) {
// 通过判断响应类型来判断是否输出⽂件流,⾮严谨的做法
String retMsg = String(entity,  "UTF-8");
return JSONObject.parseObject(retMsg, ResponseInfo.class);
}
InputStream input = Content();
//                        String result = String(entity,  "UTF-8");
// 写⼊响应流信息
OutputStream os = null;
try {
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
// response.setHeader("Content-Length", ContentLength()+"");
response.setHeader("Content-Disposition", String.format("attachment; filename=%s", de(filename, "UTF-8")));
response.setContentType("application/octet-stream; charset=utf-8");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
// 往临时⽬录写⽂件
File tmpFile = ateTempFile(filename, "");
String encFilePathTmp = CanonicalPath();
File encFileIns = new File(encFilePathTmp);
ists()) {
FileInputStream zipStream = new FileInputStream(encFileIns);
os = adInputStream(zipStream, OutputStream());
}
}
finally {
if(os != null) {
os.flush();
os.close();
}
}
// 已向客户端输出⽂件流
return Boolean.TRUE;
}
};
object = ute(httpPost, rh);
return object == Boolean.TRUE
"加密成功,下载⽂件去!"
: object;
}
catch (Exception e) {
<("", e);
}
finally  {
try {
aDefault.close();
} catch (IOException e) {
<("关闭错误", e);
}
}
}
catch (FileNotFoundException e) {
<("要加密的⽂件不存在", e);
}
catch (IOException e) {
<("要加密的⽂件不存在", e);
}
return "处理失败";
}
// 抽出写socket流的逻辑,⽅便统⼀控制
/**
* 从输⼊流中获取字节数组
*
* @param inputStream 输⼊流
* @return输出流,超过5000⾏数据,刷写⼀次⽹络
* @throws IOException
*/
public static OutputStream readInputStream(InputStream inputStream, OutputStream os) throws IOException {
byte[] bytes = new byte[2048];
int i = 0;
int read = 0;
//按字节逐个写⼊,避免内存占⽤过⾼
while ((read = ad(bytes)) != -1) {
os.write(bytes, 0, read);
i++;
// 每5000⾏
if (i % 5000 == 0) {
os.flush();
}
}
inputtypefile不上传文件
inputStream.close();
return os;
}
  此处仅是使⽤后端代码展现了前端的⼀⼈ form 提交过程,并⽆技巧可⾔。不过,这⾥说明了⼀个问题:⽂件流同样可以任意在各服务器间流转。只要按照协议规范实现即可。(注意以上代码可能需要引⼊pom依赖:
org.apache.httpcomponents:httpclient:4.5.6,org.apache.httpcomponents:httpmime:4.5.6)
2. http 协议之⽂件处理
  ⼀般地,我们应对的互联⽹上的整个上传下载⽂件,基本都是基于http协议的。所以,要从根本上理解上传下载⽂件的原理,来看看http协议就好了。
  我们可以通过上⾯的demo看下上传时候的数据样⼦,我们通过 fiddler进⾏抓包查看数据即可得如下:
POST localhost:8082/test/fileUpDownTest?systemCode=1111&outputFileName=111 HTTP/1.1
Host: localhost:8082
Connection: keep-alive
Content-Length: 197
Accept: */*
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36 OPR/68.0.3618.63
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryen2ZJyNfx7WhA3yO
Origin: localhost:8082
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: localhost:8082/swagger-ui.html
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: JSESSIONID=40832A6766FB11E105717690AEF826AA
------WebKitFormBoundaryen2ZJyNfx7WhA3yO
Content-Disposition: form-data; name="file"; filename=""
Content-Type: text/plain
123content
over
------WebKitFormBoundaryen2ZJyNfx7WhA3yO
Content-Disposition: form-data; name="file2"; filename=""
Content-Type: text/plain
2222content
2over
------WebKitFormBoundaryen2ZJyNfx7WhA3yO--
  因为fiddler会做解码操作,且http是⼀种基于字符串的传输协议,所以,我们看到的都是可读的⽂件信息。我这⾥模拟是使⽤⼀个 的⽂件,⾥⾯输⼊了少量字符:“123content\nover”;
  我们知道,http协议是每⾏作为⼀个header的,其中前三是固定的,不必多说。
  与我们相关的有:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryen2ZJyNfx7WhA3yO
Content-Type是个重要的标识字段,当我们⽤⽂件上传时,multipart/form-data代表了这是⼀个多部分上传的⽂件类型请求,即此处的⽂件上传请求。后⾯的 boundary 代表在上传的实际多个部分内容时的分界线,该值应是在每次请求时随机⽣成且避免与业务数据的冲突。
Content-Length: 197.
这个值是由浏览器主动计算出来的负载内容长度,服务端收到该信息后,只会读取这么多的长度即认为传输完成。
http协议的包体是从遇到第⼀个两个连续的换⾏符开始的。(所以,如果在header中包含了此特征时,需要⾃⾏编码后再请求,否则将发⽣协议冲突。)
每个part部分的内容,以boundary作为分界线。part部分的内容可以是⽂件、流、或者纯粹的key-value。
  根据以上数据格式,服务端作出相应的反向解析就可以得到相应的内容了。
  如果服务响应的结果是⼀个⽂件下载,那么对于响应的结果⽰例如下:
HTTP/1.1200
Cache-Control: no-cache, no-store, must-revalidate
Content-Disposition: attachment; p.txt
Pragma: no-cache
Expires: 0
Content-Type: application/octet-stream;charset=utf-8
Transfer-Encoding: chunked
Date: Sun, 17 May 202005:30:57 GMT
10
123content
over
  重要字段说明:
Content-Disposition: attachment; p.txt
该字段说明本次响应的值应该作为⼀个附件形式下载保存到本地,这会被⼏乎所有浏览器⽀持。但如果
你⾃⼰写代码接收,那就随你意好了,它只是⼀个标识⽽已;其中 filename 是⽤作⽤户下载时的默认保存名称,如果本地已存在⼀般会被添加(xxx)的后缀以避免下载覆盖。
Content-Type: application/octet-stream;charset=utf-8
代表这是⼀个⼆进制的⽂件,也就是说,浏览器⼀般⽆法作出相应的处理。当然,这也只是⼀个建议,⾄于你输出的是啥也⽆所谓了,反正只要追加到⽂件之后,就可以还原⽂件内容了。
同样,遇到第⼀个连续的换⾏之后,代表正式的⽂件内容开始了。
如上的输出中,并没有 Content-Length 字段,所以⽆法直接推断出下载的数据⼤⼩,所以会在前后加⼀些字符器,⽤于判定结束。
这样做可能导致浏览器上⽆法判定已下载的数据量占⽐,即⽆法展⽰进度条。虽然不影响最终下载数据,但是⼀般别这么⼲。
  如下,我们加下content-length之后的响应如下:
HTTP/1.1200
Cache-Control: no-cache, no-store, must-revalidate
Content-Disposition: attachment; p.txt
Pragma: no-cache
Expires: 0
Content-Type: application/octet-stream;charset=utf-8
Content-Length: 16
Date: Sun, 17 May 202007:26:47 GMT
123content
over
  如上,就是http协议对于⽂件的处理⽅式了,只要你按照协议规定进⾏请求时,对端就能接受你的⽂件上传。只要服务按照协议规定输出响应数据,浏览器端就可以进⾏相应⽂件下载。
  http协议头更多信息可以参考:
3. http协议上传下载的背后,还有什么?
  我们知道,http协议是基于tcp协议上实现的⼀个应⽤层协议。上⼀节我们说到的,如何进⾏上传下载⽂件,也是基于应⽤层去说的。说直接点就是,如果把⽹络⽐作⿊盒,那么我们认为这个⿊盒会给我们正确的数据。我们只要基于这些数据,就可以解析相应的⽂件信息了。
  实际上,tcp协议是⼀种可靠的传输协议。⾄于如何可靠,额,这么说吧:⽹络上的信息是⾮常复杂和⽆序的,你从⼀个端点发送数据到另⼀个⽹络站点,会使⽤IP协议通过⽹络传送出去,⽽这些传输是单向的,多包的。它会受到外部复杂环境的影响,可能有的包丢失,可能有的包后发先到等等。如果不能处理好它们的这些丢包、乱序,重复等问题,那么⽹络发过来的数据将是⽆法使⽤的。(基本就是数据损坏这个结论)
  tcp则是专门为处理这些问题⽽设计的,具体嘛,就很复杂了。总之⼀句话,使⽤了tcp协议后,你就⽆需关注复杂的⽹络环境了,你可以⽆条件相信你从操作系统tcp层给你的数据就是有序的完整的数据。你可以去看书,或者查看更多⽹上资料。(书更可靠些,只是更费时间精⼒)可以参考这篇⽂章:
4. java中对于⽂件上传的处理实现?
  虽然前⾯我们解读完成http协议对于⽂件的上传处理⽅式,但是,到具体如何实现,⼜当如何呢?如果给你⼀个socket的⼊⼝lib,你⼜如何去处理这些http请求呢?
  可以⼤概这么思考: 1. 接收到头信息,判断出是⽂件类型的上传;2. 取出 boundary, 取出content-length, 备⽤;3. 继续读取后续的⽹络流数据,当发现传输的是key-value数据时,将其放⼊内存缓冲中存起来,当发现是⽂件类型的数据时,创建⼀个临时⽂件,将读取到的数据写⼊其中,直到该部分⽂件传输完成,并存储临时⽂件信息;4. 读取完整个http协议指定的数据后,封装相应的请求给到应⽤代码,待应⽤处理完成后响应给客户端;
  以tomcat为例,它会依次解析各个参数值。
  有兴趣的的同学可以先看看它是如何接⼊http请求的吧:(基于nio socket)⼤概流程为(下图为其线程模型):Accepter -> Pollor -> SocketProcessor 。
// at.util.NioEndpoint.Acceptor
@Override
public void run() {
int errorDelay = 0;
// Loop until we receive a shutdown command

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。