concrete5レガシーバージョンのDesigner Content製ブロックをモダンバージョンのBlock Developer製ブロックに移行する

菱川拓郎
菱川拓郎

今回はかなりニッチな内容のブログ更新です。特殊な用語が色々と出てきますので、まず用語の解説から進めていきます。

レガシーバージョンとモダンバージョン

concrete5 の歴史は、バージョン5.6.xまでのレガシーバージョンと、バージョン5.7移行のモダンバージョンの2つに分かれています。concrete5 は通常バージョンアップがあっても、基本的には管理画面からバージョンアップボタンを押せばバージョンアップが行えるように設計されています。しかし、唯一の例外が、このレガシーバージョンとモダンバージョンの間で、バージョンアップは行えません。それは、concrete5 のモダンバージョンが、新しいPHPのスタンダードやクラウド時代のインフラ環境、急速に普及したモバイルデバイスに対応するため、バックエンドからフロントエンドまでの全ての設計を見直した新しいソフトウェアだからです。

もちろん、基本的な設計は踏襲していますし、用語や操作方法の考え方も同じですので、レガシーバージョンを使用していたユーザーも、すんなりモダンバージョンに移行出来ると思います。しかし、ソフトウェアアーキテクチャの見直しに伴い、データベースはそのまま移行することができません。どうやってモダンバージョンに移行するかというと、専用のマイグレーションツールを使ってレガシーバージョンからXML形式でデータをエクスポートし、モダンバージョンにデータをインポートする、という手順を踏む必要があります。

Designer Content と Block Developer

concrete5 では、ブロックをドラッグ&ドロップで並べることでコンテンツ(ページ)を作ることができます。このブロックは、標準でもWYSIWYGエディタや画像スライダー、問い合わせフォームなど、標準的なWebサイトを構築するには十分な種類が用意されていますが、更新の効率を向上させるため、特定のWebサイトに特化した専用ブロックを開発することがあります。

ブロックの開発には、データベースとPHPプログラミングの基礎知識が必要になるのですが、この開発を簡素化しデザイナーでもブロック開発を可能にするアドオンがあります。レガシーバージョンの時代では、その代表的なアドオンが Jordan Lev 氏が開発した Designer Content でした。Designer Content アドオンは残念ながら開発が中止され、モダンバージョンでは使用できません。代わりに、モダンバージョンでその位置についたのが Ramon Leenders 氏が開発する Block Developer です。Block Developer については、当サイトに紹介記事もあります。

Designer Content を使ったブロック開発の概要

Designer Content アドオンをインストールすると、下図のようなブロック作成画面から、ブロックのハンドル名、表示名、説明文、入力フィールドを設定して「Make The Block!」ボタンをクリックするだけで、設定に従ってブロックに必要なPHPファイル、データベース定義ファイル等が自動的に生成されます。

Designer Content アドオンによる設定画面のスクリーンショット

画面に例示したようなブロックですと、次の画像に示すような構造のテーブルが生成されます。

Designer Content によって生成されたテーブル構造のスクリーンショット

基本的には、画面上の入力フィールドごとにテーブル上にもフィールドが作られ、フィールドの種類によってVARCHARだったりINTだったりする、というイメージです。

入力画面はこんな感じになります。

作成したブロックによる入力画面

Designer Content で生成したブロックのエクスポート時の問題

さて、このように画面上の簡単な操作で独自のデータ構造をもつブロックが生成できるのですが、このアドオンの惜しいところとして、このブロックのデータのエクスポートが少しうまくないのが残念なポイントです。何も対策をしないでMigration Toolでエクスポートすると、下記のようなXMLデータとして出力されます。

