DEV Community

melpon
melpon

Posted on

Django でリアルタイムに zip を作りながらレスポンスを返す方法

ユーザが指定した任意の組み合わせで、複数のファイルを纏めて zip でダウンロードしたいことがあります。
組み合わせの数だけ zip を保存しておくのも難しいため、リアルタイムに生成しながら返すような仕組みが欲しいところです。
そこで、Django でリアルタイムに zip を作りながらレスポンスを返す機能を作りました。

Django でリアルタイムにレスポンスを返すには StreamingHttpReponse を使います。
以下のようになります。

from django.http import StreamingHttpResponse
from django.views import View


class DownloadView(View):
    def get(self, request):
        images = ['foo.jpg', 'bar.jpg', 'baz.jpg']
        response = StreamingHttpResponse(download_zip_streaming(images), content_type="application/zip")
        response['Content-Disposition'] = f'attachment; filename="download.zip"'
        return response
Enter fullscreen mode Exit fullscreen mode

images は固定のファイル名になっていますが、本来はリクエストに含めるとか DB から拾ってくるとかして下さい。
DownloadView は、これらのファイルを zip に圧縮しながらレスポンスを返しています。

download_zip_streaming(images) は Python のジェネレータになっていて、リアルタイムに zip のバイト配列を次々に生成するようなコードになっています。
これによって zip リアルタイムに生成しながらレスポンスを返せるようになっています。

download_zip_streaming 関数は以下のようになっています。

class ZipBuffer(object):
    def __init__(self):
        self._buf = b''

    def write(self, buf):
        self._buf += buf
        return len(buf)

    def flush(self):
        pass

    def consume(self):
        # 溜めておいたバッファを全て消費する
        result = self._buf
        self._buf = b''
        return result


# images の画像を、ストリーミングで zip に圧縮する
def download_zip_streaming(images):
    session = boto3.Session(region_name=settings.AWS_REGION)
    client = session.client('s3')

    zb = ZipBuffer()
    with zipfile.ZipFile(zb, mode='w') as zf:
        for image in images:
            # 画像を buf にダウンロード
            buf = io.BytesIO()
            client.download_fileobj(settings.S3_BUCKET, f'images/{image}', buf)
            buf.seek(0)

            zf.writestr(image, buf.read())
            yield zb.consume()

    yield zb.consume()
Enter fullscreen mode Exit fullscreen mode

download_zip_streaming 関数は、S3 からファイルを拾ってきて、それを zipfile を使って圧縮しています。
S3 から拾ってくる部分は何でも良くて、ファイルから読んだりプログラムで作るなり、好きにすれば良いです。

zipfile.ZipFile は zip を作ってくれるモジュールで、普通はファイル名を指定してファイルに出力しますが、他にも file-like オブジェクトも受け付けています。

そのため、ファイルに書いてもらう代わりに ZipBuffer という自作の file-like オブジェクトに出力してもらって、その出力した zip データを yield を使って返すことで zip をリアルタイムにダウンロードさせています。

これでうまく動くように見えます。
実際、zip データをダウンロードするまでは問題なく動作します。
しかし、Mac のアーカイブユーティリティで展開しようとすると、以下のエラーが表示されます。

展開エラー

unzip コマンドでは問題なく動作するのですが、Mac のアーカイブユーティリティではうまく動作しません。
調べてみると、Mac では zip のデータディスクリプタに対応していないようです。

zip のファイル単位のヘッダー(ローカルファイルヘッダー)には、圧縮済みのファイルサイズや CRC といった、圧縮後じゃないと分からないような情報が、圧縮後のデータより前に存在しています。
この様なケースに対応するため、zip では圧縮後のデータの後ろに 12 バイトのデータディスクリプタという領域を持つことが出来ます。
ここに圧縮済みのファイルサイズや CRC といった情報を書き込むことで、zip のストリーミングでの圧縮が出来るようになっています。

しかし Mac はこのデータディスクリプタに対応していないので、ストリーミングでの圧縮は出来ません。
どうすればいいのかしばらく zipfile のソースコードや zip のフォーマットの仕様を見ながら考えていましたが、全体でシーク可能にしなくても1ファイル分だけ巻き戻ってシークできれば良いことに気が付きました。

zipfile の実装を見ると、file-like オブジェクトがシーク可能な場合、1ファイルだけ圧縮して書き込んだら、そのファイルのローカルファイルヘッダーにシークした上でファイルサイズ等を書き込んでいます。
つまり、1ファイル分だけバッファを持つ file-like オブジェクトを作れば良いのです。

# consume するまではバッファに溜め続けて、
# その範囲内であれば seek 可能な file-like オブジェクト
class ZipBuffer(object):
    def __init__(self):
        self._buf = b''
        self._pos = 0
        self._minpos = 0

    @property
    def _maxpos(self):
        return self._minpos + len(self._buf)

    def tell(self):
        return self._pos

    def seek(self, pos):
        # 既に消費されたバッファに seek しようとした
        if pos < self._minpos:
            raise RuntimeError(f'pos error: pos={pos}, minpos={self._minpos}')
        # 最大位置を超えたらバッファを b'\0' で埋める
        if pos > self._maxpos:
            self._buf += b'\0' * (pos - self._maxpos)
        self._pos = pos

    def write(self, buf):
        written = len(buf)
        # 現在位置がバッファの途中だった場合は上書きする
        if self._pos < self._maxpos:
            n = min(self._maxpos - self._pos, len(buf))
            x = self._pos - self._minpos
            self._buf = self._buf[:x] + buf[:n] + self._buf[x + n:]
            self._pos += n
            buf = buf[n:]

        # 最大位置を超えた分は単に追加する
        self._buf += buf
        self._pos += len(buf)
        return written

    def flush(self):
        pass

    def consume(self):
        # 溜めておいたバッファを全て消費する
        # 以降はこの位置にはシーク不可能になる
        self._minpos += len(self._buf)
        result = self._buf
        self._buf = b''
        return result


# download_zip_streaming はさっきの実装と同じ
def download_zip_streaming(images):
    ...
Enter fullscreen mode Exit fullscreen mode

これで、ダウンロードした zip が無事 Mac のアーカイブユーティリティで開けるようになりました。
リアルタイムに zip を返す必要がある場合にこの様な機能を実装すれば、Mac でも開ける zip を返せるようになります。

ただし、1ファイルが大きい場合はこの方法は使えないので、そういう場合は諦めましょう。

Discussion (0)