ScalaでFutureとEitherを組み合わせたときに綺麗に書く方法

組み合わせるとはつまりアレのことを書くのですが、一応それほど前提知識無くても理解できるように書いてみます。さて、なぜいきなりEitherとFutureか?というと

  • play2とか使ってると、Futureがよく出てくる
  • Futureをそこら中でAwaitしたらFuture使う意味がないので、Future[A]をmapやflatMapなどでどんどん連鎖させて、メソッドの型にFutureが大量に出現!
  • Futureは非同期に行われる処理であり、そこで例外発生したら、その中に例外を含むしかない(単純に例外投げるわけにはいかないというか不可能)
  • Scala標準ライブラリのFutureは、Throwable型で例外を保持できるようになってる
  • 逆にいうと、Throwableでしか保持できない
  • 例外の種類が多くなってきて、プログラムが複雑になった場合、Throwableではなくもっと限定された独自のエラー型で表現したい
  • すると Future[ Either[エラーの型, 結果型] ] にしたくなる
  • しかし、それはそれで、Future と Either が混ざった型をうまく扱う方法がわからずに死


となる流れです。まぁtwitter上でも結構見かけますし、となりでScala書いてるチームが何ヶ月も(?)このあたり悩んでいる気がします。

というわけで、このあたりの説明を書いてみましょう。先にビルドファイル含めた全体のサンプルコード貼っておきます

https://gist.github.com/xuwei-k/756ae67f1f5563fbf265


「それほど前提知識無くても」とは言ったものの、EitherとFutureとfor式あたりはある程度知ってる前提で書きます、すいません。
最近では、Eitherとfor式についてはこれ

ScalaでWebアプリケーションのエラー処理を綺麗に書く - はこべブログ ♨

とかわかりやすかったというか、はてブがいっぱいついてたので、そのあたり自信ない人はまずそれなどを読めばいいんじゃないでしょうか?


さて、まず下準備として、以下のようなclassがあったとします

/** ほんとうは、もっといっぱい種類あるのだろうけど、とりあえず2つ */
sealed trait Error
/** 引数であたえられたUserIdのUserが、そもそも存在しなかった場合 */
final case class UserNotFound(userId: UserId) extends Error
/** データベースにつながらなかったとか、そういうの */
final case class ConnectionError(message: String) extends Error

final case class User(id: UserId, name: String)

object Main {
  type UserId = Long
}

object UsersRepository {
  def followers(userId: UserId): Either[Error, List[User]] = ???
}

userがいて、twitterのようにフォローできて、「フォローしてる」「フォローされてる」という関係を保持するアプリを作るとします。

とりあえず今あるのは、followersという、指定されたuserIdのfollower一覧を取ってくるメソッドです。さて、このメソッドだけがあったときに
「あるユーザー同士が、相互フォローの関係かどうか?」
を取得するメソッドはどう書けばよいでしょうか?


ここは、まだ下準備の段階なので、すぐに答え出しますが、以下のようになりますね?

def isFriends0(user1: UserId, user2: UserId): Either[Error, Boolean] =
  for{
    a <- followers(user1).right
    b <- followers(user2).right
  } yield a.exists(_.id == user2) && b.exists(_.id == user1)

例なので、もちろん実際は「フォロワー全部取ってきてプログラム中で判断するより、もっと効率いい方法あるだろ!」となるでしょうが、そこはツッコまないでください。
*1


さて、このfollowersが、データベースかなにかへのIOアクセスなので、非同期にしたくなって

def followers(userId: UserId): Future[Either[Error, List[User]]] = ???

に変わったとしましょう。さてそうしたときに、isFriendsメソッドは、どのように書き換えればいいでしょうか?さて、これもすぐに正解だしてしまいます。ただ、一応2パターンくらい出しておきましょう

def isFriends1(user1: UserId, user2: UserId): Future[Either[Error, Boolean]] =
  for{
    a <- followers(user1)
    b <- followers(user2)
  } yield for {
    x <- a.right
    y <- b.right
  } yield x.exists(_.id == user2) && y.exists(_.id == user1)
def isFriends2(user1: UserId, user2: UserId): Future[Either[Error, Boolean]] =
  followers(user1).flatMap{
    case Right(a) =>
      followers(user2).map{
        case Right(b) =>
          Right(a.exists(_.id == user2) && b.exists(_.id == user1))
        case Left(e) =>
          Left(e)
      }
    case Left(e) =>
      Future.successful(Left(e))
  }

なぜ2パターン示したか?というと、それぞれ微妙に動作が違うからです。なにが違うかわかりますか?

正常系の場合の動作は同じですが、followers(user1)がエラーだった場合の動作が異なります。

上記のfor式を2回使ってるisFriends1のほうでは、followers(user1)がエラーでも、followers(user2)の呼び出しは必ず実行されます。
一方、isFriends2のほうは、followers(user1)の呼び出しがエラーだと、followers(user2)は実行されません。


さて、そういう違いはあるにせよ、両方ともなんだか面倒だと思いませんか?単にFutureに包んで非同期にしたいだけで、べつに根本的なロジックは変わっていないのに、メソッドの行数は2倍くらいになってますね?
これでもまだまだ単純なほうだと思いますが、このあたりになると「型合わせゲーム」になってきます。型合わせゲーム好きな人は、こういうコード書くのある意味楽しいでしょうが、慣れてない人はたぶんイライラして投げ出したくなってくるでしょう。

