オフラインファースト的なGUIアプリケーションをScala.jsで書く話 / write stack - usecase

前回の記事では、Scala.jsで書かれたモデル層がどのようなクラス/オブジェクトをJSで書かれたUI層に公開し、UI層はそれらをどのようにして扱うのかというのを見てきました。今回からはScala.jsで書かれたモデル層のうち、コマンドから始まる一連の状態更新系(write stackと呼んでいます)について見ていきたいと思います。

write stackを構成するpackageとクラスたち

write stackを構成するクラスは以下の通りです

  • usecase パッケージ
    • Commandクラス
    • Serviceクラス
  • domain パッケージ
    • Domain Modelを表すクラスたち
  • infrastructure パッケージ
    • Repositoryクラス
    • Synchronizerクラス

このうち、今回はusecaseパッケージに注目してみましょう。

usecaseパッケージは、いわゆる「アプリケーション層」に相当するパッケージです。アプリケーション層の責務は、アプリケーションへの入力を受け取り、domaininfrastructureにメッセージを送り、ユースケースを実現する責務です。

逆に言うと、この層に「ユースケースを実現する」以外の関心が書かれてしまうと「ドメインモデル貧血症」を起こすことになりますので、この層は薄く実装されるべきです。

write stackでは、CommandクラスとServiceクラスがこのusecaseパッケージを構成しています。

Commandクラスの責務

Commandクラスの責務は、以下のふたつの責務を持っています。

  • ユーザー入力のvalidation
  • ユーザー入力をアプリケーション上意味の意味のある値に変換
  • Serviceのdispatch

わかりやすい例はAddTodoCommandでしょう。

trait Command {
  protected val addTodoService: Service

  private var _todoInput = ""
  private var _dueDateInput = ""

  def todoInput = _todoInput
  def todoInput_=(v: String) {
    _todoInput = v
  }

  def dueDateInput = _dueDateInput
  def dueDateInput_=(v: String) {
    _dueDateInput = v
  }

  def isTodoInputValid = _todoInput != ""
  def isDueDateInputValid = {
    val ret:Boolean = try {
      dueDate
      true
    } catch {
      case _:DateTimeParseException => false
    }
    ret
  }

  def isExecutable = isTodoInputValid && isDueDateInputValid

  def dueDate = {
    LocalDate.parse(dueDateInput)
  }

  def execute() = addTodoService.execute(this)
}

todoInputdueDateInputは、フォームの値が変更されたらUI側からここに値がぶっこまれてきます(前回見たとおりですね)。

is*Valid系のメソッドがユーザの入力値の妥当性を検証しています。この値をUI側から読み出すことで、エラーメッセージの表示などができるわけですね。

また、dueDateInputString型ですが、アプリケーション内ではStringではなくてLocalDate型で扱いたいですよね。その変換もこのクラスが行っています。

また、executeメソッドでは自身を引数にServiceをdispatchしています。

このaddTodoServiceが抽象メンバー(って正しい言葉なのかな、わかんない)であるところがちょっとしたポイントですね。Commandクラスが単体テストしやすいように他のクラスへの依存は抽象メンバーとして定義しておいてDIするわけです。DIについてよくわからない、という向きは手前味噌ですが要するに DI って何なのという話がわかりやすいと評判なのでおすすめです。

ちなみに、Scala.jsで定義されたクラスなどをJS側から触るためには、@JSExportの仲間たちのアノテーションを付けてやる必要がありますが、今回はjs_bridgeというパッケージに「JS側にexportするクラス」を置いています。見てみましょう

@JSExportTopLevel("AddTodoCommand")
class AddTodoCommand extends Command {
  protected val addTodoService = new ServiceImpl

  @JSExport
  override def todoInput = super.todoInput
  @JSExport
  override def todoInput_=(v: String) = super.todoInput_=(v)


  @JSExport
  override def dueDateInput = super.dueDateInput
  @JSExport
  override def dueDateInput_=(v: String) = super.dueDateInput_=(v)

  @JSExport
  override def isTodoInputValid = super.isTodoInputValid
  @JSExport
  override def isDueDateInputValid = super.isDueDateInputValid

  @JSExport
  override def execute() = super.execute()
}

さきほどはCommandをtrait(多重に継承できる抽象クラスみたいなもんです)として定義して、addTodoServiceは抽象メンバーでしたね。それを継承してJS側にExportするタイミングで、実際のServiceの実装を配線してDIしているのが見て取れるでしょう。

また、publicなメソッドであっても明示的に@JSExportしないとJS側からはそのメソッドをさわれないため、exportするメソッドは明示的にoverrideしています。しかしここには「実装」は一切書かれておらず、ロジックはすべてピュアScala.js(って言い方で通じるかな)であるtrait側に書かれていることに注意してください。

Serviceクラスの責務

では、CommandクラスによってdispatchされるServiceクラスは具体的にどのような仕事をしているでしょうか。「todoを追加する」というユースケースを実現するusecase.add_todoパッケージのServiceを見てみましょう。

trait Service {
  protected val repository: TodoRepository
  protected val synchronizer: TodoSynchronizer

  def execute(command: Command): Unit = {
    if ( ! command.isExecutable ) {
      return
    }

    // ...(1)
    val todo = Todo.open(
      id = repository.nextId(),
      body = command.todoInput,
      dueDate = command.dueDate
    )

    repository.store(todo) // ...(2)
    synchronizer.sync(todo) // ...(3) 
  }
}

domain層で定義されているTodoというドメインモデルを利用して、Todoを新しく作っています(1)。新しく作られたTodoを、アプリケーションの状態を保持する役目を持つrepositoryに保存しています(2)。参照系を構成するread stackの説明をするときに詳述しますが、read stackでこのrepositoryに保存されている状態を読み取ることで、UI側はアプリケーションの論理的な状態をUIとして描画する形です。

また、今回のアプリケーションでは「新しいTodoを作ったらなるべくすぐにサーバーに同期する」という仕様にしてあるので、「同期してくれる君」であるsynchronizerにtodoの同期を頼んで(3)、このクラスの責務はおしまいです。

このとき、サービスは「新しく作られたTodoがどのような状態やプロパティを持っているのか」や「repositoryはTodoをどこに保存しているのか」や「synchronizerはどうやってtodoをサーバーと同期しているのか」の詳細については一切意識していないことに気をつけてください。

次回詳述しますが、「新しくTodoが作られたときにそのTodoはどのような状態なのか」とか、そういうのはdomainパッケージに記述していきます。

このあたりの話の具体例をもう一つ出しましょう。「todoをdone状態にする」というユースケースを実現するusecase.make_todo_done.Service

trait Service {
  protected val repository: TodoRepository
  protected val synchronizer: TodoSynchronizer

  def execute(id: Int) = {
    repository.find(id).foreach {oldTodo =>
      val doneTodo = oldTodo.makeDone
      repository.store(doneTodo)
      synchronizer.sync(doneTodo)
    }
  }
}

repositoryから該当のtodoを引っ張ってきて、そのtodoに対してmakeDoneというメソッドを呼び出して「doneになったTodo」をrepositoryに保存して、同期してるだけです。「todoにどういうプロパティがあるのか」とか一切意識していません。

次回予告

次回は、usecaseパッケージが利用しているdomainパッケージとinfrastructureパッケージののうち、domainパッケージが持つ責務を具体的に見ていく予定です。