今回はフロントエンドのVue.jsにAxios導入して、バックエンドのPHPを経由してデータベースからデータを取得する部分を作って行くのだが、その前に、現在のデータベースのテーブル定義に欠陥があったので、そちらを修正することにした。
武器テーブルの修正
いやはや、現状の武器データを格納するテーブルが、近接武器の一部にしか対応していなかった次第……。
テーブル設計してた時に使っていたメイン武器が「ハンマー」と「大剣」だったこともあって、当時は気にならなかったんだが、その後、「狩猟笛」「ライトボウガン」「ヘビィボウガン」「ガンランス」「チャージアックス」「弓」と使用する武器の幅を広げたところで、ようやく気が付いた。まぁ、ボウガン系の弾とかは別テーブル管理でもイイかと元から考えていたのだが、ボウガンには「ブレ」や「リロード」といった個別のステータスがあるし、「ガンランス」には砲術タイプ、「チャージアックス」や「弓」にはビン効果など、それぞれの武器種ごとに個別ステータスがあったのをすっかり忘れていた。
これは早いうちにテーブル定義に取り込んでおいた方が後々面倒にならないので、早速修正していこうかと。
具体的には、武器個別のステータスはメタデータ系の副次テーブルを作成して切り出してしまうのが、既存の武器マスターテーブルに影響が少なくて良いだろう。あと、ボウガンの弾情報を管理するテーブルも副次テーブル化する予定だが、こちらも箱だけは準備しておく。
ちゅーわけで、武器管理系のテーブルを再設計した後のデータベースのERDは下記の通りだ。

