はじめに
この記事はJavaっぽいJavaのコードを(ある程度)ScalaっぽいScalaのコードに書き換えるの続きになります。
今回も既にScalaをバリバリ使いこなしている方には参考にならないと思いますのでご了承ください。
(続き物として書こうとした結果タイトルがおかしくなってきていますがご了承ください。)
なお、前回同様以下で登場するコードは次の環境で確認しております。
OS : Ubuntu 14.04
JDK : OpenJDK 1.8.0_45
Scala : 2.11.7
また、今回は以下のライブラリを使用しております。
scalaz-core 7.2.0
scalaz-effect 7.2.0
前回の最後のコードを改善する
前回の最後のコードの中でいくつか微妙なところがあったと思うので、改善していきたいと思います。
今回はいかにも微妙だったBatchMain.main()
とLoadCSV.load()
を中心に書き換えていきます。
対象のコード抜粋したものが以下になります。
def main(args: Array[String]) = {
if (args.length < 1) {
throw new IllegalArgumentException("CSVファイルを指定してください")
}
getFile(args(0)) match {
case Some(file) => {
LoadCSV.load(file) match {
case Right(list) => registDatabase(list)
case Left(e) => {
System.err.println("CSVファイルの読み込みに失敗しました")
throw e
}
}
}
case None => throw new IllegalArgumentException("CSVファイルを指定してください")
}
}
def load(file: File):Either[IOException, List[CSVData]] =
try {
val br = new BufferedReader(
new InputStreamReader(
new FileInputStream(file), Charset.forName("UTF-8")))
autoCloser(br) { cbr =>
Right(loadList(cbr))
}
} catch {
case e:IOException => Left(e)
}
あまり直接try-catch
を使いたくない
try-catch
は例外が発生するかもしれないコードを使う際に欠かせないものです。
ですが、try-cache
を直接使っているとメインの処理と例外発生時の処理が分離してしまいますので、
あまり直接使用したくはありません。
どうにかして統一的に扱いたいところです。
こういうとき、IOモナドというものを使うといい感じに扱うことができます。
LoadCSV.load()
をIOモナド(scalaz.effect.IO)を使用して書き直すと以下のような感じになります。
(ついでに、Scala標準のEitherだと今後扱いづらくなるので、Eitherをscalaz./に置き換えます。)
def load(file: File):Throwable \/ List[CSVData] = {
implicit val resourceCloser = Resource.resourceFromCloseable[BufferedReader]
IO(file).map { f =>
new BufferedReader(
new InputStreamReader(
new FileInputStream(f), Charset.forName("UTF-8")))
}.using { br =>
IO(\/-(loadList(br)):Throwable \/ List[CSVData])
}.except { e =>
IO(-\/(e))
}.unsafePerformIO()
}
一行目のimplicit val
はIO.using()
を使用する際に必要となるものです(リソースの自動破棄を行う際に必要)。
IOモナドを使うことで、例外が発生するかもしれない処理をmap(),using()で行い、例外のハンドリングをexcept()で行う
というように統一的に扱えていることがわかります。
except()が後で大丈夫なの?と思われるかもしれませんが、IOモナドの処理は全て遅延されており、実際に処理されるのは最後の
unsafePerformIO()
のときなので問題ありません。
(ところで、戻り値の型のThrowable \/ List[CSVData]
ですが、これは\/[Throwable, List[CSVData]]
と同じ意味です。
Scalaでは、型パラメータを2つとる型A[B,C]
をB A C
と表記することが可能となっています。
また、EitherのときのRight
が\/-
, Left
が-\/
に対応しています。)
処理全体を統一的に扱いたい
次にBatchMain.main()
を見ていきます。これが微妙である最大の理由は記述に統一感がないからです。
最初にif文でチェックして、その後Optionをパターンマッチし、その中で更にEitherをパターンマッチ
となっていては処理に統一感がないのも当然です。
各処理を統一的に扱えるようにしましょう。
今回の場合は各処理の結果をThrowable \/ A
として扱えれば統一的に扱えそうなので、そのように書き換えていきます。
書き換えたBatchMain.scalaは以下のような感じになります。
import java.io._
import scalaz._
import Scalaz._
object BatchMain {
def main(args: Array[String]) {
( for {
fileName <- getFileName(args)
file <- getFile(fileName)
list <- LoadCSV.load(file)
success <- registDatabase(list)
} yield success ) match {
case \/-(success) => println(success)
case -\/(e) => {
System.err.println("CSVファイルの読み込みに失敗しました")
throw e
}
}
}
private def getFileName(args: Array[String]):Throwable \/ String =
if (args.length < 1) -\/(new IllegalArgumentException("CSVファイルを指定してください"))
else \/-(args(0))
private def getFile(path: String):Throwable \/ File = {
val file = new File(path)
if (!file.exists || !file.isFile) -\/(new FileNotFoundException("CSVファイルが存在しません"))
else \/-(file)
}
// RegistDatabase.registActiveUsers()がThrowable \/ Booleanを返すようにしておく
private val registDatabase =
(RegistDatabase.registActiveUsers _) compose
(DataFilter.filterActiveUser _) compose
(DataCheck.discountHeavyUser _)
}
BatchMain.main()
の処理が1つのfor式とその結果(Throwable / Boolean)に対するパターンマッチにまとまり、
処理全体の意図も分かりやすくなったのではないでしょうか。
(なお、Scala標準のEitherの場合はmap/flatMapが定義されていないので、直接for式で書くことができません。
scalaz./にはmap/flatMapが定義されているので直接for式で書くことが可能です。便利ですね。)
処理の組み立てと実際の処理を分離する
せっかくですので、もう少し話を進めてみましょう。
現在の処理は「CSVファイルからデータを読み込み、データを加工してデータベースに書き込む」というものです。
これを「データベースからデータを読み込み、データを加工してJSONファイルに書き込む」処理に変更するとしたらどうなるでしょうか。
処理を全面的に書き直す必要がありますね。
ですが、どちらの場合も「どこかからデータを読み込み、データを加工してどこかに書き込む」という処理の流れ自体は同じです。
変更しなければならないところは変更するにしても、この「処理の流れ(組み立て)自体」は一切変更せずに済ませたいところです。
こういうときJava等ではDIフレームワークを使用してインターフェースと実装を分離したりしますよね。
ですが、Scalaは型安全であることが重要なので、DIフレームワークのような型安全でないものはあまり使用したくないところです。
こういうとき、Freeモナドというものを使うと型安全に処理の流れと実際の処理を分離することができます。
Freeモナド(scalaz.Free)を使う形に変更したコードは以下のようになります。
import java.io._
import scalaz._
import Scalaz._
import BatchLogic._
object BatchMain {
def main(args: Array[String]) {
logic(getFileName(args)).foldMap(interpreter) match {
case \/-(success) => println(success)
case -\/(e) => throw e
}
}
def logic(param: Throwable \/ String):Free[BatchLogic, Throwable \/ Boolean] = for {
rawList <- readData(param)
list <- customData(rawList)
success <- writeData(list)
} yield success
val interpreter = new (BatchLogic ~> Id) {
def apply[A](a: BatchLogic[A]): Id[A] = a match {
case ReadData(param) => for {
fileName <- param
file <- getFile(fileName)
list <- LoadCSV.load(file)
} yield list
case CustomData(list) => for {
l1 <- list
l2 <- \/-(DataFilter.filterActiveUser(l1))
l3 <- \/-(DataCheck.discountHeavyUser(l2))
} yield l3
case WriteData(list) => for {
l <- list
success <- RegistDatabase.registActiveUsers(l)
} yield success
}
}
private def getFileName(args: Array[String]):Throwable \/ String =
if (args.length < 1) -\/(new IllegalArgumentException("CSVファイルを指定してください"))
else \/-(args(0))
private def getFile(path: String):Throwable \/ File = {
val file = new File(path)
if (!file.exists || !file.isFile) -\/(new FileNotFoundException("CSVファイルが存在しません"))
else \/-(file)
}
}
import scalaz._
import Scalaz._
sealed trait BatchLogic[A]
case class ReadData(param: Throwable \/ String) extends BatchLogic[Throwable \/ List[CSVData]]
case class CustomData(list: Throwable \/ List[CSVData]) extends BatchLogic[Throwable \/ List[CSVData]]
case class WriteData(list: Throwable \/ List[CSVData]) extends BatchLogic[Throwable \/ Boolean]
object BatchLogic {
def readData(param: Throwable \/ String) = Free.liftF(ReadData(param))
def customData(list: Throwable \/ List[CSVData]) = Free.liftF(CustomData(list))
def writeData(list: Throwable \/ List[CSVData]) = Free.liftF(WriteData(list))
}
case class CSVData(mail: String, time: Long, money: Int)
object DataCheck {
def discountHeavyUser(list:List[CSVData]):List[CSVData] =
for (data <- list) yield discount(data)
private def discount(data: CSVData) =
if (isDiscount(data.time, data.money)) data.copy(money = data.money - 500)
else data
private def isDiscount(time: Long, money: Int) = time >= 18000L || money >= 5000
}
object DataFilter {
def filterActiveUser(baseList:List[CSVData]):List[CSVData] =
for {
data <- baseList if isActive(data.time, data.money)
} yield data
private def isActive(time: Long, money: Int) = time >= 3600 && money >= 1000
}
import java.io._
import java.nio.charset.Charset
import java.lang.{Long => JLong}
import scalaz._
import Scalaz._
import scalaz.effect._
object LoadCSV {
def load(file: File):Throwable \/ List[CSVData] = {
implicit val resourceCloser = Resource.resourceFromCloseable[BufferedReader]
IO(file).map { f =>
new BufferedReader(
new InputStreamReader(
new FileInputStream(f), Charset.forName("UTF-8")))
}.using { br =>
IO(\/-(loadList(br)):Throwable \/ List[CSVData])
}.except { e =>
IO(-\/(e))
}.unsafePerformIO()
}
private def loadList(br:BufferedReader) =
Iterator.continually(br.readLine()).takeWhile(_ != null).map { str =>
checkData(str)
}.collect {
case Some(data) => data
}.toList
private def checkData(str: String) =
for {
(mail, sTime, sMoney) <- split(str)
time <- parseLong(sTime)
money <- parseInt(sMoney)
} yield CSVData(mail, time, money)
private def split(str: String) = {
val strs = str.split(",")
if (strs.length != 3) None else Some((strs(0), strs(1), strs(2)))
}
private def parseLong(str: String) =
try {
Some(JLong.parseLong(str))
} catch {
case _:NumberFormatException => None
}
private def parseInt(str: String) =
try {
Some(Integer.parseInt(str))
} catch {
case _:NumberFormatException => None
}
}
import scalaz._
object RegistDatabase {
def registActiveUsers(list: List[CSVData]):Throwable \/ Boolean = {
for (data <- list) {
println(data) // 仮の処理
}
\/-(true)
}
}
BatchMain.logic()
でFreeモナドによる処理の組み立てを行っています。
この組み立てられた処理自体には、処理の実際の内容は含まれていません。
実際の処理はBatchLogic[_]
からG[_]
への自然変換BatchLogic ~> G
で与えられます。
(val interpreter = ...
の部分。ここではG[_]
の部分にscalaz.Id[_]
を指定している。
~>
はscalaz.~>[-F[_], +G[_]]
)
Free.foldMap()にこの自然変換を渡すことで、この自然変換の定義を元に処理が解決されます。
(Scalaz 7.1.xまではFree.liftFC()でFree.FreeCに変換してFree.runFC()していましたが、7.2.xで変更されたようです。)
これにより、処理の組み立て自体は一切変更せず、Free.foldMap()に渡す自然変換の定義を差し替えるだけで
実際の処理を変更できるようになりました。
最後に
何か突然IOモナドやらFreeモナドやら出てきてしまいましたが大丈夫でしたでしょうか。
この例のような非常に簡単なプログラムではあまり恩恵を感じられないかもしれませんが、
実際に必要となるもっと複雑なプログラムではこういうものを活用するかどうかで大きく変わってくると思いますので
是非活用してみてください。