Go の x/text/transform を自作する方法 & おもしろ実装サンプル

Go の x/text/transform を自作する方法 & おもしろ実装サンプル|ANDPAD Advent Calendar 2022

前置き

この記事は ANDPAD Advent Calendar 2022 の 12日目の記事です。

全力で身内ネタですがQCの冨士川さんの韻を踏む記事で大量に並ぶラップ音声ファイルを見て腹抱えて笑いました。果たして全部聞いた人はいるのか……!?この位のゆるさが好き。

お久しぶりです、バックエンドのtomtwinkleです。 ANDPADボードのバックエンドリードエンジニアをしております。

今回の記事は「ちょっと早めの忘年Goパーティ」で発表したLTの内容の解説コーナーです。レッツトランスフォーム!

voicy.connpass.com


と、その話に入る前に

このブログを書き始めた頃に丁度 go 1.20.0 rc1 が出てきましたね。 ボチボチリリースノートを読み始めていますが go 1.16 で非推奨になったoptionが削除されたり、 memory-safe arena allocation で一括メモリアロケーションする事で大規模なアクセスがあるサーバーではCPUパフォーマンスが改善する新機能が追加されたりちょこちょこ細かい修正が入っていました。

個人的には Wrapping multiple errors が追加されたのが印象的です。今まで複数のerrorをまとめる際にはuber-go/multierrを利用していましたが、Combine用途だけであれば公式の errors.Join だけ使えば良さそうです。

Bootstrapの読み込みの順番が変わっているので、あんまり居ないとは思いますがLocalでbuildしたファイルを公開しているような人はもしかしたら挙動が変わってしまうかもしれませんね。

実際のリリースは2023年2月らしいので気長に待ちましょう。

閑話休題、本日のメニューリストです。

Motivation

Golangの内部で持っているUTF-8の文字をShift-JISに変換しないといけない時、準標準packageのjapanese packageでは変換出来ないとErrorを返すだけで融通がきかないのでcustom writer作ったのですが、これがマルチバイトだとbufferの切れ目でマルチバイト文字が途中で途切れた場合にうまく行かない。

困ったので何か良い方法無い? 的なことをissueでぶん投げた所

I think your custom writer should keep state so that it properly handles characters spread over multiple writes. Or you could have implemented it as a transform.Transformer.

transform.Transformer 使えば?と軽く返されたのでまあ実装しますかと調べ始めたのがキッカケです。

x/text/transform 情報が少なすぎる!

custom Transtormer 作るために調べ始めると、この x/text/transform 関連の記事がとにかく少ない。 2022/12/09 の段階でも以下くらいしか記事がない状態です。

engineering.mercari.com

undersourcecode.hatenablog.com

qiita.com

この中でもtenntennさんの記事くらいしかまともにx/text/transform を解説している記事が見つからず、公式ドキュメントを読んでもtransformを実装する上で一番キモとなるerror typeの説明が

ErrShortDst means that dst was too short to receive all of the transformed bytes. ErrShortSrc means that src had insufficient data to complete the transformation. If both conditions apply, then either error may be returned. Other than the error conditions listed here, implementations are free to report other errors that aris

というあっさりとしたコメントしか書かれていないので実際に実装してみないと何も分からん状態になりました。

というわけで以下は自分の理解で x/text/transform を解説していこうと思います。

x/text/transform とは何か

Transformはio.Writerやio.Readerにbyte列を渡す前後で変換を加える事が出来る仕組みで 例えば文字列の場合、特定の文字列を置き換えたい等の処理をメモリに全ての文字列を保持せずにStreamのまま処理出来るというのが大きな特徴です。

勿論文字列以外でも利用できるため、先のブログにあったようなパケット情報の書き換えやJPEGのExif情報をTransformerで読み取りGPS情報だけ消す(または逆にGPS情報を付け加える)といった用途が考えられます。

transformを利用することでメモリ使用量を抑え、memory allocationやGCによるアプリケーション自体の速度低下を極力避ける事ができます。

Custom Transformerを自作する

Transformer の interface定義

Custom Transformerを作成するためにはまずTransformer interfaceを実装する必要があります。

