Scala + shapelessでExcelパーザーを自動生成

はじめに

最近は業務でScalaを使ってExcelを解析するプログラムを保守・運用している。Excelは、JSONやXML、YAMLといった他のデータフォーマットと異なり、非エンジニアであっても編集することができるというのが大きな特徴であるが、その代償として限定されたデータ構造しか記述できない。 Scalaにおいては、たとえばJSONはPlay JSONのようにScalaのケースクラスからマクロを用いてパーザーを自動生成するというのが普及している。一方でExcelにはそのようなものが(筆者らが知る限りにおいて)存在しなかったため、これまではPoi Scalaというパーザーを利用して、たとえば「あるシートの1:ADouble値として取得する」といった低レベルなプログラムを書く必要があった。そこで@ippei-takahashiがPlay JSONのようにマクロ( shapeless)を利用してExcelの行を表すケースクラスからパーザーを自動生成してパーズするライブラリーを開発した。また筆者はそのライブラリーをその部分だけで利用できるように簡単に修正し、またこの記事を執筆することにした。 この記事ではまずExcelのデータ構造について他のJSONなどと比較しつつ説明し、それがこのライブラリーでどのようにパーズできるかの例を示す。その後ライブラリーの実装について要所を解説し、最後にまとめを述べる。 この記事で説明するライブラリーの全ソースは下記のGitHubリポジトリーから参照できる。

この記事を読んで疑問点や改善点がある場合は、気軽にコメントなどで教えてほしい。

Excel vs JSON

ほとんどの人はExcelを1度は使ったことがあると思うが、Excelは次のようにシートと呼ばれる行列にデータを入れていくことができるシステムである。

上記の図ではsheet1というシートにデータを書き込んでいる。シートの名前は重複しない限り任意の文字列で与えられ、たとえば別のシートであるsheet2は次のようになっている。

このようなシンプルな構造なのでExcelで表現できる全てのデータはJSONで模倣できる1。たとえば上記の画像のExcelはJSONで次のように模倣できる。

{
  "sheet1": [
    [0, 1, 2, "hello", "world"],
    [true, 3.14]
  ],
  "sheet2": [
    ["this", "is", "a", "pen"]
  ]
}

このJSONのようなものがExcelのデータ構造である。重要な点を次にまとめる。

  • JSONはオブジェクト(連想配列)を任意にネストすることができるが、Excelではオブジェクトをトップレベルでしか利用できない
  • JSONはオブジェクトの値に任意のJSONを利用できるが、Excelは二次元配列しか利用できない
    • したがって、Excelの配列の要素は任意のプリミティブな値(数値、文字列、真偽値など)を入れることができるが、オブジェクトや配列を入れてはならない

このようなExcelデータを簡単にパーズすることを目指す。

Excel Reads

ここでは作成したライブラリーであるExcel Readsの使い方と実装について説明する。

使い方

まずはExcelの行に対応するケースクラスを用意する。

case class HelloWorld(
  hello: String,
  world: String
)

そして次のExcelを与える。

このファイルをPoi Scalaで読み込むと次のような抽象データ構造へと変換される。

val rowWithSheetName = RowWithSheetName(
  "sheet",
  Row(1) {
    Set(StringCell(1, "hello"), StringCell(2, "world"))
  }
)

RowWithSheetNameはライブラリーが提供するデータ構造で、行とシート名の組である。あとは先程のHelloWorldを型パラメーターとしてパーザーを起動するだけである。

ExcelReads[HelloWorld].read(rowWithSheetName) // Success(HelloWorld("hello", "world"))

パーザーの結果はScalazValidationNel2であり、Successは成功を表す。

実装の説明

まずは型クラスExcelReadsによってパーザーが定義・自動生成される。

trait ExcelReads[A] {
  protected def parseState(
    rowWithSheetName: RowWithSheetName
  ): State[Int, ValidationNel[ExcelRowParseError, A]]

  def read(
    rowWithSheetName: RowWithSheetName,
    initial: Int = 1
  ): ValidationNel[ExcelRowParseError, A] =
    parseState(rowWithSheetName).eval(initial)

  def map[B](f: A => B): ExcelReads[B] = { rowWithSheetName =>
    parseState(rowWithSheetName).map(_.map(f))
  }
}

まず型パラメーターAはパーズの結果得られる結果の型である。パーズ結果は関数readの結果の型ValidationNel[ExcelRowParseError, A]を返す。StringIntなどについては自明な型としてあらかじめデフォルトのインスタンスが与えられている。

