WordPress

WordPress 魔改造の手引き

Intro

この資料は、私が仕事で手がけた WordPress カスタマイズ系プロジェクトのコードをメンテしたり追加開発したりするステキな人たちへ向けたものですが、社内に閉じてない範囲をここに記す。コメントなどでの指摘歓迎。

WordPress、プラグイン機構(hook)が充実、というかデフォルトのブログシステムも自身のプラグイン機構に乗る形で作られている。そのため、モデルがブログ的でありさえすればフレームワークとして使うことも可能。これまで、やれアプリ向け API を追加してみたり、完全別システム用 CMS にしてみたり、よくまあやったものだ。

これ WordPress でやれるんだあ!?的な意味で魔改造と揶揄しているがいたって正攻法の改造である。1

Pros/Cons

以下、WordPress のよいところでもあり、裏返すと弱点でもある特徴のいくつか 2

  • とにかく利用者が多い。オープンソース。

    WordPress is ... 26.3% of all websites - w3techs.com

  • PHP 5 / MySQL / Apache 前提

  • 管理画面が充実。だいたい説明なしに使ってもらえる

  • 管理画面からポチポチするだけでアップデートしたりプラグインインストールしたりできる

  • コマンドラインからのメンテナンスも可能

  • 10年の歴史を感じるコード

    WordPressを他の言語で書き直しますか? がんばってください,5年後に会いましょう(笑) - Rasmus

  • PHP的。置けば動く。コピペで動く。未だアップデートの際に追加され続ける大量の関数群。

  • 機能のほとんどをhook可能

  • プラグインの公式ディレクトリ活発。玉石混交

  • やりたいことはググればなんでも出てくる。

  • 上記に由来するセキュリ 3

  • ホスティングサービス https://wordpress.com/ があり、オープンソース版とエクスポートファイル(WXR 形式)の互換性がある

  • 最初の開発者 Matt は https://wordpress.com/ などを運営する Automattic Inc. の CEO であり、同社にコアデベロッパー多数が在籍している

  • GPLである。wordpressコアにリンクするPHPコードはGPLとなる。

OK, wordpress4 setup is easy. but...

WordPress道の驚きの入りやすさに感銘を受けつつも、実際に使うことになるエンジニアが最初気にしそうな点について。

設定値

  • wp-config.php で定義される定数から。最初に呼ばれる 5
  • wp_options テーブルから。(特に autoloadフィールドがyesのレコード)
  • その他有効化されているplugin/themeなどのphpコードで定義された定数から。

どこに何が保存されるの

基本は MySQL。ファイルはアップロード画像だけ気をつければよい。

1) MySQL table

詳しい定義はドキュメント見てもらうとしてざっくりいうと

wp_posts

  • postマスタ。重要。いわゆる「投稿」も単に post_type = "post" な postレコード。ほかに "page"(固定ページ)や "attachment"(アップロード画像)といった post_type のレコードが全部 wp_posts に格納される。
  • wordpressの表示は基本 WP_Query による wp_posts テーブルへのクエリ結果。早い話が

    select * from wp_posts where post_type = "post" and post_status = "publish";
    
  • post_type マスター のようなものは無く、wordpress が何かをしようとした時までに register_post_type() されているものがその時の全 post_type となる。標準の post_type 自体も register_post_type() で定義されている。標準以外のものを「カスタムポストタイプ」と呼ぶ。

  • wp_postmeta テーブル: postのメタデータ。wp_posts 自体にもいろいろフィールドはあるのだけど、そこに入らないものや「カスタムフィールド」が格納される。

wp_term_taxonomy

  • taxonomyマスタ。wp_postsのレコードを横串で関連づけるもの。デフォルトで用意されているのは "category" と "post_tag"(双方 post_type:post用) など。
  • カスタムポストタイプと同様、デフォルト以外の「カスタムタクソノミー」を register_taxonomy() で追加できる。
  • wp_terms テーブル: taxonomyの表層(文字列や slug6)を格納
  • wp_term_relationships テーブル: wp_postsとtaxonomyの関連マスタ

wp_users

  • ユーザーマスタ。ログインや投稿者名。
  • wp_usermeta テーブル: wp_users に収まらないユーザーメタデータ
  • ユーザーの権限、どのpost_typeを操作できるのか、どのpost_statusへの変更が許可されるのかなどは role と capability(略称 cap)という単位で管理される

wp_options

  • key-valueな設定値置き場
  • 管理画面の隠しページ wp-admin/options.php ですべての値を見れる

2) ファイル

