0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

弊社ではクラウドインフラとしてGoogle Cloudを使用する機会が多いです。その中でもCloud Run Functions(旧Cloud Functions)を使用することが多いのですが、先日WebアプリのフロントエンドからPOSTリクエストを送り、Cloud Run FunctionsでExcelファイル出力を実装したため今回はその内容を共有します。

機能のイメージ

以下のようなWebアプリ(※1)の「Excel出力」ボタンを押すと、画面上で設定したデータがHTTPリクエスト(※2)でCloud Run Functionsに送信され、「simulation.xlsx」(※3)がダウンロードされます。

なお、今回はデジタル広告のシミュレーションを想定して、予算やクリック数などをWebアプリ上でシミュレーション後、Excel出力するという前提で記事を書いております。

※1:Webアプリ(サンプル)
スクリーンショット 2025-05-01 18.08.00.png

※2:HTTPリクエストの主な構成要素

  • HTTPメソッド
    • OPTIONS / POST
  • HTTPリクエストボディ
    • application/json
      • target
        • 性別
        • 年代
      • simulation
        • 合計
          • 予算やクリック数など
      • media
        • media名(Metaなど)
          • menu名(facebookなど)
            • 予算やクリック数など
HTTPリクエストボディ(サンプル)
{
    "target":{
        "gender":"男性",
        "ages":["20代","30代"]
    },"simulation":{
        "all":{
            "budget":1000000
        }
    },"media":{
        "Meta広告":{
            "Facebook":{
                "budget":333333
            }
        },"Google広告":{
            "GDN":{
                "budget":333333
            }
        }
    }
}

OPTIONSメソッドとは、以下の条件を持たす通信で発生するプリフライトリクエストです。
本プログラムでは、以下の2の項目を扱っているため、OPTIONSメソッドが送信されます。

  1. メソッドが GET / HEAD / POST 以外
  2. POST であっても Content-Type が application/json
  3. カスタムヘッダーがある(Authorization など)

プリフライトリクエストについてはこちらを参照ください。

※3:simulation.xlsx(サンプル)
simulation_response.png

使用環境・バージョン等

dev version
Python 3.11
Cloud Run Functions 第2世代
Flask 2.2.5
XlsxWriter 3.2.2
  • Cloud Run Functions

    • Googleが提供するサーバーレスのコンピューティングサービス
    • HTTPトリガーで構築

    本記事ではCloud Run Functionsは[未認証の呼び出しを許可する]設定としています。必要に応じて認証は設定ください。
    参考

  • Flask

    • 小規模向けのWebアプリケーションフレームワーク
    • Cloud Run Functionsで関数をデプロイする際、自動的にインストールされる

    Flaskのバージョンはrequirements.txtで明示的にしているわけではなく、こちらを参照しています。
    2025年5月14日時点の情報ですので、今後変更される場合はございます。

  • XlsxWriter

    • Excelファイルへの書き込みをサポートするPythonモジュール

全体構造

本記事で作成したCloud Run Functionsのフォルダ構成は以下の通りになっています。

.
├── main.py
├── config.py
├── make_excel_sheet.py
└── requirements.txt

プログラム内容

main.py

メインの処理です。
main関数がCloud Run Functionsのエントリーポイントとなります。

main.py
from flask import make_response, jsonify, send_file
import make_excel_sheet as mes
import config as cf

# CORS対応用ヘッダ追加関数
def cors_response(response):
    response.headers["Access-Control-Allow-Origin"] = "*"
    response.headers["Access-Control-Allow-Headers"] = "Content-Type"
    return response

def main(request):
    # OPTIONSメソッドへの応答(CORSプリフライト)
    if request.method == "OPTIONS":
        return cors_response(make_response('', 204))

    try:
        json_data = request.get_json()
        if not json_data:
            return jsonify({"error": "No JSON received"}), 400

        # Excelファイルを生成し、responseで保持
        response = send_file(
            mes.make_excel_sheet(json_data),
            download_name=cf.FILE_NAME
        )

        return cors_response(response)

    except Exception as e:
        print("❌ エラー:", e)
        return cors_response(jsonify({"error": str(e)}), 500)

