FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

継続モナドを使ってwebアプリケーションのユースケース(ICONIX)を表現/実装する

前書き

セプオリのしもむらです(@s10myk4)

弊社では、DDDによるソフトウェアの設計手法を積極的に採用していますが、
私は、DDDを具体的な開発プロセスの中で実践する際にユースケース駆動での予備設計を行うことで
要件定義フェーズからドメインについての理解を深め、
顧客が求める要望の理由についてより深い解釈ができるようになりました。

ユースケース駆動による予備設計によって、概念的にどのような方法で顧客の要求(機能要求)が実現されるかをユースケースとして言語し、 それをドキュメントとして管理・更新していましたが、AtlassianのConfluenceでの運用はコードとの同期が大変でした。

ユースケースの概念モデルをそのまま実装に落とし込むことで、

  • 異常系の考慮漏れを減らせるのでは?

  • 正常系と異常系が1つのユースケース上で表現される凝集度の高い部品が作れるのでは?

  • メンバーが仕様を理解しやすいドキュメント性の高いコードが書けるのでは?

  • コントローラーでのよく登場する手続きを汎用的なユースケースとして表現できるのでは?

  • コントローラーで実装されるロジックを極力減らしたい

みたいなことを実現できそう、したいと考えていました。

そこで、@pab_tec さんの「継続モナドを使ってWebアプリケーションのコントローラーを自由自在に組み立てる」を何度も読んだり、青山さん(@AoiroAoino)(同僚)に教えてもらい、
どうやらWebアプリケーションの処理構造を表現する上で
継続モナドというものがとても良さそうだということを知りました。

私がいるチームのプロダクトでは、全面的に取り入れていて上記で書いた実現できそう、したいと思っていたことが大方できていると考えています。
ユースケースに正常系と異常系が網羅的に表現され、コントローラーは機能要求をどのユースケースを組み合わせることで実現するかという関心に終始させることができました。

また、継続モナド自体シンプルな構造なので、既存のプロジェクトコードに対してもミニマルに導入できるが可能であることも魅力です。

この話では、ユースケース駆動重要性について触れた上で、
ユースケース駆動での予備設計を継続モナドを使ってどのように表現するかを具体的な例を用いて説明します。

目次

ユースケースをそのままコードに表現したいと思った動機

前書きで大体ここで言いたいことも書いてあるんですが、
前プロダクトで感じた課題を元により具体的に書き出してみました。

  • play.api.mvc.Resultを境に、正常系と異常系の処理が分離してしまうことで機能要求が複雑であるほど、
    コントローラーでの例外リカバーが多くなりどの処理に対する考慮かがわかりにくくなってしまった。
  • コントローラーで同じような処理が多々発生する。
  • コントローラーのテストを書くコストが高いので、
    コントローラー内にロジックを極力書きたくないけど書かれてしまう。
  • ファットなコントローラーになるとレビューが大変だった。
  • ユースケースを表現する構造を持ってなかったので、人によってアプリケーションサービスやらコントローラーにもりもり書いちゃう人がいたりと実装にムラがあった。(つまり品質にも少なからずムラがあったはず)
  • 昔所属してたチームでは追加機能開発に関わった人みんなで統合テストを行っていたが、
    統合テストやるってなったタイミングでテストケースを毎回書いてて毎回精神病むし、
    人によって網羅性に大きな差があったことは確か。

etc...

ユースケースとは? ユースケース駆動開発とは?

一応重要な用語について説明を加えておきます。

ユースケース

ある機能(目的)に関するシナリオにおいて、システム利用者(アクター)とシステムとの対話を表現したもの。
機能要求を分析し理解を深めることで、システムのどのような振る舞いによって、
機能要求が実現されるかをユビキタス言語を用いて表現します。

ユースケース駆動開発

ユースケースからオブジェクト指向設計を導出する手法。
ユースケース駆動開発を実現するための手法としてICONIXプロセスがあります。
ICONIXプロセスによってユースケースからオブジェクトモデルを発見し、コードを導出するかを実現する。
ICONIXプロセスは分析と設計の全てのステップをカバーしていますが、
ICONIXはプラガブルなプロセスであり、目的に応じて部分的に適用することが可能です。

ICONIXプロセスとは (Wiki参照)