webサーバーを複数台にするため、 wp-content/uploads/ ここに保存されるアップロード画像をS3にあげてくれるプラグイン がオススメ。そしてその他のファイルを wordpress が更新しないようにしたほうがいい。

  • wp-content/ 以下のどこかに設定を保持しようとするプラグイン(稀)は、避ける。
  • wp-config.php はウェブ UI から作成せず wp core config で作ったものをデプロイする
  • .htaccess 「パーマリンク設定」を保存したタイミングで、# BEGIN WordPress 行が無い .htaccess に書き込もうとしやがるのでコメント行を残したままにしておくことで回避する。

更新管理

以下を設定している場合(webサーバー1台構成の開発環境でのみ有効にすべき)、

define('FS_METHOD', 'direct');

wordpress の管理画面からポチポチプラグインをインストールしたり、コアのアップデートしたりできる。管理画面からではなく wp コマンドからもできる。いずれにしても wordpress コアやプラグインがどう変わったのかを把握するため、最終的には以下あたりを .gitignore しつつ全体を gitに入れて管理している。前述のようにファイルに設定を保存するプラグインとかはこれで発覚する。

wp-config.php
wp-content/upgrade/
wp-content/uploads/

wordpress コアをアップデートすると、最初のアクセスで DB マイグレーションが動いてしまうので手動で wp core update-db したほうがよい。ダウングレードのマイグレーションは無いので注意。

One big table

標準の post_typeをよく見た人は、えっ "revision" (変更履歴)もここに入るの? wp_posts.id 増えすぎ? と不安になることでしょう。それね。しかし幸か不幸か今のところこのモデルに収まるプロジェクトのみであり、メディアの成長が選択可能なDBインスタンスの向上をまだ超えてないので、パーティショニングなど検討するには至っていない。

パフォーマンス

カスタマイズ性の反面動作は遅い。とくにどんだけ mysql にクエリを投げるのっ!というくらい投げる。具体的には debug bar などのプラグインで見れる。もっと簡単には以下をどこか(有効な theme の function.php とかにでも)書いていただいて任意のページを見ると

define('SAVEQUERIES', true);
add_action('shutdown', function() {
    global $wpdb;
    echo "<!--";
    print_r($wpdb->queries);
});

鼻血がでます。

これでも、同じレコードについては in-memory で cache したり工夫はされてる。その辺を dropin して kvs に逃がすとか、ファイルキャッシュをしたりとかまあいろいろやりようはあるのですが、私としては wordpress を表示で使う場合 CDN の後ろに置くものだと割り切った方がいいなーという結論です。

behind a CDN

AWSにおく場合は、以下あたりを最小構成とし、各ロードバランサの下のインスタンスを分けてみたり数増変えてみたりのバリエーション。表示系を使わない(管理画面のみ使う)場合は CloudFront と ELB が無い。

注意点としては、複雑化を避けるためフィードについての独自ファイルキャッシュだけ忘れずに止め、

define('MAGPIE_CACHE_ON', false);

CDN に対して Apache(htaccess)のレベルにて、Cache-Control ヘッダーをリクエストパスやファイルタイプで微調整してがんばっている。そもそも表示系では CDN の設定で GET しか通さないし Cookie 通さないようにしてある程度のセキュリティも担保している。

wordpress は wp cron という仕組みで未来に実行するタスクを持っており、記事のアクセスのたびに必要なら処理(例えば公開予約記事のステータス変更など)しているが、CDNの裏の場合そこに頼れないし頼りたくないので

define('DISABLE_WP_CRON', true);

して、バッチサーバーから wp cron event などで必要なタスクだけを実行している。

First step

wordpress道の最初の3ステップ

step 1. wp-cli is your friend

wp コマンドをあなたのツールボックスに追加しよう。単体 phar でインストールも簡単。カレントディレクトリの wordpress へのあらゆる操作を行える。php にとっての composer、node にとっての npm のような開発者必携コマンド。wordpress 環境の新規セットアップなどもすべて wp コマンドを並べた shell スクリプト化できる。

step 2. ACF

数あるプラグインの中でも、Advanced Custom Fields これは必須プラグイン。良い入力 UI 付きの「カスタムフィールド」を追加するプラグイン。大抵の要求はこれでカバーできる。

wordpress のモデル上の meta (カスタムフィールド)の仕組みがない、taxonomy に対しても ACF はフィールドを追加できるが、それは wp_options に入れられているのを考慮しておくこと。

