Craft CMSのcraft.entries.searchは安易に使ってはいけない話

以前のエントリーで、タグ検索は
$criteria          = craft()->elements->getCriteria(ElementType::Entry);
$criteria->section = "sampleSection";
$criteria->search = "sampleTags:test";
$entries = $criteria->find();

こう書くよ、と記載したのですが、エントリー数が数万〜数十万になると、とっても遅いのでした。

searchを使った場合、craft_searchindexテーブルが参照されるのですが、検索対象のIDが列挙されるので
SELECT * FROM craft_searchindex WHERE elementId IN (1,2,3,4,5,6,7,8 ...)

のように、WHERE IN句に、数万のIDが列挙されたりします。結果、生成されたSQLをMySQL Workbenchに貼り付けると、アプリが落ちるほど巨大なクエリーになります。

なので、正しいクエリー方法は以下のようになります。
$criteria = craft()->elements->getCriteria(ElementType::Tag);
$criteria->groupId = craft()->tags->getTagGroupByHandle('sampleGroup')->id;
$criteria->title = 'test';
$tags = $criteria->find();

$criteria = craft()->elements->getCriteria(ElementType::Entry);
$criteria->section = "sampleSection";
$criteria->relatedTo = ['targetElement' => $tags, 'field' => 'sampleTags'],
$entries = $criteria->find();

relatedToを使用した場合、craft_relationsテーブルを参照した割と普通なクエリーになります。

エントリー数が20万ほどある状況だと、searchを使ったタグ検索は4秒、relatedToを使ったタグ検索は0.1秒以下でした。なので、searchはエントリー総数が少ないセクションに限定して使わないとダメそうです。

【追記】
Twigで記述する場合に
{% set entries = craft.entries.section('sampleSection').relatedTo(tags) %}

のような書き方が普通みたいな感じになっていますが、エントリー数が多い場合は、これも遅いです。
fieldを指定したほうが、場合によっては数百倍速いので、ちゃんと指定しましょう。
{% set entries = craft.entries.section('sampleSection').relatedTo({'targetElement':tags, 'field':'sampleTags'}) %}

Craft CMS プラグインの作法(Matrixフィールド内にSuperTableフィールドがある場合の新規ブロック追加)

番外編。

Matrixフィールド内にSuperTableフィールドをレイアウトしている場合、プラグインから新規ブロックを追加するのが、結構わかりにくいです。

'sampleMatrix'フィールドに、'sampleBlock'というブロックがあり、そのブロックに'superTableField'というSuperTableフィールドがあるとします。
$matrix["new1"] = [
'type' => 'sampleBlock',
'enabled' => true,
'fields' => [
'textField' => 'abcdefg',
'numField' => 123456,
'superTableField' => [
'new1' => [
'type' => 0,
'enabled' => true,
'fields' => [
'columnOne' => 'sample one',
'columnTwo' => 'sample two',
],
],
],
],
];
$entry->setContentFromPost(array('sampleMatrix' => $matrix));
$success = craft()->entries->saveEntry($entry);
本当は、こう書きたいところですが、これではエラーになります…。
問題は、ブロック内のsuperTableFieldのtypeに設定するブロックタイプIDが決定していない点にあります。

ということで、ブロックタイプIDを取得するメソッドを作ってみます。
public function getSuperTableFieldId($ownerId, $matrixFieldHandle, $blockHandle, $tableFieldName) {
$criteria = craft()->elements->getCriteria(ElementType::MatrixBlock);
$criteria->ownerId = (int)$ownerId;
$criteria->type = $blockHandle;
$criteria->fieldId = (int)craft()->fields->getFieldByHandle($matrixFieldHandle)->id;
$blocks = $criteria->find();
if (!empty($blocks)) {
$blockTypes = craft()->superTable->getBlockTypesByFieldId($blocks[0]->$tableFieldName->fieldId);
if (!empty($blockTypes)) {
return $blockTypes[0]->id;
}
}
return false;
}
ただし、このメソッドはentryにmatrixブロックが1つ以上、存在する状態でないと機能しません。

以上を踏まえて、作成したメソッドを利用して新規ブロック追加をしてみます。
// 一旦superTableFieldは空にしておく
$matrix["new1"] = [
'type' => 'sampleBlock',
'enabled' => true,
'fields' => [
'textField' => 'abcdefg',
'numField' => 123456,
'superTableField' => [],
],
],
];

$entry->setContentFromPost(array('sampleMatrix' => $matrix));
// ブロックを追加するために、一旦保存
$success = craft()->entries->saveEntry($entry);

// superTableFieldを追加
$table["new1"] = [
'type' => $this->getSuperTableFieldId($entry->id, 'sampleMatrix', 'sampleBlock', 'superTableField'),
'enabled' => true,
'fields' => [
'columnOne' => 'sample one',
'columnTwo' => 'sample two',
],
];
$matrix['new1']['fields'][] = $table;

