Movable Type 7 のデータベースを利用して、Yii2 で Data API っぽいものを作る

これは Movable Type Advent Calendar 2019 1日目の記事です。

Data API を使いたい気持ちはあるものの、サーバーのスペックとの兼ね合いなどで静的に JSON を出力することもしばしば。とはいえ、テンプレートを用意するのが意外と手間だったりしますよね。

そこで、Craft CMS でも採用されている PHP フレームワークの Yii2 を利用して、Movable Type 7(以下、MT7)のデータベースにアクセスしてみることにしました。

あくまで備忘録ですが、ザックリ解説してみます。

はじめに

ここで必要な MT7 や Yii2 は下記のような構成を想定しています。

├── data/
│   ├── mt/             // MT7 のインストールディレクトリ
│   └── basic/          // Yii2 のインストールディレクトリ
└── var/www/html/       // ドキュメントルート(MT7 のサイトトップ)
    └── yii2/           // Yii2 のウェブルート

サンプルデータは、下記のリポジトリに用意してあります。

サンプルデータ
https://github.com/dreamseeker/alt-dataapi-with-yii2-sample

事前準備

この時点で MT7 はインストールされているものとします。

Yii2 のインストール

ターミナルでインストール先の /data に移動し、composer コマンドを実行します。

$ cd /data
$ composer create-project yiisoft/yii2-app-basic basic 

なお、公式リファレンスには Zip ファイルからインストールする方法も記載されています。

始めよう: Yii をインストールする | Yii 2.0 決定版ガイド | Yii PHP Framework
https://www.yiiframework.com/doc/guide/2.0/ja/start-installation

データベース接続設定

/data/basic/config/db.php にデータベース接続設定を記述します。
ここでは Yii2 から MT7 のデータベースにしかアクセスしないため、直接定義します。

アプリケーション設定

/data/basic/config/web.php の urlManager 設定に関するコメントアウト部分を下記のように変更します。

'urlManager' => [
    'enablePrettyUrl' => true,
    'showScriptName' => false,
    'rules' => [
        'api/helloWorld' => 'mt/hello-world',
        'api/contentField/<contentTypeId:\d+>' => 'mt/content-field',
        'api/contentData/<contentTypeId:\d+>' => 'mt/content-data',
    ],
],

enablePrettyUrl は index.php?r=mt/hello-worldindex.php/mt/hello-world でアクセスできるようにするかどうか。
showScriptName は URL の index.php を非表示にするかを調整できます。

rules は リクエスト URL ごとにどのコントローラ・アクションを呼び出すか を定義します。
この例では api/helloWorld にアクセスすると index.php?r=mt/hello-world を呼び出した場合と同じ状態になります。

また、api/contentData/<contentTypeId:\d+> は末尾の数値を変数 contentTypeId として受け取るための記述で、api/contentData/1 にアクセスすると index.php?r=mt/content-data&contentTypeId=1 を呼び出した場合と同じ状態になります。

詳細については、公式リファレンスも確認してください。

リクエストの処理: ルーティングと URL 生成 | Yii 2.0 決定版ガイド | Yii PHP Frameworkhttps://www.yiiframework.com/doc/guide/2.0/ja/runtime-routing#using-pretty-urls

ウェブルートの設定

Yii2 のウェブルートである /var/www/html/yii2/data/basic/web に含まれるファイル一式をコピーし、続けて /var/www/html/yii2/index.php を編集します。

<?php

// comment out the following two lines when deployed to production
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'dev');

// @NOTE 環境に応じて変更
define('YII2_CORE_DIR', '/data/basic/');
define('MT7_DIR', '/data/mt/');

require YII2_CORE_DIR . 'vendor/autoload.php';
require YII2_CORE_DIR . 'vendor/yiisoft/yii2/Yii.php';

$config = require YII2_CORE_DIR . 'config/web.php';

(new yii\web\Application($config))->run();

Yii2 本体のパス YII2_CORE_DIR と MT7 本体のパス MT7_DIR を定義し、以降の require のパスを書き換えています。

なお、Yii2 へのリクエストはすべてこの index.php を経由するため、ここで定義した定数は後述のコントローラから参照できます。

htaccess を設置

urlManager の設定で URL から index.php を消すようにしたため、/var/www/html/yii2/.htaccess を作成します。

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule (.+) index.php [QSA,L]
</IfModule>

アクティブ・レコード・クラスの作成

Yii2 から MT7 のデータベースに接続するため、取得対象のテーブルに対応するアクティブ・レコード・クラスクラスを /data/basic/modules 配下に用意します。

