Magentoのエクステンションを作ってみよう〜中級編1〜

前回までは簡単なエクステンションの作り方と、デザインカスタマイズの初歩的な方法について解説しました。今回はデータベースを使った処理を実装する方法をご紹介しましょう。なお、管理画面の実装はすごく長い話になるので、今回は公開画面側だけの話に限定しています。また、本記事は前回までのエクステンションとは別に実装する形で進めています。本記事から読み始めた方はできれば概論編からお読みいただけると全体の流れがつかみやすいと思います。

準備編

概論編初級編を参考に、「My\News」エクステンションの雛形を作成しておいてください。作成するのは、

  • registration.php
  • etc/module.xml

だけで構いません。

独自テーブルを定義して、Magentoのエクステンションからアクセスするには

Magentoのエクステンションは、当然のことながらエクステンション独自のテーブルを作成して使うことができます。ただし、そのためにはMagentoのフレームワークが定める方法でいろいろな定義を作成しなければなりません。

独自のテーブルを定義するには

エクステンションから独自のテーブルを使う場合は、最初にエクステンションが使うテーブルの定義を作成しなければなりません。

Magento1系での実装経験がある方にとっては、概ね同じやり方が通用します。本記事では2.2系までの手順を紹介します。2.3系ではDoctrineなどでも用いられている、スキーマ定義ファイルを使用したテーブル定義が採用されています。(もちろん2.2系までの方法も当面は利用できます)

スキーマ定義ファイルの作成

まず、エクステンションのディレクトリ内に「Setup」ディレクトリを作成し、以下のファイルを必要に応じて作成します。

  • InstallSchema.php
  • UpgradeSchema.php
  • Recurring.php

それぞれのファイルの役割は、公式Devdocsに書かれていますが、要約すると以下のとおりです。

ファイル名 実行
InstallSchema.php エクステンション初回インストール時
UpgradeSchema.php エクステンションのアップデート時
Recurring.php エクステンションの初回インストール時とアップデート時の最後

それぞれのファイルの役割をよく理解した上で、どのファイルに定義を書くのが良いかを判断して実装します。

さて、今回は以下の列を持つテーブル「my_news」を作成します。

列名 属性 説明
id integer primary_key, auto_increment 主キー
title varchar(255) not null タイトル
content text not null 本文
created_at datetime CURRENT_TIMESTAMP 作成日時
updated_at datetime CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 更新日時

Setupディレクトリの下にInstallSchema.phpを作成したら、次のように記述してください。

<?php
namespace My\News\Setup;

use \Magento\Framework\Setup\InstallSchemaInterface;
use \Magento\Framework\Setup\ModuleContextInterface;
use \Magento\Framework\Setup\SchemaSetupInterface;
use \Magento\Framework\DB\Ddl\Table;

/**
 * @codeCoverageIgnore
 */
class InstallSchema implements InstallSchemaInterface
{
    /**
     * {@inheritdoc}
     */
    public function install(
        SchemaSetupInterface $setup,
        ModuleContextInterface $context
    ) {
        $setup->startSetup();
        $this->_setupTable($setup, $context);
        $setup->endSetup();
    }

    /**
     * @param \Magento\Framework\Setup\SchemaSetupInterface $setup
     * @param \Magento\Framework\Setup\ModuleContextInterface $context
     * @throws \Zend_Db_Exception
     */
    protected function _setupTable(
        SchemaSetupInterface $setup,
        ModuleContextInterface $context
    ) {
        $tableName = $setup->getTable('my_news');
        $table = $setup->getConnection()
            ->newTable($tableName)
            ->addColumn(
                'news_id',
                Table::TYPE_INTEGER,
                null,
                [
                    'identity' => true,
                    'unsigned' => true,
                    'nullable' => false,
                    'primary' => true
                ],
                'News ID'
            )
            ->addColumn('title',
                Table::TYPE_TEXT,
                255,
                [],
                'Title'
            )
            ->addColumn(
                'content',
                Table::TYPE_TEXT,
                '64K',
                [],
                'Content'
            )->addColumn(
                'created_at',
                Table::TYPE_TIMESTAMP,
                null,
                ['nullable' => false, 'default' => Table::TIMESTAMP_INIT],
                'Created At'
            )->addColumn(
                'updated_at',
                Table::TYPE_TIMESTAMP,
                null,
                ['nullable' => false, 'default' => Table::TIMESTAMP_INIT_UPDATE],
                'Updated At'
            );
        $setup->getConnection()->createTable($table);
    }

}

