AWS APIGateway＋lambda＋S3を使ってダウンロード機能を実装する

# 背景
AWS使ってサーバーレスで自分用の家計簿的なwebサービスを勉強も兼ねて開発中。大分自分が欲しかった機能は実装出来てきた。
今後の為にもDynamoDBをバックアップをしたい。DynamoDB自体にバックアップ機構はあるものの、間違えてテーブル自体を削除してしまった時（構築スクリプトミスとか）の為に、CSVやjsonファイルなどでローカルPCに置いておきたい。

# 方法を考察
手法は色々あると思うが、大きく分けて以下の２つになる。

 - どこかサーバー側で、aws-cliなどを使ってファイル出力する
 - web画面にダウンロード機能を追加する

やっぱりここはダウンロード機能。このサービス開発は勉強も兼ねてるし。
ここで、単純にwebページからダウンロードと言ってもまたそこで細かい手法が存在する。

 1. データを取得して、クライアント側で文字列作成してダウンロード処理
 2. サーバー側で生成したファイルストリームをダイレクトレスポンスで返す
 3. S3にアップして、そのアドレスを返し、リダイレクト
 4. S3にアップ。フォルダ一覧ページも用意してそこからリンククリックダウンロード

### 考察ポイント

 - 1はクライアントのみで処理が完結するもの向け
 - 2,3はサイズが小さいファイル向け、4は比較的大きいファイル向け（大きいファイルは作成にも時間かかるので、ダウンロード失敗などで再実行したくない）。
 - ファイル作成する方法だと、その後処理を考えないと不要なファイルが溜まっていく。
 - 処理はLambdaでやる予定。 [AWS Lambda の制限](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/limits.html) は気を付ける。

### 決定した方針

 - 将来的には扱うサイズが大きくなるかもしれないが、3番目の**S3にアップしてそのアドレスを返してリダイレクト**を選択する。
 - 全データを出力するのでなく、一定期間で指定して出力できるようにして、そもそもデータ量を多くしないようにする。
 - 出力するS3フォルダはログインユーザー毎のフォルダ
 - S3バケットのライフサイクルポリシーを設定して一定期間過ぎたファイルは消す様にする。
 - 関係するテーブル全て（伝票情報と残額情報）が対象
 - パラメーターは開始終了日

# 実装
以下の技術要素が必要になる。

 - lambdaでファイル作成
 - 複数ファイルをzip圧縮
 - S3のフォルダを決定する為の認証情報をlambdaに渡す
 - lambdaからS3へファイルアップロード
 - 返却されたS3のurlを使ってダウンロード実行

### lambdaでファイル作成
今、lambdaで使用している言語はPython。Pythonにはファイル出力機能はもちろんあるが、lambdaでは物理マシンの概念が無く、用意されているのは/tmpフォルダのみ。このフォルダはOS的には一時ディレクトリと呼ばれる場所。一定時間が切れたり再起動されたら消える。反対に言えば放置してもOS的に処理してくれる。
tempfileモジュールで取得したフォルダを使うべきなのか、公式ページ上でlambdaで使用できるフォルダとして指定されてる/tmp表記を直接使うべきか悩むところではあったけど、tempfileモジュールを使う事に決定。
後々圧縮するので、tempfile.TemporaryDirectoryで一時ディレクトリ作って、その中で色々する事にする。

### 複数ファイルをzip圧縮
ここはそんなに悩む場所ではない。zipfileモジュール使って普通に処理。

### S3のフォルダを決定する為の認証情報をlambdaに渡す
最初は認証のjwtトークンをlambdaに渡して色々する必要があると思ったが、S3のフォルダ名に使用するidentityIdはそこからは取得できなさそうだった。その為、クライアント側で取得できるidentityIdを普通にクエリパラメーターでそのまま渡す事にした。

### lambdaからS3へファイルアップロード
ここもそんなに悩む場所ではない。boto3モジュールで普通に出来る。

ここまでのlambda側処理は以下の感じ。try句が２重になっているが、圧縮前ファイルをcloseした状態でないとちゃんとファイルに出力されていない為。flushすればいいかもしれないが、ファイルポインタ解放の為にもcloseすべき。そして、S3アップの前に一時ディレクトリをcloseしてしまうと消えてしまう可能性があ。という事で、結果的に２重のtry句になった。