ラショナル統一プロセス(RUP)、エクストリーム・プログラミング(XP)、
及びアジャイルソフトウェア開発よりも前から存在するソフトウェア開発方法論である。
<中略>
ICONIXプロセスは、4ステップのプロセスでただ4つのUML図を使用して、ユースケース記述を動作するコードに変換する。

  • step 1:要件定義、要求レビュー
  • step 2:分析/予備設計、予備設計レビュー
  • step 3:詳細設計、詳細設計レビュー
  • step 4:テスト記述、配置(実装)

なぜユースケース駆動を取り入れたのか?

DDDを行う上で、ドメインモデルの振る舞いを発見し、
業務ロジックをドメインモデルの振る舞いとして持たせることを目指していたが
チームとして体系的な手法での設計は行われておらず、経験とセンスで行われている部分がありました。
ビジネスに置ける概念的なモデルと実装に乖離があり、個々の能力(モデリング、分析など)によって設計、品質に差があったのは確かでした。

私がドメインモデルについて理解を深める、振る舞いを探索する際に、"顧客の要求は何?”、"どのようなシステムのやりとりによって実現されるのか"を脳内で考えてユースケースについて分析していたので、
初めてユースケース駆動開発について知った時にとても興味を持ちました。

ICONIXプロセスを部分的にチームに取り入れ、体系的な方法で要件定義や分析/予備設計をチームで行うことで
結果、チームメンバーのドメイン知識はより洗礼され、
体系的な手法を取り入れたことでチームでの要件定義/分析/設計の効率化、
チームの設計スキルのボトムアップに繋がったと思います。

今では、コアドメインの価値向上のための検証や改善案を
開発チーム主導で行えるまでにドメイン知識を得ることができました。

典型的なwebアプリケーションの処理構造

@pab_techさんの ScalaMatsuri 2016 ドワンゴアカウントシステムを支えるScala技術の資料内にある
Typical web application structureを参考に今回の自分のテーマに沿ってWebアプリケーションの処理構造の例を具体化してみました。

f:id:s_tomoyuki:20180318133015j:plain

右側に処理が進んだ後に、逆方向に戻ってくる構造、
例えばユースケースから、ビジネスロジックまたはシステム的な手続きを実行した際に、
呼び出す前に処理を挟んだり、実行結果に大して処理を挟んだりすることはよくあるという点を頭に入れつつ、
下記の説明に移ります。

継続モナドとは?

case class Cont[R, A](run: (A => R) => R) {
  def map[B](f: A => B): Cont[R, B] =
    Cont(g => run(a => g(f(a))))
  def flatMap[B](f: A => Cont[R, B]): Cont[R, B] =
    Cont(g => run(a => f(a).run(g)))
}

コンストラクタに渡されるrun関数 (A => R) => R に注目します。
Aを取ってRを返すという型の関数を取り、Rを返すということを表現しています。

Aは途中の処理結果、 Rは最終的な処理結果を表しています。
ということは、run関数は、途中から最後までの処理を表現した関数を取り、最終的な結果を返す関数と言語化できます。

より具体的に考えると、後続の処理を関数として取り、途中結果のAに処理を加えたり、
最終的な結果のRに処理を加えたり、前後に処理を挟むことができるところがポイントです。
また特定のロジックによって後続の処理への継続を止めたりすることもできます。

継続モナドについては
@pab_tech さんの記事が丁寧に書かれていてわかりやすかったです。
参照: http://bit.ly/2E2zO0E

ユースケースを表現するのに継続モナドが適していると思われる点

1つのユースケースの実装に正常系と異常系が網羅された凝集度の高い部品

これは上で書いたように後続の処理の前後に処理を挟むことができる特徴によって、
1つのユースケース上に正常系と異常系の実装を書くことが実現できました。
そのユースケースの関心だけを表現した凝集度の高い部品が作れました。

継続モナドという一定の構造によってユースケースを表現するので、コントローラーで合成がしやすい

正常系と異常系を網羅した部品を作れることで、コントローラーは基本的に機能要求をどのユースケースを組み合わせて実現するかという関心だけに特化させることができました。
しかも柔軟に組み立てが可能です。
コントローラー内にロジックが実装されなくなり、レビューのしやすさが格段に上がりました。

ユースケースごとに異なる粒度で表現できる

多くの場合、機能要求(シナリオ)は、複数のユースケースを組み合わせることで実現されます。
そのユースケースの中には、他の機能要求を実現するためにも利用されるような汎用的なユースケースが含まれることが稀ではありません。
これはコントローラーで定型的な処置が多かったり、
同じ処理が複数であるような話と似たような類だと考えられます。

