今回は、護石管理画面の作成だ。この護石管理では、ユーザーごとに護石の登録を行う機能と、登録した護石を削除する機能を持つ仕様になる。この画面から登録した護石データはデータベースに格納するので、フロントエンド側からのリクエストを処理してINSERTやUPDATE、DELETE等のクエリを発行することになる。

──と、当初は考えていたのだが、護石データに関しては後々統計を取るなど再利用することもできそうだな……と思ったので、一度データベースに格納したデータを DELETEクエリで物理削除 するのは止めることにした。つまり、フロントエンドで削除要求が行われた護石データについては、削除フラグをONにするという UPDATEクエリによる論理削除 方式を採用しておくのだ。

 これを行うためには、護石マスターテーブルに論理削除用の削除フラグを格納するカラムが必要になる。このカラムは真偽値のみを格納することになるので bit(1) 型として追加する。なぜバイナリのビット値型のカラムにするのかは、以前私が書いた下記の記事を読んでみて欲しい。

MySQLに真偽値を格納する場合はbit(1)型のフィールドが最適

 そんなわけで、早速テーブルの修正を行う。まだ護石マスターテーブルにはデータが入っていないので、一度DROP TABLEしてから改めて作り直す対応で問題ない。

護石管理画面の追加

次に、新たに護石管理画面用のルーティングをVue.jsに追加する。ルートの追加はsrc/router/index.jsに下記のように追加するだけだ。

import Talismans from '../views/Talismans.vue'
...(中略)
const routes = [
    ...(中略)
    {
        path: '/talismans',
        name: 'Talismans',
        component: Talismans,
    },
...(以下略)

 そして、追加した設定に沿って各種コンポーネントファイルを作成・配置し、画面構成をマークアップする。Vuetifyのコンポーネントについては特に解説はしないが、最終的に出来上がった護石管理画面は下記のようになっている。

護石管理画面

 おまけ機能的に、プレビュー欄で登録しようとしている護石の評価を行うようにしてみた。評価用の公式は独自仕様なのではあるが、護石の空きスロットのサイズや数をベースにしたスロット評価と、付いているスキルを装飾品に換算して、その装飾品を作成するための素材価値をベースにしたスキル評価を合算して、100点を★5とした評価指標になっている。なお、いわゆる「神護石」と呼ばれる護石は★6の評価になるように調整してみたが、まぁ、評価方程式が素材取得難易度による定量的なものになっているので、ゲームのプレイ感を加味した定性的な観点から見ると評価結果には違和感を感じる。何気にゲーム中に重宝していて、コレって自分的に「神護石」じゃね?って護石もこの機能の評価だと★4だったりして、「結構、評価辛いなぁ……」と不満な結果になることも多いんだよね……w(ま、あくまでもおまけ機能なので、一つの指標にはなるかな……と)

バックエンドのDB更新用拡張

 フロントエンド側ができたので、次はバックエンド側のDB更新処理を開発していく。既に汎用的なINSERT処理はヘルパーにあるので、作るのは汎用的なUPDATE処理になる。今回の護石管理ではデータの物理削除を行わないので、DELETE処理は不要だが、一応これも作っておくことにする。これで、基本的なCRUD処理はすべて行えるようになる。

protected function update_data( string $table_name, array $data, array $conditions, string $operator = 'and', bool $use_named_parameters = true ): bool {
    $operator = preg_match( '/^(and|or)$/i', $operator ) ? strtoupper( $operator ) : 'AND';
    $set_clauses = [];
    $parameters = [];
    foreach ( $data as $column => $value ) {
        if ( $use_named_parameters ) {
            $set_clauses[":$column"] = "`$column` = :$column";
            $parameters[":$column"] = $value;
        } else {
            $set_clauses[$column] = "`$column` = ?";
            $parameters[$column] = $value;
        }
    }
    $where_clauses = [];
    if ( ! empty( $conditions ) ) {
        foreach ( $conditions as $_cond ) {
            if ( $use_named_parameters ) {
                if ( preg_match( '/^in$/i', $_cond[1] ) && is_array( $_cond[2] ) ) {
                    $where_clauses[":cond_{$_cond[0]}"] = "find_in_set(cast(`{$_cond[0]}` as char), :cond_{$_cond[0]})";
                    $parameters[":cond_{$_cond[0]}"] = implode(',', $_cond[2]);
                } else {
                    $where_clauses[":cond_{$_cond[0]}"] = "`{$_cond[0]}` {$_cond[1]} :cond_{$_cond[0]}";
                    $parameters[":cond_{$_cond[0]}"] = $_cond[2];
                }
            } else {
                if ( preg_match( '/^in$/i', $_cond[1] ) && is_array( $_cond[2] ) ) {
                    $where_clauses["cond_{$_cond[0]}"] = "find_in_set(cast(`{$_cond[0]}` as char), ?)";
                    $parameters["cond_{$_cond[0]}"] = implode(',', $_cond[2]);
                } else {
                    $where_clauses["cond_{$_cond[0]}"] = "`{$_cond[0]}` {$_cond[1]} ?";
                    $parameters["cond_{$_cond[0]}"] = $_cond[2];
                }
            }
        }
    }
    $base_sql = "UPDATE $table_name SET %s";
    $base_sql = sprintf( $base_sql, implode( ', ', $set_clauses ) );
    if ( ! empty( $where_clauses ) ) {
        $base_sql .= " WHERE %s";
        $base_sql = sprintf( $base_sql, implode( " $operator ", $where_clauses ) );
    }
    // Ready for binding values
    $bind_values = $this->get_binding_values( $parameters, $use_named_parameters );
    // Execute data insertion
    try {
        $sth = $this->dbh->prepare( $base_sql );
        if ( ! empty( $bind_values ) ) {
            foreach ( $bind_values as $_key => $_value ) {
                $sth->bindValue( $_key, $_value[0], $_value[1] );
            }
        }
        $result = $sth->execute();
        return $result && $sth->rowCount() != 0;
    } catch ( \PDOException $e ) {
        throw $e;
        return false;
    }
}

private function get_binding_values( array $data, bool $use_named_parameters = true ): array {
    if ( ! empty( $data ) ) {
        return [];
    }
    $bind_values = [];
    $bind_index = 0;
    foreach ( $data 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 ];
    }
    return $bind_values;
}

 追加したupdate_data(上記ソースでは省略してあるがdelete_dataも同様)は既に作ってあるINSERTとSELECT用の処理の焼き直しのようなアルゴリズムになるので、苦労することなく作れた。さらに、CRUD処理全てで使うプリペアドステートメントに値をバインドするための事前処理(値のバリデート&キャスト処理)は、プライベート・メソッドget_binding_valuesとして切り出して共通化した。

 ちなみに、特殊な処理をしているところがあるので、それだけ説明しておこう。