config.py

プログラム全体で使用する定数をまとめたファイルです。

config.py
FILE_NAME = 'simulation.xlsx'

START_COL = 1
ALL_COL = 5
START_ROW = 3

TARGET_KEY = 'target'
MEDIA_KEY = 'media'
SIMULATION_KEY = 'simulation'

TITLE = 'デジタル広告シミュレーション'
ALL = '合計'

MEDIA_COL = {
  'media': '媒体',
  'menu': 'メニュー',
}

EXCEL_COL = {
    '性別' : 'gender',
    '年代' : 'ages',
    '予算' : 'budget',
    'インプレッション' : 'impression',
    'CPM' : 'cpm',
    'クリック数' : 'click',
    'CTR' : 'ctr',
    'CPC' : 'cpc',
}
項目名 説明
FILE_NAME 作成するExcelファイルの名前
START_COL 先頭の列数
ALL_COL 合計値を入力する際の先頭の列数
START_ROW 先頭の行数
TARGET_KEY 性別と年代データの抽出用文字列
MEDIA_KEY 予算やインプレッションデータなどの抽出用文字列
SIMULATION_KEY 合計値データの抽出用文字列
TITLE タイトル用の文字列
ALL 合計値入力行用の文字列
MEDIA_COL カラム用の文字列(メディアとメニュー)
EXCEL_COL カラム用の文字列(シミュレーション算出結果)

make_excel_sheet.py

Excelファイルを作成するプログラムをまとめたファイルです。
make_excel_sheet関数がmain関数内で使用されます。引数と戻り値は以下の通りです。

  • 引数:リクエストボディから取得したデータ(JSON形式)
  • 戻り値:バイナリ形式のExcelファイルデータ
make_excel_sheet.py
import config as cf
import io
import xlsxwriter as xw

def make_excel_sheet(json_data):
    # メモリ上のバッファ
    output = io.BytesIO()

    # Excel作成と編集
    workbook = xw.Workbook(output)
    worksheet = workbook.add_worksheet()
    end_col = len(cf.MEDIA_COL) + len(cf.EXCEL_COL)
    worksheet.set_column(cf.START_COL, end_col, 15)  

    # タイトルとカラム追加
    row = cf.START_ROW
    column = cf.START_COL
    key_col = list(cf.EXCEL_COL.keys())
    add_excel = [(1, 1, cf.TITLE), (row, column, cf.MEDIA_COL['media']), (row, column + 1, cf.MEDIA_COL['menu'])]
    column += 2
    for num in range(len(key_col)):
        add_excel.append((row, column, key_col[num]))
        column += 1
    column = cf.START_COL

    # メディアデータ整理
    result_dict = {}
    for ad_num in list(cf.EXCEL_COL.values()):
        json_values = get_filtered_values(json_data, ad_num)
        for k, v in json_values:
            result_dict[k] = v

    # target
    target = {k: v for k, v in result_dict.items() if cf.TARGET_KEY in k}
    for key, value in target.items():
        if isinstance(value, list):
            list_pos = key
    target[list_pos]= ",".join(target[list_pos])

    # media
    for media_key in json_data[cf.MEDIA_KEY]:
        menus = json_data[cf.MEDIA_KEY][media_key]
        for menu_key in menus:
            prefix = f"{cf.MEDIA_KEY}.{media_key}.{menu_key}."
            menu = [v for full_key, v in result_dict.items() if full_key.startswith(prefix)]
            # Excel書き込み
            row += 1
            for value in [media_key] + [menu_key] + list(target.values()) + menu:
                add_excel.append((row, column, value))
                column += 1
            column = cf.START_COL

    # simulation
    row += 1
    add_excel.append((row, cf.START_COL, cf.ALL))
    all_col = cf.ALL_COL
    all_menu = {k: v for k, v in result_dict.items() if cf.SIMULATION_KEY in k}
    for value in all_menu.values():
        add_excel.append((row, all_col, value))
        all_col += 1

    # Excel出力
    for output_row, output_col, value in add_excel:
        worksheet.write(output_row, output_col, value)
    workbook.close()
    output.seek(0)
    return output

