fbpx

メニュー

wpdb道場 (8) 〜PSR準拠の名前空間でSingletonを実装しよう〜

高橋文樹 高橋文樹

この投稿は 9年半 前に公開されました。いまではもう無効になった内容を含んでいるかもしれないことをご了承ください。

前回でボタンの状態が変わるところまでやりましたが、肝心のデータ取得関数ができていませんでした。これはいけませんね。この機能を作りましょう。

さて、データベースをどうにかするような機能を作る場合、それを関数のような形にして色々なところに散らばったまま進めると、あとでひどい目に遭います。したがって、データの取得に関してはすべてを一つのクラスにまとめましょう。

で、その前にいきなりなのですが、PSRについて説明します。

PSRってなんやねん

PHPにはいろんな機能がありますが、自由が一番苦しいので、「PHPはこれからこうしていこうぜ」みたいな、みんなで決めたルールPSRができてきました。有名どころのフレームワークやライブラリが続々とこれに賛同しています。

PSRに参加している人たち
PSRに参加している人たち

この策定団体をPHP-Figというのですが、なんと、WordPressは入っていません! WordPressが参考にしたっぽいPEARでさえ入っているのに!

このあたりのイノベーションはコアがPHP5.3必須にしないと変わらないでしょう。こことかこことかで議論はされていますが、「ホントに変えるの嫌なんだな」と思いますね。

ともあれ、いままではてなダイアリーとかでRubyやPerl方面から(なぜかPythonからはこない。switch文がないため?)PHPerがディスられていた原因の多くはPSRで解消します。

PSRには一応レベルがあって、PSR-0というのは「名前空間をつけようね、ボクちゃん」というぐらいの緩いものなのですが、PSR-1ぐらいからWordPressのコーディング規約とバッティングしはじめます(メソッド名のところ)。さらに、PSR-2からになると、タブvsスペース戦争が勃発しはじめます。WordPressはタブ派なんですよね。

したがって、今回はPSR-1までの準拠を目指します。ルールは次の通り。

  • 名前空間を使う
  • クラスを決めるファイルと処理を行うファイルは分ける(クラス定義してそのあとにnew MyClassとか書かない)
  • 1ファイルに1クラスまで
  • クラスと名前空間はアッパーキャメル
  • メソッドはキャメルケース(WordPressはスネークケース)
  • プロパティ、変数はスネークケース
  • テンプレートタグという名のグローバル関数はスネークケース(WordPressに合わせる)
  • ファイルの読み込みはオートローダー(後述)で

PSR準拠で実装してみる

さて、それではPSRに準拠した形で実装してみましょう。まずは、オートローダーから。

それでは、プラグインファイルfreundschaft.phpにこんな記述をしてください。

add_action('init', function(){
     var_dump( class_exists('Freundschaft\\Pattern\\Singleton') );
     exit;
});

Freundschaft\Patter\Singletonというクラスが存在するかを出力して終わっています。いまはfalseと出るだけですね。これをtrueにすることを目指します。

さて、それではまずディレクトリを作成してください。プラグインフォルダに src/Freundschaft/Patternというディレクトリを作ります。大文字です。

srcフォルダを作成
srcフォルダを作成

続いて、PatternフォルダにSingleton.phpというファイルを作成します。で、こんな風に書いてください。

<?php
namespace Freundschaft\Pattern;

/**
 * Singleton Class
 *
 * @package Freundschaft\Pattern
 */
abstract class Singleton
{

}

これでFreundschaft\Pattern\Singletonクラスはできたのですが、requireしないといけないですよね。これがクソめんどくさいです。

PHPにはspl_autoload_registerという関数が存在し、クラス名が存在しないときに実行されるコールバックを登録できます。

それでは、freundschaft.phpにオートローダーを書きましょう。

/**
 * オートローダーを登録
 */
spl_autoload_register(function( $class_name ){
     $class_name = ltrim($class_name, '\\');
     if( 0 === strpos($class_name, 'Freundschaft\\') ){
          // 名前空間がFreundschaftだったら
          $path = __DIR__.DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.
                  str_replace('\\', DIRECTORY_SEPARATOR, $class_name).'.php';
          if( file_exists($path) ){
               require $path;
          }
     }
});

