2019年2月8日金曜日

DroidKaigi 2019 で「LiveData と Coroutines で 実装する DDD の戦術的設計」について話してきました。

前回前々回に引き続き、スピーチ原稿と合わせて公開します。

(講演ではアドリブもあるので原稿とは微妙に異なることをご了承ください)

みなさん、こんにちは。あんざいゆきです。 Y.A.Mの雑記帳というブログを書いています。Android の Google Developer Experts もしています。あと TechBooster というところで Android の同人誌書いてます。

twitter の id は yanzm でやんざむと読みます。
あんざいでも、やんざむでもお好きな方で呼んでください。

DroidKaigi でドメイン駆動設計について話をするのは3回目になります。はじめて聞くよという方も、いままで続けて聞いているよ、という方もどうもありがとうございます。

では、はじめましょう。
まずは、これまでの復習をしたいと思います。
前々回の「ドメイン駆動設計とはなにか」というところから。
詳しくは私のブログに前々回のまとめがあるので、そちらをお読みください。
ドメイン駆動設計とは

ドメインエキスパートの言葉を観察し、ドメインを構成するユビキタス言語を見つけ、
次にユビキタス言語を使ってドメインを適切に反映した、我々のソフトウェアに役立つドメインモデルを作り、
そして作ったドメインモデルを正確に表現するようコードを実装し、これを繰り返し、ドメインモデルと実装の両方を洗練させていく設計手法です。
ドメイン駆動設計では、実践するために役立つさまざまな手法が出てきます。これらは主に戦略的設計と戦術的設計の2つに分けることができます。
ドメインモデルを作り上げるために役立つ手法が戦略的設計、ドメインモデルからそれを表現した実装を行っていくのに役立つ手法が戦術的設計です。
戦略的設計のユビキタス言語、境界づけられたコンテキスト、コンテキストマップについては前々回にお話しました。
ドメイン駆動設計では、ドメインモデルをそのまま表現するように実装します。
究極的にはコードを読めばそのドメインモデルがわかるし、そのドメインモデルを理解しているならエンジニアじゃなくてもなんとなくテストコードが読めるという状態です。
具体的にどうすれば、ドメインモデルをそのまま表現した実装にできるのか。
そのための、こういうふうに実装するとうまくドメインモデルを表現できたよ、という実装パターンが戦術的設計です。

ここに一部載せていますが、戦術的設計としてたくさんパターンが紹介されています。
これらを全て取り入れないといけないというものではありません。
むしろ、一つも取り入れなくても、ドメインモデルをうまく表現した実装ができるなら、それはちゃんとドメイン駆動設計です。

前回は何の話をしたかというと、
ドメイン駆動設計を念頭に既存のAndroidアプリをリファクタリングしてこれはよかったなという戦術的設計パターンを紹介しました。
「ドメインを隔離する」という戦術的設計パターンは、Android ではgradle のモジュールで分けるという方法があり、モジュールに分けることで依存方向を強制できて、ドメインが UI などのその他の部分を知らない状態にできます。
値オブジェクトの話もしました。同じ属性値をもっているなら区別する必要がないモデルは値オブジェクトとして表現しましょう。

文字列で取り回している要素が値オブジェクトではないか考えましょう。

IDとか、種類やタイプ、日付やサイズなどは値オブジェクトを導入するよいスタート地点です。
エンティティについても少し話ました。属性値ではなく同一性で区別されるものがエンティティです。
例えば、AさんとBさんが同じ山田太郎という名前であっても、別の人として区別されます。
ここまでが復習です。
今回は、この講演のなかで、実際にモデリングと実装を考えてみたいと思います。

対象とするドメインはみなさんに関係あるこの DroidKaigi 2019 とします。
マスターデータはサーバにあって、APIを介してアプリがサーバーからデータをもらい、そのAPI仕様は変えられないという前提です。
このあと出てくるコードを github で公開しています。
まず、戦略的設計の
コンテキストマップを書いてみました。

DroidKaigi 2019 というドメインには、応募されたセッションを管理したり、スタッフの管理をしたりなど、カンファレンスを管理するという問題空間があります。 一方、参加者がカンファレンスの情報を閲覧するという問題空間もあります。

カンファレンスを管理する特定のソリューションとしてサーバー上のなんらかの実装があり、カンファレンス情報を閲覧するためのソリューションとしての Android アプリがそれとやりとりをします。