def get_filtered_values(d, keyword, parent_key=''):
    json_values = []
    for k, v in d.items():
        full_key = f"{parent_key}.{k}" if parent_key else k
        if isinstance(v, dict):
            json_values.extend(get_filtered_values(v, keyword, full_key))
        else:
            if k == keyword:
                json_values.append((full_key, v))
    
    return json_values

requirements.txt

使用モジュールをまとめたファイルです。

XlsxWriter==3.2.2

詳細解説・処理の流れ

OPTIONSメソッドへの対応処理

WebアプリとCloud Run Functionsが異なるドメインにあるため、クロスドメイン通信によるCORS認証エラーが発生します。これを防ぐため、OPTIONSメソッドへのレスポンスにCORS認証用ヘッダーを付与するcors_response 関数を実行しています。

今回追加したヘッダーは以下の2つですが、必要に応じて付け足してください。

  • Access-Control-Allow-Origin:リクエスト元のオリジン名
  • Access-Control-Allow-Headers:リクエスト元のヘッダー
main.py
def cors_response(response):
    response.headers["Access-Control-Allow-Origin"] = "*"
    response.headers["Access-Control-Allow-Headers"] = "Content-Type"
    return response

def main(request):
    # OPTIONSメソッドへの応答(CORSプリフライト)
    if request.method == "OPTIONS":
        return cors_response(make_response('', 204))
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

ワイルドカード「*」は、オリジンに対して制限が設けられていない状態なので、必要に応じて対象ドメインをしてしてください。

CORS CORSは、Cross Origin Resource Sharingという、異なるオリジンサーバへアクセスする際に発生するセキュリティ機能です。
信頼のないオリジンサーバへの接続には、認証情報を抜き取られて悪用される危険性があるため、設定したHTTPメソッド(今回は「POST」)よりも先にCORS認証用HTTPメソッド「OPTIONS」が送信されます。もしCORS認証用ヘッダーがレスポンスに含まれていない場合、接続が認められずに切断されます。

参考
プリフライトリクエスト

POSTメソッドへの対応処理

リクエストに含まれるJSONデータを確認した後、make_excel_sheet.pyのmake_excel_sheet関数に渡します。

main.py
def main(request):
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    try:
        json_data = request.get_json()
        if not json_data:
            return jsonify({"error": "No JSON received"}), 400

        # Excelファイルを生成し、responseで保持
        response = send_file(
            mes.make_excel_sheet(json_data), 
            download_name=cf.FILE_NAME
        )

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    except Exception as e:
        print("❌ エラー:", e)
        return cors_response(jsonify({"error": str(e)}), 500)

Excelファイル作成(make_excel_sheet.py)

メモリ(RAM)上にExcelファイルを記録するバッファ(output変数)を作成します。

make_excel_sheet.py
    # メモリ上のバッファ
    output = io.BytesIO()

xlsxwriterモジュール(xw)を用いて、Excelファイルとシートを作成します。
その後、データが出力される列の列幅(列B~K)を15に変更しています。

make_excel_sheet.py
    # Excel作成と編集
    workbook = xw.Workbook(output)
    worksheet = workbook.add_worksheet()
    end_col = len(cf.MEDIA_COL) + len(cf.EXCEL_COL)
    worksheet.set_column(cf.START_COL, end_col, 15)

add_excel変数は、Excelシートに記載する内容をまとめるためのリスト型変数です。
この変数にHTTPリクエストで受け取った情報をもとに整理したデータを入れていく処理を以下で行っています。

なお、処理の途中で使用しているget_filtered_values関数は、json_data変数の階層型構造をkeyとvalueが対になる構造として整える関数です。 再帰的に関数を呼び出すことで、valueがある階層までのパスを記録可能としました。 出力例のkeyは、valueに辿り着くまでに経由した入力値のkeyを「.」で区切り、繋ぎ合わせたものとなっています。

