Cloud Run FunctionsでExcelファイルを出力する

## はじめに

弊社ではクラウドインフラとして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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2598043/8bdd674d-b506-43ea-a5e9-79aa1ac64111.png)

※2：HTTPリクエストの主な構成要素
- HTTPメソッド
    - OPTIONS / POST
- HTTPリクエストボディ
    - application/json
        - target
            - 性別
            - 年代
        - simulation
            - 合計
                - 予算やクリック数など
        - media
            - media名（Metaなど）
                - menu名（facebookなど）
                    - 予算やクリック数など

<details>
<summary>HTTPリクエストボディ(サンプル)</summary>

```json
{
    "target":{
        "gender":"男性",
        "ages":["20代","30代"]
    },"simulation":{
        "all":{
            "budget":1000000
        }
    },"media":{
        "Meta広告":{
            "Facebook":{
                "budget":333333
            }
        },"Google広告":{
            "GDN":{
                "budget":333333
            }
        }
    }
}
```
</details>

:::note info
OPTIONSメソッドとは、以下の条件を持たす通信で発生するプリフライトリクエストです。
本プログラムでは、以下の2の項目を扱っているため、OPTIONSメソッドが送信されます。<br>
1. メソッドが GET / HEAD / POST 以外<br>
2. POST であっても Content-Type が application/json<br>
3. カスタムヘッダーがある（Authorization など）<br>

プリフライトリクエストについては[こちら](https://developer.mozilla.org/ja/docs/Web/HTTP/Guides/CORS#%E3%83%97%E3%83%AA%E3%83%95%E3%83%A9%E3%82%A4%E3%83%88%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88)を参照ください。
:::

※3：simulation.xlsx（サンプル）
![simulation_response.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2598043/cd3110e4-94a8-4dc4-b0df-bafd7155969b.png)

## 使用環境・バージョン等
|dev                 |version|
|--------------------|-------|
|Python              |3.11   |
|Cloud Run Functions |第2世代 |
|Flask               |2.2.5  |
|XlsxWriter          |3.2.2  |

- Cloud Run Functions
    - Googleが提供するサーバーレスのコンピューティングサービス
    - HTTPトリガーで構築
    :::note warn
    本記事ではCloud Run Functionsは[未認証の呼び出しを許可する]設定としています。必要に応じて認証は設定ください。
    [参考](https://cloud.google.com/run/docs/authenticating/public?hl=ja)
    
- Flask
    - 小規模向けのWebアプリケーションフレームワーク
    - Cloud Run Functionsで関数をデプロイする際、自動的にインストールされる
    :::note warn
    Flaskのバージョンはrequirements.txtで明示的にしているわけではなく、[こちら](https://cloud.google.com/run/docs/runtimes/python-dependencies?hl=ja#pre-installed_packages)を参照しています。
    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のエントリーポイントとなります。
```python: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

プログラム全体で使用する定数をまとめたファイルです。
```python: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ファイルデータ
```python: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

使用モジュールをまとめたファイルです。
```text
XlsxWriter==3.2.2
```

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

今回追加したヘッダーは以下の2つですが、必要に応じて付け足してください。
- Access-Control-Allow-Origin：リクエスト元のオリジン名
- Access-Control-Allow-Headers：リクエスト元のヘッダー

```python: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))
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~略~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
```

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

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

[参考](https://developer.mozilla.org/ja/docs/Web/HTTP/Reference/Headers)
[プリフライトリクエスト](https://developer.mozilla.org/ja/docs/Web/HTTP/Guides/CORS#%E3%83%97%E3%83%AA%E3%83%95%E3%83%A9%E3%82%A4%E3%83%88%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88)
</details>

### POSTメソッドへの対応処理
リクエストに含まれるJSONデータを確認した後、make_excel_sheet.pyのmake_excel_sheet関数に渡します。

```python: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変数）を作成します。
```python:make_excel_sheet.py
    # メモリ上のバッファ
    output = io.BytesIO()
```
xlsxwriterモジュール（xw）を用いて、Excelファイルとシートを作成します。
その後、データが出力される列の列幅（列B~K）を15に変更しています。

```python: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を「.」で区切り、繋ぎ合わせたものとなっています。

<details>
<summary>get_filtered_values関数</summary>

```python: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
```

- 入力値（サンプル）
```json
{
    "target":{
        "gender":"男性",
        "ages":["20代","30代"]
    },"simulation":{
        "all":{
            "budget":1000000
        }
    },"media":{
        "Meta広告":{
            "Facebook":{
                "budget":333333
            }
        },"Google広告":{
            "GDN":{
                "budget":333333
            }
        }
    }
}
```
    
- 出力例（サンプル）
```python
{
    'target.gender': '男性', 
    'target.ages': ['20代', '30代'], 
    'simulation.all.budget': 1000000, 
    'media.Meta広告.Facebook.budget': 333333, 
    'media.Google広告.GDN.budget': 333333, 
}
```
</details>

```python: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`に返します。
```python
    # 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アプリに送信します。

```python: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を開発したい際など、ぜひお試しください！