前回の記事では、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
パッケージが持つ責務を具体的に見ていく予定です。