ラベル scalaz-stream の投稿を表示しています。 すべての投稿を表示
ラベル scalaz-stream の投稿を表示しています。 すべての投稿を表示

2016年11月30日水曜日

よこはまクラウド勉強会: OFP & OFAD Deep Dive with Reactive Streams

Qcon Tokyo 2016で「オブジェクト‐関数型プログラミングからオブジェクト‐関数型分析設計へ~クラウド時代のモデリングを考える」と題してOFAD(Object-Functional Analysis and Design)についてお話させていただきました。

テーマはOFADですが、OFADの前提としてモダンなFP(Functional Programming)を前提としたOFP(Object-Functional Programming)の知識、さらに基本的なOOADの知識が必要なので、非常に広範囲の内容を圧縮して詰め込んだ形になってしまいました。

そこで、もう少し時間を取って説明することができればよいと思っていたのですが、横浜クラウド勉強会 で機会を頂くことができました。

QCon Tokyoでは50分に収めましたが、こちらの方は少し脱線しながら2時間程度のセッションになりました。その後、Reactive Streamsのハンズオンという構成です。

Reactive Streamsの立ち位置

QCon向けの資料をまとめていて感じたのはReactive StreamsがOFP(Object-Functional Programming)のキーテクノロジーではないか、ということです。

Reactive Streamsは以下の2つの方向性があると考えています。

  • 即応性、スケーラビリティのためのメカニズム
  • FP(Functional Programming)の適用範囲を広げる

ここでのFPは純粋関数型プログラミングを意図しています。

前者は今後のクラウドアプリケーションの方向としては当然の方向性です。この方向性を追求する場合には、FPの純粋性を犠牲にしても実行時性能やバックプレッシャーなどのプロトコル拡張を追求することになります。

一方、後者はFPの純粋性は守りながら、FPを外部入出力や大規模データ処理、ストリーミング処理に適用することを目指すものです。

Reactive Streamsは即応性やスケーラビリティという点で注目されていますが、ボクの関心事はそのことよりもFPの適用範囲を広げることに大きく寄与するのではないかという点です。ボクは現時点では後者を重視しているので、(Akka Streamsではなく)scalazと組み合わせて使えるscalaz-streamを愛用しています。

セッションでもお話したOFPのコツはつまるところOOP(Object-Oriented Programming)とFPを適材適所で、ということですが整理すると以下の方針になります。

  • アルゴリズム系の副作用を伴わない処理はFP
  • コンポーネントのファサード部(API/SPI)はOOP
  • OOADのモデリングの実現部はOOP
  • その他はできる限りFP

「できる限りFP」にしたい理由は以下のものです。

  • バグの発生率が圧倒的に少ない。
  • アルゴリズムを効率よく記述できる。
  • 並列/並行/分散処理時代への助走。
  • 将来の証明プログラミングへの備え。

現時点での最大の魅力は「バグの発生率が圧倒的に少ない」がリファクタリングに大きく寄与する点です。長期間持続的に開発を続けるシステムはこの理由だけでOFPを採用する価値があると思います。そして、長期的には「並列/並行/分散処理時代への助走」、「将来の証明プログラミングへの備え」という観点から必須のプログラミング・スタイルになるとすると、先取りして取り入れておきたいところです。

ここで問題となるのは「その他はできる限りFP」です。

FPには外部入出力処理や状態を持った処理の記述が大変という問題があります。スライドに書いたように、モナドの登場で外部入出力処理や状態を持った処理の記述が「困難」から「可能」になったのは大きな前進ですが、OFPの観点からはかならずしも「便利」とは言えないと思います。またScalaの特殊事情として文法的な制約でHaskell程簡明には書けないということもあります。

「できる限りFP」とはいえ便利でないものは使わなくなるのが道理で、対策をとらないと結局OOPの部分が大きく残ってしまう事になってしまいます。

Reactive Streamsがキーテクノロジーではないか、という期待はまさにこの問題の解消に大きく寄与するのではないかという点です。

scalaz-streamが提供するProcessモナドも一種のモナドですが、概念的に分かりやすいのとリソース管理やフロー制御という伝統的なOOPによる外部入出力でも難しかった問題が解決されています。実際に製品開発で使ってみてこの便利さを体感しました。