Transformer interfaceにはTransform, Resetという2つのメソッドがあり Transformメソッドではバイト列の変換処理、ResetメソッドはTransformerが再利用できるようにリセットするための機能を提供します。

Transformer の Reset() メソッドについて

Resetメソッドに関してはstateを持たない単純な変換処理しか行わない場合はReset処理自体不要なので 何もしないNopResetterをstructにEmbeddedしてあげればReset処理を実装しなくてもOKです。

Custom Transformerのstateについて LT中にも「Custom Transformerでは具体的にどのようにstateを利用するか」という質問が出ましたが、 Transformメソッドの呼び出しの際のsrcのbyte列の情報はioへの書き込み頻度やbuffer sizeに依存するため、一度の処理で欲しい情報がすべてinputされるわけではありません そのため、Transformで変換している間は処理のためにずっと保持しておきたい状態や情報持つ必要が出てくる事もあるかと思います。その場合はResetメソッドを実装して再度Custom Transformerを呼び出す際に内部情報をclearにしてあげる必要があります。

Transform() メソッドの戻り値 error について

TransformerはTransformメソッドを呼び出す前に処理中のbyte列をある程度Bufferで保持しています。 「読み取り側のbyte列が処理するには足りないからもっと欲しい」とか「書き込み側のbyte列が足りないから次の処理に続けて欲しい」 といった事をTransformメソッドが返すerror typeで判定しているため自作する場合は正しくerror typeを返却してあげる必要があります。

ErrShortDst は書き込み側のdst byte列がsrc byte列に対して不足している場合に返すerrorで これを正しく返さない場合はTransformメソッドは1度しか呼ばれないのでdefault buffer sizeである4096byte以上書き込まずに終わってしまいます。

ErrShortSrc はStream処理で書き込む際にsrc byteのinputが少なすぎてTransformメソッド内で処理出来ないパターンで使用します。 例えば 「Hello」を「Hi」に変換するTransformerを作りたい時、inputに「Hell」しか来ていないのでもう少し欲しいみたいな時です。 もちろんsrcがdst buffer sizeを超えてしまうと ErrShortDst を返さないといけないのでこのerrorを返す時にはsrcはdst buffer sizeの容量以下である必要があります。

ErrShortSrc を使わないといけない場合は結構レアケースで大抵のCustom Transformerは 次に説明する nDst nSrcErrShortDst を正しく返してあげるだけで実装出来そうです。

Transformメソッドの戻り値 nDst, nSrc について

また戻り値のnDst, nSrcは名前から何となく察しが付くと思いますが

  • dst bufferに格納したbyte数が nDst
  • srcのbyte列から取り出したbyte数が nSrc

になります。

Transform内で変換処理を何も行わない場合はnSrc == nDst になりますが、大抵の場合Transform内での変換後に変換後のbyte数は変化する可能性があります。 そのためTransformメソッドの実装時には src から読み取ったbyte数や dst に書き込んだbyte数を必ず計算しておく必要があります。

Transformerの実装例

何も変換しない Nop Transformer

Go Playground

func (t *customTransformer) Transform(dst, src []byte, atEOF bool) (int, int, error) {
    var err error
    // byte列をcopy
    n := copy(dst, src)
    // nDst: dstに割り当てたbyte数
    // nSrc: srcから割り当て済みのbyte数
    nDst, nSrc := n, n
    if n < len(src) {
        // dstに一度に書き込みできるbuffer sizeは決まっているため
        // srcがdstよりも大きい場合は ErrShortDst をerrorで返して処理を継続させる
        err = transform.ErrShortDst
    }
    return nDst, nSrc, err
}

まずは一番単純な何も変換しないNop Transformerを作成してみます。

byte列をcopyしたら終わり、ではなく先程説明した通りに処理済みのbyte数がsrcのbyte数より小さい場合は ErrShortDst errorを返却してあげる必要があります。

Nop Transformerの場合何も変換しないのでbyte数の変化はなくnDstとnSrcの値は等しくなります。

UTF-8からShift-JISに変換する際に出来ない文字を置き換えるTransformer

次に私が自作したTransformerを例にあげます github.com