$entry->setContentFromPost(array('sampleMatrix' => $matrix));
$success = craft()->entries->saveEntry($entry);

一旦saveEntry()を実行してブロックを保存していますが、ブロックだけ更新するような場合は、MatrixServiceのsaveBlock()でも良いと思います。ただ、エントリーの他のフィールドも一緒に更新する場合は、saveEntry()でまとめて保存してしまったほうが良い気もするので、お好みで。

Craft CMS プラグインの作法(メール関連)

Craft CMSの設定ページでメールテンプレートを編集できますが、そのテンプレートを追加したい場合の方法。

1)プラグイン本体に、registerEmailMessages()を書く。配列に記述するのはテンプレートのキー名。
public function registerEmailMessages() {
return array(
'template_one',
'template_two',
);
}


2)プラグインのtranslationsフォルダにja.phpやen.phpを作成して、各言語毎のデフォルトテンプレートを定義する。
return array(
'template_one_heading' => 'hogehogeされた時',
'template_one_subject' => 'メール件名',
'template_one_body' => "Dear {{ user.username }}¥n¥n" . "本文.¥n¥n",
'template_two_heading' => 'hogehogeされた時',
'template_two_subject' => 'メール件名',
'template_two_body' => "Hello, {{ user.username }}¥n¥n" . "本文.¥n¥n",
);
※Craft CMSの設定ページでテンプレートを編集すると、データベースに保存されます。

3)サービス等から、テンプレート名をキーにして、送信する。配列で渡すのはテンプレートで使用するデータ。
craft()->email->sendEmailByKey($user, 'template_one', array(
'user' => $user,
'entry' => $entry,
));

Craft CMS プラグインの作法(セクション関連)

Craft CMSのプラグインを書くときの作法

スラッグでセクションを取得する


URLのslugでセクションからエントリーを1件取得する方法。
$criteria = craft()->elements->getCriteria(ElementType::Entry);
$criteria->section = 'sampleSection';
$criteria->slug = 'test-slug';
$entry = $criteria->first();

sampleSectionのsampleTagsフィールドでタグ検索した結果を取得する


$criteria->searchを使います。twigで記述した場合
{% set entries = craft.entries.section('sampleSection').search('sampleTags:test') %}
と等価です。
$criteria          = craft()->elements->getCriteria(ElementType::Entry);
$criteria->section = "sampleSection";
$criteria->search = "sampleTags:test";
$entries = $criteria->find();

新規にセクションを作成する


プラグインでセクションの新規エントリーを保存する方法。
$currentUser = craft()->userSession->getUser();
$section = craft()->sections->getSectionByHandle('sampleSection');
$section->getEntryTypes();
$entry = new EntryModel();
$entry->sectionId = $section->id;
$entry->enabled = true;
$entry->authorId = $currentUser->id;
// ToDo : セクションのフィールドを埋める
$success = craft()->entries->saveEntry($entry);

セクションのSuper Tableフィールドを更新する


new1,new2,new3…のインデックスを持った配列を設定します。新規作成時、更新時どちらも同じ方法です。
$field = craft()->fields->getFieldByHandle("sampleSuperTableField");
$blockTypes = craft()->superTable->getBlockTypesByFieldId($field->id);
$blockType = $blockTypes[0];
$superTableData = array();

$tableValues = array(
'new1' => array(
'sampleTextField' => 'foobar',
'sampleAssetFileField' => $assets,
),
'new2' => array(
'sampleTextField' => 'foobar',
'sampleAssetFileField' => $assets,
),
);

foreach($tableValues as $key => $value) {
$superTableData[$key] = array(
'type' => $blockType->id,
'enabled' => true,
'fields' => $value,
);
}
$entry->setContentFromPost(array("sampleSuperTableField" => $superTableData));

$success = craft()->entries->saveEntry($entry);

アセットフィールドの更新


アセットフィールドはIDの配列なので、そのように設定します。タグフィールドやカテゴリフィールドも基本的に同じです。
$entry->getContent()->setAttributes(array(
"sampleAssetField" => array($assetModel->id),
));
$success = craft()->entries->saveEntry($entry);


Craft CMSのTasks

Craft CMSのTasksを利用して、旧サイトから大量のデータを変換してインポートする処理を実装しようとしていたりします。

TasksはWordPressでいうと"wp_cron"と同じようなもので、サイトへのアクセスをトリガーに処理の実行を開始します。

Craft CMSでは、以下のようなURLでタスクのスケジュールを開始します。
http://craft.domain/actions/pluginName/actionName
(タスクのスケジュール登録方法はコントローラー以外でも可)