<?xml version="1.0" encoding="UTF-8"?>
<concrete5-cif version="1.0">
    <pages>
        <page name="DC Block Test" path="/blog/dc-block-test" filename="" public-date="2020-05-02 22:23:00"
              pagetype="blog_entry" description="" user="admin" package="">
            <area name="Main">
                <block type="dc_sample_block" name="">
                    <data table="btDCDcSampleBlock">
                        <record>
                            <field_1_textbox_text><![CDATA[This is a title.]]></field_1_textbox_text>
                            <field_2_wysiwyg_content><![CDATA[<p>This is a cool content.</p>
<p>You can insert an image like this:</p>
<p><img src="{CCM:FID_5}" alt="sh_thumbnail.jpg" width="150" height="150" /></p>]]></field_2_wysiwyg_content>
                            <field_3_image_fID><![CDATA[7]]></field_3_image_fID>
                            <field_3_image_internalLinkCID><![CDATA[128]]></field_3_image_internalLinkCID>
                            <field_3_image_altText><![CDATA[England Village]]></field_3_image_altText>
                            <field_4_file_fID><![CDATA[6]]></field_4_file_fID>
                            <field_4_file_linkText><![CDATA[]]></field_4_file_linkText>
                            <field_5_link_cID><![CDATA[132]]></field_5_link_cID>
                            <field_5_link_text><![CDATA[]]></field_5_link_text>
                            <field_6_date_value><![CDATA[2020-05-02]]></field_6_date_value>
                            <field_7_select_value><![CDATA[1]]></field_7_select_value>
                        </record>
                    </data>
                </block>
            </area>
        </page>
    </pages>
</concrete5-cif>

XMLを見ていただくと一目瞭然ですが、データベースの1レコードが、そのままXMLに変換された形になっています。この中で、例えば field_3_image_fID 要素のデータは、concrete5 のファイルマネージャーにアップロードされたファイルのIDなのですが、このように数値のままにしておくとうまくありません。というのも、移行先のモダンバージョンのconcrete5はデータベースを引き継がないため、ファイルも新しくアップロードし直しになり、当然IDも別の数値が振られます。このまま新しいconcrete5にインポートしてしまうと、全く別の画像を差し示すことになってしまうのです。同様に、field_2_wysiwyg_content 要素の中にも CCM:FID_5 という記述がありますが、これもファイルのIDを指しており、同じ問題が発生します。​​​​​​

このため、Designer Content で生成されたブロックの controller.php にエクスポート用の記述を追加してあげる必要があります。

<?php defined('C5_EXECUTE') or die("Access Denied.");

class DcSampleBlockBlockController extends BlockController
{
    protected $btName = 'Designer Content Sample';
    protected $btDescription = 'An example block type built with Designer Content';
    protected $btTable = 'btDCDcSampleBlock';

    protected $btInterfaceWidth = "700";
    protected $btInterfaceHeight = "450";

    protected $btCacheBlockRecord = true;
    protected $btCacheBlockOutput = true;
    protected $btCacheBlockOutputOnPost = true;
    protected $btCacheBlockOutputForRegisteredUsers = false;
    protected $btCacheBlockOutputLifetime = CACHE_LIFETIME;

    // ここから追加

    /** @var array ページIDが格納されているフィールド名を配列で指定 */
    protected $btExportPageColumns = array('field_3_image_internalLinkCID', 'field_5_link_cID');
    /** @var array ファイルIDが格納されているフィールド名を配列で指定 */
    protected $btExportFileColumns = array('field_3_image_fID', 'field_4_file_fID');
    /** @var array WYSIWYGエディタで編集可能なフィールド名を配列で指定 */
    protected $btExportContentColumns = array('field_2_wysiwyg_content');