implicit val parserStringOption: ExcelReads[Option[String]] = { rowWithSheetName =>
  State { s =>
    (
      s + 1,
      rowWithSheetName.row.cells.find(_.index == s).map {
        case StringCell(_, data) =>
          Success(data.trim)
        case cell =>
          failureNel(
            UnexpectedTypeCell(
              errorIndex = s,
              expectedCellType = StringCellType,
              actualCellType = CellType.fromCell(cell)
            )
          )
      }.sequence
    )
  }
}

まずは上記のようにOption[String]のようなOption[?]の型のインスタンスを作成する。State[Int, ?]Intは列方向のインデックスを示している。たとえばs + 1という処理は、1つセルをパーズしたので次のセルへと移動するという処理である。このようにStateモナドを利用しているため、たとえば3つのセルを利用するといったパーザーも自由に書くことができる。 そしてOption[A]のインスタンスからAのインスタンスを次のように導出する。

implicit def parseA[A](implicit R: ExcelReads[Option[A]]): ExcelReads[A] = { rowWithSheetName =>
  for {
    validation <- R.parseState(rowWithSheetName)
    s <- State.get[Int]
  } yield validation andThen {
    case Some(a) => Success(a)
    case None => failureNel(UnexpectedEmptyCell(s - 1))
  }
}

このようにするため、たとえばOption[Option[A]]のようなOptionがネストした型をパーズすることはできない。しかし、ネストしたOption型の値に対応するExcel表現が自明ではないことから、コードのシンプルさを優先してこのようなインスタンスとした。 また、Seq[A]Aのインスタンスを利用して次のようになる。

implicit def parserSeq[A](implicit R: ExcelReads[A]): ExcelReads[Seq[A]] = { rowWithSheetName =>
  val row = rowWithSheetName.row

  State { s =>
    val res: Seq[ValidationNel[ExcelRowParseError, A]] = unfold(s) { x =>
      val (next, value) = R.parseState(rowWithSheetName)(x)

      value match {
        case v @ Success(_) =>
          Some((v, next))
        case v @ Failure(_) =>
          if (row.cells.exists(_.index >= x))
            // Even if an error occurred at somewhere in the row,
            // it parses at the end to concat all errors.
            Some((v, next))
          else
            // Otherwise parsing is done.
            None
      }
    }

    (
      s + res.length,
      res.foldRight[ValidationNel[ExcelRowParseError, Seq[A]]](Success(Nil)) {
        (xv, acc) =>
          xv.ap(acc.map(xs => x => x +: xs))
      }
    )
  }
}

やや大きいコードだが、次のような特徴がある。

  • Validation型を利用している利点を活かすため、たとえパーズに失敗したとしても空のセルが出現するまでパーズを続けて可能な限りエラーを集めるようにしている
  • 簡単のためこの実装はバックトラックのような仕組みを搭載していない。したがってこのコードはケースクラスの末尾にSeqがある場合にのみしか正常に動作しない
    • 正規表現エンジンのようにバックトラックを実装することで、Seqがケースクラスのどの位置においても使えるようにするという戦略もあるが、しかしそうすると正規表現のように*が貪欲かそうでないか、などといった複雑さも生じる
    • これらの複雑さとユースケースを勘案した結果、ひとまずは末尾でのみSeqが使えれば十分であろうと考えてこの実装を与えた

最後にHListに関するインスタンスを作成する。HListはshapelessの機能でケースクラスからマクロでHListへ変換するため必要になる。

implicit val parserHNil: ExcelReads[HNil] = { _ =>
  State(s => (s, Success(HNil: HNil)))
}

implicit def parserHCons[H, T <: HList](
  implicit head: ExcelReads[H],
  tail: ExcelReads[T]
): ExcelReads[H :: T] = { rowWithSheetName =>
  for {
    hv <- head.parseState(rowWithSheetName)
    tv <- tail.parseState(rowWithSheetName)
  } yield
    hv.ap(tv.map(t => h => h :: t))
}

implicit def parserHList[A, L <: HList](
  implicit gen: Generic.Aux[A, L],
  parserHList: Lazy[ExcelReads[L]]
): ExcelReads[A] = { rowWithSheetName =>
  parserHList.value.parseState(rowWithSheetName).map(_.map(gen.from))
}

このようになっているが、特別に説明するところはないと思う。注意するべきなのはCoproductのインスタンスは用意されていないということである。たとえば次のようなケースクラスUserをデフォルトのインスタンスでパーズすることはできない。

sealed trait Sex
case class Male(value: String) extends Sex
case class Female(value: String) extends Sex

case class User(
  name: String,
  age: Int,
  sex: Sex
)

val rowWithSheetName = ???

ExcelReads[User].read(rowWithSheetName) // compile error!

これに対応するExcelは次のようになる。

