フューチャー技術ブログ

Go 1.22 リリース連載 encoding, encoding/json

はじめに

CSIGの棚井です。

本ブログは、Go 1.22 Release Notes の内容を紹介する「フューチャー技術ブログ Go 1.22リリース連載」の4本目の記事です。

今回は encoding のアップデートを取り上げます。

  • encoding/base32,base64,hex
  • encoding/json

また、本ブログは release-branch.go1.22 での動作をベースとしています。

$ go version
go version go1.22rc2 linux/amd64

encoding/base32, base64, hex

TL;DR(1)

  • encoding/base32, encoding/64, encoding/hex に、byte slice を利用したエンコード関数とデコード関数が追加されまし
    • エンコード関数: AppendEncode
    • デコード関数: AppendDecode
  • base32.Encoding と base64.Encoding の Padding に「マイナスの値」を指定すると、panic を起こすようになりました
    • 1.21 以前は「文字化け」していたので、今回の 1.22 で fix されました

アップデート内容(1)

Go 1.22 のリリースノート原文には、以下の説明があります。

The new methods AppendEncode and AppendDecode added to each of the Encoding types in the packages encoding/base32, encoding/base64, and encoding/hex simplify encoding and decoding from and to byte slices by taking care of byte slice buffer management.

該当する issue は「encoding: provide append-like variants #53693」です。issue には、以下のような説明があります。

ちなみに、今回追加された Append ライクなエンコード関数とデコード関数の追加は、こちらのコミット(encoding: add AppendEncode and AppendDecode)にて確認できます。

base32, base64, hex のそれぞれに同じ機能の関数が追加されているので、今回は base32 での処理内容をメインに、AppendEncodeAppendDecode を見ていきます。

// AppendEncode appends the base32 encoded src to dst
// and returns the extended buffer.
func (enc *Encoding) AppendEncode(dst, src []byte) []byte {
n := enc.EncodedLen(len(src))
dst = slices.Grow(dst, n)
enc.Encode(dst[len(dst):][:n], src)
return dst[:len(dst)+n]
}
// AppendDecode appends the base32 decoded src to dst
// and returns the extended buffer.
// If the input is malformed, it returns the partially decoded src and an error.
func (enc *Encoding) AppendDecode(dst, src []byte) ([]byte, error) {
n := enc.DecodedLen(len(src))
dst = slices.Grow(dst, n)
n, err := enc.Decode(dst[len(dst):][:n], src)
return dst[:len(dst)+n], err
}

base32 変換について

Go での base32 のエンコード・デコード処理は、もちろんソースコード内に実装されています。

私の場合、実装コードとテストコードを眺めるだけでは、いまいち処理内容がつかめなかったので、base32 のエンコード・デコード処理を「手計算」で実施してみました。

以下、「Hello」という文字列を、base32 の値にエンコードするまでの流れです。

①「Hello」を Ascii コードに変換する

まず、「Hello」の文字列を、1つずつ Asciiコード に変換します。

H e l l o
10進数 72 101 108 108 111
16進数 0x48 0x65 0x6c 0x6c 0x6f

② 8 ビットのバイナリに変換する

続いて、IP アドレスやサブネットマスクで見慣れた「2進数、バイナリ値」に変換します。

H e l l o
10進数 72 101 108 108 111
16進数 0x48 0x65 0x6c 0x6c 0x6f
binary 01001000 01100101 01101100 01101100 01101111

③ バイナリ値を連結する

②で変換したバイナリ値を連結すると、8 × 5 = 40 ビットのバイナリストリームができあがります。

H e l l o
binary 01001000 01100101 01101100 01101100 01101111

0100100001100101011011000110110001101111

④ バイナリストリームを 5ビットごとに区切る

0と1で連結された文字列を、5つごとに区切ります。

0100100001100101011011000110110001101111

01001, 00001, 10010, 10110, 11000, 11011, 00011, 01111

⑤ 5ビットの値ごとに、10進数へと変換する

2進数の値を10進数に変換します。

binary per 5 bits 01001 00001 10010 10110 11000 11011 00011 01111
10進数 9 1 18 22 24 27 3 31

⑥ 10進数を base32 変換する

10進数の値それぞれを、1つずつ base32 で変換(エンコード)します。

binary per 5 bits 01001 00001 10010 10110 11000 11011 00011 01111
10進数 9 1 18 22 24 27 3 31
base32 J B S W Y 3 D P

エンコードされたそれぞれの文字を結合してできる文字列が、最終的な base32変換された値となります。

# 元の文字列 -> base32エンコード後の文字列

Hello -> JBSWY3DP

検証用に、以下の Go コードを動かしてみると、base32 変換後の値が一致することも確認できます。

base32_encode.go
package main

import (
"encoding/base32"
"fmt"
"os"
)

func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: go run main.go [String to encode]")
return
}

src := []byte(os.Args[1])
enc := base32.StdEncoding.EncodeToString(src)
fmt.Println(enc)
}
$ go run base32_encode.go "Hello"
JBSWY3DP

また、base32は「5バイト(40ビット)」ごとに分割して、5バイトからの不足分は「=」によりパディングするルールがあります。

  • 1バイト不足 -> 「=」を1つパディング
  • 2バイト不足 -> 「=」を3つパディング
  • 3バイト不足 -> 「=」を4つパディング
  • 4バイト不足 -> 「=」を6つパディング
  • 不足なし -> パディングなし