    public function export(SimpleXMLElement $blockNode)
    {
        $tbl = $this->getBlockTypeDatabaseTable();
        $data = $blockNode->addChild('data');
        $data->addAttribute('table', $tbl);
        $db = Loader::db();
        $r = $db->Execute('select * from ' . $tbl . ' where bID = ?', array($this->bID));
        while ($record = $r->FetchRow()) {
            $tableRecord = $data->addChild('record');
            foreach ($record as $key => $value) {
                if (in_array($key, $this->btExportPageColumns)) {
                    $tableRecord->addChild($key, ContentExporter::replacePageWithPlaceHolder($value));
                } elseif (in_array($key, $this->btExportFileColumns)) {
                    $tableRecord->addChild($key, ContentExporter::replaceFileWithPlaceHolder($value));
                } elseif (in_array($key, $this->btExportContentColumns)) {
                    $cnode = $tableRecord->addChild($key);
                    $node = dom_import_simplexml($cnode);
                    $no = $node->ownerDocument;
                    $node->appendChild($no->createCDataSection(Loader::helper('content')->export($value)));
                } elseif ($key !== 'bID') {
                    $cnode = $tableRecord->addChild($key);
                    $node = dom_import_simplexml($cnode);
                    $no = $node->ownerDocument;
                    $node->appendChild($no->createCDataSection($value));
                }
            }
        }
    }