(A => R) => Rというシンプルな構造によって柔軟な表現ができ、
様々な機能要求を実現する一部である汎用的なユースケースをそのままの粒度で表現することで、
再利用性の高い部品を提供することができます。
1つのユースケースが正常系、異常系を網羅している部品であるという前提は、
使いやすさ再利用性に大きく寄与しています。

実装してみる(サンプルコード)

ここでは実際に継続モナドを使って、下記の機能要求を実現するユースケースを実装するサンプルを提示します。

今回実現したい機能要求(シナリオ)

・ユーザーは、ある特定の戦士に新しい武器を装備できる

ユースケース記述
[正常系]

ユーザーは、自分の所有している戦士の中から1人を選んでタップする
システムは、戦士の詳細画面を表示する
ユーザーは、武器装備ボタンをタップする
システムは、武器一覧を取得し、武器装備画面に表示する
ユーザーは、戦士に装備したい武器を一覧から選択して、タップする
システムは、選択した武器を装備するのに必要なレベル以上であるかを確認する
システムは、選択した武器の属性が戦士の属性が同じであるかを確認する
システムは、選択した武器を装備した戦士を作成し、保存する
システムは、"武器を変更しました"というメッセージを画面に表示する
[異常系]

・戦士のレベルが選択した武器を装備するのに必要なレベルを未満である
-> システムは、"この武器を装備するための条件を満たしていません"というメッセージを武器装備画面に表示する

・戦士の属性と選択した武器の属性が異なる
-> システムは、"この武器を装備するための条件を満たしていません"というメッセージを武器装備画面に表示する

・指定した戦士が存在しない
-> システムは、"対象の戦士が存在しません"というメッセージを戦士一覧画面に表示する

継続モナドの合成、実行イメージを先に例示

最終的にこのような感じで継続モナド(ContT)が合成され、実行されます。

//継続モナドを合成
val composedConts = for {
  form <- bindCont(EquipNewWeaponForm.apply)(r)
  (warriorId, weapon) = (WarriorId(form.warriorId), form.weapon)
  warrior <- findWarrior(warriorId)
  res <- warriorEquippedNewWeapon(warrior, weapon)
} yield res

//合成した継続モナドに Futureのモナドインスタンスを適用して実行
composedConts.run_

このような合成がどのような実装/部品によって実現されるかをサンプルコードを用いて説明します。

モナドトランスフォーマーにも言及したくもあるんですが、話が大きく逸れてしまいそうなので、
下記を参考にしていただければと思います。
https://gist.github.com/gakuzzzz/ce10189cdd40427951bb5fadf18403b9
http://xuwei-k.hatenablog.com/entry/20140919/1411136788

上記ユースケースを継続モナドを使って実装

Futureと組み合わせて、最終的にUseCaseResultを返す継続モナドを ActionContと定義します。
ユースケースの概念において、PlayFramework固有の概念であるplay.api.mvc.Resultは存在しないので、
ユースケースの最終的な処理結果としてユースケースの実行結果(正常、異常 etc)を定義しました。

別途で、UseCaseResultをplay.api.mvc.Resultに対応づける部品も下記で提供しています。

今回のサンプルコードはこちらにあげてあります。 https://github.com/s10myk4/use-case-implemented-by-contT

package application

import application.usecase.UseCaseResult

import scala.concurrent.Future
import scalaz.ContT

package object cont {
  type ActionCont[A] = ContT[Future, UseCaseResult, A]
}

継続モナドのファクトリ

package application.support

import application.cont.ActionCont
import application.usecase.UseCaseResult

import scala.concurrent.{ExecutionContext, Future}
import scalaz.ContT

object ActionCont {
  def apply[A](f: (A => Future[UseCaseResult]) => Future[UseCaseResult]): ActionCont[A] = ContT(f)

  def fromFuture[A](future: Future[A])(implicit ec: ExecutionContext): ActionCont[A] =
    ContT(future.flatMap(_))

  def successful[A](a: A)(implicit ec: ExecutionContext): ActionCont[A] =
    fromFuture(Future.successful(a))

  def failed[A](throwable: Throwable)(implicit ec: ExecutionContext): ActionCont[A] =
    fromFuture(Future.failed(throwable))
}
package adapter.http.controller.support

import application.cont.ActionCont
import application.usecase.{InvalidInputParameters, UseCaseResult}
import play.api.data.{Form, FormError}
import play.api.mvc.{AnyContent, Request}

import scala.concurrent.Future
import scalaz.ContT