Android アプリ側はサーバーとのやりとりの仕様を変えられないので、コンテキストの統合の関係は Android側が順応者になります。

まとめると、
DroidKaigi 2019 という対象ドメインの カンファレンス情報を閲覧するという問題に対して、Android アプリというソリューションを考えます ドメインエキスパートはカンファレンスの参加者や運営者です。
次にユビキタス言語をチェックします。
ドメインエキスパートはカンファレンスの参加者や運営者ですが、自分も参加者なので、まずは運営者の言葉をチェックしましょう。

運営者にインタビューするのが一番いいのですが、運営者忙しいので、運営からのお知らせや Webサイトの言葉をチェックすることにします。
スポンサー、タイムテーブル、セッション、エンジニア、Android、カンファレンス、開催概要などなど

ざっくりチェック先を洗い出せたらさっそくモデリングしましょう。
カンファレンス情報を閲覧するという問題に対して、一番重要なのはやはりセッションの情報でしょう。
ということで、セッションについて考えます。
まずは言葉のチェックから。
タイトルとか発表者とか、実際に使われている言葉がなにかをチェックします。
DroidKaigi 2019 という比較的複雑ではないドメインであって、言葉のゆらぎは起こってしまうようで、
セッションの内容を説明する項目は、募集要項だとアブストラクトなのですが、Sessionize というセッション情報を管理するサービスを DroidKaigi は利用しているのですが、これの項目名は Description になっています。 ここは募集要項を優先してアブストラクトを使うことにします。
セッションの内容に対するカテゴリを応募時に選択するのですが、これも場所によってカテゴリだったりトピックだったりしています。
これも募集要項を優先してカテゴリ、ただし英語のほうも Category を使うことにします。
SessionFormat は発表時間、30分か50分か、
Language は発表言語、日本語か英語か
の項目なのですが、対応する日本語が Web やメールにはなかったので、そのまま英語表記のみで進めます。 本来ならここで運営者にインタビューして、日本語でなんと言っているのか確認しましょう。
これでセッションに関する項目名が出揃いました。
セッションをモデリングするにあたって、セッションが値オブジェクトなのかエンティティなのかを考えましょう。

最初の復習のところでも触れましたが、属性値によって区別されるされるものが値オブジェクト、同一性によって区別されるものがエンティティです。
まったく同じタイトルなら同じセッションかというと、別々のセッションで同じタイトルはありえるので No

カンファレンス当日までの間にアブストラクトが変わったとしても同じセッションなので Yes

よってセッションはエンティティです。
同一性のあるセッションをドメインモデルとして表現するには一意に識別するための ID が必要になります。
ということで、セッションを構成するものが出揃いました。
これを実際に実装してみましょう。
id の型は値オブジェクトです。ここでは data class にしていますが、Kotlin の inline class を使うのも手です。
タイトル、
アブストラクト

プロパティ名にはユビキタス言語をつかいます。
発表者は、複数人の場合がありえるので List にしました。
発表者についてまだモデリングしていないので Speaker は空クラスにしておきます。
SessionFormat は選択肢が30分か50分と決まっているので enum にしました。
Language も同様に enum
カテゴリも同様に enum にしました。
同時翻訳は、対象か、対象でないかの2択なので Boolean にしました。
部屋も選択肢が決まっているので enum にしました。
日付・時間についてはまだモデリングしていないので Speaker と同じく空クラスにしておきます。
できました。
これを使ってダミーデータを用意しておきます。
いま作った Session クラスおよびそのダミーデータを使って、セッション詳細画面を作ってみましょう。
TextView を並べて、String の項目はそのままセットして、それ以外は toString() をセットすると
こんな感じになります。
タイトルとアブストラクト以外のところの文字列表現を考えないといけないですね。まずは enum から考えましょう。
この種別のテキスト表現をどこに置くか問題ですが、3つほど案があります。
まず、enum に string resources を持たせる方法はどうでしょうか
UI 側は TextView の setText() に string resrouce の id をそのまま渡せばいいのでシンプルです。
ただし、この enum を置く domain module が Android Framework に依存することになります。
Kotlin multiplatform project ではこの enum を shared code に置けません。
enum に文字列を持たせるのはどうでしょうか。

多言語対応として Locale に応じて文字を出し分ける必要があります。
UI 側から Locale を渡す必要があるので案1より UI側は複雑になります。