これで、src/Freundschaftにルールにのっとったファイルを配置する限りは自動で読み込まれます。今後は特に明示せずクラスを定義した場合はこのルールに則っていると考えてください。

Singletonクラスを実装しよう

さて、上で定義したSingletonクラスですが、これはSingletonという有名なデザインパターンです。よくプログラミングのクラスは「設計書」に例えられますが、そんなにたくさんのインスタンスが必要でないクラスもありますよね。

たとえば、ボタンとかウィンドウなら、たくさんあるのもわかります。new Button('更新', 'update')みたいなね。

しかし、「現在のユーザー」というようなクラスの場合はどうでしょう。何個もあったら困ったりしないですか。そういうとき、Singltonパターンを使います。

PHPのようなシングルスレッドの場合はそうなんですが、HTTPリクエストでアクセスされてから出力を終了するまでの間に、何度も必要になるものなんて、そんなにないんですよね。今回の例でいえば、「あるユーザーが別のユーザーをフォローしたかどうかを記録する」というクラスが何個も必要だとは思えません。

また、Singletonのいいところに、「いつでも同じインスタンスが取得できる」という特徴があります。一個しかないですからね。これにより、やたらめったらグローバル変数を使わなくても済みます。

では、実装してみましょう。

<?php

namespace Freundschaft\Pattern;

/**
 * Singleton Class
 *
 * @package Freundschaft\Pattern
 */
abstract class Singleton
{
     /**
      * @var array
      */
     private static $instances = array();

     /**
      * Constructor
      *
      * @param array $settings
      */
     protected function __construct( array $settings = array() ){
          // Do nothing
     }

     /**
      * Instance
      *
      * @param array $settings
      *
      * @return self
      */
     public static function getInstance( array $settings = array() ){
          $class_name = get_called_class();
          if( !isset(self::$instances[$class_name]) ){
               self::$instances[$class_name] = new $class_name($settings);
          }
          return self::$instances[$class_name];
     }

}

Singletonパターンとして紹介される方法とはちょっと違うかもしれませんか、継承されることを前提としているので、こんな感じになりました。ちなみに、抽象クラスなのでnewできません。かならずサブクラス名::getInstance() で取得します。

Followersクラスを実装しよう

続いて、いよいよデータベースと連携するフォロワークラスを実装します。これはSingletonを継承します。また、Modelという名前空間に配置して、Freundschaft\Model\Followersというクラス名にしましょう。

namespace Freundschaft\Model;

use Freundschaft\Pattern\Singleton;

class Followers extends Singleton
{

}

で、これから色々と実装していくわけですが……$wpdbを操作する場合、以前紹介したように、global $wpdbをよく使います。でも、毎回グローバル変数呼び出すのダルいですよね。

したがって、マジックメソッドの__getを実装することで、$this->dbとやったらwpdbが取得できるようにします。あと、テーブル名も欲しいですね。

/**
 * Getter
 *
 * @param string $name
 *
 * @return null
 */
public function __get( $name ){
     switch( $name ){
          case 'db':
               global $wpdb;
               return $wpdb;
               break;
          case 'table':
               return $this->db->prefix.'followers';
               break;
         default:
               return null;
               break;
     }
}

あと、クラス宣言の上に次のようなDoc blockを書いておいてください。IDEを使っている場合、これにより、コードヒントが出てきます。

/**
 * Followers Model
 *
 * @package Freundschaft\Model
 * @property-read \wpdb $db
 * @property-read string $table
 */
class Followers extends Singleton
みんなもPhpStormで幸せになろう!
みんなもPhpStormで幸せになろう!

フォロー状況を取得するメソッド

それでは、やっと本題に入ります。Ajaxで前回作ったのは、ユーザーIDの配列を受け取り、そのそれぞれをフォローしているか否かの配列を返すというものでしたね。

では、これを実装しましょう。メソッド名をgetFollowStatusとします。

SQLで複数の値の中に含まれるかどうかを検証するのはWHERE user_id IN (1, 2, 4)という形式です。したがって、出来上がるSQLはこんな感じですね。

SELECT * FROM wp_followers
WHERE follower_id = 1
  AND user_id IN (1, 2, 4)