このような経験からReactive Streamを開発の基盤とすることで、OFPの多くの部分をFP側に倒すことが可能になるのではないかと期待しているわけです。

Reactive Streamsハンズオン

そんな思いもあり、セッションの後半はReactive Streamsのハンズオンにしました。以下にソースコードがあります。

このハンズオンのソースはセッションの朝に急ごしらえで作ったものなので、動作確認などはあまりできておらず、当日会場で調整しながら使用したものです。その点はご留意下さい。

ここでの趣旨は、(即応性、スケーラビリティではなく)FP成分を重視したReactive Streamsについて、プログラミングレベルでのイメージをつかんでいただくことです。

以下簡単に説明します。

ステップ1: Hello World

ステップ1はscalaz-streamのHello World的なプログラムです。scalaz-streamの最小限の使い方を体験することが目的です。

mainメソッドの第1引数に入力ファイル名を、第2引数に出力ファイル名を指定すると入力ファイルを出力ファイルに複写するプログラムです。

ハンズオンの問題は以下になります。

package handson.reactive

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

object Step1 {
  def main(args: Array[String]) {
    val in = args(0)
    val out = args(1)
    val t = converter(in, out)
    t.run
  }

  def converter(in: String, out: String): Task[Unit] = ???

  def converterProcess(in: String, out: String): Process[Task, Unit] = ???
}

converter関数とconverterProcess関数を実装します。

実装例

ステップ1の実装例です。

package answer.reactive

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

object Step1 {
  def main(args: Array[String]) {
    val in = args(0)
    val out = args(1)
    val t = converter(in, out)
    t.run
  }

  def converter(in: String, out: String): Task[Unit] =
    converterProcess(in, out).run

  def converterProcess(in: String, out: String): Process[Task, Unit] =
    io.linesR(in).pipe(text.utf8Encode).to(io.fileChunkW(out))
}

converter関数はconverterProcess関数から返されたProcessモナドをrunしてTaskモナドにします。

converterProcess関数はscalaz-streamのパイプラインをProcessモナドという形で構築して返します。

ステップ2: Monadic API

ステップ1はscalaz-streamの最小限の使い方でした。

ステップ2ではscalaz-streamのパイプラインが、通常のMonadic APIとして使えることを体験します。ここでいうMonadic APIはJavaでいう所のStream APIで、FunctorやMonadによるパイプラインをベースにしたAPIです。(Streamは色々な意味付けがされていてミスリーディングな面もあるのでここでは本ブログではMonadic APIと呼んでいます。)

package handson.reactive

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

object Step2 {
  def main(args: Array[String]) {
    val in = args(0)
    val out = args(1)
    val player = args(2)
    val t = converter(in, out, player)
    t.run
  }

  def converter(in: String, out: String, player: String): Task[Unit] =
    converterProcess(in, out, player).run

  def converterProcess(in: String, out: String, player: String): Process[Task, Unit] =
    io.linesR(in).
      map(toRecord).filter(isPlayer(player)).map(toYear).map(_ + "\n").
      pipe(text.utf8Encode).to(io.fileChunkW(out))

  def toRecord(s: String): Vector[String] = s.split(",").toVector

  def isPlayer(player: String)(record: Vector[String]): Boolean = ???

  def toYear(record: Vector[String]): String = ???
}

ステップ1との違いは以下の3つの関数をmapコンビネータ、filterコンビネータでパイプラインに組み込んでいることです。

  • toRecord関数
  • isPlayer関数
  • toYear関数

これらの関数が通常の関数であること、そして簡単にProcessモナドのパイプラインに組み込むことができる点を体験するのが目的です。

実装例

ステップ2の実装例です。

package answer.reactive

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

object Step2 {
  def main(args: Array[String]) {
    val in = args(0)
    val out = args(1)
    val player = args(2)
    val t = converter(in, out, player)
    t.run
  }

  def converter(in: String, out: String, player: String): Task[Unit] =
    converterProcess(in, out, player).run

  def converterProcess(in: String, out: String, player: String): Process[Task, Unit] =
    io.linesR(in).
      map(toRecord).filter(isPlayer(player)).map(toYear).map(_ + "\n").
      pipe(text.utf8Encode).to(io.fileChunkW(out))