有料版もあり、おすすめ。要注意としては Pro と無料版が API はだいたい同じ(Pro のみの API がある)だがデータ構造が違うので別ものと思ったほうがいい。途中で Pro にアップグレードするときは注意が必要。

step 3. Hello world

一番かんたんな wordpress API へアクセスして何かするスクリプト

<?php
require_once 'wordpress/wp-load.php'; // これだけ!

echo "blogname is ", get_option('blogname');

ワンライナー

$ cd wordpress/
$ wp eval 'echo get_option("blogname");'

# もちろんこの例だと専用のコマンドがある
$ wp option get blogname

Writing a plugin

どこに書けば wordpress に読んでもらえるか

index.php(表示フロントコントローラ) や wp-admin/*.php (管理画面)に機能を追加するには、大きく2つある

プラグイン

ディレクトリ wp-content/plugins/ 以下のファイルが走査され、特定の形式のPHPコメントがあるファイルが「プラグイン」として認識される。それらのうち、アクティブにされているプラグインのファイルが以後 include_once() される。 7

新規プラグインの元ネタは wp scaffold で作成できる。

$ wp scaffold plugin great-api
Success: Created plugin files.
Success: Created test files.

$ tree wp-content/plugins/great-api/
wp-content/plugins/great-api/
├── Gruntfile.js
├── bin
│   └── install-wp-tests.sh
├── great-api.php
├── package.json
├── phpunit.xml.dist
├── readme.txt
└── tests
    ├── bootstrap.php
    └── test-sample.php

$ cat wp-content/plugins/great-api/great-api.php 
<?php
/**
 * Plugin Name: Great-api
 * Version: 0.1-alpha
 * Description: PLUGIN DESCRIPTION HERE
 * Author: YOUR NAME HERE
 * Author URI: YOUR SITE HERE
 * Plugin URI: PLUGIN SITE HERE
 * Text Domain: great-api
 * Domain Path: /languages
 * @package Great-api
 */

アクティブにするには管理画面 wp-admin/plugins.php からもしくは wp plugin activate する。

テーマの function.php

ディレクトリ wp-content/themes/ 以下の style.css ファイルが走査され、特定の CSS コメントがあるファイルのディレクトリが「テーマ」として認識される。そのうち有効にしたテーマ1つが表示で利用されることになる。その有効テーマディレトリに function.php がある場合、それも include_once() される。

基本的にこの function.php には表示に直接関連したごく一部だけを置くのがよいでしょう。社内では wordpress テーマはデザイナが作っているので function.php もデザイナに任せています。8

hook

キモは以下の2種類のhook。フィルタとアクション。

add_filter(), apply_filters()

特定の値を確定させるところにhookするための機構。値がバケツリレー式にフィルタされる。

例えば wordpress コアのこのへんで、投稿データを DB にいれる直前に用意されている "wp_insert_post_data" という名前のフィルタを使うと、DB に入れる直前に値をごにょごにょできる。

add_filter('wp_insert_post_data', function($data) {
    // post_type = "post" の場合のみ...
    if ($data['post_type'] !== 'post') {
        return $data;
    }

    // あやしい感じにする
    $data['post_title'] .= " [要出典]"; // TODO 保存するたびに増えるよ

    // 次の "wp_insert_post_data" フィルタに登録されている関数へ渡される
    return $data;
});

自分たちで作るプラグインでも、共用するプラグインでは適宜 hook を入れておき、外から処理や設定値をコントロールできるようにしておくとよい。フィルタ名は自由。

// デフォルトは ["post", "page"] だけど外から追加削除できるようにしておく例
$target_types = apply_filters('great-api/target_types', ["post", "page"]);

