[PHP] MySQLをPDOで操作する – 入門編 その3「モデル編」

※この記事は専門学校の講義用に作成されたものです

PDOはデータベースへのアクセスを抽象化してくれる非常に便利なライブラリですが、ある程度の規模のシステムになってくるとメンテナンス性を向上したくなりもう一段階、抽象化したくなる物です。最近人気のLarabelや、古くから利用されているCakePHPSymfonyなどのフレームワークには標準で搭載されている機能の簡易版を今回は学習がてらスクラッチで作ってみたいと思います。

これまでのお話

以下から参照ください。 blog.katsubemakito.net blog.katsubemakito.net

今回は前回までに作成したRESTful APIリファクタリングする形で実装していきます。ここまでのコードは以下のリポジトリからも参照できます(SQL除く)。 github.com

よくある設計

役割を3つの階層に分ける

こういった設計を行う際によく用いられるのが「プレゼンテーション層」「ビジネスロジック層」「データアクセス層」の3つの階層に分けて考えるやり方です。

ざっくり言うとそれぞれ以下のようなイメージです。

プレゼンテーション層
クライアントから呼び出される部分を指します。クライアントから渡されたデータを受け取り、最終的な結果を何かしら返却します。データを保存したりもしくは最終結果に必要なデータがある場合にはビジネスロジックを呼び出します。
ビジネスロジック
プレゼンテーション層とデータアクセス層をつなぐ部分です。例えば今回のシリーズで言えばユーザーを登録する、ユーザー情報を取得する、お金を消費するといった大まかな処理を指します。
データアクセス層
これが一番イメージしやすいと思います。直接データベースとのやり取りを記述した物で、具体的にはPDOなどでSQLを実行しデータを保存したり検索する部分のコードにあたります。

実装例

ではこれをPHPで実装する際のイメージについて簡単にまとめます。

データアクセス層

まずはスケルトンを書いてイメージを膨らませましょう。データアクセス層はざっくりと言うと以下のようにデータベースと直接やり取りを行うクラスをイメージしてください。全体像をわかりやすくするために引数や具体的な処理は割愛しています

<?php
class Model{
  function __construct(){
    // 接続情報(DSN,ID,PW)をもらう
    // (もしくはデフォルトの値を使う)
  }

  function connect(){
    // DBへ接続する
  }

  function query($sql){
    // SQLをもらい実行する
  }

  function fetch(){
    // SQLの実行結果を1レコード返却する
  }

  // その他、トランザクション関係や
  // 便利メソッドを追加します。
}

ビジネスロジック

こちらもスケルトンで概要を。ビジネスロジックは今回の例で言えば「ユーザー」「ガチャ」「キャラクター」など大まかなデータの単位でわけてあげます。データアクセス層で作成したクラスを継承することで実装するパターンが多いでしょうか。

<?php
require_once('model.php'); // Modelクラスを読み込む

class UserModel extends Model{
  function __construct(){
    parent::__construct();  // スーパークラスのコンストラクタを実行
    $this->connect();       // DBに接続する
  }

  // クエリーからユーザーIDを取得
  static function getUserIDfromQuery(){
    $uid = isset($_GET['uid'])?  $_GET['uid']:null;

    // Validationなども本来はここで行う

    return($uid);
  }

  // ユーザー情報を返却する
  function get($id){
    $this->query('SELECT * FROM User WHERE id=?', [$id]);
    return( $this->fetch() );
  }

  // その他、Userにまつわる処理や定数などをまとめます。
}

プレゼンテーション層

クライアントからアクセスされるファイルです。データを操作したい場合はビジネスロジック層で作成したクラスを利用します。

<?php
/**
 * ユーザー情報を返却する
 */

require_once('model/user.php');  // UserModelクラスを読み込む

// ユーザーIDを取得
$uid = UserModel::getUserIDfromQuery();

// ユーザー情報を取得
$user = new UserModel();
$buff = $user->get($uid);

// 結果返却
echo json_encode($buff);

何がうれしいの?

メリットとしては次のような点があげられます。

最小限のコードで済む
例えばここに「クエスト」など新たなデータ郡を追加したい場合、データベースとのやり取りを行う部分はModelクラスに集約されているため似たようなコードを書く必要がありません。
データベースを載せ替えたい場合、データアクセス層を変更すれば良い(可能性が高い)
実務としてはあまり考えたくありませんがw DBの載せ替えを行いたいときに1つのクラスを変更すれば良いというのは非常に安心できますね。ビジネスロジック層ではSQLを書かなくて済む実装にするとより可能性を高めることができます。
役割分担できる
大規模な開発の場合、一人ですべてのコードを書くことは困難になってきます。そんな時に明確な思想で設計されていると、複数人で同時に開発することが容易になります。

総じて言えるのは「メンテナンス性」が高くなるということですね。昨今のシステムは長期間の稼働を前提とすることが多く、運用中に機能追加や修正を日常的に行う必要が出てきます。そんな時にどこを触るとどういう結果になるのか誰が見ても一目瞭然の状態になっているとトラブルを未然に防ぐことができ、開発工数を減らすことができます。睡眠時間を削らなくて済むわけですねw

逆説的に言えばスーパープログラマしかさわれないようなシステムは、長期の運用を前提としたシステムでは最悪と言わざるを得ません。

実際のコード

ではここまでを踏まえて本番のコードを見てみることにしましょう。全てのコードはGitHubから確認してください。ここでは主要な物のみ掲載します。 github.com

