前回の記事では、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パッケージは、いわゆる「アプリケーション層」に相当するパッケージです。アプリケーション層の責務は、アプリケーションへの入力を受け取り、domainやinfrastructureにメッセージを送り、ユースケースを実現する責務です。
逆に言うと、この層に「ユースケースを実現する」以外の関心が書かれてしまうと「ドメインモデル貧血症」を起こすことになりますので、この層は薄く実装されるべきです。
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) }
todoInputとdueDateInputは、フォームの値が変更されたらUI側からここに値がぶっこまれてきます(前回見たとおりですね)。
is*Valid系のメソッドがユーザの入力値の妥当性を検証しています。この値をUI側から読み出すことで、エラーメッセージの表示などができるわけですね。
また、dueDateInputはString型ですが、アプリケーション内では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パッケージが持つ責務を具体的に見ていく予定です。