お久しぶりです。 ANDPADの原田(tomtwinkle)です。
2022/4/28(木)にオンラインで開催された「\非公式/ Go Conference 2022 Spring スポンサー企業4社 アフタートーク」にLTで登壇していました。
LT自体が久々というのと、最近あまりこういう人前で話す機会がなかったので噛み噛みでしたが何とか乗り切れました。
実質7分の枠でしたのでかなり早口で飛ばしてしまいタイトル通り細かすぎて伝わらない感じになってしまっていたので 中身についてもう少し詳細にブログで解説して行こうと思います。
目次
GolangでExcelを出力する
ANDPADボードを利用している職人さんは独自でデータ加工出来る人ばかりではないので 職人向けのExport機能ではCSVではなく職人さん達が普段使用しているExcelで出力をしています。
GolangでExcelの出力のために使用しているLibraryが qax-os/excelize です。
Excelizeはアリババグループの@xurime氏がメンテしているLibraryですが Excelで出来ることが大体出来るという多機能なLibraryになっており、日本語ドキュメントもかなり充実しています。
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のこんな記事が出てくると思います。
ベストアンサーに書かれた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を自作するのがベストの方法だと思います。 そこで作成したのが此方です。
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コミュニティが活発に活動しております。 ご興味を持たれた方はぜひカジュアル面談や情報交換のご連絡ください!