pythonでGCSに置いた文字列をブラウザで見ると文字化けする

# tl;dr

- GCSはContent-Typeを指定できる
- Chromeは`text/plain`をShift-JISで表示しようとする
- `text/plain; charset=utf-8` が親切

# 現象

次のようなスクリプトでGoogle Cloud Storageにオブジェクトを作成します。
これは[googleapis/python-storageのExample Usage](https://github.com/googleapis/python-storage)のコードを、日本語を含む文字列を保存するよう変更したものです。
bucketの作成とか認証とかはここでは関係ないので飛ばします。

```python
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": "日本語"}')
```

スクリプト実行後、オブジェクトが作成できたかブラウザで確認してみたいと思います。
<img width="348" alt="スクリーンショット 2020-04-25 1.05.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266939/d42d514e-b8d5-b40f-5ccc-cd5ba0364652.png">

オブジェクトは無事作成されたようです。中身も見てみましょう。
<img width="588" alt="スクリーンショット 2020-04-25 2.23.36.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266939/a0de53ab-aafd-ad96-836a-f0e99cf35f61.png">

Google Cloud Storageは一時的なリンクを生成する機能があり、ブラウザからリンクを踏むとオブジェクトがダウンロードできます。

<img width="167" alt="スクリーンショット 2020-04-25 1.07.30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266939/86e26061-0e6a-a674-fa9c-53499efe35ca.png">

オブジェクトの中身がこのように文字化けしてしまいました。
これが今回の問題です。

```json:file.json
{"name": "譌･譛ｬ隱�"}
```

# 調査

## encode忘れ仮説

よくやるのが文字列のencode, decode忘れです。
`upload_from_string` にはstr型を渡していますが、UTF-8などにencodeしなくて良かったのかを調べます。

[コード](https://github.com/googleapis/python-storage/blob/69b2c45079ce2c86f7847fdc9611a9462ae28678/google/cloud/storage/blob.py#L1650)を見ると`upload_from_string`がやっていることは単純で

```python:抜粋
def upload_from_string(省略):
    data = _to_bytes(data, encoding="utf-8")
    string_buffer = BytesIO(data)
    self.upload_from_file(省略)
```

1. データをUTF-8のバイト列にする
2. 先のバイト列をファイルのように扱えるようにする
3. `upload_from_file`を呼ぶ

上記から、文字列のencodeは問題ないように見えます。

## Content-Type仮説

ところで、ブラウザでオブジェクトの情報を見ていると気になる部分を見つけました。

<img width="588" alt="スクリーンショット 2020-04-25 2.23.36.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266939/a0de53ab-aafd-ad96-836a-f0e99cf35f61.png">


`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`で返すサーバを立てます。

```python:server.py
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)
```

ブラウザで開きます

![スクリーンショット 2020-04-25 2.28.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266939/1c7b7fa2-a6b1-3280-8add-2ed5000d6b47.png)

再現しました。

ついでに、　`application/json` などにしてブラウザで開きます。

![スクリーンショット 2020-04-25 2.34.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266939/72b3e6d3-2168-e1d9-8d1a-527c5ad8de69.png)

`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](https://github.com/googleapis/python-storage/blob/master/google/cloud/storage/blob.py#L1650-L1660)が悪さをしていて、

```python:抜粋
    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のようです。

```console
> document.characterSet
"Shift_JIS"
```

Shift_JISで表示しようとしてる

## 問題の大きさ

ここまでで、次の2点が文字化けの原因であることがわかりました。
- GCSがオブジェクトを返すときのResponse Headerが`Content-Type: text/plain`となっている
- Chromeブラウザでは `Content-Type: text/plain` をShift_JISで表示しようとして化ける

ブラウザで見る場合には文字化けするのですが、次のようにプログラムで処理する場合は問題になりません。

```python
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使うときは気をつけましょう。