🧮

Tagless-final + EffなScalaによるExcelパーザー

2021/02/09に公開

https://zenn.dev/yyu/articles/d7d965b661e158

はじめに

かつてExcelのパーザーを自作する次のような記事を書いた。

この記事ではshapelessを利用してScalaのケースクラスから自動的にパーザーを生成するExcelReadsという型クラスの解説をした。ただ、これに対して最近の議論によって次のような問題が指摘された。

  • 従来のExcelReadsPoi Scalaが提供するExcelの抽象化に完全に依存しているため、たとえばApache POIにExcelのバックエンドを切り替えたくなった場合にインスタンスは作り直しになってしまう
  • Poi Scalaは下記のような問題が指摘されており、バックエンドをApache POIにしたいといった要請が存在する
    • Excelが持ついくつかの役割のうちCSVのようなデータ部分に強くフォーカスしており、たとえばセルのスタイル情報の取得が不十分である
    • 膨大にあるExcelの仕様と照らしあわせて、たとえば日付といった一部の処理に問題があると思われる
    • EqualsSemigroupなどScalazインスタンス定義がかなり適当である

したがって新しいExcelReadsはバックエンドをTagless-final[1]的な手法によって切り替え可能にしたうえで、かつExcelReadsのインターフェースを完全にExtensible Effects(Eff)ベースとした。この記事ではこれらの設計について解説する。この記事の述べるライブラリーの完全なソースコードは下記のGitHubリポジトリーに置かれている。

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

利用例

説明の前に、この新しいExcelReadsはどのように使えるのかを使えるのか書いておく。

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

まずはこのような行(Row)に対応するようなケースクラスを作る。そして次のようなEffのエフェクトスタックを与える[3]

type R = Fx.fx2[Reader[PoiScalaRow, *], State[Int, *]]

あとは次のように行データを与えればパーズすることができる。

val row = PoiScalaRow(
  Row(0) {
    Set(StringCell(0, "hello"), StringCell(1, "world"))
  }
)

val actual = ExcelReads[R, HelloWorld]
  .parse
  .runReader(row)
  .evalState(0)
  .run

assert(actual == Valid(HelloWorld("hello", "world")))

また、テストには実際のExcelファイルをパーズするものもある。

ExcelReadsのインターフェース

ExcelReadsは次のようなインターフェースとなっている。

ExcelReads.scala
trait ExcelReads[R, A] {
  def parse(implicit
    m: State[Int, *] |= R
  ): Eff[R, ValidatedNel[ExcelParseError, A]]
}

まず2つの型パラメーターRAについて説明する。まず型パラメーターREffのエフェクトスタックであり、かつimplicitパラメーターmとしてRState[Int, *]が含まれていることを要請している。そしてAはパーズの結果得られる型であり、ValidatedNelの形で場合によってはエラーを出力する。
そしてメソッドparseが実際にExcelをパーズする部分であるが、ここではExcelの行を表す型などそういった具体的な情報は一切なく、先ほど述べたように単にEffのエフェクトスタックにState[Int, *]が含まれていることしか要請しない。このStateに含まれるIntはExcelの行における列方向のインデックスとなっている。

Tagless-finalなExcelバックエンドインターフェース

見てきたようにExcelReadsにはExcelに関する情報が一切含まれていない。今回のExcelReadsではTagless-finalな抽象化によってExcelを実際に読み込むバックエンドを切り替え可能にしている。Tagless-finalとは型クラスを利用してインターフェースと実装を分離する手法であり、DIで利用するGuiceと似ているが、型クラスを利用するのでコンパイルタイムに配線が行われる。
まずはGuiceの利用時のように次のようなインターフェースを作成する。

ExcelBasicSYM.scala
abstract class ExcelBasicSYM[F[_]: Monad] {
  def getString(
    index: Int
  ): F[ValidatedNel[ExcelParseError, Option[String]]]

  def getDouble(
    index: Int
  ): F[ValidatedNel[ExcelParseError, Option[Double]]]

  def getInt(
    index: Int
  ): F[ValidatedNel[ExcelParseError, Option[Int]]]

  def getBoolean(
    index: Int
  ): F[ValidatedNel[ExcelParseError, Option[Boolean]]]
}

ExcelBasicSYMの高階型パラメーターFはモナドであり、そのインスタンスを要請する。そして各メソッドはそのFで包んだF[ValidatedNel[ExcelParseError, Option[?]]]という形となっている[4]。このインターフェースにもExcelの行列などといった具体的情報が存在せず、引数は全てindex: Intという列方向のインデックス情報だけとなっている。一見するとこのインターフェースではExcelに関する情報を得られないと思うかもしれない。そういう具体的な情報は高階型パラメーターF[_]に詰め込む。次の実装をみるとわかりやすい。