必要なのはuser_idとフォローの有無。いつフォローしたかはとりあえずなしにしておきます。

/**
 * Get following status of
 *
 * @param int $follower_id
 * @param array $user_ids
 *
 * @return array
 */
public function getFollowStatus($follower_id, $user_ids = array()){
     // Convert all ids to int
     $user_ids = array_map('intval', (array)$user_ids);
     if( empty($user_ids) ){
          return array();
     }
     // Make Query
     $where_in = implode(', ', $user_ids);
     $query = <<<SQL
          SELECT user_id FROM {$this->table}
          WHERE  follower_id = %d
            AND  user_id in ({$where_in})
SQL;
     // Get result as array
     $result = $this->db->get_col($this->db->prepare($query, $follower_id));
     // Make return array['user_id' => true|false]
     $return = array();
     foreach( $user_ids as $user_id){
          if( !isset($return[$user_id]) ){
               // If user id found in $result, it means following.
               $return[$user_id] = false !== array_search($user_id, $result);
          }
     }
     return $return;
}

$wpdb->get_colは結果を行ではなく、値の配列にして取得するメソッドです。今回のように、idしか必要でない(フォローしていればかならずレコードが存在するから)場合は便利ですね。

$wpdb->prepareは以前ちょろっと触れた「プリペアードステートメント」です。1つ目の引数がSQL文、2個目以降の引数が置換したい値です。

プリペアードステートメントとは?

SQLを発行する場合、常にSQLインジェクションという脆弱性を気にする必要があります。たとえば、ユーザーから入力を受け取るログインフォームのようなものを考えてみましょう。

みんなが馬鹿正直にユーザー名とパスワードを入れてくれればいいのですが、それが最終的にSQL文に変換されることを見越して、悪意のあるスクリプトを入れてくる可能性があります。詳しくはWikipediaなどを参照ください。

SQLインジェクションを防ぐために、データベースを使ったWebアプリケーションでは入力を無害化する仕組みがあり、それは大抵の場合プリペアードステートメントという形で実装されています。

$wpdb->prepareでは、1つめに渡したクエリのうち、%s, %d, %fがそれぞれ2つめ以降の引数によって整数、文字列、浮動小数点に置き換えられることが保証されます。これにより、ユーザーの入力に悪意のあるスクリプトが混入していても無害化できるというわけですね。

なお、ORマッパーであればそもそもSQLを書く必要がぐっと減るので、安全性はさらに増します。

閑話休題。それではいよいよこのメソッドをAjaxで呼び出しましょう。そうそう、さっき追加したこのコードは消しておいてください。

add_action('init', function(){
     var_dump( class_exists('Freundschaft\\Pattern\\Singleton') );
     exit;
});

Ajaxで実装する

それでは、前回実装したメソッドを_freundschaft_logged_inを修正します。

/**
 * ログイン済みユーザーのAjax
 */
function _freundschaft_logged_in(){
     $users = array();
     if( isset($_POST['author_ids']) && is_array($_POST['author_ids'])){
          $author_ids = array_unique($_POST['author_ids']);
          $result = Freundschaft\Model\Followers::getInstance()->getFollowStatus(get_current_user_id(), $author_ids);
          $users = array();
          foreach( $result as $user_id => $bool ){
               $users['user_'.$user_id] = $bool;
          }
     }
     wp_send_json(array(
          'logged_in' => true,
          'users' => $users,
          'nonce' => wp_create_nonce('freundschaft'),
     ));
}

見かけは前回と変わりませんが、Ajaxの戻り値を見てみると正しそうです。もっとも、現時点でフォローする機能がないので、当たり前なのですが。

Webインスペクタで戻り値の確認
Webインスペクタで戻り値の確認

続いてフォローとフォロー解除の機能を作りましょう。まずはModel\Followersを修正して、フォローとフォローの解除のメソッドを実装します。

/**
 * Follow
 *
 * @param int $follower_id
 * @param int $user_id
 *
 * @return bool
 */
public function follow($follower_id, $user_id){
     return (bool) $this->db->insert($this->table, array(
          'follower_id' => $follower_id,
          'user_id' => $user_id,
          'created' => current_time('mysql'),
     ), array('%d', '%d', '%s'));
}

