\非公式/ Go Conference 2022 Spring スポンサー企業4社 アフタートーク LT内容の解説 〜ExcelとShift-JISとの闘争編〜

お久しぶりです。 ANDPADの原田(tomtwinkle)です。

2022/4/28(木)にオンラインで開催された「\非公式/ Go Conference 2022 Spring スポンサー企業4社 アフタートーク」にLTで登壇していました。

andpad.connpass.com

www.youtube.com

LT自体が久々というのと、最近あまりこういう人前で話す機会がなかったので噛み噛みでしたが何とか乗り切れました。

実質7分の枠でしたのでかなり早口で飛ばしてしまいタイトル通り細かすぎて伝わらない感じになってしまっていたので 中身についてもう少し詳細にブログで解説して行こうと思います。

目次

GolangでExcelを出力する

ANDPADボードを利用している職人さんは独自でデータ加工出来る人ばかりではないので 職人向けのExport機能ではCSVではなく職人さん達が普段使用しているExcelで出力をしています。

GolangでExcelの出力のために使用しているLibraryが qax-os/excelize です。

Excelizeはアリババグループの@xurime氏がメンテしているLibraryですが Excelで出来ることが大体出来るという多機能なLibraryになっており、日本語ドキュメントもかなり充実しています。

xuri.me

Excelカラム名とIndex値を相互変換する

ExcelizeでCellにアクセスする際はExcel特有の AK12 のようなアドレスでアクセスする必要があります。 このままだと使いづらいのでExcelのカラム名とIndex値を相互に変換したくなると思います。

Excelizeドキュメントを探すと中々見つからないですが lib.go 内に便利な変換関数が用意されており以下のように使用できます。

  • カラム名からIndex値に変換する
excelize.ColumnNameToNumber("AK") // returns 37, nil
  • Index値からカラム名に変換する
excelize.ColumnNumberToName(37) // returns "AK", nil

Border用の関数を用意する

ExcelのCellを使ったお絵かきではBorder(罫線)を如何に設定するかが重要です。 Excelize標準機能をそのまま使おうとすると決め打ちで書かねばいけないため Borderを作成するための関数を別途用意したほうが良いでしょう。

type BorderPosition string

const (
    BorderPositionTop    BorderPosition = "top"
    BorderPositionLeft   BorderPosition = "left"
    BorderPositionRight  BorderPosition = "right"
    BorderPositionBottom BorderPosition = "bottom"
)

// BorderStyle https://xuri.me/excelize/ja/style.html#border
type BorderStyle int

const (
    BorderStyleNone        BorderStyle = 0  // Weight: 0, Style:
    BorderStyleContinuous0 BorderStyle = 7  // Weight: 0, Style: -----------
    BorderStyleContinuous1 BorderStyle = 1  // Weight: 1, Style: -----------
    BorderStyleContinuous2 BorderStyle = 2  // Weight: 2, Style: -----------
    BorderStyleContinuous3 BorderStyle = 5  // Weight: 3, Style: -----------
    BorderStyleDash1       BorderStyle = 3  // Weight: 1, Style: - - - - - -
    BorderStyleDash2       BorderStyle = 8  // Weight: 2, Style: - - - - - -
    BorderStyleDot         BorderStyle = 4  // Weight: 1, Style: . . . . . .
    BorderStyleDouble      BorderStyle = 6  // Weight: 3, Style: ===========
    BorderStyleDashDot1    BorderStyle = 9  // Weight: 1, Style: - . - . - .
    BorderStyleDashDot2    BorderStyle = 10 // Weight: 2, Style: - . - . - .
    BorderStyleDashDotDot1 BorderStyle = 11 // Weight: 1, Style: - . . - . .
    BorderStyleDashDotDot2 BorderStyle = 12 // Weight: 2, Style: - . . - . .
    BorderStyleSlantDash   BorderStyle = 13 // Weight: 2, Style: / - . / - .
)

type BorderColor string

const (
    BorderColorBlack BorderColor = "#000000"
)

func GetBorder(position BorderPosition, style BorderStyle, color BorderColor) []excelize.Border {
    return []excelize.Border{
        {
            Type:  string(position),
            Color: string(color),
            Style: int(style),
        },
    }
}

Alignment用の関数を用意する

Alignment はCell内の配置設定を定義するためのものです。 「文字をCellの左上に設定したい」とか「Cell内で中央揃えにしたい」とか「Cell内で文字を折り返したい」とか「Cell内の文字列を回転させたい」とかそういうのです。 今回は特に全部のAlignment定義を用意する必要はないので帳票に必要な最低限のものだけ用意しています。

