さて、今回はユーザー個別データの管理部分を開発していく予定だが、まずその前にやっておくことがある。

新スキル「風雷合一」に対応する

 Ver.3.0の大型無料アップデートで追加された新スキルに「風雷合一」というのがある(英語表記は「Stormsoul」、こっちのスキル名の方が格好いいな……)。このスキル、特定防具の強化レベルを9以上にしないと発動しないうえに、スキルレベルが4以上になると、全部位の防具自体に初めからバンドルされているビルトインスキルのレベルを+1~+2するという効果を持っている。ゲームではかなり面白いスキルで、コーディネートの幅が広がって楽しいのだが、このシミュレーターにとってはかなり厄介な仕様なのである。
 まぁ、「スキル」シミュレーターを名乗っているからには、このスキルも忠実にアプリ内に再現させなければばらないわけで、さっそくこの新スキルに対応すべくコンポーネントの処理を改修して行く。

 まず、スキルの発動条件が防具のレベルが9以上なので、VuexストアのsetArmorLevelアクションをスキル内訳用のコンポーネントで拾って、従来のスキルレベル集計処理の後に、防具のビルトインスキルのみスキルレベルを加算するようにした。「風雷合一」はそれぞれ個別の防具のスキルに対してではなく、合計されたスキルレベルに対して加算が発生するからだ。
 そして、やっかいなのが、「風雷合一」のスキルレベル1から発動する雷・龍属性攻撃値の強化だった。これって、他の属性攻撃強化系スキルが発動した後の強化値に対して倍率強化が入るのだ。他の属性攻撃強化スキルは、武器のベース属性値に対して倍率と加算が入るので、それらと同列には処理できないことが分かり、結構手広く改修する羽目になった。
 装備とスキルとレベルと装飾品の変更ごとに各コンポーネント間でかなり複雑な処理が入り乱れるような感じになったが、Vuexストアのゲッターとミューテーション、アクションのおかげで、そこまで迷わず作れたのが幸いだった。まぁ、結果として、下記のようにストアのコード量はかなり増えた(src/store/index.js)。

const getInitialState = () => {
  return {
    weapons: [],
    armors: [],
    talismans: [],
    decorations: [],
    skills: [],
    skill_evaluation: [],
    weapon_meta: [],
    ammo: [],
    weapon:   { data: {},           slots: {}, },
    head:     { data: {}, level: 1, slots: {}, },
    chest:    { data: {}, level: 1, slots: {}, },
    arms:     { data: {}, level: 1, slots: {}, },
    waist:    { data: {}, level: 1, slots: {}, },
    legs:     { data: {}, level: 1, slots: {}, },
    talisman: { data: {},           slots: {}, },
    aggs: {},
    player: { gender: 'female', items: [], },
  }
}

const state = () => getInitialState()

const getters = {
  // Get all weapons of the specified weapon type
  weaponsKindOf: (state) => (kind) => {
    return state.weapons.filter(weapon => weapon.type == kind)
  },
  // Get all armor in the specified part
  armorsKindOf: (state) => (part) => {
    return state.armors.filter(armor => armor.part == part)
  },
  // Get singular data with specified ID for each data table
  itemsById: (state) => (type, id) => {
    return state[type].find(item => item.id === id)
  },
  // Whether or not the item is currently equipped
  equipmentExists: (state) => (kind=null) => {
    let isExists = false,
        parts = kind == null
          ? ['weapon', 'head', 'chest', 'arms', 'waist', 'legs', 'talisman']
          : (/^armors?$/.test(kind) ? ['head', 'chest', 'arms', 'waist', 'legs']: [kind])
    parts.forEach(part => {
      if (state[part] && Object.prototype.hasOwnProperty.call(state[part], 'data')) {
        for (let key in state[part].data) {
          if (key === 'id' && state[part].data[key] != 0) {
            isExists = true
          }
        }
      }
    })
    return isExists
  },
  // Get the currently equipped item data by specifying the type and key
  equipmentKindOf: (state) => (kind, key=null) => {
    return !key ? state[kind].data: state[kind].data[key]
  },
  // Get the level of the currently equipped armor
  armorLevelKindOf: (state) => (part) => {
    if (Number.isFinite(part)) {
      part = ['head', 'chest', 'arms', 'waist', 'legs'][part]
    }
    return state[part].level
  },
  // Get the slot status of the currently equipped item
  currentSlotsKindOf: (state) => (kind) => {
    return state[kind].slots
  },
  // Get the skills granted by decoration in slots
  currentSkillsInSlots: (state) => (kind) => {
    let skills = []
    for (let [, decoId] of Object.entries(state[kind].slots)) {
      let decoration = state.decorations.find(item => item.id === decoId)
      if (decoration) {
        skills = skills.concat(Object.keys(decoration.skills))
      }
    }
    return skills
  },
  // Get the player's specified data
  playerDataOf: (state) => (target) => {
    return state.player[target]
  },
}

