Quantcast
Channel: Modegramming Style
Viewing all 149 articles
Browse latest View live

[scalaz-stream] シーケンス番号とエンドマーク

$
0
0

プロダクトの開発中にわりと難しい処理がscalaz-streamでうまくさばけたのでメモ。

近い将来Buzzwordになってきそうな「Functional Reactive Programming」ですが、monadicなstreaming系のアプローチが筋がよさそうということで、scalaz-streamを実プロダクト開発に導入して試行錯誤しているところです。大規模データ系のいろいろな処理に適用していますが、かなりいい感触を得ています。

そのような応用の一つである大規模メール配信処理で出てきた要件が以下のものです。

  • 大規模なデータ列を一つの固まりとしてストリーム上に流したい
  • 事前にデータ列の総量は分からない
  • データ規模が大きいので復数パケットに分割する必要がある
  • 性能向上のため、可能な範囲で1パケットに復数データ列を格納したい

ネットワーク系のプログラムではわりとよく出てくる処理だと思います。

やろうとしていることはそれほど難しくないのですが、プログラムを組んでみると思いの外、複雑なものになってしまうといった系統の処理です。

こういった処理をいかに簡単に書くことができるようにするのかというのがプログラミング・モデルの重要なテーマの一つです。OOPであれば専用フレームワーク的なものを用意する感じですが、FPの場合はComposableな関数のCompositionでさばいてみたいところです。

サンプルロジック

上記の要件をscalaz-streamで実現することができるかを検証するためにサンプルプログラムをつくってみました。

以下の要件を実装しています。

  • 事前にすべてデータが読み込まれていることは前提としない
  • 3データごとに1つのパケットに集約
  • シーケンス番号をつける
  • 最終パケットにエンドマークをつける

scalaz-streamのProcessモナドを使用しています。

package sample

import scalaz._, Scalaz._
import scalaz.concurrent.Task
import scalaz.stream._

object Main {
case class Packet(seqno: Int, end: Boolean, content: String)

val source: Process[Task, Int] = {
Process.range(0, 10)
}

def sink: Sink[Task, Packet] = {
io.channel((a: Packet) => Task.delay { println(a) })
}

def main(args: Array[String]) {
execute(source, sink)
}

def execute(source: Process[Task, Int], sink: Sink[Task, Packet]) {
import process1._
val pipeline = source.
chunk(3).
pipe(zipWithNext).
pipe(zipWithIndex).
map(toPacket).
to(sink)
val task = pipeline.run
task.run
}

def toPacket(x: ((Seq[Int], Option[Seq[Int]]), Int)): Packet = {
val ((current, next), index) = x
val content = current.mkString("-")
Packet(index + 1, next.isEmpty, content)
}
}
Source

ストリームの起点となるProcessモナドをSourceと呼びます。

例題なのでProcess#range関数で0から10の数値のストリームを生成しています。

val source: Process[Task, Int] = {
Process.range(0, 10)
}
Sink

ストリームの終端となるProcessモナドをSinkと呼びます。

例題なのでscalaz.stream.io#channel関数を使って、コンソール出力するSinkを定義しています。

def sink: Sink[Task, Packet] = {
io.channel((a: Packet) => Task.delay { println(a) })
}
ストリームの構築

SourceとSinkはサンプル用のものなので、ここからが本題です。

ストリームの構築は、以下のようにProcessモナドが提供するコンビネータをつないでいく形になります。

FunctorやMonadが定義している基本コンビネータであるmapやflatMap以外にProcessモナドが提供するpipeといったコンビネータを多用することになるのが特徴です。

val pipeline = source.
chunk(3).
pipe(zipWithNext).
pipe(zipWithIndex).
map(toPacket).
to(sink)

ストリームを処理するパイプラインはSourceから始まってtoコンビネータで指定するSinkが終端になります。途中、chunk、pipe、mapの各メソッド/コンビネータがパイプラインを構成しています。

chunk

ストリーム内を流れるデータを1つのチャンクにまとめる処理はchunkメソッドを使います。チャンクにまとめる個数の3を引数に指定します。

ストリームにはsourceからデータであるIntが流れてきますが、これがSeq[Int]に変換されます。

pipe

pipeコンビネータはProcessモナドをストリーム処理のパイプライン部品として埋め込みます。

ここではscalaz.stream.process1にある以下の関数を使って生成したProcessモナドをパイプラインに埋め込んでいます。

  • zipWithNext
  • zipWithIndex

zipWithNext関数が返すProcessモナドは、処理中のデータに加えてその次のデータをOptionとして渡してきます。処理中のデータが最終データの場合は、「その次のデータ」はNoneになります。これを使用すると、次のデータの有無、次のデータの内容によって処理を変更することができるわけです。

ここまでの処理でストリームからSeq[Int]が流れてきますが、この段でTuple2[Seq[Int], Option[Seq[Int]]]に変換されます。

zipWithIndex関数が返すProcessモナドは、処理中のデータにインデックスをつけます。

ここまでの処理でストリームからSeq[Int]が流れてきますが、この段でTuple2[Tuple2[Seq[Int], Option[Seq[Int]]], Int]変換されます。

map

mapコンビネータで以下のtoPacket関数をパイプラインに組み込んでいます。

def toPacket(x: ((Seq[Int], Option[Seq[Int]]), Int)): Packet = {
val ((current, next), index) = x
val content = current.mkString("-")
Packet(index + 1, next.isEmpty, content)
}

ここまでの処理でストリームからTuple2[Tuple2[Seq[Int], Option[Seq[Int]]], Int]が流れてきますが、この段でPacketに変換されます。

toPacket関数の処理は簡単でストリームから流れてきた情報を元に:

インデックス
ストリーム上の情報は0起点なので1起点に変換
エンドマーク
Nextがない場合は最終パケットなのでtrue

の情報をPacketに設定しています。

サンプルコード内で自前のアルゴリズムらしい物を書いているはここだけです。

ストリームの実行

ストリームの実行はrunメソッドで行います。

今回はTaskモナドを使う指定をしているので、ProcessモナドのrunメソッドではTaskモナドが返ってきます。

Taskモナドのrunメソッドを実行するとストリームが動作します。

val task = pipeline.run
task.run

実行

実行とすると以下の結果が出力されました。

10個のデータ列が4つのパケット列として出力され、最後のパケットにはエンドマークがついています。

Packet(1,false,0-1-2)
Packet(2,false,3-4-5)
Packet(3,false,6-7-8)
Packet(4,true,9)

受信側の処理

このパケット列を受け取った側の処理としては:

  • エンドマークがついているパケットまでパケットを読み込む
  • シーケンス番号を監視して、欠損があれば再送を依頼する

というような処理になるかと思います。

こういった処理をscalaz-streamのProcessモナドで実装可能か、というのも面白そうなテーマなので機会があれば試してみたいと思います。

まとめ

大規模データのパケット分割処理ですが、自前のロジックはtoPacket関数のものぐらいで、後はscalaz-streamの用意する部品を組み合わせるだけで構築できました。

パケット分割や復元は自分でロジックを組むとそれなりにバグが出る所なので、既存部品を組み合わせるだけで、ほとんどの処理が組み上がって、最後の仕上げだけ自前のロジックを差し込むことができるのはほんとうに楽です。

また、OOP的なフレームワークだと:

  • パイプラインの構成をXMLなどの外部DSLで定義
  • 自前ロジックをパイプラインに組み込むためのボイラープレイト作成

といった作業が必要になるので、それなりに大変です。

それに対して、scalaz-streamの方式はmonadic programmingの作法に則っていれば通常の関数型プログラミングでOKなのが非常に使いやすいところです。具体的にはOOP的なフレームワークアプローチに対して以下のメリットがあります。

  • パイプラインを外部DSLで構築すると、型安全でなくなる、デバッグがしづらいといった問題も出るが、いずれの問題もなくなる。
  • 自前ロジックを通常の関数で記述すればよく、特別なボイラープレイトのコードは必要ない。
いいことずくめのようですが:
  • monadic programmingの習得
  • processモナドの理解

といった難点があるので、これはこれで一定のハードルがあります。

こういったハードルをcoding idiomやdesin patternといった技法でクリアすることができれば、大規模データ処理にはなかなか有力なアプローチだと思います。

注意

本稿の「大規模」は処理対象としてメモリに一度に乗らない規模のデータ量を指していて、最大1GB程度のものを想定しています。

これを超えてくるデータの処理はHadoopやSpark的な並列分散処理が必要になってくるので、本稿のスコープ外です。このような並列分散処理もSparkのRDDといったものを使ったmonadic programmingが有力と思うので、いずれ取り上げてみたいと思います。

諸元

  • Scala 2.10.3
  • Scalaz 7.1.0
  • Scalaz-stream 0.6a

[scalaz] Tryモナド問題

$
0
0

Scalazでmonadic programmingする上で困っているのがTryモナドの問題です。

scala.util.TryはScalaで例外処理をmonadicに処理するためにScala 2.10で導入されたScalaの基本機能です。Scalaのモナドとしての要件(flatMapメソッドが定義されている等)は満たしているのでfor式で使用することができます。

しかし、ScalazのMonadではないのでScalazの提供する各種機能の恩恵を得ることができません。このことがScalazを軸としたmonadic programmingの阻害要因になっています。

例外をハンドリングするためにはTryを使うのが自然ですが、Tryを使うとScalazのmonadic programmingがやりづらくなる、という構図です。

問題

TryがScalaz Monadとなっていない理由は、Tryがモナド則の1つであるleft identityを満たしていないとされているからのようです。

この理由は:

の記事の解説によると:

def foo[A, B](a: A): Try[B] = throw new Exception("oops")

foo(1) // exception is thrown

Try(1).flatMap(foo) // scala.util.Failure

ということなので:

  • foo関数が例外を返す場合、TryのflatMapコンビネータにfoo関数を適用した時にflatMapコンビネータが例外を返さないとMonad則のleft identity law違反

ということだと思います。

さて、それではTryのflatMapコンビネータで例外を返すようにすればよいかというと、これはOOP的には困ってしまう仕様です。

OOP的にはTryは例外を包んで外に出さないことを期待したいところです。というのは、flatMapコンビネータが例外を返す可能性があるとすると、Tryを使っているにもかからわずTryも例外を出す可能性があるということなので、Tryの外側をTryで包まなければならなくなります。

これはプログラミング的にも煩雑ですし、Tryをネストさせなければならない条件をプログラマが意識しないといけないのでバグの出やすいインタフェースです。

こういう事情もありScalaの基本ライブラリは、あえて現在の仕様を選択しているようです。

解決策

本稿の趣旨ですが、Try Monad問題を解釈変更で解決するという試みです。

left identityの解釈

Scalaのようなハイブリッドな関数型言語で純粋関数型の計算を行う場合、いくつかの紳士協定を前提とします。

代表的なものとしては以下のようなものがあります。

  • var変数は用いない
  • mutableなコレクションは用いない
  • eqメソッドは用いない

つまりScalaにおける純粋関数型の演算は紳士協定が前提なので、Tryのleft identity問題も紳士協定で解決すればよいのではないかということです。

具体的には返却値にTryを返す:

f(a: A): Try[B]

というシグネチャの関数では例外をスローしない、という紳士協定を導入するというアプローチはどうでしょうか。もちろん絶対ということはないので、例外をスローしたらプログラムが致命的状態(e.g. バグ発生、メモリ不足)に入ったとして扱うという形になります。

プログラミング的には以下をコーディングの基本形にするということなので、特に煩雑な点はありません。

f(a: A): Try[B] = Try {
...
}

この紳士協定上ではTryをScalaz Monad化しても問題ないように思います。

実行タイミングの解釈

FPでは参照透過性が重要な要件になっていて、関数を評価した時の内部処理の実行タイミングや実行順序は結果に影響を与えないことになっています。

TryのようなFunctor系のコンテナの場合、コンテナの内部情報を取り出すメソッドの実行前の任意の時点で評価が行われていればよいわけです。

TryのflatMapコンビネータでも、同様のことが言えるはずです。つまりflatMapコンビネータ内で必ずしもMonadのbind演算をする必要はなく、もっと後のタイミングでしても大丈夫ではないかということです。と考えると、flatMapコンビネータが例外をスローすることは必須ではなく、このことがMonad則違反というのは必ずしも真とは限らないというわけです。

整理すると、以下になります。

  • Tryの内部実行がコンビネータの呼び出しと同時に行われていなければならないという計算モデルではMonad則違反
  • Tryの内部実行がコンテナの内部を取り出すメソッドの実行前の任意の時点で評価が行われていればよいという計算モデルではMonad則OK

Tryの計算モデルを後者であると解釈すると、Monad則が成り立っていると考えてよいのではないかと思います。

実際にScalazのFuture MonadやTask Monadも同様の問題があるはずですが、いずれもScalaz Monadとして定義されています。

Futureの場合は、関数型的な意味での遅延評価ではなくて、別スレッドで非同期実行されるためflatMapで例外を返すようにはできないのだと推測されます。

Taskの場合は、(通常は)runメソッドで実行を指示されるまで実行は遅延されます。

いずれの場合もmapコンビネータやflatMapコンビネータが例外を返すことはありません。そして例外は実行結果を取り出すときに、必要に応じてスローされるようになっています。

FP的なMonadの定義としてこれが許されるならTryについてもgetメソッドで例外のスルーは行われるわけですから、flatMapコンビネータで例外を返さなくてもよいと考えることは十分可能と思います。

まとめ

考察の結果left identityの解釈、実行タイミングの解釈のどちらか一つでもOKであればScala TryをScalaz Monadとして定義しても問題ないのではないかという結論に落ち着きました。

個人的にはいずれの解釈もOKと思えるので、プロダクションのコードでTry Monadを使用することにしました。

Try Monadの実装としてはscalaz-outlawsを使うのが一案ですが、現状ではdeprecatedの警告が出るので諦めて、自前のライブラリ内で定義して使うことにしました。

TryをScalaz Monad化できると、さらに広い範囲でmonadic programmingを適用できるようになります。Try Monadの存在を軸に例外処理戦略を練りなおしてみたいと思います。

おまけ

Scalazによるmonadic programmingでTryを使う場合、TryがApplicativeであるだけでも、色々と応用範囲が広がります。(e.g. Traverse)

TryはApplicativeの要件は満たしていると思うので、Try Monadが不安な場合は(Validationと同じように)TryをApplicativeとして定義して使用するのも有用だと思います。

諸元

  • Scala 2.10.3
  • Scalaz 7.1.0

Scala的状態機械/OOP編

$
0
0

オブジェクト・モデリングにおける動的モデルは状態機械で記述するのが基本です。つまり状態機械はオブジェクト・モデリングの重要な構成要素の一つということです。

ScalaでObject-Functional Programming(OFP)を行う場合でも、要求・分析・設計の各アクティビティを経て作成されたオブジェクト・モデル内の状態機械をどのように実装していくのかという実装方式が論点になります。

普通のOOP流の実装はすでに議論しつくされていると思いますが、OFPにおけるFPでの実装方式については、これから整備されていくことになると思います。

注意が必要なのはクラウド・アプリケーション開発をターゲットにする場合、伝統的なFPというよりMonadic Programming(以下MP)を経てFunctional Reactive Programming(以下FRP)がゴールになるということです。

このためFRPとして利用可能な状態機械実装を探っておきたいところです。

課題

状態機械の使い所としては、エンティティの状態遷移を記述することで業務ワークフローの構築に用いたり、プロトコルドライバのフロー制御といったものが考えられます。前者はDBに状態を格納することになりエンタープライズ・アプリケーション的なライフサイクルの長い応用ですし、後者は制御系の応用でメモリ内の制御で完結する形のものです。

色々な応用があるわけですが、どの方面でも使える実現方法を念頭におきつつ、ここではCSVのパーサー処理を状態機械で実装してみることにします。FPとの接続部分に興味を集中させるためできるだけ簡単なものを選んでみました。

具体的には以下の課題になります。

  • CSVの1行を「,」区切りのレコードとしてパースし、文字列の列を取得する処理に使用する状態機械

この状態機械は文字列の列の各文字をイベントとして扱い、一連のイベント終了後にパース処理の結果となる「文字列の列」を状態の情報として保持する形になります。

case classで状態機械

状態機械は「状態×イベント→状態」のメカニズムが基本の構成要素です。このメカニズムをcase classで実装します。

いうまでもなくcase classはScalaプログラミングの超重要な構成要素で、OOP的側面とFP的側面を兼ね備えたObject-Functional Programmingの肝となる機構です。

OOP的にはDDDでいうところのvalue objectの実装に適した文法になっています。

FP的には代数的データ型(algebraic data type)として利用することが想定されています。

つまりvalue object兼代数的データ型を実現するための機構がcase classです。

ただしcase classの文法上はvalue objectや代数的データ型に適さない普通のオブジェクトを実装することも可能です。このため、value object兼代数的データ型を実現するための紳士協定を組み込んでおかなければなりません。

この紳士協定は以下の2つです。

  • 不変オブジェクト(immutable object)にする
  • 基底のトレイトや抽象クラスはsealedにする

Value objectと代数的データ型は共に不変オブジェクトである必要があります。

また代数的データ型の要件としてコンパイル時に全インヘリタンス関係が確定している必要があるので、sealedにすることでこれを担保します。

case classはオブジェクト指向の普通のオブジェクトでもあるので、不変オブジェクトの性質さえ守ればオブジェクトのフルスペックを使用することができます。

package sample

sealed trait ParseState {
def event(c: Char): ParseState
def endEvent(): EndState
}

case object InitState extends ParseState {
def event(c: Char) = c match {
case ',' => InputState(Vector(""), "")
case '\n' => EndState(Nil)
case _ => InputState(Nil, c.toString)
}
def endEvent() = EndState(Nil)
}

case class InputState(
fields: Seq[String],
candidate: String
) extends ParseState {
def event(c: Char) = c match {
case ',' => InputState(fields :+ candidate, "")
case '\n' => EndState(fields :+ candidate)
case _ => InputState(fields, candidate :+ c)
}
def endEvent() = EndState(fields :+ candidate)
}

case class EndState(
row: Seq[String]
) extends ParseState {
def event(c: Char) = this
def endEvent() = this
}

case class FailureState(
row: Seq[String],
message: String
) extends ParseState {
def event(c: Char) = this
def endEvent() = sys.error("failure")
}

状態機械は、「状態×イベント→状態」の情報を記述したマトリックスとして表現できます。このマトリクスを実現するデータ構造と評価戦略は色々考えられますが、ここではOOP的なアプローチで実現しました。

状態

まず状態機械全体を表すトレイトとしてParseStateを定義します。sealedにすることで代数的データ構造の要件の1つを満たします。

ParseStateのサブクラスとして具体的な状態を定義します。情報を持つInitState, InputState, EndState, FailureStateはcase classとして、情報を持たないInitStateはcase objectとして定義しました。

イベント

発生するイベントはメソッドで表現しました。

event
1文字入力イベント発生
endEvent
パース終了イベント発生
状態×イベント→状態

状態とイベントの組から新しい状態を決定するアルゴリズムは「発生するイベント」で定義したメソッドの実装になります。

具体的には各case class, case objectのeventメソッド、endEventメソッドの実装を参照して下さい。

オブジェクト版状態機械

case classによる状態機械の表現はimmutableなので、そのままでは状態機械としては動きません。あくまでも「状態×イベント→状態」のメカニズムを提供するまでになります。

状態機械の状態遷移は状態を遷移させるので本質的にはmutableな処理です。

OOP的には、オブジェクトのインスタンス変数でmutableな状態を保持する実現方法になります。

OOP版の状態機械をParserObjectとして実装しました。

package sample

class ParserObject {
var state: ParseState = InitState

def charEvent(c: Char) {
state = state.event(c)
}

def parseEnd(): Seq[String] = {
val end = state.endEvent()
state = end
end.row
}
}

PaserObjectクラスでは、変更可能なインスタンス変数としてstateを定義しています。

イベントの発生をメソッドで受取り、状態とイベントの組合せで新たな状態を計算し、これをstateに設定することで状態遷移を実現しています。

新しい状態の計算ロジックは、ParseStateオブジェクトにカプセル化しています。

代数的データ構造で表現した状態機械のimmutableなオブジェクトを、インスタンス変数で管理することで、OOP的な状態機械が実現できました。

使い方

プログラムを実行するためのSpecは以下になります。

package sample

import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalatest._
import org.scalatest.prop.GeneratorDrivenPropertyChecks

@RunWith(classOf[JUnitRunner])
class ParserObjectSpec extends WordSpec with Matchers with GivenWhenThen with GeneratorDrivenPropertyChecks {
"ParserObject" should {
"parse" in {
val parser = new ParserObject()
val text = "abc,def,xyz"
for (c <- text) {
parser.charEvent(c) // 一文字づつパーサーに送信
}
val r = parser.parseEnd() // 解析終了のイベントを送信
println(s"ParserObject: $r")
r should be (Vector("abc", "def", "xyz"))
}
}
}
実行

実行結果は以下になります。

$ sbt test-only sample.ParserObjectSpec
ParserObject: List(abc, def, xyz)
[info] ParserObjectSpec:
[info] ParserObject
[info] - should parse
[info] ScalaTest
[info] 36mRun completed in 180 milliseconds.0m
[info] 36mTotal number of tests run: 10m
[info] 36mSuites: completed 1, aborted 00m
[info] 36mTests: succeeded 1, failed 0, canceled 0, ignored 0, pending 00m
[info] 32mAll tests passed.0m
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 0 s, completed 2015/03/21 17:47:46

アクター版状態機械

FRP的な応用を考える場合、並行・並列処理で枠組みの中で状態機械を実現していく必要があります。

この実現方式として有力なのがアクターです。

ScalaではAkkaというアクター基盤を使用することができます。

このアクターは(理論的には色々あるでしょうが)状態を持ったactive objectを実現する方式になっています。

active objectであるアクター間はメッセージボックスでキューイングされたメッセージ通信で協調動作するので、アプリケーションレベルで特別な排他制御を行わなくても並行・並列処理を簡潔に記述することができます。

アクター版のCSVパーサーであるParserActorは以下になります。

package sample

import akka.actor._

case class ParseCharEvent(c: Char)
case object ParseEnd
case class ParseResult(result: Seq[String])

class ParserActor extends Actor {
var state: ParseState = InitState

def receive = {
case ParseCharEvent(c) =>
state = state.event(c)
case ParseEnd =>
val end = state.endEvent()
state = end
sender ! ParseResult(end.row)
}
}

基本的に行っているのは(passive objectである)ParserObjectと同じです。オブジェクトのインスタンス変数でmutableな状態を保持する実現方法になります。

ParserObjectとの違いは、メソッド呼び出しではなくアクター間でのメッセージ通信によって処理が実行されるため、以下のようなメッセージ通信のためのコードを用意しないといけない点です。

  • アクター間で送受信されるメッセージの定義
  • メッセージを受け取り処理を行うイベントハンドラ

ParserObjectではメソッドとして簡単に定義できる処理ですが、これをメッセージ処理ように仕立て直さないといけないわけです。

アクターはとても簡単に利用できるのですが、少しボイラープレイトのコードを書く必要があります。

使い方
package sample

import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalatest._
import org.scalatest.prop.GeneratorDrivenPropertyChecks
import akka.actor._
import akka.pattern.ask
import akka.util.Timeout
import scala.concurrent.duration._

@RunWith(classOf[JUnitRunner])
class ParserActorSpec extends WordSpec with Matchers with GivenWhenThen with GeneratorDrivenPropertyChecks {
"ParseActor" should {
"parse" in {
implicit val timeout = Timeout(5.seconds)
val system = ActorSystem("state")
implicit val context = system.dispatcher
val actor = system.actorOf(Props[ParserActor])

val text = "abc,def,xyz"
for (c <- text) {
actor ! ParseCharEvent(c)
}
for (r <- actor ? ParseEnd) yield {
r match {
case ParseResult(r) =>
println(s"ParserActorSpec: $r")
r should be (Vector("abc", "def", "xyz"))
}
}
system.shutdown()
}
}
}

アプリケーションロジックは以下の部分です。

val text = "abc,def,xyz"
for (c <- text) {
actor ! ParseCharEvent(c)
}
for (r <- actor ? ParseEnd) yield {
r match {
case ParseResult(r) =>
println(s"ParserActorSpec: $r")
r should be (Vector("abc", "def", "xyz"))
}
}

アクターを使うために以下のような準備が必要になります。

// タイムアウト値の暗黙知を定義
implicit val timeout = Timeout(5.seconds)
// アクターの実行基盤を作成
val system = ActorSystem("state")
// スレッドの実行文脈を定義
implicit val context = system.dispatcher
// アクターの生成
val actor = system.actorOf(Props[ParserActor])
....
// アクターの実行基盤をシャットダウン
system.shutdown()

アクターはJava流のスレッドによる並行・並列プログラミングと比較するとはるかに楽でバグも出にくいアプローチなのですが、それでも少し込み入ったボイラープレイトが必要になります。

実行

実行結果は以下になります。