    // ここまで追加

ブロックコントローラーに export() メソッドがあると、エクスポートの際にこの中の処理が呼び出されます。このメソッドの中身は深く知らなくてもコピペで大丈夫です。ポイントはその前のいくつかのクラス変数による定義です。$btExportFileColumns で、ファイルIDが格納されているフィールド名を指定しています。この指定により、出力時にファイルIDがそのまま出力されるのではなく、ファイル名に変換されて出力されます。他の $btExportPageColumns と $btExportContentColumns も同様に適切なフィールド名を指定してください。

このようにブロックコントローラーを修正した結果、下記のようなXMLファイルが出力されるようになりました。

<?xml version="1.0" encoding="UTF-8"?>
<concrete5-cif version="1.0">
    <pages>
        <page name="DC Block Test" path="/blog/dc-block-test" filename="" public-date="2020-05-02 22:23:00"
              pagetype="blog_entry" description="" user="admin" package="">
            <area name="Main">
                <block type="dc_sample_block" name="">
                    <data table="btDCDcSampleBlock">
                        <record>
                            <field_1_textbox_text><![CDATA[This is a title.]]></field_1_textbox_text>
                            <field_2_wysiwyg_content><![CDATA[<p>This is a cool content.</p>
<p>You can insert an image like this:</p>
<p><img src="{ccm:export:image:sh_thumbnail.jpg}" alt="sh_thumbnail.jpg" width="150" height="150" /></p>]]></field_2_wysiwyg_content>
                            <field_3_image_fID>{ccm:export:file:england_village.jpg}</field_3_image_fID>
                            <field_3_image_internalLinkCID>{ccm:export:page:/about}</field_3_image_internalLinkCID>
                            <field_3_image_altText><![CDATA[England Village]]></field_3_image_altText>
                            <field_4_file_fID>{ccm:export:file:europe_valencia_hemispheric.jpg}</field_4_file_fID>
                            <field_4_file_linkText><![CDATA[]]></field_4_file_linkText>
                            <field_5_link_cID>{ccm:export:page:/about/contact-us}</field_5_link_cID>
                            <field_5_link_text><![CDATA[]]></field_5_link_text>
                            <field_6_date_value><![CDATA[2020-05-02]]></field_6_date_value>
                            <field_7_select_value><![CDATA[1]]></field_7_select_value>
                        </record>
                    </data>
                </block>
            </area>
        </page>
    </pages>
</concrete5-cif>

以上でレガシー版での準備は完了です。

Block Developer を使ってブロックを再作成

Block Developer を使ったブロックの作成方法は、以前の記事で紹介していますので割愛して、移行のためのブロック開発のポイントのみご紹介します。そのポイントとはズバリ、Designer Content で作ったブロックとデータベース構造を出来るだけ合わせる、ということです。下記に Designer Content で作ったブロックとフィールド名と種類を完全に合わせて作った Block Developer の config.php の例を示します。先に掲載したテーブル構造の画像と見比べてみてください。

<?php

return [
    // Block
    'block' => [
        'name' => t('BD Sample Block'),
        'description' => t('Created with Block Developer'),
    ],

    // Fields
    'fields' => [
        'field_1_textbox_text' => [
            'type' => 'Text',
            'label' => t('Title'),
            'required' => true,
            'searchable' => true,
        ],
        'field_2_wysiwyg_content' => [
            'type' => 'Wysiwyg',
            'label' => t('Body'),
            'required' => false,
            'searchable' => true,
        ],
        'field_3_image_fID' => [
            'type' => 'Image',
            'label' => t('Thumbnail'),
            'required' => false,
            'searchable' => false,
        ],
        'field_3_image_internalLinkCID' => [
            'type' => 'Page',
            'label' => t('Link to Page'),
            'required' => false,
            'searchable' => false,
        ],
        'field_3_image_altText' => [
            'type' => 'Text',
            'label' => t('Alt Text'),
            'required' => false,
            'searchable' => false,
        ],
        'field_4_file_fID' => [
            'type' => 'File',
            'label' => t('Attachment'),
            'required' => false,
            'searchable' => false,
        ],
        'field_4_file_linkText' => [
            'type' => 'Text',
            'label' => t('Link Text'),
            'required' => false,
            'searchable' => false,
        ],
        'field_5_link_cID' => [
            'type' => 'Page',
            'label' => t('Read More'),
            'required' => false,
            'searchable' => false,
        ],
        'field_5_link_text' => [
            'type' => 'Text',
            'label' => t('Link Text'),
            'required' => false,
            'searchable' => false,
        ],
        'field_6_date_value' => [
            'type' => 'DateTime',
            'label' => t('Display Date Time'),
            'required' => false,
            'searchable' => false,
        ],
        'field_7_select_value' => [
            'type' => 'Select',
            'label' => t('Category'),
            'required' => false,
            'searchable' => false,
            'config' => [
                'options' => [
                    '1' => 'Foo',
                    '2' => 'Bar',
                    '3' => 'Baz',
                ],
            ],
        ],
    ],
];

この定義ファイルによって生成されるブロックのデータベーステーブルも、アドオン作者の設計方針の違いにより多少の差は出るものの、Designer Content によって生成したものとほぼ同じになります。

Block Developer により生成されたテーブル定義のスクリーンショット

ここまで準備ができたら、レガシーバージョンからMigration ToolでエクスポートしたXMLファイルを、モダンバージョンにMigration Toolでインポートすることが「ほぼ」可能になります。

インポートバッチの作成時に、ブロックのマッピングを指定することを忘れないようにしてください。

Migration Tool でのブロックタイプのマッピング設定画面のスクリーンショット

先ほど「ほぼ」と書いたのは、移行元の Designer Content で作成したブロックに、WYSIWYG エディタで編集するフィールドがある場合のみ、さらにもう1つの準備が必要になります。

Publisher クラスの作成

データのマッピングを行うためのMigration Toolのための専用のクラスを作成することで、移行元のデータベース構造と移行後のデータベース構造が全く異なっていても移行が可能になります。データベースの構造をぴったり合わせることで、別のブロックであってもほぼデータ移行ができるようになりますが、それで済まない場合は、この方法に頼ることになります。

具体的なやることとしては、インターフェース \PortlandLabs\Concrete5\MigrationTool\Publisher\Block\PublisherInterface を実装したクラスを作成し、\PortlandLabs\Concrete5\MigrationTool\Publisher\Block\Manager マネージャに追加すること。下記は私が作成した Designer Content からの移行のための Publisher クラスです。Migration Tool にもいくつかのブロックのための Publisher が同梱されているので、自作する際は参考にしてください。

<?php
namespace C5J\ExampleCustomPublisher\Block;

use Concrete\Core\Application\ApplicationAwareInterface;
use Concrete\Core\Application\ApplicationAwareTrait;
use Concrete\Core\Backup\ContentImporter\ValueInspector\ValueInspector;
use Concrete\Core\Page\Page;
use Doctrine\Common\Collections\Collection;
use PortlandLabs\Concrete5\MigrationTool\Entity\Import\Batch;
use PortlandLabs\Concrete5\MigrationTool\Entity\Import\BlockValue\BlockValue;
use PortlandLabs\Concrete5\MigrationTool\Entity\Import\BlockValue\StandardBlockDataRecord;
use PortlandLabs\Concrete5\MigrationTool\Entity\Import\BlockValue\StandardBlockValue;
use PortlandLabs\Concrete5\MigrationTool\Publisher\Block\PublisherInterface;
use PortlandLabs\Concrete5\MigrationTool\Publisher\Block\StandardPublisher;

class DesignerContentPublisher implements PublisherInterface, ApplicationAwareInterface
{
    use ApplicationAwareTrait;