get_filtered_values関数
make_excel_sheet.py
def get_filtered_values(d, keyword, parent_key=''):
    json_values = []
    for k, v in d.items():
        full_key = f"{parent_key}.{k}" if parent_key else k
        if isinstance(v, dict):
            json_values.extend(get_filtered_values(v, keyword, full_key))
        else:
            if k == keyword:
                json_values.append((full_key, v))
    
    return json_values
  • 入力値(サンプル)
{
    "target":{
        "gender":"男性",
        "ages":["20代","30代"]
    },"simulation":{
        "all":{
            "budget":1000000
        }
    },"media":{
        "Meta広告":{
            "Facebook":{
                "budget":333333
            }
        },"Google広告":{
            "GDN":{
                "budget":333333
            }
        }
    }
}
  • 出力例(サンプル)
{
    'target.gender': '男性', 
    'target.ages': ['20代', '30代'], 
    'simulation.all.budget': 1000000, 
    'media.Meta広告.Facebook.budget': 333333, 
    'media.Google広告.GDN.budget': 333333, 
}
make_excel_sheet.py
    # タイトルとカラム追加
    row = cf.START_ROW
    column = cf.START_COL
    key_col = list(cf.EXCEL_COL.keys())
    add_excel = [(1, 1, cf.TITLE), (row, column, cf.MEDIA_COL['media']), (row, column + 1, cf.MEDIA_COL['menu'])]
    column += 2
    for num in range(len(key_col)):
        add_excel.append((row, column, key_col[num]))
        column += 1
    column = cf.START_COL

    # メディアデータ整理
    result_dict = {}
    for ad_num in list(cf.EXCEL_COL.values()):
        json_values = get_filtered_values(json_data, ad_num)
        for k, v in json_values:
            result_dict[k] = v

    # target
    target = {k: v for k, v in result_dict.items() if cf.TARGET_KEY in k}
    for key, value in target.items():
        if isinstance(value, list):
            list_pos = key
    target[list_pos]= ",".join(target[list_pos])
    
    # media
    for media_key in json_data[cf.MEDIA_KEY]:
        menus = json_data[cf.MEDIA_KEY][media_key]
        for menu_key in menus:
            prefix = f"{cf.MEDIA_KEY}.{media_key}.{menu_key}."
            menu = [v for full_key, v in result_dict.items() if full_key.startswith(prefix)]
            # Excel書き込み
            row += 1
            for value in [media_key] + [menu_key] + list(target.values()) + menu:
                add_excel.append((row, column, value))
                column += 1
            column = cf.START_COL
    
    # simulation
    row += 1
    add_excel.append((row, cf.START_COL, cf.ALL))
    all_col = cf.ALL_COL
    all_menu = {k: v for k, v in result_dict.items() if cf.SIMULATION_KEY in k}
    for value in all_menu.values():
        add_excel.append((row, all_col, value))
        all_col += 1

add_excel変数のデータをExcelシートに書き込み、output変数ごとmain.pyに返します。

    # Excel出力
    for output_row, output_col, value in add_excel:
        worksheet.write(output_row, output_col, value)
    workbook.close()
    output.seek(0)
    return output

Excelファイル送信

受け取ったExcelファイルとファイル名を含んだレスポンスにCORS認証用ヘッダーを付与し、Webアプリに送信します。

main.py
def cors_response(response):
    response.headers["Access-Control-Allow-Origin"] = "*"
    response.headers["Access-Control-Allow-Headers"] = "Content-Type"
    return response

def main(request):
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        # Excelファイルを生成し、responseで保持
        response = send_file(
            mes.make_excel_sheet(json_data), 
            download_name=cf.FILE_NAME
        )

        return cors_response(response)
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

まとめ

今回は、バッファを用いたCloud Run Functions上でのExcelファイル出力についてご紹介しました。そこまで多くのAPIを開発する必要のない場合は、今回のようにFaaSで開発することも1つの手段だと思います。Google Cloudを用いて簡易的なファイル作成APIを開発したい際など、ぜひお試しください!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?