tl;dr
- GCSはContent-Typeを指定できる
- Chromeは
text/plain
をShift-JISで表示しようとする -
text/plain; charset=utf-8
が親切
現象
次のようなスクリプトでGoogle Cloud Storageにオブジェクトを作成します。
これはgoogleapis/python-storageのExample Usageのコードを、日本語を含む文字列を保存するよう変更したものです。
bucketの作成とか認証とかはここでは関係ないので飛ばします。
from google.cloud import storage
client = storage.Client()
bucket = client.get_bucket('bucket-id-here')
blob = bucket.get_blob('remote/path/to/file.json')
blob.upload_from_string('{"name": "日本語"}')
スクリプト実行後、オブジェクトが作成できたかブラウザで確認してみたいと思います。
オブジェクトは無事作成されたようです。中身も見てみましょう。
Google Cloud Storageは一時的なリンクを生成する機能があり、ブラウザからリンクを踏むとオブジェクトがダウンロードできます。
オブジェクトの中身がこのように文字化けしてしまいました。
これが今回の問題です。
{"name": "譌・譛ャ隱�"}
調査
encode忘れ仮説
よくやるのが文字列のencode, decode忘れです。
upload_from_string
にはstr型を渡していますが、UTF-8などにencodeしなくて良かったのかを調べます。
コードを見るとupload_from_string
がやっていることは単純で
def upload_from_string(省略):
data = _to_bytes(data, encoding="utf-8")
string_buffer = BytesIO(data)
self.upload_from_file(省略)
- データをUTF-8のバイト列にする
- 先のバイト列をファイルのように扱えるようにする
-
upload_from_file
を呼ぶ
上記から、文字列のencodeは問題ないように見えます。
Content-Type仮説
ところで、ブラウザでオブジェクトの情報を見ていると気になる部分を見つけました。
type="text/plain"
GCSではオブジェクトにメタデータ与えられます。
オブジェクトが呼ばれたときのResponse HeaderをContent-Typeメタデータで指定することができるようです。
https://cloud.google.com/storage/docs/metadata#content-type
デフォルトではapplication/octet-stream
か application/x-www-form-urlencoded
になるはずですが、どうもこれが text/plain
になっているようです。これが原因でしょうか?
実験
文字化けの原因は Content-Type: text/plain
という仮説を立てたので、GCSと切り分けるために手元にサーバを立てて表示を確認してみます。
bottleでシンプルに文字列をContent-Type: text/plain
で返すサーバを立てます。
from bottle import Bottle, HTTPResponse
import os
app = Bottle()
@app.route('/')
def serve():
r = HTTPResponse(status=200, body='ほげ')
r.set_header('Content-Type', 'text/plain')
return r
if __name__ == '__main__':
port = os.environ['PORT'] if 'PORT' in os.environ else '3000'
app.run(host='0.0.0.0', port=port)
ブラウザで開きます
再現しました。
ついでに、 application/json
などにしてブラウザで開きます。
application/json
だと正しく表示されます。
上記よりGCSとは関係なく Content-Type: text/plain
が文字化けの原因と考えて良さそうです。
どうしてContent-Type: text/plainになっているのか
GCSのデフォルトではapplication/octet-stream
か application/x-www-form-urlencoded
になるはずの Content-Type
がどうして text/plain
になっていたのかという疑問が残ります。
これは調べるとuploadに利用したモジュール google.cloud.storage
のBlob.upload_from_stringが悪さをしていて、
def upload_from_string(
self,
data,
content_type="text/plain",
client=None,
predefined_acl=None,
if_generation_match=None,
if_generation_not_match=None,
if_metageneration_match=None,
if_metageneration_not_match=None,
):
content_typeのデフォルト引数が text/plain
になっているので、特に指定しなければContent-Type: text/plain
となるように実装されています。
ブラウザの挙動
昔は見る側が文字エンコーディングを変更できたようなのですが今はブラウザの自動推論onlyのようです。
> document.characterSet
"Shift_JIS"
Shift_JISで表示しようとしてる
問題の大きさ
ここまでで、次の2点が文字化けの原因であることがわかりました。
- GCSがオブジェクトを返すときのResponse Headerが
Content-Type: text/plain
となっている - Chromeブラウザでは
Content-Type: text/plain
をShift_JISで表示しようとして化ける
ブラウザで見る場合には文字化けするのですが、次のようにプログラムで処理する場合は問題になりません。
from google.cloud import storage
client = storage.Client()
bucket = client.get_bucket('bucket-id-here')
blob = bucket.get_blob('remote/path/to/file.txt')
print(blob.download_as_string())
私の場合保存したオブジェクトはどうせプログラムで読み出すので実は中身を手軽に見たいときにちょっと困る程度の問題でした。
静的ファイルホスティングとして利用している場合は問題になるかもしれない。
解決策
upload_from_stringを利用する場合、Content-typeを指定すると良いです。
jsonならapplication/json
にしても良いですし、textならtext/plain; charset=utf-8
のようにcharsetを指定してもChromeはutf-8で読んでくれます。
まとめ
最近めったに化けないので油断していました。
今回Lesson learnedはあまりなくて、Blob.upload_from_string使うときは気をつけましょう。