func (t *replacer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
    _src := src
    // srcが最後のbyte列の場合はatEOF=trueになる
    // このロジック上では無くても動作上問題ないが、余計な処理が走らないようにearly returnしている
    if len(_src) == 0 && atEOF {
        return
    }
    // transformに渡されるbyte列が文字列であるとは限らないため文字列かどうかの判定を行う
    if !utf8.Valid(_src) {
        // If not a string, do not process
        err = ErrInvalidUTF8
        return
    }

    // 入力されたbyte列をruneに変換し、rune毎に処理を行う
    for len(_src) > 0 {
        // 入力されたbyte列をruneに変換、最初の1文字だけ取り出す n はruneのbyte数
        _, n := utf8.DecodeRune(_src)
        buf := _src[:n]

        // runeがShift-JISでencode出来るかチェック
        // encode出来なかったら別の文字に置き換え
        if _, encErr := t.enc.Bytes(buf); encErr != nil {
            // Replace strings that cannot be converted
            buf = []byte(string(t.replaceRune))
        }
        // 変換後のbyte列がdstのbufferからあふれる場合はdstにcopyせずにTransformの処理は終了
        // ErrShortDst errorを返して次のTransform処理に回す
        if nDst+len(buf) > len(dst) {
            // over destination buffer
            err = transform.ErrShortDst
            break
        }
        dstN := copy(dst[nDst:], buf)
        if dstN <= 0 {
            break
        }
        nSrc += n
        nDst += dstN
        _src = _src[n:]
    }
    return
}

基本的にはコメントに書いた通りなのですが、マルチバイト文字などを扱う場合には注意が必要です。

マルチバイトの文字の途中でsrcのbyte列が途切れてしまうことがあります。

その場合、処理できなかったbyte列は dst にコピーせずに dst にまだ空きがある状態にしておいた上で nDstdst に copyしたbyte数だけで算出し、ErrShortDst errorを返却してあげます。 そうすると次回のTransformの呼び出し時には前回未処理だった分のbyte列を先頭に繋げて src を渡してくれます。

ここら辺の情報は公式ドキュメントにも書かれていないので、知っていないとTransformer内に前回処理できなかったbyte列を余計に持っておく必要が出てきてTransformerの実装が複雑化してしまいます。

今までに自作したTransformer

と、ここまででTransformerの自作の仕方を説明したので自作したTransformerを紹介しておきます。

UTF-8をShift-JISに変換し、変換できない文字を置き換えるTransformer

github.com

UTFのBOMを削除するTransformer

github.com

特定の文字列を別の文字列に変換Transformer

github.com

それぞれ fuzzing test は通していますがもしかすると未知のバグがあるかもしれません。なにか見つけたらissueで教えて下さい。PRも歓迎です!

私選!Githubで公開されている面白Transformer

特定の文字列を別の文字列に変換

github.com

特定の文字列を別の文字列に変換するTransformer。 Unicode文字を使いがちな浜崎あゆみの楽曲とかを他の媒体に表示しても問題ない文字に変換する用途で使ったりとか出来ます。

Windows向けのCodePage Decoder

github.com

CP932, CP65001のようなWindowsのCode Page向けに文字列をマッピングします。 Win32 APIのMultiByteToWideCharでUTF-16に変換してからUTF-8にマッピングしていたりしていました。 Windows向けクライアントをGolangで作るみたいな場合に覚えておくとイザというときに便利かもしれません。

ニーモニックEncoder/Decoder

github.com

パスワード等の乱数のような意味のない英数字を理解しやすい単語に変換して電話越しで伝えやすくするTransformer こんな感じの変換が出来ます。

8f9240688685a1e9 ↔ magic-slang-crimson--inch-calypso-ibiza

最後に一言

そんな感じで、1つGoの x/text/transform の情報がインターネット上に増えたことでこれからも、続々と便利なTransformerが増えていくことを期待しています!

アンドパッドではエンジニアを積極採用中です。 ご興味を持って頂けましたら、下記よりお気軽にカジュアル面談や採用職種についてお問い合わせください!

engineer.andpad.co.jp

hrmos.co

明日は、VPoEの下司さんがアンドパッドの開発生産性の取り組みについて紹介してくれる予定です!お楽しみに!