HHeLiBeXの日記 正道編

日々の記憶の記録とメモ‥

Zend FrameworkからLaminasに移行する話(5)

はじめに

お仕事で、Zend Frameworkのバージョンアップをしなければならなくなった・・と思ったら、Zend Frameworkはもうなくて、Laminas Projectに移って新たなフレームワークとして公開されている。

いちから勉強しないといけないじゃん、ということで、必要な要件を満たせるかどうかを一歩ずつ調査していく。

要件(5)

5つ目の要件は、「セントラルDBに保存したテナントDB接続情報を使ってテナントDBへの接続および操作ができること」。

Laminasの設定ファイルにDB接続情報を書いて接続するっていうのができることは、チュートリアルなどを見て確認しているのだけど、独自にモデルを作れるところまで確認できなかったので、その検証を行う。

導入

こちらでセットアップした環境を(コピーして)使っていく。

hhelibex.hatenablog.jp

私は以下のようにコピーを作成。

cp -pr laminas-setup-4-multiple-modules laminas-setup-5-multiple-databases
cd laminas-setup-5-multiple-databases

DBの作成

今回、PostgreSQLを使用する。

まず、「セントラルDB」と「テナントDB」を作っていく。

セントラルDB

DDLは以下のような感じ。

CREATE ROLE central_user WITH CREATEDB LOGIN PASSWORD 'central_password';
CREATE DATABASE central WITH OWNER central_user TEMPLATE = 'template0'
 ENCODING = 'UTF8' LC_COLLATE = 'ja_JP.UTF-8' LC_CTYPE = 'ja_JP.UTF-8';

\c central central_user;

CREATE TABLE db_master_2(
    id BIGSERIAL PRIMARY KEY,
    tenant_code VARCHAR(64) NOT NULL,
    db_host VARCHAR(256) NOT NULL,
    db_port INTEGER NOT NULL,
    db_user VARCHAR(256) NOT NULL,
    db_password VARCHAR(256) NOT NULL,
    db_name VARCHAR(256) NOT NULL);

INSERT INTO db_master_2(tenant_code, db_host, db_port, db_user, db_password, db_name)
 VALUES('0001', 'localhost', 5432, 'tenant_0001_user', 'tenant_0001_password', 'tenant_0001');
INSERT INTO db_master_2(tenant_code, db_host, db_port, db_user, db_password, db_name)
 VALUES('0002', 'localhost', 5432, 'tenant_0002_user', 'tenant_0002_password', 'tenant_0002');

テナントDB

DDLは以下のような感じ。

CREATE ROLE tenant_0001_user WITH CREATEDB LOGIN PASSWORD 'tenant_0001_password';
CREATE DATABASE tenant_0001 WITH OWNER tenant_0001_user TEMPLATE = 'template0'
 ENCODING = 'UTF8' LC_COLLATE = 'ja_JP.UTF-8' LC_CTYPE = 'ja_JP.UTF-8';

\c tenant_0001 tenant_0001_user;

CREATE TABLE user_master(id BIGSERIAL PRIMARY KEY, user_name VARCHAR(256) NOT NULL, real_name VARCHAR(256) NOT NULL);

INSERT INTO user_master(user_name, real_name) VALUES('tenant0001', 'テナント1 太郎');
CREATE ROLE tenant_0002_user WITH CREATEDB LOGIN PASSWORD 'tenant_0002_password';
CREATE DATABASE tenant_0002 WITH OWNER tenant_0002_user TEMPLATE = 'template0'
 ENCODING = 'UTF8' LC_COLLATE = 'ja_JP.UTF-8' LC_CTYPE = 'ja_JP.UTF-8';

\c tenant_0002 tenant_0002_user;

CREATE TABLE user_master(id BIGSERIAL PRIMARY KEY, user_name VARCHAR(256) NOT NULL, real_name VARCHAR(256) NOT NULL);

INSERT INTO user_master(user_name, real_name) VALUES('tenant0002', 'テナント2 次郎');

設定・実装1

まず、セントラルDBに接続して、データが取得できるようにする。

以下のコマンドを実行して、DB接続のためのコンポーネントをインストールする。

composer require laminas/laminas-db