データベースの取り扱い: アクティブ・レコード | Yii 2.0 決定版ガイド | Yii PHP Framework
https://www.yiiframework.com/doc/guide/2.0/ja/db-active-record

MtAsset クラス

アセットのテーブル mt_asset を例にすると /data/basic/modules/MtAsset.php をファイルとして作成し、次の内容を定義します。

<?php

namespace app\models;

use yii\db\ActiveRecord;

class MtAsset extends ActiveRecord
{
}

SELECT 文でデータを取得するだけであれば、このように ActiveRecord クラスをエクステンドするだけです。
ファイル名クラス名 はアッパーキャメルケースとなります。

なお、今回アクセスしたいテーブルは下記7種なので、同様にファイルを作成しておきます。

  • mt_asset
  • mt_asset_meta
  • mt_blog
  • mt_category
  • mt_cd
  • mt_cf
  • mt_content_type

コントローラの定義(基本について)

リクエストされたアクションごとに取得したデータを JSON などで出力するための MtController クラスを /data/basic/controllers/MtController.php に定義します。

Hello World の出力

コントローラクラスの最小構成は、次のようなコードになります。

<?php

namespace app\controllers;

use yii\web\Controller;

class MtController extends Controller
{
    public function actionHelloWorld(){
        return 'Hello World';
    }
}

actionHelloWorld はフロントエンドから yii2/mt/hello-world または yii2/api/helloWorld でアクセスできます。「接頭辞 action のキャメルケースで定義しておけば、外部からアクセス可能」と覚えておくといいでしょう。

(サイトを含む)ブログ一覧の出力

mt_blog テーブルにアクセスする場合、use app\models\MtBlog であらかじめアクティブ・レコード・クラスを読み込んでおけば MtBlog::find() でアクセスできます。

<?php

namespace app\controllers;

use app\models\MtBlog;
use yii\web\Controller;

class MtController extends Controller
{
    public function actionBlogIndex(){
        $blogs = MtBlog::find()
            ->orderBy('blog_id')
            ->asArray()
            ->all();

        return $this->asJson($blogs);
    }
}

SQL 文の代わりにクエリ・ビルダと呼ばれる仕組みで必要な条件を追加していきます。

データベースの取り扱い: クエリ・ビルダ | Yii 2.0 決定版ガイド | Yii PHP Framework
https://www.yiiframework.com/doc/guide/2.0/ja/db-query-builder

なお、サンプルでは asJson() を使って JSON 形式でレスポンスを出力していますが、asXml() で XML 形式で出力することも可能です。

コントローラの定義(コンテンツデータの出力)

引き続き MtController クラスに追記していきますが、コンテンツデータの取得は少し複雑なため最終的なコードはリポジトリのサンプルファイルを参照してください。

アクティブ・レコード・クラスの読み込み

use app\models\MtAsset;
use app\models\MtAssetMeta;
use app\models\MtBlog;
use app\models\MtCategory;
use app\models\MtCd;
use app\models\MtCf;
use app\models\MtContentType;

// MTSerialize クラスのファイルを読み込み
require_once MT7_DIR . 'php/lib/MTSerialize.php';

最後の一文は MT7 本体に含まれる PHP の MTSerialize クラスの読み込みです。

コンテンツデータは mt_cd テーブルの cd_data カラムにシリアライズされた形で格納されているため、ダイナミック・パブリッシングと同じ処理で個々のフィールドの値を取る際に利用します。

なお、気づいた範囲だと mt_content_type テーブルの content_type_fields カラムも同様のため、利用頻度は高いかもしれません。

MtController クラス初期化時の処理

public $contentTypeId;
public $mtSerialize;

public function init(){
    parent::init();

    // URL パラメータを取得
    $request = \Yii::$app->request;
    $this->contentTypeId = $request->get('contentTypeId');

    // MT のシリアライズ関連クラスを初期化
    $this->mtSerialize = \MTSerialize::get_instance();
}

ここでは URL パラメータ contentTypeId の取得と MTSerialize クラスの初期化を行っています。

actionContentData の定義

コンテンツデータを実際に取得するメソッドを定義していきます。 はじめに「コンテンツフィールド」と「コンテンツタイプ」のデータを取得しておきます。

// コンテンツフィールドの取得
$contentFields = $this->getContentFieldData();

// コンテンツタイプの「データ識別ラベル」に指定されたフィールドのユニーク ID を取得
$contentTypeDataLabel = $this->getContentTypeDataLabel();