  def toRecord(s: String): Vector[String] = s.split(",").toVector

  def isPlayer(player: String)(record: Vector[String]): Boolean =
    record.lift(0) == Some(player)

  def toYear(record: Vector[String]): String =
    record.lift(1) getOrElse "Unknown"
}

toRecord関数、isPlayer関数、toYear関数はごく普通の関数です。これらを簡単にProcessモナドのパイプラインに組み込んで動作することが確認できました。

ステップ3: フロー制御

ステップ3は一種のフロー制御であるチャンク化の体験です。

通常のMonadic APIは構造上フロー制御を行うことができませんが、Processモナドではpipeコンビネータなどの仕組みによってフロー制御を可能にしています。

ここが通常のMonadic APIに対するReactive Steramsの優位点で、外部入出力を効率的に処理することを可能にする拡張となっています。

package handson.reactive

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

object Step3 {
  def main(args: Array[String]) {
    val in = args(0)
    val out = args(1)
    val t = converter(in, out)
    t.run
  }

  def converter(in: String, out: String): Task[Unit] =
    converterProcess(in, out).run

  def converterProcess(in: String, out: String): Process[Task, Unit] =
    io.linesR(in).
      map(toRecord).
      chunk(1000).map(groupByYear).pipe(process1.unchunk).
      map(toYear).
      pipe(text.utf8Encode).to(io.fileChunkW(out))

  def toRecord(s: String): Vector[String] = s.split(",").toVector

  def toYear(record: Vector[String]): String = ???

  def groupByYear(records: Vector[Vector[String]]): Vector[Vector[String]] = ???
}

注目するポイントはパイプライン中の「chunk(1000).map(groupByYear).pipe(process1.unchunk)」の部分です。パイプライン中のchunk関数とpipeコンビネータで適用したprocess.unchunk関数によって、パイプラインを流れるデータを1000個単位でチャンク化しています。

mapコンビネータで指定されているgroupByYear関数はチャンク化したデータに対する処理を行うようになっています。

チャンク化は入出力処理で性能向上するための必須処理なので、これを自動的に行なってくれる部品が提供されていることは生産性に大きく寄与します。

実装例

ステップ3の実装例です。

package answer.reactive

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

object Step3 {
  def main(args: Array[String]) {
    val in = args(0)
    val out = args(1)
    val t = converter(in, out)
    t.run
  }

  def converter(in: String, out: String): Task[Unit] =
    converterProcess(in, out).run

  def converterProcess(in: String, out: String): Process[Task, Unit] =
    io.linesR(in).
      map(toRecord).
      chunk(1000).map(groupByYear).pipe(process1.unchunk).
      map(toYear).
      pipe(text.utf8Encode).to(io.fileChunkW(out))

  def toRecord(s: String): Vector[String] = s.split(",").toVector

  def toYear(record: Vector[String]): String =
    record.lift(1) getOrElse "Unknown"

  def groupByYear(records: Vector[Vector[String]]): Vector[Vector[String]] = {
    case class Z(years: Set[String] = Set.empty, result: Vector[Vector[String]] = Vector.empty) {
      def +(rhs: Vector[String]) =
        rhs.lift(1).fold(this) { year =>
          if (years.contains(year))
            this
          else
            Z(years + year, result :+ rhs)
        }
    }
    records.foldLeft(Z())(_ + _).result
  }
}

toYear関数、groupByYear関数を普通に実装するだけです。データをチャンク化する処理はscalaz-streamが提供している部品が自動的に行なってくれます。

フロー制御の例としては以下の記事が参考になると思います。

ステップ4: ストリーム

ステップ4はReactive Streamsでストリーム処理を行う課題です。

まず準備としてストリームを生成する部品EventProcessorを作ります。

package handson.reactive
  
import scalaz.concurrent.Task
import scalaz.stream._  

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

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

buildメソッドはscalaz-streamによるパイプラインを作って、これをバックグラウンドで起動します。

最後に呼んでいるstimulusメソッドはEventProcessorを使って入力ファイルの内容を1行づつ読み込み1秒のインターバルでストリームに送出します。

package handson.reactive