private[http] trait FormHelper {

  def bindCont[A](form: Form[A])(implicit req: Request[AnyContent]): ActionCont[A] =
    ContT(f =>
      form.bindFromRequest.fold[Future[UseCaseResult]](
        error => Future.successful(InvalidInputParameters("不正な内容です", convertFormErrorsToMap(error.errors))),
        a => f(a)
      )
    )

  def convertFormErrorsToMap(errors: Seq[FormError]): Map[String, String] = {
    errors.map(e => e.key -> e.message).toMap
  }
}

ドメインオブジェクトを定義

package domain.model.character.warrior

import domain.model.exception.ConditionViolatedException
import domain.model.weapon.Weapon
import domain.model.{Attribute, BaseEntity, Level}

/**
  * 戦士を表すドメインオブジェクト
  */
sealed abstract case class Warrior(
  id: WarriorId,
  name: String,
  attribute: Attribute,
  weapon: Option[Weapon],
  level: Level,
) extends BaseEntity[WarriorId] {

  def setNewWeapon(weapon: Weapon): Either[ConditionViolatedException, Warrior] = {
    if (isValidEquipmentCondition(weapon))
      Left(new ConditionViolatedException("属性が一緒且つ、戦士のレベルが武器の指定レベル以上である必要があります。"))
    else
      Right(new Warrior(this.id, this.name, this.attribute, Some(weapon), this.level) {})
  }

  private def isValidEquipmentCondition(weapon: Weapon): Boolean = {
    //属性が一緒か & 条件となるlevelを満たしているか
    attribute == weapon.attribute & weapon.levelConditionOfEquipment <= level.value
  }

}

object Warrior {
  def create(
    id: WarriorId,
    name: String,
    attribute: Attribute,
    level: Level,
  ): Warrior = {
    new Warrior(id, name, attribute, None, level) {}
  }
}
package domain.model.weapon

import domain.model._

/**
  * 武器を表すドメインオブジェクト
  */
sealed trait Weapon {
  val name: String
  val offensivePower: Int
  val attribute: Attribute
  val levelConditionOfEquipment: Int
}

object Weapon {

  case object GoldSword extends Weapon {
    val name: String = "gold sword"
    val offensivePower: Int = 44
    val attribute: Attribute = LightAttribute
    val levelConditionOfEquipment: Int = 30
  }

  case object BlackSword extends Weapon {
    val name: String = "black sword"
    val offensivePower: Int = 50
    val attribute: Attribute = DarkAttribute
    val levelConditionOfEquipment: Int = 40
  }

  def apply(str: String): Option[Weapon] = str match {
    case "goldSword" => Some(GoldSword)
    case "blackSword"    => Some(BlackSword)
    case _              => None
  }
}

ユースケースの結果(正常系 / 異常系)を表現する

package application.usecase

sealed trait UseCaseResult

/**
  * 正常系
  */
object NormalCase extends UseCaseResult

/**
  * 異常系
  */
trait AbnormalCase extends UseCaseResult {
  val cause: String
}

/**
  * エンティティが存在しない
  */
trait EntityNotFound extends AbnormalCase

/**
  *  エンティティが重複
  */
trait EntityDuplicated extends AbnormalCase

/**
  * 不正な入力値
  */
final case class InvalidInputParameters(cause: String = "不正な入力値です", errors: Map[String, String]) extends AbnormalCase

ユースケースの結果をplay.api.mvc.Resultに対応づける

package adapter.http.controller.support

import application.usecase._
import io.circe.syntax._
import play.api.mvc.Result
import play.api.mvc.Results._

/**
  * ユースケースの実行結果をhttpStatusに変換する機能
  */
private[http] trait HttpStatusConverter extends JsonEncoder with WritableJsonConverter {

  implicit class StatusConverter(result: UseCaseResult) {
    def convertHttpStatus: Result = {
      result match {
        case NormalCase => Ok
        case x: EntityDuplicated => Conflict(x.asJson)
        case x: EntityNotFound => NotFound(x.asJson)
        case x: InvalidInputParameters => BadRequest(x.asJson(formErrorEncoder))
      }
    }
  }

}

ここからがやっと本題の実装

"戦士を特定する"のユースケースを実装する

戦士が特定できた場合は、取得した戦士エンティティを後続の処理渡して、後続処理を続けます。
戦士が特定できなかった場合は、異常系となり後続の処理を呼んでいないので処理がここで止まり、
異常系の結果を返します。

package application.usecase