PDOのプリペアードステートメントの値としてPHPの配列をバインドするケースに対応するための方法だ。例えば、下記のようなUPDATEクエリを想定して欲しい。

UPDATE talismans SET disabled = true WHERE id IN (1,2,4,6,7,9,10);

 SQLのIN句を使って複数のIDにマッチするレコードを更新したいケースになる。PDOのプリペアードステートメントの値では配列は取り扱えないので、複数IDを結合した文字列として渡すのだが、下記のような渡し方をするとエラーになってしまう。

$sth = $dbh->prepare("UPDATE talismans SET disabled = :disabled WHERE id IN (:ids)");
$sth->execute([':disabled' => true, ':ids' => implode(',', [1,2,4,6,7,9,10])]);

 PDOでは文字列のバインド値を文字列としてそのまま展開するため、実際に発行されるSQLが、

UPDATE talismans SET disabled = true WHERE id IN ("1,2,4,6,7,9,10");

──となってしまうからだ。これを回避するために、IN句の場合のみ下記のような関数を経由させるようなプリペアードステートメントを生成するようにしてある。

$sth = $dbh->prepare("UPDATE talismans SET disabled = :disabled WHERE find_in_set(cast(id as char), :ids)");
$sth->execute([':disabled' => true, ':ids' => implode(',', [1,2,4,6,7,9,10])]);

 これで、バインドされたカンマ区切りの文字列をSQL上でパースして、IN句と同等の条件マッチができるようになる。ただ、SQLによる文字列キャスト処理はオーバーヘッドが高いので、配列に含まれる要素数が多くなると、クエリのパフォーマンスが低下する懸念が残る。後々、取り扱う配列サイズにキャップを設ける等の施策が必要になるかもしれない……。