$ sbt test-only sample.ParserActorSpec
[info] Compiling 1 Scala source to /Users/asami/src/workspace2015/0317.blog.statemachine/target/scala-2.11/test-classes...
ParserActorSpec: List(abc, def, xyz)
[info] ParserActorSpec:
[info] ParseActor
[info] - should parse
[info] ScalaTest
[info] 36mRun completed in 408 milliseconds.0m
[info] 36mTotal number of tests run: 10m
[info] 36mSuites: completed 1, aborted 00m
[info] 36mTests: succeeded 1, failed 0, canceled 0, ignored 0, pending 00m
[info] 32mAll tests passed.0m
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 2 s, completed 2015/03/21 17:48:09

まとめ

今回は状態機械のベースとなるcase class群(ParseState)を作成した後、通常のOOPのアプローチとして普通のオブジェクト版(passive object)のパーサーとアクター版(active object)のパーサーを作成しました。

ParseStateはOOPのvalue objectであると同時にFPの代数的データ型でもあります。このParseStateを普通のオブジェクト、アクターの両方で普通に利用して状態機械を作成できることが確認できました。

次回は今回のParseStateをベースにMonadic Programming, Functional Reactive Programmingにおける状態機械の実現方法について考えます。

諸元

  • Scala 2.11.4
  • Scalaz 7.1.0
  • Scalaz-stream 0.6a
  • Scalatest 2.2.4

Scala的状態機械/FP編

$
0
0

Scalaにおける状態機械の実装戦略について検討しています。

Scala的状態機械/OOP編」で状態+状態遷移を表現するトレイトであるParseStateを作成しました。ParseStateの具象クラスはcase classまたはcase objectとして実現しています。これらのトレイト、case class、case objectがワンセットでFPで使用できる代数的データ構造となっています。

「OOP編」ではこのParseStateを使用して、オブジェクト版の状態機械とアクター版の状態機械を作成しました。代数的データ構造でもあるParseStateがOOP的に問題なく使用できることが確認できました。

「FP編」では、ParseStateの代数的データ構造の性質を活かしてMonadic Programming(以下MP)版の状態機械を考えてみます。

Stateモナド版状態機械

MPで状態を扱いたい場合には、状態を扱うモナドであるStateモナドが有力な選択肢です。

代数的データ型であるParseStateはそのまま利用し、これをStateモナドでくるむことで状態遷移を実現したものが以下のParserStateMonadです。

package sample

import scalaz._, Scalaz._

object ParserStateMonad {
def action(event: Char) = State((s: ParseState) => {
(s.event(event), event)
})

def parse(events: Seq[Char]): Seq[String] = {
val s = events.toVector.traverseS(action)
val r = s.run(InitState)
r._1.endEvent.row
}
}
action関数

モナドを使った共通機能を作る場合には、共通機能としていわゆるモナディック関数を提供するのが一つの形になっています。

モナディック関数とは「A→M[B]」(Mはモナド)の形をしている関数です。モナドMのflatMapコンビネータの引数になります。

Stateモナドを使用する場合には、A→State[B]の形の関数を用意することになります。

def action(event: Char) = State((s: ParseState) => {
(s.event(event), event)
})

今回作成したStateモナド用のモナディック関数であるaction関数は「Char→State[ParseState→(ParseState, Char)]」の形をしています。

A→M[B]の形とは以下の対応になります。

  • A : Char
  • M : State
  • B : ParseState→(ParseState, Char)

Stateモナドに設定している関数は「ParseState→(ParseState, Char)」の形をしていますが、action関数全体ではaction関数の引数もパラメタとして利用しているので、結果として「Char→ParseState→(Parse, Char)」の動きをする関数になっています。

action関数が返すStateモナドはParseStateによって表現された状態を、受信したイベントとの組合せ状態遷移する関数が設定されています。

状態+状態遷移を表現するオブジェクト兼代数的データ型であるParseStateがきちんと定義されていれば、Stateモナド用のモナディック関数を定義するのは容易なことが分かります。

parse関数

CharのシーケンスからCSVをパースするparse関数は以下になります。

def parse(events: Seq[Char]): Seq[String] = {
val s = events.toVector.traverseS(action)
val r = s.run(InitState)
r._1.endEvent.row
}

parse関数は、Stateモナド用モナディック関数actionと型クラスTraverseのtraverseSコンビネータの組合せで実現しています。

traverseSコンビネータはStateモナド用のtraverseコンビネータです。Scalaの型推論が若干弱いためStateモナド専用のコンビネータを用意していますが、動きは通常のtraverseコンビネータと同じです。

状態遷移のロジックそのものはParseStateオブジェクトにカプセル化したものをaction関数から返されるStateモナド経由で使用します。

traverseSコンビネータとStateモナドを組み合わせると、Traverseで走査対象となるコレクションをイベント列と見立てて、各イベントの発生に対応した状態機械をStateモナドで実現することができます。この最終状態を取得することで、イベント列を消化した最終結果を得ることができます。

OOPオブジェクトであり代数的データ構造でもあるParseStateは、このようにしてStateモナドに包むことで、FP的な状態機械としてもそのまま使用することができるわけです。

使い方

プログラムを実行するためのSpecは以下になります。

package sample

import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalatest._
import org.scalatest.prop.GeneratorDrivenPropertyChecks

@RunWith(classOf[JUnitRunner])
class ParserStateMonadSpec extends WordSpec with Matchers with GivenWhenThen with GeneratorDrivenPropertyChecks {
"ParserStateMonad" should {
"parse" in {
val events = "abc,def,xyz".toVector
val r = ParserStateMonad.parse(events)
println(s"ParserStateMonadSpec: $r")
r should be (Vector("abc", "def", "xyz"))
}
}
}
実行

実行結果は以下になります。

$ sbt test-only sample.ParserStateMonadSpec
ParserStateMonadSpec: List(abc, def, xyz)
[info] ParserStateMonadSpec:
[info] ParserStateMonad
[info] - should parse
[info] ScalaTest
[info] 36mRun completed in 400 milliseconds.0m
[info] 36mTotal number of tests run: 10m
[info] 36mSuites: completed 1, aborted 00m
[info] 36mTests: succeeded 1, failed 0, canceled 0, ignored 0, pending 00m
[info] 32mAll tests passed.0m
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 1 s, completed 2015/03/21 17:49:39

scalaz stream版状態機械

Stateモナド版状態機械ではStateモナドと型クラスTraverseのtraverseSコンビネータを使用して、状態機械をMPで実現しました。

この実現方法は、メモリ上に展開したデータに対して一括処理をするのには適していますが、大規模データ処理やストリーム処理への適用は不向きです。

そこで、大規模データ処理やストリーム処理をFunctional Reactive Programming(以下FRP)の枠組みで行うために、scalaz streamを使用して状態機械の実装してみました。

package sample

import scalaz._, Scalaz._
import scalaz.stream._
import scalaz.stream.Process.Process0Syntax

object ParserProcessMonad {
def fsm(state: ParseState): Process1[Char, ParseState] = {
Process.receive1 { c: Char =>
val s = state.event(c)
Process.emit(s) fby fsm(s)
}
}

def parse(events: Seq[Char]): Seq[String] = {
val source: Process0[Char] = Process.emitAll(events)
val pipeline: Process0[ParseState] = source.pipe(fsm(InitState))
val result = pipeline.toList.last
result.endEvent.row
}

import scalaz.concurrent.Task

def parseTask(events: Seq[Char]): Task[Seq[String]] = {
val source: Process0[Char] = Process.emitAll(events)
val pipeline: Process[Task, ParseState] = source.pipe(fsm(InitState)).toSource
for {
lastoption <- pipeline.runLast
last = lastoption.get
} yield last.endEvent.row
}
}
fsm関数

Processモナドは一種の状態機械なので、この性質を利用してPraseStateによる状態遷移をProcessモナド化します。この処理を行うのがfsm関数です。

fsm関数は状態であるParseStateを引数に、Charを受け取るとParseStateとCharの組合せで計算された新しいParseStateによる状態に遷移しつつ処理結果としてParseStateを返すProcessモナドを返します。

def fsm(state: ParseState): Process1[Char, ParseState] = {
Process.receive1 { c: Char =>
val s = state.event(c)
Process.emit(s) fby fsm(s)
}
}
parse関数

parse関数はscalaz streamをメモリ上の小規模データに適用する際の典型的な使い方です。

def parse(events: Seq[Char]): Seq[String] = {
val source: Process0[Char] = Process.emitAll(events)
val pipeline: Process0[ParseState] = source.pipe(fsm(InitState))
val result = pipeline.toList.last
result.endEvent.row
}

parse関数のおおまかな流れは以下になります。

  1. パイプライン(Processモナド)を生成
  2. パイプラインの処理を記述
  3. パイプラインで加工後のデータを取得
パイプラインの生成

まず引数のCharシーケンスからソースとなるProcessモナドを生成します。

val source: Process0[Char] = Process.emitAll(events)

ProcessのemitAll関数は引数に指定されたシーケンスによるストリームを表現するProcessモナドを生成します。

ただし、内部的に非同期実行する本格的なシーケンスではなく、通常のシーケンスに対してストリームのインタフェースでアクセスできるという意味合いのProcessモナドになります。(内部的には実行制御に後述のTaskモナドではなく、Idモナドを使用しています。)

パイプラインの処理を記述

pipeコンビネータでfsm関数にInitStateを適用して得られるProcessモナドをパイプライン本体のProcessモナドに設定しています。

val pipeline: Process0[ParseState] = source.pipe(fsm(InitState))

これが処理の本体です。ここではpipeコンビネータを始めとする各種コンビネータを使ってパイプラインを構築します。

パイプラインで加工後のデータを取得

最後にtoListメソッドで、パイプラインの処理結果をListとして取り出し、ここからパース結果を取り出す処理を行っています。

val result = pipeline.toList.last
result.endEvent.row
parseTask関数

parse関数はscalaz streamをメモリ上の小規模データに適用する際の使用例ですが、より本格的な応用である大規模データ処理やストリーム処理では少し使い方が変わってきます。

そこで、参考のために実行制御にTaskモナドを使ったバージョンのparseTask関数を作りました。

def parseTask(events: Seq[Char]): Task[Seq[String]] = {
val source: Process[Task, Char] = Process.emitAll(events).toSource
val pipeline: Process[Task, ParseState] = source.pipe(fsm(InitState))
for {
lastoption <- pipeline.runLast
last = lastoption.get
} yield last.endEvent.row
}

parse関数と同様にparseTask関数のおおまかな流れは以下になります。

  1. パイプライン(Processモナド)を生成
  2. パイプラインの処理を記述
  3. パイプラインで加工後のデータを取得
パイプラインの生成

ProcessモナドのtoSourceメソッドで、Taskモナドを実行制御に使用するProcessモナドに変換されます。

val source: Process[Task, Char] = Process.emitAll(events).toSource
パイプラインの処理を記述

fsm関数から得られたProcessモナドをpipeコンビネータでパイプラインに設定します。

val pipeline: Process[Task, ParseState] = source.pipe(fsm(InitState))

この処理はTask版でないparse関数と全く同じです。

パイプラインで加工後のデータを取得

Taskモナドを実行制御に使用する場合には、for式などを使ってモナディックに実行結果を取得する形になります。

for {
lastoption <- pipeline.runLast
last = lastoption.get
} yield last.endEvent.row
使い方

プログラムを実行するためのSpecは以下になります。

package sample

import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalatest._
import org.scalatest.prop.GeneratorDrivenPropertyChecks

@RunWith(classOf[JUnitRunner])
class ParserProcessMonadSpec extends WordSpec with Matchers with GivenWhenThen with GeneratorDrivenPropertyChecks {
"ParserProcessMonad" should {
"parse" in {
val events = "abc,def,xyz".toVector
val r = ParserProcessMonad.parse(events)
println(s"ParserProcessMonadSpec: $r")
r should be (Vector("abc", "def", "xyz"))
}
}
}
実行

実行結果は以下になります。

$ sbt test-only sample.ParserProcessMonadSpec
ParserProcessMonadSpec: List(abc, def, xyz)
[info] ParserProcessMonadSpec:
[info] ParserProcessMonad
[info] - should parse
[info] ScalaTest
[info] 36mRun completed in 301 milliseconds.0m
[info] 36mTotal number of tests run: 10m
[info] 36mSuites: completed 1, aborted 00m
[info] 36mTests: succeeded 1, failed 0, canceled 0, ignored 0, pending 00m
[info] 32mAll tests passed.0m
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 0 s, completed 2015/03/21 17:49:12

scalaz stream + Stateモナド版状態機械

「scalaz stream」ではParseStateオブジェクトを直接使用して状態機械を作成しました。通常はこれで十分ですが、Stateモナド用モナディック関数が用意されている場合は、こちらを使用する方法もあります。

この方法では、Stateモナドの使い方だけ理解していればよいので、プログラミングはより簡単かつ汎用的になります。

package sample

import scalaz._, Scalaz._
import scalaz.stream._

object ParserProcessMonadStateMonad {
def fsm(state: ParseState): Process1[Char, ParseState] = {
Process.receive1 { c: Char =>
val s = ParserStateMonad.action(c).exec(state)
Process.emit(s) fby fsm(s)
}
}

def parse(events: Seq[Char]): Seq[String] = {
val source: Process0[Char] = Process.emitAll(events)
val pipeline: Process0[ParseState] = source.pipe(fsm(InitState))
val result = pipeline.toList.last
result.endEvent.row
}

import scalaz.concurrent.Task

def parseTask(events: Seq[Char]): Task[Seq[String]] = {
val source: Process0[Char] = Process.emitAll(events)
val pipeline: Process[Task, ParseState] = source.pipe(fsm(InitState)).toSource
for {
lastoption <- pipeline.runLast
last = lastoption.get
} yield last.endEvent.row
}
}
fsm関数

ParseStateによる状態機械の動作を行うProcessモナドを生成するfsm関数のStateモナド版は以下になります。

def fsm(state: ParseState): Process1[Char, ParseState] = {
Process.receive1 { c: Char =>
val s = ParserStateMonad.action(c).exec(state)
Process.emit(s) fby fsm(s)
}
}

ParseStateのeventメソッドを直接使用する代わりに、ParseStateを包んだStateモナドを返すモナディック関数actionを使用します。

この版のfsm関数ではaction関数から取得したStateモナドのexecメソッドを使用して、現状態とイベント(Char)から新状態を計算し、この状態を保持した、新たなProcessを生成しています。

この方法のメリットはParseStateの使い方(この場合はeventメソッド)は知る必要がなく、汎用的なStateモナドの使い方だけ知っていればよい点です。

つまりaction関数だけ作っておけば、Traverseを使った状態機械、Processモナドを使った状態機械のどちらも簡単に作ることができるわけです。

使い方

プログラムを実行するためのSpecは以下になります。

package sample

import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalatest._
import org.scalatest.prop.GeneratorDrivenPropertyChecks

@RunWith(classOf[JUnitRunner])
class ParserProcessMonadStateMonadSpec extends WordSpec with Matchers with GivenWhenThen with GeneratorDrivenPropertyChecks {
"ParserProcessMonad" should {
"parse" in {
val events = "abc,def,xyz".toVector
val r = ParserProcessMonadStateMonad.parse(events)
println(s"ParserProcessMonadStateMonadSpec: $r")
r should be (Vector("abc", "def", "xyz"))
}
}
}
実行

実行結果は以下になります。

$ sbt test-only sample.ParserProcessMonadStateMonadSpec
ParserProcessMonadStateMonadSpec: List(abc, def, xyz)
[info] ParserProcessMonadStateMonadSpec:
[info] ParserProcessMonad
[info] - should parse
[info] ScalaTest
[info] 36mRun completed in 286 milliseconds.0m
[info] 36mTotal number of tests run: 10m
[info] 36mSuites: completed 1, aborted 00m
[info] 36mTests: succeeded 1, failed 0, canceled 0, ignored 0, pending 00m
[info] 32mAll tests passed.0m
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 0 s, completed 2015/03/21 17:49:26

状態機械実装戦略

オブジェクト・モデルで状態機械が出てきた場合の実装戦略としては、まずベースとして:

  • sealed trait + case classで状態+状態遷移のオブジェクト&代数的データ型(以下、状態遷移case class)

を作成します。

前回に見たようにこの状態遷移case classを使って、OOP版の状態機械を簡単に作成することができます。

次に、FP用に以下の部品を整備しておくのがよいでしょう。

  • Stateモナド用モナディック関数
  • Processモナド用状態機械関数

どちらの関数も状態遷移case classが用意されていれば、ほとんど定型的な記述のみで作成することができます。この部品を使うことでFP版の状態機械を簡単に作成することができます。

以上、状態機械実装戦略について整理しました。

この戦略で重要なのは、状態+状態遷移を表現するために作成した状態遷移case classは1つだけ作ればよく、これをOOP流、FP流にマルチに展開していける点です。

この点が確定すれば、オブジェクト・モデルに状態機械が出てきたら安心して1つの状態遷移case classの実装に集中することができます。

まとめ

OFPの状態機械の実装についてOOP的実装、FP的実装について整理してみました。

いずれの場合もオブジェクト&代数的データ型である「状態遷移case class」が基本で、ここからOOP的状態機械、FP的状態機械へマルチに展開できることが確認できました。

OFPではアプリケーション・ロジックは可能な限りFP側に寄せておくのがよいので、FPでも状態機械を簡単に実現できることが確認できたのは収穫でした。

また、FP的なアプローチが使えないケースが出てきても、簡単にOOP的アプローチに切り替え可能なのも安心材料です。

諸元

  • Scala 2.11.4
  • Scalaz 7.1.0
  • Scalaz-stream 0.6a
  • Scalatest 2.2.4

[OFAD] クラウド・サービスのモデリング

$
0
0

ここ数年Apparel Cloudの開発に携わっています。

Apparel Cloudはアパレル向けのクラウド・サービスを実現するためのサービス・プラットフォームで、サーバーサイドの実装はScala+ScalazによるMonadic Programmingを採用しています。

また、サービス企画からクラウド・サービス向けの仕様策定にはオブジェクト指向開発の伝統的なモデル体系をクラウド・サービス向けにチューニングしたものを用いています。

実システムの構築にこれらの新しい技術を適用して一通り材料も揃ってきたので、クラウド・サービス開発の枠組みとその要素技術について整理していこうと思います。

今回はモデリング体系の枠組みの整理です。この枠組みをベースに、個々の要素技術の詳細化を行っていく予定です。

枠組み

大枠では以下のような枠組みを考えています。

  • Service Platform as-a-Service
  • Object-Functional Programming
  • Object-Functional Analysis and Design

まず、クラウド・サービスの開発はスクラッチ開発ではなく、Service Platform上でのカスタマイズや追加機能をプラグインとして開発する形を想定しています。

また、プログラミング・モデルはオブジェクト指向と関数型を併用したObject-Functional Programmingです。並列、並行、分散、ストリーミングといったクラウド時代の要件を満たすためには関数型の導入が必須となるためです。

スクラッチ開発ではなくサービス・プラットフォーム上でのカスタマイズ+プラグイン、オブジェクト指向と関数型を併用したプログラミング・モデルという2つの大きな枠組みの変更は、当然ながら要件定義から分析・設計の一連の技術にも影響を与えます。

Service Platform as-a-Service

クラウド・サービスの開発では、スクラッチでシステムを組むというより、既存のサービスを組み合わせた上で、必要な部分だけ開発するという形が基本です。

このアプローチの核になるのがService Platformです。さらにService Platformをクラウドサービスとして提供したものをService Platform as-a-Service(SPaaS)と呼ぶことにします。

SPaaSが提供するプラットフォームを利用することで、クラウド・サービスの開発と運用をより簡単に実現することができます。

Apparel Cloudは、アパレル業界でのO2O用途向けのSPaaSということになります。

本稿を起点とする一連のブログ記事では、SPaaSの具体的な紹介や利用方法というよりも、SPaaSの存在を前提としたクラウド・サービス開発のモデリング(Object-Functional Analysis and Design)やプログラミング・モデル(Object-Functional Programming)を整理し、可能な範囲で体系化していきたいと考えています。

Object-Functional Programming

Object-Functional Programming(OFP)はObject-Oriented Programming(OOP)とFunctional Programming(FP)を融合させたプログラミング・モデルです。

FPはアルゴリズム記述の容易性や信頼性、保守性の向上がメリットですがOOPと比べると以下の問題点もあり、エンタープライズ分野では限定的な利用にとどまっていました。

  • 実行性能の低下
  • メモリ消費の増大
  • 難易度の高いプログラミング・モデル
  • (エンタープライズ的な意味で実績のある)安定した実行環境
  • 開発エコシステム(開発環境、クラスライブラリ、コミュニティなど)

しかし、クラウド時代に入って以下のような目的により積極的に採用する必要性が出てきています。

  • 並列・並行・分散プログラミング
  • 大規模データ処理
  • ストリーミング処理
  • 問合せ処理
  • DSL(Domain Specific Language)

特に、モナド(monad)という新しい概念をベースとしたMonadic Programming(MP)、MPをベースにしたFunctional Reactive Programming(FRP)が、本質的なプログラミング・モデルの変革をもたらします。

さらに、既存のOOPとの併用・融合も新しいテーマとなってきます。

本ブログでは今までも、これらのテーマについてScala+Scalazによるソリューションについて検討してきました。今後も引き続きこのテーマについて検討を進めていきますが、可能な範囲で今回提示した枠組みであるSPaaS、OFADとの関係についても考えていきたいと思います。

Object-Functional Analysis and Design

サービス企画の要求をまとめて、サービス開発に落とし込むためのメソッドはObject-Oriented Analysis and Design(OOAD)が基本になりますが、Service Platform as-a-Service(SPaaS)とObject-Functional Programming(OFP)という2つの中核的な技術変革により要件定義から分析・設計に至る一連のアクティビティにも大きなインパクトが出てくることが予想されます。

OOADについては、以前大学で教えていた時の内容を以下の2冊にまとめています。

現時点でもこの内容がボクとしての結論で、大きな枠組としては変わっていません。

ざっくりいうとボキャブラリとなるドメイン・モデルと物語であるユースケースを起点としたアプリケーション・モデルの二系統のモデルを軸に業務モデルからObject-Oriented Programming(OOP)による実装までの一連の開発アクティビティを一気通貫でカバーしています。

このOOADに対して、SPaaSとOFPの成分をどのように織り込んでいくのかという点が論点となります。本ブログでは、これらの要素を織り込んでOOADを拡張した方法論をObject-Functional Analysis and Design(OFAD)と呼ぶことにします。

まとめ

材料が揃ってきたので、クラウド・サービス開発の方法論の整備を始めることにしました。今回は議論のベースとなる枠組みを整理しました。

Monadic ProgrammingやFunctional Reactive Programmingを中心とするObject-Functional Programmingは、今まさに技術革新が進行中のホットな技術分野であり、本ブログの中心的なテーマとして取り上げてきました。今後も基本的には変わらないスタンスですが、今回提示した枠組みであるSPaaS、OFADとの関係についても併せて考えていきたいと思います。

Object-Functional Analysis and Designは、既存のOOADがベースなので既存のものと大きな違いはない想定ですが、SPaaS成分、OFP成分が入ることで相応の拡張が必要になりそうです。Apparel Cloudでの実践で得られた経験をもとに体系化を進めていきたいと思います。

[OFAD]Everforthのモデル体系

$
0
0

前回「クラウド・サービスのモデリング」で、クラウド・サービスを開発する際のモデリングの枠組みについて考えました。

クラウド・サービスの開発はSPaaS(Service Platform as-a-Service), OFP(Object-Functional Programming), OFAD(Object-Functional Analysis and Design)の3つの要素から構成されるという枠組みを提示しました。

今回は、この枠組の中のOFADを実現するためにEverforthが採用しているモデル体系についてご紹介します。

モデリングの参照モデル

クラウド・サービスのモデリングを議論するための参照モデルとして以下のものを使用します。



この参照モデルでは、モデルは大きく以下の2系統に分かれます。

  • ドメイン・モデル
  • アプリケーション・モデル

ドメイン・モデルは、利用者やサービス提供者、開発者といったステークホルダー間で共有する事業ドメインのモデルです。最上流では用語集にまとめられたドメイン・オブジェクトが、最終的にはデータベースで管理されたり、センサーなどの外部デバイスとして実現されます。

アプリケーション・モデルは、各ステークホルダーの要件を定義し、ここからサービスとして実現する方法を定義するモデルです。ビジネス・ユースケースからユースケースとして定義した物語を、サービスに落としこみます。

クラウド・サービスの設計と実装

分析と設計のアクティビティによって以下の2つのモデルが作成されます。

  • ドメイン・モデル
  • サービス・モデル

この2つのモデルから、サービス・プラットフォーム上にクラウド・サービスを構築する際の、設計と実装を行う際の流れを以下にまとめました。


スクラッチのシステム開発の場合には、この2つのモデルからシステム全体を開発するわけですが、サービス・プラットフォーム上でクラウドサービスを開発する場合、サービス・プラットフォームが提供するDSLに載せる形で開発することになります。

ドメイン・モデルについては、業界全体の現時点での技術レベル的に80%程度は自動生成することを前提にするのが合理的です。サービス・プラットフォームを使う場合は、サービス・プラットフォームが提供するDSLに合わせた実装を自動生成することになります。