これでエクステンションをインストールする際に、自動的にデータベース上にテーブルが作成されるようになります。

次は作成したテーブルを操作するクラスを作成していきましょう。

Magentoからデータベース上のデータを操作するには

Magentoでデータベース上のデータを操作するためには、ModelクラスとResourceModelクラスが必要です。最初はこの2種類のクラスの関係がわかりにくいので、ここで簡単に解説しておきましょう。

Modelクラスとは

Modelクラスには通常ビジネスロジックを記述します。ビジネスロジックについてはWikipediaのページを見ていただくとして、要は各種計算処理やデータの整形処理などを受け持ちます。ただし、データベースへのSQLを用いた各種処理は行いません。そういった処理はResourceModelクラスの処理として実装することになっています。

また、Magento2においては、各クラスそれぞれをできるだけ単機能のクラスとして実装し、PHPUnitによる単体テストをきちんと実施することが推奨されています。(PHPUnitによる単体テストについて書き出すと話が進まなくなるので、今回は省略します)

ResourceModelクラスとは

ResourceModelクラスはデータベースやファイルシステム、外部APIといった、データを保持しているプログラムの外にあるものとのやり取りを担当するクラスです。大半のResourceModelクラスはデータベースとの読み書きを対象にしているため、ResourceModelクラスと対になるデータベーステーブルが1つまたは複数マッピングされるようになっています。

Collectionクラス

ResourceModelクラスには複数件のデータを扱うための特別クラスとして、Collectionクラスが存在します。1つのResourceModelクラスに対して1つ定義することができ、主に一覧表示や一括処理といった処理に使用します。

ModelクラスとResourceModelクラスを定義しよう

Modelクラスの定義

最初に、エクステンションディレクトリ内に、「Model」ディレクトリを作成します。次にそのディレクトリの中に、Modelクラスを作成していきます。今回は扱うテーブルが1つだけであることと、比較的単純な処理しか実装しないので、1つのModelクラスだけで賄うことにします。Modelディレクトリの中に、「News.php」を作成し、以下の内容を記述してください。

<?php
namespace My\News\Model;

use \Magento\Framework\Model\AbstractModel;

class News extends AbstractModel
{
    /**
     * cache tag
     */
    const CACHE_TAG = 'my_news';

    /**
     * @var string
     */
    protected $_cacheTag = 'my_news';

    /**
     * @var string
     */
    protected $_eventPrefix = 'my_news';

    /**
     *
     */
    protected function _construct()
    {
        $this->_init(
            'My\News\Model\ResourceModel\News'
        );
    }

    /**
     * @return mixed
     */
    public function getTitle()
    {
        return $this->getData('title');
    }

    /**
     * @param $title
     * @return News
     */
    public function setTitle($title)
    {
        return $this->setData('title', $title);
    }

    /**
     * @return mixed
     */
    public function getContent()
    {
        return $this->getData('content');
    }

    /**
     * @param $content
     * @return News
     */
    public function setContent($content)
    {
        return $this->setData('content', $content);
    }

    /**
     * @return mixed
     */
    public function getCreatedAt()
    {
        return $this->getData('created_at');
    }

    /**
     * @param $createAt
     * @return News
     */
    public function setCreatedAt($createAt)
    {
        return $this->setData('created_at', $createAt);
    }
}

ResourceModelクラスの定義

次にResourceModelクラスを定義します。Modelディレクトリの中に「ResourceModel」というディレクトリを作成し、先ほど作成したModelクラスと同じファイル名のファイル(News.php)を作成します。ファイルができたら、以下の内容を記述してください。

<?php
namespace My\News\Model\ResourceModel;

use \Magento\Framework\Model\ResourceModel\Db\AbstractDb;

class News extends AbstractDb
{
    protected $_idFieldName = 'news_id';

    protected function _construct()
    {
        $this->_init('my_news', $this->_idFieldName);
    }
}

Collectionクラスの定義

