Python3のCGIスクリプトでバイナリデータが出力したい

Python3でCGIを書いたとき、画像データ(PNG)をレスポンスしたくてハマったので方法をまとめておきます。

## HTTPレスポンス
HTTPではWebサーバーにデータをリクエストし、Webサーバーはリクエストされたデータ（HTMLファイルや画像など）をレスポンスとして返します。
以下はHTMLファイルを返すときのレスポンス例です（[HTTPリクエスト、HTTPレスポンスとは](https://qiita.com/minateru/items/8693538bbd0768855266)）。
```php
HTTP/1.1 200 OK
Date: Mon, 23 May 2022 22:38:34 GMT
Server: Apache/2.4.1 (Unix)
Last-Modified: Wed, 08 Jan 2022 23:11:55 GMT
Content-Type: text/html
Content-Length: 438

<html>
<head>
  <title>An Example Page</title>
</head>
<body>
  Hello World, this is an example page.
</body>
</html>
```
HTTPレスポンスは「開始行(ステータス行)+メッセージヘッダ」と「メッセージボディ」からなります（[HTTPリクエスト、レスポンスにはどんな情報が入っているのか](https://qiita.com/kazuho39/items/bcaa515279a85e100586)）。
今回注目するべきはメッセージヘッダの`Content-Type`です。レスポンスボディのメディアタイプを指定します。`text/html`であればHTMLファイルであることを示します。画像ファイルをレスポンスする場合は、例えばPNGファイルなら`image/png`になります。

## 画像のレスポンスとCGIプログラム
HTMLのレスポンスは普通に文字列で返しますが、バイナリデータである画像はバイナリでレスポンスすることになります（[「HTTP」の仕組みをおさらいしよう](https://atmarkit.itmedia.co.jp/ait/articles/1605/11/news030_2.html), [http/https始まりの画像のURLはリクエストしたら何が返ってくる？](https://tokizuoh.hatenablog.com/entry/2022/04/08/142609)）。
以下にPython3でPNGファイルをレスポンスするCGIプログラムを示します。

```python
#!/usr/bin/env python3
import sys

# PNGファイルを読み込む
with open('graph.png', 'rb') as f: # バイナリファイル[b]読み込み[r]
  png_data = f.read()

# CGIスクリプトとしてレスポンスを出力
# メッセージヘッダ
print('Content-Type: image/png')
# ヘッダとボディの間の空行
print("", flush=True) 
# メッセージボディ
sys.stdout.buffer.write(png_data)
```
`sys.stdout.buffer.write`を用いることでバイトデータを標準出力で使用できます（[Python3で文字列を処理する際の心掛け](https://qiita.com/FGtatsuro/items/f45c349e06d6df95839b)）。
この際、printでの標準出力のバッファリングが意図しない働きを行い、printの出力がすぐに表示されずに`buffer.write`の出力よりも遅れる場合があります。そのため最後のprintのオプション`flush`を`True`に設定して、バッファの内容をすべて吐き出すことで問題を解決できます。
[参考サイト](https://drafear.hateblo.jp/entry/2017/10/19/155347)ではこのフラッシュ処理を`sys.stdout.flush`を呼ぶことで対応しています。[printはデフォルトで内部に`sys.stdout`を用いている](https://docs.python.org/ja/3/library/functions.html#print)ので、printを介したflush処理でも`sys.stdout.flush`を直接呼んでもどちらでも良い筈です。

## グラフ生成&レスポンス
先程は画像を一旦保存していましたが、そうする必要はないはずです。そこで、[discord.pyでローカル画像レスポンスする記事](https://qiita.com/Yumax-panda/items/9349e06f5a12d4466ca7#%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E3%81%AE%E7%94%BB%E5%83%8F%E3%82%92%E5%9F%8B%E3%82%81%E8%BE%BC%E3%81%BFembed%E3%81%B8%E6%B7%BB%E4%BB%98%E3%81%99%E3%82%8B)を参考に、matplotlibを使って作ったグラフをそのままレスポンスする例を示します。
```python
#!/usr/bin/env python3
import sys, matplotlib, io
matplotlib.use('Agg') # 非GUI環境のため
import matplotlib.pyplot as plt # Agg指示より後でimportすべき

plt.plot([2,4,1,6,3,1,1,4,9]) # 適当なグラフ作成
plt.grid()

png_buf = io.BytesIO() # バイナリストリーム作成
plt.savefig(png_buf, format="png", dpi=180)
png_buf.seek(0) # ストリーム位置を先頭に戻す

# CGIスクリプトとしてレスポンスを出力
# メッセージヘッダ
print('Content-Type: image/png')
# ヘッダとボディの間の空行
print("", flush=True) 
# メッセージボディ
png_data = png_buf.getvalue() # バイトデータ取得
sys.stdout.buffer.write(png_data)
```
matplotlibをCGIプログラムで用いると不具合の原因になるので、`matplotlib.use('Agg')`でグラフ描画のバックエンドをAggに指定します（[サーバサイドにおけるmatplotlibによる作図Tips](https://qiita.com/TomokIshii/items/3a26ee4453f535a69e9e)）。
matplotlibを非GUI環境で使用するときは、通常`plt.savefig("graph.png")`のようにファイル名を指定してグラフ保存すると思います。このファイルを先の例のように読み出すことでPNGデータをレスポンスすることができますが、この例では中継地点を挟みませんでした。
今回は`plt.savefig`にバイナリストリームを指定することで、ストリームにデータを保存して`getvalue`メソッドでデータ取得を行いました（[matplotlibで高画質のfigure画像をnumpy arrayで手に入れる](https://jun-networks.hatenablog.com/entry/2019/11/01/020536)）。

ブラウザでこのCGIプログラムアクセスする等で確認するとこのようなグラフが表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1120826/aae8b49a-0c99-5734-c37e-76eade75d2a6.png)

## おわりに

ヘッダに`Content-Disposition: attachment`を指定すると、ファイルをWebページとして表示するのではなく、保存のダイアログからダウンロードさせることができるようになります。
```python
print("Content-Disposition: attachment; filename=test.png")
```
それでは楽しいCGI生活を！