ユーザが指定した任意の組み合わせで、複数のファイルを纏めて 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
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()
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):
...
これで、ダウンロードした zip が無事 Mac のアーカイブユーティリティで開けるようになりました。
リアルタイムに zip を返す必要がある場合にこの様な機能を実装すれば、Mac でも開ける zip を返せるようになります。
ただし、1ファイルが大きい場合はこの方法は使えないので、そういう場合は諦めましょう。
Top comments (0)