    protected $dcTableName = '';
    protected $btExportContentColumns = [];

    /**
     * @param string $dcTableName Table Name of legacy block type build with Designer Content
     * @param array $btExportContentColumns Field name array of WYSIWYG Editor content
     */
    public function __construct($dcTableName, array $btExportContentColumns)
    {
        $this->dcTableName = $dcTableName;
        $this->btExportContentColumns = $btExportContentColumns;
    }

    public function publish(Batch $batch, $bt, Page $page, $area, BlockValue $value)
    {
        $b = null;

        if ($value instanceof StandardBlockValue) {
            $records = $value->getRecords();
            if ($this->isMigrateFromDesignerContent($records)) {
                /** @var ValueInspector $inspector */
                $inspector = $this->app->make('migration/import/value_inspector', [$batch]);
                $record = $this->getDataFromDesignerContent($records);
                if (is_object($record)) {
                    $data = [];
                    foreach ($record->getData() as $key => $value) {
                        $result = $inspector->inspect($value);
                        if (in_array($key, $this->btExportContentColumns)) {
                            $data[$key] = $result->getReplacedContent();
                        } else {
                            $data[$key] = $result->getReplacedValue();
                        }
                    }
                    $b = $page->addBlock($bt, $area, $data);
                }
            } else {
                // Fallback to Standard Publisher
                $publisher = new StandardPublisher();
                $b = $publisher->publish($batch, $bt, $page, $area, $value);
            }
        }

        return $b;
    }

    /**
     * @param Collection $records
     *
     * @return bool
     */
    public function isMigrateFromDesignerContent(Collection $records)
    {
        $record = $this->getDataFromDesignerContent($records);

        return ($record !== null) ? true : false;
    }

