😊

GoでUTF-8文字列をいい感じに切り出すライブラリu8pを作りました

2023/12/09に公開

u8pというGoのライブラリを作りました。

https://github.com/catatsuy/u8p

今回はなぜ作ったのかとか、注意点などを紹介します。

u8pの機能

例えばログなどで大量の文字列を出力されたとして、そのログを別サーバーに送信したいとします。この場合、全量を別サーバーに送れば、送信先のサーバーのリソースが枯渇する可能性があります。特にログを送信するサーバーが複数あって、何らかの理由でそれらのサーバーから同時に大量のログが出力されれば、1台1台は大したことがなくても、一気に送信されればつらい状況になることは容易に想像できます。

そういった事故を防ぐために、一定以上のログが出力されたら全量を送らずに、適当に打ち切って一部を送ることが考えられます。

ここで問題になるのはどこで打ち切るのかという点です。適当に10000文字目で切ればいいのでは?と思った人もいると思いますが、これは以外と大変です。

Goで先頭から10000文字目で切りたい場合、runeにcastするのが楽です。以下のようなコードを実行してみると分かると思います。

package main

import (
	"fmt"
)

func main() {
	inputString := "Goで先頭から10文字目で切りたい場合、runeにcastするのが楽です。"

	// 文字列をruneに変換
	runes := []rune(inputString)

	// 先頭から10文字までの部分文字列を取得
	maxLength := 10
	if len(runes) > maxLength {
		runes = runes[:maxLength]
	}

	// runeから文字列に変換
	outputString := string(runes)

	// result: Goで先頭から10文
	fmt.Println(outputString)
}

これでいいように思うかもしれませんが、このコードには複数の問題があります。

  • stringからruneへのcastで文字列すべてのコピーが走る
    • そもそも文字列が大きいから部分文字列にしたいのに、全量コピーしたらメモリー不足で死にかねない
  • 10000byte目ではなく、10000文字目という設定になる
    • 今回の用途だと適当に打ち切ってくれればよいので、そこまで厳密に切りたいわけではない

適当にinputString[0:20]とか切れば先頭20byteまで出力されます。それでいいのでは?と思う人もいるかもしれませんが、このコードは以下の問題があります。

  • ひらがななどはUTF-8で3バイトなので適当に切るとUTF-8として不正なバイト列になる

UTF-8は変な位置で切ると必ず不正なバイト列になります。その辺は昔書いた記事に軽く書きました。

https://qiita.com/catatsuy/items/bccc2c76be501e98382a

そこでu8pの登場です。u8pは以下のように使います。

b := "Hello, 🌍. Hi!"
lb := 13
index, err = u8p.Find(b, lb)
if err != nil {
	fmt.Printf("Error: %v\n", err)
} else {
	// result: Emoji example - Hello, 🌍.
	fmt.Printf("Emoji example - %s\n", b[:index])
}

u8p.Findという関数で第1引数に文字列、第2引数にintを渡すことで、第2引数に渡した値以下で最大のUTF-8として正当な文字列の先頭位置を返します。

実装としてはUTF-8の1文字は4byte以下なので、周囲4byte分でUTF-8の文字の先頭1byte目に当たる箇所を探しています。なのでエラーになった==それで見つからない場合はどこで切ってもUTF-8として不正です。なのでその場合は諦めて雑に切ると良いと思います。

なので現実的には以下のようなコードを書けばいいと思います。

if len(inputString) > maxLength {
	index, err := u8p.Find(inputString, maxLength)
	if err != nil {
		// どこで切っても不正なバイト列になるので適当なところで切る
		index = maxLength
	}
	inputString = inputString[:index]
}

スライスの指定は後ろだと1個手前までが返ってきます。なのでこのようにUTF-8の先頭1byte目の位置を指定すればUTF-8として正当な文字列になります。逆にそこから後ろ(inputString[index:])という使い方も可能です。

特に計測していませんが、4byte分のbyte列がUTF-8の先頭1byte目かどうかを判定しているだけなので、runeにcastするよりも圧倒的に速いはずです。特に文字列が大きければ大きいほど恩恵は大きいはずです。

おしゃれポイント

Fuzzingを利用して様々な文字列に対してUTF-8として正当な文字列を切り出すことが可能であることを確認しています。Fuzzingを使ってみたいなと思いつつ、ちょうど良い関数を実装できていなかったのですが、今回ドンピシャで利用できる関数を実装できたのでうれしかったです。

注意点

例えば国旗🇯🇵などの絵文字はUTF-8の文字2文字分で国旗として表示できるようになるので、そういった絵文字は変なところで切られて表示できなくなる可能性があります。

しかしその場合でも文字として表示できないだけで、UTF-8として不正なバイト列にはなりません。Goの標準パッケージでも考慮できない部分なので、そういったことも考慮したい場合は完全に別の実装が必要になります。今回の用途だと想定していないので対応予定はありません。

また利用方法によっては、部分文字列を利用している間、元の文字列はGCの対象にならないことに注意が必要です。特に元の文字列が大きく、部分文字列が小さい場合、GCが発生せずにメモリが解放されない可能性があります。このようなリスクを避けるために、元の文字列をGC可能な状態に保つためにstrings.Cloneを使用することを検討することができます。

Discussion