赞
踩
最近遇到一个flask框架导出超大文件(GB级别)的需求,当我使用Content-Disposition
头设置中文
文件名的时候,发现仅文件名中的ASCII值被成功传输,非ASCII则被忽略掉了。
溯源flask的send_file源码,发现在werkzeug的send_file中,将download_name进行了编码:
然后查询HTTP规范,恍然大悟:
HTTP(超文本传输协议)本身并不强制要求文件名必须使用ASCII编码。HTTP协议是用于从Web服务器传输超文本到本地浏览器的传输协议,它支持多种字符编码。
然而,在实际应用中,由于历史原因和兼容性问题,URL通常使用ASCII字符集。这是因为早期的互联网只支持ASCII字符集,而且很多服务器和客户端可能不支持非ASCII字符。因此,当文件名包含非ASCII字符(如中文、日文、阿拉伯文等)时,通常会通过URL编码(百分号编码)的方式来表示这些字符。URL编码会将非ASCII字符转换为百分号(%)后跟两位十六进制数的形式。
例如,如果你有一个名为 “文件名.txt” 的文件,它包含非ASCII的中文字符,那么在URL中可能会表示为:
%E6%96%87%E4%BB%B6%E5%90%8D.txt
在HTTP响应中,如果要指定一个下载文件的名称,通常会在Content-Disposition头部使用filename参数来指定。这个参数的值如果包含非ASCII字符,通常会使用上述的URL编码,或者使用RFC 5987中定义的编码规则来进行编码,以确保文件名在不同的浏览器和操作系统中能够被正确处理。
例如,HTTP响应头可能包含如下字段:
Content-Disposition: attachment; filename="文件名.txt"
或者使用RFC 5987编码:
Content-Disposition: attachment; filename*=UTF-8''%E6%96%87%E4%BB%B6%E5%90%8D.txt
总之,HTTP协议本身不强制ASCII编码的文件名,但是由于历史和兼容性的原因,非ASCII字符在HTTP中通常需要进行特定的编码处理。
鉴于此,需要给download_name参数赋值时,导入from urllib.parse import quote
进行编码。
对于一个超大文件,如果直接设置其Content-Length头进行传输,则会导致服务器内存爆满,最终OOM,哪怕勉强塞到HTTP中也会给网络IO造成极大的负担。(您公司财大气粗,单台服务器的内存按TB来计算请点击右上角退出)
HTTP 流式传输通常是指在 HTTP/1.1 协议中使用 Transfer-Encoding: chunked 头部来实现的。在这种模式下,服务器可以将响应数据分割成多个部分(称为“块”)逐个发送给客户端,而不是一次性发送整个响应体。这种方式允许服务器开始发送响应数据,而不必先计算出整个响应的大小。
HTTP/1.1 流式传输
当你在 HTTP 响应中以流的方式写入内容时,如果你设置了 Transfer-Encoding: chunked,那么即使你写入了 1GB 的数据,HTTP 响应也只会有一个,但是它的 body 会被分割成多个块逐个发送。每个块的开始会有一个表示该块大小的十六进制数字,后面跟着一个 CRLF(回车换行),然后是块内容和另一个 CRLF。当所有数据都发送完毕后,一个大小为 0 的块会被发送,表示响应结束。
例如,流式传输的 HTTP/1.1 响应可能看起来像这样:
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked
1a
这是第一个块的内容
18
这是第二个块的内容
0
在这个例子中,1a 和 18 是块大小的十六进制表示,后面跟着的是实际的数据块。
流式传输的工作方式
在流式传输中,客户端和服务器之间的连接保持打开状态,直到所有数据都被发送完毕。客户端读取每个数据块,并处理它们,就像它们是连续到达的一样。这种方式对于发送大文件或者实时数据非常有用,因为它允许客户端开始处理数据,而不必等待所有数据都到达。
HTTP/2 流式传输
HTTP/2 也支持流式传输,但它的工作方式与 HTTP/1.1 有所不同。HTTP/2 使用二进制帧来传输数据,其中每个帧都属于一个特定的流。HTTP/2 的流式传输允许多个请求和响应在同一个连接上并行进行,而不会互相阻塞(称为多路复用)。
总结
另外需要特别注意的是:
Content-Length 和 Transfer-Encoding: chunked 是两种不同的 HTTP 响应头,它们通常不会同时出现在同一个响应中,因为它们代表了两种不同的数据发送方式。
Content-Length 告诉客户端响应体的确切字节数。当服务器知道响应的全部内容大小时,它会在响应头中包含这个字段。客户端使用这个值来确定何时完成接收数据。
Transfer-Encoding: chunked 用于分块传输编码,它允许服务器发送一个由多个大小不定的块组成的响应体,而不需要在开始发送之前知道整个响应的大小。每个块开始之前都有一个表示该块大小的十六进制数字,最后一个大小为零的块表示数据结束。
当你在响应中设置了 Content-Length 头,服务器就假定你会发送一个固定大小的响应体,因此它不会使用分块传输编码。相反,如果你没有设置 Content-Length,并且你的服务器或框架支持分块传输编码,那么它可能会自动添加 Transfer-Encoding: chunked 头,并以分块的方式发送响应体。
在实践中,如果你知道响应体的大小,并且希望客户端能够显示下载进度,你应该设置 Content-Length 头。如果你不知道响应体的大小,或者响应体是动态生成的,那么你应该让服务器使用分块传输编码,这时不应该设置 Content-Length 头。
请注意,如果你手动设置了 Content-Length 头,但实际发送的数据大小与这个头部声明的大小不一致,那么可能会导致客户端出现错误,因为客户端会期待接收到声明的字节数。如果发送的数据少于声明的大小,客户端会等待剩余的数据;如果发送的数据多于声明的大小,客户端可能会忽略额外的数据或者报错。
经过实验,只需要以流的形式写入http writer即可,不需要特别添加Transfer-Encoding: chunked
头,如果想在浏览器展示进度条,需要自己计算Content-Length写入http headers即可。
给出一个基于flask的流式Response的实现:
import os.path import typing as t from datetime import datetime from hashlib import sha256 from urllib.parse import quote from time import time from flask import stream_with_context, Response, request def stream_file_hash(path_or_file: t.Iterable[bytes], hash_factory: sha256) -> str: __h = hash_factory() for line in path_or_file: __h.update(line) return __h.hexdigest() def stream_send_file( download_name: str, bytes_size: int, path_or_file: t.Iterable[bytes], mimetype: t.Optional[str] = "application/octet-stream", as_attachment: bool = True, conditional: bool = True, etag: t.Optional[str] = None, last_modified: t.Optional[t.Union[datetime, int, float]] = None, max_age: t.Optional[t.Union[int, t.Callable[[t.Optional[str]], t.Optional[int]]]] = None ): response = Response(stream_with_context(path_or_file), mimetype=mimetype) if conditional: response = response.make_conditional(request_or_environ=request, accept_ranges=True, complete_length=bytes_size) if etag: response.set_etag(etag) if max_age is not None: if max_age > 0: response.cache_control.no_cache = None response.cache_control.public = True response.cache_control.max_age = max_age response.expires = int(time() + max_age) # type: ignore if last_modified is not None: response.last_modified = last_modified # type: ignore response.headers.set( "Content-Disposition", "attachment" if as_attachment else "inline", **{"filename": quote(download_name)} ) response.headers.set("Content-Length", bytes_size) return response from flask import Flask app = Flask(__name__) def generator(): with open("elasticsearch-7.16.0-windows-x86_64.zip", "rb") as f: for line in f: yield line @app.get("/download") def download(): return stream_send_file("elasticsearch-7.16.0-windows-x86_64.zip", os.path.getsize("elasticsearch-7.16.0-windows-x86_64.zip"), generator()) if __name__ == '__main__': app.run()
以django导出xlsx文件为例,
Django 提供三种方式实现文件下载功能,分别是:HttpResponse、StreamingHttpResponse和FileResponse
普通文件导出案例:
def download1(request):
#服务器上存放文件的路径
file_path = r"E:\myDjango\file\1.jpg"
try:
r = HttpResponse(open(file_path,"rb"))
print(r)
r["content_type"]="application/octet-stream"
r["Content-Disposition"]="attachment;filename=1.jpg"
return r
except Exception:
raise Http404("Download error")
xlsx导出案例:
def make_res1(buffer):
# ok
response = HttpResponse(content_type="application/octet-stream")
response["Content-Disposition"] = 'attachment;filename="122.xlsx"'
response.write(buffer.getvalue())
return response
普通文件导出案例:
def download2(request):
file_path = r"E:\myDjango\file\2.jpg"
try:
r = StreamingHttpResponse(open(file_path,"rb"))
r["content_type"]="application/octet-stream"
r["Content-Disposition"]="attachment;filename=2.jpg"
return r
except Exception:
raise Http404("Download error")
使用bytesIo导出文件案例:
def yield_buffer(buffer):
buffer.seek(0)
for line in buffer:
yield line
def make_res2(buffer):
response = StreamingHttpResponse(yield_buffer(buffer))
response['Content-Type'] = 'application/octet-stream'
response["Content-Disposition"] = 'attachment;filename="122.xlsx"'
return response
普通文件导出案例:
def download3(request):
file_path = r"E:\myDjango\file\3.jpg"
try:
f = open(file_path,"rb")
r = FileResponse(f,as_attachment=True,filename="3.jpg")
return r
except Exception:
raise Http404("Download error")
使用bytesIo导出文件案例:
def make_res3(buffer):
response = FileResponse(yield_buffer(buffer))
response.write()
response['Content-Type'] = 'application/octet-stream'
response["Content-Disposition"] = 'attachment;filename="122.xlsx"'
return response
drf封装了django的http形容了一个强大的Response类用于返回各类型的http相应,
但是,当直接把一个file或file_like丢给Response会导致报错,所以Response还是用django的一些流式http返回比较好。
使用flask进行大文件传输,首先需要抛弃掉封装末端的send_file
方法~,并且导入原生Response和流上下文处理器:
from flask import Response,stream_with_context
以下给出一个案例:
from flask import Flask, Response, stream_with_context
app = Flask(__name__)
def generate_large_file():
# 假设这是一个生成大文件内容的函数
for i in range(1000000): # 假设我们有100万行数据要发送
yield f"This is line {i}\n"
@app.route('/download')
def download_file():
return Response(stream_with_context(generate_large_file()), content_type='text/plain')
if __name__ == '__main__':
app.run()
此时打开浏览器,并且访问下载接口,你就会发现当前接口的相应体在逐渐增大,而不是之前的等待响应完全返回才嗖的一下全部展示出来:
在这个例子中,generate_large_file 函数是一个生成器,它逐行生成文件内容。当你访问 /download 路由时,Flask 会自动将响应设置为分块传输模式,并且会逐块发送生成器产生的数据。
如果使用其他框架或者自己实现 HTTP 服务,可能需要手动处理分块传输的逻辑,包括添加头部、格式化每个数据块以及发送终止块。
总结来说,使用 Flask 时,不需要显式地设置 Transfer-Encoding: chunked 头部,只需要返回一个生成器即可。如果在其他环境中工作,可能需要更多的手动设置和实现。
Gin 框架中的 http.ResponseWriter 确实允许多次写入响应。在 Gin 中实现流式传输相对直接,因为可以直接写入到 http.ResponseWriter。以下是一个 Gin 的例子:
package main import ( "fmt" "github.com/gin-gonic/gin" "net/http" ) func main() { r := gin.Default() r.GET("/download", func(c *gin.Context) { // 设置响应头部,以附件形式下载文件 c.Header("Content-Disposition", "attachment; filename=large_file.txt") c.Header("Content-Type", "text/plain") // 不设置 Content-Length,使其自动使用分块传输编码 // 逐步写入数据 for i := 0; i < 1000000; i++ { data := fmt.Sprintf("This is line %d\n", i) c.Writer.Write([]byte(data)) c.Writer.Flush() // 确保每次写入都发送到客户端 } }) r.Run() // 监听并在 0.0.0.0:8080 上启动服务 }
在这个 Gin 示例中,在路由处理函数中直接向 c.Writer 写入数据,并在每次写入后调用 Flush 方法,确保数据被立即发送到客户端。这样就实现了流式传输。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。