全武器で共有するステータスのみを武器マスターテーブル(weapons)に集約して、武器個別のステータスやシミュレータとってあまり重要でないデータはメタテーブル(weapon_meta)へリレーション、さらにボウガン系の弾丸管理用のテーブル(ammo)を準備してリレーションできるようにしておいた。
これに合わせてバルク処理も調整したが、追加したリレーション系副次テーブルへのバルクインポートは今のところ対応していない。というのも、武器マスターへデータを投入して自動発番された武器IDの値を元にしてリレーションデータを紐づけながらのインサートが必要になるからだ。まぁ、こちらは必要に応じて追々対応すれば良いかな。当面のスキルシミュレータでは、武器マスターのみあれば事足りるからだ。
ついでに、防具マスターテーブル(armors)など他のテーブルも、ちょいちょい修正してある。例えば、防具名がプレイヤーの性別によって異なっていたり、五十音並び替え用にフリガナ名を格納できるようにしたのだ。
とりあえず、メインテーブル群のバルク処理は既にあるので、この段階であれば、weaponsやarmorsテーブルを一度DROP TABLEで破棄してから再度CREATE TABLEで作成してしまうのがベストである。修正後のバルクインポートのテストもできるし、一石二鳥である。ALTER TABLEでカラム単位にちまちま修正することも可能だが、効率が悪いだけでメリットがない。
ちなみに、上記のERDはLucidchartで作成している。Lucidchartはおよそビジネスに関わるようなドキュメントのほとんどをWEB上で作成できるクラウドツールだ。無料枠でもかなり自由に様々なドキュメントが書けるうえ、作った文書はPDFや画像等にエクスポートできる優れもののツールである。私は英語UIで使っているが、日本語にも対応しているので、英語が苦手な人にも安心である。
そんなこんなで、データベースのテーブル修正はサクッと完了した次第。
修正後の全テーブルのCREATE TABLE文はこちらのGistで参照できる。
Axiosを導入する
さて、本筋に戻ろう。Vue.jsのアプリ側からURLベースでサーバサイド(バックエンド)側とデータをやり取りする方法はいくつかあるが、Axiosモジュールを使うのが一番手っ取り早くて簡単である。そんなわけで、プロジェクトにAxiosを追加する。
cd {プロジェクトのディレクトリ}
yarn add axios vue-axios
これでAxiosがインストールされたので、あとはVue.jsのエントリーファイルsrc/main.jsにモジュールをインポートする。
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import axios from 'axios'
import VueAxios from 'vue-axios'
import vuetify from './plugins/vuetify'
import functions from './plugins/functions'
import './styles/mhrss.scss'
import './styles/mhrssi.scss'
Vue.config.productionTip = false
Vue.use(VueAxios, axios)
Vue.mixin(functions)
new Vue({
router,
vuetify,
render: h => h(App)
}).$mount('#app')
注意点としては、前回追加したミックスインの前でVue.use()することだ。今回はAxios系の共通処理をミックスインに含めてしまうので、ミックスインを定義する前にAxiosが読み込まれている必要があるのだ。
では早速ミックスインsrc/plugins/functions.jsにAxiosの共通処理を追加してみる。
export default {
methods: {
// Determine the host and switch debug mode
isLocalhost: function() {
return /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname)
},
// Sleep at the milliseconds specified
// Usage: this.sleep(300).then(() => { Do something... })
sleep: async function(sec=1000) {
return await new Promise(resolve => setTimeout(resolve, sec))
},
// Create axios instance
createAxios: function(overrideURL=null) {
const DEFAULT_BASE_URL = this.isLocalhost() ? 'localhost:8080/': `${window.location.hostname}/`
let connectURL = overrideURL || '//' + DEFAULT_BASE_URL
return this.axios.create({
baseURL: connectURL,
headers: {
'Content-Type': 'application/json;charset=utf-8',
'Accept': 'application/json',
},
params: {
token: null,
},
})
},
}
}
追加したのは、Axiosを使った処理を行う際のインスタンス初期化の処理だ。前回で追加した現在の環境がローカルホストかどうかを判定するメソッドを利用して、Axiosオブジェクトの接続先URLを振り分けながらインスタンス化するメソッドである。また、接続先URL自体は呼出し元で上書きできるようにしてある。
あと、しれっとスリープ用のメソッドも追加してある。こういうどこでも使いそうな共用メソッドを追加して行くためのミックスインなのだ。
Axiosでの接続テスト
次に、コンポーネント側からAxios経由でのデータ取得をテストしてみる。前回モックアップ用のデータとして準備したJSONファイルをAxiosで読み込ませてみよう。サンプルとして、メイン画面の左カラム「装備・装飾品」欄の個別装備表示用コンポーネントである「EquipmentItem.vue」を下記のように修正する。
<template>
<v-card
outlined
tile
class="d-flex flex-wrap mb-2 pa-2"
>
<template v-if="loading">
<div class="pa-2 text-center" style="width:100%">
<v-progress-circular
:size="32"
:width="3"
indeterminate
color="grey darken-2"
></v-progress-circular>
</div>
</template>
<template v-else-if="item">
...(中略)
</template>
<script>
import Talisman from '@/components/Talisman'
//import mockData from '@/../public/mock_data.json' <- 削除
export default {
...(中略)
data: () => ({
...(中略)
loading: true,// <- 追加
}),
...(中略)
created() {
this.getData('mock_data.json')// <- 追加
/* 以下は削除
let items = mockData[`${this.$props.type}s`].filter(item => item.id == Number(this.$props.id))
if (items) {
this.item = items.shift()
this.hasSlot = (this.item.slot1 + this.item.slot2 + this.item.slot3) > 0
} else {
this.item = {
name: '-', rarity: 0,
}
}
*/
},
...(中略)
methods: {
...(中略)
// 下記を追加
getData: function(path) {
const instance = this.createAxios()
instance.get(path)
.then(response => {
let items = response.data[`${this.$props.type}s`].filter(item => item.id == Number(this.$props.id))
if (items) {
this.item = items.shift()
this.hasSlot = (this.item.slot1 + this.item.slot2 + this.item.slot3) > 0
} else {
this.item = {
name: '-', rarity: 0,
}
}
})
.catch(error => {
console.error(`Failure to retrieve equipment data. (${error})`)
})
.finally(() => {
this.sleep(500).then(() => {
this.loading = false
})
})
},
},
}
</script>
今までのJSONファイルインポートを廃止して、データ読み込み中のインジケータ表示と、それ用の状態管理用変数を追加。ライフサイクルのcreated()では新たにAxios経由のデータ読み込みメソッドgetData()を呼び出すようにする。getData()では、前項にて定義したミックスインのメソッドを使ってAxiosオブジェクトを初期化し、JSONデータをURLから読み込んでいる。ローカル環境の場合、http://localhost:8080/mock_data.json のURLからデータが読み込まれることになる。前回、mock_data.jsonをsrc/assets等ではなく、Vueアプリの公開用ディレクトリpublicに格納していたのはこのための伏線だったのだ。一方で、window.location.hostnameでホスト名が引ける仮想環境や本番環境などではドキュメントルート直下にmock_data.jsonが配置されていれば、データが読み込まれる。
なお、先ほどミックスインに追加したスリープを使ってロード中フラグを切り替えているのは、ローカル環境だとAxiosでのファイル読み込みが一瞬で終了してしまい、データ読み込み中のインジケータ表示の動作確認がしづらいための対策である。
これで、フロントエンド側のデータ読み込み準備が整った。次は、バックエンド側の対応をしていく。
エンドユーザー用のエンドポイントを追加する
このアプリを利用するユーザー向けのバックエンドのエンドポイントはindex.phpとなる。このエンドポイントではユーザーのリクエストに適宜対応して、データベースからデータを取得してアプリ側にその結果を応答として返す必要がある。