エンドポイントのアクセス・スキームの拡張

 次に、エンドポイントの処理として、フロントエンド側から受け取ったリクエストをハンドリングできるようにする。具体的には、コアクラスのCustomerCore::handle_request()メソッドを拡張するのだ。

 今まではフロントエンドからの要求はSELECTクエリによる参照リクエストのみだったので、エンドポイントで捌くメソッドはGETだけだったが、これからはINSERTやUPDATE、DELETEのリクエストが発生することになる。それらのリクエスト・メソッド用に処理を準備していくうえで重要なのがRESTfulなメソッド定義である。つまり、処理内容がデータの登録(INSERT)であるならばPOSTメソッド、データ更新(UPDATE)ならばPUTメソッド、データ削除(DELETE)ならばDELETEメソッドといった風にHTTPのリクエストメソッドで処理を分岐させることになるのだ。

protected function handle_request() {
    $use_named_parameters = true;
    $response = null;
    switch ( $this->request_method ) {
        case 'POST':
            // データ登録処理
            break;
        case 'GET':
            // データ参照処理
            break;
        case 'PUT':
            // データ更新処理
            break;
        case 'DELETE':
            // データ削除処理
            break;
        case 'COOKIE':
        default:
            break;
    }
    if ( $response ) {
        $response = $this->check_to_cast( $response );
        $this->return_response( $response );
    } else {
        $this->return_response( [] );
    }
}

 上記はコアクラス側での実装例だが、取得したリクエスト・メソッドに応じて、処理を分岐させている。
注意が必要なのが、PUTやDELETEといったメソッドで送信されたデータのPHP側での受け取り方だ。PHPにはINPUT_PUTやINPUT_DELETEといったフィルター関数用の定数が用意されていないのだ。かといってINPUT_POSTでも取得できないので、下記のようにfile_get_conents('php://input')で取得する必要がある。

protected function catch_request(): void {
    $this->request_method = $_SERVER['REQUEST_METHOD'];
    $options = [
        // filter options (省略)
    ];
    $this->the_request = match( $this->request_method ) {
        'POST' => filter_input_array( INPUT_POST, $options ),
        'GET' => filter_input_array( INPUT_GET, $options ),
        'COOKIE' => filter_input_array( INPUT_COOKIE, $options ),
        default => filter_input_array( INPUT_POST, $options ),
    };
    if ( ! $this->the_request ) {
        $this->the_request = json_decode( file_get_contents( 'php://input' ), true );
    }
    if ( ! $this->the_request ) {
        $this->app_die( 'Request not found.' );
    }
}

 さらに、フロントエンド側へレスポンスを返す時のヘッダ出力も調整しておく必要がある。特に異なるオリジン間ではCORSに引っかかってバックエンドとの通信がエラーになることが多い。特にGETやPOST以外の拡張メソッドを使用する場合はプリフライトリクエストでOPTIONSメソッドが発行されたりして、CORSによるブロックが発生しやすいのだ。そのため、PHPから出力するレスポンスのヘッダAccess-Control-Allow-Origin:にしっかりオリジンとなるURIを許可しておく等の対策も必要になってくる。この辺は解説しだすと、それだけで一記事になるので詳細は省くが、CORSを回避するためのバックエンド側の実装は下記のようになっている。