PoiScalaExcelBasicSYM.scala
class PoiScalaExcelBasicSYM[R](implicit
  m: Reader[PoiScalaRow, *] |= R
) extends ExcelBasicSYM[Eff[R, *]] {

ExcelBasicSYMの実装PoiScalaExcelBasicSYMは高階な型としてEff[R, *]を渡し、かつこれのエフェクトスタックRにはReader[PoiScalaRow, *]が入っていることを要請している。つまりReaderモナドのようにaskでExcelの具体的な列情報を得られるようになっている。ちなみにPoiScalaRowはPoi Scalaの型をラッピングしただけの構造で特別なことは何もない。

PoiScalaRow.scala
case class PoiScalaRow(
  value: Row
) extends AnyVal

あとはこのReaderがあること前提に(つまりimplicitパラメーターを引数に取りつつ)PoiScalaExcelBasicSYMを実装すればよい。

PoiScalaExcelBasicSYM.scala
private def successNel[A](a: A): ValidatedNel[ExcelParseError, A] =
  Validated.Valid(a)
 
private def failureNel[A](e: ExcelParseError): ValidatedNel[ExcelParseError, A] =
  Validated.Invalid(NonEmptyList(e, Nil))
 
private def get[A](
  index: Int,
  pf: PartialFunction[Cell, A]
): Eff[R, ValidatedNel[ExcelParseError, Option[A]]] =
  for {
    row <- ask
  } yield row.value.cells
    .find(_.index == index) match {
    case Some(a) =>
      pf
        .andThen(a => successNel(Some(a)))
        .applyOrElse(
          a,
          (_: Cell) =>
            failureNel(
              UnexpectedEmptyCell(errorIndex = index)
            )
        )
    case None =>
      successNel(None)
  }
 
override def getString(index: Int): Eff[R, ValidatedNel[ExcelParseError, Option[String]]] =
  get(
    index,
    { case StringCell(_, data) => data }
  )

Poi Scala関連のコードがやや冗長となってしまっているが、重要なところは下記のaskReaderからExcelの行情報を取得して利用しているところである。

for {
  row <- ask
} yield row.value.cells

同じようにExcelBasicSYMのApache POIを直接利用するバージョンを作ることができる。

ApachePoiRow.scala
case class ApachePoiRow(
  value: Row
) extends AnyVal
ApachePoiExcelBasicSYM.scala
class ApachePoiExcelBasicSYM[R](implicit
  m: Reader[ApachePoiRow, *] |= R
) extends ExcelBasicSYM[Eff[R, *]] {
  ???
}

バックエンドの拡張可能性

Tagless-finalではExcelBasicSYM以外のインターフェースを作ることができる。たとえば次のようなセルの背景色といったスタイル情報を取得するインターフェースExcelStyleSYMを追加で作成できる。

ExcelStyleSYM.scala
abstract class ExcelStyleSYM[Style, F[_]: Monad] {
  def getStyle(
    index: Int
  ): F[ValidatedNel[ExcelParseError, Option[Style]]]
}

あとは同様にPoi ScalaやApache POIの実装を作成すればよい。

Tagless-final vs Algebraic data type(ADT)

Poi ScalaのようにExcelに対応するケースクラスを作る、いわゆる代数的データ型(Algebraic data type, ADT)を利用する方法もある。しかし少なくとも今回のExcelパーズに限ってはTagless-finalのようなインターフェースを利用したやり方が次のような理由でよいと考えた。

  • Excelの仕様はECMA-376 Office Open XML file formatsという仕様の一部としてまとめられている。全てがExcelのための仕様ではないにしろ、5000ページ以上あるPDFに書かれた内容をきちんと理解して一発で適切なデータ構造を設計するのは無理がある
  • 一方でADTを利用した方法はたとえば下記のようにsealed trait/case classを作ることになる
    Cell.scala
    sealed abstract class Cell(val index: Int, val style: Option[CellStyle]) 
    case class StringCell(override val index: Int, data: String) extends Cell(index, None)
    case class NumericCell(override val index: Int, data: Double) extends Cell(index, None)
    
  • そして、ADTを使えば次のようにパターンマッチするようなコードがいずれ出現する
    (cell: Cell) match {
      case StringCell(index, data) => ???
     
      case NumericCell(index, data) => ???
    }
    
  • このようにパターンマッチが作られたあとに、先ほどのsealed trait Cellに別のケースクラスを追加すると上記のパターンマッチに網羅性検査警告が出てしまう
    • 警告なのでもちろん放置することもできるが、それはランタイムエラーで壊れる可能性へと問題を変換したにすぎない
  • したがってこのようにADTを作る場合は、最初に全てが網羅されている方が既存の部分の網羅性検査警告をいちいち修正してまわるといった手間が減る
  • 前節で述べたように、Tagless-finalはインターフェースを利用するので、このように最初に全てを網羅する必要がなくオンデマンドに追加することができる
    • これはたとえばGuiceでデータベース用のインターフェースを作って最初はそれだけを利用して、あとで必要になったらRedis用のインターフェースを作って依存を開始する、といったことに似ている

今回はTagless-finalを利用したが、このような問題を解決する方法としてData types à la carteのようなFreeモナドを駆使した方法も提案されている。しばしば例として登場するように、Tagless-finalはコンパイルタイム/ランタイムの違いがあるが達成したいことは産業界で頻繁に利用されるGiuceと似ており、産業界に多少は親和性があるという考えでこちらを採用した。

ExcelReadsのインスタンス定義

Tagless-finalを利用してExcelバックエンド部分を抽象化したので、いよいよこれを利用したExcelReadsインスタンスを作成する。

ExcelReads.scala
object ExcelReads {
  def from[R, A](
    f: State[Int, *] |= R => Eff[R, ValidatedNel[ExcelParseError, A]]
  ): ExcelReads[R, A] = (m: State[Int, *] |= R) => f(m)
}
ExcelReadsInstances.scala
trait ExcelReadsInstances {
  private def basicInstance[R, A](
    f: ExcelBasicSYM[Eff[R, *]] => Int => Eff[R, ValidatedNel[ExcelParseError, A]]
  )(implicit
    sym: ExcelBasicSYM[Eff[R, *]]
  ): ExcelReads[R, A] =
    ExcelReads.from { implicit m =>
      for {
        s <- get
        aOpt <- f(sym)(s)
        _ <- put(s + 1)
      } yield aOpt
    }
   
  implicit def stringInstance[R](implicit
    sym: ExcelBasicSYM[Eff[R, *]]
  ): ExcelReads[R, Option[String]] =
    basicInstance { sym => sym.getString }
}

全部書くと大変なので、とりあえずはOption[String]なインスタンスだけを取り出した。これはGuice風に書くと次のようになる。

class ExcelReadsInstances @Inject()(
  sym: ExcelBasicSYM[Eff[R, *]]
) {
  def basicInstance[R, A](
    f: ExcelBasicSYM[Eff[R, *]] => Int => Eff[R, ValidatedNel[ExcelParseError, A]]
  ): ExcelReads[R, A] =
    ExcelReads.from { implicit m =>
      for {
        s <- get
        aOpt <- f(sym)(s)
        _ <- put(s + 1)
      } yield aOpt
    }

  def stringInstance[R]: ExcelReads[R, Option[String]] =
    basicInstance { sym => sym.getString }
}

型パラメーターが非常に複雑ではあるが、やっていることはbasicInstanceメソッドのfor内の次の処理でほぼ全てである。

  1. getState[Int, *]からIntな値を取り出しsに代入する
  2. それを利用して実際にsymを呼び出す
    • 上記のコードは汎用化のためsymを呼び出すコードを関数fとして引数で取るようになっている。この部分をstringInstanceを例に展開すると次のようになる
      for {
        s <- get
        aOpt <- sym.getString(s)
        _ <- put(s + 1)
      } yield aOpt
      
  3. s + 1を新しい状態として保存(put)する

また以前の記事と同様にHListのインスタンスを作っておき、shapelessを利用して任意のケースクラスに拡張できるようにしておく。このインスタンスは前回からほぼ変化がないので省略する[5]

インスタンス配線

最後にここまで作ったimplicitを配線する。GuiceではFoobarModuleのようなクラスを作成し、そこにbind(???).to(???)としてインターフェースと実装の関係を記述するが、それに近い作業となる。インスタンス配線はExcelReadsExcelBasicSYM系でやり方が次のように異なる。

  • ExcelReadsのインスタンスはコンパニオンオブジェクトに配置すればよい
  • ExcelBasicSYMを継承したApachePoiExcelBasicSYMなどは、この具体的なインスタンスが利用するExcelの行の型(この例ではPoiScalaRow)のコンパニオンオブジェクトに設置する

後者について、なぜこのようになるか説明する。まずApachePoiExcelBasicSYMの定義を再掲すると次のようになっている。

ApachePoiExcelBasicSYM.scala
class ApachePoiExcelBasicSYM[R](implicit
  m: Reader[ApachePoiRow, *] |= R
) extends ExcelBasicSYM[Eff[R, *]]

この実装ApachePoiExcelBasicSYMが利用するExcel行の型はApachePoiRowなので、ApachePoiRowのコンパニオンオブジェクトに設置することになる。ApachePoiExcelBasicSYMの型パラメーターRはエフェクトスタックを表しており、これはExcelReadsの第1型パラメーターでもある。型Rは具体的には次のようになる。

type R = Fx.fx2[Reader[PoiScalaRow, *], State[Int, *]]

そして、今ExcelBasicSYM[Eff[R, *]]というimplicitパラメーターを要求する関数があるとき、Scalaコンパイラーは次のようにimplicitパラメーターを探索する。

  1. ExcelBasicSYMのコンパニオンオブジェクトを探索する
    • ExcelReadsライブラリーの利用者はこのコンパニオンオブジェクトを編集できないため、ここに置くことはできない
  2. ExcelBasicSYMの型パラメーターに含まれる型のコンパニオンオブジェクトを探索する

(2)の挙動から、さらに次の場所が検索される。

  1. Effのコンパニオンオブジェクト
  2. Fx.fx2の型であるFx2のコンパニオンオブジェクト
  3. Readerのコンパニオンオブジェクト
  4. PoiScalaRowのコンパニオンオブジェクト
  5. Stateのコンパニオンオブジェクト

したがってPoiScalaRowApachePoiExcelBasicSYMのインスタンスを次のように設置する。

ApachePoiRow.scala
object ApachePoiRow {
  implicit def apachePoiBasicSymInstances[R](implicit
    m: Reader[ApachePoiRow, *] |= R
  ): ExcelBasicSYM[Eff[R, *]] =
    new ApachePoiExcelBasicSYM[R]
}

まとめ

正直にいってkind-projectorを利用しないとまともに書けないほど型定義は複雑になってしまったのと、ApachePoiRowといった型を自ら作ることでしかインスタンス配線ができなかった部分には多少改善したい気持ちがある。
しかしこれらによってインスタンスは完全に自動的な探索(importなし)を達成できた。またExcelReadsのインターフェースおよびそのインスタンス定義からは完全にExcelの具体的な情報を排除することができたため、Excelのバックエンドライブラリーを切り替えても定義したExcelReadsのインスタンスは無駄にならないと思う。さらにパーズ結果をEffにしたことで他のエフェクトとも組み合せやすくなった。たとえばS3にあるExcelファイルをロードするといったより強力なExcelバックエンド(S3ExcelBasicSYM)を実装したとしても、それらを既存のバックエンドとEffで簡単に組み合せられると考えている。

謝辞

この実装を考えるにあたって、次の方々とのExcelに関する議論が多いに参考となったので感謝したい。

また、Tagless-finalでインターフェースの返り値としてEffを利用するというのは@lotz84_さんの下記の記事を参考にした。

脚注
  1. 正式にはTyped-finalというらしいが、OlegさんのWebサイトでもTagless-finalという語が引き続き利用されているため、とりあえずこの解説ではTagless-finalで統一的に表記することにした。 ↩︎

  2. 2022年1月現在のExcelReadsは、この記事を執筆した時点からさらに改造し、Effの値型の型に表われていたValidatedNelEitherとしてエフェクトスタックへ移動している。またExcelの行だけではなくてシート全体をパーズするための拡張が施されている。 ↩︎

  3. この記事では全体的にkind-projectorの利用を前提としている。 ↩︎

  4. これらのメソッドは全てOption[A]の形となっているが、これはセルが空であるという判定に利用したいためである。個人の感覚になるが、あるセルが空であるのかを厳密に判定するためには極めて注意深く仕様書を読む必要があると考えていて、したがってExcelReads[R, A]なインスタンスからExcelReads[R, Option[A]]を導出するといったことはあえてしないほうがいいと思っている。 ↩︎

  5. 知りたい場合はこちらのコードを参照すればよい。https://github.com/y-yu/excel-reads/blob/01ee695ff963ce1fb7629bacb3a534b6d031dbc7/core/src/main/scala/excelreads/ExcelReadsInstances.scala#L109-L131 ↩︎

Discussion