Tagless-final + EffなScalaによるExcelパーザー
はじめに
かつてExcelのパーザーを自作する次のような記事を書いた。
この記事ではshapelessを利用してScalaのケースクラスから自動的にパーザーを生成するExcelReads
という型クラスの解説をした。ただ、これに対して最近の議論によって次のような問題が指摘された。
- 従来の
ExcelReads
はPoi Scalaが提供するExcelの抽象化に完全に依存しているため、たとえばApache POIにExcelのバックエンドを切り替えたくなった場合にインスタンスは作り直しになってしまう - Poi Scalaは下記のような問題が指摘されており、バックエンドをApache POIにしたいといった要請が存在する
- Excelが持ついくつかの役割のうちCSVのようなデータ部分に強くフォーカスしており、たとえばセルのスタイル情報の取得が不十分である
- 膨大にあるExcelの仕様と照らしあわせて、たとえば日付といった一部の処理に問題があると思われる
-
Equals
やSemigroup
など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
は次のようなインターフェースとなっている。
trait ExcelReads[R, A] {
def parse(implicit
m: State[Int, *] |= R
): Eff[R, ValidatedNel[ExcelParseError, A]]
}
まず2つの型パラメーターR
とA
について説明する。まず型パラメーターR
はEff
のエフェクトスタックであり、かつimplicitパラメーターm
としてR
にState[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の利用時のように次のようなインターフェースを作成する。
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[_]
に詰め込む。次の実装をみるとわかりやすい。
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の型をラッピングしただけの構造で特別なことは何もない。
case class PoiScalaRow(
value: Row
) extends AnyVal
あとはこのReaderがあること前提に(つまりimplicitパラメーターを引数に取りつつ)PoiScalaExcelBasicSYM
を実装すればよい。
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関連のコードがやや冗長となってしまっているが、重要なところは下記のask
でReader
からExcelの行情報を取得して利用しているところである。
for {
row <- ask
} yield row.value.cells
同じようにExcelBasicSYM
のApache POIを直接利用するバージョンを作ることができる。
case class ApachePoiRow(
value: Row
) extends AnyVal
class ApachePoiExcelBasicSYM[R](implicit
m: Reader[ApachePoiRow, *] |= R
) extends ExcelBasicSYM[Eff[R, *]] {
???
}
バックエンドの拡張可能性
Tagless-finalではExcelBasicSYM
以外のインターフェースを作ることができる。たとえば次のようなセルの背景色といったスタイル情報を取得するインターフェースExcelStyleSYM
を追加で作成できる。
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.scalasealed 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
インスタンスを作成する。
object ExcelReads {
def from[R, A](
f: State[Int, *] |= R => Eff[R, ValidatedNel[ExcelParseError, A]]
): ExcelReads[R, A] = (m: State[Int, *] |= R) => f(m)
}
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
内の次の処理でほぼ全てである。
-
get
でState[Int, *]
からInt
な値を取り出しs
に代入する - それを利用して実際に
sym
を呼び出す- 上記のコードは汎用化のため
sym
を呼び出すコードを関数f
として引数で取るようになっている。この部分をstringInstance
を例に展開すると次のようになるfor { s <- get aOpt <- sym.getString(s) _ <- put(s + 1) } yield aOpt
- 上記のコードは汎用化のため
-
s + 1
を新しい状態として保存(put
)する
また以前の記事と同様にHList
のインスタンスを作っておき、shapelessを利用して任意のケースクラスに拡張できるようにしておく。このインスタンスは前回からほぼ変化がないので省略する[5]。
インスタンス配線
最後にここまで作ったimplicitを配線する。GuiceではFoobarModule
のようなクラスを作成し、そこにbind(???).to(???)
としてインターフェースと実装の関係を記述するが、それに近い作業となる。インスタンス配線はExcelReads
とExcelBasicSYM
系でやり方が次のように異なる。
-
ExcelReads
のインスタンスはコンパニオンオブジェクトに配置すればよい -
ExcelBasicSYM
を継承したApachePoiExcelBasicSYM
などは、この具体的なインスタンスが利用するExcelの行の型(この例ではPoiScalaRow
)のコンパニオンオブジェクトに設置する
後者について、なぜこのようになるか説明する。まずApachePoiExcelBasicSYM
の定義を再掲すると次のようになっている。
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パラメーターを探索する。
-
ExcelBasicSYM
のコンパニオンオブジェクトを探索する-
ExcelReads
ライブラリーの利用者はこのコンパニオンオブジェクトを編集できないため、ここに置くことはできない
-
-
ExcelBasicSYM
の型パラメーターに含まれる型のコンパニオンオブジェクトを探索する
(2)の挙動から、さらに次の場所が検索される。
-
Eff
のコンパニオンオブジェクト -
Fx.fx2
の型であるFx2
のコンパニオンオブジェクト -
Reader
のコンパニオンオブジェクト PoiScalaRow
のコンパニオンオブジェクト-
State
のコンパニオンオブジェクト
したがってPoiScalaRow
にApachePoiExcelBasicSYM
のインスタンスを次のように設置する。
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_さんの下記の記事を参考にした。
-
正式にはTyped-finalというらしいが、OlegさんのWebサイトでもTagless-finalという語が引き続き利用されているため、とりあえずこの解説ではTagless-finalで統一的に表記することにした。 ↩︎
-
2022年1月現在のExcelReadsは、この記事を執筆した時点からさらに改造し、
Eff
の値型の型に表われていたValidatedNel
がEither
としてエフェクトスタックへ移動している。またExcelの行だけではなくてシート全体をパーズするための拡張が施されている。 ↩︎ -
この記事では全体的にkind-projectorの利用を前提としている。 ↩︎
-
これらのメソッドは全て
Option[A]
の形となっているが、これはセルが空であるという判定に利用したいためである。個人の感覚になるが、あるセルが空であるのかを厳密に判定するためには極めて注意深く仕様書を読む必要があると考えていて、したがってExcelReads[R, A]
なインスタンスからExcelReads[R, Option[A]]
を導出するといったことはあえてしないほうがいいと思っている。 ↩︎ -
知りたい場合はこちらのコードを参照すればよい。https://github.com/y-yu/excel-reads/blob/01ee695ff963ce1fb7629bacb3a534b6d031dbc7/core/src/main/scala/excelreads/ExcelReadsInstances.scala#L109-L131 ↩︎
Discussion