const mutations = {
  // Usage: $store.commit('setItems', {property: 'weapons', data: response.data})
  setItems: (state, payload) => {
    state[payload.property] = payload.data
  },
  // Usage: $store.commit('addItem', {property: 'talismans', data: newTalisman})
  addItem: (state, payload) => {
    //console.log('$store.commit::addItem:', payload)
    state[payload.property].push(payload.data)
  },
  // Usage: $store.commit('updateEquipItem', {property: 'head', data: {...}, slots: []})
  updateEquipItem: (state, payload) => {
    if (Object.prototype.hasOwnProperty.call(payload, 'data')) {
      state[payload.property].data = payload.data
    }
    if (Object.prototype.hasOwnProperty.call(payload, 'level')) {
      state[payload.property].level = payload.level
    }
    if (Object.prototype.hasOwnProperty.call(payload, 'slots')) {
      state[payload.property].slots = payload.slots
    }
  },
  // Usage: $store.commit('updateArmorLevel', {part: 'head', level: n})
  updateArmorLevel: (state, payload) => {
    state[payload.part].level = payload.level
  },
  // Usage: $store.commit('updateAggSkills', {aggrigation: {}})
  updateAggSkills: (state, payload) => {
    state.aggs = payload.aggrigation
  },
  // Usage: $store.commit('clearEquipments', {kind: 'all'})
  clearEquipments: (state, payload) => {
    if (payload.kind === 'all') {
      ['weapon', 'head', 'chest', 'arms', 'waist', 'legs', 'talisman'].forEach(key => {
        state[key] = Object.assign(state[key], getInitialState()[key])
      })
    } else {
      Object.assign(state[payload.kind], getInitialState()[payload.kind])
    }
  },
  // Usage: $store.commit('updatePlayerData', {property: 'gender', value: 'female'})
  updatePlayerData: (state, payload) => {
    state.player[payload.property] = payload.value
  },
  // Usage: $store.commit('resetState')
  resetState: (state) => {
    Object.assign(state, getInitialState())
  },
  // Usage: $store.commit('resetMasterData', {property: 'talismans'})
  resetMasterData: (state, payload) => {
    Object.assign(state[payload.property], getInitialState()[payload.property])
  },
}

const actions = {
  initData: ({ commit }, payload) => {
    commit('setItems', payload)
  },
  addData: ({ commit }, payload) => {
    commit('addItem', payload)
  },
  setEquipment: ({ commit }, payload) => {
    commit('updateEquipItem', payload)
  },
  setArmorLevel: ({ commit }, payload) => {
    commit('updateArmorLevel', payload)
  },
  setAggSkills: ({ commit }, payload) => {
    commit('updateAggSkills', payload)
  },
  removeEquipment: ({ commit }, payload) => {
    commit('clearEquipments', payload)
  },
  setPlayerData: ({ commit }, payload) => {
    commit('updatePlayerData', payload)
  },
  resetState: ({ commit }) => {
    commit('resetState')
  },
  resetMasterData: ({ commit }, payload) => {
    commit('resetMasterData', payload)
  },
}

 いやはや、かなり骨が折れたが、何とか実装できたのでとりあえず一安心である。
 なお、「風雷合一」をはじめVer2.0以降に追加された新スキルをシミュレートできるように防具データもいくつか更新してある。後述のモックアップでそれらは試すことができる。

