CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: (今のところ) Flask で Request#get_data(as_text=True) は使わない方が良い

今回は最近見つけた Flask (正確には、その中で使われている WSGI ツールキットの Werkzeug) のバグについて。 先にざっくりと概要を説明しておくと Flask の Request#get_data() の引数として as_text=True を渡したときの挙動に問題がある。 このメソッドは Content-Type に含まれる charset 指定にもとづいてマルチバイト文字をデコードできない。 デコードに使われる文字コードが UTF-8 に固定されてしまっているため、それ以外の文字コードを扱うことができない。

このエントリでは、上記の問題について詳しく見ていくことにする。

今回使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.5
BuildVersion:   15F34
$ python --version
Python 3.5.1
$ echo $LANG
ja_JP.UTF-8

下準備

まずは Flask をインストールしておく。

$ pip install Flask
$ pip list | grep -i flask
Flask (0.11.1)

Request#charset メンバについて

Flask (正確には Werkzeug) の Request オブジェクトには charset というメンバがある。 これは、おそらくは Content-Body をエンコードした文字コードを格納するためのものだろう。 ただし、「おそらく」と言ったように、実際にはそのようには動作しない。 このメンバの値は、今のところ UTF-8 に固定されてしまっているためだ。

動作を確認するために、次のようなサンプルコードを用意しよう。 このサンプルコードでは Request#charset の内容をレスポンスとして返す。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
   return request.charset

上記のサンプルコードを実行する。 Flask v0.11 からは Flask のテストサーバの推奨される起動方法が少し変わった。

$ export FLASK_APP=charset.py
$ flask run
 * Serving Flask app "charset"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

まずは Content-Type に charset の指定がないとき。 これは「utf-8」になる。 まあ、猫も杓子も UTF-8 の昨今、これは特に違和感のない挙動だと思う。

$ curl -X POST -H "Content-Type: text/plain" -d "こんにちは" http://localhost:5000
utf-8

次に UTF-8 以外の文字コードを扱ってみることにしよう。 今回は EUC-JP を使うことにして、それでエンコードされたテキストファイルを用意する。 先ほどのサンプルコードでは Content-Body の内容を読みこんだりはしないけど、一応ね。

$ cat << 'EOF' > greeting.txt
こんにちは
EOF
$ nkf -e greeting.txt > greeting.euc.txt
$ nkf --guess greeting.euc.txt
EUC-JP (LF)

ちなみに Mac OS X に nkf はデフォルトではインストールされていないので Homebrew でインストールしよう。

$ brew install nkf

次は Content-Type に charset をつけてリクエストする。 Content-Body も、それに合わせて EUC-JP でエンコードされたテキストファイルを使って送る。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
utf-8

Content-Type の charset で EUC-JP と指定しているんだけど utf-8 になってしまっている。

このように Flask (正確には Werkzeug) の Request#charset は、今のところ正しく動作しない。

MIMETYPE の文字コードは何処に格納されるのか

じゃあ Flask では Content-Type で指定された文字コードを正しく扱うことはできないのか?というと、そうではない。 実は Request#mimetype_params という辞書の中に入っている。

これもサンプルコードを用意して、動作を確認してみよう。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
   return str(request.mimetype_params.get('charset'))

そしてアプリケーションを起動する。

$ export FLASK_APP=mimetype.py
$ flask run
 * Serving Flask app "mimetype"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

先ほどと同じように EUC-JP を指定したリクエストを送ってみよう。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
EUC-JP

今度はちゃんと EUC-JP になっている!

ちなみに、この値にはデフォルト値が入るわけではない。 指定がないときは空になっている。

$ curl -X POST -H "Content-Type: text/plain" -d @greeting.euc.txt http://localhost:5000
None

Request#get_data(as_text=True) への副作用

先ほどの Request#charset が UTF-8 で固定される問題は、別のメソッドにも影響を与えている。 それが、今回のエントリのタイトルにもなっている Request#get_data() メソッドだ。 このメソッドはリクエストから Content-Body として送られたデータを取り出すためのメソッドになっている。

