Python3でCGIを書いたとき、画像データ(PNG)をレスポンスしたくてハマったので方法をまとめておきます。
HTTPレスポンス
HTTPではWebサーバーにデータをリクエストし、Webサーバーはリクエストされたデータ(HTMLファイルや画像など)をレスポンスとして返します。
以下はHTMLファイルを返すときのレスポンス例です(HTTPリクエスト、HTTPレスポンスとは)。
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リクエスト、レスポンスにはどんな情報が入っているのか)。
今回注目するべきはメッセージヘッダのContent-Type
です。レスポンスボディのメディアタイプを指定します。text/html
であればHTMLファイルであることを示します。画像ファイルをレスポンスする場合は、例えばPNGファイルならimage/png
になります。
画像のレスポンスとCGIプログラム
HTMLのレスポンスは普通に文字列で返しますが、バイナリデータである画像はバイナリでレスポンスすることになります(「HTTP」の仕組みをおさらいしよう, http/https始まりの画像のURLはリクエストしたら何が返ってくる?)。
以下にPython3でPNGファイルをレスポンスするCGIプログラムを示します。
#!/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で文字列を処理する際の心掛け)。
この際、printでの標準出力のバッファリングが意図しない働きを行い、printの出力がすぐに表示されずにbuffer.write
の出力よりも遅れる場合があります。そのため最後のprintのオプションflush
をTrue
に設定して、バッファの内容をすべて吐き出すことで問題を解決できます。
参考サイトではこのフラッシュ処理をsys.stdout.flush
を呼ぶことで対応しています。printはデフォルトで内部にsys.stdout
を用いているので、printを介したflush処理でもsys.stdout.flush
を直接呼んでもどちらでも良い筈です。
グラフ生成&レスポンス
先程は画像を一旦保存していましたが、そうする必要はないはずです。そこで、discord.pyでローカル画像レスポンスする記事を参考に、matplotlibを使って作ったグラフをそのままレスポンスする例を示します。
#!/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)。
matplotlibを非GUI環境で使用するときは、通常plt.savefig("graph.png")
のようにファイル名を指定してグラフ保存すると思います。このファイルを先の例のように読み出すことでPNGデータをレスポンスすることができますが、この例では中継地点を挟みませんでした。
今回はplt.savefig
にバイナリストリームを指定することで、ストリームにデータを保存してgetvalue
メソッドでデータ取得を行いました(matplotlibで高画質のfigure画像をnumpy arrayで手に入れる)。
ブラウザでこのCGIプログラムアクセスする等で確認するとこのようなグラフが表示されました。
おわりに
ヘッダにContent-Disposition: attachment
を指定すると、ファイルをWebページとして表示するのではなく、保存のダイアログからダウンロードさせることができるようになります。
print("Content-Disposition: attachment; filename=test.png")
それでは楽しいCGI生活を!