例えば「Golang」の文字列であれば、

8bit × 6文字
= 48 bit
= 6 byte
= 5 byte + 1 byte
= 5 byte + (5 byte - 4 byte) ← 4バイト不足

なので、「=」は6つ追加されます。

$ go run base32_encode.go "Golang"
I5XWYYLOM4======

ちなみに、「Hello」の文字列はちょうど5バイトなので、パディングは発生しません。

AppendEncode, AppendDecode を動かしてみる

それでは、今回追加されたエンコード関数とデコード関数を動かしてみます。

append_encode_decode.go
package main

import (
"encoding/base32"
"fmt"
)

func main() {
src := []byte("Hello")
dst := []byte("")

enc := base32.StdEncoding

dst = enc.AppendEncode(dst, src)
fmt.Println("Encode: ", string(dst))

dec := []byte("")
dec, err := enc.AppendDecode(dec, dst)
if err != nil {
fmt.Println("Error: ", err)
}
fmt.Println("Decode: ", string(dec))
}

// Output
// Encode: JBSWY3DP
// Decode: Hello

提案元の issue に記載されているように「Append ライク」な動作ということなので、関数の呼び出し側でバッファを意識せずとも「Append」が可能です。

以下のサンプルコードは、こちらの「テストコード」をもとに作成しました。

append_encode.go
package main

import (
"encoding/base32"
"fmt"
)

func main() {
src := []byte("Hello")
dst := []byte("lead")

enc := base32.StdEncoding

dst = enc.AppendEncode(dst, src)
fmt.Println(string(dst))
}

// Output
// leadJBSWY3DP

パディング値に「negative value」を代入してみる

こちらのコミット(encoding: require unique alphabet for base32 and base64)で修正された「パディングの受け取る値」について、以前のバージョンでの挙動と比較しながら確認してみます。

base32 エンコードでは、5バイトごとに分割した際の「不足分」が、「=」によりパディングされるというルールがありました。
Go の実装では、パディングの値は「こちら」で定義されています。

const (
StdPadding rune = '=' // Standard padding character
NoPadding rune = -1 // No padding
)

パディングの値を「$」に指定すると、base32エンコードの結果が以下のようになります。

change_padding.go
package main

import (
"encoding/base32"
"fmt"
)

func main() {
src := []byte("Golang")

var padding rune = '$'
enc := base32.StdEncoding.WithPadding(padding).EncodeToString(src)

fmt.Println(string(enc))
}

// Output
// I5XWYYLOM4$$$$$$

リリースノートには以下の記載があります。

The methods base32.Encoding.WithPadding and base64.Encoding.WithPadding now panic if the padding argument is a negative value other than NoPadding.

試しに WithPadding-2 を代入して動かしてみます。

invalid_padding.go
package main

import (
"encoding/base32"
"fmt"
)

func main() {
src := []byte("Golang")

var padding rune = -2
enc := base32.StdEncoding.WithPadding(padding).EncodeToString(src)

fmt.Println(string(enc))
}
$ go build -trimpath -o invalid_padding invalid_padding.go
$ ./invalid_padding
panic: invalid padding

goroutine 1 [running]:
encoding/base32.Encoding.WithPadding(...)
encoding/base32/base32.go:103
main.main()
./with_padding.go:12 +0x2a

Go 1.22 で動かすと、リリースノートの記載通り panic が起きました。

比較検証として、Go 1.21 で動かしてみると、panic にはならず「パディングの値が文字化けして」表示されました。
このバグを踏むケースがあまりイメージできませんが、1.22 で回避されるようになりました。

$ go run invalid_padding.go
I5XWYYLOM4������

encoding/json

TL;DR(2)

  • これまでのマーシャリングとエンコードでは、'\b''\f' はそれぞれ、\u0008\u000c に変換されていました
  • Go 1.22 から、'\b'\b に、'\f'\f に変換されるようになりました
  • これにより、RFC 8259 で定義された5つの制御文字全てへの対応が完了しました

アップデート内容(2)

リリースノートには、1行だけ説明があります。

Marshaling and encoding functionality now escapes ‘\b’ and ‘\f’ characters as \b and \f instead of \u0008 and \u000c.

本アップデートは、こちらの「コミットログ(encoding/json: encode \b and \f as ‘\b’ and ‘\f’ in JSON strings)」に詳しい説明があります。

EFC 8259 には5つの制御文字があり、

する中で、残りの \b\f に対応したのが今回のアップデートのようです。

また、コミットログには以下の記載があります。

This change is to prepare the path forward for a potential v2 “json” package, which has more consistent encoding of JSON strings.

「v2 json package への準備」とのことなので、今回のリリースにて math/rand/v2 が追加されたこともり、今後のアップデートが楽しみだなと思いました。

おわりに

本ブログでは、encoding パッケージへの追加機能、bugfix 内容を紹介しました。
今回の連載記事の内容を調べるなかで「アップデートの背景を、issue を通して知る」ことの面白さに気付きました。

コードを読む中で、以外と小さな単位のコミットがマージされているケースも見つかりましたので、私もできるところから OSSにコミットしていきたいなと思いました。