簡単な実装であればTaskとControllerとServiceの実装だけで済みます。

実行中は、定義したステップ数を最大値とした円形のプログレス表示がダッシュボードに表示されて、非同期でステップを順次処理していくのですが、プログレス表示は一度エラーにならないと表示されません…。"desc"の部分はTaskクラスで定義したdescriptionが表示されます。


エラーになると、エラーメッセージが表示され、再スケジュールかキャンセルを選択できます(ただし、詳細なエラーメッセージは定義できない)。再スケジュールをすると、ダッシュボードをリロードしたタイミングでプログレス表示がメニューに追加されます。


通常だとタイムアウトになってしまうような処理を分割して処理したい時には便利だと思います。正常終了したときは、一切結果が表示されないので、正常処理時も結果を確認したいような処理はプラグインのコントロールパネル等で実装したほうがよさそうです。

カスタムタクソノミーのメタボックスだけを非表示にする

CPT UIにはmeta_box_cbのオプションが無いので、投稿画面の右側に表示されるメタボックスだけを非表示にするには、こんなフィルタを書く。

function _disable_custom_taxonomy_metabox($args, $taxonomy, $object_type) {
if ($taxonomy == 'foo' || $taxonomy == 'bar') {
$args['meta_box_cb'] = false;
}
return $args;
}
add_filter('register_taxonomy_args', '_disable_custom_taxonomy_metabox', 10, 3);

これでカスタムタクソノミー"foo"と"bar"のメタボックスが消える(左側のメニューには表示される)。

PhpStormでGruntを使う

最近はフロントエンド部分の開発を行っていなかったので、JavaScriptやCSSの結合や圧縮をすることがなかったのですが、サイトの運用開始後にちょっとだけ修正するといったことも増えてきたので、この作業を自動化してみることにしました。

今回使ったのはGruntとというJavaScriptで記述するタスクランナーです。

https://www.jetbrains.com/help/phpstorm/grunt.html

基本的な手順は、PhpStorm 2017.2 Helpに書かれています。

今回はGit Bashを使って設定したので、GitとNode.js(windows版)を予めインストールしておきます。

管理者権限で起動したGit Bachでgrunt-cliをインストール
$ npm install -g grunt-cli

この時に表示されるインストールパスをメモ。

[Run] - [Edit Configurations...]でインタプリターの設定と、grant-cliのパスを指定。


package.jsonの初期化とgruntのインストール。必ず、プロジェクトディレクトリのルートに移動してからコマンドを実行します。
$ cd /d/workspace/hoge
$ npm init
$ npm install grunt --save-dev


[File] - [Settings...] - [Language & Frameworks] - [Node.js and NPM]

ここで、npm installが出来るっぽいんですが、上手くいかなかったので(インストールしたはずのパッケージがNot Foundになる)、引き続きGit Bashでインストール。
$ npm install grunt-contrib-concat --save-dev
$ npm install grunt-contrib-ugify --save-dev

d:¥workspace¥hoge¥gruntfile.js
module.exports = function (grunt) {
grunt.initConfig({
concat: {
files: {
src : ['assets/js/plugin.js','assets/js/script.js'],
dest: 'assets/js/concat.js'
}
},

uglify: {
dist: {
files: {
'assets/js/all.js': 'assets/js/concat.js'
}
}
}
});

grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.registerTask('default', ['concat', 'uglify']);
};
plugin.jsとscript.jsを結合して、concat.jsとして保存して、それをminifyしてall.jsとして出力する例。

[View] - [Tool Window] - [Grunt]でGruntのツールウィンドウが開くので、そこから実行。

Craft CMS 覚え書き

Craft CMSというかYii Framework 2.6に関するメモ。

models


DBに保存されないデータモデル。
主に入力の検証を行う(ログインフォーム等のデータモデル)。
必須(required)、型(AttributeType)、規定値(default)、サイズ(length)などの属性を定義。


records


DBに保存するデータモデルは、Active Record(AR)として定義する。
DB型(ColumnType)、重複不可(unique)、主キー、外部キー、などRDBに関する定義は一通りできる。
Number型のPKは自動的にAuto Incrementになる。
作成したプラグインをインストールすると、recordsの定義通り、DBにテーブルが自動作成される。

リレーションの書き方サンプル

[MyPlugin_TagGroupRecord.php](親テーブル)
protected function defineAttributes()
{
return array(
'group_id' => array(AttributeType::Number, 'column' => ColumnType::PK, 'required' => true),
'tag_id' => array(AttributeType::Number, 'required' => true),
'content_id' => array(AttributeType::Number, 'required' => true),
);
}

public function defineRelations() {
return array(
'taggroup' => array(self::HAS_MANY, 'MyPlugin_TagRecord', 'tag_id'),
);
}


