Play(Scala)でCSVを吐き出す機構をサクッと作った

直近業務でCSVを吐き出す必要があり、既存サービスの実装の使い勝手に多少問題があるとのことだったので、冬休みの自由研究としてCSVをPlayで扱う機構をサクッと考えて作ってみました。よくある話だと思うので、思想含めて参考になればと思いまとめてみました。

# アーキテクチャ

DBの一部の情報をCSVとして欲しいと言う、ちょっとWEBサービスを作る身としては悲しい要望ですが、どうしてもローデータが欲しいと言う状況はtoBのサービスではあるあるの要望だと思います。物凄い雑にアーキテクチャを書くと以下のようになります。

HTTPリクエスト -> DB -> (Model) -> **CSVを作る機構** -> HTTPレスポンス

# なぜCSVを作る機構を作る必要があるのか

CSVを扱うライブラリはいくらでもあります。それではなぜ、わざわざWEBサービスにおけるCSVを作る機構と言うものが必要なのでしょうか。それは開発者として以下の要求を満たしたいからです。

- CSVの仕様を担保したい
- CSV上における表現をサービスで統一したい
- フレームワーク(PlayFramework)におけるCSVファイルの扱いを意識せず書きたい
- ドメインに応じてCSV上の表現を変えたい

それぞれの要求を見ていく前に、今回作った機構を用いた結果どのようにCSVを吐き出せるかのサンプルコードを見てみましょう。

