Scala + CleanArchitecture に Eff を組み込んでみた

この記事は RECRUIT MARKETING PARTNERS Advent Calendar 2018 の投稿記事です。

こんにちは。スタディサプリENGLSHでサーバーサイドとインフラを担当している松川です。

CleanArchitectureにEffを組み込むことによって、これまでモナドトランスフォーマーでは辛かった数種類以上のモナドを取り扱う処理をフラットに書けるようになったり、多くのメリットがあったので紹介させていだきます。

はじめに

スタディサプリ ENGLISHのサーバーサイドは全てScalaで書かれており、CleanArchitectureを採用しています。

1つのサービスにおけるsbtプロジェクトの依存関係は以下のようになっています1)StreamAdapter,InternalAdapterは存在しないサービスもあります。

基本的にはオブジェクトが主役な設計ですが、UseCaseには関数型のエッセンスを積極的に取り入れるようにしています。

これまでUseCaseは全てモナドトランスフォーマーで書き、抽象UseCaseのシグニチャでFuture(monix.Taskへ移行中)とDisjunction以外の効果は使えない制限を入れ、一部どうしてもWriter等を使いたい箇所を例外的に扱うことで運用していましたが、今回複数あるマイクロサービスのうちの1つに対して試験的にEffを導入してみました。

Eff とは

Effは、モナドトランスフォーマーの問題点であるモナドスタックの律速問題やシンタックスの問題、合成順を入れ替えられなかったり、合成順によって計算結果が変わってしまう問題をEfficient FreerとOpen Unionによって解決してくれています。

※ 理論は論文とねこはるさんの👇の記事がとても参考になりました。ありがとうございました!

個人的には、常にフラットな構造で別々のモナドを取り扱えることによる表現力と可読性に大きなメリットを感じています。スタディサプリ ENGLISHではスタックに積む効果を特定の数に絞っており(どんなに多くても4つ以下)、モナドトランスフォーマーを利用していてもパフォーマンスに影響が出にくいようにはしていますが、導入する価値が充分にあると考え、メンバーと議論しつつ試験導入をしてみました。

ものすごくざっくりいうと、要は以下のように書けるということです。

// 通常
for {
  x <- Option(7)
  y <- DBIO.successful(x)
  _ <- -\/(UseCaseError)
} yield y // 普通にコンパイルエラー
// Eff
for {
  x <- fromOption(Option(7))
  y <- fromDBIO(DBIO.successful(x))
  _ <- fromMyError(-\/(UseCaseError))
} yield y // Eff[R, Int]がRにOption,DBIO,Disjunctionがスタックされた状態で返る

もう少し掘り下げて、通常版、モナドトランスフォーマー版、Eff版の3種類示してみます。

// 通常
for {
  x <- Option(1).right
  y <- {
    x match {
      case Some(n) => Option(n).right
      case None    => Option(0).right
    }
  }
  z <- {
    y match {
      case Some(n) => Some(n).right
      case e       => e.left
    }
  }
} yield z
// モナドトランスフォーマー
for {
  x <- EitherT[Option, String, Int] { Option(\/-(1)) }
  y <- EitherT[Option, String, Int] {
    Option(\/-(x))
  }
  z <- EitherT[Option, String, Int] {
    Option(\/-(y))
  }
 } yield z
// Eff
for {
  xE <- fromOption[R, Int](Option(\/-(1)))
  x  <- fromDisjunction[R, MyError, Int](xE)
  yE <- fromOption[R, Int](Option(\/-(x)))
  y  <- fromDisjunction[R, MyError, Int](yE)
  zE <- fromOption[R, Int](Option(\/-(y)))
  z  <- fromDisjunction[R, MyError, Int](zE)  
} yield z

雑な例ですが、Effではここからさらに効果が増えても同じようにフラットに記述できることがわかるかと思います。

