1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PythonAdvent Calendar 2023

Day 1

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

Last updated at Posted at 2023-11-30

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のオプションflushTrueに設定して、バッファの内容をすべて吐き出すことで問題を解決できます。
参考サイトではこのフラッシュ処理を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プログラムアクセスする等で確認するとこのようなグラフが表示されました。
image.png

おわりに

ヘッダにContent-Disposition: attachmentを指定すると、ファイルをWebページとして表示するのではなく、保存のダイアログからダウンロードさせることができるようになります。

print("Content-Disposition: attachment; filename=test.png")

それでは楽しいCGI生活を!

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?