从 Django 服务器一次流式传输多个文件

Stream multiple files at once from a Django server

我是 运行 一个 Django 服务器,用于提供来自受保护网络中另一台服务器的文件。当用户发出一次访问多个文件的请求时,我希望我的 Django 服务器将这些文件一次全部流式传输给该用户。

由于在浏览器中一次下载多个文件并不容易,因此需要以某种方式将文件捆绑在一起。我不希望我的服务器必须先下载所有文件,然后再提供一个现成的捆绑文件,因为这会为较大的文件增加很多时间损失。对于 zips,我的理解是它在组装时无法流式传输。

有没有什么方法可以在远程服务器的第一个字节可用时立即开始流式传输容器?

Tar-files 用于将多个文件收集到一个存档中。它们是为录音机开发的,因此提供顺序写入和读取。

使用 Django 可以使用 FileResponse() 将文件流式传输到浏览器,它可以将生成器作为参数。

如果我们为它提供一个生成器,该生成器 assemble 将 tar-file 与用户请求的数据一起生成,tar 文件将及时生成。然而 pythons built-in tarfile-module 不提供开箱即用的这种功能。

然而,我们可以利用 tarfile 的能力来传递一个 File-like object 来自己处理存档的组装。因此,我们可以创建一个 BytesIO() object,tar 文件将逐渐写入并将其内容刷新到 Django 的 FileResponse() 方法。为此,我们需要实现一些 FileResponse()tarfile 期望访问的方法。让我们创建一个 class FileStream:

class FileStream:
    def __init__(self):
        self.buffer = BytesIO()
        self.offset = 0

    def write(self, s):
        self.buffer.write(s)
        self.offset += len(s)

    def tell(self):
        return self.offset

    def close(self):
        self.buffer.close()

    def pop(self):
        s = self.buffer.getvalue()
        self.buffer.close()
        self.buffer = BytesIO()
        return s

现在,当我们 write() 数据到 FileStream 的缓冲区时,yield FileStream.pop() Django 会立即将该数据发送给用户。

作为数据我们现在想要assemble即tar-file。在FileStreamclass我们再添加一个方法:

    @classmethod
    def yield_tar(cls, file_data_iterable):
        stream = FileStream()
        tar = tarfile.TarFile.open(mode='w|', fileobj=stream, bufsize=tarfile.BLOCKSIZE)

这会在内存中创建一个 FileStream 实例和一个 file-handle。 file-handle 访问 FileStream-instance 来读取和写入数据,而不是磁盘上的文件。

现在在tar-file中我们首先要添加一个tarfile.TarInfo() object代表一个header用于顺序写入的数据,有文件名,大小等信息及修改时间

        for file_name, file_size, file_date, file_data in file_data_iterable:
            tar_info = tarfile.TarInfo(file_name)
            tar_info.size = int(file_size)
            tar_info.mtime = file_date
            tar.addfile(tar_info)
            yield stream.pop()

您还可以看到将任何数据传递给该方法的结构。 file_data_iterable 是包含
的元组列表 ((str) file_name, (int/str) file_size, (str) unix_timestamp, (bytes) file_data).

发送 TarInfo 后迭代 file_data。 此数据需要是可迭代的。 例如,您可以使用 requests.response object 并通过 requests.get(url, stream=True) 检索。

            for chunk in (requests.get(url, stream=True).iter_content(chunk_size=cls.RECORDSIZE)):
                # you can freely choose that chunk size, but this gives me good performance
                tar.fileobj.write(chunk)
                yield stream.pop()

注意:这里我使用了变量url来请求一个文件。您需要在元组参数中传递它而不是 file_data 。如果您选择传入可迭代文件,则需要更新此行。

最后,tar文件需要一种特殊格式来指示文件已完成。 Tarfile 由块和记录组成。通常一个块包含512字节,一条记录包含20个块(20*512字节=10240字节)。首先,包含最后一块文件数据的最后一个块用 NUL(通常是纯零)填充,然后下一个文件的下一个 TarInfo header 开始。

要结束存档,当前记录将被 NUL 填满,但必须至少有两个块完全被 NUL 填满。这将由 tar.close() 处理。另见 Wiki.

            blocks, remainder = divmod(tar_info.size, tarfile.BLOCKSIZE)
            if remainder > 0:
                tar.fileobj.write(tarfile.NUL * (tarfile.BLOCKSIZE - remainder))
                yield stream.pop()
                blocks += 1
            tar.offset += blocks * tarfile.BLOCKSIZE
        tar.close()
        yield stream.pop()

您现在可以在 Django 视图中使用 FileStream class:

from django.http import FileResponse
import FileStream

def stream_files(request, files):
    file_data_iterable = [(
        file.name,
        file.size,
        file.date.timestamp(),
        file.data
    ) for file in files]

    response = FileReponse(
        FileStream.yield_tar(file_data_iterable),
        content_type="application/x-tar"
    )
    response["Content-Disposition"] = 'attachment; filename="streamed.tar"'
    return response

如果要传递 tar 文件的大小以便用户可以看到进度条,您可以提前确定未压缩的 tar 文件的大小。在FileStreamclass中添加另外一个方法:

    def tarsize(cls, sizes):
        # Each file is preceeded with a 512 byte long header
        header_size = 512
        # Each file will be appended to fill up a block
        tar_sizes = [ceil((header_size + size) / tarfile.BLOCKSIZE)
                     * tarfile.BLOCKSIZE for size in sizes]
        # the end of the archive is marked by at least two consecutive
        # zero filled blocks, and the final record block is filled up with
        # zeros.
        sum_size = sum(tar_sizes)
        remainder = cls.RECORDSIZE - (sum_size % cls.RECORDSIZE)
        if remainder < 2 * tarfile.BLOCKSIZE:
            sum_size += cls.RECORDSIZE
        total_size = sum_size + remainder
        assert total_size % cls.RECORDSIZE == 0
        return total_size

并使用它来设置响应 header:

tar_size = FileStream.tarsize([file.size for file in files])
...
response["Content-Length"] = tar_size

非常感谢chipx86 and allista,他们的要点对我完成这项任务有很大帮助。