Goのcsv操作をまとめる

[到達したい状態]
- GoのCSVパッケージの概要を人に説明できる
- GoのCSV操作をマスターしている
[やること]
- CSVのパッケージを読む
- 実際にコードを書く

CSVファイルとは
- CSVファイルとは、「comma separated values」の略であり、値や項目をカンマ(,)で区切って書いたテキストファイルのことである。CSVファイルとは言っているが要は中身が一定の規則に従っているだけのテキストファイルである。
- ファイルの拡張子は「.csv」となり、さまざまなソフトで開くことができるのが大きな特徴である。
- CSVファイルの内容はプログラミング言語で扱うことができる。

encoding/csvパッケージとは
-
encoding/csvパッケージとは、「CSV形式のテキストやファイルをGolangのstringスライスを用いて読み書きできる機能」を提供するパッケージである。
-
os.Fileではファイルの読み書きにバイトスライスを使っていたけど、CSVではstringスライスを使うとイメージするとスッと理解できる。
-
csvパッケージでは、主に以下の構造体を使用します。
このパッケージは、主に次のクラスを提供しています。- csv.Reader: CSV の読み出しに使用
- csv.Writer: CSV の書き込みに使用
CSV ファイル/テキストを読み出す - csv.Reader
csv形式のファイルやテキストを読み出すには、csv.NewReaderコンストラクタで csv.Readerを生成する
*csv.Readerに対して実装されているReadメソッドはファイルのReadメソッドとは違って、1つのレコードしか読みとらないので注意。ファイルのReadメソッドの場合、引数で渡したバイトスライスに全てのデータを書き込む。
func main() {
f, err := os.Open("csv/output.csv")
if err != nil {
logger := GetLogger()
logger.Warn("Hello zap", zap.Error(err), zap.Time("now", time.Now()))
}
defer f.Close()
// csv形式のファイルやテキストを読み出すには、csv.NewReaderコンストラクタで csv.Readerを生成する
csvReader := csv.NewReader(f)
for {
// csv.Readerに対して実装されているReadメソッドはファイルのとは違って、1つのレコードしか読みとらない
// ファイルのReadはすべて読み取る
// 確かに1レコードはカンマ区切りでstringの集合だな。
record, err := csvReader.Read()
// 最後まで読んだ
if err == io.EOF {
break
}
// 読み出し時にエラー
if err != nil {
logger := GetLogger()
logger.Warn("Hello zap", zap.Error(err), zap.Time("now", time.Now()))
}
// 1 レコード分のデータを出力(record は []string 型)
// カンマ区切りでスライスの1要素になっている。
fmt.Println(record)
// => [id firstName]
// [1 yuki]
}
}
大きな CSV ファイルを扱わないことが分かっているのであれば、*csv.Reader.Readllを利用した方が良いです。csvファイルの全レコードを2次元スライスで取り出すことができます。
func main() {
f, err := os.Open("csv/output.csv")
if err != nil {
logger := GetLogger()
logger.Warn("Hello zap", zap.Error(err), zap.Time("now", time.Now()))
}
defer f.Close()
csvReader := csv.NewReader(f)
record, err := csvReader.ReadAll()
if err != nil {
logger := GetLogger()
logger.Warn("Hello zap", zap.Error(err), zap.Time("now", time.Now()))
}
fmt.Println(record)
// => [[id firstName] [1 yuki]]
}