途中で以下の質問が出てくるが、「1」を選択する。

  Please select which config file you wish to inject 'Laminas\Db' into:
  [0] Do not inject
  [1] config/modules.config.php
  [2] config/development.config.php.dist
  Make your selection (default is 1):1

完了すると、config/modules.config.phpLaminas\Dbが追加される。

続いて、セントラルDBへの接続設定を行う。

config/autoload/global.phpに以下のように記述を行う。

<?php

/**
 * Global Configuration Override
 *
 * You can use this file for overriding configuration values from modules, etc.
 * You would place values in here that are agnostic to the environment and not
 * sensitive to security.
 *
 * NOTE: In practice, this file will typically be INCLUDED in your source
 * control, so do not include passwords or other sensitive information in this
 * file.
 */

return [
    'db' => [
        'driver' => 'Pdo',
        'dsn' => 'pgsql:host=localhost;port=5432;dbname=central;user=central_user;password=central_password',
    ],
];

続いて、セントラルDBのdb_master_2テーブルのデータを取得するためのModelを作成する。ファイルパスはmodule/Application/src/Model/DbMaster2.php

<?php

declare(strict_types=1);

namespace Application\Model;

class DbMaster2
{
    public int $id;
    public string $tenantCode;
    public string $dbHost;
    public int $dbPort;
    public string $dbName;
    public string $dbUser;
    public string $dbPassword;

    public function exchangeArray(array $data)
    {
        $this->id = isset($data['id']) ? $data['id'] : null;
        $this->tenantCode = isset($data['tenant_code']) ? $data['tenant_code'] : null;
        $this->dbHost = isset($data['db_host']) ? $data['db_host'] : null;
        $this->dbPort = isset($data['db_port']) ? $data['db_port'] : null;
        $this->dbUser = isset($data['db_user']) ? $data['db_user'] : null;
        $this->dbPassword = isset($data['db_password']) ? $data['db_password'] : null;
        $this->dbName = isset($data['db_name']) ? $data['db_name'] : null;
    }
}

続いて、オブジェクトマッパーを作っていく。ファイルパスはmodule/Application/src/Model/DbMaster2Table.php

<?php

declare(strict_types=1);

namespace Application\Model;

use RuntimeException;
use Laminas\Db\TableGateway\TableGatewayInterface;

class DbMaster2Table {
    private $tableGateway;

    public function __construct(TableGatewayInterface $tableGateway) {
        $this->tableGateway = $tableGateway;
    }
    public function fetchAll() {
        return $this->tableGateway->select();
    }
    public function getDb($tenantCode) {
        if (is_null($tenantCode)) {
            throw new RuntimeException('Tenant code is required.');
        }

        $rowset = $this->tableGateway->select(['tenant_code' => $tenantCode]);
        $row = $rowset->current();
        if (!$row) {
            throw new RuntimeException(sprintf(
                'Could not find row with tenant code %s',
                $tenantCode
            ));
        }

        return $row;
    }
}

続いて、ServiceManagerを書いていく。module/Application/src/Module.phpを以下のように編集する。

<?php

declare(strict_types=1);

namespace Application;

use Laminas\Db\Adapter\AdapterInterface;
use Laminas\Db\ResultSet\ResultSet;
use Laminas\Db\TableGateway\TableGateway;
use Laminas\ModuleManager\Feature\ConfigProviderInterface;

class Module implements ConfigProviderInterface
{
    public function getConfig(): array
    {
        /** @var array $config */
        $config = include __DIR__ . '/../config/module.config.php';
        return $config;
    }
    public function getServiceConfig(): array
    {
        return [
            'factories' => [
                Model\DbMaster2Table::class => function($container) {
                    $tableGateway = $container->get(Model\DbMaster2TableGateway::class);
                    return new Model\DbMaster2Table($tableGateway);
                },
                Model\DbMaster2TableGateway::class => function($container) {
                    $dbAdapter = $container->get(AdapterInterface::class);
                    $resultSetPrototype = new ResultSet();
                    $resultSetPrototype->setArrayObjectPrototype(new Model\DbMaster2());
                    return new TableGateway('db_master_2', $dbAdapter, null, $resultSetPrototype);
                },
            ],
        ];
    }
    public function getControllerConfig() {
        return [
            'factories' => [
                Controller\TenantController::class => function($container) {
                    return new Controller\TenantController(
                        $container->get(Model\DbMaster2Table::class)
                    );
                },
            ],
        ];
    }
}