ユーザーデータの保存先の選定

 さて、ようやくこの回の本題に入る。
 このシミュレーターでは、ユーザーごとに自分が登録した護石のデータや、コーディネートした装備一式のデータをマイセットとして保存できるようにしたい。これらのユーザー個別のデータを保存する先として、真っ先に思いつくのはデータベースだろう。だが、データベースにユーザーデータを保存した場合、そのデータを再利用する際に使用されるユーザーを引き当てる必要がある。そのためにはユーザー自体を識別するためのデータも同時に必要になり、それにはユーザーを登録させるという仕組みが必要になって来る。つまり、ユーザーを登録するためのサインアップと、ユーザー認証用のサインイン・サインアウトなどの機能が必要になるということだ。本格的なWEBサービスであれば、その実装も分かるが、今回のアプリではそこまで大仰な仕組みを導入したくない。登録したユーザー個別データの管理リスクとか厄介事が増えるのが嫌だって云うのもあるが、最大の理由は作るの面倒だし、作ってて楽しくないからだw
 そんなわけで、ユーザー認証も必要なくユーザー個別のデータを保存できる仕組みを考えなければならないのだが、まぁ、これはWEBアプリで、ブラウザからしか使えないモノなので、ブラウザに保存してしまうのが一番手っ取り早いだろう。
 そんな時に使えるのが、JavaScriptのWeb Storageだ。具体的には、Cookie、Local Storage、Session Storage、Indexed DB、Cache Storageなど多々ある。とはいえ、保存容量が小さ過ぎるCookieと、ブラウザウィンドウの有効時しかデータが保持されないSession Storageは要件を満たさない。また、機能がリッチ過ぎて、コーディングコストも高いIndexed DBも除外でいいだろう。そうすると、Local StorageとCache Storageのどちらかが保存先の候補になる。Local Storageはシンプルで使い勝手が良く、私的にも実装経験が多いのだが、今更感もあってちょっと面白みに欠けるなぁ……。ちゅーわけで、今回はほとんど触ったことがない Cache Storage(Cache API) を採用することにした。こういう機会こそ、新しいモノに触ってみないとね!

 なお、ブラウザ保存型のデメリットとしては、異なるブラウザ(デバイス)間でのデータの共有が難しいことだ。一応、ユーザーデータはローカル環境にエクスポートして、それをインポートできるようにはするが、理想としては、エクスポート・インポートなんぞせずとも、スマホから護石登録して、PCで装備のコーディネートをする……みたいなユーズケースを実現したいんだよねぇ……。
 まぁ、その欲望への対応は後々手立てを考えていこうかね。

Cache Storageの基本

 そもそも、Cache APIはフロントエンド側で発生するバックエンドへのリクエストとレスポンスそのものをキャッシュして、オフライン状態などでバックエンドとの通信ができない時にもそのキャッシュされたデータを介してアプリケーションを継続させるためのデータストレージの仕組みだ。バックエンド側の処理自体もJavaScript建てのサービスワーカー(Service Worker)として実装することで、ユーザーのネットワーク環境に依存せずに、あたかもネイティブアプリと同等の機能が提供できるアプリケーションを作ることができるようになる。こういうアプリケーションを、プログレッシブWebアプリケーション(Progressive Web Applications: PWA)と呼ぶ。
 今回のシミュレーターはPWA化までするつもりはないが、Axiosを介してのバックエンドとのリクエスト/レスポンスをそのままキャッシングできる仕様はかなり強力である。例えば、もう一つのユーザーサイドストレージの候補であったLocal Storageでは、シンプルなキー・バリューストアとして文字列しかストレージできない制限がある。今回のアプリで取り扱うバックエンドとのリクエスト/レスポンスは基本的に配列等が含まれるオブジェクト型であり、それらを文字列としてやり取りするためには都度JSONエンコード/デコードが必要になるなど、ひと手間かかってしまうのだ。まぁ、その辺のデータ出し入れ処理をラップしてくれるvue-local-storageプラグイン等もあるので、別段Local Storageを採用したとしても実装に関しては苦にならないんだが……。
 以前から、個人的にWeb StorageのCache APIとIndexedDBについては使い倒してみたかったこともあって、今回のアプリではCache Storageを採用した次第だ。

Cache Storageによる護石管理の仕組み

 では、さっそく具体例として、護石登録・削除の仕組みをユーザー個別に紐づけるためのCache APIでのI/Oを設計してみる。

Cache APIでのI/O図

 各ユーザーが登録/削除する護石関連のリクエストとレスポンスのペアはそのままキャッシュしてしまい、同時にDB登録・更新も行う。ユーザー側に読み込まれる護石データは、原則としてキャッシュされているリクエスト/レスポンスのデータの集合とする。これにより、自分が登録した護石しか管理対象にならないので、護石データが個別ユーザーに紐づくことになるわけだ。
 上記の図ではユーザーの護石はCache Storageから直接読み込まれるように書いているが、実際にはCache StorageからVuexストア側にクローンされたデータを読み込むことになる。Vuexストアのデータがなかった場合のみCache Storageから再取得を行うような建付けだ。
 ただし、もしキャッシュが破損した場合など、DBから再読み込みを行わなければならないようなケースの対応に懸念が残っている。というのも、その場合の護石とユーザーの紐づけをどうしようか……という問題だ。まぁ、それは追々考えよう。