CSV ファイルを書き込む - csv.Writer
以下のような流れでCSVファイルに書き込むことができます。
CSVファイルにデータを書き込む流れ
- ファイルオブジェクトを作る
- csv.NewWriter 関数に渡して、csv.Writer を生成する。
- *csv.Writerに実装されているWriteメソッドを呼び出して、一つの文字列スライス(CSVレコード)をCSVファイルに出力します。
- ただし、出力処理は内部的にバッファリングされCSVファイルには出力されません。そのため、*csv.Writerに実装されているFlushメソッドを呼び出して、バッファに残っているデータをCSVファイルに書き込む必要があります。
- 終了
func main() {
f, err := os.OpenFile("csv/output.csv", os.O_RDWR|os.O_APPEND, 0666)
if err != nil {
logger := GetLogger()
logger.Warn("Hello zap", zap.Error(err), zap.Time("now", time.Now()))
}
defer f.Close()
writer := csv.NewWriter(f)
writer.Write([]string{"6", "hoge"})
writer.Write([]string{"7", "fuga"})
writer.Flush()
f.Seek(0, io.SeekStart)
b, err := io.ReadAll(f)
if err != nil {
logger := GetLogger()
logger.Warn("Hello zap", zap.Error(err), zap.Time("now", time.Now()))
}
fmt.Println(string(b))
}
文字列の2次元スライスと*csv.Writerに実装されているWriteAllをを利用することで、複数レコードをまとめてCSVファイルに書き込むことができます。WriteAllメソッドを使う場合は、内部でFlushを呼び出してくれるのでFlushを明示的に呼びだす必要はないです。
func main() {
f, err := os.OpenFile("csv/output.csv", os.O_RDWR|os.O_APPEND, 0666)
if err != nil {
logger := GetLogger()
logger.Warn("Hello zap", zap.Error(err), zap.Time("now", time.Now()))
}
defer f.Close()
writer := csv.NewWriter(f)
writer.Write([]string{"6", "hoge"})
writer.Write([]string{"7", "fuga"})
writer.Flush()
records := [][]string{
{"8", "eight"},
{"9", "nine"},
}
writer.WriteAll(records)
f.Seek(0, io.SeekStart)
b, err := io.ReadAll(f)
if err != nil {
logger := GetLogger()
logger.Warn("Hello zap", zap.Error(err), zap.Time("now", time.Now()))
}
fmt.Println(string(b))
// =>
// id,firstName
// 6,hoge
// 7,fuga
// 6,hoge
// 7,fuga
// 6,hoge
// 7,fuga
// 8,eight
// 9,nine
// 6,hoge
// 7,fuga
// 8,eight
// 9,nine
//
}

