はじめに
最近は業務でScalaを使ってExcelを解析するプログラムを保守・運用している。Excelは、JSONやXML、YAMLといった他のデータフォーマットと異なり、非エンジニアであっても編集することができるというのが大きな特徴であるが、その代償として限定されたデータ構造しか記述できない。 Scalaにおいては、たとえばJSONはPlay JSONのようにScalaのケースクラスからマクロを用いてパーザーを自動生成するというのが普及している。一方でExcelにはそのようなものが(筆者らが知る限りにおいて)存在しなかったため、これまではPoi Scalaというパーザーを利用して、たとえば「あるシートの1:A
をDouble
値として取得する」といった低レベルなプログラムを書く必要があった。そこで@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"))
パーザーの結果はScalazのValidationNel
2であり、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]
を返す。String
やInt
などについては自明な型としてあらかじめデフォルトのインスタンスが与えられている。
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のセルではDouble
とInt
に区別がないため、どちらでパーズするのかに何らかの優先順位が必要になる- 上記の
User
の例ではString
とString
であるため、型だけで区別することは不可能である
- 上記の
- たとえば正規表現では
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を使ったテクニックを利用すればいいのでは?と思い彼に相談してみたところ👇というふうになった……。
List[State[Int, ValidationNel[Error, A]]]👈こういう感じでMonadPlusにしてバックトラックを搭載したらどうですかね?とねこはる君に相談したけど、複雑になりすぎて使えなくなりますよーという感じになった……😇
— 吉村 優 / YOSHIMURA Yuu (@_yyu_) September 9, 2019ようするに、たとえば次のようなケースクラスを考える
case class TwoStringSeq( seq1: Seq[String], seq2: Seq[String] )
- このようにしたとき、正規表現では
(\w*)(\w*)
のようなものになると言えるので、seq1
に貪欲にマッチしてseq2
は空となるという1つの戦略がある。しかし、正規表現では慣用的にこのように振る舞うが、こうでなければならないというわけでもない - したがってExcel上の表記と型との間の関係がどんどんと非自明になってしまう
- このような議論があって
Coproduct
のインスタンスをあえて作らなかったので、このような途中にあらわれるSeq
もなくてよいのではないか - 一方で
HList
のインスタンスを改良することで、ケースクラスの末尾以外にSeq
がある場合はコンパイルエラーとすることができそう- こちらはやる価値があるのではないか
何ひとつ 主張できない 予感して(575) pic.twitter.com/ONWDHORVzA
— 吉村 優 / YOSHIMURA Yuu (@_yyu_) September 9, 2019Validation
はアプリカティブなので、エラーを同時にいくつも集めることができるがバックトラックした場合、どのエラーを表示するのがよいのか分からなくなる- バックトラックした組み合せを全てエラー表示すると、ユーザーにとって使いやすいものではなくなりそう
- したがってバックトラックを入れてしまうと
Validation
をつかっている意味が薄れるのではないか?
このあとでList[State[Int, ValidationNel[Error, A]]
はStateT
をつかってStateT[List, ValidationNel[Error, A]]
の方がいいですね、という話をした。
コメント
コメントを投稿