AWS Lambdaで遊ぼう(第6話: Excelをダウンロード)

なぜか一部の読者に大人気の(?)Lambda 解説コーナー。

なんだかんだで6話目に突入しました。バックナンバーはこちらです。


ナイスな導入

AWS Lambda で作った REST API って、JSON のようなテキストデータしか返せないの?

いや、そんなことはないよ。

今日は、Excel ファイルのダウンロード API を Lambda で作ってみよう。


今日は、S3 も DynamoDB も SQS も使いません。 その代わり、少しハイレベルなことをやります。

通常、Lambda で作る Web API といえば、送受信ともに JSON データを利用する仕様が一般的です。

しかし、実際の Web アプリケーションとなると、画像や PDF(帳票)などバイナリ形式のファイルを動的に生成してダウンロードできる仕掛けをよく目にします。

これを Lambda でも実現しよう! というのが本日のテーマです。

動機付け

たとえば、もしも Lambda で Excel ファイルを生成・加工してクライアントに返すことができれば、

  • 複数のオープンデータを自動巡回してさまざまなデータを集計可能な形式でまとめて落とせたり、
  • 溜まりに溜まった Redmine の未着手チケットをいい感じに分類したレポートを自動生成したり、

アイディア次第で便利なマイクロサービスが作れそうです。

──あまり前置きが長くなるのもなんなので、さっそく本題に入りましょう。

■ 本日の内容

前提

sam init コマンドで、Hello World プロジェクトを新規作成した直後から始めます。 手順などは2話目をご参照ください。

tercel-tech.hatenablog.com

この時点で、以下のようなディレクトリ構成になっていると思います*1
f:id:tercel_s:20220202233008p:plain:w250

AWS SAM CLI のバージョンと Python ランタイムは以下のとおりです。

OpenPyXL ライブラリの追加

この節では、下図の赤枠のついたファイル (requirements.txt) を編集します。
f:id:tercel_s:20220202222113p:plain:w250

初めに、Excel を読み書きするためのライブラリをプロジェクトに追加します。 Python から Excel ファイルを扱うためのライブラリは沢山ありますが、多機能で扱いやすい OpenPyXL というライブラリを使うことにしましょう。

プロジェクトにライブラリを追加するのは簡単です。

requirements.txt を開き、内容を以下のように修正するだけ。 ホントこれだけです。

■ requirements.txt

openpyxl

これで、Lambda 関数から OpenPyXL が使えるようになりました。

Excel ファイルをダウンロードさせる Lambda 関数

この節では、下図の赤枠のついたファイル (app.py) を編集します。
f:id:tercel_s:20220202222652p:plain:w250

Excel ファイルを、その場で動的に生成して呼び出し元に返す Lambda を書いてみましょう。

準備運動1: Excel ファイルを動的に生成するコード

まずは準備運動として、OpenPyXL に少しだけ慣れておくことにします。

以下は、Excel ブックを新規作成し、1シート目の A1 セルに「42」という数字*2を書き込んで、sample.xlsxという名前で保存する Python のプログラムです。

from openpyxl import Workbook

# Excelブックの新規作成
wb = Workbook()
ws = wb.active
    
# A1セルに数字を書き込む
ws['A1'] = 42

# Excelブックを保存
wb.save('sample.xlsx')
wb.close()

コードの内容は、実に手続き的で解りやすいと思います。

工夫すれば、動的にデータを書き込むことも造作ないでしょう。

準備運動2: バイナリデータの応答電文の生成

では、生成した Excel ファイルをバイナリデータとして応答するにはどうすればよいでしょうか。

以下のページによると、バイナリデータの応答電文を生成するポイントは、大きく3点です。

docs.aws.amazon.com

第1に、レスポンスヘッダの Contentコンテント-Typeタイプapplicationアプリケーション/octetオクテット-streamストリーム を設定すること。

第2に、レスポンスボディには Base64 エンコードしたバイナリデータを設定すること。

第3に、isBase64EncodedTrue を設定すること。

具体的には、以下のようなコードになるでしょう。

import base64
# (略)