あと、具体的には

case Left(e) => Left(e)

とか

case Left(e) => Future.successful(Left(e))

が、ださいですね?失敗だったら、それをFutureに包まれたまま勝手に伝播してほしいわけですが、なぜわざわざ型を合わせるために自分でFuture.successfulで包み直さないといけないんでしょう?
ただ以下の部分

followers(user2).map{
  case Right(b) =>
    Right(a.exists(_.id == user2) && b.exists(_.id == user1))
  case Left(e) =>
    Left(e)
}

に関しては

followers(user2).map{
  _.right.map(b => a.exists(_.id == user2) && b.exists(_.id == user1))
}

とも書けます。多少短くはなりますね。

しかし

case Left(e) => Future.successful(Left(e))

の部分はいずれにせよ短く書く方法がないですし、「中途半端にmap使うなら、全部パターンマッチのほうがわかりやすいか」となったり、あまり根本的な解決にはなりません。


とにかく、非同期にしてFutureを使っても正常系の動作だけを集中して書きたいはずです。for式のほうも、なんだか定型パターンっぽいし、もうちょっと短く書ける気がしませんか?




さて、実はこういうのを便利に扱うためのクラスがあるんです。なっ、なんだってーーー!?


名前を scalaz.EitherT といいます。

https://github.com/scalaz/scalaz/blob/v7.1.0/core/src/main/scala/scalaz/EitherT.scala

無駄に怖がったりしたり「EitherTのTってなんだよ!」とか考えずに、とりあえず便利なクラスだと思い込みましょう。

さて、下準備として、followersメソッドの型は以下でしたが

def followers(userId: UserId): Future[Either[Error, List[User]]] = ???

ちょっと以下のように変更します

def followers(userId: UserId): EitherT[Future, Error, List[User]]] = ???

このあたりから、すでになんだかよくわからなくなってくるかもしれませんが、Future[ Either[Error, List[User] ] ] も EitherT[ Future, Error, List[User] ] ] も、基本的に同じものです。
大雑把にいってしまえば、Future[ Either[Error, List[User] ] ]に便利メソッド追加するために、ちょっとEitherTというオブジェクトでラップしたものです。ただし、Scala標準ライブラリのEitherではなく、Scalaz独自のEitherになってますが。



さて、followersのメソッドがEitherTで返ってくるようになっていれば、相互フォロー関係を取得するisFriendsメソッドは、驚くほど短く書けるようになります。それが以下です

def isFriends3(user1: UserId, user2: UserId): EitherT[Future, Error, Boolean] =
  for{
    a <- followers(user1)
    b <- followers(user2)
  } yield a.exists(_.id == user2) && b.exists(_.id == user1)

どうですか?メソッドの戻り値型が違うだけで、実際のメソッドの中のコードは、Futureを使っていない一番最初のversionとまったく一緒ですね?

このように、scalaz.EitherTというのを使うと、なぜか知らないけど短くシンプルにかけるようになります。
ちなみに、このEitherTを使ったものは
「followers(user1)が失敗したら、followers(user2)の呼び出しはされない」
という動作をします。


さて、これを読んで、少しでも「あれっ、Scalazって便利なのでは?」と思ってもらえたらいいでしょう。わかりやすさを追求するために、色々例が雑なので実は突っ込みどころがいくつかあるかもしれませんが。



一応、最後にちゃんとした言葉を使った説明や補足をしておきましょう。なんとなくわかったつもりのままで終わりたい人は、モナドトランスフォーマーについて考えだすと結局モヤモヤするので、以下は読まないほうがいいかもしれません。

  • EitherTのTは、TransformerのTです。
  • つまり、今回のテーマは、「Eitherモナドトランスフォーマーの紹介」でした
  • 「たまたまEitherのモナドトランスフォーマーにFutureを当てはめた例」を紹介しただけで、EitherTは、Future以外(IOモナドとか、Readerモナドとか)とも組み合わせることが可能です
  • そもそもモナドトランスフォーマーとは、2つのモナドを組み合わせて、そこからまた別のモナドをつくるためのものです
  • とても大雑把な説明をすると「EitherとFutureはそれぞれモナドであり、EitherTによって組み合わさったそれもまた別のモナドになったので、最後for式1つだけで綺麗に書けた」とかそういう感じです
  • Eitherモナドトランスフォーマー以外にも、OptionTとかReaderTとかWriterTとかStateTとか色々なモナドトランスフォーマーがあります
  • 今回はEitherTの最低限の使い方示しただけで、EitherTにはもっと色んな便利メソッドもあります
  • 実は今回の例だけだと、Applicativeの範囲でいけるので、必ずしもモナドトランスフォーマーは必須ではありません
    • が、Aplicativeでは不可能な例作るのが面倒だったので・・・
  • これ以上色々書くより、あとはググったほうがわかりやすい詳しい説明見つかるだろうし、もしくは直接twitterで自分に聞いてください
  • さて上記で「なんとなく便利そう!」と思った程度の知識で、実際にscalaz.EitherTなどをガンガン使っていって幸せになれるのかどうか?は保証しかねます・・・

*1:あと、変数名aとかbとか適当なのも、面倒というかScalazがそういう慣習なので・・・という言い訳をしておきます