コンテンツデータのクエリでは URL パラメータで指定された contentTypeId と合致するものだけに絞り込んでいます。その後 count() で総数を取得、all() でデータを取得します。

// 基本クエリをセット
$query = MtCd::find()
    ->where('cd_content_type_id=:contentTypeId', [':contentTypeId' => $this->contentTypeId]);

// 総数の取得
$totalResults = $query->count();

// コンテンツデータの取得
$results = $query
    ->orderBy(['cd_authored_on' => SORT_ASC])
    ->asArray()
    ->all();

取得したレスポンス $results をループ処理します。

foreach ($results as $row){
    // 整形後の cd_data を格納する配列を初期化
    $data = [];

    // ラベルを格納するための変数を初期化
    $label = null;

    // cd_data カラムをアンシリアライズ
    $unserializedCdData = $this->mtSerialize->unserialize($row['cd_data']);

    // コンテンツデータをフィールドごとにループ処理
    foreach ($unserializedCdData as $key => $value) {
        // 「入力フォーマット」に関するデータを除外
        if(!preg_match('/\_convert\_breaks$/', $key)){
            // コンテンツフィールドの配列から、ループ中のフィールド情報のインデックス番号を取得
            $keyIndex = array_search($key, array_column($contentFields, 'id'));

            // 入力値をセット(必要に応じて後続の処理で上書き)
            $data[] = [
                'id'    => $key,
                'label' => $contentFields[$keyIndex]['name'],
                'type'  => $contentFields[$keyIndex]['type'],
                'data'  => $value,
            ];

            // フィールドタイプごとの追加処理
            if (in_array($contentFields[$keyIndex]['type'], ['categories', 'asset_image'])) {
                // カテゴリー・画像
                switch ($contentFields[$keyIndex]['type']){
                    case 'categories':
                        // 重複しないカテゴリ ID をセット
                        $categoryIds = array_unique(array_merge($categoryIds, $value));
                        break;
                    case 'asset_image':
                        // 重複しないアセット ID をセット
                        $imageIds = array_unique(array_merge($imageIds, $value));
                        break;
                }
            }

            // フィールドのユニーク ID が「データ識別ラベル」なら、変数にセット
            if($contentFields[$keyIndex]['uniqueId'] === $contentTypeDataLabel) {
                $label = $value;
            }
        }
    }

    $items[] = [
        'id'            => (int) $row['cd_id'],
        'label'         => $label,
        'data'          => $data,
        'authorId'      => (int) $row['cd_author_id'],
        'blogId'        => (int) $row['cd_blog_id'],
        'basename'      => $row['cd_identifier'],
        'date'          => $row['cd_authored_on'],
        'createdDate'   => $row['cd_created_on'],
        'modifiedDate'  => $row['cd_modified_on'],
    ];
}

ポイントは $this->mtSerialize->unserialize($row['cd_data']) です。
これで $unserializedCdData にループ中のコンテンツデータのコンテンツフィールド ID を key、入力値を value に持つ配列がセットされるため、さらにループ処理を行います。

サンプルでは if(!preg_match('/\_convert\_breaks$/', $key)) で「入力フォーマット」に関するデータを除外したり、あらかじめ取得しておいた $contentFiels からコンテンツフィールドの「名前」などを取得するといった整形処理を行っています。

なお、「どのコンテンツフィールドがデータ識別ラベルなのか?」はコンテンツタイプ側のレコードで保持する情報のため、if($contentFields[$keyIndex]['uniqueId'] === $contentTypeDataLabel) で判別を行い、コンテンツデータごとのラベルをセットするようにしています。

// 整形後のデータを JSON 形式で出力
return $this->asJson([
    'items'         => $items,
    'categories'    => $this->getCategoryData($categoryIds),
    'images'        => $this->getAssetImageData($imageIds),
    'totalResults'  => $totalResults,
]);

最後に JSON 形式として出力を行います。

サンプルではコンテンツデータ内でセットされている「カテゴリ」および「画像アセット」の ID と合致するデータも JSON に含めるようにしているため、フロントエンド側からデータを取得する際も1度のリクエストで済む想定です。

やってみて

ザックリすぎて伝わらないだろうとは思いつつ、おおよそこのような流れで任意の REST API を用意できると実感しました。

もちろん、「キャッシュの仕組み」や「特定のコンテンツタイプのみアクセスできるように」など細かいカスタマイズは必要ですが、確認した範囲では従来の Data API よりレスポンスも早く面白いアプローチだと思いますので、興味のある方はぜひお試しください。

comments powered by Disqus