仕上げにCollectionクラスを定義します。Collectionクラスを配置する場所もフレームワークの規約で決められていて、以下のディレクトリに作成することになっています。

エクステンションディレクトリ/Model/ResourceModel/Model名

ファイル名はすべて「Collection.php」ですが、名前空間は異なるので、実際のクラス名がかぶることはないはずです。先程ResourceModelを作成したディレクトリの中に、「News」ディレクトリを作成し、「Collection.php」を作成してください。記述する内容は以下のとおりです。

<?php
namespace My\News\Model\ResourceModel\News;

use \Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;

class Collection extends AbstractCollection
{
    protected $_idFieldName = 'news_id';

    protected function _construct()
    {
        $this->_init(
            'My\News\Model\News',
            'My\News\Model\ResourceModel\News'
        );
    }
}

これでデータベースから情報を読み書きするための準備は整いました。ここまで作成してきた内容で、エクステンションのディレクトリは下図のようになっていると思います。

まだこの段階ではエクステンションを有効化・インストールしないでください。他にも準備するものがあります。

初期データの投入

ここまでデータベースに作成するテーブルの定義や、そのテーブルを読み書きするクラスの定義を行ってきました。ところが作成したばかりのテーブルにはデータが何も入っていません。エクステンションを実装する人が意図的にデータを入れない限り、サンプルデータなどは一切自動的に生成されることはありません。今回は管理画面の作成を次回以降に取り上げる関係上、エクステンションのインストール時に初期データを投入することにします。

データ投入・更新用スクリプト

下記の3つのファイルを使用して、データの投入や更新などを行います。

  • InstallData.php
  • UpgradeData.php
  • RecurringData.php

スキーマ定義ファイルと同じようなファイル名なので、想像はしやすいと思いますが、下表のような役割が定義されています。

ファイル名 実行
InstallData.php エクステンション初回インストール時
UpgradeData.php エクステンションのアップデート時
RecurringData.php エクステンションの初回インストール時とアップデート時の最後

さて、初期データを入れる場合は、以下のように書きます。今回は10件程度サンプルデータを入れておくことにしましょう。InstallSchema.phpと同じディレクトリにInstallData.phpを作成し、以下のように記述しておいてください。

<?php
namespace My\News\Setup;

use \Magento\Framework\Setup\InstallDataInterface;
use \Magento\Framework\Setup\ModuleContextInterface;
use \Magento\Framework\Setup\ModuleDataSetupInterface;

class InstallData implements InstallDataInterface
{

    /**
     * Install Data
     *
     * @param ModuleDataSetupInterface $setup Module Data Setup
     * @param ModuleContextInterface $context Module Context
     *
     * @return void
     */
    public function install(
        ModuleDataSetupInterface $setup,
        ModuleContextInterface $context
    )
    {
        $data = [
            ['記事1', 'これは記事1'],
            ['記事2', 'これは記事2'],
            ['記事3', 'これは記事3'],
            ['記事4', 'これは記事4'],
            ['記事5', 'これは記事5'],
            ['記事6', 'これは記事6'],
            ['記事7', 'これは記事7'],
            ['記事8', 'これは記事8'],
            ['記事9', 'これは記事9'],
            ['記事10', 'これは記事10']

        ];


        $connection = $setup->getConnection();
        $table = $setup->getTable('my_news');

        foreach ($data as $row) {
            $bind = [
                'title' => $row[0],
                'content' => $row[1]
            ];
            $connection->insertOnDuplicate($table, $bind);
        }
    }
}

本文に使うcontent列はもっと長くても良いのですが、とりあえずサンプルデータなのでこの程度で良しとします。

一覧と詳細画面の作成

ようやく表示側のプログラムを書く段階になりました。今回は一覧画面と詳細画面を作ってみましょう。

一覧画面で使用するクラスの作成

まずは一覧画面の表示を担当するBlockクラスとControllerクラスを作成します。下図のように以下の3つのファイルをエクステンションのディレクトリ(今回であればapp/code/My/News)内に作成します。

  • Block/News.php
  • Controller/Index/Index.php
  • etc/frontend/routes.xml

では、ファイルの中身を記述していきましょう。最初はetc/frontend/routes.xmlです。以下のように記述します。

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="standard">
        <route id="mynews" frontName="mynews">
            <module name="My_News" />
        </route>
    </router>