# Excelファイルのバイナリデータを取得
with open('sample.xlsx', 'rb') as excel:
    # データをBase64エンコードしてクライアントに返却
    excel_data = base64.b64encode(excel.read())
    return {
        'statusCode': 200,
        'headers': {
            'Content-Type': 'application/octet-stream'
        },
        'body': excel_data.decode('utf-8'),
        'isBase64Encoded': True
    }
準備運動3: ファイルI/Oの削減

前節では、バイナリデータを応答するコードを書きました。 Excel 以外のバイナリファイルにも同じ手法が使えるので、汎用性の高いやり方だといえます。

しかしその反面、冗長なファイルI/Oが発生するという欠点もあります。 基本的にファイルの入出力は、メモリのそれと較べてパフォーマンスが落ちますので、できることならば改善したいものです。

OpenPyXL には、上記の一連の処理をすべてメモリだけで完結させる便利なメソッド openpyxl.writer.excel.saveセーブ_virtualバーチャル_workbookワークブックがあるので、これを使って先ほどのコードを書き換えてみましょう。

■ Before (app.py)

import base64
# (略)

wb.save('sample.xlsx')
wb.close()
        
# Excelファイルのバイナリデータを取得
with open('sample.xlsx', 'rb') as excel:
    excel_data = base64.b64encode(excel.read())
    return {
        'statusCode': 200,
        'headers': {
            'Content-Type': 'application/octet-stream'
        },
        'body': excel_data.decode('utf-8'),
        'isBase64Encoded': True
    }


■ After (app.py)

import base64
from openpyxl.writer.excel import save_virtual_workbook
# (略)

# Excelファイルのバイナリデータを取得
excel_data = save_virtual_workbook(wb)
        
# データをBase64エンコードしてクライアントに返却
return {
    'statusCode': 200,
    'headers': {
        'Content-Type': 'application/octet-stream'
    },
    'body': base64.b64encode(excel_data).decode('utf-8'),
    'isBase64Encoded': True
}

ファイルの保存や再オープンが無く、コードの階層も一段階浅くなっています。

Lambda の実装

これで準備が整いました。

Excel ブックを新規作成し、A1 セルに数値「42」を書き込み、それをクライアントに返却する Lambda 関数を以下に示します。

■ app.py

import base64
from openpyxl import Workbook
from openpyxl.writer.excel import save_virtual_workbook

# Excelのファイル名
FILE_NAME = 'sample.xlsx'

def lambda_handler(event, context):
    # Excelブックの新規作成
    wb = Workbook()
    ws = wb.active
    
    # A1セルに数字を書き込む
    ws['A1'] = 42
    
    # Excelファイルのバイナリデータを取得
    excel_data = save_virtual_workbook(wb)
        
    # データをBase64エンコードしてクライアントに返却
    return {
        'statusCode': 200,
        'headers': {
            'Content-Type': 'application/octet-stream',
            'Content-Disposition': f'attachment; filename={FILE_NAME}'
        },
        'body': base64.b64encode(excel_data).decode('utf-8'),
        'isBase64Encoded': True
    }

応答ヘッダにContentコンテント-Dispositionディスポジションを設定していますが、これを設定すると、ダウンロード時のファイル名をゆるく指定することができます。 必須ではありませんが、あると親切なので設定しています。

SAMテンプレートの編集

最後に、SAM テンプレート(template.yaml)を編集します。
f:id:tercel_s:20220202223905p:plain:w250

具体的には、API Gateway に対してバイナリサポートの有効化を行う必要があります。

docs.aws.amazon.com

準備運動1: バイナリサポートの有効化

通常は以下のように、イベントソースに Api を指定すると、暗黙的に API Gateway リソースが生成されるのですが、残念ながら自動生成された API Gateway は簡略版で、バイナリサポートの有効化設定ができません。

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.9
      Events:
        HelloWorld:
          Type: Api # イベントソースに Api を指定すると、API Gatewayリソースが暗黙的に生成される
          Properties:
            Path: /hello
            Method: get

そこでバイナリサポートの有効化を設定するには、以下のように API Gateway リソース (Type: AWS::Serverless::Api) をテンプレートに明示的に追加する必要があります。