protected function return_response( array $data ): void {
    if ( isset( $_SERVER['HTTP_ORIGIN'] ) ) {
        $allow_origin_regex = '@^https?://(localhost:8080|192\.168\.0\.19:8080|(.*)?ka2.org)$@';
        $origin = preg_match( $allow_origin_regex, $_SERVER['HTTP_ORIGIN'] ) ? $_SERVER['HTTP_ORIGIN'] : '*';
        $allow_methods = $origin === '*' ? 'GET,OPTIONS' : 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS';
        $allow_credentials = $origin === '*' ? 'false' : 'true';
    } else {
        $origin = '*';
        $allow_methods = 'GET,OPTIONS';
        $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: X-Requested-With,Origin,X-Csrftoken,Content-Type,Accept' );
    header( 'Access-Control-Expose-Headers: X-Custom-header', false );
    header( 'Content-Type: application/json; charset=utf-8' );
    die( json_encode( $data ) );
}

 リクエスト元のオリジンをスーパーグローバル変数$_SERVERで取得して、正規表現で許容するオリジンかどうかを判定する。許可オリジンに対してのみプリフライトリクエストOPTIONSの次に来る本来のリクエストPUTやDELETE等を許可するという流れだ。なお、許可していないオリジンにはGETとOPTIONSメソッドのみ許可している。

 そんなこんなで、あとはコアクラス側に具体的な処理を追加して行けば良い。ここでは省略するが、詳しいソース内容に興味がある人はGitHubのソースを見て欲しい。

Axiosで更新処理を行う

 最後に、バックエンドに追加した更新処理をAxiosからリクエストする。まずはPOST、PUT、DELETEのDB書き込み系のメソッドを一手にリクエストできる共通メソッド saveDatasrc/plugins/functions.js のミックスインに準備する。

saveData: async function(method, params, callback=null, always=null) {
    const instance = this.createAxios()
    method = ['post', 'put', 'delete', 'patch'].includes(method) ? method: 'post'
    await instance[method]('index.php', {params: params})
    .then(response => {
        if (callback && typeof callback === 'function') {
            callback(response)
        }
    })
    .catch(error => {
        console.error('Failure to insert data.', error)
    })
    .finally(() => {
        if (always && typeof always === 'function') {
            always()
        }
    })
}

 後は、各コンポーネント側でデータを登録したいところで、

this.saveData('post', {table: 'talismans', data: newTalismanData})

──と呼べば良い。さらに、データを更新する時は、

this.saveData('put', {table: 'talismans', data: {disabled: true}, conditions: [ ['id', '=', 1], ], operator: 'and'})

──という感じだ。
 第3引数にバックエンドからの応答結果を受け取ってコールバック関数を受け渡せるようにしているので、そこに処理結果を通知する等の後処理を追加する形で使える。例えば、実際の護石管理コンポーネントでの護石登録用のメソッドは下記のようになっている。

registerData: function() {
    let newTalisman = {
        name: this.t_name,
        rarity: this.t_rarity,
        slot1: this.t_slot1,
        slot2: this.t_slot2,
        slot3: this.t_slot3,
        skills: {},
        worth: this.t_worth,
    }
    if (this.t_skill1 != null && this.t_skill1_level != null) {
        newTalisman.skills[this.t_skill1] = this.t_skill1_level
    }
    if (this.t_skill2 != null && this.t_skill2_level != null) {
        newTalisman.skills[this.t_skill2] = this.t_skill2_level
    }
    this.saveData('post', {table: 'talismans', data: newTalisman}, (response) => {
        let notices = {
            title: null,
            messages: [],
        }
        if (response.data.state == 201) {
            newTalisman.id = response.data.id
            this.$store.dispatch('addData', {property: 'talismans', data: newTalisman})
            notices.title = '通知'
            notices.messages = [ '護石を登録しました。', '登録された護石は護石管理から確認・削除できます。' ]
        } else {
            notices.title = 'エラー'
            notices.messages = [ '護石の登録に失敗しました。', 'もう一度お試しください。' ]
        }
        this.$root.$emit('open:notification', notices)
    })
}

 注意点としては、護石の登録・削除後はVuexのストア側も更新しないといけないことだ。これを忘れるとDB側とアプリ側のデータ同期に齟齬が生じてしまうので、DB更新後のタイミングでストアを更新するアクションも準備しておかなければならない。基本的にはその都度DBから最新データを取得してストアをリロードしておけば問題ないだろう。

護石管理画面が「一応」完成

 これで、護石管理画面から護石データを登録できるようになった。
 しかーし、このままでは登録した護石がユーザーに紐づいていない。つまり、せっかく登録してもユーザー個別の護石データになっていないのだ。そんなわけで護石登録が完了した時点で、リクエスト元のユーザー側にその辺の紐づけ処理を行う必要がある。
 今回のアプリではユーザー登録等の面倒な処理は実装する予定がないんだが、では、ユーザー側のどこにそれらのデータを紐づけるべきだろうか……。色々と手段はあるものの、無難かつ簡単なところではユーザーのブラウザに保存すれば良いだろう。
 そんなわけで、次回はユーザー側へのデータ保存処理を実装して、護石管理機能を完全化する。

 さて、護石データは登録できるようになったが、まだユーザーに紐づけられないのが歯がゆいところだ。まぁ、護石評価を試してみる用途には使えるだろう。なお、それ以外のUIについても一通りのデバッグを行って、ある程度実装が完了している状態だ。

 あとは武器と防具のデータを投入するためにExcelのスプレッドシートを書き上げなきゃなぁ……(何気に、これが一番シンドイんだわ……w)。

 ようやく、あと数回のコラムで完成しそうな勢いになって来た。本当はVer.3のアップデート前に完成させたかったんだが……まぁ、のんびりやるさ。趣味だしねw

 ちゅーか、Ver.3で追加された「風雷合一」スキルが、かなりシミュレータ泣かせの鬼仕様なんだが……。これも、次回に対応しようかね……w