インターネットから公園のcsvテキストデータを取得して、特定の公園の時にコンソールに出力する
csvutilを使うとcsvファイルの1レコードを構造体にマッピングできる。気をつけることは、csvファイルが文字集合にUnicodeを使用している場合、BOMが付与されている可能性があることである。BOMが付与されていると、record[1] == Xが当たり前だが成立しなくなり、構造体へのマッピングが最初のプロパティだけ失敗する。
csvutilはパーサーではなく、値のマッピングを提供するだけなので、そこも気をつけましょう。
func main() {
const park_url = "https://opendata.arcgis.com/api/v3/datasets/ba60fb431b77463ea1efb61acb374ac7_0/downloads/data?format=csv&spatialRefId=4326&where=1%3D1"
// リクエストの生成
req, err := http.NewRequest(http.MethodGet, park_url, nil)
if err != nil {
logger := GetLogger()
logger.Warn("Error", zap.Error(err), zap.Time("now", time.Now()))
}
// リクエストの送信
resp, err := http.DefaultClient.Do(req)
if err != nil {
logger := GetLogger()
logger.Warn("Error", zap.Error(err), zap.Time("now", time.Now()))
}
body, _ := io.ReadAll(resp.Body)
// WriteFIleでCSVァイルを生成しつつ、バイトスライスを書き込む
// もしバイトの集合を表すバイトスライスではなくてバイトだと、8ビットしか書き込めない
err = os.WriteFile("csv/park.csv", body, os.ModePerm)
if err != nil {
logger := GetLogger()
logger.Warn("Error", zap.Error(err), zap.Time("now", time.Now()))
}
// マッピングさせる構造体スライス型の変数を定義
var parks []Park
f, err := os.Open("csv/park.csv")
if err != nil {
logger := GetLogger()
logger.Warn("Error", zap.Error(err), zap.Time("now", time.Now()))
}
defer f.Close()
// ファイルオブジェクトからBOMを取り除く。
sr, _ := utfbom.Skip(f)
b, err := io.ReadAll(sr)
if err != nil {
logger := GetLogger()
logger.Warn("Error", zap.Error(err), zap.Time("now", time.Now()))
}
// csvutilはパーサーではなく、値のマッピングを提供するだけです。
if err = csvutil.Unmarshal(b, &parks); err != nil {
logger := GetLogger()
logger.Warn("Error", zap.Error(err), zap.Time("now", time.Now()))
}
for _, p := range parks {
if p.Name == "神宮前公園" {
fmt.Printf("%+v\n", p)
// => {
// X: 139.7138537
// Y: 35.6734118000001
// ObjectID: 115
// PrefectureCode: 131130
// NO: 115
// PrefectureName: 東京都
// CityName: 渋谷区
// Name: 神宮前公園
// NameKana: ジングウマエコウエン
// Address: 神宮前2-2-30
// Latitude: 35.6734118
// Longitude: 139.7138537
// AreaSquareMeters: 468
// HasToilet:
// HasAccessibleToilet:
// HasWaterFacility:
// URL: https://www.google.com/maps/place/%E6%B8%8B%E8%B0%B7%E5%8C%BA%E7%AB%8B%E7%A5%9E%E5%AE%AE%E5%89%8D%E5%85%AC%E5%9C%92/@35.6734161,139.7116704,17z/data=!4m8!1m2!2m1!1z56We5a6u5YmN5YWs5ZyS!3m4!1s0x60188c9833e98307:0x9fa414cfcac59e61!8m2!3d35.6734118!4d139.7138537
// Image:
// ImageLicense:
// Remarks:
// }
}
}
}
今回はエンコーディング方式にUTF-8を利用しているファイルだったので、BOMを取り除くだけで構造体にマッピングできた。しかし、エンコーディング方式にShift_JISを利用しているファイルもある。Goでは内部のエンコーディングにUTF-8を採用しているそうなので、もし、エンコーディング方式にShift_JISを利用しているファイルをGoで取り扱う場合、エンコーディング方式を変換する必要がある。
ちなみにファイルにBOMがついているかどうかは、fileコマンドとeオプションを利用すれば確認できる。
➜ cmd file -e encoding csv/park.csv
csv/park.csv: Unicode text, UTF-8 (with BOM) text, with very long lines (570)

これクライアント側のコードだな。今考えると。