Resources:
  # ★追加ここから★
  HelloWorldApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: Prod
      BinaryMediaTypes:
        - '*/*'
  # ★追加ここまで★

StageNameステージネームは必須項目ですので、何でもよいのでとにかく設定しましょう。

ここではさしあたり、Prod (Production) を設定しておきます。

本題はBinaryMediaTypesバイナリメディアタイプスです。 ここに、*/* をシングルクォート ' で囲ったものを指定します。

API Gateway は、HTTP リクエストの Accept ヘッダが BinaryMediaTypes に指定したものに合致する場合のみ、応答をバイナリで返却します。*/*ワイルドカード指定した場合は、無条件でバイナリデータを返すようになります。

準備運動2: Lambda と API Gateway の紐付け

今ほど作った API Gateway を、Lambda と紐付けます。

以下のように、RestApiIdレストエーピーアイアイディーに先ほど作成した API Gateway (HelloWorldApi) を指定するだけです。

Resources:
  HelloWorldApi:
    Type: AWS::Serverless::Api
  # (略)

  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.9
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get
            RestApiId: !Ref HelloWorldApi # ★追加★

SAM テンプレートの予備知識は以上となります。

SAM テンプレートの修正

ここまでを踏まえて、template.yaml を以下のように修正しましょう。

■ template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sam-excel

  Sample SAM Template for sam-excel

Globals:
  Function:
    Timeout: 3

Resources:
  # ★追加ここから★
  HelloWorldApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: Prod
      BinaryMediaTypes:
        - '*/*'
  # ★追加ここまで★

  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get
            RestApiId: !Ref HelloWorldApi # ★追加★

Outputs:
  HelloWorldApi:
    Description: "API Gateway endpoint URL for Prod stage for Hello World function"
#   Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
    Value: !Sub "https://${HelloWorldApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/" # ★追加★

ビルド・デプロイと動作確認

以下のコマンドで SAM プロジェクトをビルドします。

ビルド
$sam build --use-container

ビルドに成功すると、ターミナルに 「Build Succeeded」と表示されます。

f:id:tercel_s:20220202215922p:plain

もし、「RuntimeError: Container does not exist. Cannot get logs for this container」というエラーが発生してビルドに失敗した場合は、Cloud9 にアタッチされている EBS の空き容量不足が疑われます。

以下のページを参考に、空き容量を増やして再度ビルドしてみましょう。

docs.aws.amazon.com

デプロイ

続いてデプロイです。

$sam deploy --guided

うまくいくと、ターミナルにエンドポイント URL が表示されます。

f:id:tercel_s:20220202220320p:plain

ブラウザで https://██████.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/ にアクセスすると、Excel ファイルが落ちてくるでしょう。

うまく開けたら成功です。おめでとうございます。

おまけ: ダウンロードリンク

おまけとして、Excel のダウンロードリンクを HTML で作ってみましょう。

何の装飾もないボタンが1つあるだけのページですが、押すと Excel が落ちてきます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <title>Download Sample</title>
</head>
<body>
  <a href="https://██████.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/" download>
    <button>ダウンロード</button>
  </a>
</body>
</html>

本日の演習は以上です。 お疲れ様でした。

まとめ

  • バイナリを応答する REST API を、Lambda で作ることができる
  • バイナリを応答するには、Lambda 関数の戻り値が以下に従うようにする
    • 応答ヘッダの Content-Typeapplication/octet-stream を指定する
    • body には、Base64 エンコードしたバイナリデータを設定する
    • isBase64Encoded には、Trueを設定する
  • template.yaml では Type: AWS::Serverless::Api を明示的に記述する
    • Type: AWS::Serverless::Api リソースはバイナリメディアサポートを有効化する
    • 通常は、BinaryMediaTypes'*/*' を設定すればよい

※ この話は、第7話に続きます。 ぜひ合わせてお読みください。

tercel-tech.hatenablog.com

*1:events や tests ディレクトリは畳まれていますが、本記事では触らないので中身はどうなっていてもよいです。また、samconfig.toml はこの時点で存在しなくても問題ありません。

*2:生命、宇宙、そして万物についての究極の疑問の答え。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.