import application.cont.ActionCont
import application.support.ActionCont
import domain.lifcycle.{IOContext, WarriorRepository}
import domain.model.character.warrior.{Warrior, WarriorId}

import scala.concurrent.Future

/**
  * 戦士を取得する
  */
trait FindWarrior {
  def apply(id: WarriorId): ActionCont[Warrior]

  case object WarriorNotFound extends EntityNotFound {
    val cause: String = "A warrior do not found."
  }

}

final class FindWarriorImpl[Ctx <: IOContext](
  ctx: Ctx,
  repository: WarriorRepository[Future]
) extends FindWarrior {
  def apply(id: WarriorId): ActionCont[Warrior] = {
    ActionCont { f =>
      repository.resolveBy(id).flatMap {
        case Some(w) => f(w)
        case None => Future.successful(WarriorNotFound)
      }(ctx.ec)
    }
  }
}

"戦士に新しい武器を装備する"のユースケースを実装する

package application.usecase

import application.cont.ActionCont
import application.support.ActionCont
import domain.lifcycle.{IOContext, WarriorRepository}
import domain.model.character.warrior.Warrior
import domain.model.weapon.Weapon

import scala.concurrent.Future

/**
  * 戦士に新しい武器を装備する
  */
trait WarriorEquippedNewWeapon {
  def apply(warrior: Warrior, newWeapon: Weapon): ActionCont[UseCaseResult]

  case object InvalidCondition extends AbnormalCase {
    val cause: String = "この武器を装備するための条件を満たしていません"
  }

}

final class WarriorEquippedNewWeaponImpl[Ctx <: IOContext](
  ctx: Ctx,
  repository: WarriorRepository[Future]
) extends WarriorEquippedNewWeapon {
  def apply(warrior: Warrior, newWeapon: Weapon): ActionCont[UseCaseResult] =
    ActionCont { f =>
      warrior.setNewWeapon(newWeapon) match {
        case Right(w) => repository.store(w).flatMap(_ => f(NormalCase))(ctx.ec)
        case Left(_) => Future.successful(InvalidCondition)
      }
    }
}

戦士を特定する戦士に新しい武器を装備する を使って 機能要求(ユーザーは、戦士を指定してその戦士に新しい武器を装備できる)を実現する

package adapter.http.controller

import adapter.http.controller.support.{FormHelper, HttpStatusConverter}
import adapter.http.form.EquipNewWeaponForm
import application.usecase.{FindWarrior, WarriorEquippedNewWeapon}
import domain.model.character.warrior.WarriorId
import play.api.mvc._

import scala.concurrent.ExecutionContext
import scalaz.std.scalaFuture

class WarriorController(
  cc: ControllerComponents,
  findWarrior: FindWarrior,
  warriorEquippedNewWeapon: WarriorEquippedNewWeapon,
) extends AbstractController(cc) with HttpStatusConverter with FormHelper {

  private val ec: ExecutionContext = ExecutionContext.global

  def equipNewWeapon: EssentialAction = Action.async { r =>
    implicit val fm = scalaFuture.futureInstance(ec)

    //継続モナドを合成
    val composedConts = for {
      form <- bindCont(EquipNewWeaponForm.apply)(r)
      (warriorId, weapon) = (WarriorId(form.warriorId), form.weapon)
      warrior <- findWarrior(warriorId)
      res <- warriorEquippedNewWeapon(warrior, weapon)
    } yield res

    //合成した継続モナドに Futureのモナドインスタンスを適用して実行
    val res = composedConts.run_
    //ユースケースの実行結果をplay.api.mvc.Resultに変換
    res.map(_.convertHttpStatus)(ec)
  }

}

まとめ

当初、動機のところで書いた

  • play.api.mvc.Resultを境に、正常系と異常系の処理が分離してしまうことで
    機能要求が複雑であるほど、コントローラーでの例外リカバーが多くなり、
    どの処理に対する考慮かがわかりにくくなってしまった。

  • コントローラーで同じような処理が多々発生する

などの課題を継続モナドを使ってユースケースを実装することで改善する方法を1つ提示しました。

後続の処理関数の前後に処理を挟むことができるという継続モナドの特徴によって、
1つのユースケース内に正常系と異常系を網羅的に実装でき、凝集度の高い部品を作ることができました。
それらによってコントローラーは機能要求をどのユースケースを組み合わせることで実現するかという関心に終始することができました。

最後まで読んでいただきありがとうございました。
拙い内容ではありますが同じような課題を感じている方々にとって少しでも参考になれば幸いです。

参考