Axiosのレスポンス取得後にCache API処理をフックする

 まず、アプリケーション全体として利用するキャッシュ名をVueアプリの環境変数として定義する。はじめキャッシュ名にアプリのバージョン番号を入れていたのだが、これだとバージョン番号が更新されるとキャッシュが使用できなくなってしまうので、バージョンに依存しない固有名とした。Vueアプリ用の環境変数はアプリケーションディレクトリ直下のvue.config.jsVUE_APP_ の接頭辞を付けて定義すれば有効になる。

process.env.VUE_APP_VERSION    = require('./package.json').version
process.env.VUE_APP_CACHE_NAME = 'mhrss-user-cache'
module.exports = {
  ...(以下略)

 次に、Cache APIにてキャッシュの操作を行うプラグインを作成する。

touch src/plugins/userCache.js

 userCache.jsの中身は下記のようになる(暫定版だが……)。

const userCache  = {
  install(Vue, options) {
    Vue.prototype.$userCache = {
      open: async function () {
        return await caches.open(options.cacheName).then(cache => cache)
      },
      save: async function (request, axiosResponse, cacheContent) {
        if (typeof request !== 'string') {
          request = this._createRequestObject(axiosResponse)
        }
        let response = this._createResponseObject(axiosResponse, cacheContent)
        await this.open()
        .then(cache => cache.put(request, response))
        .catch(error => console.error('Failed to save cache.', error))
      },
      loadAll: async function (callback=null) {
        return await this.open()
        .then(cache => cache.matchAll())
        .then(responses => {
          let cacheData = []
          if (responses && responses.length > 0) {
            responses.forEach(res => {
              res.json().then(json => {
                return cacheData.push(json)
              })
              Promise.resolve(res)
            })
          }
          return cacheData
        })
        .then(cacheData => {
          if (callback && typeof callback === 'function') {
            callback(cacheData)
          } else {
            return Promise.reject()
          }
          return Promise.resolve()
        })
        .catch(error => console.log('', error))
      },
      remove: async function (request, axiosResponse=null, callback=null) {
        if (typeof request !== 'string' && axiosResponse) {
          request = this._createRequestObject(axiosResponse)
        }
        await this.open()
        .then(cache => cache.delete(request))
        .then(result => {
          if (callback && typeof callback === 'function') {
            callback(result)
          }
          return Promise.resolve()
        })
        .catch(error => console.error('Failed to delete cache.', error))
      },
      _createRequestObject: function (axiosResponse) {
        let url = axiosResponse.config.baseURL + axiosResponse.config.url,
            payload = {method: 'get', body: axiosResponse.config.data}
        return new Request(url, payload)
      },
      _createResponseObject: function (axiosResponse, bodyContent) {
        let bom  = new Uint8Array([0xEF, 0xBB, 0xBF]),
            blob = new Blob([bom, JSON.stringify(bodyContent, null, 2)], {type: 'application/json'}),
            init = {status: axiosResponse.status, statusText: axiosResponse.statusText, headers: axiosResponse.headers}
        return new Response(blob, init)
      },
    }
  }
}
export default userCache

 キャッシュ操作用のインスタンス・メソッドを定義してあるのだが、注意点が2つある。
 まず、Cache APIのメソッドの戻り値はすべてPromiseになるということだ。そのため、インスタンス側で各キャッシュ操作の結果を取得してから何かを行うには、各インスタンス・メソッドにコールバック関数を引き渡して処理しなければならない。
 そしてもう1つは、Cache APIがキャッシュするデータはネイティブJavaScriptで定義されているResponseオブジェクトでなければならないという点だ。特に今回のアプリのようにAxiosを使っている場合、Axiosから取得できるレスポンスはAxios専用にトランスフォームされてしまっているので、そのレスポンスをそのままキャッシュできないのだ。そこで、AxiosのレスポンスをCache API用にResponseオブジェクトにコンバートする必要が出てくる。それを行っているのが、プラグインの_createResponseObject()メソッドである。ここでは、キャッシュするコンテンツをBOM付きのバイナリ型JSONデータにコンバートしている。同じようにキャッシュのキーとなるリクエストについてもRequestオブジェクトが許可されているが、ここはURL(文字列)でも構わない。今回のアプリではキャッシュのキーを正規表現で検索したいので、キャッシュ・キーはURLで統一させている。
 それでは、このプラグインをVue.jsにインポートしよう。src/main.jsに以下のように追加すれば良い。Vue.use()のタイミングはミックスイン定義の前とする。

import userCache from './plugins/userCache'
Vue.use(userCache, {cacheName: process.env.VUE_APP_CACHE_NAME})

 これでCache Storageを利用する準備が完了だ。Vueインスタンス内であれば、いつでもthis.$userCache.save()のような感じでキャッシュ操作ができるようになっている。

 現状、DBへの登録・更新・削除のリクエストはミックスインに追加したsaveData()メソッドで一元化しているので、このメソッドのコールバック処理にてキャッシュを保存する処理を追加することになる。具体的には、例えば護石登録処理であれば、saveData()にてDBに護石データを追加した後のコールバック(第3引数)に下記のようにキャッシュ保存処理を追加している。

this.saveData('post', {table: 'talismans', data: newTalisman}, (response) => {
  let notices = {
    title: null,
    messages: [],
  }
  if (response.data.state == 201) {
    newTalisman.id = response.data.id
    // Save into cache storage
    let request = `index.php?tbl=talismans&filters[id]=${response.data.id}`,
        cacheContent = newTalisman
    this.$userCache.save(request, response, cacheContent)
    this.$store.dispatch('addData', {property: 'talismans', data: newTalisman})
    notices.title = '通知'
    notices.messages = [ '護石を登録しました。', '登録された護石は護石管理から確認・削除できます。' ]
  } else {
    notices.title = 'エラー'
    notices.messages = [ '護石の登録に失敗しました。', 'もう一度お試しください。' ]
  }
  this.$root.$emit('open:notification', notices)
})

 Vuexストアにデータを登録するためにaddDataアクションをdispatchする前に、登録したキャッシュを参照するためのリクエストURLとAxiosのレスポンスから生成した新規護石データオブジェクトをペアとしてキャッシュに保存している。
 さらに、アプリのルートコンポーネントsrc/App.vuecreated()で行っているマスターデータの一括読み込み処理(getMasterData()メソッド)から護石データのロードを削除する。そして、新たにユーザー個別の護石データをキャッシュから読み込みためにloadUserData()のメソッドを準備し、これをミックスインに追加する。

loadUserData: function() {
  this.$userCache.loadAll(cacheData => {
    this.$store.dispatch('initData', {property: 'talismans', data: cacheData})
  })
},

 $userCache.loadAll()メソッドはCache Storageからすべてのキャッシュを取得するインスタンス・メソッドで、戻り値がPromiseになるため、コールバック関数を引数で渡して、そのコールバック内でVuexストアへのデータ登録を行っている。
 最後に、護石削除時に該当するキャッシュデータを削除する処理も追加した。護石削除も処理はsaveData()メソッドで行っているので、実装方法は登録時とほぼ同じである。なので、ここでは詳細は省略する。
 そんなわけで、護石関連のキャッシュ操作系が完成した。詳しい実装については、GitHubのリポジトリを参照してほしい。

Cache Storageでのキャッシングをテストする

 一通り処理が出来たので、動作テストを行ってみる。Cache Storageはオリジン単位(ドメインではなくHTTPスキーマ+FQDN(ドメイン名)+ポート番号のURI単位)でブラウザにキャッシュされるため、同じブラウザではテストができない。そこで、異なるブラウザで護石管理を起動して、管理できる護石がユーザー(=ブラウザ)ごとに違っているかどうかを確認することになる。

ブラウザごとに管理護石が異なる

 上記はChromeとFirefoxで同時にアプリから護石を登録/削除した時の結果だ。期待通りに動いている。やったぜ!
 今回初めて触ったが、Cache Storageの使い勝手はかなり良い。ストラクチャーのシンプルさと取り扱いの容易さではLocal Storageに敵わないものの、オブジェクトなどの文字列以外のデータをそのまま保存・参照できるので、データの保守性・運用性としてはこちらの方がかなり優れている感じだ。Cache APIを使い込むと壮大なPromiseチェーンが出来上がってしまう懸念点もあるが、その辺は今回のようにプラグインなんかでラッパーメソッドを準備してあげれば、カバーできることも分かった。とにもかくにも、取り扱うデータ形式に応じて、使うべきストレージの選択肢が増えたのは大きな収穫である。

 ──そして、今回の実装が反映されたアプリのモックアップは下記の通りである。

 インラインではない直接リンクはこっちだ。
 


 今回はここまでにする。何気に、Cache Storege用のプラグイン開発に一日かかった……Promiseが理解できてないとおそらく使いこなせないAPIだろうが、私的には作っててかなり楽しかったわw

 いやぁ、シミュレーターのメイン機能がほとんど実装された感じだ。あと残っている主要機能としては、マイセット登録と各種データのローカルへのインポートとエクスポートぐらいかな。それもCache APIでほぼ解決できそうな感じではあるが……んなわけで、次はマイセット登録とデータのインポート/エクスポートを開発していこうかね。