この Request#get_data() というメソッドには as_text という引数がある。 これは Content-Body をデコードした内容を受け取るためのオプションで、デフォルトでは False になっている。 つまり、デフォルトでは Request#get_data() を実行したとき得られるものはバイト列 (bytes) ということになる。 そして、この引数を True にすると、バイト列をデコードしたユニコード文字列 (Python3: str, Python2: unicode) になる。

問題は、この as_text オプションを True にしたとき使われる文字コードだ。 ここまで語ってきたように Request#charset は utf-8 に固定されてしまっている。 だから、このメンバにもとづいてデコードしているとアウトなんだけど、今 (v0.11.10) の Wekzeug は見事にそれをやってしまっている。

github.com

この挙動を確認するため、次のようなサンプルコードを用意しよう。 このサンプルコードでは Request#get_data(as_text=True) で取得した内容を、そのままレスポンスとして返す。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
    return request.get_data(as_text=True)

上記を実行する。

$ export FLASK_APP=getdata1.py
$ flask run
 * Serving Flask app "getdata1"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

先ほどと同じように EUC-JP を含むリクエストを送ってみよう。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
����ˤ���

文字化けしてしまった…。

何が起こったのか

これはつまり、以下のようなことが起こっている。

[EUC-JP 文字列] -> (UTF-8 デコード) -> [Python ユニコード文字列] -> (UTF-8 エンコード) -> [UTF-8 文字列 (文字化け)]

HTTP クライアントから送られてきた EUC-JP のバイト列を UTF-8 でデコードしてしまっているのが間違い。 結果的にめちゃくちゃなユニコード文字列が生成されて、それをエンコードしたところで文字化けしてしまう、という寸法だ。

じゃあ、どうすればいいのか

Wekzeug のバグが修正されるまではワークアラウドでしのぐしかない。 Werkzeug 任せにすると、リクエストに含まれるバイト列が UTF-8 固定でデコードされてしまうのが根本的な原因だ。 つまり、デコードを自分でやれば問題は起きなくなる。

次のサンプルコードを見てほしい。 このコードではリクエストからバイト列でデータを取り出した上で、それを自分でデコードしている。 デコードに使う文字コードは Request#mimetype_params に入っている値で、それがなければ UTF-8 を使うようにした。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
    data = request.get_data()
    charset = request.mimetype_params.get('charset') or 'UTF-8'
    return data.decode(charset, 'replace')

実行してみる。

$ export FLASK_APP=getdata2.py
$ flask run
 * Serving Flask app "getdata2"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

今度も同じように EUC-JP を含むリクエストを送ってみよう。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
こんにちは

今度は文字化けしていない!

今度は、次のようなことが起こっている。

[EUC-JP 文字列] -> (EUC-JP デコード) -> [Python ユニコード文字列] -> (UTF-8 エンコード) -> [UTF-8 文字列]

EUC-JP 文字列が正しい文字コードでデコードされて、本来のユニコード文字列になっている。 それを UTF-8 でエンコードしてレスポンスとして返した。 そして、ターミナルの文字コードも UTF-8 なので文字化けは起きない。

実は、このやり方は Flask の Request#get_json() を真似している。 このメソッドでも、同じように Request#mimetype_params に入っている charset にもとづいてデコードしているからだ。 つまり Request#get_json() はバグっていない。 github.com

最近は猫も杓子も JSON だし、UTF-8 以外の文字コードを使う機会も少ないから今回のバグを踏む人は少ないのかもしれない。 とはいえ、こういう問題があるので Flask のアプリケーションでマルチバイト文字を扱うときは注意しよう。

ちなみに

この不具合については Wekzeug にバグレポートした。 将来的には、いつか直るかもしれない。

Request#get_data(as_text=True) does not work with Content-Type/charset · Issue #947 · pallets/werkzeug · GitHub