CSVを読み込んでみた！（F# and Csvhelper）

この記事は [F# advent calendar 2021](https://qiita.com/advent-calendar/2021/fsharp) の4日目です。

# Intro

callmekoheiは事務員さんなので、CSVというファイルをよく扱います。昔はVBAというエクセルについてる言語で扱ってたのですが、データ件数が多くて処理できなくなったのでF#に切替えました。で、Csvhelperがとても便利なのでメモしたいと思います！

## CSVとは？

```csv
りんご、ばなな、ちぇりー
```

みたいな感じです（RFC4180的な...）

## Csvhelperとは？

`F#`でCSVをらくに読み書きできるライブラリです。（`VB`とか`C#`という言語でも使用できます）

## 使い方1

下記の様な感じで使用します

```fsharp
// class(CSVの構造）
type foo = {
  // ...
}

// class map (csvの構造に肉付け)
type fooMap {
  // ...
}

// read(実際に読み込む)
read<foo>()
```

## 使い方2

`callmekohei`は下記の様なやり方で`csv`を読み込んでます

### 1. class （csvの構造）

mutable を使用して、csvのデータ以外を書き込んだりします 
e.g. ファイル名に書かれてる会社コードとか


```fsharp
[<CLIMutableAttribute>] // おまじない
type MyCsv =
  {
    mutable foo:string // 後で変更することができる
    bar:int
    baz:string
  }
```

### 2. class map （肉付け）

ここでデータチェックとかしたりします

```fsharp
type MyCsvMap () as this =
  inherit ClassMap<MyCsv>()
  let sb = System.Text.StringBuilder()
  let csv = Unchecked.defaultof<MyCsv>
  let dic = Microsoft.FSharp.Reflection.FSharpType.GetRecordFields(typeof<MyCsv>) |> Seq.mapi(fun idx x -> x.Name,idx) |> Map.ofSeq
  let fieldValue (rObj:CsvHelper.ConvertFromStringArgs) columnName = ( columnName |> dic.TryFind |> fun x -> x.Value) |> rObj.Row.GetField<'T>
  do

    this.Map(fun x -> x.商品名).Index(0) |> ignore
    this.Map(fun x -> x.価格).Index(1) |> ignore
    // validate all fields at last column
    this.Map(fun x -> x.色)
    |> fun x -> x.Convert(this.RowFieldsValidate)
    |> fun x -> x.Index(2)
    |> ignore

  // validateする場合
  member this.RowFieldsValidate:CsvHelper.ConvertFromStringArgs -> string =
    fun rowObj ->
      sb.Clear() |> ignore

      (fieldValue rowObj (nameof(csv.商品名)))
      |> MyValidate.guardOverLen "商品名" sb 10
      |> ignore

      if sb.ToString() <> ""
      then raise <| MyCsvException (sb.ToString())
      (fieldValue rowObj (nameof(csv.色)))

```

データチェックには下記を使用したりします（まぁお好みで...）

```fsharp
// validate用の便利関数
module MyValidate =
  open System.Text
  open FSharp.Core
  let either fOk fError = function
    | Ok x -> fOk x
    | Error x -> fError x
  let either2 f = function
    | Ok x -> f x
    | Error x -> f x
  let (|OverLen|_|) n = function
    | (s:string) when s.Length > n -> Some s
    | _ -> None
  // 任意の文字数より上の文字数のみ通さない guard
  let guardOverLen label (sb:StringBuilder) n = function
    | OverLen n s -> Error(sb.Append($"{label}:letters is over {n}.") |> ignore ; s)
    | s -> Ok s
```

### 3. reading by hand

大体は by hand で読み込んでます

```fsharp
  (*

    A. CSVレコード格納用変数（Array.Resize()とかの方が速度的によさげ？）

  *)

  let cqBad = new ConcurrentQueue<obj>()
  let cq = new ConcurrentQueue<MyCsv>()

  // 一時変数が必要な場合は下記を使用したりすると便利かも...
  // let tmp_hoge = ref Unchecked.defaultof<Csvhoge>


  (*

    B. CSV読込設定（お好みで...）

  *)

  // util func（ヘッダーをスキップする際に使用する）
  let skipRows (csv:CsvReader) i =
    for j in [1..i] do
      csv.Read() |> ignore

  let config = CsvConfiguration(CultureInfo.CurrentCulture)
  // config.NewLine <- "\n"（多分これは効かない...笑）
  config.Delimiter <- ","
  config.IgnoreBlankLines <- true
  config.HasHeaderRecord <- false
  config.TrimOptions <- TrimOptions.Trim
  config.ShouldSkipRecord <- (fun arg -> arg.Record.[0] = "EOF" )
  let csv = new CsvReader( streamReader , config)
  // Multiple Record type の場合はここをレコードタイプ毎に追加していく e.g. <csv1>, <csv2>
  csv.Context.RegisterClassMap<MyCsvMap>() |> ignore

  (*

    C. CSV読込（Reading by hand）

  *)

  // ファイル名から情報を取得する
  // let fileCreatedTime = finfo.Name.Replace(".zip","").Split('_') |> Array.item 4 |> fun dt -> System.DateTime.ParseExact(dt,"yyyyMMddHHmmss", DateTimeFormatInfo.InvariantInfo, DateTimeStyles.None)

  let mutable flg = true
  while flg do
    try
      flg <- csv.Read()
      if flg
      then

        let tmp = csv.GetRecord<MyCsv>()
        tmp.ファイル作成日時 <- fileCreatedTime
        cq.Enqueue(tmp)

  (*

    D. エラー処理（お好みで...）

  *)

    with
      | :? CsvHelperException as e ->

        match e with

        // classmapエラーはここに来る
        | :? CsvHelper.TypeConversion.TypeConverterException as te ->
          let errMsg = ...
          let errLog:ErrorRecordLog = {...}
          cqBad.Enqueue(errLog)

        | _ ->

          match e.InnerException with
          // 各々にて作成したvalidationエラーはここに来る
          | :? MyCsvException as myerr ->
            let errMsg = myerr.Data0 // 自作のエラーメッセージ
            let errLog:ErrorRecordLog = {...}
            cqBad.Enqueue(errLog)

          | _ -> ...

      | otherErr -> ...

  (*

    E. 返り値

  *)

  (
      cqBad
    , cq
  )

```

## データ補正

例えば下記みたいなデータを補正する必要があったりします

```csv 
あああ,300,いいい
ううう,1,980,えええ
```

補正できる場合（readをtryではさむ）

```fsharp 

try 
  read<foo>()
with e -> 
  ここで補正する

```

補正できない場合

```
1. CSV作成元に『頼むぽよー』とお願いする
2. 泣き寝入りして、こちらで手作業で修正する
```

上記１，２は関係性で決まります

## その他

### 規模感

毎日、一ファイルで１０００万レコードとかも普通にサクサク読込めるので、この辺りのボリュームだったらオケかと。（zipされてるcsvをよみこんでます）

### 改行コードの入り乱れ

```
config.NewLine <- "\n"（多分これは効かない...笑）
```

この設定が、効かない？感じなので、とりあえずバイナリで読み込んでLF以外はスペースにする等の処理したりして回避したりしてます。（今後に期待？）

### 情報がない？

あまり情報がなさげです（ググる力がないともいう。。。）トライ＆エラーが必要です

### 放流的な🐟

https://gist.github.com/callmekohei/c415569c28e393aee6bb6a44930690d6


# 現場からは以上です(｡･ω･｡)ﾉ Enjoy!