これは 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-world
を index.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 よりレスポンスも早く面白いアプローチだと思いますので、興味のある方はぜひお試しください。