// AlignmentHorizontal https://xuri.me/excelize/ja/style.html#align
type AlignmentHorizontal string

const (
    AlignmentHorizontalLeft             AlignmentHorizontal = "left"             // Left (indented)
    AlignmentHorizontalCenter           AlignmentHorizontal = "center"           // Centered
    AlignmentHorizontalRight            AlignmentHorizontal = "right"            // Right (indented)
    AlignmentHorizontalFill             AlignmentHorizontal = "fill"             // Filling
    AlignmentHorizontalJustify          AlignmentHorizontal = "justify"          // Justified
    AlignmentHorizontalCenterContinuous AlignmentHorizontal = "centerContinuous" // Cross-column centered
    AlignmentHorizontalDistributed      AlignmentHorizontal = "distributed"      // Decentralized alignment (indented)
)

// AlignmentVertical https://xuri.me/excelize/ja/style.html#align
type AlignmentVertical string

const (
    AlignmentVerticalTop         AlignmentVertical = "top"         // Top alignment
    AlignmentVerticalCenter      AlignmentVertical = "center"      // Centered
    AlignmentVerticalJustify     AlignmentVertical = "justify"     // Justified
    AlignmentVerticalDistributed AlignmentVertical = "distributed" // Decentralized alignment
)

func GetAlignment(horizontal AlignmentHorizontal, vertical AlignmentVertical, wrapText bool) *excelize.Alignment {
    return &excelize.Alignment{
        Horizontal: string(horizontal),
        Vertical:   string(vertical),
        WrapText:   wrapText,
    }
}

帳票では「左添え」か「中央揃え」しか使わないですね。

// 左揃えの場合
GetAlignment(AlignmentHorizontalLeft, AlignmentVerticalCenter)

// 中央揃えの場合
GetAlignment(AlignmentHorizontalCenter, AlignmentVerticalCenter)

Fill用の関数を用意する

FillはCellの塗りつぶしの定義です。 出力する帳票の中には特定のセルに色を付けて欲しいというものがあるのでそのためだけに用意しています。

// FillPattern https://xuri.me/excelize/ja/style.html#pattern
type FillPattern int

const (
    FillPatternNone            FillPattern = 0
    FillPatternSolid           FillPattern = 1
    FillPatternMediumGray      FillPattern = 2
    FillPatternDarkGray        FillPattern = 3
    FillPatternLightGray       FillPattern = 4
    FillPatternDarkHorizontal  FillPattern = 5
    FillPatternDarkVertical    FillPattern = 6
    FillPatternDarkDown        FillPattern = 7
    FillPatternDarkUp          FillPattern = 8
    FillPatternDarkGrid        FillPattern = 9
    FillPatternDarkTrellis     FillPattern = 10
    FillPatternLightHorizontal FillPattern = 11
    FillPatternLightVertical   FillPattern = 12
    FillPatternLightDown       FillPattern = 13
    FillPatternLightUp         FillPattern = 14
    FillPatternLightGrid       FillPattern = 15
    FillPatternLightTrellis    FillPattern = 16
    FillPatternGray125         FillPattern = 17
    FillPatternGray0625        FillPattern = 18
)

func GetFill(pattern FillPattern, color string) excelize.Fill {
    return excelize.Fill{
        Type:    "pattern",
        Pattern: int(pattern),
        Color:   []string{color},
    }
}

patternを列挙したは良いものの基本的には FillPatternSolid しか使わないですね。

Styleを適用する

前述の部分でそれぞれCellの定義を作成していよいよCellにStyleを適用していきます。 Styleの適用はこんな感じです。

package main

import (
    "fmt"

    "github.com/xuri/excelize/v2"
)

func main() {
    f := excelize.NewFile()
    if err := SetStyleCell(f, "Sheet1", 1, 3,
        &excelize.Style{
            Border:    GetBorder(BorderPositionTop, BorderStyleContinuous1, BorderColorBlack),
            Fill:      GetFill(FillPatternSolid, "#FFFFFF"),
            Font:      &excelize.Font{Bold: true},
            Alignment: GetAlignment(AlignmentHorizontalCenter, AlignmentVerticalCenter, false),
        },
    ); err != nil {
        fmt.Println(err)
    }
    if err := f.SaveAs("Book1.xlsx"); err != nil {
        fmt.Println(err)
    }
}