</config>

続いて、Controller/Index/Index.phpです。

<?php
namespace My\News\Controller\Index;

use \Magento\Framework\App\Action\Action;

class Index extends Action
{

    /**
     * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface|void
     */
    public function execute()
    {
        $this->_view->loadLayout();
        $this->_view->renderLayout();
    }
}

そして、Block/News.phpです。

<?php
namespace My\News\Block;

use \Magento\Framework\View\Element\Template;
use \My\News\Model\ResourceModel\News\Collection;
use \My\News\Model\ResourceModel\News\CollectionFactory;
use \My\News\Model\News as Model;

class News extends Template
{
    /**
     * @var CollectionFactory
     */
    private $collectionFactory;


    /**
     * News constructor.
     * @param CollectionFactory $collectionFactory
     * @param Template\Context $context
     * @param array $data
     */
    public function __construct(
        CollectionFactory $collectionFactory,
        Template\Context $context,
        array $data = []
    ) {
        $this->collectionFactory = $collectionFactory;
        parent::__construct($context, $data);
    }

    /**
     * @return mixed
     */
    public function getCollection()
    {
        $collection = $this->collectionFactory->create();

        return $collection;
    }

    /**
     * @param Model $item
     * @return string
     */
    public function getDate(Model $item)
    {
        return $this->formatDate($item->getCreatedAt(), \IntlDateFormatter::SHORT, true);
    }
}

画面レイアウトとテンプレートの作成

次にテンプレートとレイアウトXMLです。このあたりは前回と同じ流れです。エクステンションディレクトリ下にview/frontendディレクトリを作成し、下記のファイルを作成します。

  • layout/mynews_index_index.xml
  • templates/news.phtml

レイアウトXML

最初にレイアウトXMLを作成します。layout/mynews_index_index.xmlを作成し、以下の内容を書き込みます。

<?xml version="1.0"?>

<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="content">
            <block class="My\News\Block\News" name="mynews" template="news.phtml" />
        </referenceContainer>
    </body>
</page>

テンプレート

レイアウトXMLに続いて、テンプレートを作成します。 templates/news.phtmlを作成し、以下の内容を書き込みます。

<div class="block block-news">
    <?php /* @var My\News\Block\News $block */ ?>
    <?php $news = $block->getCollection(); ?>
    <div class="block-content">
        <?php if (count($news) > 0): ?>
            <div class="table-wrapper news">
                <table class="data table table-news-items recent" id="news-table">
                    <caption class="table-caption"><?php /* @escapeNotVerified */ echo __('News') ?></caption>
                    <thead>
                    <tr>
                        <th scope="col" class="col title"><?php /* @escapeNotVerified */ echo __('Title') ?></th>
                        <th scope="col" class="col date"><?php /* @escapeNotVerified */ echo __('Date') ?></th>
                    </tr>
                    </thead>
                    <tbody>
                    <?php foreach ($news as $_news): ?>
                        <tr>
                            <td data-th="<?php echo $block->escapeHtml(__('Title')) ?>" class="col title">
                                <a href="<?php echo $block->getUrl('mynews/index/detail', ['id' => $_news->getId()]);?>">
                                    <?php echo $block->escapeHtml($_news->getTitle()) ?>
                                </a>
                            </td>
                            <td data-th="<?php echo $block->escapeHtml(__('Date')) ?>" class="col date">
                                <?php echo $block->escapeHtml($block->getDate($_news)) ?>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                    </tbody>
                </table>
            </div>
        <?php else: ?>
            <div class="message info empty"><span><?php /* @escapeNotVerified */ echo __('No news now.'); ?></span></div>
        <?php endif; ?>
    </div>
</div>

ここまでできたら、一旦画面表示させてみましょう。URLは、

http(s)://ドメイン名/mynews/index/index

です。うまく次のように一覧が表示されていればOKです。

詳細画面の作成

一覧画面が完成したら、次は詳細画面です。一覧画面と同じように、ControllerとレイアウトXMLとBlockクラス、テンプレートを用意します。

レイアウトXMLの作成

mynews_index_index.xmlと同じディレクトリに、「mynews_index_detail.xml」を作成し、以下のように記述します。