import scalaz._, Scalaz._  
import scalaz.concurrent.Task  
import scalaz.stream._
import scala.concurrent.Future  
import scala.concurrent.ExecutionContext.Implicits.global
import scalax.io._
import scalax.io.JavaConverters._
import java.io.File

object Step4 {
  def main(args: Array[String]) {
    val in = args(0)

    val stream = EventProcessor.eventStream
    build(stream)
    stimulus(in)
  }

  def build(stream: Process[Task, String]) {
    Future {
      val t = converterProcess(stream).to(io.printLines(System.out))
      t.run.run
    }  
  }  

  def converterProcess(source: Process[Task, String]): Process[Task, String] =
    source.map(toRecord).map(toYear)

  def toRecord(s: String): Vector[String] = s.split(",").toVector

  def toYear(record: Vector[String]): String = ???

  def stimulus(in: String) {
    val queue = EventProcessor.q
    val input = new File(in).asInput
    input.lines() foreach { line =>
      queue.enqueueOne(line).run
      Thread.sleep(1000)
    }
  }
}

scalaz-streamのパイプラインはconvertProcess関数で作成します。このパイプラインにはmapコンビネータでtoYear関数を接続しています。

本課題ではこのtoYear関数を実装します。

toYear関数は通常の関数です。この通常の関数をscalaz-streamのパイプラインに組み込むだけでFPによるストリーム処理が記述できるのを体験するのが本課題の趣旨です。

実装例

ステップ4の実装例です。

package answer.reactive

import scalaz._, Scalaz._  
import scalaz.concurrent.Task  
import scalaz.stream._
import scala.concurrent.Future  
import scala.concurrent.ExecutionContext.Implicits.global
import scalax.io._
import scalax.io.JavaConverters._
import java.io.File

object Step4 {
  def main(args: Array[String]) {
    val in = args(0)

    val stream = EventProcessor.eventStream
    build(stream)
    stimulus(in)
  }

  def build(stream: Process[Task, String]) {
    Future {
      val t = converterProcess(stream).to(io.printLines(System.out))
      t.run.run
    }  
  }  

  def converterProcess(source: Process[Task, String]): Process[Task, String] =
    source.map(toRecord).map(toYear)

  def toRecord(s: String): Vector[String] = s.split(",").toVector

  def toYear(record: Vector[String]): String =
    record.lift(1) getOrElse "Unknown"

  def stimulus(in: String) {
    val queue = EventProcessor.q
    val input = new File(in).asInput
    input.lines() foreach { line =>
      queue.enqueueOne(line).run
      Thread.sleep(1000)
    }
  }
}

ストリーム処理の例としては以下の記事が参考になると思います。

ステップ5: ストリーム&フロー制御

ステップ5はステップ4で作成したストリームに、自前のフロー制御を組み込むという課題です。

この課題はちょっと難しいので、時間が余った人向けを想定しています。

package handson.reactive

import scalaz._, Scalaz._  
import scalaz.concurrent.Task  
import scalaz.stream._
import scala.concurrent.Future  
import scala.concurrent.ExecutionContext.Implicits.global
import scalax.io._
import scalax.io.JavaConverters._
import java.io.File

object Step5 {
  def main(args: Array[String]) {
    val in = args(0)

    val stream = EventProcessor.eventStream
    build(stream)
    stimulus(in)
  }

  def build(stream: Process[Task, String]) {
    Future {
      val t = converterProcess(stream).to(io.printLines(System.out))
      t.run.run
    }  
  }  

  def converterProcess(source: Process[Task, String]): Process[Task, String] =
    source.map(toRecord).pipe(toYear)

  def toRecord(s: String): Vector[String] = s.split(",").toVector

  def toYear: Process1[Vector[String], String] = ???

  def stimulus(in: String) {
    val queue = EventProcessor.q
    val input = new File(in).asInput
    input.lines() foreach { line =>
      queue.enqueueOne(line).run
      Thread.sleep(1000)
    }
  }
}

ステップ4のようにscalaz-streamのパイプラインにmapコンビネータで関数を合成する場合は、通常の関数を使うことができますが、フロー制御を組み込むことはできません。

フロー制御を組み込むためにはProcessモナドを引数にして、Processモナドを返す関数を作成し、pipeコンビネータでパイプラインに組込みます。