WriterやReaderが途中で入ってきて処理の全体でそれらの型を意識して書いたり、複数段ネストした先にある値をliftで持ち上げて処理するといった複雑な型合わせゲームから開放される点が大きなメリットだと考えています。

これによってReader,Writer等、使いたい場面は多いが型合わせがしんどすぎて導入しにくかった効果を気楽に導入できるようになりました。2)モナドトランスフォーマー + extensionでも、ある程度マシにはできましたがそれでも辛かった

こちらも雑な例ですが、EffでReader,Writer,monix.Task,Disjunctionを扱うと以下のように書けます。

for {
  clock <- ask[R, Clock]
  x     <- fromTask[R, LocalDateTime](Task.now(LocalDateTime.now(clock)))
  _     <- tell[U, LogMessage](LogMessage(s"HogeHoge"))
  z     <- fromDisjunction[R, MyError, LocalDateTime](\/-(x))
} yield z

Interpreter3)記事中で指すInterpreterはEDSLのInterpreterです。



Eff[R, A]を返すコードでEffは処理を実行しているわけではなく、monix.Task,Disjunction,Writerといった処理を定義するに留められています。これらをInterpreterにかけることにより、実行(効果を付与)することができます。この性質により、Controllerでrunするまでは単なる式を組み立てる処理として取り扱うことができるのもメリットではないかと考えています。

スタディサプリ ENGLISH ではatnos-eff を利用しているため、基本的な型のInterpreterはライブラリが提供してくれるものを利用し、CacheIO、PushIOといった独自のEffectとInterpreterが必要になったものは、独自に実装をしています。

※ スタディサプリ ENGLISH ではmonix.Taskを利用していますが、atnos-effではFutureを内部的に定義と実行を分離したTimedFutureに変換しているため、Future型でも利用することができます。

Interpreterは効果を実行することよって生まれた効果(例えば、RedisClientを実行することによって発生したmonix.Task型等)をスタックし直すことも可能ですし、なんの効果も与えず値だけ取り出してスタックから対象効果を取り除いた値を返すInterpreterを書くことも可能なのでテスト用の実装も楽です。

Eff のメリット

型合わせゲームの緩和

これは前述の通り、Controllerで実行するまでは Eff[R, A] 型を引き回すだけなので、liftなどの操作要らずで、フラットな構造としてデータを取り扱いつつ様々な効果を付与することができます。

表現力を持った独自型の追加

独自のモナド型(Freerで作るので実質ADTとスマートコンストラクタだけ)を定義し、Interpreterに固くテストを書くことによって比較的気楽に効果を定義できるので、テストケースの削減に繋がりました。

例えばPushIOという「失敗しても握りつぶすmonix.Task型」を定義することよって、UseCaseなどでmonix.Task型が失敗するケースのテストを書く必要がなくなりました。

※ PushPublisherのInterfaceで返り値がmonix.Task型だと、不要とわかっていても異常系書きたくなってしまいます。

これによって、「型はドキュメントじゃ!」という状態をより実現しやすくなったのではないでしょうか。

ドメインモデルの表現力向上

ドメインモデルの振る舞いにおいて、結果として何かしらのAPIを叩くような作用が発生するものは、DIし難い問題もあってxxxDomainService等に切り出すことが多くありました。Eff化することにより、最後にControllerでInterpreterを挿す形になるので
依存性の方向や、Adapter以下に技術的関心ごとの依存を持ってくるといったことをしないでも、柔軟に振る舞いを定義することが可能になります。

一番用途の多い、ID生成と現在時間の効果をEff化すると以下のようになります。

def apply[R: _idgen: _jodaTime](hogeName: HogeName): Eff[R, HogeModel] = {
  for {
    id  <- HogeId.generateEff[R]
    now <- JodaTimeM.now[R]
  } yield {
    HogeModel(
      id = id,
      hogeName = hogeName,
      createdAt = now,
      updatedAt = now
    )
  }
}

Interpreterの差し替え

