こんにちは!
スマレジの テックファーム(SES 部門)のWebエンジニア やまて(@r_yamate) と申します。
はじめに
前回の記事(②Zend Framework のインストール)で、Zend Framework の Welcome 画面を表示できました。
本連載の目次
本連載では以下の順序で進めています。
目次
今回は、MySQLより取得した値を表示する、Webサービスのトップページとなる「商品一覧画面」を作成します。
Zend Framework 公式ドキュメントのチュートリアルの下記ページを参考に作成しました。
チュートリアルは取り扱うデータが音楽CDですが、私はスマレジ社員なので、自社プロダクトを意識して、取り扱うデータは商品(商品名、価格)にしています。
本記事完了時点のソースコード
本記事完了時点のソースコードをGitHub上に公開しています。
以下は変更差分です。
商品管理ページの構成
ページ | 説明 |
---|---|
商品一覧⭐️ | 商品の一覧を表示。商品の新規登録や編集、削除のためのリンクボタン表示。 |
商品登録 | 新規商品を登録するためのフォーム。 |
商品編集 | 商品を編集するためのフォーム。 |
商品削除 | 商品を削除することを確認し、削除するページ。 |
productsテーブル設計
フィールド名 | 名前 | データ型 | オプションなど |
---|---|---|---|
id | ID | INTEGER | PRIMARY KEY, AUTO INCREMENT |
item_name | 商品名 | VARCHAR(256) | NOT NULL |
price | 商品単価 | INTEGER | NOT NULL |
image | 商品画像パス | VARCHAR(256) | |
delete_flag | 論理削除 | BOOLEAN | DEFAULT FALSE, NOT NULL |
created_at | 作成日 | DATETIME | DEFAULT CURRENT_TIMESTAMP, NOT NULL |
updated_at | 更新日 | DATETIME | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, NOT NULL |
ディレクトリ構成
Zend Framework のパッケージ zend-mvc
はモジュールシステムを使用して、各モジュールごとにコードを整理します。
doc_root/ module/ Application/ Product/ config/ src/ Controller/ Form/ Model/ view/ product/ product/
Module
ディレクトリ以下に、Product
ディレクトリをApplication
ディレクトリと同一の階層で作成し、Product
に関するMVC構成をその中で構築します。(Zend Framework はModule単位でMVC構成を構築します)
1. モジュール
1-1. Product モジュールの作成・編集
作成・編集:module/Product/src/Module.php
<?php namespace Product; use Zend\ModuleManager\Feature\ConfigProviderInterface; /** * 商品モジュールクラス。 * ModuleManager はProduct\Moduleクラスを探し、自動的に getConfig() を呼び出す。 */ class Module implements ConfigProviderInterface { /** * ModuleManagerが、自動的に getConfig() を呼び出す。 * */ public function getConfig() { return include __DIR__ . '/../config/module.config.php'; } }
Zend Framework が提供する ModuleManager は、モジュール( Product\\Module
クラス)をロードして、設定を呼び出します。
1-2. Composer のオートローディング機能の設定
編集:composer.json
"autoload": { "psr-4": { "Application\\": "module/Application/src/", "Product\\": "module/Product/src/" } },
autoload
セクションに、名前空間とファイルのパスを記述します。
1-3. Composer のオートロード・ルール更新
Composer のオートロード・ルールを更新します。
docker-compose exec app composer dump-autoload
# r_yamate @ mbp in ~/Documents/code/zend-framework-crud-sample on git:feature-create-index x [23:28:01] $ docker-compose exec app composer dump-autoload Generating autoload files Generated autoload files
1-4. モジュールコンフィグの作成
コントローラ、ビューについての設定を記述します。
作成:module/Product/config/module.config.php
<?php namespace Product; use Zend\ServiceManager\Factory\InvokableFactory; return [ 'controllers' => [ // モジュールが提供する全てのコントローラのリスト 'factories' => [ // 完全修飾名のクラス名で参照し、 zend-servicemanager の InvokableFactory を使用してインスタンスを作成 Controller\ProductController::class => InvokableFactory::class, ], ], // TemplatePathStack の設定にビューディレクトリを追加。 'view_manager' => [ 'template_path_stack' => [ // これにより、view/ ディレクトリに保存されている Album モジュールのビュースクリプトを見つける。 'product' => __DIR__ . '/../view', ], ],];
1-5. 作成したモジュールについてのアプリケーションへの通知
新しいモジュールが存在することを ModuleManager に伝えます。
編集:config/modules.config.php
<?php // 略 return [ 'Zend\Router', 'Zend\Validator', 'Application', + 'Product', ];
2. ルーティングとコントローラ
2-1. ルーティングの設定
編集:module/Product/config/module.config.php
<?php namespace Product; use Zend\Router\Http\Segment; use Zend\ServiceManager\Factory\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\AlbumController::class => InvokableFactory::class, ], ], 'router' => [ 'routes' => [ 'album' => [ 'type' => Segment::class, 'options' => [ 'route' => '/product[/:action[/:id]]', // ルート。`/album` で始まるすべての URL にマッチ 'constraints' => [ // 制約 'action' => '[a-zA-Z][a-zA-Z0-9_-]*', // 文字で始まり、その後の文字は英数字、アンダースコア、ハイフンのみに制限 'id' => '[0-9]+', // 数字に限定 ], 'defaults' => [ 'controller' => Controller\AlbumController::class, 'action' => 'index', ], ], ], ], ], 'view_manager' => [ 'template_path_stack' => [ 'product' => __DIR__ . '/../view', ], ], ];
URL と特定のアクションのマッピングは、モジュールの module.config.php ファイルに定義されているルートを使って行われます。
2-2. コントローラの作成
作成・編集:module/Product/src/Controller/ProductController.php
<?php namespace Product\Controller; use Product\Model\ProductTable; use Zend\Mvc\Controller\AbstractActionController; use Zend\View\Model\ViewModel; class ProductController extends AbstractActionController { private $table; /** * ProductController は ProductTable に依存する。 * * @param \Product\Model\ProductTable $table */ public function __construct(ProductTable $table) { $this->table = $table; } /** * 商品一覧を表示するため、モデルから商品を取得し、ビューに渡す。 * * @return void */ public function indexAction() { // ビューに変数を設定するために、 ViewModel のインスタンスを返す。 return new ViewModel([ // コンストラクタの最初のパラメータは、表現したいデータを含む配列。自動的にビュースクリプトに渡される。 'products' => $this->table->fetchAll(), ]); } }
indexAction
メソッド(商品一覧を表示するため、モデルから商品を取得し、ビューに渡す)を記述します。
3. データベースとモデル
3-1. products テーブルの確認
確認:docker/mysql/initdb.d/01_products.sql
SET CHARACTER_SET_CLIENT = utf8; SET CHARACTER_SET_CONNECTION = utf8; CREATE TABLE products ( id INTEGER AUTO_INCREMENT PRIMARY KEY, item_name VARCHAR(256) NOT NULL, price INTEGER NOT NULL, image VARCHAR(256), delete_flag BOOLEAN NOT NULL DEFAULT FALSE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); INSERT INTO products (item_name, price, image) VALUES ('日替わりランチA', 600, null), ('日替わりランチB', 800, null), ('日替わりランチC', 1000, null);
MySQLのproducts
テーブル 及び 登録データ について、 Dockerコンテナ(dbコンテナ) を起動した際に自動生成させるようにしています。
※ image は不使用で進めます。
3-2. zend-db のインストール
Composer を使用して、データベース操作に関する zendframework/zend-db をインストールします。
Setting Up A Database Adapter - Tutorials - Zend Framework Docs
docker-compose exec app composer require zendframework/zend-db:"^2.8.1"
コマンド実行結果
# r_yamate @ mbp in ~/Documents/code/zend-framework-crud-sample on git:feature-create-index x [8:34:50] $ docker-compose exec app composer require zendframework/zend-db:"^2.8.1" Warning from https://repo.packagist.org: Support for Composer 1 is deprecated and some packages will not be available. You should upgrade to Composer 2. See https://blog.packagist.com/deprecating-composer-1-support/ Info from https://repo.packagist.org: #StandWithUkraine The "http://repo.packagist.org/p/provider-2017%244a643c6d65a7098901fe03a750d0d5b49308d5264539fc36b4c1886e2e533ad8.json" file could not be downloaded: failed to open stream: Address not available http://repo.packagist.org could not be fully loaded, package information was loaded from the local cache and may be out of date ./composer.json has been updated Loading composer repositories with package information Warning from https://repo.packagist.org: Support for Composer 1 is deprecated and some packages will not be available. You should upgrade to Composer 2. See https://blog.packagist.com/deprecating-composer-1-support/ Info from https://repo.packagist.org: #StandWithUkraine Updating dependencies (including require-dev) Package operations: 1 install, 0 updates, 0 removals - Installing zendframework/zend-db (2.11.0): Downloading (100%) Please select which config file you wish to inject 'Zend\Db' into: [0] Do not inject [1] config/modules.config.php [2] config/development.config.php.dist Make your selection (default is 1):1 Remember this option for other packages of the same type? (Y/n)Y Installing Zend\Db from package zendframework/zend-db zendframework/zend-db suggests installing zendframework/zend-hydrator (Zend\Hydrator component for using HydratingResultSets) Package container-interop/container-interop is abandoned, you should avoid using it. Use psr/container instead. Package zendframework/zend-component-installer is abandoned, you should avoid using it. Use laminas/laminas-component-installer instead. Package zendframework/zend-config is abandoned, you should avoid using it. Use laminas/laminas-config instead. Package zendframework/zend-escaper is abandoned, you should avoid using it. Use laminas/laminas-escaper instead. Package zendframework/zend-eventmanager is abandoned, you should avoid using it. Use laminas/laminas-eventmanager instead. Package zendframework/zend-http is abandoned, you should avoid using it. Use laminas/laminas-http instead. Package zendframework/zend-json is abandoned, you should avoid using it. Use laminas/laminas-json instead. Package zendframework/zend-loader is abandoned, you should avoid using it. Use laminas/laminas-loader instead. Package zendframework/zend-modulemanager is abandoned, you should avoid using it. Use laminas/laminas-modulemanager instead. Package zendframework/zend-mvc is abandoned, you should avoid using it. Use laminas/laminas-mvc instead. Package zendframework/zend-router is abandoned, you should avoid using it. Use laminas/laminas-router instead. Package zendframework/zend-servicemanager is abandoned, you should avoid using it. Use laminas/laminas-servicemanager instead. Package zendframework/zend-stdlib is abandoned, you should avoid using it. Use laminas/laminas-stdlib instead. Package zendframework/zend-uri is abandoned, you should avoid using it. Use laminas/laminas-uri instead. Package zendframework/zend-validator is abandoned, you should avoid using it. Use laminas/laminas-validator instead. Package zendframework/zend-view is abandoned, you should avoid using it. Use laminas/laminas-view instead. Package zfcampus/zf-development-mode is abandoned, you should avoid using it. Use laminas/laminas-development-mode instead. Package zendframework/zend-db is abandoned, you should avoid using it. Use laminas/laminas-db instead. Writing lock file Generating autoload files
途中、
Please select which config file you wish to inject 'Zend\Db' into: (どの設定ファイルに 'Zend\Db' を注入したいかを選択してください。)
という質問をされて、 [1] config/modules.config.php
を選択すると、'Zend\Db' 使用についての設定を追記してくれます。
Remember this option for other packages of the same type? (Y/n) (このオプションを他の同じタイプのパッケージにも適用しますか? (Y/n))
とも聞かれるので、Y
にします。
3-3. アプリケーションへの zend-db 追加
アプリケーションにパッケージを追加するために設定します。(上の質問で [1] config/modules.config.php
選択していたら確認のみです)
確認 or 編集:config/modules.config.php
<?php // 略 return [ + 'Zend\Db', 'Zend\Router', 'Zend\Validator', 'Application', 'Product', ];
3-4. モデルファイルの作成
作成・編集:module/Product/src/Model/Product.php
<?php namespace Product\Model; /** * 商品モデルクラス。 * zend-db の TableGateway クラスで動作させるために、 exchangeArray() メソッドを実装する。 */ class Product { public int $id; public string $itemName; public int $price; public ?string $image; /** * 指定した配列からデータをエンティティのプロパティにコピーする。 * (zend-db の TableGateway クラスで動作させるため) * * @param array $data * @return void */ public function exchangeArray(array $data) { $this->id = isset($data['id']) ? $data['id'] : null; $this->itemName = isset($data['item_name']) ? $data['item_name'] : null; $this->price = isset($data['price']) ? $data['price'] : null; $this->image = isset($data['image']) ? $data['image'] : null; } }
パッケージ zend-db
の TableGateway
クラスで動作させるために、 Product.php
にexchangeArray()
メソッドを実装します。
※ isset
を用いた箇所については、チュートリアルでは!empty()
が使用されています。!empty()
を用いると、$data[{key}]
の値が(例として)intの0
やstringの'0'
、空文字''
などの場合に$this->{key}=null
になります。PHP: empty - Manual には、
empty() は本質的に !isset($var) || $var == false と同じことを簡潔に記述しているだけです。
とあります。「パラメータが入力されているか」の判定は必要ですが、「入力されたパラメータの値が何か」の判定は不要なので、isset()
に置き換えています。
作成・編集:module/Product/src/Model/ProductTable.php
(ここでは、商品一覧表示のみのコードにします。新規作成、編集、削除に関連するメソッドの記述は次の記事で追加します)
<?php namespace Product\Model; use RuntimeException; use Zend\Db\TableGateway\TableGatewayInterface; /** * 商品のデータベース・テーブルに対する操作を実行するクラス。 */ class ProductTable { /** @var \Zend\Db\TableGateway\TableGatewayInterface $tableGateway */ private TableGatewayInterface $tableGateway; /** * コンストラクタ。 * 渡された TableGateway インスタンスを $tableGateway に設定する。 * TableGateway インスタンスを使用して、データベースのテーブルから行を検索、挿入、更新、削除する。 * (これにより、テスト時にモックインスタンスを含む別の実装を簡単に提供することができる) * * @param \Zend\Db\TableGateway\TableGatewayInterface $tableGateway */ public function __construct(TableGatewayInterface $tableGateway) { $this->tableGateway = $tableGateway; } /** * データベースからすべての商品のカラムをResultSetとして取得する。 * * @return Zend\Db\ResultSet\ResultSet データベースから取得したすべての商品のカラム */ public function fetchAll() { return $this->tableGateway->select(); } }
パッケージ zend-db
の TableGateway
クラスで動作させるために、 Product.php にexchangeArray()
メソッドを実装します。
ProductTable.php にて、TableGateway
インスタンスを使用して、DB(テーブル)に対する操作を実行します。
3-5. ServiceManager を使用してテーブルゲートウェイを設定し、ProductTable にインジェクトする
編集:module/Product/src/Module.php
<?php namespace Product; use Zend\Db\Adapter\AdapterInterface; use Zend\Db\ResultSet\ResultSet; use Zend\Db\TableGateway\TableGateway; use Zend\ModuleManager\Feature\ConfigProviderInterface; /** * 商品モジュールクラス。 * ModuleManager はProduct\Moduleクラスを探し、自動的に getConfig() を呼び出す。 */ class Module implements ConfigProviderInterface { /** * ModuleManagerが、自動的に getConfig() を呼び出す。 */ public function getConfig() { return include __DIR__ . '/../config/module.config.php'; } /** * ServiceManager に渡す前に ModuleManager によってすべてマージされたfactoriesの配列を返す。 * * @return array ModuleManager によってすべてマージされたfactoriesの配列 */ public function getServiceConfig() { return [ 'factories' => [ // ServiceManager を使用して Product\Model\ProductTableGateway サービスを作成し、そのコンストラクタに渡す。 Model\ProductTable::class => function ($container) { $tableGateway = $container->get(Model\ProductTableGateway::class); return new Model\ProductTable($tableGateway); }, // ProductTableGateway サービスが Zend\Db\Adapter\AdapterInterface の実装(これも ServiceManager から)を取得し、 // それを使用して TableGateway オブジェクトを作成することによって作成されることを ServiceManager に伝える。 // TableGateway クラスは、結果セットとエンティティの作成にプロトタイプ・パターンを使用する。 // (必要なときにインスタンスを作成するのではなく、 以前にインスタンス化されたオブジェクトをクローンする) Model\ProductTableGateway::class => function ($container) { $dbAdapter = $container->get(AdapterInterface::class); $resultSetPrototype = new ResultSet(); $resultSetPrototype->setArrayObjectPrototype(new Model\Product()); return new TableGateway('products', $dbAdapter, null, $resultSetPrototype); }, ], ]; } /** * Product コントローラのファクトリ * (Productコントローラが ProductTable に依存するようになったため必要) * * @return array */ public function getControllerConfig() { return [ 'factories' => [ Controller\ProductController::class => function ($container) { return new Controller\ProductController( $container->get(Model\ProductTable::class) ); }, ], ]; } }
常に同じ ProductTable
のインスタンスを使用するために、ServiceManager を使用して、インスタンスの作成方法を定義します。Module
クラスで getServiceConfig()
というメソッドを作成し、これを ModuleManager が自動的に呼び出して ServiceManager に適用します。
編集:config/autoload/global.php
<?php // return [ // PDO を使って MySQL データベースに接続 'db' => [ 'driver' => 'Pdo_Mysql', 'dsn' => 'mysql:dbname=self_order;host=db;charset=utf8', 'username' => 'db_user', 'password' => 'secret', ], ];
global.php に、データベースの設定情報を追加します(PDO を使って MySQL データベースに接続)。
3-6. モデルをコントローラにインジェクトする
編集:module/Product/src/Controller/ProductController.php
<?php namespace Product\Controller; use Product\Model\ProductTable; use Zend\Mvc\Controller\AbstractActionController; use Zend\View\Model\ViewModel; /** * 商品コントローラークラス。 */ class ProductController extends AbstractActionController { /** @var \Product\Model\ProductTable $table */ private ProductTable $table; /** * コンストラクタ。 * ProductController は ProductTable に依存する。 * * @param \Product\Model\ProductTable $table productsテーブル */ public function __construct(ProductTable $table) { $this->table = $table; } /** * 商品一覧を表示する。 * 商品一覧を表示するために、モデルから商品を取得し、ビューに渡す。 * * @return Zend\View\Model\ViewModel 商品一覧 */ public function indexAction() { // ビューに変数を設定するために、 ViewModel のインスタンスを返す。 return new ViewModel([ // コンストラクタの最初のパラメータは、表現したいデータを含む配列。自動的にビュースクリプトに渡される。 'products' => $this->table->fetchAll(), ]); } }
3-7. コントローラのファクトリの作成
編集:module/Product/src/Module.php
<?php namespace Product; use Zend\Db\Adapter\AdapterInterface; use Zend\Db\ResultSet\ResultSet; use Zend\Db\TableGateway\TableGateway; use Zend\ModuleManager\Feature\ConfigProviderInterface; /** * 商品モジュールクラス。 * ModuleManager はProduct\Moduleクラスを探し、自動的に getConfig() を呼び出す。 */ class Module implements ConfigProviderInterface { /** * ModuleManagerが、自動的に getConfig() を呼び出す。 */ public function getConfig() { return include __DIR__ . '/../config/module.config.php'; } /** * ServiceManager に渡す前に ModuleManager によってすべてマージされたfactoriesの配列を返す。 * * @return array ModuleManager によってすべてマージされたfactoriesの配列 */ public function getServiceConfig() { return [ 'factories' => [ // ServiceManager を使用して Product\Model\ProductTableGateway サービスを作成し、そのコンストラクタに渡す。 Model\ProductTable::class => function ($container) { $tableGateway = $container->get(Model\ProductTableGateway::class); return new Model\ProductTable($tableGateway); }, // ProductTableGateway サービスが Zend\Db\Adapter\AdapterInterface の実装(これも ServiceManager から)を取得し、 // それを使用して TableGateway オブジェクトを作成することによって作成されることを ServiceManager に伝える。 // TableGateway クラスは、結果セットとエンティティの作成にプロトタイプ・パターンを使用する。 // (必要なときにインスタンスを作成するのではなく、 以前にインスタンス化されたオブジェクトをクローンする) Model\ProductTableGateway::class => function ($container) { $dbAdapter = $container->get(AdapterInterface::class); $resultSetPrototype = new ResultSet(); $resultSetPrototype->setArrayObjectPrototype(new Model\Product()); return new TableGateway('products', $dbAdapter, null, $resultSetPrototype); }, ], ]; } /** * Product コントローラのファクトリ * (Productコントローラが ProductTable に依存するようになったため必要) * * @return array */ public function getControllerConfig() { return [ 'factories' => [ Controller\ProductController::class => function ($container) { return new Controller\ProductController( $container->get(Model\ProductTable::class) ); }, ], ]; } }
編集:module/Product/config/module.config.php
<?php namespace Product; use Zend\Router\Http\Segment; // URLパターン(ルート)にプレースホルダーを指定し、マッチしたルートの名前付きパラメーターにマッピングさせる return [ 'router' => [ 'routes' => [ 'product' => [ 'type' => Segment::class, 'options' => [ 'route' => '/product[/:action[/:id]]', // ルート。/product で始まるすべての URL にマッチ 'constraints' => [ // 制約 'action' => '[a-zA-Z][a-zA-Z0-9_-]*', // 文字で始まり、その後の文字は英数字、アンダースコア、ハイフンのみに制限 'id' => '[0-9]+', // 数字に限定 ], 'defaults' => [ 'controller' => Controller\ProductController::class, 'action' => 'index', ], ], ], ], ], // TemplatePathStack の設定にビューディレクトリを追加。これにより、view/ ディレクトリに保存されている Product モジュールのビュースクリプトを見つける。 'view_manager' => [ 'template_path_stack' => [ 'product' => __DIR__ . '/../view', ], ], ];
コントローラが ProductTable
に依存するようになったので、コントローラのファクトリを作成します。
4. ビュー
4-1. ビュースクリプトファイルの作成
商品一覧についての記述をします。
作成:module/Product/view/product/product/index.phtml
<?php // ページのタイトルと、ブラウザのタイトルバーに表示される headTitle() ビューヘルパーを使用して // <head> セクションのタイトルを設定する。 $title = '商品一覧'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <table class="table"> <tr> <th>ID</th> <th>商品名</th> <th>商品単価</th> </tr> <!-- コントローラアクションから割り当てた$productsを繰り返し処理 --> <?php foreach ($products as $product) : ?> <tr> <!-- 各アルバムのタイトルとアーティストを表示するテーブルを作成 --> <!-- ビュースクリプトに渡す変数と内部で作成した変数を区別するために、 $this->{variable name} を使用してアクセスする --> <td><?= $this->escapeHtml($product->id) ?></td> <td><?= $this->escapeHtml($product->itemName) ?></td> <td><?= $this->escapeHtml(number_format($product->price)) ?></td> </tr> <?php endforeach; ?> </table> <!-- クロスサイトスクリプティング(XSS)対策のために、escapeHtml()ビューヘルパーを常に使用 -->
ちなみに、「.phtml」はPHPのバージョン3より前に使用されていた拡張子とのことです…
5. トップページに一覧画面が表示されるよう変更
これまで Zend Framework のWelcome画面を表示していたトップページについて、商品データの一覧画面が表示されるよう変更します。
編集:module/Application/config/module.config.php
<?php // 略 namespace Application; + use Product\Controller\ProductController; use Zend\Router\Http\Literal; use Zend\Router\Http\Segment; use Zend\ServiceManager\Factory\InvokableFactory; return [ 'router' => [ 'routes' => [ 'home' => [ 'type' => Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ - 'controller' => Controller\IndexController::class, + 'controller' => ProductController::class, 'action' => 'index', ], ], ], // 略 ];
5-1. 最後に画面を確認
Dockerコンテナを起動した状態で、下記URLにアクセスします。
おわりに
今回は、MySQLより取得した値を表示する、Webサービスのトップページとなる「商品一覧画面」を作成しました。
次回、 ④登録機能の作成 を投稿します。
ついに、初めての実務が始まりました。
今日はSESの出向先への初出勤だった。私が実務未経験であることを理解いただいた上で契約いただいているので、コストをかけてリスクもとっていただいた分の価値提供をする。求められている価値を提供するために自分にできる行動は何か、自分なりに考えて進めていく。
— やまて|Webエンジニア2年目 (@r_yamate) 2022年3月14日
ということで、頑張ります。