    /**
     * @param Collection $records
     *
     * @return StandardBlockDataRecord|null
     */
    public function getDataFromDesignerContent(Collection $records)
    {
        /** @var StandardBlockDataRecord $record */
        foreach ($records as $record) {
            if (method_exists($record, 'getTable') && $record->getTable() == $this->dcTableName) {
                return $record;
            }
        }

        return null;
    }
}

作成したPublisherクラスをマネージャに登録する例です。

<?php

$app = Concrete\Core\Support\Facade\Facade::getFacadeApplication();
$director = $app->make(Symfony\Component\EventDispatcher\EventDispatcherInterface::class);
$director->addListener('on_before_dispatch', function ($event) use ($app) {
    $manager = $app->make('migration/manager/publisher/block');
    if (is_object($manager)) {
        // 旧ブロックタイプ(Designer Content で作った方)のブロックハンドルを指定します。
        $manager->extend('dc_sample_block', function () use ($app) {
            return $app->make(C5J\ExampleCustomPublisher\Block\DesignerContentPublisher::class, [
                'btDCDcSampleBlock', // 旧ブロックタイプのテーブル名を指定します
                ['field_2_wysiwyg_content'], // WYSIWYGエディタコンテンツのフィールド名を指定します。
            ]);
        });
    }
});

導入が簡単になるよう、パッケージ化してGitHubにて公開していますので、Publisher クラスを作成する際は活用してみてください。

hissy/example_custom_publisher

以上の手順で、無事にデータが移行できました。

Block Developer で再作成したブロックにコンテンツが移行できた様子のスクリーンショット

Designer Content 時代のテンプレートが動くようにする

データが無事移行できた後は、表示が気になります。可能な限り、レガシーバージョンで使っていたテンプレートが何も手を加えずに動くのが嬉しいですよね。Block Developer で生成したブロックのコントローラに一手間加えることで、それも可能になります。

Block Developer では、ファイルは File クラス、ページは Page クラスのインスタンスとしてテンプレートに渡されるようになっていますが、Designer Content ではファイルは stdClass クラス、ページはページIDとしてテンプレートに渡されます。ここの差異だけ吸収してあげれば、Designer Content で使っていたテンプレートでも問題なく動作します。ただし、繰り返しになりますが、データベースのフィールド名を新旧でぴったり合わせていることが条件になります。

テンプレート内でこの差異を吸収してもいいのですが、できればコントローラで対応する方が、テンプレートにいっさい手を加えなくて良くなるので、手間がかからないと思います。下記は、Block Developer が生成した controller.php に少し手を加えて、上記のファイルとページのデータの渡り方を調整した例になります。

<?php
namespace Application\Block\BdSampleBlock;

use Concrete\Core\Entity\File\File as FileEntity;
use Concrete\Core\Entity\File\Version;
use Concrete\Core\File\File;
use Concrete\Core\File\Image\BasicThumbnailer;
use Devoda\BlockDeveloper\BlockDeveloper\Block\BlockController;

class Controller extends BlockController
{
    public function __construct($obj = null)
    {
        parent::__construct($obj, realpath(dirname(__FILE__)));
    }

    // ここから追加
    public function view()
    {
        parent::view();

        // ファイルをDesigner Content互換のstdClassに置き換えます。
        $this->set('field_3_image', ($this->field_3_image_fID > 0) ? $this->get_image_object($this->field_3_image_fID, 0, 0, false) : null);
        // ページはオブジェクトとして渡されるところ、データベースの値(ID)を渡しなおします。
        $this->set('field_3_image_internalLinkCID', $this->field_3_image_internalLinkCID);
        $this->set('field_5_link_cID', $this->field_5_link_cID);
    }

    private function get_image_object($fID, $width = 0, $height = 0, $crop = false)
    {
        // Designer Content で生成したブロックコントローラからコピーしてきたコードです。
        if (empty($fID)) {
            $image = null;
        } elseif (empty($width) && empty($height)) {
            //Show image at full size (do not generate a thumbnail)
            /** @var FileEntity|Version $file */
            $file = File::getByID($fID);
            $image = new \stdClass();
            $image->src = $file->getRelativePath();
            $image->width = $file->getAttribute('width');
            $image->height = $file->getAttribute('height');
        } else {
            //Generate a thumbnail
            $width = empty($width) ? 9999 : $width;
            $height = empty($height) ? 9999 : $height;
            $file = File::getByID($fID);
            /** @var BasicThumbnailer $ih */
            $ih = $this->getApplication()->make('helper/image');
            $image = $ih->getThumbnail($file, $width, $height, $crop);
        }

        return $image;
    }
    // ここまで追加
}

解説としては長くなってしまいましたが、やっていることは単純でそれほど頭を悩ませるようなことではないため、粛々と対応すればレガシーバージョンからモダンバージョンへの移行も怖くはありません!

レガシーバージョンのサポートは残念ながらすでに切れてしまいましたが、できればモダンになって進化を続けるモダンバージョンに移行し、引き続き concrete5 を使い続けていただけると嬉しいです。参考になれば幸いです。