如何在不占用磁盘的情况下开始下载并渲染响应?
我在Django中有一个科学数据的Excel文件验证表单,运行得很好。这个表单是循环进行的,用户可以在他们的研究中不断上传新数据的文件。每次上传时,DataValidationView
会检查这些文件,并给用户提供一个错误报告,列出他们数据中需要修复的问题。
我们最近意识到,有一些错误(但不是全部)可以自动修复,所以我一直在想办法生成一个包含多个修复的文件副本。因此,我们把“验证”表单页面重新命名为“构建提交页面”。每次用户上传一组新文件时,目的是让他们仍然收到错误报告,同时也能自动下载一个包含多个修复的文件。
我今天才了解到,无法同时渲染一个模板并启动下载,这很合理。不过,我原本打算不把生成的修复文件保存到磁盘上。
有没有办法在展示错误的模板时,自动触发下载,而不需要事先将文件保存到磁盘上呢?
这是我目前的form_valid
方法(没有触发下载,但在我意识到下载和渲染模板不能同时进行之前,我已经开始创建文件了):
def form_valid(self, form):
"""
Upon valid file submission, adds validation messages to the context of
the validation page.
"""
# This buffers errors associated with the study data
self.validate_study()
# This generates a dict representation of the study data with fixes and
# removes the errors it fixed
self.perform_fixes()
# This sets self.results (i.e. the error report)
self.format_validation_results_for_template()
# HERE IS WHERE I REALIZED MY PROBLEM. I WANTED TO CREATE A STREAM HERE
# TO START A DOWNLOAD, BUT REALIZED I CANNOT BOTH PRESENT THE ERROR REPORT
# AND START THE DOWNLOAD FOR THE USER
return self.render_to_response(
self.get_context_data(
results=self.results,
form=form,
submission_url=self.submission_url,
)
)
在我遇到这个问题之前,我正在编写一些伪代码来流式传输文件……这完全没有经过测试:
import pandas as pd
from django.http import HttpResponse
from io import BytesIO
def download_fixes(self):
excel_file = BytesIO()
xlwriter = pd.ExcelWriter(excel_file, engine='xlsxwriter')
df_output = {}
for sheet in self.fixed_study_data.keys():
df_output[sheet] = pd.DataFrame.from_dict(self.fixed_study_data[sheet])
df_output[sheet].to_excel(xlwriter, sheet)
xlwriter.save()
xlwriter.close()
# important step, rewind the buffer or when it is read() you'll get nothing
# but an error message when you try to open your zero length file in Excel
excel_file.seek(0)
# set the mime type so that the browser knows what to do with the file
response = HttpResponse(excel_file.read(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
# set the file name in the Content-Disposition header
response['Content-Disposition'] = 'attachment; filename=myfile.xlsx'
return response
所以我在想,我需要:
- 把文件保存到磁盘,然后想办法让结果页面开始下载
- 以某种方式将数据嵌入到结果模板中,并通过JavaScript将其发送回来,转化为文件下载流
- 以某种方式将文件保存在内存中,并从结果模板触发下载?
实现这个的最佳方法是什么呢?
更新想法:
我最近用tsv
文件做了一个简单的技巧,把文件内容嵌入到结果模板中,并用一个下载按钮通过JavaScript抓取数据周围标签的innerHTML
来启动“下载”。
我想,如果我对数据进行编码,可能可以用类似的方法处理Excel文件的内容。我可以进行base64编码。
我查看了过去的研究提交,最大的一个是115kb。这个大小可能会增长很多,但现在115kb是上限。
我在网上查找了如何将数据嵌入模板的方法,找到了这个:
import base64
with open(image_path, "rb") as image_file:
image_data = base64.b64encode(image_file.read()).decode('utf-8')
ctx["image"] = image_data
return render(request, 'index.html', ctx)
我最近在JavaScript中玩base64编码,虽然是为了其他工作,这让我相信嵌入是可行的。我甚至可以自动触发。有人对这样做有什么注意事项吗?
更新
我花了一整天尝试实现@Chukwujiobi_Canon的建议,但在处理很多错误和我不熟悉的东西后,我卡住了。新标签页打开了(但它是空的),文件被下载了,但无法打开(浏览器控制台显示“框架加载中断”的错误)。
我先实现了Django代码,我认为它运行正常。当我在没有JavaScript的情况下提交表单时,浏览器下载了多部分流,看起来符合预期:
--3d6b6a416f9b5
Content-Type: application/octet-stream
Content-Range: bytes 0-9560/9561
PK?N˝Ö€]'[Content_Types].xm...
...
--3d6b6a416f9b5
Content-Type: text/html
Content-Range: bytes 0-16493/16494
<!--use Bootstrap CSS and JS 5.0.2-->
...
</html>
--3d6b6a416f9b5--
这是JavaScript代码:
validation_form = document.getElementById("submission-validation");
// Take over form submission
validation_form.addEventListener("submit", (event) => {
event.preventDefault();
submit_validation_form();
});
async function submit_validation_form() {
// Put all of the form data into a variable (formdata)
const formdata = new FormData(validation_form);
try {
// Submit the form and get a response (which can only be done inside an async functio
let response;
response = await fetch("{% url 'validate' %}", {
method: "post",
body: formdata,
})
let result;
result = await response.text();
const parsed = parseMultipartBody(result, "{{ boundary }}");
parsed.forEach(part => {
if (part["headers"]["content-type"] === "text/html") {
const url = URL.createObjectURL(
new Blob(
[part["body"]],
{type: "text/html"}
)
);
window.open(url, "_blank");
}
else if (part["headers"]["content-type"] === "application/octet-stream") {
console.log(part)
const url = URL.createObjectURL(
new Blob(
[part["body"]],
{type: "application/octet-stream"}
)
);
window.location = url;
}
});
} catch (e) {
console.error(e);
}
}
function parseMultipartBody (body, boundary) {
return body.split(`--${boundary}`).reduce((parts, part) => {
if (part && part !== '--') {
const [ head, body ] = part.trim().split(/\r\n\r\n/g)
parts.push({
body: body,
headers: head.split(/\r\n/g).reduce((headers, header) => {
const [ key, value ] = header.split(/:\s+/)
headers[key.toLowerCase()] = value
return headers
}, {})
})
}
return parts
}, [])
}
服务器控制台输出看起来正常,但到目前为止,输出都无法正常工作。
2 个回答
@Chukwujiobi_Canon的回答非常好,而且可扩展,虽然我花了一整天才差不多搞定,但还是没完全成功。我预计可能还需要一天时间来完善。不过考虑到我的文件大小都在1MB以下,我决定回到最初的想法:把文件内容用base64编码嵌入到渲染的页面中(隐藏),然后用JavaScript自动触发下载。
我花了不到一个小时,这个方法完全可行,而且代码量非常少。当然,有些代码是从其他解决方案中复用过来的。
下面是我生成文件内容的方法。我还包括了一个把类似pandas的字典转换成xlsx文件的函数(需要安装pip install xlsxwriter
)。
import xlswriter
def form_valid(self, form):
# This buffers errors associated with the study data
self.validate_study()
# This generates a dict representation of the study data with fixes and
# removes the errors it fixed
self.perform_fixes()
# This sets self.results (i.e. the error report)
self.format_validation_results_for_template()
study_stream = BytesIO()
xlsxwriter = self.create_study_file_writer(study_stream)
xlsxwriter.close()
# Rewind the buffer so that when it is read(), you won't get an error about opening a zero-length file in Excel
study_stream.seek(0)
study_data = base64.b64encode(study_stream.read()).decode('utf-8')
study_filename = self.animal_sample_filename
if self.animal_sample_filename is None:
study_filename = "study.xlsx"
return self.render_to_response(
self.get_context_data(
results=self.results,
form=form,
submission_url=self.submission_url,
study_data=study_data,
study_filename=study_filename,
),
)
def create_study_file_writer(self, stream_obj: BytesIO):
xlsxwriter = pd.ExcelWriter(stream_obj, engine='xlsxwriter')
# This iterates over the desired order of the sheets and their columns
for order_spec in self.get_study_sheet_column_display_order():
sheet = order_spec[0]
columns = order_spec[1]
# Create a dataframe and add it as an excel object to an xlsxwriter sheet
pd.DataFrame.from_dict(self.dfs_dict[sheet]).to_excel(
excel_writer=xlsxwriter,
sheet_name=sheet,
columns=columns
)
return xlsxwriter
这是我在模板中渲染数据的标签
<pre style="display: none" id="output_study_file">{{study_data}}</pre>
这是用来“下载”文件的JavaScript代码:
document.addEventListener("DOMContentLoaded", function(){
// If there is a study file that was produced
if ( typeof study_file_content_tag !== "undefined" && study_file_content_tag ) {
browserDownloadExcel('{{ study_filename }}', study_file_content_tag.innerHTML)
}
})
function browserDownloadExcel (filename, base64_text) {
const element = document.createElement('a');
element.setAttribute(
'href',
'data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,' + encodeURIComponent(base64_text)
);
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
为了后人留个记录,这里有一个关于在Django中实现HTTP 1.1的multipart/byteranges Response
的指南。如果想了解更多关于multipart/byteranges的信息,可以查看RFC 7233。
multipart/byteranges的内容格式如下:
HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=3d6b6a416f9b5
--3d6b6a416f9b5
Content-Type: application/octet-stream
Content-Range: bytes 0-999/2000
<octet stream data 1>
--3d6b6a416f9b5
Content-Type: application/octet-stream
Content-Range: bytes 1000-1999/2000
<octet stream data 2>
--3d6b6a416f9b5
Content-Type: application/json
Content-Range: bytes 0-441/442
<json data>
--3d6b6a416f9b5
Content-Type: text/html
Content-Range: bytes 0-543/544
<html string>
--3d6b6a416f9b5--
你大概明白了。前两个是同样的二进制数据分成了两个部分,第三个是一个以JSON格式发送的字符串,第四个是一个以HTML格式发送的字符串。
在你的情况下,你是要发送一个File
和你的HTML
模板。
from io import BytesIO, StringIO
from django.template.loader import render_to_string
from django.http import StreamingHttpResponse
def stream_generator(streams):
boundary = "3d6b6a416f9b5"
for stream in streams:
if isinstance(stream, BytesIO):
data = stream.getvalue()
content_type = 'application/octet-stream'
elif isinstance(stream, StringIO):
data = stream.getvalue().encode('utf-8')
content_type = 'text/html'
else:
continue
stream_length = len(data)
yield f'--{boundary}\r\n'
yield f'Content-Type: {content_type}\r\n'
yield f'Content-Range: bytes 0-{stream_length-1}/{stream_length}\r\n'
yield f'\r\n'
yield data
yield f'\r\n'
yield f'--{boundary}--\r\n'
def multi_stream_response(request):
streams = [
excel_file, # The File provided in the OP. It is a BytesIO object.
StringIO(render_to_string('index.html', request=request))
]
return StreamingHttpResponse(stream_generator(streams), content_type='multipart/byteranges; boundary=3d6b6a416f9b5')
可以参考这个例子 [stackoverflow],了解如何在客户端解析multipart/byteranges。