この記事は、2017年3月4日に大阪にて行われたYAPC::Kansaiというカンファレンスで発表した、「Vue.jsで学ぶMVVM 非同期処理 その光と闇」というプレゼンの再現ブログの後編です。前編はこちらですので、前編をまだ読んでない方はぜひそちらからどうぞ。それでははじめます。
Bパート導入
さて、Aパートでは、
- 無駄にSPAするのはよくない
- SPAするならGUIのアーキテクチャ・パターンを参考にしよう
- GUIアプリケーションパータンの中から、MVVMとはどういうパターンなのか
ということについて見てきました。Bパートでは、MVVMを採用したプロダクトでわたしが実際にどのような課題にぶち当たり、どのようにその課題に対応してきたかについて見ていきたいと思います。
認証、セッションまわりの話
まずは、実用的なwebアプリケーションには付き物である認証、セキュリティ周りの話から見ていきます。
スマートフォンなどのGUIアプリケーションならば、だいたいAPIサーバーにtokenを払い出してもらい、そのtokenをセキュアな場所に永続化して使うというのが普通の実装になるかと思います。
一方、ブラウザ上でうごくアプリケーションの場合、CookieとSessionによる認証/認可のプラクティスがすでに存在しています。また、多くのユーザは「ふつうのwebアプリケーション」と「SPA」の違いについてそこまで意識していないでしょう。ログインフォームには「次回からもにログインしたままにする」というチェックボックスが付いているのが普通だし、長らくアクセスしてなかったらセッションが揮発するのが自然だと感じるようにすでに「教育」されています。そういう中で、自前でlocalstrageなどを利用して認証/認可を実装するのは果たして得策でしょうか?これはあくまで私の意見ですが、認証/認可についてはすでに「普通のブラウザアプリケーション」がセキュリティも含めてベストプラクティスが確立しています。ならば、なるべくそのレールに乗ることが安全なのではないでしょうか。
CORS問題
さて、SPAを開発していると、とくにサーバーサイドレンダリングを行わない場合は、「あれ、これ、バックエンドのAPIサーバーはJSON吐くだけだし、静的ファイルをS3とかから配信すれば自前でサーバー用意しなくていいんじゃないんの」という考えが頭をもたげてきます。
ただまあ、なにも考えずにそうしてしまうと、JSON吐いてるバックエンドwebサーバーと静的ファイルを配信してるサーバーのオリジンが異なるということが普通に起こってくるわけです。最初のうちは「静的ファイル配信してるオリジンからのAjaxリクエストを許可すりゃいいんでしょ?余裕だわ」みたいなこと言ってそれで開発しようとしたんですけど、これ、Safariを利用したとたんに破綻するんですね。
というのも、Safariはデフォルトの挙動では他のオリジンからのSet-Cookieヘッダを捨てるという実装になっています。そうするとですね、
- ブラウザ側がログインフォームからログインのリクエストをAjaxで送る
- Set-Cookieのついたレスポンスが「別オリジンから」返ってくる
- 200 OKなので、次の画面を描画し、別のAPIを叩こうとする
- Set-Cookieが捨てられているので空Cookieで送ってしまう
- ログインした途端に「ログアウトしました」というメッセージが表示され、ログインフォームに戻される
という最高に便利()なアプリケーションが完成してしまうわけです。
とはいえ、まあこれはどちらかというとSafariが偉くて、セキュリティ的なことやプライバシー的なことを考えるとやっぱりSafariみたいに安全側に寄せていくべきなんですよね。
ということを考えると、とくに問題がないならばなるべく同じオリジンから配信するってのが安牌なのではないでしょうか。我々は結局そのようにしました。そのようにしない知見をお持ちの方は是非どこかしらで共有してください。普通にその話めちゃめちゃ聞きたいです。
CSRF問題
さて、普通にCookie認証でやる、となると、普通にCSRF対策をしないといけませんね。でもこれ特に言うことなくて、普通に普通のCSRF対策をすればいいと思います。
このあたりはほんとに「普通にやる」ってことがすごい大事だと思っていて、普通にやれば、API側はweb application frameworkの恩恵も受けられるし、とにかくブラウザの仕組み、HTTPの仕組みに乗っかっていくというのが大切だと思います。
非同期処理との戦い
つづいては非同期処理との戦いについて見ていきたいと思います。
やりがちな失敗として、プレゼンテーションレイヤーのコンポーネントの分割と同じようにモデルを分割しちゃって、なおかつそれぞれが勝手気ままにモデルを呼び出したりするみたいな失敗があると思うんですね。ちょっとこれ例を出さないとわかりにくいと思うので、例をだしましょう。
たとえばこういうアプリケーションを考えてみましょう。ページ上部にグローバルナビゲーションがあって、その下にはフラッシュメッセージが出て来るような領域があります。で、その下がメインで、左側にユーザ基本情報。右側には日毎の情報が並んでるダッシュボードみたいなものがある画面を考えてみます。
グローバルナビゲーションとフラッシュメッセージは常に表示しているので、ページ遷移してもここは基本的に書き換わらないとします。
一方、メインの領域はURLが変われば書き換わります。
また、日別ダッシュボードなので、日付を変えればユーザ基本情報の部分は書きかわらないけれど、ダッシュボードの部分だけ書き換わります。
ViewModelの設計
こういう画面を作るなら、わたしならこういうふうにViewModel(というかコンポーネント)を分割します。
まずRootとなるVMが、常に表示するグローバルナビゲーションと、フラッシュメッセージを表示するためのアラートのVMを持ちます。その下に、RouterとなるViewModelを保持し、こいつがURLに応じてダッシュボードのVMを読み込んだり、別のVMを読み込んだりします。
こうすることで、たとえば/dashboard/:date
にアクセスしたら、ルーターがダッシュボードのVMを読み込み、表示します。
また、日付を変更した場合は、dashboard VM が daily info VM の日付を変更し、その日の情報をロードします。
Modelの設計(駄目なやつ)
さて、つぎは、やりがちなModelの失敗設計です。
それぞれのViewModelが、それぞれのコンポーネントが関心を持つModelを持ち、その向こうにAPIサーバーがあります。
これで、たとえば、/dashboard/:date
にアクセスがあった場合、user info ViewModelはUserモデルの「loadUser」かなんかをdispatchし、daily info ViewModelも「loadDashboardOf(:date)」みたいなメソッドをdispatchします。
すると、ModelたちはAPIと通信し、
その情報を自らの状態に書き戻します。場合によってはエラーが起こった旨を伝えるために、Alertモデルを書き換えたりするでしょう。
それぞれのViewModelは、それぞれのModelの変更を監視しているので、これで無事画面が書き換わります。
:date
の部分が変わった場合は、
daily info VMだけで同じことが起こります。
一見すると結構よさそうな設計に見えますね。
しかし、たとえば、/dashboard/:date
にアクセスしたタイミングでセッションが切れた場合はどうでしょうか。
同じようにuser info VMとdaily info VMがUser ModelとDailyInfoModelを叩きます。そして、それぞれのモデルが独立に非同期リクエストをサーバーに対して送るでしょう。その結果、サーバーはそれぞれのモデルに対して独立に「セッションが切れましたよ」というエラーを返すでしょう。
そして、セッションが切れたのであれば。「ログインしてるアカウント」の情報を保持してる「CurrentAccount」みたいなモデルを適切にログアウトさせてあげないといけません(そうすると、この「CurrentAccountがログアウトした」というイベントを監視してるVMがログインページへ遷移させるなどの操作をするでしょう)。
しかし、今回は独立に2回もエラーが返ってきているので、なんとCurrentAccountモデルは二回もlogoutメソッドを呼ばれてしまいました!
OOPS!レースコンディションです!今回のレースコンディションなら、CurrentAccountモデルが「ログアウトしてる状態でlogoutメソッド呼ばれてもなにもしない」くらいの対応でなんとかなるかもしれませんが、もしこれが「呼ばれる順番に意味がある」みたいなメソッドだったら、事態は一層深刻です。
と、こんな感じで、複数のモデルを好き勝手VMから叩いてしまうと、非同期処理でレースコンディションを起こしがちという問題があります。これに対応するためには、「窓口」をきちんと作ってあげるのが有効です。
各ViewModelが、関心のあるモデルの変化を監視しているというのには違いがありませんが、「usecase」という層をひとつ設けてあります。
ダッシュボードにアクセスすると、VMはこのusecaseを叩きます。
叩かれたusecaseは、APIと通信をします。
その結果に応じ、各モデルのメソッドを呼び、モデル内に変化を起こします。すると、各モデルの変化を監視しているViewModelは、各モデルから新しい値を読み出し、Viewに反映する、という流れです。
仮に、セッションが切れた場合はどうでしょうか。
さきほどまでは独立にモデルが好き勝手非同期処理を行っていましたが、今回はusecaseが「非同期処理の待ち合わせポイント」になっています。
そのため、ふたつのレスポンスをまってもよし、いっこめでエラーが返ってきた時点で次のPromiseの値は捨てちゃって、すぐにログアウトするもよし、という感じで、非同期処理によるレースコンディションに立ち向かうことができました。
なにが言いたいのかというと、つまり、こういうことです。
Bパートまとめ
さて、Bパートのまとめです。
ブラウザを作ってるひとたちや、webの仕組みを作ってるひとたちはわたしたちのアプリケーションを作る以上のとてつもない労力と知力をつぎ込んでそれらを作っています。そもそも規模が違う。ならば、なるべくそのレールに乗る、というのが、SPAを作る上でも重要なのではないでしょうか。
画面遷移するたびに同期的に画面を全部書き換える牧歌的な時代は過ぎ、われわれは非同期処理でばしばし画面の一部を書き換える時代に生きています。そういうときに、プレゼンテーションレイヤーに非同期処理の複雑さが漏れ出してしまわないように、ドメインのレイヤーにきちんと非同期処理の「待ち合わせポイント」「管理ポイント」を設けてあげて、非同期処理の複雑さに立ち向かいましょう。
というわけで、Bパート完です。エンディングテーマを挟んでの、Cパートとして、全体のまとめです。
はい、とくにこれ以上に言うことはありません。
このプレゼンは、株式会社メディロムの提供でお送りいたしました(交通費とか宿泊費とか)。株式会社メディロムでは一緒にはたらくメンバーを募集しています。ぜひお声がけください。