Processモナドを直接操作するので少し難しいプログラミングになりますが、フロー制御を自分で操作できるので色々な用途に適用することができるようになります。

ストリーム処理&フロー制御の例としては前項と同様に以下の記事が参考になると思います。

この課題は時間切れでボクも実装例を作ることができませんでした。

まとめ

OFPにおけるReactive Streamsの位置付けについて、FPを重視する方向性から整理してみました。

その上でプログラミングレベルでReactive Streamsを把握するための仕掛けとしてハンズオンの資料を紹介しました。

もちろんOFPのReactive StreamsはOFADにも、少なからず影響するはずです。この点はまだ考えが整理できていませんが、ブログで継続して検討していきたいと思います。

2016年3月31日木曜日

State的状態機械2016

昨年Scalaにおける状態機械の実装戦略について以下の記事で検討しました。

OOP編はcase classで作った汎用の状態機械オブジェクトをOOP的な枠組みで利用しました。

そして、FP編ではOOP編で作った汎用の状態機械オブジェクトをそのままFP的な枠組みで利用できることを確認しました。

もちろん、これはOOPでもFPでも使用できる汎用の状態機械オブジェクトの作り方を確認するのが目的で、幸いどちらの目的にも使用できるものを作成することができました。

この記事で検討した方針で一年間プログラミングをしてきましたが、改良のポイントが出てきたので、これを反映した2016年版の状態機械オブジェクトを考えてみます。

ParseState

「状態+状態遷移を表現するオブジェクト兼代数的データ型」であるParseStateですが、2016年版では以下の点を変更しています。

  • イベントの入力用メソッドであるeventメソッドとendEventメソッドを統合
  • イベントをParseEventオブジェクトで表現
  • イベントに対する反応を新状態のParseStateと処理結果ParseResultの組を返すようにした

2015年版と機能的には変わらないのですが、Stateモナドの形に合わせることでストリーム処理で使いやすい構造になっています。

sealed trait ParseState {
  def apply(event: ParseEvent): (ParseState, Option[ParseResult])
}

case object InitState extends ParseState {
  def apply(event: ParseEvent): (ParseState, Option[ParseResult]) = event match {
    case CharEvent(',') => (InputState(Vector(""), ""), None)
    case CharEvent('\n') => (InitState, Some(ParseResult.empty))
    case CharEvent(c) => (InputState(Vector.empty, c.toString), None)
    case EndEvent => (InitState, None)
  }
}

case class InputState(
  fields: Vector[String],
  candidate: String
) extends ParseState {
  def apply(event: ParseEvent): (ParseState, Option[ParseResult]) = event match {
    case CharEvent(',') => (InputState(fields :+ candidate, ""), None)
    case CharEvent('\n') => (InitState, Some(RecordParseResult(fields :+ candidate)))
    case CharEvent(c) => (InputState(fields, candidate :+ c), None)
    case EndEvent => (InitState, Some(RecordParseResult(fields :+ candidate)))
  }
}
ParseEvent

2016年版では入力パラメタとして入力イベントを表現するParseEventを使用するようにしました。

2015年版では入力の終了をendEventメソッドでハンドリングしていましたが、2016年版ではEndEventイベントで扱うようになっています。

製品開発で実際に使用した経験で、この構造の方がストリーミング処理に適していることが分かりました。

具体的には、EndEventなどのイベントで状態をクリアな状態に戻すことができるので、scalaz-streamといったReactive-Streams的なパイプラインの部品として使用することが容易になります。

trait ParseEvent
case class CharEvent(c: Char) extends ParseEvent
case object EndEvent extends ParseEvent
ParseResult

計算結果を表現するParseResultは以下になります。

2015年版では、ParseStateが結果を管理していましたが、2016年版では結果をParseResultで外部に出力するようにしました。

ParseEventと同様に製品開発で実際に使用した経験で、Stateモナドとの相性がよいことが分かりました。

sealed trait ParseResult {
  def getRecord: Option[Vector[String]]
}
case object NoneParseResult extends ParseResult {
  def getRecord = None
}
case class RecordParseResult(record: Vector[String]) extends ParseResult {
  def getRecord = Some(record)
}