依存性を制御するため、Adapter以下の層でのテストではテスト用のInterpreterを挿す形になります。

これは好みの分かれるところで宗教戦争案件かもしれませんが、個人的には依存性の方向を制御するためのDI(Guiceを利用しています)とテスト都合で小さい部品(DateTime等)の差し替えをするDIは、前述のドメインモデルの振る舞いの表現力観点やシンタックス等から分けたいと考えているので、とても気持ちよくハマりました。
前者はauto wireの機能がないとコード量が増えてつらいですが、後者はそうでもないのでコンパイルタイムに注入できるメリットもあります。(もちろんDIの手法は一つのほうが良いという気持ちもわかりますのであくまで好みの問題です)

UseCaseなどでDIしたものをドメインモデルのInterfaceで受け取って実行するよりは、最後にInterpreterを挿すほうが自然に書けるので良さを感じています。

デメリット

importゲー

型合わせゲーではなくなりましたが、スマートコンストラクタや各効果用の型、Interpreterをextensionで差し込むので、importがかなり多くなってしまいます4)scalaz,catsなどと同じですね。
ここはIntellij IDEAのライブテンプレートで解決することができましたが、まだ運用はしていません。

以下の例はcatsですが、このようにimport-eff-primary-adapter,import-eff-secondary-adapter,import-eff-otherと、importしておけばEffで書き始められるものを各層でまとめてライブテンプレートとして定義しようと考えています。

シンタックス問題 ( Interface )

Repository,UseCase,ControllerのInterfaceにEffを定義したところ、Open Union用のimplicit paramの引き回しに辛みが出てきました。

※ Presenterにはすべての効果を実行した後に渡すので除外します。

// DomainModel
def apply[R: _idgen: _jodaTime]: Eff[R, HogeModel]
// Repository
def store[R: _dbio: _task](entity: HogeModel): Eff[R, HogeModel]
// UseCase
def execute[R: _task: _dbio: _errorEither: _idGen: _jodaTime](
  arg: CreateHogeModelUseCaseArgs
): Eff[R, CreateHogeModelUseCaseResult] 
// Controller
type R = Fx.fx5[DBIO, Task, ErrorEither, IdGen, JodaTimeM]
  (for {
    hogeName <- fromMyError[R, String](
      request.getQueryString("hogeName") \/> InvalidRequestError("error msg.", request.toString())
    )
    useCaseRes <- createHogeModelUseCase.execute[R](
      CreateHogeModelUseCaseArgs(
        HogeName(hogeName)
      )
    )
 } yield useCaseRes)
   .runDBIO
   .runMyError
   .runAsync
   .runAsync
   .map(createHogeModelPresenter.response)
}  

ここでドメインモデルの def apply[R: _idgen: _jodaTime]_logの効果を追加したくなった場合、Repository,UseCaseのInterfaceに_logを追加し、Controllerの type R にMemberの追加をしなければなりません。

Interfaceで明示的に効果を指定しているので分かりやすくはあるのですが、Interfaceが変わりやすくなる点は微妙です。効果が増えるのだからInterfaceも変わっていいのでは? という観点もあると思うので、ここは運用しながら辛くないかを試していきたいと思います。現状はそんなに辛くないかな感

シンタックス問題 ( 型指定 )

Scalaは型推論があまり得意ではないので明示的に型を指定する必要があります。

👇のようなケースでは、HogeModel.applyで型指定をしない場合、for式のコンテキストがapplyのR: _idGen: _jodaTimeに束縛されてしまうのでコンパイルエラーとなってしまいます。

少し煩わしいですが、以下のように毎回必ず指定するものとして運用しています。

type R = Fx.fx5[DBIO, Task, ErrorEither, IdGen, JodaTimeM]
for {  
  x <- HogeModel[R](hogeName)
  y <- hogeRepository.store[R](x)
} yield y

シンタックス問題 ( Interpreter )

