なぜか一部の読者に大人気の(?)Lambda 解説コーナー。
なんだかんだで6話目に突入しました。バックナンバーはこちらです。
lambdaのブログ読ませていただきました!
— RUI@mtg (@mtg_rui) November 18, 2021
めちゃくちゃわかりやすくて感動しました、、
ナイスな導入
今日は、S3 も DynamoDB も SQS も使いません。 その代わり、少しハイレベルなことをやります。
通常、Lambda で作る Web API といえば、送受信ともに JSON データを利用する仕様が一般的です。
しかし、実際の Web アプリケーションとなると、画像や PDF(帳票)などバイナリ形式のファイルを動的に生成してダウンロードできる仕掛けをよく目にします。
これを Lambda でも実現しよう! というのが本日のテーマです。
動機付け
たとえば、もしも Lambda で Excel ファイルを生成・加工してクライアントに返すことができれば、
- 複数のオープンデータを自動巡回してさまざまなデータを集計可能な形式でまとめて落とせたり、
- 溜まりに溜まった Redmine の未着手チケットをいい感じに分類したレポートを自動生成したり、
アイディア次第で便利なマイクロサービスが作れそうです。
──あまり前置きが長くなるのもなんなので、さっそく本題に入りましょう。
■ 本日の内容
前提
sam init
コマンドで、Hello World プロジェクトを新規作成した直後から始めます。 手順などは2話目をご参照ください。
OpenPyXL ライブラリの追加
この節では、下図の赤枠のついたファイル (requirements.txt) を編集します。
初めに、Excel を読み書きするためのライブラリをプロジェクトに追加します。 Python から Excel ファイルを扱うためのライブラリは沢山ありますが、多機能で扱いやすい OpenPyXL というライブラリを使うことにしましょう。
プロジェクトにライブラリを追加するのは簡単です。
requirements.txt を開き、内容を以下のように修正するだけ。 ホントこれだけです。
■ requirements.txt
openpyxl
これで、Lambda 関数から OpenPyXL が使えるようになりました。
Excel ファイルをダウンロードさせる Lambda 関数
この節では、下図の赤枠のついたファイル (app.py) を編集します。
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点です。
第1に、レスポンスヘッダの Content-Type
に application/octet-stream
を設定すること。
第2に、レスポンスボディには Base64 エンコードしたバイナリデータを設定すること。
第3に、isBase64Encoded
に True
を設定すること。
具体的には、以下のようなコードになるでしょう。
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)を編集します。
具体的には、API Gateway に対してバイナリサポートの有効化を行う必要があります。
準備運動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」と表示されます。
もし、「RuntimeError: Container does not exist. Cannot get logs for this container
」というエラーが発生してビルドに失敗した場合は、Cloud9 にアタッチされている EBS の空き容量不足が疑われます。
以下のページを参考に、空き容量を増やして再度ビルドしてみましょう。
おまけ: ダウンロードリンク
おまけとして、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>
本日の演習は以上です。 お疲れ様でした。