リクエストに応じて、CSVのデータをJSONで返すようなエンドポイントを実装する。
csvパッケージ使うかなと思ったが、結局csvutilしか使わなかった。
ヘッダーはw.Header().Set、ステータスコードは、w.WriteHeader()、ボディの送信はw.Writeで行う。
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
// bはjson.Marshalで返されるバイト列
w.Write(b)
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/dimchansky/utfbom"
"github.com/jszwec/csvutil"
"go.uber.org/zap"
)
var ErrFoo = fmt.Errorf("foo")
var ErrBar = errors.New("bar")
type Book struct {
Id int `csv:"Id" json:"id"`
Title string `csv:"Title" json:"title"`
Description string `csv:"Description" json:"description"`
}
type RequestJSON struct {
Id int `csv:"Id" json:"id"`
Title string `csv:"Title" json:"title"`
Description string `csv:"Description" json:"description"`
}
type ResponseSuccess struct {
Data any `json:"data"`
}
type ResponseError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func NewResponseSuccess(data any) *ResponseSuccess {
return &ResponseSuccess{
Data: data,
}
}
func NewResponseError(code int, message string) *ResponseError {
return &ResponseError{
Code: code,
Message: message,
}
}
func handleResponseSuccess(w http.ResponseWriter, code int, data any) {
r := NewResponseSuccess(data)
b, err := json.Marshal(r)
if err != nil {
logger := GetLogger()
logger.Warn("Error", zap.Error(err), zap.Time("now", time.Now()))
handleResponseError(w, http.StatusInternalServerError, err.Error())
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(b)
}
func handleResponseError(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
r := NewResponseError(code, message)
b, err := json.Marshal(r)
if err != nil {
logger := GetLogger()
logger.Warn("Error", zap.Error(err), zap.Time("now", time.Now()))
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(code)
w.Write(b)
}
func main() {
http.HandleFunc("/books/search", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
id := r.URL.Query().Get("id")
title := r.URL.Query().Get("title")
description := r.URL.Query().Get("description")
// リクエストに問題がある場合
if id == "" && title == "" && description == "" {
handleResponseError(w, http.StatusBadRequest, "一つ以上のパラメータを指定してください")
return
}
books, err := NewBooksByCSV("csv/output.csv")
if err != nil {
handleErrorLog(err)
handleResponseError(w, http.StatusInternalServerError, err.Error())
return
}
// リクエストで正常な値を一つ以上指定した場合
if id != "" {
id, err := strconv.Atoi(id)
if err != nil {
handleErrorLog(err)
handleResponseError(w, http.StatusInternalServerError, err.Error())
return
}
for _, r := range books {
if r.Id == id {
handleResponseSuccess(w, http.StatusOK, r)
return
}
}
}
if title != "" {
var results []Book
for _, r := range books {
if strings.Contains(r.Title, title) {
results = append(results, r)
}
}
handleResponseSuccess(w, http.StatusOK, results)
return
}
if description != "" {
var results []Book
for _, r := range books {
if strings.Contains(r.Description, description) {
results = append(results, r)
}
}
handleResponseSuccess(w, http.StatusOK, results)
return
}
// 正常なパラメータを指定したけど、何もヒットしなかった場合
handleResponseSuccess(w, http.StatusOK, "")
}
})
// 本当だったら、/books/:id
// httpパッケージでパスパラメータを取得するのが結構めんどくさそう
http.HandleFunc("/books/edit", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var requestJSON RequestJSON
json.NewDecoder(r.Body).Decode(&requestJSON)
csvPathName := "csv/output.csv"
books, err := NewBooksByCSV(csvPathName)
// logger := GetLogger()
// logger.Sugar().Warn("msg", "books", books)
if err != nil {
handleErrorLog(err)
handleResponseError(w, http.StatusInternalServerError, err.Error())
return
}
for i, r := range books {
if r.Id == requestJSON.Id {
books[i] = Book(requestJSON)
}
}
// 構造体をcsvデータのバイトスライスとしてマッピング
/// ここをjson.Marshalでやると、データがJSONとしてバイトスライスに変換される
b, err := csvutil.Marshal(books)
if err != nil {
handleErrorLog(err)
handleResponseError(w, http.StatusInternalServerError, err.Error())
return
}
if err := os.WriteFile(csvPathName, b, os.ModePerm); err != nil {
handleErrorLog(err)
handleResponseError(w, http.StatusInternalServerError, err.Error())
return
}
handleResponseSuccess(w, http.StatusOK, Book(requestJSON))
}
})
if err := http.ListenAndServe(":8080", nil); err != nil {
logger := GetLogger()
logger.Fatal("サーバーの起動に失敗", zap.Error(err), zap.Time("now", time.Now()))
}
}
func NewBooksByCSV(pathName string) ([]Book, error) {
f, err := os.Open(pathName)
if err != nil {
return nil, err
}
defer f.Close()
sr, _ := utfbom.Skip(f)
b, err := io.ReadAll(sr)
if err != nil {
return nil, err
}
var books []Book
if err = csvutil.Unmarshal(b, &books); err != nil {
return nil, err
}
return books, nil
}
func GetLogger() *zap.Logger {
var logger *zap.Logger
if os.Getenv("GO_ENV") == "prod" {
logger, _ = zap.NewProduction()
} else {
logger, _ = zap.NewDevelopment()
}
return logger
}
func handleErrorLog(err error) {
logger := GetLogger()
logger.Warn("Error", zap.Error(err), zap.Time("now", time.Now()))
}

strings.Containesである文字列にある文字が含まれるかチェックできる。
csvutilを使う上で参考になった記事