// Controller
type R = Fx.fx5[DBIO, Task, ErrorEither, IdGen, JodaTimeM]
(for {
  hogeName <- fromMyError[R, String](
    request.getQueryString("hogeName") \/> InvalidRequestError("error msg.", request.toString())
  )
  useCaseRes <- createHogeModelUseCase.execute[R](
    CreateHogeModelUseCaseArgs(
      HogeName(hogeName)
    )
  )
} yield useCaseRes)
 .runDBIO
 .runMyError
 .runAsync
 .runAsync
 .map(createHogeModelPresenter.response)
}  

効果が増えてくると、Intepreterを実行するコード(runDBIO.runxxxx....etc)が増えていく点が少し微妙なので、これはある程度の単位でextensionを提供したいと考えています。

まとめ

Effによってフラットな構造でUseCaseを記述できるようになり、for式のみで完結するように書いていたUseCaseの処理が更に読みやすくなりました。

我々のUseCase上では破壊的なキャスト等はなく利用できていますが(多分)、きちんと検証しないと型安全性を保てない処理もかけてしまいますし、理論も難しいのでまだまだ気楽に入れられるものではありません。

しかし、関数型のエッセンスは保守性と表現力のトレードオフだった型システムに表現力の幅をもたらしてくれるものだと強く思っているので、破壊的ではない範囲で積極的に取り入れ、適度な制約をもたせることによって、深い造詣がなくとも自然に書けるようなアーキテクチャを目指し、日々チームのメンバーと議論をしています(たのしい)。

スタディサプリ ENGLISH は全体としては結構な規模になってきましたが、一年ほど前にマイクロサービス化したことによって各コンテキストを分割統治できていたため、比較的小さいコンテキストで他に影響を与えることなく試験導入することができたのはとても良かったと思います。

👇モノレポ構成のリポジトリを解析したところ、Scalaコードで40万行ほどでした。gRPCの自動生成コードを含めると58万行あったのでデプロイ独立性が担保されていないモノリシック構成だとあまりこういう試みをする気にはなれなかったのではないかと思います。

$ cloc es-server
    9283 text files.
    8763 unique files.
    1537 files ignored.
github.com/AlDanial/cloc v 1.80  T=5.81 s (1366.2 files/s, 93375.6 lines/s)
--------------------------------------------------------------------------------
Language                      files          blank        comment           code
--------------------------------------------------------------------------------
Scala                          7146          72954          23513         400496
SQL                             392           3439              2          19999
HTML                             83            372              4           8266
Protocol Buffers                177           1091            297           4311
liquid                           53             32              0           2036
Bourne Again Shell                6            157             89            856
YAML                              5             66              0            779
XML                              18            143            234            760
Ruby                              2             63              0            639
Markdown                         10            205              0            430
TypeScript                       13             82              1            391
JavaScript                       12             43             10            270
Bourne Shell                     10             34              3            144
JSON                              2              0              0             81
Sass                              1             11              0             67
CSS                               3              2              9             11
Dockerfile                        2              1              0              7
make                              1              0              0              4
--------------------------------------------------------------------------------
SUM:                           7936          78695          24162         439547
--------------------------------------------------------------------------------

弊社のサーバーサイドのメンバーは関数型言語が好きな人が多いので楽しく書いてくれています。様子を見て気長に他のコンテキストにも適用していこうと考えています。

メッセージ基盤周り、決済基盤の洗練、IDaaS導入やサービスメッシュ導入などなど、まだまだやりたいことはたくさんあるので興味のある方はぜひお話を聞きに来ていただけると幸いです。やっていき!

脚注

脚注
1 StreamAdapter,InternalAdapterは存在しないサービスもあります。
2 モナドトランスフォーマー + extensionでも、ある程度マシにはできましたがそれでも辛かった
3 記事中で指すInterpreterはEDSLのInterpreterです。

4 scalaz,catsなどと同じですね。