ruby binary encoding

## 背景
| ライブラリ | バージョン |
|------------|------------|
| Rails | 7.1.1 |
| google-cloud-tasks | 3.0.0 |

[google\-cloud\-ruby/google\-cloud\-tasks at main · googleapis/google\-cloud\-ruby](https://github.com/googleapis/google-cloud-ruby/tree/main/google-cloud-tasks)

↑ライブラリーを使って、Google Cloud Taskを作成しようとする時に以下のエラーが発生した。

```ruby
def create_task(payload)
  parent = @queue_path

  task = {
    http_request: {
      http_method: 'POST',
      url: @function_url,
      headers: {
        'Content-Type' => 'application/json'
      },
      body: payload,
      oidc_token: {
        service_account_email: @service_account_email,
        audience: @function_url
      }
    }
  }

  @client.create_task(parent: parent, task: task)
rescue Google::Cloud::Error => e
  Rails.logger.error("Failed to create cloud task: #{e.message}")
  raise
end
```

## エラー
```rb
[241740fc-a33b-48d7-b58f-3f3a682f376f] Error in extract action: 
/usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:43:in `initialize': 
U+FF1A from UTF-8 to ASCII-8BIT (Encoding::UndefinedConversionError)
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:43:in `new'
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:43:in `coerce'
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:100:in `coerce_submessage'
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:62:in `block in coerce_submessages'
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:58:in `each'
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:58:in `coerce_submessages'
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:42:in `coerce'
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:100:in `coerce_submessage'
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:62:in `block in coerce_submessages'
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:58:in `each'
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:58:in `coerce_submessages'
	from /usr/local/bundle/gems/gapic-common-0.25.0/lib/gapic/protobuf.rb:42:in `coerce'
	from /usr/local/bundle/gems/google-cloud-tasks-v2-1.2.0/lib/google/cloud/tasks/v2/cloud_tasks/client.rb:1699:in `create_task'
	from /app/app/services/gcp/cloud_tasks_service.rb:51:in `create_task'
```

## 調査
Rubyの特徴（欠点）として、専用のbinary data typeが存在しません。Rubyの基本データタイプは以下です：

| データタイプ | 説明 |
|-------------|-----|
| Integer | 整数 |
| Float | 浮動小数点数 |
| String | 文字列 |
| Array | 配列 |
| Hash | ハッシュ |
| Symbol | シンボル |
| TrueClass, FalseClass | 真偽値 |
| NilClass | nil |

バイナリデータも文字列も同じStringクラスで表現されます。区別するために、encodingが`ASCII-8BIT`（別名`BINARY`）のStringはバイナリデータとして扱われます。そのため、文字列をバイナリとして扱いたい場合は、encodingを`ASCII-8BIT`に変更する必要があります。

## encodeとforce_encodingの違い

| メソッド | 動作 | 例 | 特徴 |
|---------|------|-----|------|
| `encode` | 文字列を別のエンコーディングに変換し、バイト表現が変更される | `"こんにちは".encode("EUC-JP")` | ・変換できない文字があるとエラー<br>・新しいStringオブジェクトを返す |
| `force_encoding` | バイト表現はそのままで、エンコーディングラベルだけを変更 | `"こんにちは".force_encoding("ASCII-8BIT")` | ・実際の変換は行われない<br>・不適切な指定で文字化けの可能性<br>・元のオブジェクトが変更される |

```rb:例
[1] pry(main)> h = {name: 'こんにちは！'}
=> {:name=>"こんにちは！"}
[2] pry(main)> JSON.dump(h).encoding
=> #<Encoding:UTF-8>
[3] pry(main)> JSON.dump(h).encode('UTF-8').class
=> String

# エンコーディングラベルを ASCII-8BIT に変更することで、バイナリデータとして出力されることを確認できます
[4] pry(main)> JSON.dump(h).force_encoding('ASCII-8BIT')
=> "{\"name\":\"\xE3\x81\x93\xE3\x82\x93\xE3\x81\xAB\xE3\x81\xA1\xE3\x81\xAF\xEF\xBC\x81\"}"

[5] pry(main)> JSON.dump(h).force_encoding('ASCII-8BIT').class
=> String

# エンコーディングラベルはASCII-8BITですが、中身は`UTF-8`のままです
[6] pry(main)> JSON.dump(h).force_encoding('ASCII-8BIT').encoding
=> #<Encoding:ASCII-8BIT>
```

Cloud TaskのHTTPリクエストのbodyはバイナリとして扱われるため、今回は変換ではなくラベル変更の`force_encoding`が適切な選択でした。

## 原因と解決策

### 原因
Cloud TaskのHTTP requestのbodyパラメータは、Google Protocol Buffersによってシリアライズされる際にバイナリデータ（ASCII-8BITエンコード）として扱われます。しかし、Rubyの`JSON.dump`や`to_json`メソッドの結果はUTF-8エンコードのままです。

今回のケースでは、JSONデータ内に日本語文字（U+FF1A、全角コロン「：」）が含まれており、Protocol Buffersライブラリが暗黙的にUTF-8からASCII-8BITへの変換（**つまり、encodeが呼び出されることになります**）を試みた際に`Encoding::UndefinedConversionError`が発生しました。これは、マルチバイト文字がASCII-8BITの範囲を超えるためです。

### 解決策
JSONシリアライズした後で、Protocol Buffersによる変換前に明示的にASCII-8BITエンコーディングに変更することで問題を解決しました：

```ruby
# ここでJSONをシリアライズして、ASCII-8BITエンコーディングに明示的に変更
body = JSON.dump(payload).force_encoding('ASCII-8BIT')
```

## 参照
- [Ruby: US\-ASCII\-8BITというエンコードを理解する（翻訳）｜TechRacho by BPS株式会社](https://techracho.bpsinc.jp/hachi8833/2018_07_02/58408)
- [String\#force\_encoding \(Ruby 3\.4 リファレンスマニュアル\)](https://docs.ruby-lang.org/ja/latest/method/String/i/force_encoding.html)
- [String\#encode \(Ruby 3\.4 リファレンスマニュアル\)](https://docs.ruby-lang.org/ja/latest/method/String/i/encode.html)