[リポジトリ](https://github.com/ababup1192/play-csv-download)

```Model.scala
case class Model(id: Long,
                 name: String,
                 gender: Gender,
                 ageOpt: Option[Int],
                 hasGitHubAccount: Boolean,
                 favoriteLanguageSeq: Seq[String],
                 birthDate: LocalDate,
                 registerDateTime: ZonedDateTime) {

  import util.csv.syntax._
  def toCSVRow: CSVRow = CSVRow(
    id.asCSV,
    name.asCSV,
    gender.value.asCSV,
    ageOpt.map(_.toString).asCSV,
    (if (hasGitHubAccount) "o" else "").asCSV,
    favoriteLanguageSeq.asCSV,
    birthDate.asCSV,
    registerDateTime.asCSV
  )
}

object Model {
  val csvHeader: CSVHeader =
    CSVHeader("ID", "名前", "性別", "年齢", "GitHubアカウントの有無", "得意言語", "誕生日", "登録時間")
}

abstract class Gender(val code: String, val value: String)

case object FEMALE extends Gender("FEMALE", "女")

case object MALE extends Gender("MALE", "男")
```

```Controller.scala
def index(): Action[AnyContent] = Action {
    implicit request: Request[AnyContent] =>
      {

        val models = Seq(
          Model(
            id = 1,
            name = "abab",
            gender = MALE,
            ageOpt = Some(30),
            hasGitHubAccount = true,
            favoriteLanguageSeq = Seq("Elm"),
            birthDate = LocalDate.of(1988, 1, 1),
            registerDateTime =
              ZonedDateTime.of(2020, 1, 1, 12, 0, 0, 0, ZoneId.of("UTC"))
          ),
          Model(
            id = 2,
            name = "↑↓",
            gender = MALE,
            ageOpt = Some(15),
            hasGitHubAccount = false,
            favoriteLanguageSeq = Seq("Scala", "TypeScript"),
            birthDate = LocalDate.of(2000, 4, 1),
            registerDateTime =
              ZonedDateTime.of(2000, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"))
          ),
          Model(
            id = 3,
            name = "BA",
            gender = FEMALE,
            ageOpt = None,
            hasGitHubAccount = true,
            favoriteLanguageSeq = Seq(),
            birthDate = LocalDate.of(1000, 10, 1),
            registerDateTime =
              ZonedDateTime.of(100, 1, 1, 8, 0, 0, 0, ZoneId.of("UTC"))
          )
        )

        val csvResult =
          CSVUtil.createCSV(Model.csvHeader, models.map(_.toCSVRow))

        Result(
          header = ResponseHeader(
            status = 200,
            headers = Results
              .contentDispositionHeader(inline = false, name = Some("hoge.csv"))
          ),
          body = HttpEntity
            .Streamed(
              csvResult.source,
              csvResult.contentLength,
              Some("text/csv")
            )
        )
      }
  }
}
```

Scalaは表現力が高い言語のためScalaを知らない人でも、きっちり隠蔽されCSVを生成する過程をあまり意識することなく自然に読めそうなコードになっていそうですね。

## CSVの仕様を担保したい

これはCSVを扱う上でコアにあたる部分です。CSVは非常に歴史があるデータ形式です。ここは扱いやすさや先進性がありライブラリの成長度を取る必要はあまりなく、安定した枯れたライブラリを選ぶ方が良いと思いました。これに関しては無理にScalaのライブラリを選ぶよりもJavaのライブラリを選ぶ方が確実性が高いと判断しました。幸いにもApach Commonsの[commons-csv](https://commons.apache.org/proper/commons-csv/)は、それなりに扱いやすくラップしやすいライブラリでした。安心感は言うまでもありませんね。

## CSV上における表現をサービスで統一したい

CSV上における表現はサービスによって統一されるべきです。CSVの種類が複数ある場合の表記が揺れているのは好ましくありませんね？また、今回はJavaライブラリを利用しているため、Scalaでの標準の型`Option`, `Seq`等がCSVで自然には表現されません。他にサービスで統一したい表現は日付や日時でしょうか。言葉での説明よりもテストコードで見た方がわかりやすいと思います。

```CSVUtilSpec.scala
class CSVUtilSpec extends FunSpec {
  describe("CSVUtil") {
    describe("CSVString#toRecord") {
      it("文字列がそのまま表示されること") {
        assert("あ" == CSVString("あ").toRecord)
      }
    }

    describe("CSVOptionString#toRecord") {
      describe("Stringがあるとき") {
        it("文字列がそのまま表示されること") {
          assert("あ" == CSVOptionString(Some("あ")).toRecord)
        }
      }
      describe("Stringがないとき") {
        it("何も表示されること") {
          assert("" == CSVOptionString(None).toRecord)
        }
      }
    }

    describe("CSVStringValues#toRecord") {
      describe("Stringがないとき") {
        it("カンマ区切りで文字列が表示されること") {
          assert("" == CSVStringValues(Seq.empty).toRecord)
        }
      }

      describe("Stringが一つあるとき") {
        it("カンマ区切りで文字列が表示されること") {
          assert("あ" == CSVStringValues(Seq("あ")).toRecord)
        }
      }

      describe("Stringが複数あるとき") {
        it("カンマ区切りで文字列が表示されること") {
          assert("a,b,c" == CSVStringValues(Seq("a", "b", "c")).toRecord)
        }
      }
    }

    describe("CSVLong#toRecord") {
      describe("数値があるとき") {
        it("文字列として表示されること") {
          assert("1" == CSVLong(1).toRecord)
        }
      }

      describe("桁数の大きい数値があるとき") {
        it("文字列として表示されること") {
          assert(
            "1234567890112344567" == CSVLong(1234567890112344567L).toRecord
          )
        }
      }
    }

    describe("CSVDate#toRecord") {
      describe("日付があるとき") {
        it("YYYY/MM/DDの形式の文字列として、表示されること") {
          assert("2020/01/01" == CSVDate(LocalDate.of(2020, 1, 1)).toRecord)
        }
      }
    }

    describe("CSVDateTime#toRecord") {
      describe("時間があるとき") {
        it("UTC時間が日本時間に変換され、yyyy/MM/dd HH:mmの形式の文字列として、表示されること") {
          assert(
            "2020/01/01 09:00" == CSVDateTime(
              ZonedDateTime.of(2020, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"))
            ).toRecord
          )
        }
      }
    }
  }
}
```

実装コードは以下のようになります。特に難しい実装ではありません。

```CSVUtil.scala
// ...

sealed trait CSVItem {
  def toRecord: String
}

case class CSVString(value: String) extends CSVItem {
  override def toRecord: String = value
}
case class CSVOptionString(value: Option[String]) extends CSVItem {
  override def toRecord: String = value.getOrElse("")
}
case class CSVStringValues(values: Seq[String]) extends CSVItem {
  override def toRecord: String = values.mkString(",")
}
case class CSVLong(value: Long) extends CSVItem {
  override def toRecord: String = value.toString
}
case class CSVDate(value: LocalDate) extends CSVItem {
  override def toRecord: String = value.toString.replaceAll("-", "/")
}
case class CSVDateTime(value: ZonedDateTime) extends CSVItem {
  override def toRecord: String = formatJapanTime(value)

  private def formatJapanTime(dateTime: ZonedDateTime): String =
    DateTimeFormatter
      .ofPattern("yyyy/MM/dd HH:mm", Locale.JAPANESE)
      .format(dateTime.withZoneSameInstant(ZoneId.of("Asia/Tokyo")))
}
```

## フレームワーク(PlayFramework)におけるCSVファイルの扱いを意識せず書きたい

フレームワークからCSVを生成する際に、commons-csvを意識はしたくありません。Playが必要とする情報に変換して、そのロジックは隠蔽してあげましょう。ここに変更が加わる時は、サービスの扱うCSVの形式が変わる時になります。


```CSVUtil.scala
case class CSVResult(source: Source[ByteString, _], contentLength: Option[Long])

object CSVUtil {
  def createCSV(header: CSVHeader, rows: Seq[CSVRow]): CSVResult = {

    val tempFile = File.createTempFile("test", ".csv", null)
    val writer = Files.newBufferedWriter(tempFile.toPath)

    val csvPrinter =
      CSVFormat.EXCEL.withHeader(header.values: _*).print(writer)

    rows.foreach(row => csvPrinter.printRecord(row.values.map(_.toRecord): _*))
    csvPrinter.flush()

    CSVResult(
      FileIO.fromPath(tempFile.toPath),
      Some(Files.size(tempFile.toPath))
    )
  }
}

// ...
```

CSVをAPIとして返したい場合は、ボイラプレートになります。Playではファイルサイズが大きくなる可能性がある場合は、Akkaの`Source`型に変換し、コンテンツの長さを`HttpEntity#Streamed`に渡してあげることで自動でChunkしてくれるそうです。こういう時フルスタックフレームワークである強みを感じますね。

https://www.playframework.com/documentation/2.8.x/ScalaStream

```Controller.scala
 val csvResult =
          CSVUtil.createCSV(Model.csvHeader, models.map(_.toCSVRow))

        Result(
          header = ResponseHeader(
            status = 200,
            headers = Results
              .contentDispositionHeader(inline = false, name = Some("hoge.csv"))
          ),
          body = HttpEntity
            .Streamed(
              csvResult.source,
              csvResult.contentLength,
              Some("text/csv")
            )
        )
```

## ドメインに応じてCSV上の表現を変えたい

CSVの形式をcommons-csvが担保し、サービス共通な表現はCSVUtilとそのテストが保証しましたが、どうしてもドメイン固有の表現を考慮しなければならないケースがあります。それはBool型、Enumや独自で用意した型になります。Bool型はScalaのプリミティブな型ですが、CSVの項目によって はい/いいえ, YES/NO, o/x など表現方法が異なるため敢えて用意はしませんでした。そのような項目のためにテストを用意してあげます。

```ModelSpec.scala
class ModelSpec extends FunSpec {

  describe("#toCSVRow") {
    describe("gender=FAMELE, gasGitHubAccount=trueのとき") {
      it("Modelが正しくCSVRowに変換できていること") {
        val model = Model(
          id = 1,
          name = "abab",
          gender = MALE,
          ageOpt = Some(30),
          hasGitHubAccount = true,
          favoriteLanguageSeq = Seq("Elm"),
          birthDate = LocalDate.of(1988, 1, 1),
          registerDateTime =
            ZonedDateTime.of(2020, 1, 1, 12, 0, 0, 0, ZoneId.of("UTC"))
        )

        val csvRow = CSVRow(
          CSVLong(1),
          CSVString("abab"),
          CSVString("男"),
          CSVOptionString(Some("30")),
          CSVString("o"),
          CSVStringValues(Seq("Elm")),
          CSVDate(LocalDate.of(1988, 1, 1)),
          CSVDateTime(
            ZonedDateTime.of(2020, 1, 1, 12, 0, 0, 0, ZoneId.of("UTC"))
          )
        )

        assert(model.toCSVRow == csvRow)
        // Rowのサイズとヘッダの長さが一致していること
        assert(csvRow.size == Model.csvHeader.values.length)
      }
    }

    describe("gender=FEMELE, gasGitHubAccount=falseのとき") {
      it("Modelが正しくCSVRowに変換できていること") {
        val model = Model(
          id = 1,
          name = "abab",
          gender = FEMALE,
          ageOpt = Some(30),
          hasGitHubAccount = false,
          favoriteLanguageSeq = Seq("Elm"),
          birthDate = LocalDate.of(1988, 1, 1),
          registerDateTime =
            ZonedDateTime.of(2020, 1, 1, 12, 0, 0, 0, ZoneId.of("UTC"))
        )

        val csvRow = CSVRow(
          CSVLong(1),
          CSVString("abab"),
          CSVString("女"),
          CSVOptionString(Some("30")),
          CSVString(""),
          CSVStringValues(Seq("Elm")),
          CSVDate(LocalDate.of(1988, 1, 1)),
          CSVDateTime(
            ZonedDateTime.of(2020, 1, 1, 12, 0, 0, 0, ZoneId.of("UTC"))
          )
        )

        assert(model.toCSVRow == csvRow)
        // Rowのサイズとヘッダの長さが一致していること
        assert(csvRow.size == Model.csvHeader.values.length)
      }
    }
  }
}
```

実装は以下のようになります。

```Model.scala
case class Model(id: Long,
                 name: String,
                 gender: Gender,
                 ageOpt: Option[Int],
                 hasGitHubAccount: Boolean,
                 favoriteLanguageSeq: Seq[String],
                 birthDate: LocalDate,
                 registerDateTime: ZonedDateTime) {

  import util.csv.syntax._
  def toCSVRow: CSVRow = CSVRow(
    id.asCSV,
    name.asCSV,
    gender.value.asCSV,
    ageOpt.map(_.toString).asCSV,
    (if (hasGitHubAccount) "o" else "").asCSV,
    favoriteLanguageSeq.asCSV,
    birthDate.asCSV,
    registerDateTime.asCSV
  )
}

object Model {
  val csvHeader: CSVHeader =
    CSVHeader("ID", "名前", "性別", "年齢", "GitHubアカウントの有無", "得意言語", "誕生日", "登録時間")
}

abstract class Gender(val code: String, val value: String)

case object FEMALE extends Gender("FEMALE", "女")

case object MALE extends Gender("MALE", "男")
```

Modelの`#toCSVRow`メソッドでは、CSVRowが必要とするCSVItem型を構築するために本来であればCSVString, CSVDateのようなクラスのコンストラクタを用意する必要がありましたが冗長な表現になってしまいます。そのためCSVで表現できるプリミティブな型には、`#asCSV`のようなCSVItemに変換するメソッドを生やしています。いわゆる型クラスです。 `import util.csv.syntax._` このようなpackageオブジェクトをimportすることで可能になります。

以下のようにただ値を包んであげているだけです。

```syntax/package.scala
package object syntax {
  implicit class RichString(val self: String) {
    def asCSV: CSVItem = CSVString(self)
  }

  implicit class RichOptionString(val self: Option[String]) {
    def asCSV: CSVItem = CSVOptionString(self)
  }

  implicit class RichStringValues(val self: Seq[String]) {
    def asCSV: CSVItem = CSVStringValues(self)
  }

  implicit class RichLong(val self: Long) {
    def asCSV: CSVItem = CSVLong(self)
  }

  implicit class RichDate(val self: LocalDate) {
    def asCSV: CSVItem = CSVDate(self)
  }

  implicit class RichDateTime(val self: ZonedDateTime) {
    def asCSV: CSVItem = CSVDateTime(self)
  }
}
```

# まとめ

CSVは一見するとカンマで表を用意するだけの単純なデータ構造に見えますが、CSV上での表現を考えたり サービスという観点が入ると実はバカにできないぐらい難しい問題になってきます。そのようなときは、ユーザが求めるCSVと開発者としての要望を慎重に分析し機構を用意するべきです。これはCSVだけに限らず様々な場合で起こりうる問題です(これだからサービス開発はやめられませんね)。また、Scalaの表現力の高さやPlayFrameworkの強力さも伝えられたと思います。それでは来年も開発を頑張っていきましょう。