また、java.util パッケージ の Locale に依存していると、Kotlin multiplatform project の shared code に置けません。置くなら Locale に相当するものを expect で用意するか、
このように Locale による判定を UI 側で行うようにする必要があります。
3つめの UI に string resoures を持たせる方法はどうでしょうか。
拡張関数として string resoure id を返す関数を定義すれば、UI側からは enum が string resources を持っているかのように記述できます。
いちいち Locale を渡さなくて済みますが、Kotlin multiplatform project の場合、各プラットフォームごとに文字列を定義しなければなりません。
enum のテキスト表現をどこに置くのか
ドメインモデルの表現としてモデルが文字列を持つのが自然なら enum に文字列を持たせればいいし
文字列表現は UI の関心事とするのが自然なら UI に文字列を定義すればよいのです
カンファレンス情報を閲覧するドメインではどうかという視点もあるし、Kotlin multiplatform project にするかどうかという実装からのフィードバックがモデルに影響するということでもあります。

一概にこれと決められるものではありません。
実装の前提として Kotlin multiplatform project ではないので、ここでは UI に string resources を持たせることにしましょう。
SessionFormat, Language, カテゴリー、部屋 の種別を文字列リソースにマッピングする拡張関数を用意して、
それを利用するように UI 側を変えると
種別部分の表示も良くなりました
残りは発表者と日付·時間部分です。
まず、日付・時間について考えましょう。
セッションには開始日時と終了日時があるので、
単純に start と end を持たせて見ました。
セッションの日時は東京での時間、つまり関心ごとは現地時間なので
LocalDateTime を使ったり、”Asia/Tokyo” ゾーンに固定した ZonedDateTime を使った方がよさそうです。
DroidKaigi 2019 のドメインでは日をまたぐセッションはないので、日付と時間を別々にすると、日をまたぐセッションはないということを表現できます。

日に LocalDate、時間に LocalTime を使えば関心ごとは現地時間ということも表現できます。
これでセッションの日付と時間を表現できるようになりましたが、TimeAndDate の文字列表現をどこで行うかを考えないといけません。
日付・時間のテキスト表現がモデルの関心事なら、TimeAndDate に文字列を返すメソッドを用意すればよいでしょう。

この場合、テキスト表現のロジックが変わったら、それを利用しているすべての画面に影響します。影響して OK というか、影響しなければならない、という状況なら日付・時間のテキスト表現はモデルの関心事です。
一方、画面によってテキスト表現がことなるなら、日付・時間のテキスト表現は UI の関心事でしょう。

日付・時間のテキスト表現が UI の関心事なら、UI 側に文字列を返すメソッドを用意します。
いずれの方法をとるにせよ、これで日付·時間の表示もよくなりました。
残っていた発表者について考えてみましょう。
発表者はエンティティですね。発表者Aさんと発表者Bさんが同じ名前でも別の発表者です。これはもう大丈夫ですよね。
発表者に関する言葉をチェックしました。
あいまいな言葉として「氏名」と「表示名」がありましたが、ここでは 氏名:Name を使うことにします。
Speaker の id も値オブジェクトとして用意しましょう。
ダミーデータを更新して
発表者の表示もよくなりました。
そろそろダミーデータをやめたいですよね。
そこで、次に導入するのがリポジトリーです。
ドメイン駆動設計じゃなくてもリポジトリーって名前わりと聞ききますけど、リポジトリーって結局なんなんでしょう。

「エリック・エヴァンスのドメイン駆動設計」ではリポジトリーについて何と言っているか見てみましょう。
リポジトリは、特定の型のオブジェクトを、すべて概念上の集合(通常は、それを模したもの)として表現する。これはコレクションのように動作するが、より手の込んだクエリ機能を持っている。
グローバルアクセスを必要とするオブジェクトの各型に対して、あるオブジェクトを生成し、その型のすべてのオブジェクトで構成されるコレクションが、メモリ上にあると錯覚させることができるようにすること。
よく知られているグローバルインタフェースを経由してアクセスできるようにすること。… クライアントをモデルに集中させ、あらゆるオブジェクトの格納をアクセスをリポジトリに委譲すること。

ちょっと、よくわからないですよね。

エリック・エヴァンスのドメイン駆動設計で言っていることを簡単に解説します。
モデルのオブジェクトを使って何かをするにはそのオブジェクトへの参照が必要になります。