/**
 * Unfollow
 *
 * @param int $follower_id
 * @param $user_id
 *
 * @return bool
 */
public function unfollow($follower_id, $user_id){
     return (bool) $this->db->delete($this->table, array(
          'follower_id' => $follower_id,
          'user_id' => $user_id,
     ), array('%d', '%d'));
}

$wpdb->insert$wpdb->deleteも変更された行数を返します。タイムアウト(ex. WebサーバやMySQLが落ちてる)以外のエラーとしては、すでにフォロー済みもしくはそもそもフォローされていないというケースなどが考えられますが、その場合falseを返します。

それでは、これを使ったAjaxのエンドポイントを作りましょう。freundshcaft.phpを修正します。

/**
 * Ajaxを実装
 */
add_action('admin_init', function(){
     if( defined('DOING_AJAX') && DOING_AJAX ){
          // Ajaxリクエストのときだけ実行
          add_action('wp_ajax_nopriv_fs_status', '_freundschaft_not_logged_in');
          add_action('wp_ajax_fs_status', '_freundschaft_logged_in');
          add_action('wp_ajax_fs_follow', '_freundschaft_follow');
          add_action('wp_ajax_fs_unfollow', '_freundschaft_unfollow');
     }
});

これでAjaxのエンドポイントが2つ追加されました。では、それぞれの関数を実装します。

/**
 * フォローする
 */
function _freundschaft_follow(){
     $json = array(
          'success' => false,
          'message' => '',
     );
     try{
          // nonceをチェック
          if( !isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'freundschaft')){
               throw new Exception('不正な遷移です。', 500);
          }
          // ユーザーIDをチェック
          if( !isset($_POST['user_id']) || !is_numeric($_POST['user_id']) ){
               throw new Exception('ユーザーIDが指定されていません。', 500);
          }
          if( !Freundschaft\Model\Followers::getInstance()->follow(get_current_user_id(), $_POST['user_id']) ){
               throw new Exception('すでにフォローしています。', 500);
          }
          $json = array(
               'success' => true,
               'message' => 'フォローしました',
          );
     }catch ( Exception $e ){
          $json['message'] = $e->getMessage();
     }
     wp_send_json($json);
}

/**
 * フォロー解除
 */
function _freundschaft_unfollow(){
     $json = array(
          'success' => false,
          'message' => '',
     );
     try{
          // nonceをチェック
          if( !isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'freundschaft')){
               throw new Exception('不正な遷移です。', 500);
          }
          // ユーザーIDをチェック
          if( !isset($_POST['user_id']) || !is_numeric($_POST['user_id']) ){
               throw new Exception('ユーザーIDが指定されていません。', 500);
          }
          if( !Freundschaft\Model\Followers::getInstance()->unfollow(get_current_user_id(), $_POST['user_id']) ){
               throw new Exception('このユーザーをフォローしていません。', 500);
          }
          $json = array(
               'success' => true,
               'message' => 'フォロー解除しました',
          );
     }catch ( Exception $e ){
          $json['message'] = $e->getMessage();
     }
     wp_send_json($json);
}

このエンドポイントは次のようなJSONを返します。

{
     "success" => true,
     "message" => "フォローしました"
}

あ、そうだ。JSに渡す変数も追加しないといけないですね。

// JSに変数を渡す
wp_localize_script('freundschaft', 'Freundschaft', array(
     'endpoint' => admin_url('admin-ajax.php'),
     'action' => 'fs_status',
     'action_follow' => 'fs_follow',
     'action_unfollow' => 'fs_unfollow',
));

うまくいった場合は何も表示する必要はなく、ボタンのclassだけ変更すればデザインを変わるので楽ですね。うまくいかなかった場合は「異常」と判定されるので、メッセージを表示します。freundschaft.jsの最後の方に書いた.fs-btn のクリック判定をこう変えましょう。

あと、nonceも保存する仕組みにしましょう。