代数的データ型(ADT)であるような「このうちのどれか」という構造のパーズを汎用に行なうためにはCoproductのインスタンスが必要になる。しかし次のような理由でデフォルトのインスタンスでこれは用意していない。

  • shapelessによるケースクラスからHListへの変換は、引数(フィールド)の並びとHList上の型の並びが対応するため順序が明らかに自明であるが、一方で代数的データ型の場合はどのような順序のCoproductが生成されるのかshapelessの実装次第となると思われる
  • そのため、たとえばEither[Double, Int]のようなとき、ExcelのセルではDoubleIntに区別がないため、どちらでパーズするのかに何らかの優先順位が必要になる
    • 上記のUserの例ではStringStringであるため、型だけで区別することは不可能である
  • たとえば正規表現ではA | Bのように書いた場合、正規表現AにもBにもマッチする場合は左を優先してAにマッチしたとする、といったルールがある
    • このようなルールは(正規表現エンジンごとに違いが多少あるとはいえ)正規表現の文字列としての表現からある程度知ることができるが、Coproductの生成方法はshapeless任せなのでこの実装を知っていなければならない
    • 型レベルリスト(HList)などで優先順位を与えるという方法もあるが、議論としてEitherのようなものが必要な場合は、一旦どちらも可能な1つのデータ型として解釈しておき、そのあとドメインモデルなどへバリデーション処理をしつつ変換するときにより厳密にすればよいという結論となった
    • Excelの解釈によって生まれるデータ型は、あくまでもData Transfer Object(DTO)であろうから、あまり複雑なバリデーションや変換をこのフェーズで一気にやる必要はないと判断した
  • このような検討の末、ひとまずのところはCoproductのインスタンスは作成しなかった

このようにしてExcelReadsのインスタンスが定義されている。

まとめ

このようExcelからケースクラスへのパーザーをshapelessで自動生成するようなものを作成した。今後の課題として、本文でも説明したがSeqをケースクラスの途中でも使えた方がなにかと便利そうな気はする。そのため多少は複雑になるが、このシステムがもし使えそうとなった時には、正規表現のようなバックトラックを導入したり、正規表現に近いDSLを導入して貪欲かどうかなどを与えられるようにしたい。

議論 (余談)

この実装が生まれたときの議論を紹介しておく。Excelのデータ構造がJSONで模倣できるので、JSONからケースクラスへ変換するPlay JSONがある以上この変換は可能であると思われていたが、使いやすいものになるかどうかという点が謎であった。

とはいえ作って使ってみないと分からないということで(?)@ippei-takahashiにより実装が行なわれた。

まだこのライブラリーを利用してがっつりとプログラムを書いたわけではないので、今後使ってみてどんどん洗練させていきたい。

追記

Seqを任意の場所に置くためにバックトラックについて考えていたところ、ねこはる君がScalaMatsuri2019で発表していたMonadPlusを使ったテクニックを利用すればいいのでは?と思い彼に相談してみたところ👇というふうになった……。

    • ようするに、たとえば次のようなケースクラスを考える

      case class TwoStringSeq(
        seq1: Seq[String],
        seq2: Seq[String]
      )
    • このようにしたとき、正規表現では(\w*)(\w*)のようなものになると言えるので、seq1に貪欲にマッチしてseq2は空となるという1つの戦略がある。しかし、正規表現では慣用的にこのように振る舞うが、こうでなければならないというわけでもない
    • したがってExcel上の表記と型との間の関係がどんどんと非自明になってしまう
    • このような議論があってCoproductのインスタンスをあえて作らなかったので、このような途中にあらわれるSeqもなくてよいのではないか
    • 一方でHListのインスタンスを改良することで、ケースクラスの末尾以外にSeqがある場合はコンパイルエラーとすることができそう
      • こちらはやる価値があるのではないか
    • Validationはアプリカティブなので、エラーを同時にいくつも集めることができるがバックトラックした場合、どのエラーを表示するのがよいのか分からなくなる
    • バックトラックした組み合せを全てエラー表示すると、ユーザーにとって使いやすいものではなくなりそう
    • したがってバックトラックを入れてしまうとValidationをつかっている意味が薄れるのではないか?

このあとでList[State[Int, ValidationNel[Error, A]]StateTをつかってStateT[List, ValidationNel[Error, A]]の方がいいですね、という話をした。


  1. 厳密に言えばExcelにはJSONには存在しない日付型や数式型などを持っているが、ここで重要な点はExcelのセルの型というよりは、オブジェクトの中にオブジェクトをネストできないといった構造の制約であるため、この差はひとまず無視するここにする。

  2. Validation[A, B]ANonEmptyListに強制したものであり次のように定義される。

    type ValidationNel[A, B] = Validation[NonEmptyList[A], B]

コメント