func SetStyleCell(f *excelize.File, sheetName string, colIndex, rowIndex int, style *excelize.Style) error {
    styleID, err := f.NewStyle(style)
    if err != nil {
        return err
    }
    colName, err := excelize.ColumnNumberToName(colIndex)
    if err != nil {
        return err
    }
    cellAddress, err := excelize.JoinCellName(colName, rowIndex)
    if err != nil {
        return err
    }
    err = f.SetCellStyle(sheetName, cellAddress, cellAddress, styleID)
    if err != nil {
        return err
    }
    return nil
}

StyleIDを毎回発行するのはどうなのかと思ったりもしましたが今の所パフォーマンス的には問題なく動いています。

ただし、1つのWorkbookに適用可能なスタイルの数は4000までですので、それを超えそうならちゃんとStyleIDを管理したほうが良いです。

GolangでShift-JISを出力する

CSVを出力すること自体はそこまで大変なことではないのですが、Shift-JISで書き出す必要がある場合が曲者です。

LTで語った内容も CSV Writerに buffer size指定出来るインターフェースがない という部分以外のハマりどころはShift-JISによるものです。

何が問題だったのか?

Golang内部の文字列はUnicode(UTF-8)で保持しているためShift-JISでの書き出しの場合UTF-8からShift-JISへの変換になります。 GolangでShift-JISに変換する際には準標準パッケージである golang.org/x/text/transform とencoderの golang.org/x/text/encoding/japanese を用いてShift-JISに変換します。

具体的にはこうです。

package main

import (
    "bytes"
    "fmt"
    "log"

    "golang.org/x/text/encoding/japanese"
    "golang.org/x/text/transform"
)

func main() {
    in := bytes.NewBufferString("UTF-8からShift-JISへの変換を行う")

    var buf bytes.Buffer
    w := transform.NewWriter(&buf, japanese.ShiftJIS.NewEncoder())
    if _, err := w.Write(in.Bytes()); err != nil {
        log.Fatal(err)
    }
    if err := w.Close(); err != nil {
        log.Fatal(err)
    }
 
    // Shift-JIS
    fmt.Println(buf.String())
}

この時入力する文字列の中にUTF-8からShift-JISへ正常にマッピング出来ない文字列が来ることが想定されます。 golang.org/x/text/encoding/japanese の実装ではそんな時には encoding: rune not supported by encoding. でerror終了します。

https://go.dev/play/p/cUasERBZlEB

package main

import (
    "bytes"
    "fmt"
    "log"

    "golang.org/x/text/encoding/japanese"
    "golang.org/x/text/transform"
)

func main() {
    in := bytes.NewBufferString("変換出来ない🍣🍺を入れてみよう")

    var buf bytes.Buffer
    w := transform.NewWriter(&buf, japanese.ShiftJIS.NewEncoder())
    if _, err := w.Write(in.Bytes()); err != nil {
        // encoding: rune not supported by encoding.
        log.Fatal(err)
    }
    if err := w.Close(); err != nil {
        log.Fatal(err)
    }

    fmt.Println(buf.String())
}

それじゃあ困りますね。 変換できないなら別の文字に置き換えて欲しいというのがやりたいことです。

そこで 「golang Shift-JIS rune not supported by encoding」 等でググるとteratailのこんな記事が出てくると思います。

teratail.com

ベストアンサーに書かれたruneWriterのコードが載っているのですが実はこれと似たような実装をしてしまうと少々問題がありました。

type runeWriter struct {
    w io.Writer
}

func (rw *runeWriter) Write(b []byte) (int, error) {
    var err error
    l := 0

loop:
    for len(b) > 0 {
        _, n := utf8.DecodeRune(b)
        if n == 0 {
            break loop
        }
        _, err = rw.w.Write(b[:n])
        if err != nil {
            _, err = rw.w.Write([]byte{'?'})
            if err != nil {
                break loop
            }
        }
        l += n
        b = b[n:]
    }
    return l, err
}

お分かりでしょうか?

utf8.DecodeRune(b) で decodeしてその分だけWriterにWriteする、Write出来なければ変換不可文字として別の文字を書き込む。 という動作自体には問題はないため、これは想定通りの動作をします。

しかし、「default buffer sizeの4096byteずつ書き込む」というWriterの動作と「日本語というマルチバイト文字」の特性が組み合わさり 変換不可文字が含まれ、4096byte目にマルチバイト文字来て、かつマルチバイト文字の途中でbyte分割されてしまうパターンで正常に動作しなくなります。