ただし、今までモックアップとして開発してきたメイン画面のリクエストは複雑になるので、一旦そこは置いておいて、手始めにスキルデータをデータベースから取得して一覧表示する機能を実装していこうかと。ぶっちゃけ、ある程度のデータ量を登録してあるのが、まだスキルテーブルしかないというのが本当の理由だったりするのだが……w
早速、プロジェクトディレクトリにエンドポイントのファイルを作成する。
cd {プロジェクトディレクトリ}
touch index.php
エンドポイントファイルの中身は下記のようになる。
<?php
require_once( __DIR__ . '/vendor/autoload.php' );
use MHRise\SkillSimulator\CustomerCore;
$class = 'MHRise\SkillSimulator\CustomerCore';
if ( class_exists( $class ) ) {
CustomerCore::get_object();
} else {
trigger_error( "Unable to load class: $class", E_USER_WARNING );
exit;
}
このエンドポイントでは、コアクラスCustomerCoreをインスタンス化するだけだ。実際の様々な処理はコアクラス側に実装されるので、かなりシンプルになっている。
次に、コアクラスを作成する。
cd apps
touch CustomerCore.php
とりあえず、中身は下記のようにしておく。
<?php
namespace MHRise\SkillSimulator;
if ( !class_exists( 'CustomerCore' ) ) :
final class CustomerCore extends abstractClass {
use DBHelper;
public function init() {
die( 'Called CustomerCore::init method!' );
}
}
endif;
XAMPP等の仮想環境上で、このエンドポイントのURL(例えばhttp://{vhost name}/index.phpのようなURL)にブラウザでアクセスしてみる。画面に「Called CustomerCore::init method!」と表示されていれば、エンドポイントの追加は完了だ。
エンドポイントへのアクセス・スキームの定義
さて、ここでエンドポイントにVue.jsアプリ側からどのようにアクセスさせ、それぞれのアクセスをPHPのクラス側でどのように捌くかを決める必要がある。簡単に云うと、Axiosに受け渡すURLのパスと、そのURLパスから受け取れるデータの対応リストを決めていくのだ。
| リクエストURL(BASE_URL以降のパス) | メソッド | 取得JSONデータの内容 |
|---|---|---|
/?tbl=weapons もしくは /weapons |
GET | 全武器のデータ |
/?tbl=weapons&id=:id もしくは /weapon/:id |
GET | 指定された武器IDのデータ |
/?tbl=armors もしくは /armors |
GET | 全防具のデータ |
/?tbl=armors&id=:id もしくは /armor/:id |
GET | 指定された防具IDのデータ |
/?tbl=talismans もしくは /talismans |
GET | 全護石のデータ |
/?tbl=talismans&id=:id もしくは /talisman/:id |
GET | 指定された護石IDのデータ |
/?tbl=decorations もしくは /decorations |
GET | 全装飾品のデータ |
/?tbl=decorations&id=:id もしくは /decoration/:id |
GET | 指定された装飾品IDのデータ |
/?tbl=skills もしくは /skills |
GET | 全スキルのデータ |
/?tbl=skills&id=:id もしくは /skill/:id |
GET | 指定されたスキルIDのデータ |
リクエストURLが2パターンあるのは、取得リソースを選別するための条件を古典的なパラメータ付与型にするか、RESTfulなURI(リソース指向アーキテクチャ(Resource Oriented Architecture:ROA)の「アドレス可能性」と「ステートレス性」に準拠した形式)にするかを選別できるように提示している。根本的にRESTfulなURIの方が今時っぽくて格好良くみえるのだが、実際の動きとしてはWEBサーバ側で古典的なパラメータ付与型のURIに変換しているだけなので、どちらでも特に問題はない。むしろ、その変換設定をサーバ側に行うひと手間を考えると、前者のURIの方が実装が簡単だ。
まぁ、リクエストURLは基本的にアプリ内部からしか使わない想定なので、そこに格好良さを求めても意味がないし、今回はパラメータ付与型のURLで行くことにする。
コアクラスでリクエストを捌く
コアクラスに、フロントエンドからのリクエストをキャッチして、ハンドリング後に応答するためのメソッドを追加する。
private $request_method;
protected $the_request;
public function init() {
$this->catch_request();
$this->handle_request();
}
protected function catch_request(): void {
$this->request_method = $_SERVER['REQUEST_METHOD'];
$options = [
// tbl=:table_name
'tbl' => FILTER_SANITIZE_STRING,
// id=:id
'id' => [
'filter' => FILTER_VALIDATE_INT,
'options' => [ 'min_range' => 1, ],
],
// filters[key]=value
'filters' => [
'filter' => FILTER_SANITIZE_STRING,
'flags' => FILTER_REQUIRE_ARRAY,
],
];
$this->the_request = match( $this->request_method ) {
'GET' => filter_input_array( INPUT_GET, $options ),
'POST' => filter_input_array( INPUT_POST, $options ),
'COOKIE' => filter_input_array( INPUT_COOKIE, $options ),
};
if ( ! $this->the_request ) {
$this->the_request = json_decode( file_get_contents( 'php://input' ), true );
}
if ( ! $this->the_request ) {
die( 'Request not found.' );
}
}
protected function handle_request() {
$use_named_parameters = true;
$response = null;
switch ( $this->request_method ) {
case 'POST':
break;
case 'GET':
if ( array_key_exists( 'tbl', $this->the_request ) ) {
if ( ! $this->table_exists( $this->the_request['tbl'] ) ) {
die( 'That table does not exist.' );
}
} else {
die( 'Table not specified.' );
}
$conditions = [];
if ( array_key_exists( 'id', $this->the_request ) && $this->the_request['id'] ) {
$conditions[] = [ 'id', '=', $this->the_request['id'] ];
} elseif ( array_key_exists( 'filters', $this->the_request ) && $this->the_request['filters'] ) {
foreach ( $this->the_request['filters'] as $_key => $_value ) {
$conditions[] = [ $_key, '=', $_value ];
}
} else {
//
}
$response = $this->retrieve_data( $this->the_request['tbl'], $conditions, $use_named_parameters );
break;
case 'COOKIE':
default:
break;
}
if ( $response ) {
$this->return_response( $response );
} else {
$this->return_response( [] );
}
}
protected function return_response( array $data ): void {
if ( isset( $_SERVER['HTTP_ORIGIN'] ) ) {
$allow_origin_regex = '@^https?://(localhost:8080|127\.0\.0\.1)@';
$origin = preg_match( $allow_origin_regex, $_SERVER['HTTP_ORIGIN'] ) ? $_SERVER['HTTP_ORIGIN'] : '*';
$allow_methods = 'GET, POST, OPTIONS';
$allow_credentials = 'true';
} else {
$origin = '*';
$allow_methods = 'GET';
$allow_credentials = 'false';
}
header( 'Access-Control-Allow-Origin: '. $origin );
header( 'Access-Control-Allow-Methods: '. $allow_methods );
header( 'Access-Control-Allow-Credentials: '. $allow_credentials );
header( 'Access-Control-Allow-Headers: Origin, Content-Type, Accept' );
header( 'Access-Control-Expose-Headers: X-Custom-header', false );
header( 'Content-Type: application/json; charset=utf-8' );
die( json_encode( $data ) );
}
そして、データベースを検索してデータを取得するメソッドをデータベースヘルパーであるDBHelper.phpトレイトに追加する。ここに追加するメソッドは、汎用的なSELECTクエリ発行用メソッドとして使えるような仕様にしてある(まだ完成形ではない)。
protected function retrieve_data( string $table_name, array $conditions, bool $use_named_parameters = true ): array {
$where = [];
$parameters = [];
if ( ! empty( $conditions ) ) {
foreach ( $conditions as $_cond ) {
if ( $use_named_parameters ) {
$where[':' . $_cond[0]] = '`'. $_cond[0] .'` '. $_cond[1] .' :'. $_cond[0];
$parameters[':' . $_cond[0]] = $_cond[2];
} else {
$where[$_cond[0]] = '`'. $_cond[0] .'` '. $_cond[1] .' ?';
$parameters[$_cond[0]] = $_cond[2];
}
}
}
$base_sql = "SELECT * FROM $table_name";
if ( ! empty( $where ) ) {
$base_sql .= ' WHERE %s';
$base_sql = sprintf( $base_sql, implode( ' AND ', $where ) );
}
// Ready for binding values
$bind_values = [];
$bind_index = 0;
foreach ( $parameters as $_key => $_val ) {
switch ( gettype( $_val ) ) {
case 'integer':
$value = intval( $_val );
$data_type = \PDO::PARAM_INT;
break;
case 'array':
case 'object':
$value = json_encode( $_val );
$data_type = \PDO::PARAM_STR;
break;
case 'boolean':
$value = boolval( $_val );
$data_type = \PDO::PARAM_BOOL;
break;
case 'NULL':
$value = null;
$data_type = \PDO::PARAM_NULL;
break;
case 'double':
case 'string':
default:
$value = strval( $_val );
$data_type = \PDO::PARAM_STR;
break;
}
$bind_index++;
$bind_key = $use_named_parameters ? $_key : $bind_index;
$bind_values[$bind_key] = [ $value, $data_type ];
}
// Execute data selection
try {
$sth = $this->dbh->prepare( $base_sql );
foreach ( $bind_values as $_key => $_value ) {
$sth->bindValue( $_key, $_value[0], $_value[1] );
}
$sth->execute();
return $sth->fetchAll( \PDO::FETCH_ASSOC );
} catch ( \PDOException $e ) {
throw $e;
return [];
}
}
バックエンドの準備はこれでOKだ。次にフロントエンドを拡張する。
スキル一覧画面の作成
まず、Vue.js側にスキル一覧用のルートを作成する必要があるので、src/router/index.jsに下記をコードを追加する。
...(省略)
import Skills from '../views/Skills.vue'
const routes = [
...(中略)
{
path: '/skills',
name: 'Skills',
component: Skills,
},
]
...(省略)
そして、src/viewsディレクトリにSkills.vueファイルを追加して、スキル一覧用の画面を作成する。コンポーネントの詳細はここでは省略するので、気になる人はGitHubのリポジトリを参照して欲しい。
なお、インターネットでアクセスできるPHP+MySQLの実働環境があれば、ローカル環境からでもURL経由でバックエンド側と連動できるので、その場合はVue.jsのミックスインに追加したcreateAxios()メソッド内のBASE_URIの定数を実働環境のホスト名に変更すると良いだろう。
スキル一覧画面のコンポーネントSkillList.vueからバックエンドのエンドポイントに接続するコード部分を参考までに紹介しておく。
<script>
export default {
data: () => ({
labels: {
title: 'スキル一覧',
},
skills: null,
loading: true,
}),
created() {
this.getData('index.php?tbl=skills')
},
methods: {
getData: function(path) {
const instance = this.createAxios()// <- PHP+MySQLの稼働している環境のURLがメソッドの初期値と異なる場合はここに指定する
instance.get(path)
.then(response => {
this.skills = response.data
this.skills.sort((a, b) => {
return a.name.localeCompare(b.name, 'ja')
})
})
.catch(error => {
console.error(`Failure to retrieve skill data. (${error})`)
})
.finally(() => {
this.sleep(300).then(() => {
this.loading = false
})
})
},
},
}
</script>
実際に動かすと下記のようになる。サイドメニューから「スキル」を選択するとルーティングが切り替わって「スキル一覧画面」が表示されるようになっている。
基礎的なデータベース連携はこれで完成だ。だが、このままだと、ルーティング切り替え(≒ページ遷移)のたびに毎回データベースへのアクセスが走って効率が悪い。今回のアプリでは、ほとんどのデータが一度取得したらそのまま引き回せるタイプのものなので、アプリ側にキャッシュしてしまいたい。そのためには、Vuexを導入して、取得したデータはストアに格納してしまうのが良いだろう。
というわけで、 次回はVuexによるデータ・キャッシュを実装する。