参照を手に入れる方法として、1つ目はオブジェクトを生成するというのがあります。
これまではセッションのダミーデータを生成していたので、それを使って画面に表示することができました。

2つ目が関連をたどるというものです。セッションからその発表者オブジェクトへたどることができます。

3つ目がデータベースに格納されているオブジェクトを取り出すことです。

3つ目の方法で、もしクライアントが直接クエリを構築してデータベースからデータを取得し、オブジェクトを作っていたら、エンティティと値オブジェクトはただのデータコンテナになってしまいます。
そこで、技術的なインフラストラクチャやデータベースアクセスの仕組みをカプセル化し、クライアントからは、特定の型のオブジェクトの概念上の集合のように見えるようにしたのがリポジトリです。
つまり、クライアントが、どう永続化されているかとかどういうインフラストラクチャを使っているかという技術的な詳細を意識せず、必要なオブジェクトの参照を取得するためのあれこれを一手に引き受けてカプセル化するのがリポジトリです。

「エリック・エヴァンスのドメイン駆動設計」ではデータベースアクセスが念頭にある感じですが、内部で行なっていることがサーバーアクセスだとしても、永続化先がサーバーなだけで役割としてはあっています。

セッションの概念上の集合として SessionRepository を考えてみましょう。
カンファレンス情報を閲覧するために、各カンファレンス日にあるセッションのコレクションを返す機能と特定のセッション ID に一致するセッションを返す機能が必要です。
セッションの追加と削除は今回のドメインでは必要ないので、
インタフェースはこのようになります。
この SessionRepository を実装した AssetsSessionRepository があるとします。
DetailActivity で AssetsSessionRepository インスンタンスを生成するとAssetsSessionRepository を利用することになってしまいます。
クライアントはグローバルなリポジトリインターフェースを利用するべきですし、実装面からみても、これだとテスト時にリポジトリを差し替えることができなってしまいます。
DetailActivity で AssetsSessionRepository インスンタンスを生成するのではなく、外部から SessionRepository インスタンスを渡すようにし、DetailActivity は SessionRepository の実態が何であるかを知らないようにします。
これを実現するには、SessionRepository のインスタンスをどう UI 側に渡すかという問題を解決しないといけません。
Android では UI 側からグローバルにアクセスできるところとしてよく Application を使います。
管理するものや画面が増えてきたら、自分でやるのは大変なので適宜 Dagger など DI コンテナを導入しましょう。
Application で SessionRepository のインスタンスを保持し、テスト時には差し替えられるようにしておきます。

拡張関数を用意しておくと、Activity から SessionRepository のインスタンスを取得するコードがシンプルになります。
リポジトリーでデータベースアクセスやサーバーアクセスの処理をカプセル化することで、クライアント側、つまり UI 側はオブジェクトが再構成されるときの技術的詳細を気にしなくてよくなりました。
しかし、Android ではデータベースアクセスやサーバーアクセスなどの時間のかかる処理を UI スレッドから呼び出すのはよくありません。
そのためリポジトリーからモデルを取得する処理はバックグランドで行うようにする必要があります。
そこで、Coroutines を使ってこの問題に対応してみたいと思います。
Activity で Coroutines を使うときのパターンをものすごくざっくり紹介しますので、詳しくは本日 15時40分に Room3 である mhidaka のセッションを見てください。
まず Activity があるとして
この Activity に Job を持たせます
次に Activity が CoroutineScope を実装するようにします
CoroutineScope は CoroutineContext プロパティを持ったインタフェースです。 Activity では Dispathers.Main と先ほど持たせた Job から構成される CoroutineContext を返すようにします。
onCreate() で job のインスタンスを生成し、onDestroy() でキャンセルします。
リポジトリーからモデルを取得して TextView にセットしている部分を
launch 内に移動し、
さらにリポジトリーからモデルを取得する部分は withContext() 内に移動します。
withContext() 内の処理はバックグラウンドで実行したいので Dispatchers.Default を指定します。
こうすると、リポジトリーからモデルを取得する部分はバックグラウンドで実行され、その処理が終わると UI スレッドで TextView にセットする部分が実行されます。
さて、リポジトリーからモデルを取得している途中でユーザーが Activity を閉じると、 onDestory() に記述した job の cancel() が呼ばれます。
このとき withContext() から抜けた後の処理、つまり TextView にセットしている部分の処理は実行されません。
しかし、リポジトリーからモデルを取得する処理が終わるまで、つまり SessionRepository の sessionId() メソッドが値を返すまで Coroutine は止まりません。
Coroutine のキャンセル処理は cooperative であり、キャンセルされる側がキャンセルシグナルが来ているかどうかをチェックして、行なっている処理を中止する必要があります。
リポジトリーが内部で行なっているディクスアクセスやサーバーアクセス処理を Coroutine のキャンセルシグナルに応じて中止するには、リポジトリーのメソッドを suspend 関数にします。
suspend 関数内では Coroutine にキャンセルシグナルが来ているかチェックできるため、それに応じて内部で行なっている処理を中止できます。
これで onDestory() で処理がキャンセルされるようになりましたが、まだ問題があります。