ファイル構造は以下のようになっています。

📂 sgrpg
  📂 model
    📔 model.php
    📔 user.php
    📔 gacha.php
    📔 chara.php
  📂 api
    📔 util.php
    📂 user
      📔 join.php
      📔 data.php
    📂 gacha
      📔 drop.php

データアクセス層 - model/model.php

データアクセス層にあたるModelクラスになります。(一部のコードを省略しています)

<?php
/**
 * ベースモデル
 *
 * @version 1.0.0
 * @author M.Katsube <katsubemakito@gmail.com>
 */
class Model{
  //-------------------------------------
  // プロパティ
  //-------------------------------------
  // DBの接続情報
  private $dsn  = 'mysql:dbname=sgrpg;host=127.0.0.1';
  private $user = 'senpai';
  private $pw   = 'indocurry';

  // DBとの接続管理用
  private $dbh  = null;
  private $sth  = null;


  /**
   * コンストラクタ
   *
   * @param string $dsn 
   * @param string $user
   * @param string $pw
   * @return void
   */
  function __construct($dsn=null, $user=null, $pw=null){
    if($dsn  !== null) $this->dsn  = $dsn;
    if($user !== null) $this->user = $user;
    if($pw   !== null) $this->pw   = $pw;
  }

  /**
   * DBへ接続
   *
   * @return void
   */
  function connect(){
    $this->dbh = new PDO($this->dsn, $this->user, $this->pw);
    $this->dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);    
  }

  /**
   * SQLを実行
   *
   * @param string $sql
   * @param array  $bind  [ ['name'=>xxx, 'value'=>xxx, 'type'=>xxx], [...], [...] ]
   * @param boolean
   */  
  function query($sql, $bind=null){
    // DBへ接続
    if( $this->dbh === null ){
      $this->connect();
     }

    $this->sth = $this->dbh->prepare($sql);

    if( $bind !== null && is_array($bind) ){
      for( $i=0; $i<count($bind); $i++ ){
        $name  = $bind[$i]['name'];
        $value = $bind[$i]['value'];
        $type  = $bind[$i]['type'];

        $this->sth->bindValue($name, $value, $type);
      }
    }

    return( $this->sth->execute() );
  }

  /**
   * 実行結果を取得
   *
   * @return array|false
   */  
  function fetch(){
    return( $this->sth->fetch(PDO::FETCH_ASSOC) );
  }

  /**
   * トランザクションを開始
   */
  function begin(){
    // DBへ接続
    if( $this->dbh === null ){
      $this->connect();
     }

    $this->dbh->beginTransaction();
  }

  /**
   * コミット
   */
  function commit(){
    $this->dbh->commit();  
  }

  /**
   * ロールバック
   */
  function rollback(){
    $this->dbh->rollBack();
  }
}

ビジネスロジック層 - model/user.php

ユーザーにまつわる情報を取り扱います。(一部のコードを省略しています)

<?php
require_once('model.php');

/**
 * Userモデル
 *
 * @version 1.0.0
 * @author M.Katsube <katsubemakito@gmail.com>
 */
class UserModel extends Model{
  // 対象テーブル
  protected $tableName = 'User';

  // レコードの初期値
  private $defaultValue = [
    ['name'=>':lv',    'value'=>1,    'type'=>PDO::PARAM_INT],
    ['name'=>':exp',   'value'=>1,    'type'=>PDO::PARAM_INT],
    ['name'=>':money', 'value'=>3000, 'type'=>PDO::PARAM_INT]
  ];

  /**
   * UserIDの書式が正しいかチェック
   *
   * @return integer|false
   */
  static function getUserIDfromQuery(){
    $uid = isset($_GET['uid'])?  $_GET['uid']:null;

    if( ($uid === null) || (!is_numeric($uid)) ){
      return(false);
    }
    else{
      return($uid);
    }
  }

  /**
   * ユーザーを追加
   *
   * @return integer|false
   */
  function join(){
    // ユーザーを追加
    $sql1 = 'INSERT INTO User(lv, exp, money) VALUES(:lv, :exp, :money)';
    $this->query($sql1, $this->defaultValue);

    // AUTO_INCREMENTしたユーザーIDを取得
    $sql2 = 'SELECT LAST_INSERT_ID() as id';
    $this->query($sql2);
    $buff = $this->fetch();

    return( $buff['id'] );
  }

  // 以下省略
}

プレゼンテーション層 - api/user/join.php

ここではユーザー登録用のAPIを掲載します。最初に書いたコードと比べるとずいぶん見通しがよくなった気がしますね。

<?php
/**
 * MySQLに接続しデータを追加する
 *
 */

//-------------------------------------------------
// ライブラリ
//-------------------------------------------------
require_once('../util.php');
require_once("../../model/user.php");

//-------------------------------------------------
// SQLを実行
//-------------------------------------------------
try{
  $user = new UserModel();
  $uid = $user->join();
}
catch( PDOException $e ) {
  sendResponse(false, 'Database error: '.$e->getMessage());
  exit(1);
}

//-------------------------------------------------
// 実行結果を返却
//-------------------------------------------------
// データが0件
if( $uid === false ){
  sendResponse(false, 'Database error: can not fetch LAST_INSERT_ID()');
}
// データを正常に取得
else{
  sendResponse(true, $uid);
}

続き

※鋭意執筆中(来週公開予定)