// ボタンの初期値
var $btns, nonce = '';
// ……中略
               if( result.logged_in ){
                    // ログインしているので、ユーザーに応じたボタンを出力
                    for( var prop in result.users ){
                        if( result.users.hasOwnProperty(prop) ){
                            var userId = prop.replace(/user_/, '');
                            if( result.users[prop] ){
                                // フォローしている
                                $('.fs-btn[data-author-id=' + userId + ']').removeClass('fs-disabled').addClass('fs-following');
                            }else{
                                // フォローしていない
                                $('.fs-btn[data-author-id=' + userId + ']').removeClass('fs-disabled').addClass('fs-follow');
                            }
                        }
                    }
                    // nonceを保存
                    nonce = result.nonce;
                }else{
                    // ログインしていないので、
                    // ボタンをログインに
                    $btns.removeClass('fs-disabled').addClass('fs-login');
                }
// ……中略
//
$(document).on('click', '.fs-btn', function(e){
    var $btn = $(this),
        userId = $btn.attr('data-author-id');
    if( $btn.hasClass('fs-disabled') ){
        e.preventDefault();
    }else if( $btn.hasClass('fs-follow') ){
        // フォローする
        e.preventDefault();
        // 一時停止
        $btn.removeClass('fs-follow').addClass('fs-disabled');
        // Ajax
        $.post(Freundschaft.endpoint, {
            action: Freundschaft.action_follow,
            user_id: userId,
            _wpnonce: nonce
        }).done(function(result){
            if( result.success ){
                // 成功
                $btn.removeClass('fs-disabled').addClass('fs-following')
            }else{
                // 失敗
                $btn.removeClass('fs-disabled').addClass('fs-follow');
                alert(result.message);
            }
        }).fail(function(xhr, status, error){
            $btn.removeClass('fs-disabled').addClass('fs-follow');
        });
    }else if( $btn.hasClass('fs-following') ){
        // フォロー解除
        e.preventDefault();
        // 一時停止
        $btn.removeClass('fs-following').addClass('fs-disabled');
        // Ajax
        $.post(Freundschaft.endpoint, {
            action: Freundschaft.action_unfollow,
            user_id: userId,
            _wpnonce: nonce
        }).done(function(result){
            if( result.success ){
                // 成功
                $btn.removeClass('fs-disabled').addClass('fs-follow')
            }else{
                // 失敗
                $btn.removeClass('fs-disabled').addClass('fs-following');
                alert(result.message);
            }
        }).fail(function(xhr, status, error){
            $btn.removeClass('fs-disabled').addClass('fs-following');
        });
    }
});

ふう……やっとできました。あとは、そうですね、状態が変わった時がなんか分かりづらいので、CSSのTransitionでアニメーションをつけましょう。

// 共通
.fs-btn{
  display: inline-block;
  font-size: 0.85em;
  border-width: 1px !important;
  border-style: solid !important;
  @include border-radius(2px);
  padding: 0.25em 0.5em;
  &:link, &:visited{
    text-decoration: none;
  }
  &:hover, &:active{
    text-decoration: none;
  }
  // アニメーション
  @include transition(background-color .5s linear,
                             color .5s linear,
                             border-color .5s linear);
}

かなりいい感じですね。サイズが変わる時ちょっと変ですけど。

まとめ

  • PSRっちゅうルールがあるんやで
  • 名前空間とオートローダーでrequire_once地獄からの解放
  • モデルを作り、マジックメソッドでおしゃれに$this->db

とまあ、いろいろやりましたが、まだ課題がありますね。それは、twitterのフォロワーを眺めてうっとりする画面がないことです。これは次回やりましょう。

あと、そうですね、なんとなく似たような記述がいっぱいあるので、ここらでリファクタリングしたくなってきますね。前回からのdiffはこちら。それでは、終わり。

[429] [429] Client error: `POST https://webservices.amazon.co.jp/paapi5/getitems` resulted in a `429 Too Many Requests` response: {"__type":"com.amazon.paapi5#TooManyRequestsException","Errors":[{"Code":"TooManyRequests","Message":"The request was de (truncated...)

すべての投稿を見る

高橋文樹ニュースレター

高橋文樹が最近の活動報告、サイトでパブリックにできない情報などをお伝えするメーリングリストです。 滅多に送りませんので、ぜひご登録お願いいたします。 お得なダウンロードコンテンツなども計画中です。