```python:lambda側ファイル関係処理部分（python）
# 宣言部分
import boto3
import os
import tempfile
import zipfile
# 中略
    tmpdir = tempfile.TemporaryDirectory()
    try:
        packed_full_name = os.path.join(tmpdir.name, packed_file_name)
        slip_full_name = os.path.join(tmpdir.name, slip_file_name)
        balance_full_name = os.path.join(tmpdir.name, balance_file_name)
        # 中略
        slip_file = open(slip_full_name, 'w')
        balance_file = open(balance_full_name, 'w')
        try:
            # 出力処理部分
        finally:
            slip_file.close()
            balance_file.close()

        # 出力ファイルをまとめてzip圧縮
        with zipfile.ZipFile(packed_full_name, 'w', compression=zipfile.ZIP_DEFLATED) as new_zip:
            new_zip.write(slip_full_name, arcname=slip_file_name)
            new_zip.write(balance_full_name, arcname=balance_file_name)

        # S3へアップロード。event['identityid'] がクエリパラメータで渡されたCognitoのidentityid
        s3 = boto3.resource('s3')
        bucket = s3.Bucket('myapp-userdata')
        bucket.upload_file(packed_full_name, 'cognito/myhome-account/' + event['identityid'] + '/' + packed_file_name)
    finally:
        tmpdir.cleanup()
```

### 返却されたS3のurlを使ってダウンロード実行
出力先のS3バケットが、publicならそのurlを直接使えるが、privateの場合、AccessDeniedが出てしまう。s3.getSignedUrl で一時ダウンロードURLを生成し、それを使わなければならない。生成出来たらあとはlocation.hrefに指定するだけ。

```javascript:クライアント側javascriptダウンロード部分
    download: function () {
      const cognitoUser = this.$cognito.userPool.getCurrentUser()
      var that = this
      cognitoUser.getSession((err, session) => {
        if (!err && session.isValid()) {
          const itoken = session.getIdToken().getJwtToken()
          // Initialize the Amazon Cognito credentials provider
          AWS.config.region = awsconfig.Region
          AWS.config.credentials = new AWS.CognitoIdentityCredentials({
            IdentityPoolId: awsconfig.IdentityPoolId,
            Logins: {
              [PROVIDER_KEY]: itoken
            }
          })
          var identityId = AWS.config.credentials.identityId

          //・・中略・・

          that.$axios.get(that.apienv.baseendpoint + 'download?' + prmstr, config).then(
            response => {
              var s3 = new AWS.S3({
                params: { Bucket: S3_USERBACKETNAME }
              })
              var getUrlparams = {
                // バケット名
                Bucket: S3_USERBACKETNAME,
                // S3に格納済みのファイル名（ファイル名がサーバー側から返ってくる）
                Key: 'cognito/myhome-account/' + identityId + '/' + response.data,
                // 期限(秒数)
                Expires: 900
              }
              // URL発行
              s3.getSignedUrl('getObject', getUrlparams, that.execdownload)
            }
          ).catch(err => {
            that.$message({message: err, type: 'error'})
          })
        }
      })
    },
    execdownload: function (dummy, url) {
      location.href = url
    },
```

# 学んだ事
 - ファイル出力を確定する前に、そのファイルに対して別の処理してはいけない。
 - jwtから取得できる情報にはidentityId（s3などのユーザー毎キーになる）は含まれてない（？）
 - オープンでないS3バケットに上げたものは、getObjectで直接取得できない。
 - s3.getSignedUrlで一時URLを発行し、location.href すべし。


[実際のソース（lambda側）](https://github.com/silverbox/homeaccounting/blob/4-2-file-download/aws/lambda/myhome-account-get-download/myhome-account-get-download.py#L7)

# 参考にさせてもらったページ

[AWS Lambda の制限](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/limits.html)
[Lambda + API Gateway入門。CSVやCORS](https://xp-cloud.jp/blog/2018/07/13/3682/)
[API Gateway + Lambdaでバイナリダウンロード](http://blog.serverworks.co.jp/tech/2017/07/03/post-57760/)
[DynamoDBからデータをCSVにエクスポートする方法4つ](https://alliance7.blogspot.com/2017/05/dynamodbcsv4.html)
[【備忘録・まとめ】AWS Lambda 開発者ガイド](https://qiita.com/yhsmt/items/0605d2f7c259267f01b5)
[【JavaScript入門】ファイルダウンロード処理を実装する方法とは？](https://www.sejuku.net/blog/63078)
[Amazon Cognitoの認証情報を取得してみる～API Gateway＋Lambda編～](https://www.tdi.co.jp/miso/amazon-cognito-api-gateway)
[【vue.js】AWS-S3へ簡単にファイルをアップロードする方法](https://ma-vericks.com/vue-aws-s3/)