getControllerConfigに書いてあるController\TenantControllerはこの次に作っていく。

module/Application/src/Controller/TenantController.phpを以下の内容で作成する。

<?php

declare(strict_types=1);

namespace Application\Controller;

use Laminas\Mvc\Controller\AbstractActionController;
use Laminas\View\Model\ViewModel;

use Application\Model\DbMaster2Table;

class TenantController extends AbstractActionController
{
    private $dbMaster2Table;

    public function __construct(DbMaster2Table $dbMaster2Table)
    {
        $this->dbMaster2Table = $dbMaster2Table;
    }

    public function tenantAction()
    {
        $result = $this->dbMaster2Table->getDb($_GET['tenant_code']);
        return new ViewModel([
            'db_name' => $result->dbName,
        ]);
    }
}

module/Application/config/module.config.phpにroutesを追加する。

            'tenant' => [
                'type'    => Segment::class,
                'options' => [
                    'route'    => '/tenant[/:action]',
                    'defaults' => [
                        'controller' => Controller\TenantController::class,
                        'action'     => 'index',
                    ],
                ],
            ],

最後に、ビューテンプレートを作成する。ファイルパスはmodule/Application/view/application/tenant/tenant.tpl

DB name:{$db_name|escape:"html"}

動作確認1

ここでいったん動作確認をする。

以下のURLにアクセスして、ページが表示されるかを確認する。

http://192.168.56.xxx/laminas-setup-5-multiple-databases/public/tenant/tenant?tenant_code=0001

以下のような画面が出ればOK。

laminas-setup-5-multiple-databases-1

設定・実装2

テナントDBのuser_masterテーブルのデータを取得するためのModelクラスを作成する。ファイルパスはmodule/Application/src/Model/Tenant/UserMaster.php

<?php
    
declare(strict_types=1);

namespace Application\Model\Tenant;
    
class UserMaster
{   
    public int $id;
    public string $userName;
    public string $realName;
        
    public function exchangeArray(array $data)
    {   
        $this->id = isset($data['id']) ? $data['id'] : null;
        $this->userName = isset($data['user_name']) ? $data['user_name'] : null;
        $this->realName = isset($data['real_name']) ? $data['real_name'] : null;
    }   
}

続いて、オブジェクトマッパーを作っていく。ファイルパスはmodule/Application/src/Model/Tenant/UserMasterTable.php

<?php

declare(strict_types=1);

namespace Application\Model\Tenant;

use RuntimeException;
use Laminas\Db\TableGateway\TableGatewayInterface;

class UserMasterTable {
    private $tableGateway;

    public function __construct(TableGatewayInterface $tableGateway) {
        $this->tableGateway = $tableGateway;
    }
    public function fetchAll() {
        return $this->tableGateway->select();
    }
    public function getUser($id) {
        if (is_null($id) || !is_int($id)) {
            throw new RuntimeException('ID is required.');
        }

        $id = (int)$id;

        $rowset = $this->tableGateway->select(['id' => $id]);
        $row = $rowset->current();
        if (!$row) {
            throw new RuntimeException(sprintf(
                'Could not find row with ID %d',
                $id
            ));
        }

        return $row;
    }
}

続いて、ServiceManagerを書いていく。module/Application/src/Module.phpを以下のように編集する。

<?php

declare(strict_types=1);

namespace Application;

use Laminas\Db\Adapter\Adapter;
use Laminas\Db\Adapter\AdapterInterface;
use Laminas\Db\ResultSet\ResultSet;
use Laminas\Db\TableGateway\TableGateway;
use Laminas\ModuleManager\Feature\ConfigProviderInterface;