サービス・モデルはOFPを用いて実装することになります。この場合も、サービス・プラットフォームが提供するDSLに合わせた実装になります。

Everforthでのモデリング

「モデリングの全体像」と「クラウド・サービスの設計と実装」の参照モデルについて説明しました。この参照モデルにそった形でEverforthで採用しているモデリングの枠組みは以下になります。




モデルは大きくサマリ・モデルと詳細モデルの2つに分かれます。

サマリ・モデル

サービス毎にサマリ・モデルとして以下のモデルを作成します。

  • マインドマップ・モデル
  • WireFrame(WF)
  • API利用一覧
  • サービス記述
マインドマップ・モデル

マインドマップ・モデルは拙著「マインドマップではじめるモデリング講座」のものをベースにしています。ざっくりいうとマインドマップでビジネス・ユースケースとドメイン・モデルを記述するための手法です。

マインドマップ・モデルで作成したモデルは基本的にOOADのモデルなので、必要に応じて本格的なOOADモデリングに展開可能です。

WireFrame

WireFrame(WF)はWebやiOS/Androidアプリ開発で一般的に使われているものを採用しました。画面設計を中心にしたモデルです。ただし、画面とクラウド・サービスが提供するAPIの関係を定義する拡張を行っています。

API利用一覧

API利用一覧は、WFで定義したものも含めて、WebやiOS/Androidアプリケーションからクラウド・サービスのAPI利用方法一覧です。

サービス記述

サービス記述は、開発するサービスの概要情報を定義したものです。

このサービス・プラットフォームのカスタマイズ情報を定義することがサービス記述の重要な目的の一つになっています。

レギュラー・モデル

またサービスの要件が複雑な場合は、必要に応じてレギュラー・モデルとして以下のモデルを作成します。

  • ユースケース・モデル
  • ドメイン・モデル

ユースケース・モデルは拙著「上流工程UMLモデリング」で定義したユースケース一覧とユースケース詳細の2つの帳票を用いています。いずれもExcelやGoogleスプレッドシートなどの表計算ソフトで作成します。ユースケース一覧の作成が主で、必要に応じてユースケース詳細を作成するバランスで運用しています。

ドメイン・モデルはモデル・コンパイラEverforthModelerのDSLとして記述します。DSLはemacs-org形式のプレインドキュメントです。EverforthModelerはDSLのモデルから、サービス・プラットフォームが定義するエンティティ管理のDSLを自動生成します。

ユースケース・モデルを作成することで、自然にドメイン・モデルが整備されます。このドメイン・モデルの実装はモデル・コンパイラで自動生成してサービスに組込みます。そして、このドメイン・モデルを操作するサービスをユースケース・モデルをベースにAPI仕様としてまとめOFPによる実装につなげていきます。

まとめ

EverforthでApparel Cloudを開発する際に使用しているモデル体系についてご紹介しました。

実装技術としてはScalaによるOFPを使用していますが、要件定義や分析といった上流工程におけるモデリングの重要性は通常のエンタープライズ開発と変わるところはありません。ベースとなる方法論としてはOOADが引き続き有力です。

ただ、(1)クラウド・サービス・プラットフォームを前提とする、(2)実装技術で関数型成分が重要になる、という2つの要因によってモデリングの具体的な手法には少なからず影響が出てきます。

このいった点も含めて、クラウド・サービスのモデリングの方法論について、実践の経験をベースに今後もブログで検討を進めていきたいと思います。

[scalaz-stream] ストリーミングで状態機械

$
0
0

Functional Reactive Programming(FRP)の眼目の一つはMonadic Programming(MP)によるストリーミング処理です。

MPでFRPを記述できることで安全なプログラムを簡単に書くことができるようになります。

そこで今回は「Scala的状態機械/FP編」で作成したProcessモナド版CSVパーサーをストリーミング処理に適用してみます。

準備

Scala的状態機械/OOP編で作成した状態遷移を記述した代数的データ型ParseStateをストリーミング用に一部手直しします。

package sample

sealed trait ParseState {
def event(c: Char): ParseState
def endEvent(): EndState
}

case object InitState extends ParseState {
def event(c: Char) = c match {
case ',' => InputState(Vector(""), "")
case '\n' => EndState(Nil)
case _ => InputState(Nil, c.toString)
}
def endEvent() = EndState(Nil)
}

case class InputState(
fields: Seq[String],
candidate: String
) extends ParseState {
def event(c: Char) = c match {
case ',' => InputState(fields :+ candidate, "")
case '\n' => EndState(fields :+ candidate)
case _ => InputState(fields, candidate :+ c)
}
def endEvent() = EndState(fields :+ candidate)
}

case class EndState(
row: Seq[String]
) extends ParseState {
def event(c: Char) = c match {
case ',' => InputState(Vector(""), "")
case '\n' => EndState(Nil)
case _ => InputState(Nil, c.toString)
}
def endEvent() = this
}

case class FailureState(
row: Seq[String],
message: String
) extends ParseState {
def event(c: Char) = this
def endEvent() = sys.error("failure")
}

具体的にはEndStateが完全終了ではなく、次のイベントが発生したら入力受付け状態に復帰するようにしました。

Scala的状態機械/FP編で作成したParserStateMonadは変更ありません。今回はこの中で定義しているモナディック関数であるactionを使用します。

package sample

import scalaz._, Scalaz._

object ParserStateMonad {
def action(event: Char) = State((s: ParseState) => {
(s.event(event), event)
})

def parse(events: Seq[Char]): Seq[String] = {
val s = events.toVector.traverseS(action)
val r = s.run(InitState)
r._1.endEvent.row
}
}

ストリーミング版

それではストリーミング版の作成に入ります。

EventProcessor

まずストリーミング処理の動作環境として、scalaz-streamが提供する非同期キュー(scalaz.stream.async.mutable.Queue)を作成します。

package sample

import scalaz.concurrent.Task
import scalaz.stream._

object EventProcessor {
val q = async.unboundedQueue[Char]

val eventStream: Process[Task, Char] = q.dequeue
}

EventProcessorは、scalaz.stream.async.unboundedQueue関数で作成したQueueによって、ストリームに対するイベントと、イベントをハンドリングするProcessモナドを接続します。

Queueに対して送信されたイベントは、Queueのdequeueメソッドで取得できるProcessモナドに転送されます。

StreamingParser

ストリーミング処理用のCSVパーサーは以下になります。

package sample

import scala.language.higherKinds
import scalaz.concurrent.Task
import scalaz.stream._

object StreamingParser {
def createParser[F[_]](source: Process[F, Char]): Process[F, ParseState] = {
source.pipe(fsm(InitState))
}

def fsm(state: ParseState): Process1[Char, ParseState] = {
Process.receive1 { c: Char =>
val s = ParserStateMonad.action(c).exec(state)
Process.emit(s) fby fsm(s)
}
}
}

まずパーサーはProcessモナドに組み込んで使用する必要があるので、組込み可能なモナディック関数fsmを用意します。これは、Scala的状態機械/FP編で作成したParseProcessMonadStateMonadのfsm関数と同じものです。

その上で、このfsm関数をpipeコンビネータでProcessモナドに組み込む関数createParserを用意しました。この関数は便利関数という位置付けのものです。

つまりScala的状態機械/FP編で作成した部品はそのままストリーミング処理にも適用できるということになります。

使い方

動作確認のためのプログラムは以下になります。

package sample

import scalaz.concurrent.Task
import scalaz.stream._
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

object StreamingParserSample {
def main(args: Array[String]) {
val stream = EventProcessor.eventStream
build(stream)
execute()
}

def build(stream: Process[Task, Char]) {
Future {
val parser = StreamingParser.createParser(stream).map {
case EndState(row) => report(row)
case x => ignore(x)
}.run.run
}
}

def report(row: Seq[String]) {
println(s"report: $row")
}

def ignore(s: ParseState) {
println(s"ignore: $s")
}

def execute() {
val queue = EventProcessor.q
val a = "abc,def,ghi\n"
val b = "jkl,mno,pqr\n"
a.foreach(queue.enqueueOne(_).run)
Thread.sleep(2000)
b.foreach(queue.enqueueOne(_).run)
Thread.sleep(2000)
}
}

まずイベントの送信ですが、EventProcessorのeventStreamメソッドで取得したProcessモナドに対してbuild関数でパーサーの組込みを行っています。パーサーはStreamingParserのcreateParser関数で作成しています。さらにmapコンビネータでパース結果のコンソール出力処理を追加しています。

execute関数は、EventProcessorのメソッドで取得した非同期キューに対してenqueueOneメソッドでイベントを送出しています。

非同期キューに対して送出したイベントが、非同期キューと連動したProcessモナドに対して送られます。

この例では、プログラム内に埋め込まれたデータを使用していますが、WebサーバーのHTTPリクエストやAkkaのメッセージの受信処理でこの非同期キューに転送することで、簡単にProcessモナドによるストリーミング処理を行うことができます。

実行

実行結果は以下になります。

ignore: InputState(List(),a)
ignore: InputState(List(),ab)
ignore: InputState(List(),abc)
ignore: InputState(List(abc),)
ignore: InputState(List(abc),d)
ignore: InputState(List(abc),de)
ignore: InputState(List(abc),def)
ignore: InputState(List(abc, def),)
ignore: InputState(List(abc, def),g)
ignore: InputState(List(abc, def),gh)
ignore: InputState(List(abc, def),ghi)
report: List(abc, def, ghi)
ignore: InputState(List(),j)
ignore: InputState(List(),jk)
ignore: InputState(List(),jkl)
ignore: InputState(List(jkl),)
ignore: InputState(List(jkl),m)
ignore: InputState(List(jkl),mn)
ignore: InputState(List(jkl),mno)
ignore: InputState(List(jkl, mno),)
ignore: InputState(List(jkl, mno),p)
ignore: InputState(List(jkl, mno),pq)
ignore: InputState(List(jkl, mno),pqr)
report: List(jkl, mno, pqr)

「ignore: InputState(List(),a)」といった形のパース処理の途中結果が流れた後に、「report: List(abc, def, ghi)」といった形のパース結果が流れてくることが確認できました。アプリケーション側ではパース結果のみをmatch式で拾いだして処理をすることになります。

フロー制御

StreamingParserのfsm関数はパース処理の途中結果もすべてストリームを流れてきます。

これはちょっとクールではないので、簡単なフロー制御を入れて対処することにしましょう。

この目的でStreamingParserを改修してStreamingParserRevisedを作成しました。

package sample

import scala.language.higherKinds
import scalaz.concurrent.Task
import scalaz.stream._

object StreamingParserRevised {
def createParser[F[_]](source: Process[F, Char]): Process[F, Seq[String]] = {
source.pipe(fsm(InitState))
}

def fsm(state: ParseState): Process1[Char, Seq[String]] = {
Process.receive1 { c: Char =>
val s = ParserStateMonad.action(c).exec(state)
s match {
case EndState(row) => Process.emit(row) fby fsm(InitState)
case FailureState(row, message) => log(message); fsm(InitState)
case x => fsm(s)
}
}
}

def log(msg: String) {
println(s"error: $msg")
}
}

fsm関数ではaction関数から返されるStateモナドの実行結果によって以下のように処理を切り替えています。

EndStateの場合
パース結果の文字列をストリームに送出し、状態機械は初期状態に戻す
FailureStateの場合
エラーをログに出力し、状態機械は初期状態に戻す
その他
パース途中結果の状態に状態機械を遷移させる

上記の処理を行うことでEndStateの場合のみ、ストリーム上にデータを送出するようになっています。

これは一種のフロー制御ですが、このようなフロー制御が簡単に記述できることが確認できました。

使い方

StreamingParserRevisedの使用方法は以下になります。

package sample

import scalaz.concurrent.Task
import scalaz.stream._
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

object StreamingParserRevisedSample {
def main(args: Array[String]) {
val stream = EventProcessor.eventStream
build(stream)
execute()
}

def build(stream: Process[Task, Char]) {
Future {
val parser = StreamingParserRevised.createParser(stream).
map(row => report(row)).run.run
}
}

def report(row: Seq[String]) {
println(s"report: $row")
}

def execute() {
val queue = EventProcessor.q
val a = "abc,def,ghi\n"
val b = "jkl,mno,pqr\n"
a.foreach(queue.enqueueOne(_).run)
Thread.sleep(2000)
b.foreach(queue.enqueueOne(_).run)
Thread.sleep(2000)
}
}

StreamingParserの場合はストリーミングに処理途中、処理結果を問わずParseStateが流れてくるので、このParseStateのハンドリングを行っていました。

StreamingParserRevisedでは、処理結果の文字列のみが流れてくるので、処理結果に対する処理のみを記述する形になっています。

実行

実行結果は以下になります。

report: List(abc, def, ghi)
report: List(jkl, mno, pqr)

まとめ

ストリーミング処理をProcessモナドで記述するメリットは、Processモナド用に作成した部品やアプリケーション・ロジックをそのままストリーミング処理に適用することができる点です。

今回の例でも簡単な改修で適用することができました。

ただしProcessモナド用に作成した部品もストリーミング処理で使用すると効率的でない部分も出てくるので、必要に応じて最適化を行っていくことになります。

ストリーミング処理での最適化ではフロー制御が重要な要因となります。

フロー制御を実現するためには、ストリーミング処理内で状態機械を記述できる必要があります。Processモナドでは、この状態機械の記述が可能なので、フロー制御も簡単に実現できることが確認できました。

諸元

  • Scala 2.11.4
  • Scalaz 7.1.0
  • Scalaz-stream 0.6a
  • Scalatest 2.2.4

Finagle+Karaf+Dockerでmicroservices

$
0
0

流行りなのでmicroservicesという用語を使ってみましたが、意図としては(microservicesも含む)マルチサーバー構成によるクラウド・システムを構成するサービス群がターゲットです。

この実行基盤としてFinagle、Karaf、Dockerの組合せが有力ではないかということで、試しにサンプルプロジェクトを作ってみました。このサンプルプロジェクトは、新しいクラウド・システム内サービスを作る時の雛形を想定しています。

このサンプルプロジェクトを実際に作ってみることで、Karafがどのぐらいの手間で使えるのか、ScalaやFinagleとの相性はどうなのか、といったことを試してみるのが目的です。この手のプロジェクト構築ではライブラリ間の依存関係の解決が難しいのですが、Finagleをリンクしても大丈夫な設定を発見できたので、この問題はクリアできていると思います。

以下ではクラウド・システム内サービスをサービスの粒度によらずmicroserviceと呼ぶことにします。

Apache Karaf

Apache Karafは軽量OSGiコンテナです。

Karafを導入する目的は大きく以下の3つです。

  • microserviceの環境設定共有
  • dependency hell対策
  • CamelによるEIP(Enterprise Integration Patterns)
環境設定共有

第一の目的はクラウド・システムを構成するmicroservice群の環境設定を共通化することです。

microserviceをmainメソッドを作るなどして自前でデーモン化すると各種設定もすべて自前で行わなければならなくなります。

たとえば、SLF4J+fluentedなどによるロギング、JMX(Java Management Extensions)による運用監視、JNDI(Java Naming and Directory Interface)によるディレクトリ管理といった各種設定をサービス毎に設定する必要があります。

これは(1)設定そのものが大変な手間、(2)クラウド・システム内の共通設定情報の共有の手間、(3)設定漏れの危険、(4)ハードコーディングによるカスタマイザビリティの欠如、といった問題があります。

microserviceをコンテナ内で動作させることで、各種設定はコンテナに対する設定として共通化する上記の問題を解消することができます。

この目的で導入するコンテナとして、軽量OSGiコンテナであるKarafがよいのではないか考えています。

dependency hell対策

Java VM上での開発における未解決問題の一つにdependency hellがあります。

この問題はJava 9でModule機能として解決される見込みですが、当面の対策としてはOSGiコンテナを使用するのが現実解となっています。

最近はちょっとしたライブラリをリンクしても、その裏でApache Commons、Spring、JBossといった巨大なライブラリがついてくることが多いので、思ったより切実な問題です。

CamelによるEIP

Karafを採用したボーナスのようなものですが、KarafからはCamelを簡単に使えるようになっています。(KarafはESB(Enterprise Service Bus)のServiceMixのOSGiコンテナ部を切り出して製品化したものです。)

Camelを使うと、EIPのパターンを使用して様々な通信プロトコルを使って外部サービスと連携する処理を簡単に作成することができます。またCamelにはScala DSLも用意されています。

Twitter Finagle

Twitter Finagleはmicroservices指向のRPCシステムです。

FinagleはFutureモナドによる非同期実行、耐故障性の抽象化を行っており、Monadic Programmingとの相性がよいのもFunctional Reactive Programming指向のmicroservicesの通信基盤として魅力です。

準備

Karafを使うためのベースとしてKarafの実行環境をDockerイメージ化しました。

Karaf Docker Image

Dockerイメージを作成するプロジェクトは以下になります。

Dockerfileだけの簡単なプロジェクトです。

実際にmicroserviceをインストールして使うことを想定しているので、余分な設定は行わずプレインな簡単なものにしています。

Docker Hub

このDockerイメージをDocker Hubに登録しました。

以下のようにして利用できます。

$ docker pull asami/karaf-docker

サンプル・プロジェクト

サンプル・プロジェクトは以下のGitHubプロジェクトとして作成しました。

サンプル・サーバー

Finagleによるサンプル・サーバーは以下のものです。

package sample

import com.twitter.finagle.{Http, Service, ListeningServer}
import com.twitter.util.{Await, Future}
import java.net.InetSocketAddress
import org.jboss.netty.handler.codec.http._

object Server {
val service = new Service[HttpRequest, HttpResponse] {
def apply(req: HttpRequest): Future[HttpResponse] =
Future.value(new DefaultHttpResponse(
req.getProtocolVersion, HttpResponseStatus.OK))
}

def start(): ListeningServer = {
Http.serve(":8080", service)
}

def stop(server: ListeningServer) {
server.close()
}

def main(args: Array[String]) {
val server = start()
Await.ready(server)
}
}
ビルド

ビルドは以下の手順で行います。

  • Karaf KAR
  • Dockerfile
  • Dockerイメージ
Karaf KAR

Karafに配備するKARファイルの作成にはkarafタスクを使用します。

$ sbt karaf

karafタスクはbuild.sbtで定義しています。

Dockerfile

KARファイルを配備したKaraf実行環境のDockerイメージを作成するためのDockerfileの作成にはdockerタスクを使用します。

$ sbt docker

dockerタスクはbuild.sbtで定義しています。

Dockerイメージ

Dockerイメージの作成は以下になります。

$ docker build -t sample-finagle-karaf-docker .

dockerタスクを実行するとDockerfileが作成されるので、このDockerfileでDockerイメージをビルドします。この時、前述したDockerイメージasami/karaf-dockerを内部的に使用します。

実行

作成したDockerイメージsample-finagle-karaf-dockerを実行してみましょう。

$ docker run -t -i --rm -p 1099:1099 -p 8101:8101 -p 44444:44444 -p 8080:8080 sample-finagle-karaf-docker

ポートは1099, 8101, 44444, 8080の4ポートを使用します。

ポート1099, 8101, 44444はKarafが使用するポートです。

ポート用途
1099JMX RMI registry
8101SSHコンソール
44444JMX RMI server

ポート8080はサンプルのFinagleサービスが使用するポートです。

確認

Dockeイメージを実行するとKarafコンテナ内で自動的にサンプルのFinagleサービスが起動されます。

curlコマンドでサンプルのFinagleサービスにアクセスすると以下の結果が得られます。無事動作していることが確認できました。