<?xml version="1.0"?>

<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="content">
            <block class="My\News\Block\Detail" name="mynews" template="detail.phtml" />
        </referenceContainer>
    </body>
</page>

Blockクラスの作成

続いてBlockクラスです。Blockディレクトリに「Detail.php」を作成し、以下のように記述します。

<?php
namespace My\News\Block;

use \Magento\Framework\View\Element\Template;
use \My\News\Model\News;

class Detail extends Template
{
    /**
     * @var News
     */
    private $news;


    /**
     * @param News $item
     * @return string
     */
    public function getDate(News $item)
    {
        return $this->formatDate($item->getCreatedAt(), \IntlDateFormatter::SHORT, true);
    }

    /**
     * @param News $news
     */
    public function setNews(News $news)
    {
        $this->news = $news;
    }

    /**
     * @return mixed
     */
    public function getNews()
    {
        return $this->news;
    }
}

テンプレートの作成

テンプレートを作成します。templatesディレクトリに「detail.phtml」を作成し、以下のように記述します。

<div class="block block-news">
    <?php /* @var My\News\Block\News $block */ ?>
    <?php $news = $block->getNews(); ?>
    <div class="block-content">
        <h2><?php echo $block->escapeHtml($news->getTitle()) ?></h2>
        <span><?php echo $block->escapeHtml($block->getDate($news)) ?></span>
        <div class="news-content">
            <?php /* @noEscape */echo $news->getContent() ?>
        </div>
    </div>
</div>

Controllerの作成

最後にControllerです。Controllerディレクトリに、「Detail.php」を作成し、以下のように記述します。

<?php
namespace My\News\Controller\Index;

use \Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use \My\News\Model\News;
use \My\News\Model\NewsFactory;

class Detail extends Action
{

    /**
     * @var NewsFactory
     */
    private $newsFactory;

    /**
     * Detail constructor.
     * @param NewsFactory $newsFactory
     * @param Context $context
     */
    public function __construct(
        NewsFactory $newsFactory,
        Context $context
    ) {
        $this->newsFactory = $newsFactory;
        parent::__construct($context);
    }


    /**
     * @return \Magento\Framework\App\ResponseInterface|\Magento\Framework\Controller\ResultInterface|void
     */
    public function execute()
    {
        $id = trim($this->getRequest()->getParam('id', null));

        if(!$this->validateId($id)) {
            $this->messageManager->addErrorMessage(__('Invalid news id was given.'));
            $this->_redirect('mynews/index');
        } else {
            /** @var News $news */
            $news = $this->loadNews($id);

            if(!$news->getId()) {
                $this->messageManager->addErrorMessage(__('No such news data.'));
                $this->_redirect('mynews/index');
            } else {
                $this->_view->loadLayout();

                /** @var \My\News\Block\Detail $block */
                $block = $this->_view->getLayout()->getBlock('mynews');
                $block->setNews($news);

                $this->_view->renderLayout();
            }
        }


    }

    /**
     * @param $id
     * @return bool
     */
    private function validateId($id)
    {
        if(!preg_match('/(\d)*/', $id)) {
            return false;
        }

        return true;
    }

    /**
     * @param $id
     * @return mixed
     */
    private function loadNews($id)
    {
        $news = $this->newsFactory->create();
        $news->load($id);

        return $news;
    }
}

詳細画面の場合は、表示対象のデータが無かった場合の処理や、URLを介して与えられたパラメータが設計者の想定範囲であるかをチェックする処理が必要です。SQLインジェクション対策は、生のSQLを書かなければ基本的にMagentoのフレームワークがエスケープ処理を自動で行ってくれるので、開発者はあまり意識する必要はありません。

全部できたら、先程の一覧画面から、リンクをたどってみましょう。無事に次のように詳細情報が表示されればOKです。(CSS定義を書いていないので、見た目はあまりよろしくないですが、そこはスルーしてください)

終わりに

今回はMagentoで独自のデータベーステーブルを使用したエクステンションの作り方をご紹介しました。ただ、今回は記事の長さの関係上、公開画面側の処理だけの内容となっています。次回は管理画面側の実装方法をご紹介します。さらにややこしい定義や記述が増えるので、今回よりも長くなると思います。