class Module implements ConfigProviderInterface
{
    public function getConfig(): array
    {
        /** @var array $config */
        $config = include __DIR__ . '/../config/module.config.php';
        return $config;
    }
    public function getServiceConfig(): array
    {
        return [
            'factories' => [
                Model\DbMaster2Table::class => function($container) {
                    $tableGateway = $container->get(Model\DbMaster2TableGateway::class);
                    return new Model\DbMaster2Table($tableGateway);
                },
                Model\DbMaster2TableGateway::class => function($container) {
                    $dbAdapter = $container->get(AdapterInterface::class);
                    $resultSetPrototype = new ResultSet();
                    $resultSetPrototype->setArrayObjectPrototype(new Model\DbMaster2());
                    return new TableGateway('db_master_2', $dbAdapter, null, $resultSetPrototype);
                },
                Model\Tenant\UserMasterTable::class => function($container) {
                    $tableGateway = $container->get(Model\Tenant\UserMasterTableGateway::class);
                    return new Model\Tenant\UserMasterTable($tableGateway);
                },
                Model\Tenant\UserMasterTableGateway::class => function($container) {
                    $dbAdapter = $container->get(Model\Tenant\Adapter::class);
                    $resultSetPrototype = new ResultSet();
                    $resultSetPrototype->setArrayObjectPrototype(new Model\Tenant\UserMaster());
                    return new TableGateway('user_master', $dbAdapter, null, $resultSetPrototype);
                },
                Model\Tenant\Adapter::class => function($container) {
                    $tenantCode = $_GET['tenant_code'];
                    $dbMaster2Table = $container->get(Model\DbMaster2Table::class);
                    $result = $dbMaster2Table->getDb($tenantCode);
                    $config = [
                        'driver' => 'pdo_pgsql',
                        'database' => $result->dbName,
                        'username' => $result->dbUser,
                        'password' => $result->dbPassword,
                        'hostname' => $result->dbHost,
                        'port' => $result->dbPort,
                        'charset' => 'UTF-8',
                    ];
                    $dbAdapter = new Adapter($config);
                    return $dbAdapter;
                },
            ],
        ];
    }
    public function getControllerConfig() {
        return [
            'factories' => [
                Controller\TenantController::class => function($container) {
                    return new Controller\TenantController(
                        $container->get(Model\DbMaster2Table::class),
                        $container->get(Model\Tenant\UserMasterTable::class)
                    );
                },
            ],
        ];
    }
}

修正すべき個所は、getServiceConfig()メソッド内へのファクトリ定義の追加と、getControllerConfig()メソッド内でのController\TenantControllerのコンストラクタのパラメータの修正。

続いて、module/Application/src/Controller/TenantController.phpを以下の内容に変更する。

<?php

declare(strict_types=1);

namespace Application\Controller;

use Laminas\Mvc\Controller\AbstractActionController;
use Laminas\View\Model\ViewModel;

use Application\Model\DbMaster2Table;
use Application\Model\Tenant\UserMasterTable;

class TenantController extends AbstractActionController
{
    private $dbMaster2Table;
    private $userMasterTable;

    public function __construct(DbMaster2Table $dbMaster2Table, UserMasterTable $userMasterTable)
    {
        $this->dbMaster2Table = $dbMaster2Table;
        $this->userMasterTable = $userMasterTable;
    }

    public function tenantAction()
    {
        $result = $this->dbMaster2Table->getDb($_GET['tenant_code']);
        return new ViewModel([
            'db_name' => $result->dbName,
        ]);
    }

    public function usersAction()
    {
        $result = $this->userMasterTable->fetchAll();
        return new ViewModel([
            'users' => $result,
        ]);
    }
}

最後に、ビューテンプレートmodule/Application/view/application/tenant/users.tplを以下の内容で作成する。

<ul>
{foreach from=$users item='user'}
<li>{$user->id|escape:"html"}:{$user->userName|escape:"html"}({$user->realName|escape:"html"})</li>
{/foreach}
</ul>

動作確認2

以下のURLにアクセスして、ページが表示されるかを確認する。

http://192.168.56.xxx/laminas-setup-5-multiple-databases/public/tenant/users?tenant_code=0001

以下のような画面が出ればOK。

laminas-setup-5-multiple-databases-2

まとめ

  • 独自でDB接続をするには、\Laminas\Db\Adapter\Adapter のインスタンスを作ってあげればいい
  • PDO経由にしてあげないと動かないので注意
    • driverpgsqlにしていて、int型のプロパティへの代入ができないと言われてしばらくハマった
    • 型変換を独自でやるならPDO経由でなくてもいいけど、無駄な労力が発生する