object ParseResult {
  val empty = RecordParseResult(Vector.empty)
}

Stateモナド

それでは、新版のParseStateをStateモナドで使ってみましょう。

2015年版に比べると結果を取り出す処理が若干複雑になっていますが、それほど大きな違いはありません。

Stateモナドで問題なく使えることが確認できました。

import scalaz._, Scalaz._

object ParserStateMonad {
  def action(event: ParseEvent) = State((s: ParseState) => s.apply(event))

  def parse(events: Seq[Char]): Seq[String] = {
    val xs = events.toStream.map(CharEvent) :+ EndEvent
    val t = xs.traverseS(action)
    val r = t.eval(InitState)
    r.flatten.flatMap(_.getRecord).flatten
  }

  def parseAnnotated(events: Seq[Char]): Seq[String] = {
    val xs: Stream[ParseEvent] = events.toStream.map(CharEvent) :+ EndEvent
    val t: State[ParseState, Stream[Option[ParseResult]]] = xs.traverseS(action)
    val r: Stream[Option[ParseResult]] = t.eval(InitState)
    r.flatten.flatMap(_.getRecord).flatten
  }
}

scalaz-stream版状態機械

次はscalaz-streamのProcessモナドです。

Scala的状態機械/FP編の「scalaz-stream版状態機械」と基本的には同じで、ParseStateの入出力が変更された部分に対応しています。

大きな違いとしては2015年版はParseState自身がストリームを流れてくる構造になっており、ParseStateから計算結果を取り出す処理になっていました。

それに対して、今回はParseStateによる処理結果であるParseResultがストリームを流れてくるので、これを取り出す構造になっています。

いずれにしても、OO版としても使える汎用の状態機械オブジェクトをscalaz-streamのProcessモナドで使えることが確認できました。

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

object ParserProcessMonadStateMonad {
  def fsm(state: ParseState): Process1[ParseEvent, ParseResult] = {
    Process.receive1 { evt: ParseEvent =>
      ParserStateMonad.action(evt).run(state) match {
        case (s, Some(r)) => Process.emit(r) fby fsm(s)
        case (s, None) => fsm(s)
      }
    }
  }

  def parse(xs: Seq[Char]): Seq[String] = {
    val events = xs.map(CharEvent) :+ EndEvent
    val source: Process0[ParseEvent] = Process.emitAll(events)
    val pipeline: Process0[ParseResult] = source.pipe(fsm(InitState))
    pipeline.map(_.getRecord).pipe(process1.stripNone).toList.last
  }

  import scalaz.concurrent.Task

  def parseTask(xs: Seq[Char]): Task[Seq[String]] = {
    val events = xs.map(CharEvent) :+ EndEvent
    val source: Process0[ParseEvent] = Process.emitAll(events)
    val pipeline: Process[Task, ParseResult] = source.pipe(fsm(InitState)).toSource
    pipeline.map(_.getRecord).pipe(process1.stripNone).runLastOr(Nil)
  }
}

まとめ

状態機械の実装方法について、1年間の実践経験を踏まえて改良版をまとめました。

状態機械の実装方法は色々な選択肢がありますが、OOPかつFPを同時に満たすものとなると選択肢が限られてきます。

OOP化はそれほど難しくないので、焦点はFP化ですが、StateモナドやProcessモナドで使用することを前提にした上で、自然な形の実装方法に落とし込めたと思います。

諸元

  • Java 1.7.0_75
  • Scala 2.11.7

2015年6月29日月曜日

[FP] Scalaへの道

先日、社内勉強会で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を使う方式を検討するのがよいでしょう。

2015年6月22日月曜日

[FP] パイプライン・プログラミング

関数型プログラミングとは何か?

この問は深遠すぎてとてもボクの手には負えませんが、実務的なプラクティスとしてはパイプライン・プログラミングとして考えると分かりやすいのではないかと思います。

そこで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

これらのパイプラインを適材適所で使い分ける、という切り口で関数型プログラミングを考えてみると面白いかもしれません。

2015年4月20日月曜日

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

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

2015年3月30日月曜日

Scala的状態機械/FP編

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

2015年3月2日月曜日

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

プロダクトの開発中にわりと難しい処理が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