golangのcsv操作をより使いやすくできるのではないか
goでのcsvの操作には、https://github.com/gocarina/gocsv を使っていた。
いくつか問題・改善点があると思ったので、issueからのPRを投げようかと思った。
あまり盛んに開発が行われていない様子。管理している方はいらっしゃるようだが、積極的なユーザーではないとのこと。
具体的な問題・改善点
- グローバル変数で管理している値がある。それによって複数エンドポイントから異なる条件で、同時に処理を開始できない。
- https://github.com/gocarina/gocsv/issues/84 結構前に同様の問題を抱えている人がいたみたい。偶然発見したので追記
- 出力カラムの動的な選択ができない
- 出力カラムの並び替えができない
- folkして軽く修正すればできたが、グローバル変数でカラム選択関数を持たせているので、複数エンドポイントからの同時異条件の処理に対応できない
とりあえず出力処理から実装していく。
XSVWrite構造体のフィールドでcsv出力に関する各種設定項目をいじれるようにする。
XSVWrite構造体にはメソッドが生えていて、そのメソッドからcsvのファイルへの書き込みなどを行えるようになっている。そうすることで、グローバル変数で管理している値がなくなり、複数処理が同時期に実行されたときでもそれぞれの設定に影響なく実行できる。
- 構造体の命名
- メンバー関数の公開・非公開
上記は再度吟味していこうと思う。一旦ここまで。
感想
writeFromChan関数のexample作成時に、「複数goroutineから単一チャネルに値を送信するときに、すべてのgoroutineが値をチャネルに送信し終わってからチャネルを閉じる処理を実装する方法」がわからなかった。
chat gptに聞いてみたら、一発で返答が返ってきた。
変な日本語でもとりあえず投げてみたら、まるで文脈を読んだかのような答えだったので感動した。
書き込み機能 開発メモ(テキトーな文章)
gocsv パッケージには、marshalCsv やmarshalString, marshalFileと言ったメソッドが提供されている。
同様の関数を提供したいが、その時に、csv.WriterのフィールドであるComma とUseCLRF の制御をどう実現するか迷った。
- XSVWrite.Write(w *csv.Writer)で書き込み
- csv.Writerの設定項目をいじるためには利用側で設定しないといけない。別にいいけど。
- XSVWrite構造体のフィールドにwriter csv.Writerを持たせる
- MarshalFileの場合だと関数内部でcsv.NewWriterを実行することになるので、commaとuseCLRF設定を更新することができない。
読み取り機能
gocsvパッケージの読み取り機能は、decode.goとunmarshaller.goで記述されている。
読み取りをするのは同じ機能だが、unmarshaller.goの方は、一行ずつ読み取ったりする機能を提供していた。
unmarshaller.goの方は一行ずつ読み取っていく機能なので、一括読み取りと比べると優先度が低いと思った。
XSVRead構造体で一行ずつ読み取る機能を提供したかったが、移植が意外に大変で一旦思考放棄。
今後
- genericsを使えば不要になる処理が所々にあるので削っていく。
- reflectでsliceかarrayを判定してるところとか。
- README.mdの部分に軽いドキュメントを書く
- Unmarshaller.goが提供していた機能を実装できるか考えてみる。
さらなるやりたいこと
- コードオーナー追加
- github actions設定 とりあえず
go test
- テストケースの追加
- SelectedColumnIndex
- ColumnSorter
感想
go 1.21出てた。
csv書き込み読み込みの処理もっと改善できそう。
現状はgocsvをほとんどそのまま利用してるのでジェネリクスとか使ってないみたい。実際にジェネリクスを使っていい感じに書き換えられるかどうかはもっと詳しくみてみないとわからない。
ほら。僕と同じ悩みを持ってる人がいた。
SelectedColumns機能
出力するカラムを動的に指定できる機能をどのように提供するか。
現状は、SelectedColumnsフィールドをXSV Write構造体に持たせて、それを元に出力するカラムを抽出する処理に判別ロジックを追加している。
gocsvではgetFieldInfosの中身で判別している。getFieldInfos関数の後にXSVWriteのSelectedColumnsでフィルタリングする実装になっている。
どうせならgetFieldInfosの中にフィルタリングロジックを埋め込むことによって不要なforループを無くしたい。できるか?
→ 結局、getFieldInfosの中にフィルタリングロジックを埋め込むと余計に処理が増えるとわかった。getFieldInfosから返されるfieldInfosの中のどの値を見れば良いかがわかったので、返された値を元に処理をすれば良かった。https://github.com/shigetaichi/xsv/pull/6
感想
コードリーディングが難しい。でもやる意義があるので楽しく取り組めてる感じがする。getFieldInfos関数の内部でどんな処理が行われているのかを理解したい。
そもそもXSVWrite構造体のメンバー関数として提供したいまであるが、Read系でも使われているので統合は一旦考えないことにしよう。内部のパフォーマンス向上に注力だ!
カラム並べ替え機能
モチベーション
カラムを動的に並べ替える機能を実装したい。
以前gocsvで実現できないかissueで質問を投げたところ、優しい方にアイデアを頂いた。回答では、今のgocsvにそれを実現する機能は提供されていない。ただ、DefaultNormalizerのように比較的簡単に機能を実装することはできるかもしれないとのことだった。PRあげようかと試みたが、やはりグローバル変数で管理されている各種項目のハンドリングが難しく断念した。
具体的には、「ほぼ同時に、別のカラム並べ替えロジックをもった処理をリクエストされる」状況の時に対応できなかった。
他のissueでも似たような(厳密にいうとSelectedColumsの機能)要望が上がっているようなので、実装する価値がある機能とみて、xsvではカラムの並べ替え機能を提供したい。
方針
どんなロジックで並べ替えるかはユーザーそれぞれ違う。
したがって今回はロジックをxsv構造体に渡しておけば、それ通りに並べ替えができる機能を作る。
SelectedColumns機能がすでに出来上がったので、それと一緒に使った時に問題ないようにしたい。
- 並び替える順番を指定するSortOrderスライス([]int型)
- 並び替えるロジックを指定するColumnSorter関数型(func(row []string) []string)
並び替えるロジックを指定する関数を渡せるようにすると、各行で別の結果が出た時にヘッダーとの整合性がつかなくなる危険性がある。したがって、すべての行で確実にヘッダーと対応できるように「並び替える順番を指定するSortOrder」フィールドを提供すると決めた。
HeaderModifier機能
header名を動的に変更できる機能。
https://github.com/gocarina/gocsv/issues/128 gocsvリポジトリでも同様のissueが見られた。
- ヘッダーを動的に変更したい
- ヘッダーは、構造体のタグに指定したものを出力している
構造体のタグを書き換えるより、Write関数実行時に対応するヘッダーを上書きする方が合理的。
完成
初めに書いたコードはループをネストしていた。一回のループで処理を終えられるように修正できた🎉
Zennで記事を書いた。
作るだけだとあまりにも知ってもらえないので、知ってもらえるように丁寧に伝えていこうと思う。
papaparseのような他の言語のcsvライブラリからも、新機能のインスピレーションが得られる。既存のgocsvのissueにもアイデアがたくさんある。
特に、次は読み取りのfrom, to機能を付けようと思う。他にも付けたい機能を列挙しておこう。
- 読み取り
- From, To
- OnRoad
- OnError
- SkipError
- 書き込み
- OnRoad
- シングルクオート対応
From, To機能 🚧
意外と考えることが多かった。
- FromとToの初期値をどうするかを決めなければならない。
- Fromは1にできる。Toは全てのcsvを指定する必要がある。
- ヘッダー行を0行目とするか、1行目とするか。
- 開発者が利用するパッケージであるので、0行目とする。→ 既存の実装はインデックス行数ではなくエクセルやNumbersで見る行数を採用していたのでそれに倣う。
- エラー時にcsv行数を出力する記述があったのでそれにも対応する。
- WithoutHeaders系は行番号初期値をどう定義すればいいか。特にFrom
- 読み込み機能として提供されている関数が意外と多い。ReadTo、ReadEach、、、
- それだとそれぞれについてテストを書かねばならない。
テストの記述をもう少し綺麗にできないかと考えている。テストcsvファイルみたいなのを用意できるといいかも。
OnRecord機能 XsvRead
読み込み時に、各レコードごとに変換・置換処理などを書くことができる。
XsvRead構造体に関数を渡すと、読み取り時に一つのレコードに対して逐次実行される。
難しかった点
if r.OnRecord != nil {
outInner = reflect.ValueOf(r.OnRecord(outInner.Interface().(T)))
}
OnRecordに渡す関数の引数を、XsvReadのT型と揃えたかった。
単純に揃えるだけだと、 Read系関数の実行部分でreflectを使っているため型が合わず実行できないという問題があった。
上記のように、一回reflectによる変換を駆使した。こういった変換を全ての行に対して行うのはパフォーマンスの観点で良くないかもしれないが、OnRecordはそういう機能だと利用者側も把握しているはずだと思って導入。改善はあとから。
感動した点 Github Copilot導入
実装する時に、大まかな道筋は頭の中にあった。「だいたいここでOnRecordを実行して〜」的な。
だが上述の問題に遭遇したので、また別の日に見てみようと思った。
その間に、Github Copilotをトライアル導入した。
えぐい。タブだけで上記のコードを生成したくれた。一応内部の実装見てみたが、正しい。テストもしっかりクリア。簡単な実装はGithub Copilotが大幅に助けてくれると実感した。これからもAIと仲良くするために、さらに勉強していきたいとワクワクしてきた。
OnRecord機能 XsvWrite
書き込み時に、各レコードごとに変換・置換処理などを書くことができる。
難しかった点
OnRecord関数をフィールドとして追加するとき、行番号をどのように渡すか迷った。
背景
書き込み処理としてXsvWriter構造体が提供している関数は、WriteTo関数とWriteFromChan関数の2種類。
WriteTo関数の方は配列のインデックスをOnRecordに渡すことができるが、WriteFromChanはチャネルからの書き込みなのでWriteTo関数の時のような「配列インデックス」という概念がない。
考えられる解決策
- OnRecordとOnRecordWithIndexの二つのフィールドを設ける。
- OnRecordForWriteToとOnRecordForWriteFromChanの二つのフィールドを設ける。
- OnRecordには配列インデックスを提供しない。
- WriteFromChanの時はインデックスに-1を渡すようにする。
- OnRecordの配列インデックスをポインタ型にして、WriteFromChanの時はnilにする。
上記が考えられたが、結局OnRecordには配列インデックスを提供しないを採用した。
採用理由
GPTに聞いてみて、機能もりもり病にかかっていたことを諭されたので。
会話↓
自分のリポジトリにいissueコメントが来た!!🎉
初めての経験。OSSのプログラムの海で戦うプログラマーたちの仲間入りを果たせた気がした🎉
バグの指摘にだった。
gocsvを使ってた時からomitemptyの挙動について不明瞭な部分があると思っていた。案の定今回omitemptyが絡んだissueだった。
調査の内容やプログラムの細かな内容についてはgithubのissueに記載していくことにする。
そもそもomitempty機能というのが、このパッケージに必要ではないと思うので削除して行きたいと思う。
既存の挙動に影響が大きそうで、結構大変そうだ。
問題調査・修正
そもそもどういった問題か。
issueの内容を元に原因を解析すると、
「埋め込まれたポインタ構造体のフィールドにomitemptyタグが付けられていたとき、エラーが起きる」というものだった。
omitemptyの挙動の理解が難しかった。
「ポインタ型フィールドに空文字が来たときに、nilとするか空文字列or'0'のポインタを渡すか」という判断において、
「ポインタ型フィールドに空文字が来たときは、nilにする」を実現するのがomitemptyであるようだ。
あくまでも実装から推測する限りでは。csv"-"
の挙動と混同してしまっているケースがあるようだ。実際に僕もそうだった。
ここら辺は、ドキュメントなどに明確に記載する必要がある。
setField関数内部に問題があると思われるので、対応していく。
対応完了🎉
やはり、最高のパフォーマンスとコードを追求したい。
そもそもの構造を見直してみよう。
csvの書き込みより、読み取りの方が実装コストが大きいと思う。まず読み取りの見直しから始めてみよう。
大きく分けて、以下のようなフロー
- csvデータを受け取る
- ヘッダーがある場合と仮定
- csvタグとヘッダーの照合
- 合致するタグを持つ構造体フィールドに値を代入。