$ curl http://192.168.59.103:8080 -v
* Rebuilt URL to: http://192.168.59.103:8080/
* Hostname was NOT found in DNS cache
* Trying 192.168.59.103...
* Connected to 192.168.59.103 (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> User-Agent: curl/7.37.1
> Host: 192.168.59.103:8080
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Length: 0
<
* Connection #0 to host 192.168.59.103 left intact

感想

現時点だとSBTからKaraf向けのKARファイルの作成のための設定が大変なので、まだまだKarafは気軽には使えないような感じです。

ただAPPREL CLOUD級の規模になってくると設定の大変さよりも運用管理上のメリットの方が大きくなるのでそろそろ導入を考えてもよさそうです。

諸元

  • Scala 2.10.5
  • Finagle 6.25.0
  • Karaf 4.0.0.M2
  • Docker 1.3.2
  • sbt 0.13.7

[docker] Docker ComposeでMySQLを使う

$
0
0

製品の評価や、開発時のテストなどDBをカジュアルに作成して消したいシチュエーションは多々あります。

開発マシンにDBを入れてデータ投入する運用だと、開発マシン環境が汚れてきたり、復数の設定が共存して収集がつかなくなったり、DBやライブラリのバージョンに依存する場合の切り替えに困ったりということになりがちです。

そこで以前だとVMを使ったり、AWSのようなクラウド上にDBを立てたりしていたわけですが、設定はそのものが難しくはないものの、毎回環境を構築する作業が必要になったりと、まだまだ手間のかかる作業でした。

言うまでもなく、このような問題がDockerで一気に解決したのは大変画期的なことです。

今回はDockerを使ってDBをカジュアルに構築する方法について考えてみます。

SoS JobScheduler

今回はジョブ管理製品のSoS JobSchedulerを使ってみました。

JobSchedulerはバックエンドにDBを使っているので、このDBをDocker上でどのようにして構築して接続するのかという点がポイントです。

またJobSchedulerはapt-getやrpmといったインストーラに対応しておらずインストールが手作業になります。このような製品の配布方式はけっこうありますが、この部分をDockerfile側、起動スクリプト側双方のシェルスクリプトで対応することになります。

MySQL公式Dockerイメージ

MySQLをインストールしたマイDockerイメージを作る方法も有力ですが、MySQLの公式Dockerイメージが機能豊富で結構便利だったので使ってみました。

また、JobSchedulerとMySQLを同じDockerイメージにインストールする方法もありますが、JobScheduler用Dockerイメージとしての汎用性が損なわれるので、避けたほうがよいでしょう。

このため、今回使用するDockerイメージはMySQL公式イメージとJobSchedulerをインストールしたマイイメージの2つになります。このように復数のイメージを接続する場合、dockerコマンドののパラメタを設定する方法もありますが、かなり煩雑です。

この問題に対応するため今回はDocker Composeを使ってみました。

Docker Compose

Docker Compose(旧fig)は復数のDockerイメージを連携動作させるための機能です。

定義ファイルdocker-compose.ymlの設定に従って、復数のDockerコンテナを同時に立ち上げ、Dockerのlinking systemを用いて各Dockerコンテナをリンクで接続する処理を行います。

設定

それでは、Docker Composeを用いた設定を行います。設定結果の全体はGitHubにありますので必要に応じて参照して下さい。

docker-compose.yml

docker-compose.ymlの設定は以下になります。

jobscheduler:
build: .
links:
- db
ports:
- "4444:4444"
db:
image: mysql
ports:
- ":3306"
volumes:
- conf.d/etc.mysql.conf.d:/etc/mysql/conf.d
environment:
MYSQL_USER: jobscheduler
MYSQL_PASSWORD: jobscheduler
MYSQL_ROOT_PASSWORD: jobscheduler
MYSQL_DATABASE: jobscheduler

jobschedulerとdbの2つのDockerコンテナの定義をしています。

jobschedulerコンテナは自前のDockerfileを使ったコンテナです。linksでdbコンテナをリンクする設定を行っています。

dbコンテナはMySQL公式イメージをそのまま使っています。ポイントとなるのはenvironmentで指定している4つの環境変数です。これらの環境変数を設定することで、自動的に必要な初期設定をしてくれるようになっています。

多くのケースで、MySQL公式イメージが提供している環境変数で目的が足りると思われるので、MySQL公式イメージはかなり使い出がありそうです。

Dockerfile

JobSchedulerを実行するDockerfileの設定は以下になります。

FROM dockerfile/java:oracle-java8
MAINTAINER asami

ENV JOBSCHEDULER_VERSION 1.9.0

RUN mkdir -p /opt/jobscheduler && cd /opt/jobscheduler; curl -L http://freefr.dl.sourceforge.net/project/jobscheduler/jobscheduler_linux-x64.$JOBSCHEDULER_VERSION.tar.gz -o - | tar -xz --strip-components=1

# SSH, API/HTTP, API/HTTPS, JOC
EXPOSE 22 44440 8443 4444

USER root

COPY scheduler_install.xml /opt/jobscheduler/scheduler_install.xml

# Set the default command to run when starting the container
COPY startup-jobscheduler.sh /opt/startup-jobscheduler.sh
CMD ["/opt/startup-jobscheduler.sh"]

JobSchedulerがJava 8依存なので、基盤イメージとしてjava8版のJava公式イメージを指定しています。

wgetはインストールが必要なので用いずcurlを使っています。curlとtarを連動させている以下の行は:

RUN mkdir -p /opt/jobscheduler && cd /opt/jobscheduler; curl -L http://freefr.dl.sourceforge.net/project/jobscheduler/jobscheduler_linux-x64.$JOBSCHEDULER_VERSION.tar.gz -o - | tar -xz --strip-components=1
  • 中間ファイルを残さない。
  • 配布アーカイブにあるディレクトリ"jobscheduler.1.9.0"を"jobscheduler"に付け替え。こうすることで、後続の処理でバージョン番号を意識する処理を減らすことができます。

という意図です。このようなケースのイディオム的なスクリプトです。

startup-jobscheduler.sh

dockerの定義で比較的難しいのは起動スクリプトのところです。

基本的には、ターゲットのプログラムを起動するだけなのですが、環境との整合性を取るための処理を色々と書く必要があります。

今回の起動スクリプトはstartup-jobscheduler.shで、DockerfileのCMDで指定しています。

#! /bin/bash

sleep 10s

sed -i -e "s/{{DB_PORT_3306_TCP_ADDR}}/$DB_PORT_3306_TCP_ADDR/g" /opt/jobscheduler/scheduler_install.xml
sed -i -e "s/{{DB_PORT_3306_TCP_PORT}}/$DB_PORT_3306_TCP_PORT/g" /opt/jobscheduler/scheduler_install.xml

(cd /opt/jobscheduler;/usr/bin/java -jar jobscheduler_linux-x64.$JOBSCHEDULER_VERSION.jar scheduler_install.xml)

sleep infinity

DBの起動を待ち合わせるためsleepコマンドで10秒ウエイト入れています。

scheduler_install.xmlの設定を、Docker実行時の環境に適合するようにsedコマンドで書き換えています。環境変数DB_PORT_3306_TCP_ADDRとDB_PORT_3306_TCP_PORTはDockerのlinking systemが設定してくる環境情報です。これらの情報の取り込みを起動スクリプトで対応する必要があります。

JobSchedulerは起動後、自動的にバックグラウンドになってしまうため、そのままstartup-jobscheduler.shが終わるとそのままDockerも終わってしまいます。そこでsleepコマンドで永久にウエイトするようにしています。

実行

Docker Composeを使ってJobSchedulerを立ち上げてみましょう。

設定の取得

GitHubから設定一式を取得します。

$ git clone https://github.com/asami/SoS-JobScheduler-docker.git
ビルド

docker-composeコマンドのbuildを実行します。

$ docker-compose build
実行

docker-composeコマンドのupを実行します。

$ docker-compose up

起動は以上で終了です。

以下のアドレスにアクセスするとJobSchedulerの管理画面が表示されます。(Macの場合)

まとめ

Docker Composeを使って、自前のJobSchedulerコンテナとMySQL公式イメージを連動させ、JobSchedulerの実行環境を作ってみました。

MySQL公式イメージの機能が結構豊富なので、DB側は設定だけで使用することができました。

Dockerも便利ですが、Docker Composeもかなり便利で、アイデア次第で色々と応用がありそうです。

諸元

Mac OS 10.7.5 docker 1.6 docker-compose 1.2.0

[docker] Docker Composeでデータ投入

$
0
0

Docker ComposeでMySQLを使う」ではDocker Composeを使ってJobSchedulerからMySQLをそれぞれ別のDockerコンテナ上で動作させ連携して使用しました。

テストなどでMySQL公式Dockerコンテナを使う際の問題点として、データ投入があります。事前に用意したデータをMySQLに投入後に、テスト対象のアプリケーションが起動されると理想的なのですが、MySQL公式Dockerイメージではデータ投入する機能は提供されていません。

また、MySQL公式DockerイメージではMySQLの起動完了を待ち合わせる機能を持っていないことも問題です。

これらの問題に対応するためMySQL公式Dockerイメージを元に、マイDockerイメージであるmysql-java-embulk-dockerを作ってみました。

mysql-java-embulk-docker

mysql-java-embulk-dockerはdocker-composeを使ってアプリケーションDockerイメージのテストを行う際に、事前にデータ投入したMySQLデータベースを提供するためのDockerイメージです。

GitHubにソースコードがありますので、詳細はこちらを参照して下さい。

以下では、実装上のポイントと使い方について説明します。

Dockerfile

mysql-java-embulk-dockerのDockerfileは以下になります。

FROM mysql:5.6
MAINTAINER asami

RUN apt-get update && apt-get -y install wget curl

# Install JDK 1.7
RUN cd /opt; wget --no-cookies --no-check-certificate --header "Cookie: oraclelicense=accept-securebackup-cookie" "http://download.oracle.com/otn-pub/java/jdk/7u51-b13/jdk-7u51-linux-x64.tar.gz" -O /opt/jdk-7-linux-x64.tar.gz

# Install in /usr/java/jdk1.7.0_51
RUN mkdir /usr/java && (cd /usr/java; tar xzf /opt/jdk-7-linux-x64.tar.gz)
RUN rm /opt/jdk-7-linux-x64.tar.gz
RUN update-alternatives --install /usr/bin/java java /usr/java/jdk1.7.0_51/jre/bin/java 20000; update-alternatives --install /usr/bin/jar jar /usr/java/jdk1.7.0_51/bin/jar 20000; update-alternatives --install /usr/bin/javac javac /usr/java/jdk1.7.0_51/bin/javac 20000; update-alternatives --install /usr/bin/javaws javaws /usr/java/jdk1.7.0_51/jre/bin/javaws 20000; update-alternatives --set java /usr/java/jdk1.7.0_51/jre/bin/java; update-alternatives --set javaws /usr/java/jdk1.7.0_51/jre/bin/javaws; update-alternatives --set javac /usr/java/jdk1.7.0_51/bin/javac; update-alternatives --set jar /usr/java/jdk1.7.0_51/bin/jar;

RUN curl --create-dirs -o /opt/embulk -L "http://dl.embulk.org/embulk-latest.jar" && chmod +x /opt/embulk

RUN /opt/embulk gem install embulk-output-mysql

RUN apt-get -y install redis-server

COPY charset.cnf /etc/mysql/conf.d/charset.cnf
COPY entrypoint.sh /opt/entrypoint.sh
RUN chmod +x /opt/entrypoint.sh

VOLUME ["/var/lib/mysql", "/etc/mysql/conf.d", "/opt/setup.d"]

ENTRYPOINT ["/opt/entrypoint.sh"]

CMD ["mysqld"]
Embulk

Embulkはビッグデータスケールのデータローダです。

データ投入にEmbulkを利用できると大変便利なので組み込んでみました。

EmbulkはJava VM上で動作するので、Embulk用にJDKをインストールしています。

また、MySQLにデータ投入するので、Embulkにembulk-output-mysqlプラグインを追加しています。

Redis

Docker Compose上でmysql-java-embulk-dockerコンテナの起動時にデータ投入をする際に問題点として、mysql-java-embulk-dockerコンテナの起動とアプリケーションコンテナの起動の同期が行われないというものがあります。

mysql-java-embulk-dockerコンテナの起動とアプリケーションコンテナの起動が同時に行われてしまうために、mysql-java-embulk-dockerコンテナの起動時に行われるデータ投入が完了する前に、アプリケーションコンテナが動き出してしまい、想定したデータがない状態なので誤動作する、という問題です。

現段階ではDocker Composeにはこの問題を解決するための機能は提供されていないようなので、Redisを使って対応することにしました。

この目的でRedisをインストールしています。

本来はRedisのクライアントのみがインストールできればよいのですが、簡単にできるよい方法がみつからなかったのでRedisをまるごとインストールしています。

charset.cnf

MySQLで日本語を使うための設定として、サーバーのコード系をUTF-8に設定します。

この目的で以下のchaset.cnfを用意して、Dockerコンテナの/etc/mysql/conf.d/charset.cnfにCOPYします。

[mysqld]
character-set-server = utf8
entrypoint.sh

mysql-java-embulk-dockerのentrypoint.shは以下になります。

#!/bin/bash

# WAIT_DB_TIMER
# WAIT_CONTAINER_KEY

# set -x

set -e

echo Wait contaner key: ${WAIT_CONTAINER_KEY:=mysql-java-embulk-docker}
echo Redis host: ${REDIS_SERVER_HOST:=$REDIS_PORT_6379_TCP_ADDR}
echo Redis port: ${REDIS_SERVER_PORT:=$REDIS_PORT_6379_TCP_PORT}

function check_db {
if [ "$MYSQL_ROOT_PASSWORD" ]; then
mysql -u root -p"$MYSQL_ROOT_PASSWORD" -e "status"
elif [ "$MYSQL_ALLOW_EMPTY_PASSWORD" ]; then
mysql -e "status"
else
exit 1
fi
}

function wait_db {
result=1
for i in $(seq 1 ${WAIT_DB_TIMER:-10})
do
sleep 1s
result=0
check_db && break
result=1
done
if [ $result = 1 ]; then
exit 1
fi
}

if [ "${1:0:1}" = '-' ]; then
set -- mysqld "$@"
fi

is_install=false

if [ "$1" = 'mysqld' ]; then
# read DATADIR from the MySQL config
DATADIR="$("$@" --verbose --help 2>/dev/null | awk '$1 == "datadir" { print $2; exit }')"

if [ ! -d "$DATADIR/mysql" ]; then
if [ -z "$MYSQL_ROOT_PASSWORD" -a -z "$MYSQL_ALLOW_EMPTY_PASSWORD" ]; then
echo >&2 'error: database is uninitialized and MYSQL_ROOT_PASSWORD not set'
echo >&2 ' Did you forget to add -e MYSQL_ROOT_PASSWORD=... ?'
exit 1
fi

is_install=true

echo 'Running mysql_install_db ...'
mysql_install_db --datadir="$DATADIR"
echo 'Finished mysql_install_db'

# These statements _must_ be on individual lines, and _must_ end with
# semicolons (no line breaks or comments are permitted).
# TODO proper SQL escaping on ALL the things D:

tempSqlFile='/tmp/mysql-first-time.sql'
cat > "$tempSqlFile" <<-EOSQL
DELETE FROM mysql.user ;
CREATE USER 'root'@'%' IDENTIFIED BY '${MYSQL_ROOT_PASSWORD}' ;
GRANT ALL ON *.* TO 'root'@'%' WITH GRANT OPTION ;
DROP DATABASE IF EXISTS test ;
EOSQL

if [ "$MYSQL_DATABASE" ]; then
echo "CREATE DATABASE IF NOT EXISTS \`$MYSQL_DATABASE\` ;" >> "$tempSqlFile"
fi

if [ "$MYSQL_USER" -a "$MYSQL_PASSWORD" ]; then
echo "CREATE USER '$MYSQL_USER'@'%' IDENTIFIED BY '$MYSQL_PASSWORD' ;" >> "$tempSqlFile"

if [ "$MYSQL_DATABASE" ]; then
echo "GRANT ALL ON \`$MYSQL_DATABASE\`.* TO '$MYSQL_USER'@'%' ;" >> "$tempSqlFile"
fi
fi

echo 'FLUSH PRIVILEGES ;' >> "$tempSqlFile"

# http://qiita.com/toritori0318/items/242274d4f5794e2f68e5
# setup
echo "use $MYSQL_DATABASE;" >> "$tempSqlFile"
if [ -e "/opt/setup.d/setup.sql"]; then
cat /opt/setup.d/setup.sql >> "$tempSqlFile"
fi
# start mysql
set -- "$@" --init-file="$tempSqlFile"
fi

chown -R mysql:mysql "$DATADIR"
fi

exec "$@" &

wait_db

if [ -e "/opt/setup.d/setup.yml" ]; then
if [ $is_install=true ]; then
echo "embulk run setup.yml"
cd /opt/setup.d && /opt/embulk run setup.yml
fi
fi

if [ -n "$REDIS_SERVER_HOST" ]; then
redis-cli -h $REDIS_SERVER_HOST -p $REDIS_SERVER_PORT SET $WAIT_CONTAINER_KEY up
fi

sleep infinity

MySQL公式をベースに、データ投入用SQLおよびEmbulkでデータ投入するように拡張したものです。

setup.sql

/opt/setup.d/setup.sqlとしてデータ投入用SQLが存在する場合は、MySQLの初期起動スクリプトにこの内容を追加することで、起動時にデータ投入されるようになっています。

/opt/setup.dはDockerfileでVolumeなっていて、外部からディレクトリをマウントして使用することを想定しています。

データ投入用SQLは「Docker公式のmysqlイメージを使いつつ初期データも投入する」の記事を参考にしました。

setup.yml

/opt/setup.d/setup.ymlとしてデータ投入用Embulk記述ファイルが存在する場合は、Embulkを使ってデータ投入するようになっています。

ただし、MySQLの起動が完了した後でないとデータ投入ができないのでmysqlコマンドを使って待ち合わせ処理を行っています。

Redisによる同期

mysql-java-embulk-dockerコンテナの起動終了の待ち合わせのためRedisを使用します。

具体的には以下のように、外部コンテナでRedisが起動されている場合に、redis-cliコマンドを使って環境変数WAIT_CONTAINER_KEYで指定されたスロットに「up」という文字列を設定しています。

if [ -n "$REDIS_SERVER_HOST" ]; then
redis-cli -h $REDIS_SERVER_HOST -p $REDIS_SERVER_PORT SET $WAIT_CONTAINER_KEY up
fi

アプリケーション側は、Redisのこのスロットがupになるまでポーリングで待ち合わせることで同期を取ることになります。

終了抑止

最後にDockerでサービスを記述する時のお約束としてsleepコマンドで終了抑止を行っています。

Docker Hub

Docker HubはGitHubやBitBucketと連動した自動ビルド機能を提供しています。

mysql-java-embulk-dockerもこの設定を行っているので、以下の場所にDockerイメージが自動ビルドされます。

このイメージは「asami/mysql-java-embulk-docker」という名前で利用することができます。

サンプル

Dockerイメージ「asami/mysql-java-embulk-docker」を利用してテストデータの投入を行うサンプルを作ってみます。

サンプルのコードはGitHubのmysql-java-embulk-dockerのsampleディレクトリにあるので、詳細はこちらを参照して下さい。

docker-compose.yml

サンプルプログラムのdocker-compose.ymlは以下になります。

app:
build: .
links:
- mysql
- redis
environment:
WAIT_CONTAINER_KEY: mysql-java-embulk-docker
MYSQL_SERVER_USER: baseball
MYSQL_SERVER_PASSWORD: baseball
mysql:
image: asami/mysql-java-embulk-docker
links:
- redis
ports:
- ":3306"
volumes:
- setup.d:/opt/setup.d
environment:
MYSQL_USER: baseball
MYSQL_PASSWORD: baseball
MYSQL_ROOT_PASSWORD: baseball
MYSQL_DATABASE: baseball
redis:
image: redis
ports:
- ":6379"

setup.dをコンテナの/opt/setup.dにマウントしています。

setup.dには後述のsetup.ymlとデータファイルBatting.csvが格納されています。

Batting.csvは以下のサイトからデータを取得しました。

setup.yml

setup.ymlはembulkで移入するデータの情報を記述したものです。

in:
type: file
path_prefix: Batting.csv
parser:
charset: UTF-8
newline: CRLF
type: csv
delimiter: ','
quote: '"'
escape: ''
skip_header_lines: 1
columns:
- {name: playerID, type: string}
- {name: yearID, type: long}
- {name: stint, type: long}
- {name: teamID, type: string}
- {name: lgID, type: string}
- {name: G, type: long}
- {name: AB, type: long}
- {name: R, type: long}
- {name: H, type: long}
- {name: 2B, type: long}
- {name: 3B, type: long}
- {name: HR, type: long}
- {name: RBI, type: long}
- {name: SB, type: long}
- {name: CS, type: long}
- {name: BB, type: long}
- {name: SO, type: long}
- {name: IBB, type: long}
- {name: HBP, type: long}
- {name: SH, type: long}
- {name: SF, type: long}
- {name: GIDP, type: long}
exec: {}
out:
type: mysql
host: localhost
database: baseball
user: baseball
password: baseball
table: batting
mode: insert

CSVファイルから入力したデータをMySQLに投入する際の標準的な指定と思います。

Dockerfile

サンプルアプリケーションのDockerfileは以下になります。

FROM mysql
MAINTAINER asami

ENV MYSQL_ALLOW_EMPTY_PASSWORD true

RUN apt-get update && apt-get -y install redis-server

COPY app.sh /opt/app.sh
RUN chmod +x /opt/app.sh
ADD https://raw.githubusercontent.com/asami/mysql-java-embulk-docker/master/lib/mysql-java-embulk-docker-lib.sh /opt/mysql-java-embulk-docker-lib.sh

ENTRYPOINT /opt/app.sh

アプリケーションでmysqlコマンドを使うので、MySQL公式Dockerイメージをベースにしました。

mysql-java-embulk-dockerコンテナとの同期にRedisを使うのでRedisをインストールしています。

また、アプリケーション起動シェルの共通ライブラリmysql-java-embulk-docker-lib.shをGitHubからコンテナ内にコピーしています。

アプリケーション起動の動きは大きく以下の3つの部分に分かれます。

  • パラメタの取り込み
  • データ投入の待ち合わせ
  • アプリケーションロジック

この中の「パラメタの取り込み」と「データ投入の待ち合わせ」をmysql-java-embulk-docker-lib.shが行います。

app.sh

サンプルアプリケーションの実行スクリプトapp.shは以下になります。

#! /bin/bash

# set -x

set -e

DIR="${BASH_SOURCE%/*}"
if [[ ! -d "$DIR" ]]; then DIR="$PWD"; fi
source $DIR/mysql-java-embulk-docker-lib.sh

mysql -u $MYSQL_SERVER_USER -p$MYSQL_SERVER_PASSWORD --host=$MYSQL_SERVER_HOST --port=$MYSQL_SERVER_PORT -e "select count(*) from baseball.batting"

まず、共通ライブラリmysql-java-embulk-docker-lib.shをsourceで取り込んでいます。

この中でRedisを使った同期が行われ、Embulkによるデータ投入が完了した状態でアプリケーションロジックに入ってきます。

今回のアプリケーションロジックは非常に単純で以下の処理を行います。

  • baseball.battingテーブルの総レコード数を取得する

この問合せ処理をmysqlコマンドを使って行っています。

実行

docker-composeのbuildコマンドでビルドします。

$ docker-compose build

docker-composeのupコマンドでビルドします。

$ docker-compose up

動作過程がコンソールに出力されますが、最後の方で以下のような出力があります。

app_1   | count(*)
app_1 | 99846

無事、Batting.csvをMySQLのbaseball.battingテーブルに投入した後、baseball.battingテーブルの総レコード数を取得することができました。 

まとめ

SQLとEmbulkを使ってデータ投入できるMySQL用のDockerコンテナを作ってみました。

アプリケーション開発では、テスト用データベースの準備とデータ投入が大きな手間であり、テスト自動化の障壁にもなっていたので、今回開発したDockerイメージをアプリケーション開発に適用していきたいと思います。

それにしても、Dockerのイメージ開発はシェルスクリプトプログラミングということを実感しました。相当錆び付いていましたが、なんとか動くものができました。

諸元

  • Mac OS 10.7.5
  • docker 1.6
  • docker-compose 1.2.0

[docker] DockerでSpark SQL

$
0
0

Spark SQL 1.3から以下の2つの機能が導入されています。

  • DataSourceとしてJDBCが使えるようになった
  • DataFrame

この2つの機能追加によってSpark SQLを汎用のバッチ処理基盤にできるのではないかというインスピレーションが湧きました。

この実現目的でSparkバッチをスタンドアロンで実行するためのDockerイメージspark-sql-scala-dockerを作ってみた、というのが今回のお話です。

Spark SQL

Spark SQLは、Sparkの分散計算処理をSQLで記述できるようにしたものです。SQLとSpark本来のmonadicなAPI(e.g. filter, map, flatMap)を併用して計算処理を記述することができます。

このプログラミングモデルは非常に強力で、大枠の絞り込みはSQLで行っておいて、アプリケーションに特化した検索ロジックをScalaで記述したUDF(User Defined Function)で補完するといった処理を、プログラミング言語的に簡潔に記述することができます。

Spark SQLの基本機能に加えて1.3から以下の機能も使えるようになりました。

DataSourceのJDBC対応

DataSourceとしてJDBCが使えるようになったことで、RedShift上にためた分析データなどから直接データを取得できるようになりました。MySQLやPostreSQLなどのデータを一旦S3に変換するといった準備タスクが不要になったので、ジョブ作成の手間が大きく低減すると思います。

小さな機能追加ですが、実運用上のインパクトは大きいのではないかと思います。

DataFrame

大きな機能追加としてはDataFrameが導入されました。

DataFrameは表形式の大規模データを抽象化したAPIで、元々はR/Pythonで実績のある機能のようです。

DataFrameは分析専用のAPIではなく、表形式データ操作の汎用APIとして使用できるのではないかと期待しています。計算結果を外部出力する際の汎用機能としても期待できます。

もちろんR/Pythonなどのデータ分析処理系との連携も期待できそうです。

Spark SQLの用途

Spark SQLの基本機能と上記の2つの機能追加によって、Sparkバッチを大規模(データ量/計算量)向けデータ処理基盤としてだけではなく、汎用のバッチ実行基盤として使えるようになるのではないかとインスピレーションが湧いたわけです。

データ集計用のバッチをSparkバッチとして作成して、データ量、計算量に応じてスタンドアロンジョブとSparkクラスタ上でのジョブのいずれかでジョブ実行するというユースケースです。

そのベースとして、Sparkクラスタを用いないスタンドアロンジョブとして実行するためのDockerイメージを作ってみました。

spark-sql-scala-docker

spark-sql-scala-dockerはSparkアプリケーションをスタンドアロンで実行するためのDockerイメージです。

GitHubにソースコードがありますので、詳細はこちらを参照して下さい。

以下では、実装上のポイントと使い方について説明します。

Dockerfile

spark-sql-scala-dockerのDockerfileは以下になります。

FROM sequenceiq/spark:1.3.0

RUN mkdir -p /opt/spark/lib
RUN cd /opt/spark/lib && curl -L 'http://dev.mysql.com/get/Downloads/Connector-J/mysql-connector-java-5.1.30.tar.gz' -o - | tar -xz --strip-components=1 mysql-connector-java-5.1.30/mysql-connector-java-5.1.30-bin.jar
RUN curl -L 'http://jdbc.postgresql.org/download/postgresql-9.2-1002.jdbc4.jar' -o /opt/spark/lib/postgresql-9.2-1002.jdbc4.jar

ENV SPARK_CLASSPATH /opt/spark/lib/mysql-connector-java-5.1.30-bin.jar:/opt/spark/lib/postgresql-9.2-1002.jdbc4.jar

RUN rpm -ivh http://ftp-srv2.kddilabs.jp/Linux/distributions/fedora/epel/6/x86_64/epel-release-6-8.noarch.rpm

RUN yum -y install redis --enablerepo=epel

COPY spark-defaults.conf /opt/spark-defaults.conf

COPY entrypoint.sh /opt/entrypoint.sh

ENV COMMAND_JAR_DIR /opt/command.d

ENV COMMAND_JAR_NAME command.jar

VOLUME [$COMMAND_JAR_DIR"]

ENTRYPOINT ["/opt/entrypoint.sh"]

Dockerイメージsequenceiq/spark:1.3.0をベースにしていて以下の調整だけ行っています。

  • MySQLとPostgreSQLのJDBCドライバのインストール
  • Sparkアプリケーションの登録処理
entrypoint.sh

spark-sql-scala-dockerのentrypoint.shは以下になります。

#! /bin/bash

# WAIT_CONTAINER_TIMER
# WAIT_CONTAINER_FILE
# WAIT_CONTAINER_KEY

# set -x

set -e

echo MySQL host: ${MYSQL_SERVER_HOST:=$MYSQL_PORT_3306_TCP_ADDR}
echo MySQL port: ${MYSQL_SERVER_PORT:=$MYSQL_PORT_3306_TCP_PORT}
echo PostgreSQL host: ${POSTGRESQL_SERVER_HOST:=$POSTGRESQL_PORT_5432_TCP_ADDR}
echo PostgreSQL port: ${POSTGRESQL_SERVER_PORT:=$POSTGRESQL_PORT_5432_TCP_PORT}
echo Redis host: ${REDIS_SERVER_HOST:=$REDIS_PORT_6379_TCP_ADDR}
echo Redis port: ${REDIS_SERVER_PORT:=$REDIS_PORT_6379_TCP_PORT}
export MYSQL_SERVER_HOST
export MYSQL_SERVER_PORT
export POSTGRESQL_SERVER_HOST
export POSTGRESQL_SERVER_PORT
export REDIS_SERVER_HOST
export REDIS_SERVER_PORT

function wait_container {
if [ -n "$REDIS_SERVER_HOST" ]; then
wait_container_redis
elif [ -n "$WAIT_CONTAINER_FILE" ]; then
wait_container_file
fi
}

function wait_container_redis {
result=1
for i in $(seq 1 ${WAIT_CONTAINER_TIMER:-100})
do
sleep 1s
result=0
if [ $(redis-cli -h $REDIS_SERVER_HOST -p $REDIS_SERVER_PORT GET $WAIT_CONTAINER_KEY)'' = "up" ]; then
break
fi
echo spark-sql-scala-docker wait: $REDIS_SERVER_HOST
result=1
done
if [ $result = 1 ]; then
exit 1
fi
}

function wait_container_file {
result=1
for i in $(seq 1 ${WAIT_CONTAINER_TIMER:-100})
do
sleep 1s
result=0
if [ -e $WAIT_CONTAINER_FILE ]; then
break
fi
echo spark-sql-scala-docker wait: $WAIT_CONTAINER_FILE
result=1
done
if [ $result = 1 ]; then
exit 1
fi
}

COMMAND_JAR=$COMMAND_JAR_DIR/$COMMAND_JAR_NAME

wait_container

sed -i "s!hdfs://.*:9000!file:\/\/\/tmp!g" /usr/local/hadoop/etc/hadoop/core-site.xml

spark-submit --properties-file /opt/spark-defaults.conf $COMMAND_JAR

基本的にはspark-submitでSparkアプリケーションのジョブをサブミットしているだけですが、以下の2つの調整を行っています。

  • Redisを使って他のコンテナの待ち合わせ
  • 中間データのローディング先をHDFSではなくローカルファイルに変更する
コンテナの待ち合わせ

Sparkアプリケーションを動作させる前の準備を他のコンテナで進める場合は、コンテナの待ち合わせが必要になります。この待ち合わせをmysql-java-embulk-dockerと同様にRedisを用いて実現しています。

典型的な使用例は、Sparkアプリケーションのテスト実行時でのテストDBの準備です。この実例は後ほどサンプルで説明します。

中間データのローディング先

core-site.xmlの変更処理です。

sed -i "s!hdfs://.*:9000!file:\/\/\/tmp!g" /usr/local/hadoop/etc/hadoop/core-site.xml

ここの設定を変更しないとDocker環境内でスタンドアロンでは動かなかったので設定変更しています。

設定変更の方法としてはHDFSを動くようにするという方式もあるのですが、スタンドアプリケーションなのでここではローカルのファイルを使う方式で対応しています。

Docker Hub

mysql-java-embulk-dockerと同様にspark-sql-scala-dockerもDocker Hubの自動ビルドの設定を行っているので、以下の場所にDockerイメージが自動ビルドされます。

このイメージは「asami/spark-sql-scala-docker」という名前で利用することができます。

サンプル

Dockerイメージ「asami/spark-sql-scala-docker」を利用してテストデータの投入を行うサンプルを作ってみます。

手元の環境上でテスト目的で動作させるためmysql-java-embulk-dockerを併用してテストデータの投入を行っています。

サンプルのコードはGitHubのspark-sql-scala-dockerのsampleディレクトリにあるので、詳細はこちらを参照して下さい。

SimpleApp.scala

サンプルのSparkバッチであるSimpleAppのプログラムは以下になります。

import org.apache.spark.SparkContext
import org.apache.spark.SparkContext._
import org.apache.spark.SparkConf
import org.apache.spark.sql.{SQLContext, DataFrame}

object SimpleApp extends App {
val batting = SparkSqlUtils.createMysqlDataFrame("Simple Application", "batting")
val count = batting.count()
println(s"count = ${batting.count()}")
}

object SparkSqlUtils {
def createSqlContext(name: String): SQLContext = {
val conf = new SparkConf().setAppName(name)
val sc = new SparkContext(conf)
new SQLContext(sc)
}

def createMysqlDataFrame(name: String, table: String): DataFrame = {
val sqlc = createSqlContext(name)
createMysqlDataFrame(sqlc, table)
}

def createMysqlDataFrame(sqlc: SQLContext, table: String): DataFrame = {
val host = System.getenv("MYSQL_SERVER_HOST")
val port = System.getenv("MYSQL_SERVER_PORT")
val user = System.getenv("MYSQL_SERVER_USER")
val password = System.getenv("MYSQL_SERVER_PASSWORD")
sqlc.load("jdbc", Map(
"url" -> s"jdbc:mysql://$host:$port/baseball?user=$user&password=$password",
"dbtable" -> table
))
}
}

SparkSqlUtilsにDataFrame取得処理をまとめています。ここは汎用ライブラリ化できるところです。

この処理を除いた以下の処理がSparkバッチの本体です。

val batting = SparkSqlUtils.createMysqlDataFrame("Simple Application", "batting")
val count = batting.count()
println(s"count = ${batting.count()}")
アプリケーションロジック

指定したテーブル"batting"に対応したDataFrameを取得し、countメソッドでレコード総数を取得し、その結果をコンソールに出力しています。とても簡単ですね。

この部分を以下の機能を用いて記述することで高度なバッチ処理を簡単に記述できます。

  • DataFrameによる表データ操作
  • DataFrameから変換したRDDを用いてSpark計算処理

前述したように「大枠の絞り込みはSQLで行っておいて、アプリケーションに特化した検索ロジックをScalaで記述したUDF(User Defined Function)で補完するといった処理を、プログラミング言語的に簡潔に記述することができます。」

移入・移出

テーブル"batting"をDataFrameとしてローディングしているのは、前述のSpark 1.3の機能追加「DataSourceとしてJDBCが使えるようになった」によるものです。

また、ここでは外部出力をコンソール出力にしていますが、RDDのsaveAsTextFileメソッドやDataFrameを用いることで、S3やデータベースなどに集計結果を簡単に出力することができます。

データベースなどへの外部出力が簡単に行えるのもSpark 1.3の機能追加「DataFrame」の効果です。

ここからも分かるように、Spark SQL 1.3で導入された「DataSourceとしてJDBCが使えるようになった」と「DataFrame」により、Sparkバッチ処理の難題であったデータの移入/移出処理が極めて簡単に記述できるようになったわけです。

SBTの設定

SBTによるScalaプログラムのビルドの設定は以下になります。

Spark本体とSpark SQLを依存ライブラリとして設定している、ごくオーソドックスな設定です。

Sparkバッチ用にすべての依存ライブラリをまとめたJARファイルを作る必要があるので、sbt-assemblyの設定を行ってます。

ポイントとしては、Sparkバッチの実行環境にScalaの基本ライブラリとSpark本体/Spark SQLのライブラリが用意されているので、sbt-assemblyでまとめるJARファイルから排除する設定を行っています。

  • Spark本体とSpark SQLの依存ライブラリの設定を"provided"にしてリンク対象から外す
  • sbt-assemblyの設定で"includeScala = false"としてScala基本ライブラリをリンク対象から外す

これらの設定はなくても動作しますが、JARファイルが巨大になってしまいます。

name := "simple"

version := "1.0"

scalaVersion := "2.10.4"

libraryDependencies += "org.apache.spark" %% "spark-core" % "1.3.1" % "provided"

libraryDependencies += "org.apache.spark" %% "spark-sql" % "1.3.1" % "provided"

assemblyOption in assembly := (assemblyOption in assembly).value.copy(includeScala = false)

sbt-assemblyプラグインが必要なのでproject/assembly.sbtに以下の設定をしておきます。

addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.13.0")
docker-compose.yml

サンプルプログラムのdocker-compose.ymlは以下になります。

spark:
image: asami/spark-sql-scala-docker
links:
- mysql
- redis
volumes:
- target/scala-2.10:/opt/command.d
environment:
COMMAND_JAR_NAME: simple-assembly-1.0.jar
WAIT_CONTAINER_KEY: mysql-java-embulk-docker
MYSQL_SERVER_USER: baseball
MYSQL_SERVER_PASSWORD: baseball
mysql:
image: asami/mysql-java-embulk-docker
links:
- redis
ports:
- ":3306"
volumes:
- setup.d:/opt/setup.d
environment:
MYSQL_USER: baseball
MYSQL_PASSWORD: baseball
MYSQL_ROOT_PASSWORD: baseball
MYSQL_DATABASE: baseball
redis:
image: redis
ports:
- ":6379"

自前ではDockerイメージを作らず、以下の3つの汎用Dockerイメージを再利用しています。

  • asami/spark-sql-scala-docker
  • asami/mysql-java-embulk-docker
  • redis
asami/spark-sql-scala-docker

ボリュームと環境変数の記述で、targetscala-2.10simple-assembly-1.0.jarがSparkバッチプログラムとして認識されるようにしています。

simple-assembly-1.0.jarはsbt-assemblyで作成した「全部入り(SparkとScala以外)」のJARファイルです。

それ以外は、mysql-java-embulk-dockerと同期をとるためのおまじないです。

asami/mysql-java-embulk-docker

前回の記事「Docker Composeでデータ投入」と同じ設定です。テスト用のMySQLデータベースにテストデータを投入しています。

Batting.csvは以下のサイトからデータを取得しました。

redis

asami/mysql-java-embulk-dockerによるテストデータ投入の待ち合わせにredisを用いています。

ビルド

Sparkバッチのビルドはsbtで行います。

$ sbt assembly

テスト環境はdocker-composeのbuildコマンドでビルドします。

$ docker-compose build
実行

docker-composeのupコマンドで実行します。

$ docker-compose up

動作過程がコンソールに出力されますが、最後の方で以下のような出力があります。

spark_1 | count = 99846

無事Sparkバッチでデータ集計ができました。

まとめ

Spark SQLを汎用のバッチ処理基盤として運用する目的でSparkバッチをスタンドアロンで実行するためのDockerイメージspark-sql-scala-dockerを作ってみましたが、無事動作しました。

このことによってspark-sql-scala-dockerとmysql-java-embulk-dockerを使って手元で簡単にSparkバッチをテストできるようになりました。

汎用Dockerイメージをdocker-composeで組み合わせるだけなので運用的にも大変、楽だと思います。

今回は試していませんが、spark-sql-scala-dockerを使ってSparkバッチをECS(EC2 Container Service)などのDocker環境上でスタンドアロンバッチとして実行するという運用も可能ではないかと考えています。

もちろん、SparkバッチのJARファイルをspark-submitコマンドによるジョブ投入により直接Sparkクラスタ上で実行することでSpark本来の大規模(データ量/計算量)処理を行うことができます。

いずれの場合も、基本的に開発するのは、Scalaによる通常のSparkバッチプログラムだけです。テストやDocker環境上でのスタンドアロンバッチのいずれも汎用Dockerイメージを活用することで、簡単な設定のみで運用することができそうです。

今回の作業で上記の3つのユースケースを同時に満たせることの目処が立ちました。この成果をベースにSpark SQLを汎用のバッチ処理基盤として利用するためのノウハウの積み上げをしてきたいと思います。

諸元

  • Mac OS 10.7.5
  • docker 1.6
  • docker-compose 1.2.0
  • Spark SQL 1.3

case class 2015

$
0
0

case classはScalaプログラミングにおける最重要機能の一つです。

case classはいろいろな便利機能が言語機能としてビルトインされているのに加えて、OOP的にはValue Object、関数型プログラミングとしては代数的データ構造として利用することができます。

case classはそのままシンプルに使っても便利ですが、case classを作る時に基本対応しておくとよさそうな各種機能があるので、その辺りの最新事情を取り込んだ2015年版case classについて考えてみます。

対応機能

2015年版case classでは以下の機能に対応することにします。

  • Monoid
  • Argonaut
  • ScalaCheck
Monoid

Scala Tips / Monoid - 新規作成」は2012年6月の記事なので、かれこれ3年になりますがMonoidの重要性は変わる所がありません。Monoidを使うためだけにScalazを導入してもお釣りがくるぐらいです。

case classを作る時は常にMonoidを意識しておいて、可能であればMonoid化しておくのがよいでしょう。

MonoidはScalazで実装します。

2012年版の「Scala Tips / Monoid - 新規作成」ではScalaz 6でしたが、今回はScalaz 7でMonoidの定義の仕方も変更されています。

Argonaut

Finagleなどを使ってRESTベースのmicroservicesアーキテクチャを取る場合、case classをJSONで送受信するニーズが大きくなります。

case classをできるだけ簡単にJSON化する方法としてArgonautが有力なので使ってみました。

ScalaCheck

case classがMonoidである場合は、必ず二項演算があるので、この二項演算のテストコードが必要になります。

Scalaプログラミングでは、こういった演算はScalaCheckでプロパティベーステストを行うのがお約束になっています。

プログラム

build.sbt

build.sbtは特に難しい所はありません。必要なライブラリを登録しているだけです。

scalaVersion := "2.11.6"

val scalazVersion = "7.1.0"

libraryDependencies ++= Seq(
"org.scalaz" %% "scalaz-core" % scalazVersion,
"io.argonaut" %% "argonaut" % "6.1-M4",
"org.scalatest" %% "scalatest" % "2.2.4" % "test",
"org.scalacheck" %% "scalacheck" % "1.12.2" % "test",
"junit" % "junit" % "4.12" % "test"
)
Average.scala

Monoid化とArgonaut化したcase class Averageは以下になります。

package sample

import scalaz._, Scalaz._
import argonaut._, Argonaut._

case class Average(count: Int, total: Int) {
import Average.Implicits._

def value: Float = total / count.toFloat

def +(rhs: Average): Average = {
Average(count + rhs.count, total + rhs.total)
}

def marshall: String = this.asJson.nospaces
}

object Average {
import Implicits._

val empty = Average(0, 0)

def unmarshall(s: String): Validation[String, Average] = s.decodeValidation[Average]

object Implicits {
implicit object AverageMonoid extends Monoid[Average] {
def append(lhs: Average, rhs: => Average) = lhs + rhs
def zero = Average.empty
}

implicit def decodeAverageJson: DecodeJson[Average] =
casecodec2(Average.apply, Average.unapply)("count", "total")

implicit def encodeAverageJson: EncodeJson[Average] =
jencode2L((d: Average) => (d.count, d.total))("count", "total")
}
}

case classの定義に難しいところはないと思います。

以下ではMonoidとArgonautの定義について説明します。

Monoid

Monoidは、Scalazの型クラスMonoidの型クラスインスタンスを作成して暗黙オブジェクトとして定義します。

implicit object AverageMonoid extends Monoid[Average] {
def append(lhs: Average, rhs: => Average) = lhs + rhs
def zero = Average.empty
}

型クラスMonoidの型クラスインスタンスのappendメソッドとzeroメソッドを実装します。

appendメソッドはMonoid対象の二項演算を定義します。通常、Monoid対象となるcase classで「+」メソッドを提供しているはずなのでこれを呼び出す形になります。

zeroメソッドは空の値を返すようにします。通常、case classの空値はコンパニオン/オブジェクトの変数emptyに定義するのが普通なので、これをzeroメソッドの値として返す形になります。

Argonaut

まずArgonautの基本定義として、ScalaオブジェクトとJSON間の相互変換をする暗黙関数を2つ用意します。

ここも自動化できればよいのですが、マクロを使う形になるはずなので、マクロの仕様がフィックスしていない現状だとやや時期尚早かもしれません。

implicit def decodeAverageJson: DecodeJson[Average] =
casecodec2(Average.apply, Average.unapply)("count", "total")

implicit def encodeAverageJson: EncodeJson[Average] =
jencode2L((d: Average) => (d.count, d.total))("count", "total")

次に、マーシャル関数(Scala→JSON)とアンマーシャル関数(JSON→Scala)を定義します。

まず、マーシャル関数はcase class Averageのメソッドとして定義しました。

def marshall: String = this.asJson.nospaces

アンマーシャル関数はAverageのコンパニオンオブジェクトに定義しました。

def unmarshall(s: String): Validation[String, Average] = s.decodeValidation[Average]
AverageSpec.scala

case class Averageのテストコードとして、ScalaCheckによるプロパティベーステストを行うAverageSpecを作りました。

package sample

import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalatest._
import org.scalatest.prop.GeneratorDrivenPropertyChecks

@RunWith(classOf[JUnitRunner])
class AverageSpec extends WordSpec with Matchers with GivenWhenThen with GeneratorDrivenPropertyChecks {
"Average" should {
"value" in {
forAll ("ns") { (ns: List[Int]) =>
val ns1 = ns.filter(_ >= 0)
if (ns1.nonEmpty) {
val x = Average(ns1.length, ns1.sum)
x.value should be (toAverageValue(ns1))
}
}
}
"value gen" in {
forAll ((Gen.nonEmptyListOf(Gen.posNum[Int]), "ns")) { ns =>
val x = Average(ns.length, ns.sum)
x.value should be (toAverageValue(ns))
}
}
"+" in {
forAll ("ns") { (ns: List[Int]) =>
val ns1 = ns.filter(_ >= 0)
if (ns1.nonEmpty) {
val xs = toAverages(ns1)
val r = xs.foldLeft(Average.empty)((z, x) => z + x)
r.value should be (toAverageValue(ns1))
}
}
}
"monoid concatenate" in {
import scalaz._, Scalaz._
import Average.Implicits.AverageMonoid
forAll ("ns") { (ns: List[Int]) =>
val ns1 = ns.filter(_ >= 0)
if (ns1.nonEmpty) {
val xs = toAverages(ns1)
val r = xs.concatenate
r.value should be (toAverageValue(ns1))
}
}
}

def toAverages(ns: List[Int]): List[Average] = {
ns.map(Average(1, _))
}

def toAverageValue(ns: List[Int]): Float = {
ns.sum.toFloat / ns.length
}
}
}

ScalaCheckのプロパティベーステストを行う方法はいくつかありますが、ここではScalaTestのGeneratorDrivenPropertyChecksを使ってみました。

GeneratorDrivenPropertyChecksを使うと「forAll」を使って、指定された型の値をワンセット自動生成してくれるので、この値を用いてテストを行うことができます。

forAllの内部定義として個々のテストを書いていきますが、これは通常のテストコードと同様です。

"value" in {
forAll ("ns") { (ns: List[Int]) =>
val ns1 = ns.filter(_ >= 0)
if (ns1.nonEmpty) {
val x = Average(ns1.length, ns1.sum)
x.value should be (toAverageValue(ns1))
}
}
}

一つポイントとなるのは、テスト用データの自動生成は指定された型(ここでは List[Int])の任意の値を取る可能性があるので、これをテストコード側で排除する必要がある点です。

この問題への対処方法として、テスト用データ生成器(org.scalacheck.Gen)で値域を指定する方法があります。

org.scalacheck.Genを使って値域を指定するテスト"value gen"は以下になります。org.scalacheck.Genを使うとクロージャの引数の型(List[Int])も省略できます。

"value gen" in {
forAll ((Gen.nonEmptyListOf(Gen.posNum[Int]), "ns")) { ns =>
val x = Average(ns.length, ns.sum)
x.value should be (toAverageValue(ns))
}
}

いずれの方法を取るにしても、テストプログラムを書く時に、テストデータを準備する必要はないのは大変便利です。

また、テストプログラムが、テストをする手続きというより、より仕様定義に近いものになるのもよい感触です。

実行

Sbtのtestでテストプログラムを実行することができます。

$ sbt test
...略...
[info] AverageSpec:
[info] Average
[info] - should value
[info] - should value gen
[info] - should +
[info] - should monoid concatenate
[info] ScalaTest
[info] 36mRun completed in 1 second, 870 milliseconds.0m
[info] 36mTotal number of tests run: 40m
[info] 36mSuites: completed 1, aborted 00m
[info] 36mTests: succeeded 4, failed 0, canceled 0, ignored 0, pending 00m
[info] 32mAll tests passed.0m
[info] Passed: Total 4, Failed 0, Errors 0, Passed 4
[success] Total time: 5 s, completed 2015/05/15 16:31:57

まとめ

case classを作る時に意識しておきたい基本形を考えてみました。

プログラミング時には、この基本形を念頭に置いて、不要な機能は削除、必要な機能を追加という形でcase classを組み立てていくことをイメージしています。

なお、Monoidに対するテストに関してはScalazのSpecLiteを使うともっと強力なテストができますが、この話題は別途取り上げたいと思います。

諸元

  • Scala 2.11.6
  • Scalaz 7.1.0
  • Argonaut 6.1-M4
  • ScalaTest 2.2.4
  • ScalaCheck 1.12.2

JSONライブラリ性能比較

$
0
0

Spark SQL 1.3の登場を機にバッチ処理基盤の刷新を考えています。この流れの中でJobSchedulerやSpark SQLをDockerで動かす試み(Docker ComposeでMySQLを使う,DockerでSpark SQL)などを行ってきました。

バッチをSpark SQLで記述し、データや計算量の規模に応じてDocker Cluster(e.g. ECS)またはSpark Cluster(e.g. EMR)を選択してバッチ処理を実行するという枠組みが見えてきました。

次に考えておきたいのはバッチ処理で使用する要素技術の選択です。今回はJSONライブラリについて性能の観点から味見してみました。

なお、あくまでも味見レベルの測定なので、条件を変えると違った結果になる可能性も高いです。また、ありがちですが性能測定プログラムにバグがあって結果が逆にでるようなことがあるかもしれません。このようなリスクがあることを前提に参考にして頂ければと思います。

オンライン処理とバッチ処理

クラウド・アプリケーションはおおまかにいうとWebページやREST APIとして実現するフロント系(オンライン系)と、バックエンドで動作するバッチ系で構成されます。(今後はこれに加えてストリーム系が入ってきます。またバッチ系とはニュアンスの異なる分析系も別立てで考えておくとよさそうです。)

極簡単なアプリケーションではフロント系だけで成立する場合もありますが、ある程度本格的な機能を提供する場合はバッチ系は必須になってきます。また、クラウド・アプリケーション・アーキテクチャの定番であるCQRS+Event Sourcingはバッチ系が主役ともいえるアーキテクチャであり、今後バッチ系の重要度はますます高まってくることは間違いないでしょう。

オンライン系のロジックとバッチ系のロジックでは以下のような特性の違いがあります。

オンライン処理バッチ処理
性能特性レスポンス重視スループット重視
データ規模
計算量
実行時間
エラー処理即時通知ジョブ管理

こうやって整理すると様々な面で求められる特性が大きく異なることが分かります。

これらの特性を念頭にプログラミングしていくことは当然ですが、使用する要素技術やライブラリもこれらの特性に適合したものを選択することが望ましいです。

オンライン処理はデータ規模や計算量が小さいので使いやすさ重視でよいですが、バッチ処理はデータ規模や計算量が大きいので処理性能やメモリ消費量がクリティカルな要因になります。多少使い難くても、高速、メモリ消費量が少ない、ものを選ぶ方がよいでしょう。

バッチ処理の中のJSON

バッチ処理の中でのJSONの利用シーンですが、主に意識しなければいけないのは大規模JSONファイルの入力ということになるかと思います。ログファイルや移入データがJSON形式で用意されているようなケースですね。

通常この用途のデータはCSVやLTSVで用意されるケースが多いと思いますが、データが木構造になっている場合(いわゆる文書型)にはJSONやXMLデータが適しており、JSONで用意されるケースも多いでしょう。

バッチをSpark SQLで組む場合、データがCSVで用意されていたり、JSONであってもフラットな構造の場合はSpark SQLで簡単に取り込めるのでJSONライブラリの出番はありません。

ということで、ある程度複雑な構造を持ったJSONファイルがメインの操作対象となります。

ただ、バッチをSpark SQLではなく通常のScalaプログラムとして組みたいケースも出てくるはずなので、フラットな構造のJSONファイルに対してもペナルティなしで扱える必要はあります。

ユースケース1: 複雑な構造を持ったJSON→CSV

代表的なユースケースとしては、複雑な構造を持った大規模JSONファイルから必要な項目を抽出してCSVに書き出す、といった用途が考えられます。

SparkやHadoopの前処理として頻出しそうなユースケースです。

このケースでは(1)JSONの基本的なパース性能と、(2)JSONからデータを抽出したりデータのフィルタリングをしたりする処理の記述性が論点となります。

ユースケース2: 複雑な構造を持ったJSON→アプリケーションロジック適用

少し複雑なバッチ処理を行う場合は、アプリケーションロジックを適用するためcase classなどで定義したドメイン・オブジェクトにJSONを変換する必要がでてきます。

case classに変換後は、アプリケーションが管理しているドメインロジックを型安全に適用することができるので、プログラムの品質と開発効率を高めることができます。

ただ、JSONをcase classにマッピングする処理はそれなりに重たいので、JSON段階である程度フィルタリングした上で、必要なデータのみcase classにマッピングする形を取りたいところです。

テストプログラム

検証対象のJSONライブラリは以下の4つにしました。

  • Json4s native
  • Json4s jackson
  • Play-json
  • Argonaut

各ライブラリの性能測定対象のプログラムは以下になります。

どのライブラリもほとんど使い方は同じです。ただし、ArgonautのみJSONとオブジェクトのマッピング設定がかなり煩雑になっています。

Json4s native
package sample

import org.json4s._
import org.json4s.native.JsonMethods._

object Json4sNativeSample {
implicit val formats = DefaultFormats

def jsonSimple() = {
val s = Company.example
parse(s)
}

def jsonComplex() = {
val s = Person.example
parse(s)
}

def simple() = {
val s = Company.example
val j = parse(s)
j.extract[Company]
}

def complex() = {
val s = Person.example
val j = parse(s)
j.extract[Person]
}
}
Json4s jackson
package sample

import org.json4s._
import org.json4s.jackson.JsonMethods._

object Json4sSample {
implicit val formats = DefaultFormats

def jsonSimple() = {
val s = Company.example
parse(s)
}

def jsonComplex() = {
val s = Person.example
parse(s)
}

def simple() = {
val s = Company.example
val j = parse(s)
j.extract[Company]
}

def complex() = {
val s = Person.example
val j = parse(s)
j.extract[Person]
}
}
Play-json
package sample

import play.api.libs.json._

object PlaySample {
implicit val companyReads = Json.reads[Company]
implicit val addressReads = Json.reads[Address]
implicit val personReads = Json.reads[Person]

def jsonSimple() = {
val s = Company.example
Json.parse(s)
}

def jsonComplex() = {
val s = Person.example
Json.parse(s)
}

def simple() = {
val s = Company.example
val j = Json.parse(s)
Json.fromJson[Company](j)
}

def complex() = {
val s = Person.example
val j = Json.parse(s)
Json.fromJson[Person](j)
}
}
Argonaut
package sample

import scalaz._, Scalaz._
import argonaut._, Argonaut._

object ArgonautSample {
implicit def decodeCompanyJson: DecodeJson[Company] =
casecodec1(Company.apply, Company.unapply)("name")

implicit def encodeCompanyJson: EncodeJson[Company] =
jencode1L((a: Company) => (a.name))("name")

implicit def decodeAddressJson: DecodeJson[Address] =
casecodec2(Address.apply, Address.unapply)("zip", "city")

implicit def encodeAddressJson: EncodeJson[Address] =
jencode2L((a: Address) => (a.zip, a.city))("zip", "city")

implicit def decodePersonJson: DecodeJson[Person] =
casecodec4(Person.apply, Person.unapply)("name", "age", "address", "company")

def jsonSimple() = {
val s = Company.example
Parse.parse(s)
}

def jsonComplex() = {
val s = Person.example
Parse.parse(s)
}

def simple() = {
val s = Company.example
Parse.decodeOption[Company](s)
}

def complex() = {
val s = Person.example
Parse.decodeOption[Person](s)
}
}

テストデータ

Company
package sample

case class Company(name: String)

object Company {
val example = """
{"name": "Yamada Corp"}
"""
}
Person
package sample

case class Person(name: String, age: Int, address: Address, company: Option[Company])
case class Address(zip: String, city: String)

object Person {
val example = """
{"name": "Yamada Taro", "age": 30, "address": {"zip": "045", "city": "Yokohama", "company": "Yamada Corp"}}
"""
}

測定結果

性能測定は以下のプログラムで行いました。JSONのパース処理を100万回繰り返した時の累積ミリ秒を表示しています。

package sample

object All {
def go(msg: String)(body: => Unit): Unit = {
System.gc
val ts = System.currentTimeMillis
for (i <- 0 until 1000000) yield {
body
}
println(s"$msg: ${System.currentTimeMillis - ts}")
}

def main(args: Array[String]) {
go(s"Json4s native json simple")(Json4sNativeSample.jsonSimple)
go(s"Json4s jackson json simple")(Json4sSample.jsonSimple)
go(s"Play json simple")(PlaySample.jsonSimple)
go(s"Argonaut json simple")(ArgonautSample.jsonSimple)
go(s"Json4s native json complex")(Json4sNativeSample.jsonComplex)
go(s"Json4s jackson json complex")(Json4sSample.jsonComplex)
go(s"Play json complex")(PlaySample.jsonComplex)
go(s"Argonaut json complex")(ArgonautSample.jsonComplex)
go(s"Json4s native simple")(Json4sNativeSample.simple)
go(s"Json4s jackson simple")(Json4sSample.simple)
go(s"Play simple")(PlaySample.simple)
go(s"Argonaut simple")(ArgonautSample.simple)
go(s"Json4s native complex")(Json4sNativeSample.complex)
go(s"Json4s jackson complex")(Json4sSample.complex)
go(s"Play complex")(PlaySample.complex)
go(s"Argonaut complex")(ArgonautSample.complex)
}
}

性能測定結果は以下になります。

ネストなし/JSON
ライブラリ性能(ms)
Json4s native747
Json4s jackson788
Play-json640
Argonaut872

ほとんど同じですがPlay-jsonが速く、Argonautが遅いという結果になっています。

ネストあり/JSON
ライブラリ性能(ms)
Json4s native1495
Json4s jackson1310
Play-json1535
Argonaut1724

ネストありのJSONの場合は、Json4s jacksonが速く、Argonautが遅いという結果になりました。

ネストなし/case class
ライブラリ性能(ms)
Json4s native2080
Json4s jackson1601
Play-json1149
Argonaut1151

JSONをcase classにマッピングする場合は、単にJSONをパースするだけの性能とは全く異なる性能特性になりました。

Json4s系はいずれもかなり遅くなっています。

Play-jsonとArgonautはほぼ同じで高速です。

ネストあり/case class
ライブラリ性能(ms)
Json4s native5162
Json4s jackson4786
Play-json4471
Argonaut3840

ネストありのJSONをcase classにマッピングする場合は、Argonautが高速でした。

Json4sはこのケースでもかなり遅くなっています。

評価

性能測定結果を評価の観点で表にまとめました。

ライブラリネストありJSON性能case class性能case class設定
Json4s native
Json4s jackson
Play-json
Argonaut

ネストありJSONのパース性能はJson4sが最速ですが、逆にcase classへのマッピングはかなり遅くなっています。このため、Json4s一択という感じではなさそうです。

ArgonautはJSONパースは遅く、case classへのマッピング性能はよいもののマッピング設定が煩雑なので、使い場所が難しい感触です。Scalazと組合せて関数型的に面白いことが色々できそうなので、その点をどう評価するかということだと思います。

一番バランスがよいのがPlay-jsonで、一択するならPlay-jsonがよさそうです。

ただPlay-jsonはPlayのバージョンと連動しているためdependency hellの可能性があるのが難点です。たとえば、最新版のPlay-jsonを使って共通ライブラリを作っても、サービスで運用しているPlayのバージョンが古いと適用できない、といった問題です。

まとめ

全体的にPlay-jsonが最もバランスがよいという結果が得られました。

個別の性能的には「ユースケース1: 複雑な構造を持ったJSON→CSV」はJson4s jackson、「ユースケース2: 複雑な構造を持ったJSON→アプリケーションロジック適用」はArgonautが適しているという結果が得られました。

ただArgonautはcase classの設定が煩雑なので、広範囲に使える共通処理を作る場合はよいのですが、用途ごとの小さな要件には適用しづらい感じです。そういう意味では、Play-jsonがこの用途にも適していそうです。

以上の点から、次のような方針で考えていこうと思います。

  • Play-jsonを基本にする
  • 必要に応じて「大規模JSON→CSV」の用途向けにJson4s jacksonを併用
  • 必要に応じて「大規模JSON→アプリケーションロジック適用」の用途向けにArgonautを併用

Play-jsonを基本ライブラリとして採用するメリットが大きいので、Play-jsonのdependency hell問題は、運用で回避する作戦を取りたいと思います。

諸元

  • Mac OS 10.7.5 (2.6 GHz Intel Core i7)
  • Java 1.7.0_75
  • Scala 2.11.6
  • Json4s native 2.3.11
  • Json4s jackson 2.3.11
  • Play-json 2.3.9
  • Argonaut 6.1

DBアクセスライブラリ

$
0
0

Spark SQL 1.3の登場を機にプラットフォームのバッチ処理基盤の刷新を考えています。前回「JSONライブラリ性能比較」では、JSONライブラリについて考えました。

今回はDBアクセスライブラリがテーマです。

バッチ処理基盤として観点からDBアクセスライブラリに対して大きく以下の4つの要件を考えています。

  • CRUDの範囲の操作はcase classで定義したレコードで簡単に処理したい。
  • 細かい問合せ処理はSQLを直接使いたい。
  • SQLの組み立てを部品化したい。
  • Dockerコンテナ上でカスタマイズ可能にしたい。

逆に通常ORMに求められる以下の機能はあまり重要視していません。

  • 関連の自動制御
  • データのマイグレーション

「関連の自動制御」ですが関連の取り扱いは必要機能、性能要件、メモリ要件、キャッシュ要件がORMが提供する機能と合致するのかを熟知するのが大変なので、SQLを生で使う方式を採用しているためです。もちろん、うまく合致する場合は使ったほうが楽なので、あるに越したことはありません。

「データのマイグレーション」はアジャイルにカジュアルに行う運用ではなく、システム結合のタイミングで手動で行っているので今のところ本番運用では使う可能性がないためです。

制約

バッチ処理基盤を実現する上で考慮しなければならない制約は以下のものです。

  • バッチ処理はDockerクラスタまたはSparkクラスタ上で動作。
  • case classのパラメタ数制約22個が解消されるのはScala 2.11から。
  • プラットフォームのほとんどの主要DBテーブルのカラム数は22個超なのでScala 2.10ではレコードの表現にcase classは事実上難しい。
  • プラットフォームの主要機能がScala 2.10ベースなのでScala 2.11を全面的に採用するわけにはいかない。
  • Spark SQLがScala 2.11で安定的に動作するか不明(現在使用しているDockerイメージsequenceiq/sparkはScala 2.10みたい)。

上記の制約があるため、以下のような運用を想定しています。

  • プラットフォームとバッチ処理基盤の共通ライブラリはScala 2.10と2.11のクロスビルド。
  • Scala 2.10バッチとScala 2.11バッチを併用。
  • SparkジョブはScala 2.10バッチ。
  • 非SparkジョブはScala 2.11バッチ(case classを活用したいため)。
  • 非SparkジョブはDockerクラスタ上で実行する。
  • Sparkジョブでも小さなものはDockerクラスタ上で実行する。

クロスビルドの問題はかなり重たくて以下のような問題が出ています。

  • Play Frameworkの2.11サポートは2.3からのようだが、AnormのAPIが非互換みたい。
  • finagle-httpは2.10と2.11で別物になる気配。(詳細未調査。Netty 4対応?)

このため共通ライブラリからAnormとFinagleを外すことにしました。

アーキテクチャ選定

DockerでSpark SQL」で述べたようにSpark SQL 1.3をバッチ処理基盤の中軸に据え、1つのSparkジョブプログラムを用途ごとにSparkクラスタとDockerクラスタで実行しわける、というのが今回のバッチ処理基盤刷新の基本アイデアです。

現時点でもこの方向を軸に検討を進めています。

ただ以下のようなニーズもあるので普通のDB操作のためのライブラリも併用する形を考えています。

  • Sparkを使うまでもない小さなジョブの場合Sparkを使わず直接SQLで操作したい。
  • 開発をスケールさせるためSparkの知識なしでもバッチを記述できる方式は担保しておきたい。
  • Sparkで性能要件や機能要件が満たせないケースの回避策は担保しておきたい。
  • Spark処理の場合でもSQLによる入出力が併用できると便利かもしれない。
問題点

プラットフォームのエンジン部では現在はAnormとSquerylを併用しています。SQLを直接使いたいというニーズとORM的なCRUD処理を併用するため、このアーキテクチャを採用しました。

2012年当時の選択としてはよかったと思うのですが2015年新規開発のバッチ処理基盤として引き続き採用するのが望ましいのかという点が論点です。

AnormとSquerylとも非常に便利な機能なのですが経験上以下の問題が判明しています。

まずAnormです。

  • Play 2.2系と2.3系で非互換があるようでクロスビルドは難しそう。
  • ORM的な使い方ができないのでORMを併用する必要がある。

次にSquerylです。

  • 開発が止まっている感じ。
  • テーブル名を完全修飾名(スキーマ名.テーブル名)で指定すると動かない。
  • やりたいことをSquerylのDSLで実現する方法のノウハウが必要。
  • やりたいことがSquerylのDSLでサポートしていないことが判明した場合は対応が大変。
  • SQL組み立ての部品化にノウハウが必要。

AnormとSquerylを併用する際の問題点です。

  • コネクション管理の共通化のノウハウが必要。
  • AnormとSquerylの相互運用は難しい。

上記の問題があるのと有力な代替策があるのでバッチ処理基盤での採用は保留にしました。

Slick

Slickは実際に使ったことはないので資料からの推測ですが、レコードをcase classで表現した上で、関数型的なコレクション/モナド系の抽象操作でDB入出力を可能にしたライブラリ、と理解しています。

大変便利そうですし、関数型的にも筋がよさそうなのですが以下の懸念点があるので今回は採用を見送りました。

  • Scala 2.10系ではcase classの22個制限で事実上使用できない。
  • SQL直接操作ができないと細かい処理の記述が難しい。
  • Anormと併用する場合の親和性が不明。(コネクション管理の共通化など)
  • ORM的な関連の取り扱い方法など、新しいアプローチだけに仕様を調査するのが大変。
Scalikejdbc&Skinny-ORM

以上、色々と検討してきましたがSparkと併用するDB入出力ライブラリとしてはScalikejdbcとSkinny-ORMを採用する方向で考えています。

引き続きAnorm&Squerylをベースに考えても悪くはなさそうですが、Scalikejdbc&Skinny-ORMの方がより適切と判断しました。

ScalikejdbcはSQLベースのDB入出力ライブラリです。Skinny-ORMはScalikejdbc上に構築されたORMです。実用上はScalikejdbcとSkinny-ORMの併用を前提に考えるのがよいのではないかと思います。

Scalikejdbc&Skinny-ORMの採用を考えている理由は以下のものです。

  • Scala 2.10と2.11のクロスビルドで問題がなさそう。
  • SQLでの操作がAnormより若干使いやすそう。
  • Squerylは完全修飾名の問題があるため採用しづらい。
  • ScalikejdbcによるSQL入出力とSkinny-ORMによるORMがシームレスに連携できそう。
  • テーブルのメタデータからのプログラムの自動生成が便利。
  • Typesafe Configを用いたJDBC設定の管理メカニズムを持っている。
  • Joda-timeをサポートしている。

現在判明している問題点としては以下のものがあります。

  • case classの22個制限があるのでSkinny-ORMは2.10系では用途が限定される。
  • scalaz-streamと接続するためには工夫が要りそう。

後者のscalaz-streamの問題については引き続き解決策を探していく予定です。

まとめ

バッチ処理基盤におけるSQLライブラリですが、検討の結果Spark SQLを基本にScalikejdbc&Skinny-ORMを併用する形を基本線で考えていくことにしました。

DBアクセスライブラリのような基本機能は一度製品に採用するとなかなか切替は難しいですが、今回はバッチ処理基盤を刷新するタイミングがあったのでゼロベースで調べなおしてみました。

プロジェクトを開始した2012年当時とはかなり状況も変わってきていてキャッチアップが大変ですが、調度よいタイミングで棚卸しができたと思います。

野毛倶楽部夜会:超高速開発

$
0
0

野毛倶楽部(横浜クラウド勉強会)は土曜の昼に行うチュートリアル的な技術セッション(+夜学)を中心に活動してきましたが、これに加えて先端技術についてオフレコで語り合う場として新たに夜会を始めることにしました。

先週の月曜(6月8日)はその第1回でジャスミンソフトの贄さんを招いて超高速開発をテーマに野毛倶楽部の第1回夜会を行いました。

ジャスミンソフトは超高速開発ツールWagbyの開発/提供元で超高速開発コミュニティの幹事会社です。

プログラムの自動生成はボクも長年追いかけてきた技術分野なので、この分野で成功を収められているジャスミンソフトの具体的なお話をビジネス/技術両面からお聞きできてとても参考になりました。

ディスカッションの中では以下のキーワードが印象に残りました。

  • モデリング
  • リポジトリ

以下、夜会の後に考えたことなどをつらつら。

モデリング

プログラムの自動生成も現時点ではモデルからアプリケーション全体を生成できるわけではないですがアプリケーションの相当部分を自動生成(+フレームワーク)でカバーできるところまできています。

また「超高速開発」という枠組みでは仕様から直接システムやサブシステムを動作させるXEAD DriverASTERIA WARPといったアプローチもあります。

いずれにしても伝統的なスクラッチ開発と比べるとテストも含めたプログラム開発工数が劇的に少なくなることは明らかです。

プログラム開発工数が少なくなるのは「仕様」が直接、間接にそのまま動作するためですが、「動作可能な仕様」でなければならないので、動作可能な精密で網羅的な仕様を作成するスキルが必要になってきます。加えてシステムの既存部分やスクラッチ開発する部分との連携を仕様化するスキルも要求されます。

「動作可能な仕様」を記述する言語としてはOOADのUMLやデータモデリングのER図といったものが代表的ですがExcelやプレインテキストを用いるアプローチもあります。記述言語も大事ですが、裏側のメタモデルがより重要です。

顧客要件から動作可能な精密なレベルのUMLやER図などで記述した動作可能な精密な仕様を作成するモデリングのスキルが「超高速開発」を活用するための重要スキルということになります。

伝統的なスクラッチ開発でも基本設計書、構造設計書、詳細設計書という形で仕様書は書かれていたわけですが仕様に曖昧な点があってもプログラマが補完することで補うことが可能でした。というよりプログラマが高度な補完を行うことが前提だったといっても過言ではないでしょう。しかし「超高速開発」の場合は、仕様をそのまま動作させるため仕様段階で実行可能レベルの正確さが要求されるわけです。

こういった問題意識から超高速開発コミュニティでもモデリング分科会の活動を行っているのだと思います。

リポジトリ

「動作可能な仕様」によるシステム開発が軌道に乗ると、次はこの仕様をデータベースで一元管理したくなるのが道理です。「リポジトリ」はこのような機能を提供するものです。

「リポジトリ」はWikipediaでは以下のように説明されています。

  • リポジトリ (英: repository) とは、情報工学において、仕様・デザイン・ソースコード・テスト情報・インシデント情報など、システムの開発プロジェクトに関連するデータの一元的な貯蔵庫を意味する。

リポジトリは少なくてもボクがオブジェクト指向が調べ始めた90年代初頭にはすでにあった概念ですが、格納するメタデータの記述能力が追いつかず現時点でも部分的な応用にとどまっていると認識しています。メタデータと配備するプログラムが連動していないと、絵に描いた餅になってしまうわけですね。

OMGのMOF(Meta Object Facility)やEAI(Enterprise Application Integration)で話題となったMDM(Master Data Management)も同じようなジャンルの技術ですがまだ(一部の大企業のシステムは別として)一般的に幅広く利用されるような段階にはいたっていないと思います。

リポジトリで管理する情報が超高速開発流の「動作可能な仕様」となると、リポジトリで構想されていた本来の運用が見えてきます。

また、もうひとつの流れとしてソフトウェア構成管理技術の進歩があります。現在はCI技術によってGitHubにプログラムのソースコードをプッシュすると自動的にHerokuのような実行コンテナに配備したり、DockerHubのようにDockerイメージを生成したり、ということが可能になっています。

こういった構成管理技術と「動作可能な仕様」の「リポジトリ」を組み合わせることで、より高度な運用ができるはずです。

現時点で実用化されている要素技術を組み合わせれば、もともと「リポジトリ」で構想されていた理想的な運用に近しいところまでもっていけるのではないか。ブレークスルーの寸前まで各要素技術のポテンシャルが高くなってきており、後は誰がどのように最後のピースをつなげるか、が焦点の分野ではないかと思われるわけです。わくわくしますね。

"リポジトリ"のキーワードからそういったことを考えながら、野毛の夜は更けていきました。

SmartDoc

本題とは離れますが、Wagbyではマニュアル作成にSmartDocを現役で使って頂いているということです。うれしいですね。


[FP] パイプライン・プログラミング

$
0
0

関数型プログラミングとは何か?

この問は深遠すぎてとてもボクの手には負えませんが、実務的なプラクティスとしてはパイプライン・プログラミングとして考えると分かりやすいのではないかと思います。

そこでScalaでのパイプライン・プログラミングの選択肢を整理してみました。

関数呼び出し

関数型プログラミングにおける普通の関数呼び出しもパイプラインと考えることができます。

純粋関数型では副作用は発生しないので、表に見えている関数の引数と復帰値のみで関数の挙動のすべてが表現されているためです。

たとえば以下のプログラムは:

h(g(f(1)))

以下のようなパイプラインと考えることができます。

Functor

文脈を持ったパイプラインはFunctorを使って構成できます。関数呼び出しとの違いは「文脈」を持った計算になるという点です。

ここでいう「文脈」とはパイプラインの裏側で暗黙的に共有、引継ぎされる状態やデータというぐらいの意味です。関数の直接呼び出しにはそのような裏表はないので、Functorで加わった重要な機能ということになります。

以下の図はQCon Tokyo 2015のセッション「ScalaによるMonadic Programmingのススメ - Functional Reactive Programmingへのアプローチ」で使用したスライドから引用です。



Functor, Applicative Functor, Monadが構成するパイプラインを概観しています。

この中でFunctorはmapコンビネータを使ってパイプラインを構築します。

Option(1).map(f).map(g).map(h)

Applicative Functor

上記の図で示した通りApplicative Functorは復数のパイプラインを一つに統合する機能を提供します。

ScalazではApplicative Functorのために以下のような文法を提供しています。

(Option(1) |@| Option(2) |@| Option(3))(i(_, _, _))

以下の図は同スライドからFuture Applicative Functorの例です。



Monad

「Functor, Applicative Functor, Monadが構成するパイプラインを概観」する図の中のMonadは以下のプログラムになっています。このようにMonadではflatMapコンビネータを使ってパイプラインを構築します。

Functorとの違いは「文脈」をプログラム側で制御する機能が加わる点です。

Option(1).flatMap(f).flatMap(g).flatMap(h)

以下の図は同スライドからOption Monadの例をあげています。



こちらではflatMapコンビネータを使う代わりにfor式によるfor内包表記(for comprehension)を使用しています。

def bigCalcO(n: Int): Option[String] = {
for {
a <- Option(calcString(n))
b <- Option(calcInt(n))
c <- Option(calcFloat(n))
} yield finalCalc(a, b, c)
}
}

for内包表記はMonadを使ったパイプラインのための文法糖衣として機能します。

上記の例ではflatMapコンビネータ直接使用とfor内包表記の違いはそれほど分かりませんが以下に示すState Monadのような複雑な構造のMonadを使う場合にfor内包表記の効果が出てきます。



Reactive Stream

Applicative FunctorやMonadを使うとかなり複雑なパイプラインを構築することができますが、あくまでも制御上は関数呼び出しなのでフロー制御を行うことができません。

ここでいうフロー制御とは、一度に処理するデータ量を制限したり、データの発生またはデータの消費の契機で処理を進める制御を指します。

直感的にパイプラインというとこういう制御が入っていて欲しいところですが、Monadそのものには入っていないわけです。

そこで登場するのがReactive Streamです。単にStreamというとscala.collection.immutable.Streamと紛らわしいので、ここではReactive Streamと呼ぶことにします。

Scalaz StreamではProcess Monadを使ってReactive Streamをを実現しています。

大規模データ処理とストリーム処理それぞれでのProcessモナドの使い方は以下になります。

ストリーム処理


大規模データ処理とストリーム処理のいずれもパイプラインに抽象化されたほとんど同じプログラムになっています。

また、普通のMonadと比べても若干おまじないは増えるもののプログラミングとしては同じような形になります。

状態機械

Process MonadはMonadによるパイプラインで状態機械による制御を可能にしたものと考えることもできます。

詳しくは以下の記事を御覧ください。

まとめ

Scalaのパイプライン・プログラミングを以下のパターンに整理してみました。

  • 関数呼び出し
  • Functor
  • Applicative Functor
  • Monad
  • Reactive Stream

これらのパイプラインを適材適所で使い分ける、という切り口で関数型プログラミングを考えてみると面白いかもしれません。

[FP] Scalaへの道

$
0
0

先日、社内勉強会でScala的なプログラムにするためには、という話題が出たのでそのまとめです。

例題

勉強会で出たプログラム例をまとめて例題を作りました。

  • テキストファイル内の欧文単語を単語の長さで以下の三種類に分類してオブジェクトに設定する
  • 短 : 3文字以下
  • 中 : 7文字以下
  • 長 : 8文字以上

以下ではこの例題にそって説明していきます。

準備

分類した単語を設定するオブジェクトとしてcase classのWordsを中心としたクラスとコンパニオンオブジェクトを用意しました。

例題はテキストファイルを解析して、case class Wordsに解析結果を設定する処理になります。

package sample

case class Words(
smalls: Vector[String],
middles: Vector[String],
larges: Vector[String]
) {
def +(word: String): Words = {
if (word.length <= 3)
copy(smalls = smalls :+ word)
else if (word.length > 7)
copy(larges = larges :+ word)
else
copy(middles = middles :+ word)
}

def +(words: Words): Words = {
copy(
smalls ++ words.smalls,
middles ++ words.middles,
larges ++ words.larges
)
}

def ++(words: Seq[String]): Words = {
words.foldLeft(this)(_ + _)
}

override def toString() = s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}

object Words {
val empty = Words(Vector.empty, Vector.empty, Vector.empty)

def apply(word: String): Words = empty + word

def apply(words: Seq[String]): Words = empty ++ words

def getTokens(s: String): Vector[String] =
s.split(" ").filter(_.nonEmpty).toVector
}

Java風プログラム

JavaもJava 8でストリームAPIが追加されましたし、Functional Javaのようなアプローチも色々あるので、モダンJavaは事情が異なりますが、古くからJavaを使っているベテランプログラマ程Java 5〜7時代のプログラミング・スタイルがスタンダードだと思います。

この古めのJavaスタンダード的なプログラムをここではJava風プログラムと呼ぶことにします。

それではJava風プログラムです。

def calcWords(filename: String): Words = {
val buf = new ArrayBuffer[String] // ArrayBuffer中心ロジック
val in = new BufferedReader(new InputStreamReader(new FileInputStream(filename), "UTF-8"))
try {
var s = in.readLine
while (s != null) {
buf ++= getTokens(s)
s = in.readLine
}
} finally {
in.close() // リソースの解放が手動
}
val smalls = new ArrayBuffer[String]
val middles = new ArrayBuffer[String]
val larges = new ArrayBuffer[String]
for (x <- buf) { // アルゴリズムの配置がトランザクションスクリプト的
if (x.length <= 3)
smalls += x
else if (x.length > 7)
larges += x
else
middles += x
}
Words(smalls.toVector, middles.toVector, larges.toVector)
}

ボクもJavaからScalaに移行した当初はこのようなプログラムを書いていました。

ポイントは以下の3つです。

  • ArrayBuffer中心ロジック
  • リソースの解放が手動
  • アルゴリズムの配置がトランザクションスクリプト的
問題 : ArrayBuffer中心ロジック

Javaプログラムでは、データの集まりを作成する処理はjava.util.ArrayListとfor文などのループを組み合わせるのが定番です。

JavaからScalaに移行したての時期は、Scalaプログラムでもこの作戦を踏襲して上記のようなプログラムを書くことが多いと思います。java.util.ArrayListの代わりにscala.collection.mutable.ArrayBufferを使います。

Better Javaという意味では問題はないのですが、よりScala的、関数型プログラミング的にステップアップしたい場合には、この作戦を捨てる必要があります。

関数型プログラミングでは不変オブジェクトを使って副作用なしで処理を行うことが基本です。つまり、可変オブジェクトであるscala.collection.mutable.ArrayBufferを使った瞬間にこの基本が崩れてしまうため、関数型プログラミング的ではなくなるわけです。

問題 : リソースの解放が手動

Javaではローンパターン的なDSLを用意することが難しいため、リソースの解放がtry文を使った手動になってしまう事が多いと思います。

Scalaではリソース解放の処理を自動的に行なってくれるDSLが多数用意されているのでできるだけそれらの機能を使うようにするのが得策です。

問題 : アルゴリズムの配置がトランザクションスクリプト的

ArrayBufferを使うことでできる限り性能劣化を避けるという方針の悪い側面として、アルゴリズムの部品化を阻害するという問題があります。

たとえばArrayBufferを使ったアルゴリズムの一部を性能劣化を起こさず部品化する場合、ArrayBufferを持ちまわすようなインタフェースとなり、インタフェースが複雑化します。

また、ArrayBufferを意識した部品の場合、ArrayBuffer以外のデータ構築用のオブジェクトには適用できないので、データ構築用のオブジェクト毎に部品を用意する必要が出てくるという問題もあります。

インタフェースが複雑化した部品は結局使われないようになりがちなので、結果としてアプリケーション・ロジック内によく似たアルゴリズム断片がベタ書きされるようなケースが多くなります。

めぐりめぐって部品化が進まずプログラム開発の効率化が阻害されるという悪循環に入ることになります。

この問題への対応策は前述したように「多少の性能問題には目をつぶる」覚悟をした上で、不変オブジェクトを使った関数型プログラミングに移行することです。

性能問題対策

ArrayBufferを多用するアルゴリズムを採用する意図としては性能問題があります。

上級者ほど性能問題を重要視しているので、関数型的な不変オブジェクト中心のアルゴリズムには本能的な拒否反応があると思います。

この問題への対応ですが「多少の性能問題には目をつぶる」につきます。

Java的な感覚でいうと、そもそもScalaの生成するコードはかなりオーバーヘッドが高いのでArrayBufferを採用して部分的に性能を上げても他の所でもオーバーヘッドが出るため、結局Javaと全く同じものにはなりません。

それであれば中途半端に性能改善するよりScalaのパワーを活かした開発効率を重視する戦略を採るのが得策です。

余談 : ListBuffer

java.util.ArrayListに対応するScala側のコレクションとして、「List」つながりでscala.collection.mutable.ListBufferを選んでしまうケースがあるかもしれません。

scala.collection.mutable.ListBufferはscala.collection.mutable.ArrayBufferと比べると性能的にかなり不利なので、余程のことがなければ採用することはありません。

そもそもArrayBufferは使わないようにしようという議論ですが、仮にjava.util.ArrayList相当のコレクションが必要になった場合はListBufferではなくArrayBufferを使うようにしましょう。

Scala的プログラム

前述のJava風プログラムの改良版としてScala的プログラムを4つ用意しました。

基本形

まず基本形です。

def calcWords(filename: String): Words = {
var strings = Vector.empty[String]
for (in <- resource.managed(new BufferedReader(new InputStreamReader(new FileInputStream(filename), "UTF-8")))) {
var s = in.readLine
while (s != null) {
strings = strings ++ getTokens(s)
s = in.readLine
}
}
strings.foldLeft(Words.empty)(_ + _)
}
対策 : ArrayBuffer中心ロジック

ArrayBufferを使わず不変オブジェクトであるVectorを使うようにしています。

Vectorは不変オブジェクトですがvarと複写を併用することでアルゴリズム内では内容を更新しながら処理を進める効果を得ています。

var strings = Vector.empty[String]
...
strings = strings ++ getTokens(s)

varとVectorのこのような使い方は定番のイディオムです。

対策 : リソースの解放が手動

ファイルアクセスを伝統的なjava.ioを使って行う場合は、ストリームの解放処理が問題となります。

このような処理を記述する時によく利用するのがScala ARMです。

Scala ARMの使い方の一つとして上記のプログラムのようにfor式とresource.managedを組み合わせる方式があります。

resource.managedの引数にリソースのライフサイクルを管理したいオブジェクトを設定すると、for式の終了時に自動的にリソースを解放してくれます。

対策 : アルゴリズムの配置がトランザクションスクリプト的

単語の振り分けアルゴリズムの呼出しは以下の場所で行っています。

strings.foldLeft(Words.empty)(_ + _)

振り分けアルゴリズムはcase class Words内で定義した部品(メソッド)を、非常に簡単にcalcWords関数から呼び出して使用できており、トランザクションスクリプト的な問題は解消しています。

アルゴリズム部品の利用に貢献しているのが畳込み処理を抽象化した関数であるfoldLeftコンビネータです。ArrayBufferとfor式を使って記述しているアルゴリズムの多くはfoldLeftまたはfoldRightコンビネータで記述することができます。

VectorとはfoldLeftが相性がよいので、ArrayBufferとfor式を使いたくなったらVectorとfoldLeftの組合せの解を考えてみるのがよいでしょう。

大規模データ問題

上述のScala基本形ですが、Java風版から引き継いだ本質的な問題点として大規模データに対応できないという問題があります。

というのは、ArrayBufferに一度全データを格納するため、データ量がメモリに載らない程大規模になった場合にプログラムがクラッシュしてしまうからです。

ArrayBufferを使わずデータ読み込みのwhile式の中にすべての処理を押し込めばこの問題には対応できますが、部品化とは真逆の一枚岩の見通しの悪いアルゴリズムになってしまいます。

大規模データ問題は後述のパイプライン・プログラミングやMonadicプログラミングで解決することができます。

余談 : Iteratorパターンの本質

forロープで記述したアルゴリズムをより関数的なやり方で記述する方法について参考になるのが以下のページです。

foldLeft/foldRightで記述しきれない複雑なforループもApplicativeとTraverseという機能を使って部品化を進めながら記述できるということのようです。より本格的な関数型プログラミングを追求する場合は、こちらの方面に進むことになります。

Try

関数型プログラミングでは、関数の実行結果は関数のシグネチャに完全に記述されていることが基本です。このため、暗黙的に関数の制御フローを乱す「例外」は関数型プログラミング的には好ましくない言語機能です。

とはいえ実用的には非常に便利なのでScalaプログラムでも日常的に使用するのは問題ないと思いますが、関数ワールド的なプログラムを書きたい場合はscala.util.Tryモナドを使うのが基本です。

Tryモナドを使った版は以下になります。

def calcWords(filename: String): Try[Words] = Try {
var strings = Vector.empty[String]
for (in <- resource.managed(new BufferedReader(new InputStreamReader(new FileInputStream(filename), "UTF-8")))) {
var s = in.readLine
while (s != null) {
strings = strings ++ getTokens(s)
s = in.readLine
}
}
strings.foldLeft(Words.empty)(_ + _)
}

関数の処理全体をTryで囲むだけなので簡単ですね。

scalax.io

基本形ではリソース解放の汎用的な解としてScala ARMを紹介する目的でjava.ioによるファイルアクセスを行いましたが、Scala的にはファイルアクセスにはscalax.ioを使うのがお勧めです。

scalax.ioを使った版は以下になります。

import scalax.io.{Resource, Codec}

def calcWords(filename: String): Words = {
implicit val codec = Codec.UTF8
Resource.fromFile(filename).lines().
map(getTokens).
foldLeft(Words.empty)(_ ++ _)
}
対策 : リソースの解放が手動

ファイルからの入力処理はリソースの解放も含めてResource.fromFile関数が全て行ってくれます。

対策 : ArrayBuffer中心ロジック&アルゴリズムの配置がトランザクションスクリプト的

関数型プログラミングの常道であるパイプライン・プログラミングの一環としてmapコンビネータとfoldLeftコンビネータを使って処理を記述します。

この方式を取ることで自然に「ArrayBuffer中心ロジック」が解消されます。また、アルゴリズムの部品かも進むので「アルゴリズムの配置がトランザクションスクリプト的」も解消されます。

対策 : 大規模データ

scalax.ioの方式では、Scala基本形に残っていた「大規模データ問題」も解消されています。「大規模データ問題」を解消しつつ部品化も行えているのがパイプライン・プログラミングの効用です。

scalaz-stream

Scalaらしさを越えて、Monadicプログラミングを追求したい場合はscalaz-streamを使うとよいでしょう。

まず準備としてWordsをMonoidとして定義しておきます。

implicit object WordsMonoid extends Monoid[Words] {
def append(lhs: Words, rhs: => Words) = lhs + rhs
def zero = Words.empty
}

その上でscalaz-streamを使った版は以下になります。

import scalaz.concurrent.Task

def calcWords(filename: String): Task[Words] = {
implicit val codec = Codec.UTF8
io.linesR(filename).
map(getTokens).
runFoldMap(Words(_))
}

scalax.io版とほぼ同じような処理になります。

Processモナドを使っているので本来は高度なフロー制御ができるのですが、この例のような単純なデータ読込みだとフロー制御を使う場所はないのでscalax.ioとの違いはでてきません。

scalax.io版との違いは、結果がTaskモナドで返される点です。

TaskモナドはScalaのTryモナドとFutureモナドを合わせたような機能を持つモナドです。前出の「Try」版ではTryモナドで結果を返しましたが、同じような用途と理解すればよいと思います。

まとめ

Java風プログラムのScala的でない点を指摘した上で、Scala的プログラムの解を4つ上げました。

それぞれ長短があるので適材適所で使い分けることになります。

なお、今回はScala ARMを紹介する都合上java.ioによるファイルアクセスも使用していますが、実務的には例題のようなテキストファイル処理はscalax.ioを使うのが効率的です。scalax.ioを基本線に考え、機能不足が判明した場合に、java.io(またはjava.nio)やscalaz-streamを使う方式を検討するのがよいでしょう。

[SDN]List性能問題

$
0
0

懸案だったMonadic Programming、Functional Reactive Programmingもある程度目処がついてきたこともあり、要件定義で作成されたモデルを実装に落とし込むという流れの中での総合的なScalaプログラミングの方法論をScala Design Noteという切り口で考察していこうと思います。

大昔にJava World誌で「Java Design Note」という連載を書いていましたが、そのScala版をイメージしています。

問題

Scalaを実務に使う場合に注意が必要なのがListの性能問題です。

Scalaでは関数型言語の伝統を踏襲してLispのList由来のListを基本データ構造としています。システムの各種デフォルトもListを使う方向になっていますし、文法を説明する場合にもListを使用するケースが多いと思います。

Listは関数型プログラミングとの相性はとてもよいので妥当な選択ではあるのですが、要素の後方追加が致命的に遅いという問題を持っています。この問題があるためListの使い方には注意が必要です。

致命的に遅いといっても小さなプログラムではほぼ問題はありませんが、プログラムの処理が本格化し、扱うデータ規模が大きくなってくると顕在化してきます。

ListはScalaの基本データ構造であり、普通にScalaを使っていると色々な所でListが自然に使用されますが、これがプロダクションコードとしてはアンチパターンという点がScalaプログラミングのハマりどころとなっています。

きっかけ

前回「Scalaへの道」で以下のcase class Wordsを実装しました。

この実装を行う中で、このように復数のオブジェクトを保持しているcase classを足し込んでいくような処理におけるListの危険性が判明したのが、今回の記事のきっかけになっています。

package sample

case class Words(
smalls: Vector[String],
middles: Vector[String],
larges: Vector[String]
) {
def +(word: String): Words = {
if (word.length <= 3)
copy(smalls = smalls :+ word)
else if (word.length > 7)
copy(larges = larges :+ word)
else
copy(middles = middles :+ word)
}

def +(words: Words): Words = {
copy(
smalls ++ words.smalls,
middles ++ words.middles,
larges ++ words.larges
)
}

def ++(words: Seq[String]): Words = {
words.foldLeft(this)(_ + _)
}

override def toString() = s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}

object Words {
val empty = Words(Vector.empty, Vector.empty, Vector.empty)

def apply(word: String): Words = empty + word

def apply(words: Seq[String]): Words = empty ++ words

def getTokens(s: String): Vector[String] =
s.split(" ").filter(_.nonEmpty).toVector
}
問題

性能測定のため前述のcase class Wordsを単純化し、測定パターンごとに復数の実装を用意しました。以下は、宣言にIndexedSeq、実装にVectorを採用した実装であるWordsIVです。これを含めて16パターンの測定を行いました。(後述の表参照)

case class WordsIV(
smalls: IndexedSeq[String],
middles: IndexedSeq[String],
larges: IndexedSeq[String]
) {
def +(word: String): WordsIV = {
if (word.length <= 3)
copy(smalls = smalls :+ word)
else if (word.length > 7)
copy(larges = larges :+ word)
else
copy(middles = middles :+ word)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsIV {
val empty = WordsIV(Vector.empty, Vector.empty, Vector.empty)
}

case class WordsVは以下のようにして積算した時の実行時間を計測しました。

go("WordsIV") {
inputs.foldLeft(WordsIV.empty)(_ + _)
}
測定のパターン

性能測定パターンは以下になります。この中の「WordsIV」のプログラムは前述しました。その他のパターンのプログラムは一番最後に一括で置いておきます。

測定名性能(ms)宣言実装追加場所備考
WordsJ30ArrayBufferArrayBuffer
WordsV33VectorVector
WordsL27717ListList
WordsVP32VectorVector
WordsLP28ListList
WordsSV19840SeqVector
WordsSVV32SeqVectorロジック工夫
WordsS27620SeqSeq
WordsSP31SeqSeq
WordsIV30IndexedSeqVector
WordsIA614IndexedSeqArray
WordsI29IndexedSeqIndexedSeq
WordsIP29IndexedSeqIndexedSeq
WordsBuilderV25VectorArrayBufferBuilder
WordsBuilderL29ListArrayBufferBuilder
WordsBuilderS25SeqArrayBufferBuilder

考察

性能測定の結果をまとめると以下のような結果が得られました。

  • Listに追記する処理は圧倒的に遅い (WordsL, WordsSV, WordsS)
  • ArrayBuffer、Vectorへの追記、List、Vectorへの前置挿入はほとんど同じ性能 (WordsJ, WordsV, WordsVP, WordsLP)
  • SeqとVectorの組合せはロジックを工夫しないと非常に遅くなることがある (WordsSV)
  • Vectorへの前置挿入はほとんどListと変わらない (WordsVP, WordsLP)

Seqのデフォルト実装はListなのでWordsSも事実上Listの追記処理のパターンになります。WordLとWordsSの結果からListの追記処理が非常に低速なのは明らかです。

また、宣言にSeq、初期実装にVectorを使うWordsSVは実装にVectorを使う意図なのですが、ロジックの綾で実際はListが使われるようになるらしく非常に低速です。

ただWordsSVと同じパターンのWordsSVVは宣言Vector、実装VectorのWordsVなどと同じ性能が出ています。宣言にSeq、初期実装にVectorのパターンは実装時のロジックの綾で非常に低速になったり、通常性能になったりしてリスクが高い処理パターンといえます。

コンテナの選択

まずコンテナの候補としてはVector一択かなと考えています。

Listは追記性能が非常に遅いので、使い方に注意が必要な点がデメリットです。また、単方向リストによる実装方式からメモリも多目に消費することが推測されます。

VectorとListの性質を比較した場合、Listは前置挿入が得意で追記が苦手、Vectorは追記が得意で前置挿入が苦手、という整理の仕方がありますが、Vectorは追記が得意で前置挿入も大丈夫、というの実際のところです。

Vectorは万能で用途を選ばないのに対して、Listは追記が苦手という弱点があるので、利用局面に応じて意識して使用する必要があります。

このためコンテナの選択は弱点がないVectorの一択と考えてよいと思います。

Listは関数型言語の伝統的なデータ構造なので変な話ですが、ScalaにおけるListはHashSetなどと同様の特殊用途向けコンテナとして割り切って使うぐらいがベストプラクティスではないかと思います。

Listの長所

Listが向いている「特殊用途」は何かという話ですが、伝統的な関数型プログラムということになります。

関数型プログラミングでよく出てくる再帰呼び出しでの利用は、専用の文法が用意されていて便利です。たとえば、以下のようなコーディングパターンです。

def f(a: List[Int]): Int = {
a match {
case Nil => 0
case x :: xs => x + f(xs)
}
}

ただVectorを始めとするSeqでも以下のように書けるので使えないと困るという程ではありません。

def f(a: Vector[Int]): Int = {
a.headOption match {
case None => 0
case Some(x) => x + f(a.tail)
}
}
補足:末尾再帰呼び出し

念のために補足です。

「Listの長所」で取り上げたコードはList処理を分かりやすく提示するのが目的なので再帰呼び出しは自然な方法を用いていますが、実はこの「自然な方法」はプロダクションコードとしては適切ではありません。というのは、このコードでは末尾再帰の最適化が得られないのでスタックオーバーフローのリスクがあるためです。

今回の場合は実務的には以下のようなコーディングになります。

def f(a: List[Int]): Int = {
@annotation.tailrec
def go(b: List[Int], sum: Int): Int = {
b match {
case Nil => sum
case x :: xs => go(xs, x + sum)
}
}
go(a, 0)
}
def f(a: Vector[Int]): Int = {
@annotation.tailrec
def go(b: Vector[Int], sum: Int): Int = {
b.headOption match {
case None => sum
case Some(x) => go(b.tail, x + sum)
}
}
go(a, 0)
}

宣言の選択

オブジェクトの集まりを実現する場合に使用するコンテナとして概ね以下の4つを日常的に使用します。

コンテナ種別説明
Seqトレイト先頭からのシーケンシャルアクセス
IndexedSeqトレイトランダムアクセスに適したシーケンス
List具象クラスList構造のSeq
Vector具象クラス配列をベースとしたIndexedSq

SeqとIndexSeqはトレイトなので、それぞれ復数の具象クラスに対応します。関数の引数やオブジェクトのインスタンス変数にSeqやIndexSeqを指定することで、プログラムの実行時に具象クラスを使い分ける事ができるようになります。

ListとVectorは具象クラスなので、引数やインスタンス変数の型として指定すると型を最終決定してしまうことになります。逆に明確な決定が行われるので、予想外の動きによる性能劣化という事態は避ける事ができます。

一般的には、できるだけ抽象度の高いものを選択するのが得策です。その一方で、Seqを使うと前述のListの性能問題が発生する可能性があるので、一定のリスクがあります。

つまり汎用性と性能リスクのバランスを勘案して実装戦略を考えることになります。

実装戦略

case classの属性としてオブジェクトの集まりが必要になった時の実装戦略です。

以下が論点になります。

  • case class内での宣言
  • 実装に使用するコンテナの選択

まず実装に使用するコンテナの選択は前述した通りVector一択としました。

Listは追記性能が非常に遅いので、使い方に注意が必要な点がデメリットです。また、単方向リストによる実装方式からメモリも多目に消費することが推測されます。

つまり、実装の選択はVectorのみなので、残る選択は宣言に何を使うのかになります。

シンプル戦略

アプリケーション内で閉じた範囲で使用するcase classであれば、あまり凝ったつくりにしてもメリットは少ないので、宣言と実装の両方にVectorを使うのが簡明です。

性能測定パターンではWordsVが対応します。

安全戦略

宣言にSeqまたはIndexedSeq、実装にVectorを使うのが本格的な戦略です。

用途に応じてSeqとIndexedSeqを使い分けることができれば理想的ですが、Seqは前述のList性能問題が発生する可能性が出てくるので、安全策を採るのであれば常にIndexedSeqを使う戦略が有効です。

性能測定パターンではWordsIVが対応します。

上級戦略

対象となるcase classを操作する時のアクセスパターンが先頭からのシーケンシャルアクセスのみである場合、宣言には一番汎用性の高いSeqを使うのが理想的です。

性能測定パターンではWordsSVVが対応します。

Seqを使うことで、以下のように他のSeqコンテナをそのまま設定することとができるようになります。

WordsSVV(List("a"), List("abcde"), List("abcdefghijk"))

設定されたListをアクセスする場合は、そのままListでよいですが、後方追加の積算をそのままListで行うと性能問題が出てきます。

そこで、前出の「SeqとVectorの組合せはロジックを工夫」が必要になってきます。

具体的にはWordsSVで使用している以下のロジックでは不適切です。

def +(word: String): WordsSV = {
if (word.length <= 3)
copy(smalls = smalls :+ word)
else if (word.length > 7)
copy(larges = larges :+ word)
else
copy(middles = middles :+ word)
}

WordsSVVで使用している以下のロジックにする必要があります。このロジックのポイントは後方追加の演算の前にSeqをVectorに変換しているところです。こうすることによって、以降の計算に使用されるコンテナはVectorになるのでList性能問題は発生しなくなります。

def +(word: String): WordsSVV = {
if (word.length <= 3)
copy(smalls = smalls.toVector :+ word)
else if (word.length > 7)
copy(larges = larges.toVector :+ word)
else
copy(middles = middles.toVector :+ word)
}

まとめ

case classを漫然と作っているとListの性能問題に遭遇してしまう可能性があるので、実装戦略としてまとめてみました。

結論としては以下の3パターンを適材適所で選んでいくのがよさそうです。

  • シンプル戦略 : 宣言 => Vector、実装 => Vector
  • 安全戦略 : 宣言 => IndexedSeq、実装 => Vector
  • 上級戦略 : 宣言 => Seq、実装 => Vector

上級戦略は「SeqとVectorの組合せはロジックを工夫」を意識しておく必要があるのが難点です。このような考慮が負担になるような場合は、安全戦略を基本戦略にしておくのが簡明でよいと思います。

測定プログラム

package sample

import scala.util.Try
import scala.collection.mutable.ArrayBuffer
import scalaz._, Scalaz._
import scalaz.stream._
import scalaz.concurrent.Task

object Performance {
class WordsJ(
val smalls: ArrayBuffer[String] = new ArrayBuffer[String],
val middles: ArrayBuffer[String] = new ArrayBuffer[String],
val larges: ArrayBuffer[String] = new ArrayBuffer[String]
) {
def +(word: String): WordsJ = {
if (word.length <= 3)
smalls += word
else if (word.length > 7)
larges += word
else
middles += word
this
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

case class WordsV(
smalls: Vector[String],
middles: Vector[String],
larges: Vector[String]
) {
def +(word: String): WordsV = {
if (word.length <= 3)
copy(smalls = smalls :+ word)
else if (word.length > 7)
copy(larges = larges :+ word)
else
copy(middles = middles :+ word)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsV {
val empty = WordsV(Vector.empty, Vector.empty, Vector.empty)
}

case class WordsL(
smalls: List[String],
middles: List[String],
larges: List[String]
) {
def +(word: String): WordsL = {
if (word.length <= 3)
copy(smalls = smalls :+ word)
else if (word.length > 7)
copy(larges = larges :+ word)
else
copy(middles = middles :+ word)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsL {
val empty = WordsL(List.empty, List.empty, List.empty)
}

case class WordsVP(
smalls: Vector[String],
middles: Vector[String],
larges: Vector[String]
) {
def +(word: String): WordsVP = {
if (word.length <= 3)
copy(smalls = word +: smalls)
else if (word.length > 7)
copy(larges = word +: larges)
else
copy(middles = word +: middles)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsVP {
val empty = WordsVP(Vector.empty, Vector.empty, Vector.empty)
}

case class WordsLP(
smalls: List[String],
middles: List[String],
larges: List[String]
) {
def +(word: String): WordsLP = {
if (word.length <= 3)
copy(smalls = word :: smalls)
else if (word.length > 7)
copy(larges = word :: larges)
else
copy(middles = word :: middles)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsLP {
val empty = WordsLP(List.empty, List.empty, List.empty)
}

case class WordsSV(
smalls: Seq[String],
middles: Seq[String],
larges: Seq[String]
) {
def +(word: String): WordsSV = {
if (word.length <= 3)
copy(smalls = smalls :+ word)
else if (word.length > 7)
copy(larges = larges :+ word)
else
copy(middles = middles :+ word)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsSV {
val empty = WordsSV(Vector.empty, Vector.empty, Vector.empty)
}

case class WordsSVV(
smalls: Seq[String],
middles: Seq[String],
larges: Seq[String]
) {
def +(word: String): WordsSVV = {
if (word.length <= 3)
copy(smalls = smalls.toVector :+ word)
else if (word.length > 7)
copy(larges = larges.toVector :+ word)
else
copy(middles = middles.toVector :+ word)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsSVV {
val empty = WordsSVV(Vector.empty, Vector.empty, Vector.empty)
}

case class WordsS(
smalls: Seq[String],
middles: Seq[String],
larges: Seq[String]
) {
def +(word: String): WordsS = {
if (word.length <= 3)
copy(smalls = smalls :+ word)
else if (word.length > 7)
copy(larges = larges :+ word)
else
copy(middles = middles :+ word)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsS {
val empty = WordsS(Seq.empty, Seq.empty, Seq.empty)
}

case class WordsSP(
smalls: Seq[String],
middles: Seq[String],
larges: Seq[String]
) {
def +(word: String): WordsSP = {
if (word.length <= 3)
copy(smalls = word +: smalls)
else if (word.length > 7)
copy(larges = word +: larges)
else
copy(middles = word +: middles)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsSP {
val empty = WordsSP(Seq.empty, Seq.empty, Seq.empty)
}

case class WordsIV(
smalls: IndexedSeq[String],
middles: IndexedSeq[String],
larges: IndexedSeq[String]
) {
def +(word: String): WordsIV = {
if (word.length <= 3)
copy(smalls = smalls :+ word)
else if (word.length > 7)
copy(larges = larges :+ word)
else
copy(middles = middles :+ word)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsIV {
val empty = WordsIV(Vector.empty, Vector.empty, Vector.empty)
}

case class WordsIA(
smalls: IndexedSeq[String],
middles: IndexedSeq[String],
larges: IndexedSeq[String]
) {
def +(word: String): WordsIA = {
if (word.length <= 3)
copy(smalls = smalls :+ word)
else if (word.length > 7)
copy(larges = larges :+ word)
else
copy(middles = middles :+ word)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsIA {
val empty = WordsIA(Array[String](), Array[String](), Array[String]())
}

case class WordsI(
smalls: IndexedSeq[String],
middles: IndexedSeq[String],
larges: IndexedSeq[String]
) {
def +(word: String): WordsI = {
if (word.length <= 3)
copy(smalls = smalls :+ word)
else if (word.length > 7)
copy(larges = larges :+ word)
else
copy(middles = middles :+ word)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsI {
val empty = WordsI(IndexedSeq.empty, IndexedSeq.empty, IndexedSeq.empty)
}

case class WordsIP(
smalls: IndexedSeq[String],
middles: IndexedSeq[String],
larges: IndexedSeq[String]
) {
def +(word: String): WordsIP = {
if (word.length <= 3)
copy(smalls = word +: smalls)
else if (word.length > 7)
copy(larges = word +: larges)
else
copy(middles = word +: middles)
}

override def toString() = {
s"small:${smalls.length}, middle:${middles.length}, large:${larges.length}"
}
}

object WordsIP {
val empty = WordsIP(IndexedSeq.empty, IndexedSeq.empty, IndexedSeq.empty)
}

class WordsBuilderV {
private val smalls = new ArrayBuffer[String]
private val middles = new ArrayBuffer[String]
private val larges = new ArrayBuffer[String]

def +(word: String): WordsBuilderV = {
if (word.length <= 3)
smalls += word
else if (word.length > 7)
larges += word
else
middles += word
this
}

def toWords = WordsV(smalls.toVector, middles.toVector, larges.toVector)
}

class WordsBuilderL {
private val smalls = new ArrayBuffer[String]
private val middles = new ArrayBuffer[String]
private val larges = new ArrayBuffer[String]

def +(word: String): WordsBuilderL = {
if (word.length <= 3)
smalls += word
else if (word.length > 7)
larges += word
else
middles += word
this
}

def toWords = WordsL(smalls.toList, middles.toList, larges.toList)
}

class WordsBuilderS {
private val smalls = new ArrayBuffer[String]
private val middles = new ArrayBuffer[String]
private val larges = new ArrayBuffer[String]

def +(word: String): WordsBuilderS = {
if (word.length <= 3)
smalls += word
else if (word.length > 7)
larges += word
else
middles += word
this
}

def toWords = WordsS(smalls, middles, larges)
}

def go[T](label: String)(body: => T) {
val start = System.currentTimeMillis
try {
val r = body
val result = r match {
case x: Try[_] => x.toString
case x: Task[_] => x.toString
case x => x.toString
}
val end = System.currentTimeMillis
println(s"$label (${end - start}): ${result}")
} catch {
case e: Throwable =>
val end = System.currentTimeMillis
println(s"$label (${end - start}): ${e}")
}
}

import scalax.io.{Resource, Codec}

def main(args: Array[String]) {
import Words.getTokens
implicit val codec = Codec.UTF8
val filename = "PrincesOfMars.txt"
val inputs = Resource.fromFile(filename).lines().
map(getTokens).
foldLeft(new ArrayBuffer[String])(_ ++ _)
go("WordsJ") {
inputs.foldLeft(new WordsJ)(_ + _)
}
go("WordsV") {
inputs.foldLeft(WordsV.empty)(_ + _)
}
go("WordsVP") {
inputs.foldLeft(WordsVP.empty)(_ + _)
}
go("WordsL") {
inputs.foldLeft(WordsL.empty)(_ + _)
}
go("WordsLP") {
inputs.foldLeft(WordsLP.empty)(_ + _)
}
go("WordsS") {
inputs.foldLeft(WordsS.empty)(_ + _)
}
go("WordsSP") {
inputs.foldLeft(WordsSP.empty)(_ + _)
}
go("WordsSV") {
inputs.foldLeft(WordsSV.empty)(_ + _)
}
go("WordsSVV") {
inputs.foldLeft(WordsSVV.empty)(_ + _)
}
go("WordsI") {
inputs.foldLeft(WordsI.empty)(_ + _)
}
go("WordsIP") {
inputs.foldLeft(WordsIP.empty)(_ + _)
}
go("WordsIV") {
inputs.foldLeft(WordsIV.empty)(_ + _)
}
go("WordsIA") {
inputs.foldLeft(WordsIA.empty)(_ + _)
}
go("WordsBuilderV") {
inputs.foldLeft(new WordsBuilderV)(_ + _).toWords
}
go("WordsBuilderL") {
inputs.foldLeft(new WordsBuilderL)(_ + _).toWords
}
go("WordsBuilderS") {
inputs.foldLeft(new WordsBuilderS)(_ + _).toWords
}
}
}

諸元

  • Mac OS 10.7.5 (2.6 GHz Intel Core i7)
  • Java 1.7.0_75
  • Scala 2.11.6

クラウドアプリケーション・モデリング考

$
0
0

8月7日に「匠の夏まつり ~モデリングの彼方に未来を見た~」のイベントが行われましたが、この中でパネルディスカッションに参加させていただきました。パネルディスカッションでご一緒させていただいた萩本さん、平鍋さん、高崎さん、会場の皆さん、どうもありがとうございました。

パネルディスカッションがよいきっかけとなって、クラウドアプリケーション開発におけるモデリングについての方向性について腰を落として考えることができました。このところFunctional Reactive Programmingを追いかけていましたが、ちょうどモデリングとの接続を考えられる材料が揃ってきているタイミングでした。

パネルディスカッションの前後に考えたことをこれまでの活動の振り返りも含めてまとめてみました。

基本アプローチ

2008年頃からクラウドアプリケーション開発の手法について以下の3点を軸に検討を進めています。

  • クラウド・アプリケーションのアーキテクチャ
  • メタ・モデルと実装技術
  • モデル駆動開発

検討結果は以下にあげるスライドとブログ記事としてまとめていますが、基本的な考え方は現在も同じです。

ざっくりいうと:

  • クラウド・アプリケーションのバックエンドのアーキテクチャはメッセージ方式になる。
  • クラウド・アプリケーションのモデリングではOOADの構造モデル、状態機械モデルを踏襲。
  • 協調モデルの主力モデルとしてメッセージフローまたはデータフローを採用。
  • OOADの構造モデル、状態機械モデルはモデル駆動開発による自動生成。
  • メッセージフローまたはデータフローはDSLによる直接実行方式が有効の可能性が高い。

という方針&仮説です。「OOADの構造モデル、状態機械モデルはモデル駆動開発による自動生成」についてはSimpleModeler、「メッセージフローまたはデータフローはDSLによる直接実行方式」についてはg3 frameworkで試作を行っていました。

ここまでが2010年から2012年中盤にかけての状況です。

ブログ

2012年以降のアプローチ

2012年の後半にEverforthに参画してApparel Cloudを始めとするCloud Service Platformの開発に注力しています。

前述の論点の中で以下の3点についてはApparel Cloudの開発に直接取り入れています。

  • クラウド・アプリケーションのバックエンドのアーキテクチャはメッセージ方式になる。
  • クラウド・アプリケーションのモデリングではOOADの構造モデル、状態機械モデルを踏襲。
  • OOADの構造モデル、状態機械モデルはモデル駆動開発による自動生成。

「メッセージフローまたはデータフローはDSLによる直接実行方式が有効の可能性が高い」については当初はg3 frameworkという独自DSLによる実装を考えていたのですが、Object-Functional Programmingの核となる技術であるモナドがパイプライン的なセマンティクスを持ち、データフローの記述にも使用できそうという感触を得られたため、ScalazベースのMonadic Programmingを追求して技術的な接点を探るという方針に変更しました。

2012年以降ブログの話題がScalaz中心になるのはこのためです。

その後、まさにドンピシャの技術であるscalaz-streamが登場したので、scalaz-streamをApparel Cloudの構築技術として採用し、「メッセージフローまたはデータフローはDSLによる直接実行方式が有効の可能性が高い」の可能性を実システム構築に適用しながら探っている状況です。

今後のアプローチ

現在懸案として残っている項目は以下のものになります。

  • 協調モデルの主力モデルとしてメッセージフローまたはデータフローを採用。

前述したようにメッセージングのDSLとしてはscalaz-streamをベースにノウハウを積み重ねている状況なので、この部分との連続性をみながらモデリングでの取り扱いを考えていく予定です。

また、ストリーミング指向のアーキテクチャ&プログラミングモデルとしては以下のような技術が登場しています。

このような新技術の状況をみながら実装技術の選択を行っていく予定です。

参考: スライド

パネルディスカッションでのポジション宣言的なスライドとして以下のものを作成しました。

この中で6ページ目の「Cloud時代のモデリング」が今回パネルディスカッションのテーマに合わせて新規に作成したものです。

このスライドで言いたいことは、伝統的なスクラッチ開発とくらべてクラウドアプリケーションではプログラミング量が大幅に減るので、要件定義やその上流であるビジネスモデリングが重要になる、ということです。

  • アプリケーションの大きな部分はCloud Service Platformが実現
  • モデル駆動開発によってドメインモデル(静的構造)の大部分は自動生成される
  • Scalaで実現されているDSL指向のOFP(Object-Functional Programming)は記述の抽象度が高いので設計レベルのモデリングは不要
  • Scalaの開発効率は高いのでプログラミングの比重は下がる
補足:Featureモデル

後日スライドのキーワードページに入れておくべきキーワードとしてFeatureモデルがあることに気付いたので、上記のスライドには追加しておきました。

スライドの想定する世界では、クラウドアプリケーションはクラウドサービスプラットフォーム上で動作するため、クラウドサービスプラットフォームが提供している機能とクラウドアプリケーションの機能の差分をモデル化し、このモデルを元に実際に開発する所、カスタマイズで済ませる所などを具体化していく必要があります。この目的にはSoftware ProductlineのFeatureモデルが有効ではないかと考えています。

[scalaz]Task - 並列処理

$
0
0

先日の「Reactive System Meetup in 西新宿」で「Scalaz-StreamによるFunctional Reactive Programming」のスライドを作るにあたってscalazのTaskについて調べなおしてみたのですが、Taskの実用性について再確認することができました。

色々と切り口がありますが、その中で並列性能が今回のテーマです。

準備

準備として以下のものを用意します。

implicit val scheduler = new java.util.concurrent.ForkJoinPool(1000)

def go[T](msg: String)(body: => T): Unit = {
System.gc
val ts = System.currentTimeMillis
val r = body
println(s"$msg(${System.currentTimeMillis - ts}): $r")
}

def fa(i: Int): Task[Int] = Task {
Thread.sleep(i)
i
}

スレッド実行コンテキスト(ExecutorService)にはForkJoinPoolを使用します。

ForkJoinPoolは分割統治(devide and conquer)により並行処理を再帰的(recursive)に構成する処理向けのスレッドスケジュールを行います。ざっくりいうと並列で何らかの計算を行う処理全般に向くスケジュールといえるのではないかと思います。IO処理の場合も復数のIO発行後に同期待ち合わせをするケースでは同様にForkJoinPoolが有効と思われます。

今回の性能検証では1000並列させたいのでパラメタで指定しています。

goメソッドは性能測定用のユーティリティです。

fa関数は性能測定対象の関数です。関数faは指定されたマイクロ秒間ウェイトして指定されたマイクロ秒を返す関数をTask化したものです。

問題

以下は性能検証の課題のプログラムです。fa関数の呼出しを1000回逐次型で行います。

Vector.fill(1000)(fa(1000)).map(_.run).sum

関数faは指定されたマイクロ秒間ウェイトして指定されたマイクロ秒を返す関数をTask化したものです。これを1000回繰り返したものを合計したものを計算します。

パラメタでは1000マイクロ秒=1秒を指定しているので1000回繰り返すと16.7分程かかる処理になります。

性能検証

Taskの並列処理を行う以下の関数について性能測定を行いました。

  • Task/gatherUnordered
  • Task/reduceUnordered
  • Nondeterminism/gather
  • Nondeterminism/gatherUnordered
  • Nondeterminism/reduceUnordered
  • Nondeterminism/aggregate
  • Nondeterminism/aggregateCommutative

以下ではそれぞれの関数について説明します。

gatherUnordered(Task)

TaskのgatherUnordered関数の性能測定対象プログラムは以下になります。

def apply_gather_unordered_task: Int = {
val xs = Vector.fill(1000)(fa(1000))
val t = Task.gatherUnordered(xs).map(_.sum)
t.run
}

TaskのgatherUnordered関数はNondeterminismのgatherUnordered関数とよく似ていますが、並列実行しているTaskの1つが例外になった時に処理全体を止める機能を持っている点が異なります。デフォルトではfalseになっているので、ここではこの機能は使っていません。

unorderedつまり結果順序は元の順序を維持していない(計算が終わった順の可能性が高い)ことは待ち合わせ処理を最適化できる可能性があるので実行性能的には好材料です。一方、アルゴリズム的には順序が保たれていることが必要な場合があるので、その場合はgatherUnorderedは使用することは難しくなります。

可換モノイド(commutative monoid)は演算の順序が変わっても結果が同じになることが保証されているので、並列処理結果が可換モノイドであり、並列処理結果の結合処理が可換モノイドの演算である場合は、並列処理結果が元の順序を保持している必要はありません。つまりgatherUnorderedを使っても全く問題ないわけです。

Intは「+」演算に対して可換モノイドなので、並列処理結果の総和を計算するという結合処理向けにgatherUnorderedを使うことができます。

reduceUnordered(Task)

TaskのreduceUnordered関数の性能測定対象プログラムは以下になります。

def apply_reduce_unordered_task: Int = {
val xs = Vector.fill(1000)(fa(1000))
val t = Task.reduceUnordered(xs)(Reducer.identityReducer)
t.run
}

並列処理を行った後で、復数の処理結果をまとめる場合にはreduce機能を使用すると意図が分かりやすいですし、共通処理内での最適化も期待できます。

TaskのreduceUnorderedはscalazのReducerを使って、並列処理結果のreduce処理を行う関数です。NondeterminismのreduceUnordered関数とよく似ていますが、並列実行しているTaskの1つが例外になった時に処理全体を止める機能を持っている点が異なります。デフォルトではfalseになっているので、ここではこの機能は使っていません。

並列処理の結果得られるデータはIntで、Intは可換モノイドですから、Monoidの性質を使ってreduce処理を行うことができます。そこで、ReducerとしてidentityReducer(処理結果のMonoidをそのまま使ってreduce処理を行う)を指定しています。

gather(Nondeterminism)

Nondeterminismのgather関数の性能測定対象プログラムは以下になります。

def apply_gather: Int = {
val xs = Vector.fill(1000)(fa(1000))
val t = Nondeterminism[Task].gather(xs).map(_.sum)
t.run
}

「Nondeterminism[Task]」は型クラスNondeterminismのTask向け型インスタンスの意味です。つまりTaskはNondeterminismでもあるので、Nondeterminismのgather関数を実行することができます。

gather関数はNondeterminismデータシーケンスに対してそれぞれの要素を並列処理し、シーケンスの順序を維持した結果を計算します。

上記ではその結果得られたIntシーケンスをsum関数で合算しています。

gatherUnordered(Nondeterminism)

NondeterminismのgatherUnordered関数の性能測定対象プログラムは以下になります。

def apply_gather_unordered: Int = {
val xs = Vector.fill(1000)(fa(1000))
val t = Nondeterminism[Task].gatherUnordered(xs).map(_.sum)
t.run
}

TaskのgatherUnordered関数と同様に指定された並列処理の順序を保持せず、処理結果を順不同でシーケンスとして返します。

結果としてIntシーケンスが返ってきますが、Intは可換モノイドの性質を持つため順不同で返ってきてもsum関数で合算して問題ありません。

reduceUnordered(Nondeterminism)

NondeterminismのreduceUnordered関数の性能測定対象プログラムは以下になります。

def apply_reduce_unordered: Int = {
val xs = Vector.fill(1000)(fa(1000))
val t = Nondeterminism[Task].reduceUnordered(xs)(Reducer.identityReducer)
t.run
}

型クラスNondeterminismのreduceUnordered関数を使って並列実行と実行結果のreduce処理を行います。

TaskのreduceUnorderedの場合と同じくReducerとしてidentityReducerを指定しています。

aggregate(Nondeterminism)

Nondeterminismのaggregate関数の性能測定対象プログラムは以下になります。

def apply_aggregate: Int = {
val xs = Vector.fill(1000)(fa(1000))
val t = Nondeterminism[Task].aggregate(xs)
t.run
}

aggregate関数はreduceUnordered関数と並列実行後にreduce処理を行う点では同じ系統の計算を行いますが、以下の点が異なります。

  • aggregate関数はMonoidを前提としておりMonoidの性質を利用してreduce処理を行う。それに対してreduceUnordered関数はreduce処理を行うアルゴリズムをReducerとして指定する。
  • Monoidは可換モノイドとは限らないので並列計算の順序が保存されている必要がある。このためaggregate関数は並列実行順序を保存する処理を行っている。それに対してreduceUnordered関数は並列実行順序を保存する処理を行っていない。

aggregateは(可換モノイドでないかもしれない)Monoidを処理対象にしているため、並列計算の順序を保存する処理が必要になるので、その分性能的には不利になります。

この問題に対する改良策が次のaggregateCommutative関数です。

aggregateCommutative(Nondeterminism)

NondeterminismのaggregateCommutative関数の性能測定対象プログラムは以下になります。

def apply_aggregate_commutative: Int = {
val xs = Vector.fill(1000)(fa(1000))
val t = Nondeterminism[Task].aggregateCommutative(xs)
t.run
}

aggregateCommutative関数はaggreagte関数と同様にMonoidを処理対象としていますが、指定されたMonoidが可換モノイドであるという前提で計算を行います。

可換モノイドであるということは、演算の評価順序が異なっても同じ結果になるということなので、指定された並列計算シーケンスの実行順序を保存する処理は不要です。並列計算シーケンスの実行順序を保存する処理が不要になると実行性能的に有利です。

可換モノイドとモノイドの違い(つまり可換性)は並列処理で重要ですが、現在のところScalazには可換モノイドを表現する型クラスは用意されていないので、型を使ってエラーチェックを行ったり(例:可換性前提の関数で可換性なしのデータを使用できないようにチェック)、最適化(例:可換性の有無で実行順序の保存処理の有無を切り替える)を行うようなことはできません。

aggregateCommutative関数で行っているように、使用者側が違いを意識して使う形になります。

性能

クラスメソッド性能(ms)順序保存集約機能キャンセル機能
TaskgatherUnordered2139--
TaskreduceUnordered1869-
Nondeterminismgather1082--
NondeterminismgatherUnordered1048 ---
NondeterminismreduceUnordered1718--
Nondeterminismaggregate1049-
NondeterminismaggregateCommutative1040--

評価

表では各関数を以下の性質の有無で分類しました。

  • 順序保存
  • 集約機能
  • キャンセル機能

それぞれの性質毎に性能特性を考えます。

順序保存

評価順序保存をすると順序保存なしより少し遅くなります。

可能であれば順序保存なしを選ぶのがよいでしょう。

順序保存なしを選べるかどうかは、各並列処理の計算結果がreduce処理の演算に対して可換モノイド(commutative monoid)であるかどうかで判定できます。

整数の加算や積算は典型的な可換モノイドなので、最終的な計算結果が合算処理(全要素の加算)の場合は「順序保存なし」を選べるわけです。

集約機能

並列実行関数に集約機能が包含されていると、各並列処理の結果を使って直接集約処理を行うことができるので効率的です。一度、並列実行結果をリスト上に保存して、そのリストに対して集約するより、集約対象のデータ(例:整数値)を各並列処理の完了後に直接更新して行く方がオーバーヘッドは少なくなります。

集約機能を提供しているaggregate関数とaggregateCommutative関数が、それぞれ対応するgather関数、gatherUnorderedと比較して若干速いのはそのためだと思われます。

Monoidは集約対象として優れた性質を持っているので、集約機能の対象として使用するのがFunctional Programming(以下FP)のパターンになっています。aggregate関数とaggregateCommutative関数はこのパターンに則って、集約機能の対象としてMonoidを使用します。

一方Reducerを使った集約は大きなオーバーヘッドがあるようなので、積極的に利用する価値があるという感じではないようです。

NondeterminismのgatherUnordered関数とreduceUnordered関数の比較ではreduceUnordered関数がかなり遅くなっています。この場合、Reducer経由でMonoidの集約を行っているので、Monoidを直接集約するよりオーバーヘッドがあるのが原因と思われます。

一方TaskのgatherUnordered関数とreduceUnordered関数の場合、reduceUnordered関数の方が速いので、こちらの場合はreduceUnordered関数の利用は有力な選択肢です。キャンセル機能が重たいためにReducer機能の遅さが隠れてしまうのかもしれません。

キャンセル機能

キャンセル機能はTaskのgatherUnordered関数、reduceUnordered関数が提供しています。

NondeterminismのgatherUnordered関数、reduceUnordered関数と比較すると相当遅くなっています。キャンセル機能が必要でない場合は使わない方がよいでしょう。

まとめ

性能測定の結果、並列処理結果を可換モノイドで受け取り集約処理を行うaggregateCommutative関数が一番高速であることが分かりました。

並列処理実行後の集約処理までを一体化して処理の最適化ができるのは可換モノイドの効果です。

並列処理を設計する際には、各並列処理の結果を可換モノイドで返すような形に持ち込むことができるのかというのが一つの論点になると思います。

また可換モノイドにできない場合も、モノイドにできれば汎用関数で集約まで行うことができるので、並列処理を記述する上で大きな助けになります。

Scalazではモノイドを記述する型クラスMonoidが用意されています。MonoidはScalazによるFPで中心的な役割を担う型クラスの一つですが、並列処理においても重要な役割を担うことが確認できました。

Scalazでは可換モノイドを記述する型クラスはまだ用意されていないので、Monoidで代用することになります。aggregateCommutative関数のように引数の型としてはMonoidを使い、暗黙的に可換モノイドを前提とするような使い方になると思います。

メニーコアによる並列計算が本格化するのはもう少し先になると思いますが、その際のベースとなる要素技術はすでに実用技術として利用可能になっていることが確認できました。FPが並列計算に向いているという期待の大きな部分は、モノイドや可換モノイドのような数学的な概念をプログラミング言語で直接使用できる点にあります。Scala&Scalazはこれを実用プログラミングで可能にしているのが大きな美点といえるかと思います。

諸元

  • Mac OS 10.7.5 (2.6 GHz Intel Core i7)
  • Java 1.7.0_75
  • Scala 2.11.6
Viewing all 149 articles
Browse latest View live