foreach ((array)$target_types as $type) {
    ...

add_action(), do_action()

特定のタイミングで呼ばれる。その場で print() するなど何かを実行したり、何らかのインスタンスを受け取ってそれをごにょれるようにするために使われる。

// 管理画面の <head> で任意のstyleを出すなど
add_action('admin_head', function(){
    if (constant('ENV') !== 'dev') {
        return;
    }
?>
<style type="text/css">
background-image: url(dev.png);
</style>
<?php
});

wordpress が一つのリクエストをさばいていく中の各タイミングで呼ぶアクションの順番はこちら。よく使うのは init で(管理画面での挙動にだけ関係するものは代わりに admin_init)、この hook にやりたい全処理をかけるとわかりやすい。以下例で示す

フレームワークとして使ってみる

※JSON APIみたいのやりたいなら、前はプラグインで提供されていていま(4.7以降)ならWP内蔵のREST APIを使うのが定石です。"rewrite"といった用語とか、"template_redirect"というなんでもできちゃうhook pointあたりの説明を残しておきたいのでそのまま掲載しておきますmm

/api でトップページのように新着記事を、/api/xxx で xxx カテゴリの記事を JSON で出すプラグインを例としてつくってみる。基本的に init hook から入り...

namespace Great;

class Api
{
    public function __construct()
    {
        add_action('init', [ $this, 'router' ]);
        add_action('init', [ $this, 'dispatch' ]);
    }

wordpress の "rewrite" マップに /api の対応マップを追加する。wordpress 用語の rewrite とはフロントコントローラが受け取った PATH_INFO を内部で query string のように扱う対応を指定しておくもの。wp rewrite list すると、パスがどのようにクエリパラメータ扱いになるかがわかる。

    public function router()
    {
        add_filter('rewrite_rules_array', function($rules) {
            $rules = array_merge($rules, [
                'api/?$'       => 'index.php?great-api=1',
                'api/([^/]+?)' => 'index.php?great-api=1&category_name=$matches[1]',
            ]);

            return $rules;
        });

この例の great-api は勝手クエリパラメータなのだが、wordpress は知らないクエリパラメータを無視するので query_vars フィルタで追加しておく。

        add_filter('query_vars', function ($vars){
            $vars[] = 'great-api';
            return $vars;
        });
    }

wordpress は rewrite マップに実際の query string をマージし 9 得られたパラメータから最終的に1つの WP_Query object を作成する。これを main query と呼ぶ。どのようなクエリパラメータがどのような select 条件となるかは wiki を熟読すべし。

無事この WP_Query オブジェクト(global $wp_query に保持される)が作られた後、普通ならこの template hierarchy の図 10 にしたがって選ばれたテーマ内のテンプレートファイルに渡され、HTML が表示されることになる。今回そこをトラップして JSON で出したいので

    public function dispatch()
    {
        add_action('template_redirect', function(){
            global $wp_query;

            // テンプレートに行かせない
            if ($wp_query->get('great-api')) {
                $this->render($wp_query->posts);
            }
        });
    }

    public function render(array $posts)
    {
        $res = [];
        foreach ($posts as $post) {
            $res[] = [
                'id' => $post->ID,
                'content' = apply_filters('the_content', $post->post_content),
            ];
        }

        header('Content-Type: application/json;charset=utf-8');
        echo json_encode($res);
        exit;
    }
}

細かいところは適当だがまあこんなクラスを用意してプラグインのファイルから呼ぶ

<?php
/**
 * Plugin Name: Great-api
 * Version: 0.1-alpha
 * Description: PLUGIN DESCRIPTION HERE
 * Author: YOUR NAME HERE
 * Author URI: YOUR SITE HERE
 * Plugin URI: PLUGIN SITE HERE
 * Text Domain: great-api
 * Domain Path: /languages
 * @package Great-api
 */

require __DIR__ . '/vendor/autoload.php'; // みたいに composer 併用も自由

new Great\Api();

rewrite マップは特別な設定で、wp_options にシリアライズ保存されたものが使われ、その wp_options の値は管理画面から 設定 > パーマリンク設定 > 変更を保存 したときか、wp rewrite flush したときにだけ反映となるので注意。

# 現在のrewrite mapを見る
$ wp rewirte list

# プラグインを有効化して
$ wp plugin list
$ wp plugin activate great-api

# reriteマップを更新すると
$ wp rewire flush

# /api とかが追加されているはず
$ wp rewirte list
+----------------+--------------------------------------------------+----------+
| match          | query                                            | source   |
+----------------+--------------------------------------------------+----------+
...
| api/?$         | index.php?great-api=1                            | other    |
| api/([^/]+?)   | index.php?great-api=1&category_name=$matches[1]  | other    |
...
| (.+?)/?$       | index.php?post_type=page&pagename=$matches[1]    | other    |
+----------------+--------------------------------------------------+----------+

Cookbook

comming soon ...としたまま放置予定だったが、わりと見てもらっていたのでいくつか闇tipsを!

何が出力されるか不安なとき

<?php
/*
Plugin Name: Ultra Super Content Filter
Version: 1.0.0
Description: 最終兵器
*/

if (!preg_match('@^/wp-@', $_SERVER['REQUEST_URI'])) {
    ob_start(function($body){
        return apply_filters('our/ultra_super_filter_content', $body);
    });
}

こんなフィルタを定義したコードを mu-plguin としてロードして用意しておく最終手段。以前 http から https にしたサイトで、wp search-replace などの代わりに使ったことがあった。

add_filter('our/ultra_super_filter_content', function ($body) {
    $body = preg_replace('@http://(example[.]com)@', 'https://$1', $body); // もうすこしちゃんと
    return $body;
});

みたいな

Unit Test

プラグインのフィルタやアクションに対するUTの書き方については、wp scaffold pluginが足がかりになる。

wp scaffold plugin(既存のプラグインに対しては wp scaffold plugin-tests)を叩いて、それが作成する bin/install-wp-tests.sh と tests/bootstrap.php をチラ見するとよいです。環境変数にしたがって、テスト用 mysql に接続するまっさらな wordpress 環境をダウンロードしてインストール。プラス、ここにひっそりと置かれている wordpress用 PHPUnitのヘルパークラスをダウンロードしてくれる。

接続情報を .env で渡す場合は以下のような(Makefileの例)

setup: .env.test
    @WP_TESTS_DIR=$(CURDIR)/temp/wordpress-tests-lib \
        WP_CORE_DIR=$(CURDIR)/temp/wordpress/ \
        source $(CURDIR)/.env.test && \
        bash ./bin/install-wp-tests.sh "$$WP_DB_NAME" "$$WP_DB_USER" "$$WP_DB_PASS" "$$WP_DB_HOST"

test:
    WP_TESTS_DIR=$(CURDIR)/temp/wordpress-tests-lib phpunit

tests 以下には、PHPUnitの流儀でテストを置ける。WP_UnitTestCase は extends PHPUnit_Framework_TestCase です。

<?php
require_once __DIR__ . '/bootstrap.php';

class Great_API_Test extends WP_UnitTestCase
{
    public function setUp()
    {
        parent::setUp();
        $this->factory->post->create_many(20); // とか
    }

    public function test_フィルタのてすと()
    {
        $out = apply_filters('dekinai_wo_dekiru_ni_suru', "dekinai");

        $this->assertEquals('dekiru!!', $out);
    }

    public function test_出力をするアクションのテストの例()
    {
        $this->go_to('?feed=xxx');

        the_post();
        ob_start();
        do_action('rss2_item');
        $out = ob_get_clean();
        $this->assertRegexp('/<related>/', $out, '<realted>タグ絶対');
    }

do_feed_xxx とか、template_redirect といったもっと大きな単位の action で、出力されるもの全体をテストしたい場合もあるが、phpunit のスレッド的機能で以下のようにもできる。

    /**
     * @runInSeparateProcess
     * @preserveGlobalState disabled
     */
    public function test_まるっとXML全チェックする例()
    {
        $this->go_to('?feed=xxx');

        ob_start();
        do_action('do_feed_xxx');
        $out = ob_get_contents();

        // $outに対してがんばってテスト
    }

このへんになるとインテグレーションテストとしてやったほうがいいかなという印象。インテグレーションテスト、毎回プロジェクトの活発さや規模で変えていて、でもこれだーってものはみつからず模索中。

Go at your own risk!

さらなる魔が棲んでいると噂される領域。とびこむのは自由です。11

  • multisite
  • wordpress pluginによるcache
  • dropins
  • wp-api
  • wp-admin/js/

いじょう



  1. ほとんどはね。 

  2. それぞれがどう弱点なのかは自分でかんがえよう! 

  3. 手記はここで途絶えている 

  4. めんどくさいので正式には WordPress だが php としては以下 wordpress と書くね 

  5. wordpress ディレクトリの一つ上のディレクトリに wp-config.php ファイルがある場合、そっちが優先して呼ばれるというあまり知られていない挙動があります。おすすめ。 

  6. スラッグというのは wordpress 用語で URL につかわれる文字列のことをいいます("美容" というterm に対する "%E7%BE%8E%E5%AE%B9" だったり "beauty" としたりするやつ)。 

  7. wp-content/mu-plugins/*.php に wordpress プラグインとしての PHP コメントがある場合、"must use plugin" として include_once() される機能もある。通常のプラグインと違い常に必ず読まれ(active/inactive がない)で通常のプラグインよりも先に読まれる。 

  8. テーマも素の PHP。 なんでも できるのでちゃんとレビューしよう。 

  9. さらに pre_get_posts アクション からアクセスしてパラメータ変更も可能。 

  10. 印刷して壁に貼ろう! 

  11. 筆者はここに挙げた全ての魔境に一度ならず果敢に挑戦したが、なぜか恐ろしいものを見た以上のことを言おうとしない。ビールを飲ませると魔境の様子を話し始めるとのことである。