onCreate() で処理を開始して onDestory() でキャンセルしているので、画面回転すると以前の処理をキャンセルして再度バックグラウンド処理を行ってしまいます。
この問題に対応するために ViewModel を使いましょう。
ViewModel は Android Architecture Components で提供されている機能です。
画面回転や画面サイズが変わるなど Configuration の変更がおこると Activity が再生成されますが、ViewModel のインスタンスはこの再生成を超えて保持されます。
ViewModel のインスタンスを取得するには ViewModelProviders を利用します。
ViewModelProviders から ViewModel のインスタンスを取得する際、対応するものがない場合は生成され、画面が回転して Activity が再生成されても ViewModel のインスタンスはそのまま保持され、Activity が finish して破棄されると ViewModel の onCleard() が呼ばれて破棄されます。
ViewModel を利用するには、ViewModel または AndroidViewModel を継承したクラスを作ります。
ViewModel のインスタンス生成タイミングはライブラリがハンドリングするため、任意の引数を ViewModel のコンストラクタで渡したいときは、factory を指定します。
DetailViewModel のコンストラクタでセッションの ID とリポジトリーを渡せるように Factory を用意します。
Activity では、用意した Factory を指定して DetailViewModel を取得します。
そして、リポジトリーからセッションを取得する処理を ViewModel に移動します。
ViewModel で Coroutines を使うときのパターンもざっくり紹介します。
Activity のときと同じように ViewModel が CoroutineScope を実装するように、Dispatchers.Main と パラメータで持つ Job から構成される CoroutineContext を返すようにします。
ViewModel 生成時に Job のインスタンスも生成し、onCleared() で job をキャンセルします。
あとは初期化時にリポジトリーからセッションを取得する処理を開始し、取得した結果を保持してリスナーに通知します。
ViewModel のインスタンスは画面回転がおこっても保持されるので、ViewModel で取得処理を行えば画面回転がおこっても処理が継続されます。
リスナーをセットしたときにデータ取得済みならすぐ通知してほしいので、その処理もいれておきます。
しかしこのコードはリスナーの呼び出しが2ヶ所になっていますし、すでにデータを取得済みかどうかを自分で判定してリスナーを呼び出していて複雑です。
データ取得ずみならすぐに通知し、そうでなければ取得されたタイミングで通知したいという場合、LiveData を使うのがぴったりです。
LiveData は一言でいうと Observe できるデータホルダーです。
Lifecycle に応じて自動で Observer を解除するという特徴があります。
ViewModel が LiveData を持ち、Activity がそれを Observe するようにすると、LiveData の値が更新されたときに Activity が onStart() から onStop() の間であれば通知し、そうでなければ次に Activity が onStart() になったときに最新の値を通知します。
DetailViewModel でセッションを LiveData で持つようにするには、セッションを保持するための MutableLiveData インスタンスを生成し、リポジトリーから取得したセッションを MutableLiveData にセットします。
さらに Activity へは、 MutableLiveData ではなく LiveData 型で公開します。
Activity では LiveData の observe() に LifecycleOwner、この場合 this を渡して observe します。
さて、これでモデリング、リポジトリー、Coroutines、ViewModel、LiveData と一通りの実装ができました。
リポジトリーにはすでにカンファレンスの1日目、または2日目のセッション一覧を取得する口があるので、
TimetableViewModel で Coroutines を使ってバックグラウンドでリポジトリーからセッション一覧を取得し、LiveData で保持して Activity や Fragment に通知するようにすると
このようなタイムテーブル画面ができます。
さて、このタイムテーブル画面をよくみると、ウェルカムトークや Codelabs がありません。公募されたセッションしか表示されていないのです。
ここで、ちょっと考えてみましょう。
タイムテーブルを構成する要素として、公募セッション以外にウェルカムトークや Fireside Chat、Codelabs、パーティー、ランチがあります。 これらはセッションでしょうか?

