はじめに
Webアプリケーションの管理系の仕組みを作っていると「システムのデータをExcelで開けるCSVファイルにまとめてダウンロードできるようにして欲しい」というパターンに経験上結構遭遇する。
Python3系で何も考えずにCSVを作ると UTF-8 の文字エンコーディングになるので、Excel のデフォルトの設定だと文字化けしてしまう。 もちろん、Excelでちゃんと読み込みエンコーディングを指定すれば問題なく開けるのだが、「そんなの分からんから最初から文字化けないようにして」と言われることも多い。その場合、Windows用のエンコーディング (CP932, あるいは Shift_JIS, SJIS) でCSVファイルを作成する必要がある。
今回は Chalice を使ってやっていたのだが、ドキュメントをよく見た結果ドはまりしたのでメモ。
version など
$ pipenv run chalice --version
chalice 1.21.2, python 3.8.2, linux 5.4.0-52-generic
ドキュメントを読む
今回は Response で返したいので、Responseクラスの body に適切に値を入れればどうにかなると思っていた。 しかし、記事執筆(2020/10/23)時点のドキュメントにはこうある。
class Response(body, headers=None, status_code=200)
body: The HTTP response body to send back. This value must be a string.
https://aws.github.io/chalice/api.html#response から引用・抜粋。 一部強調。
これを読んだときの思考。
「え…? CP932 にエンコードした文字列は bytes 型になるからここに渡せない…? 無理とするとファイルを作ってS3にアップロードして、これをダウンロードさせるか…」
が、body には bytes を入れても通る 。このドキュメントの string
は str型
だけではなく、より広い文字列を意図しているのだそうだ。 改めて調べてみると、同じ疑問を持った人が質問してくれていた。
実装例: 直接レスポンス
Chalice の Response#body
には bytes型も渡すことができる。 それを踏まえて実装するとこんな感じになる。 ブラウザで /
にアクセスすると、CSVファイルとしてダウンロードされる。
#!/usr/bin/python
# -*- coding: utf-8 -*-
import csv
import tempfile
from chalice import Chalice, Response
app = Chalice(app_name='csvtest')
def csv_response(filename, encoding='utf8'):
""" CSVファイルを返す方法1: 直接レスポンス """
with tempfile.TemporaryFile(mode='r+', encoding=encoding) as fh:
# writer で書き込み
writer = csv.writer(fh, lineterminator='\r\n')
writer.writerow(['ユーザー名', 'ログイン日時'])
writer.writerow(['user01', '2000/01/01 00:00:00'])
# 書き込んだ全てのデータを data に読み込み
fh.seek(0)
data = fh.read()
headers = {}
headers['Content-Type'] = 'text/csv'
headers['Content-Disposition'] = f'attachment;filename="{filename}"'
return Response(body=data, status_code=200, headers=headers)
@app.route('/')
def index():
return csv_response('test.csv', encoding='cp932')
別解: S3へのファイルアップロード
先の例では tmpfile
を使ってオンメモリにファイルを作成、さらに data
を読み込んでいる。 しかし、AWS Lambdaの場合はメモリ利用量が制限された環境での実行となるので、ファイルが大きくなってくる場合はメモリ利用量にかなりの影響を与えることが想像できる。 もちろんメモリ利用量の上限を上げることで回避できる問題だが、料金が倍となると躊躇することもあるだろう。
そういった場合は AWS Lambda で提供されている /tmp
以下に一時的にファイルを作り、作成したファイルを S3 にアップロードするといった方法を用いればよい。
S3にアップロードした後は CloudFront - S3
の通信経由で作成したファイルにアクセスさせたり、一時URLを作成・提供してユーザーにダウンロードをさせることができる。
なお、 TempfileContext
は AWS Lambda で用いた /tmp
以下のファイルをエラーハンドリングなど考えずに消せるように準備したものであるので、コードの本質ではない。
import os
import csv
import uuid
import boto3
s3 = boto3.client('s3')
class TempfileContext:
""" 一時ファイルを作って消去するコンテキストを提供します """
def __init__(self):
tmpfile = str(uuid.uuid4())
self.filename = f'/tmp/{tmpfile}'
def __enter__(self):
return self
def __exit__(self, ex_type, ex_value, trace):
try:
if os.path.exists(self.filename):
os.remove(self.filename)
except Exception:
pass
def create_and_upload_csv(filename, encoding='utf8'):
""" CSVファイルを返す方法2: CSVを作って S3にアップしておく """
with TempfileContext() as tmp:
# 1. /tmp 領域に CSVファイルを作成
with open(tmp.filename, 'w', encoding=encoding) as fh:
# writer で書き込み
writer = csv.writer(fh, lineterminator='\n')
writer.writerow(['ユーザー名', 'ログイン日時'])
writer.writerow(['user01', '2000/01/01 00:00:00'])
# 2. S3 にアップロード
s3.upload_file(tmp.filename, BUCKETNAME, f'uploads/{filename}')