[MyPlugin_TagRecord.php](子テーブル)
protected function defineAttributes()
{
return array(
'tag_id' => array(AttributeType::Number, 'column' => ColumnType::PK, 'required' => true),
'name' => AttributeType::String,
);
}

public function defineRelations() {
return array(
'taggroup' => array(static::BELONGS_TO, 'MyPlugin_TagGroupRecord', 'tag_id', 'required' => true, 'onDelete' => static::CASCADE),
);
}

Craft CMSのdefineRelations()は、リレーションで親テーブルの主キー以外のカラムを参照しようとしても、主キーとリレーションを作成してしまうようなので、主キー以外とのリレーション定義は下記のようにするしかないかも。

[MyPlugin_TagRecord.php](子テーブル)
public function createTable() {
parent::createTable();
craft()->db->createCommand()->addForeignKey($this->getTableName(), 'tag_id', 'myplugin_taggroup', 'tag_id');
}



※入力>確認画面>保存のようなUIフローを持つ場合は、models/recordsを両方定義してもいい。

AWS LambdaでEC2インスタンスを自動起動・自動停止する

前回作成したZend Server 8 + ubuntuのインスタンスをLambdaで平日だけ稼働するようにスケジュールしてみました。

こちらの記事がわかりやすかったです。

LambdaとCloudWatch EventsでEC2の自動起動&自動停止をやってみた(Python版)
http://dev.classmethod.jp/cloud/aws/lambda_cloudwatch-events_ec2-star-stop-python/

最初、少し古い記事を読んでしまって、AWS CLIライブラリを含めたデプロイzipを作ってみたりしましたが、2017/04/11現在はAWS SDKがLambdaの実行環境に含まれていて、インスタンスの起動と停止だけなら、トリガーの設定とコードを書くだけでした。

Lambda 実行環境と利用できるライブラリ
http://docs.aws.amazon.com/ja_jp/lambda/latest/dg/current-supported-versions.html

参考にした記事ではトリガーをCloudWatchのダッシュボードから作成していますが、Lambdaからもcronの設定ができるので簡単。

トリガーにCloudWatchイベント・スケジュールを設定し

ルール名lambda-instance-start
スケジュール式cron(0 0 ? * MON-FRI *)
トリガーの有効化ON
平日の朝9時に起動

これでCloudWatch側にルールが作成されます。

停止関数のほうは、こんな感じ。
ルール名lambda-instance-stop
スケジュール式cron(0 14 ? * MON-FRI *)
トリガーの有効化ON
平日の夜11時に停止


Zend Server Developer Edition (Ubuntu)

ローカルPCにインストールしていたZend Serverのライセンスが切れたのでAWSでZend Serverのインスタンスを借りることにしました。

AWS marketplaceで「PHP 5.6 - Zend Server Developer Edition (Ubuntu)」のインスタンスを契約(RHEL版もあります)。リージョンはap-northeast-1(Tokyo)、インスタンスタイプはt2.smallです。

Zend Server0.03/時$21.60/月
EC20.032/時$23.04/月
合計0.062/時$44.64/月
費用は1ヶ月稼働させ続けたら、月5,000円弱。
最初の30日はZend Serverの分は無料で、EC2のインスタンス料金だけで済みます。

これだけだと起動する度に毎回IPが変わってしまい、WordPressが使えなくなるので、Elastic IPで固定IPを割り当ててDNSに登録することにしました。
Zend Server + EC20.768/12時間
Elastic IP$0.06/12時間
合計$0.774/日
平日に12時間稼働させたとすると大体月額2000円弱になります(Elastic IPは割り当てたインスタンスが停止していた時間分が課金対象)。これなら$240のパッケージ(1年ライセンス)や月額$20のサブスクリプションより、EC2のインスタンスが使える分お得じゃないかなと。


とりあえず、MySQLが無かったのでインストール
$ sudo apt-get install mysql-server

phpMyAdminとWordPressはZend ServerのApplication Deployからインストール
WordPressは4.3.1だったので、4.7.3にアップデート

アップデートの際に、パーミッションエラーになったので

$ sudo chown -R www-data:zend /usr/local/zend/var/apps/http/(virtual-host)/80/4.3.1_11
$ sudo vi /usr/local/zend/var/apps/http/(virtual-host)/80/4.3.1_11/wp-config.php

wp-config.php
define('FS_METHOD','direct');

を追加

と、一通り設定が終わったところで、いつも使っているのがCentOSやAmazon LinuxなのでRHEL版にすればよかったと思ったり…。


Z-Rayを有効にしても、そこそこのレスポンスなので開発用には十分。もう少し使ってみてから、AWS Lambdaでインスタンスの起動と停止を自動化してみます。