応募されたものではないので、もちろん採択されたセッション一覧にはのっていません。
ぞれぞれを構成するものをまとめてみました。
SessionFormat、Language、カテゴリーは応募されたセッションにしかありません。
一方、日付・時間はすべてにありますし、部屋もランチ以外はあります。
そのため種別を導入すると、データベースで同じテーブルに保存できます。
しかし、保存に適した形式をそのままモデルにしていいのでしょうか?
これはサーバーのレスポンスをそのままモデルにしていいのか?という問題とも共通していますよね。
ウェルカムトークも Session で表現するために、
ウェルカムトークにない属性を Nullable にすると
公募セッションに誤って null がセットされるようなコードを書いてしまうことを防げないし、
モデルの、公募セッションなら絶対 SessionFormat があることを表現できません。
種別に対象外を追加する方法はどうでしょう。
これも公募セッションに誤って対象外がセットされるようなコードを書いてしまうことを防げないし、
公募セッションなら絶対 SessionFormat が対象外でないことを表現できません。
保存形式とモデルの表現が同じとは限らないのです。

ではどうしましょうか...
まずセッションっぽいやつとセッションっぽくないやつを分けてみました。
セッションっぽいやつは詳細画面でアブストラクトなどの情報をみたいやつです。
セッションっぽいやつを Session と定義して、これまで Session にしていた応募されたセッションは PublicSession とし、ぞれぞれ Session を継承するようにしてみました。
Session を sealed class で実装し、PublicSession や WelcomeTalk は data class で実装し、共通するパラメータは Session の方に abstract で定義します。
では、セッションっぽくないやつはどうしましょうか。
パーティーとランチをそれぞれクラスで定義するとして、セッションっぽいやつとの関係をどう表現するのか。
これら全体の共通点は、タイムテーブルを構成する要素であるという点です。すべて日付・時間が決まっています。
そこで、タイムテーブルを構成する要素を表現する TimetableItem を定義し、Party, Lunch, Session がこれを継承するようにしました。
関係を図示すると、このような関係になります。
あとはリポジトリーが TimetableItem の一覧を返すように変更し、モデルに応じて表示を変える処理を UI 側で行えば
タイムテーブルにウェルカムトークや Codelabs、ランチ、Fireside Chat、パーティを表示するようにできます。

しかし、私はこのモデリングに自信がありません。なぜなら TimetableItem という言葉がユビキタス言語にないからです。モデリングはいつもすごく悩みます。Timetableクラスを用意して、そこに PublicSession のコレクションとパーティとランチを持たせたらどうだろう、なども考えました。
ユビキタス言語を探し、クラスや属性名にはユビキタス言語を使いましょう。
DroidKaigi のようなわりとシンプルなドメインでもあいまいな言葉がありました。
あいまいな言葉を見つけたらどれを使うのかチームで決め、常にそれを使うようにしましょう。

ViewModel と LiveData はとても便利です。ぜひ使ってください。

リポジトリーはデータベースアクセスやサーバーアクセスなどをカプセル化し、技術ではなくドメインのモデルに焦点あわせるようにするためのものです。

データベースアクセスやサーバーアクセスがあるため、Android ではリポジトリーからモデルを取得する処理はバックグラウンドで呼び出す必要があります。

この問題を解決するのに Coroutines を使うという方法があります。

さらに、リポジトリーが提供するメソッドを suspend 関数として定義すると、非同期処理が求められていることをクライアントに伝えることができ、Coroutines キャンセル処理に応じてリポジトリー内部で行なっている処理をキャンセルする実装が可能になります。

最後に、

今回 DroidKaigi 2019 を題材にモデリングをしてみましたが、モデリングに正解はないと思っています。
とくにタイムテーブルの部分については、種別を持たせるようなモデリングもありだと思います。

今回の題材がみなさんのモデリングの一助になれば幸いです。

ありがとうございました。





0 件のコメント:

コメントを投稿