Chalice で Excel 向けの CP932 CSV ファイルを作成して返す

# はじめに

Webアプリケーションの管理系の仕組みを作っていると「システムのデータをExcelで開けるCSVファイルにまとめてダウンロードできるようにして欲しい」というパターンに経験上結構遭遇する。
Python3系で何も考えずにCSVを作ると UTF-8 の文字エンコーディングになるので、Excel のデフォルトの設定だと文字化けしてしまう。 もちろん、Excelでちゃんと読み込みエンコーディングを指定すれば問題なく開けるのだが、「そんなの分からんから最初から文字化けないようにして」と言われることも多い。その場合、Windows用のエンコーディング (CP932, あるいは Shift_JIS, SJIS) でCSVファイルを作成する必要がある。

今回は Chalice を使ってやっていたのだが、ドキュメントをよく見た結果ドはまりしたのでメモ。


## version など

```bash
$ 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型` だけではなく、より広い文字列を意図しているのだそうだ。 改めて調べてみると、[同じ疑問を持った人が質問してくれていた。](https://github.com/aws/chalice/issues/592)


## 実装例: 直接レスポンス

Chalice の `Response#body` には **bytes型も渡すことができる**。 それを踏まえて実装するとこんな感じになる。 ブラウザで `/` にアクセスすると、CSVファイルとしてダウンロードされる。


```python:app.py
#!/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` 以下のファイルをエラーハンドリングなど考えずに消せるように準備したものであるので、コードの本質ではない。

```python
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}')
```