どういう動作をするかというと、本来変換不可文字だけ ? に置き換えるはずが他の文字も ? で置き換えてしまったり、そもそも rune not supported by encoding errorが出てしまって書き込めなくなったりします。

   in := []string{strings.Repeat("マルチバイト🍣文字難しい", 100)}

    var buf bytes.Buffer
    cw := csv.NewWriter(&runeWriter{transform.NewWriter(&buf, japanese.ShiftJIS.NewEncoder())})
    if err := cw.Write(in); err != nil {
        log.Fatal(err)
    }
    cw.Flush()
   in := []string{strings.Repeat("マルチバイト🍣文字難しい", 1000)}

    var buf bytes.Buffer
    cw := csv.NewWriter(&runeWriter{transform.NewWriter(&buf, japanese.ShiftJIS.NewEncoder())})
    if err := cw.Write(in); err != nil {
        // encoding: rune not supported by encoding.
        log.Fatal(err)
    }
    cw.Flush()

解決策 Transformerを使う

上記問題の解決策としては、変換不可文字を置換しつつマルチバイト文字が途中で途切れないように後続のTransformerに渡してあげるTransformerを自作するのがベストの方法だと思います。 そこで作成したのが此方です。

github.com

func (t *replacer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
    _src := src
    if len(_src) == 0 && atEOF {
        return
    }
    if !utf8.Valid(_src) {
        // If not a string, do not process
        err = ErrInvalidUTF8
        return
    }

    for len(_src) > 0 {
        _, n := utf8.DecodeRune(_src)
        buf := _src[:n]
        if _, encErr := t.enc.Bytes(buf); encErr != nil {
            // Replace strings that cannot be converted
            buf = []byte(string(t.replaceRune))
        }
        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
}

Transformerを自作する際、Transform関数の戻り値にdst byteにWriteしたbyte数と、そのために使用したsrc byte数と、dstのbufferが足りない場合はtransform.ErrShortDstをerrorで返してあげることで次回Transform関数が呼ばれる際に前回未処理のbyteから処理が開始できます。

これを利用することで順次dstにbyteをコピーしつつ、マルチバイト文字が分割されてdstに書ききれない場合は次回以降の処理に回すことで文字列として成立しないbyte配列が golang.org/x/text/encoding/japaneseのEncoderに渡されないよう回避しています。

GolangのTransformerはbyte配列単位でWriterに書き出す値を制御出来るため非常に便利なのですがサンプルが少なく ざっとググって見ても使えそうなコードが数えるほどしかありません。 なので今回のTransformerをサンプルの一つとして参考にしてもらえればと思います。

何かおかしな点があった場合はissueを建てる、PRを送るなどして教えていただけると助かります。

おまけ 自作Transformerのファジングテストを行う

go 1.18より追加されたファジングテストがまさに今回のようなTransformerのバグを探すのに最適なツールなので ついでにファジングテストを追加してみました。

func FuzzTransformer(f *testing.F) {
    seeds := [][]byte{
        bytes.Repeat([]byte("一二三四五六七八九十拾壱🍣🍺"), 1000),
        bytes.Repeat([]byte("一二三四🍣五六七八九🍺十拾壱"), 3000),
        bytes.Repeat([]byte("一二三四🍣五六七八九🍺十拾壱"), 3000),
        bytes.Repeat([]byte("咖呸咕咀呻🍣呷咄咒咆呼咐🍺呱呶和咚呢"), 3000),
    }

    for _, b := range seeds {
        f.Add(b)
    }

    f.Fuzz(func(t *testing.T, p []byte) {
        tr := garbledreplacer.NewTransformer(japanese.ShiftJIS, '?')
        for len(p) > 0 {
            if !utf8.Valid(p) {
                t.Skip()
            }
            _, n, err := transform.Bytes(tr, p)
            if err != nil {
                t.Fatal("unexpected error:", err)
            }
            p = p[n:]
        }
    })
}

シードコーパスをtesting.FにAddするとその値を元に様々なbyte配列でテストを試行してくれます。 今回そもそも文字列じゃないbyte配列は除外したいので

if !utf8.Valid(p) {
    t.Skip()
}

を追加しています。 ファジングテストはコケるまで回り続けるテストなのでCIで動かすというよりはローカルで動かすのが良いのかなという感じですね。

結構想定していなかった文字列が生成されてコケたりするので使ってみると面白いと思います。


アンドパッドでは一緒に働く仲間を大募集しています。 社内gopherコミュニティが活発に活動しております。 ご興味を持たれた方はぜひカジュアル面談や情報交換のご連絡ください!

engineer.andpad.co.jp