没有不值得去解决的问题,也没有不值得去学习的技术!

Implementing Multi-Tenancy for Database Migrations in Yii 2 Starter Kit

运行命令:.\yii migrate/create create_news_table、.\yii migrate/create create_news_table --tenantid=default,成功运行

1. Previous article: https://www.shuijingwanwq.com/2018/01/18/2328/

2. Referring to step 11, move the db component to the development environment to facilitate the use of Gii, \common\config\base.php, as shown in Figure 1

Referring to step 11, move the db component to the development environment to facilitate the use of Gii, \common\config\base.php
Figure 1

3. Configure for the production environment to test the multi-tenant implementation of database migration without relying on the db component, as shown in Figure 2

Configure for the production environment to test the multi-tenant implementation of database migration without relying on the db component
Figure 2

4. Run the command: .\yii app/setup, error: Exception ‘yii\base\InvalidConfigException’ with message ‘Failed to instantiate component or class “db”.’, as shown in Figure 3

Run the command: .\yii app/setup, error: Exception 'yii\base\InvalidConfigException' with message 'Failed to instantiate component or class "db".'
Figure 3

5. Implement based on a Trait, place the getTenantDb() method into the Trait, create \common\traits\TenantDb.php, and register the database connection component and RBAC component via the [[yii\di\ServiceLocator::setComponents()]] method

PHP
<?php
/**
 * Created by PhpStorm.
 * User: WangQiang
 * Date: 2018/01/26
 * Time: 16:25
 */
 
namespace common\traits;
 
use Yii;
use common\logics\http\tenant\Env;
use yii\web\ServerErrorHttpException;
 
/**
 * 获取租户模块环境配置信息,存储至Redis,注册数据库连接组件
 *
 * @author Qiang Wang <shuijingwanwq@163.com>
 * @since 1.0
 */

trait TenantDb
{
    /**
     * 数据库连接组件ID(基于多租户)
     *
     * @return string
     */
    public static function getTenantDb()
    {
        $env = new Env();
        $tenantEnv = $env->getTenantEnv();

        if ($tenantEnv === false) {
            if ($env->hasErrors()) {
                foreach ($env->getFirstErrors() as $message) {
                    $firstErrors = $message;
                }
                throw new ServerErrorHttpException(Yii::t('error', Yii::t('error', Yii::t('error', '20006'), ['firstErrors' => $firstErrors])), 20006);
            } elseif (!$env->hasErrors()) {
                throw new ServerErrorHttpException('Multi-tenant HTTP requests fail for unknown reasons.');
            }
        }

        $tenantDb = $tenantEnv['data']['tenantid'] . 'Db';

        // 检查数据库连接组件、RBAC组件是否被注册
        if (!(Yii::$app->has($tenantDb) && Yii::$app->has('authManager'))) {
            // 注册数据库连接组件、RBAC组件
            Yii::$app->setComponents([
                $tenantDb => [
                    'class' => 'yii\db\Connection',
                    'dsn' => 'mysql:host=' . $tenantEnv['data']['db_info']['host'] . ';port=3306;dbname=' . $tenantEnv['data']['db_info']['database'] . '',
                    'username' => $tenantEnv['data']['db_info']['login'],
                    'password' => $tenantEnv['data']['db_info']['password'],
                    'tablePrefix' => $tenantEnv['data']['db_info']['prefix'],
                    'charset' => 'utf8',
                    'enableSchemaCache' => YII_ENV_PROD,
                    'schemaCache' => 'redisCache',
                ],
                'authManager' => [
                    'class' => 'yii\rbac\DbManager',
                    'db' => $tenantDb,
                    'itemTable' => '{{%rbac_auth_item}}',
                    'itemChildTable' => '{{%rbac_auth_item_child}}',
                    'assignmentTable' => '{{%rbac_auth_assignment}}',
                    'ruleTable' => '{{%rbac_auth_rule}}'
                ],
            ]);
        }

        return $tenantDb;
    }
}

6. Edit \common\components\db\ActiveRecord.php, import common\traits\TenantDb

PHP
<?php
/**
 * Created by PhpStorm.
 * User: Administrator
 * Date: 2018/01/16
 * Time: 10:31
 */
 
namespace common\components\db;
 
use Yii;
use common\traits\TenantDb;
 
/**
 * 导入 TenantDb,注册数据库连接组件
 *
 * @author Qiang Wang <shuijingwanwq@163.com>
 * @since 1.0
 */

class ActiveRecord extends \yii\db\ActiveRecord
{
    use TenantDb;

    /**
     * Returns the database connection used by this AR class.
     * By default, the "db" application component is used as the database connection.
     * You may override this method if you want to use a different database connection.
     * @return Connection the database connection used by this AR class.
     */
    public static function getDb()
    {
        $tenantDb = self::getTenantDb();
        return Yii::$app->$tenantDb;
    }
}

7. Implement based on a Trait, import common\traits\TenantDb, place the init() method into the Trait, create \common\traits\TenantMigration.php, and set $tenantDb as the database connection component

PHP
<?php
/**
 * Created by PhpStorm.
 * User: WangQiang
 * Date: 2018/01/26
 * Time: 16:25
 */
 
namespace common\traits;
 
use Yii;
use common\traits\TenantDb;
 
/**
 * 导入 TenantDb,将 $tenantDb 设置为数据库连接组件
 *
 * @author Qiang Wang <shuijingwanwq@163.com>
 * @since 1.0
 */

trait TenantMigration
{
    use TenantDb;

    /**
     * Initializes the migration.
     * This method will set [[db]] to be the 'db' application component, if it is `null`.
     */
    public function init()
    {
        $tenantDb = self::getTenantDb();
        $this->db = $tenantDb;
        parent::init();
    }
}

8. Create \common\components\db\Migration.php, import common\traits\TenantMigration

PHP
<?php
/**
 * Created by PhpStorm.
 * User: Administrator
 * Date: 2018/01/26
 * Time: 13:48
 */
 
namespace common\components\db;
 
use common\traits\TenantMigration;
 
/**
 * 导入 TenantMigration,将 $tenantDb 设置为数据库连接组件
 *
 * @author Qiang Wang <shuijingwanwq@163.com>
 * @since 1.0
 */

class Migration extends \yii\db\Migration
{
    use TenantMigration;
}

9. In the directory \common\migrations, search for: yii\db\Migration, and batch replace with: common\components\db\Migration, as shown in Figure 4

In the directory \common\migrations, search for: yii\db\Migration, and batch replace with: common\components\db\Migration
Figure 4

10. Edit \common\migrations\db\m140703_123055_log.php, import common\traits\TenantMigration

PHP
<?php
require(Yii::getAlias('@yii/log/migrations/m141106_185632_log_init.php'));
 
use common\traits\TenantMigration;
 
class m140703_123055_log extends m141106_185632_log_init
{
    use TenantMigration;
}

11. Edit \common\migrations\db\m140703_123813_rbac.php, import common\traits\TenantMigration

PHP
<?php
require(Yii::getAlias('@yii/rbac/migrations/m140506_102106_rbac_init.php'));
 
use common\traits\TenantMigration;
 
class m140703_123813_rbac extends m140506_102106_rbac_init
{
    use TenantMigration;
}

12. Create the database migration class, \console\controllers\MigrateController.php

PHP
<?php
/**
 * Created by PhpStorm.
 * User: Administrator
 * Date: 2018/01/26
 * Time: 17:35
 */
 
namespace console\controllers;
 
use common\traits\TenantMigration;
 
/**
 * 导入 TenantMigration,将 $tenantDb 设置为数据库连接组件
 *
 * @author Qiang Wang <shuijingwanwq@163.com>
 * @since 1.0
 */

class MigrateController extends \yii\console\controllers\MigrateController
{
    use TenantMigration;
}

13. Adjust the database migration configuration, edit \console\config\console.php, as shown in Figure 5

PHP

        'migrate' => [
            'class' => 'console\controllers\MigrateController',
            'migrationPath' => '@common/migrations/db',
            'migrationTable' => '{{%system_db_migration}}'
        ],

14. Run the command: .\yii app/setup, error: Exception ‘yii\base\UnknownMethodException’ with message ‘Calling unknown method: yii\console\Request::get()’, as shown in Figure 6

Run the command: .\yii app/setup, error: Exception 'yii\base\UnknownMethodException' with message 'Calling unknown method: yii\console\Request::get()'
Figure 6

15. When getting request parameters, determine whether the current request is made via the command line, edit \common\logics\http\tenant\Env.php

PHP
<?php
/**
 * Created by PhpStorm.
 * User: Administrator
 * Date: 2018/01/17
 * Time: 15:20
 */
 
namespace common\logics\http\tenant;
 
use Yii;
use yii\base\Model;
use yii\web\BadRequestHttpException;
use yii\web\ServerErrorHttpException;
 
/**
 * 多租户的模块环境配置
 *
 * @author Qiang Wang <shuijingwanwq@163.com>
 * @since 1.0
 */
class Env extends Model
{
    public $app_name;
    public $secret;
    public $tenant_id;

    public function attributeLabels()
    {
        return [
            'app_name' => \Yii::t('model/http/tenant/env', 'App Name'),
            'secret' => \Yii::t('model/http/tenant/env', 'Secret'),
            'tenant_id' => \Yii::t('model/http/tenant/env', 'Tenant ID'),
        ];
    }

    /**
     * 返回租户模块环境配置信息
     *
     * @return array|false
     *
     * 格式如下:
     *
     * 租户模块环境配置信息
     * [
     *     'message' => '', //说明
     *     'data' => [], //数据
     * ]
     *
     * 失败(将错误保存在 [[yii\base\Model::errors]] 属性中)
     * false
     *
     * @throws ServerErrorHttpException 如果响应状态码不等于20x
     */
    public function getTenantEnv()
    {
        /* 获取请求参数 */
        $request = Yii::$app->request;

        // 判断当前请求是否通过命令行进行
        if ($request->isConsoleRequest) {
            $get = $request->getParams();

            /* 判断请求参数中租户ID是否存在 */
            if (empty($get[1])) {
                // throw new BadRequestHttpException(Yii::t('error', '20007'), 20007);
                $get[1] = env('TENANT_DEFAULT_ID');
            }
            $this->tenant_id = $get[1];
        } else {
            $get = $request->get();

            /* 判断请求参数中租户ID是否存在 */
            if (empty($get['tenantid'])) {
                // throw new BadRequestHttpException(Yii::t('error', '20007'), 20007);
                $get['tenantid'] = env('TENANT_DEFAULT_ID');
            }
            $this->tenant_id = $get['tenantid'];
        }

        // 设置多租户数据的缓存键
        $redisCache = Yii::$app->redisCache;
        $tenantKey = 'tenant:' . $this->tenant_id;

        // 从缓存中取回多租户数据
        $tenantData = $redisCache[$tenantKey];

        if ($tenantData === false) {
            $this->app_name = env('TENANT_APP_NAME');
            $this->secret = env('TENANT_SECRET');

            $response = Yii::$app->tenantHttp->createRequest()
                ->setMethod('get')
                ->setUrl('getTenantEnv')
                ->setData([
                    'appname' => $this->app_name,
                    'secret' => $this->secret,
                    'tenantid' => $this->tenant_id,
                ])
                ->send();
            // 检查响应状态码是否等于20x
            if ($response->isOk) {
                // 检查业务逻辑是否成功
                if ($response->data['returnCode'] === 0) {
                    $tenantData = ['message' => $response->data['returnDesc'], 'data' => $response->data['returnData']];
                    // 将多租户数据存放到缓存供下次使用
                    $redisCache[$tenantKey] = $tenantData;
                    return $tenantData;
                } else {
                    $this->addError('tenant_id', $response->data['returnDesc']);
                    return false;
                }
            } else {
                throw new ServerErrorHttpException(Yii::t('error', Yii::t('error', Yii::t('error', '20005'), ['statusCode' => $response->getStatusCode()])), 20005);
            }
        } else {
            return $tenantData;
        }

    }

}

16. Delete all tables in the database, run the command: .\yii app/setup, error: Exception ‘yii\db\Exception’ with message ‘SQLSTATE[42S02]: Base table or view not found: 1146 Table ‘cmcp-api.ca_system
_db_migration’ doesn’t exist’, as shown in Figure 7

Delete all tables in the database, run the command: .\yii app/setup, error: Exception 'yii\db\Exception' with message 'SQLSTATE[42S02]: Base table or view not found: 1146 Table 'cmcp-api.ca_system _db_migration' doesn't exist'
Figure 7

17. Run the command: .\yii app/setup, error: Exception ‘yii\base\InvalidConfigException’ with message ‘You should configure “log” component to use one or more databa
se targets before executing this migration.’, as shown in Figure 8

Run the command: .\yii app/setup, error: Exception 'yii\base\InvalidConfigException' with message 'You should configure "log" component to use one or more databa se targets before executing this migration.'
Figure 8

18. Delete E:\wwwroot\cmcp-api\common\migrations\db\m140703_123055_log.php, because the log component has already been configured to be file-based.

19. Delete all tables in the database, run the command: .\yii app/setup, error: Exception: Undefined class constant ‘STATUS_PUBLISHED’ (E:\wwwroot\cmcp-api\common\migrations\db\m150725_192740_seed_dat
a.php:63), as shown in Figure 9

Delete all tables in the database, run the command: .\yii app/setup, error: Exception: Undefined class constant 'STATUS_PUBLISHED' (E:\wwwroot\cmcp-api\common\migrations\db\m150725_192740_seed_dat a.php:63)
Figure 9

20. Edit \common\migrations\db\m150725_192740_seed_data.php, replace \common\models\Page with \common\logics\Page, as shown in Figure 10

PHP

        $this->insert('{{%page}}', [
            'slug' => 'about',
            'title' => 'About',
            'body' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
            'status' => \common\logics\Page::STATUS_PUBLISHED,
            'created_at' => time(),
            'updated_at' => time(),
        ]);

Edit \common\migrations\db\m150725_192740_seed_data.php, replace \common\models\Page with \common\logics\Page
Figure 10

21. Delete all tables in the database, run the command: .\yii app/setup, error: Exception ‘yii\base\UnknownPropertyException’ with message ‘Getting unknown property: yii\console\Application::tenantHtt
p’, as shown in Figure 11

Delete all tables in the database, run the command: .\yii app/setup, error: Exception 'yii\base\UnknownPropertyException' with message 'Getting unknown property: yii\console\Application::tenantHtt p'
Figure 11

22. Configure the client via application components, edit \common\config\web.php, and move the tenantHttp component to \common\config\base.php, as shown in Figure 12

PHP

        'tenantHttp' => [
            'class' => 'yii\httpclient\Client',
            'baseUrl' => Yii::getAlias('@tenantUrl'),
            'transport' => 'yii\httpclient\CurlTransport'
        ],

Configure the client via application components, edit \common\config\web.php, and move the tenantHttp component to \common\config\base.php
Figure 12

23. Delete all tables in the database, run the command: .\yii app/setup default, Migrated up successfully. As shown in Figure 13

Delete all tables in the database, run the command: .\yii app/setup default, Migrated up successfully
Figure 13

24. Delete all tables in the database, run the command: .\yii migrate default, Migrated up successfully.

25. Continue to run the command: .\yii rbac-migrate default, error: Exception ‘yii\base\InvalidConfigException’ with message ‘Failed to instantiate component or class “db”.’, as shown in Figure 14

Continue to run the command: .\yii rbac-migrate default, error: Exception 'yii\base\InvalidConfigException' with message 'Failed to instantiate component or class "db".'
Figure 14

26. Edit the RBAC database migration class, \console\controllers\RbacMigrateController.php,

PHP
<?php
 
namespace console\controllers;
 
use yii\console\controllers\MigrateController;
use common\traits\TenantMigration;
 
/**
 * @author Eugene Terentev <eugene@terentev.net>
 */
class RbacMigrateController extends MigrateController
{
    use TenantMigration;
 
    /**
     * Creates a new migration instance.
     * @param string $class the migration class name
     * @return \common\rbac\Migration the migration instance
     */
    protected function createMigration($class)
    {
        $file = $this->migrationPath . DIRECTORY_SEPARATOR . $class . '.php';
        require_once($file);
 
        return new $class();
    }
}

27. Continue to run the command: .\yii rbac-migrate default, Migrated up successfully. As shown in Figure 15

Continue to run the command: .\yii rbac-migrate default, Migrated up successfully.
Figure 15

28. Open the URL: http://backend.cmcp-api.localhost/ , error: SQLSTATE[42S02]: Base table or view not found: 1146 Table ‘cmcp-api.ca_system_log’ doesn’t exist
The SQL being executed was: SELECT COUNT(*) FROM `ca_system_log`, as shown in Figure 16

Open the URL: http://backend.cmcp-api.localhost/ , error: SQLSTATE[42S02]: Base table or view not found: 1146 Table 'cmcp-api.ca_system_log' doesn't exist The SQL being executed was: SELECT COUNT(*) FROM `ca_system_log`
Figure 16

29. Edit \backend\views\layouts\common.php, the related code involving SystemLog:: needs to be deleted, because the system log table file was already deleted when implementing the database migration.

30. Run the command: .\yii migrate/create create_news_table, error: Exception ‘yii\web\ServerErrorHttpException’ with message ‘Multi-tenant HTTP request failed: Module information not configured’, as shown in Figure 17

Run the command: .\yii migrate/create create_news_table, error: Exception 'yii\web\ServerErrorHttpException' with message 'Multi-tenant HTTP request failed: Module information not configured'
Figure 17

31. Decide to convert the tenant ID from a parameter format to an option, such as –tenantid=default, edit \common\logics\http\tenant\Env.php

PHP

        // 判断当前请求是否通过命令行进行
        if ($request->isConsoleRequest) {
            $get = $request->getParams();

            foreach ($get as $value) {
                $option = explode('=', $value);
                if ($option[0] == '--tenantid') {
                    $optionValue = $option[1];
                    break;
                }
            }

            /* 判断请求参数中租户ID是否存在 */
            if (empty($optionValue)) {
                // throw new BadRequestHttpException(Yii::t('error', '20007'), 20007);
                $optionValue = env('TENANT_DEFAULT_ID');
            }
            $this->tenant_id = $optionValue;
        } else {
            $get = $request->get();

            /* 判断请求参数中租户ID是否存在 */
            if (empty($get['tenantid'])) {
                // throw new BadRequestHttpException(Yii::t('error', '20007'), 20007);
                $get['tenantid'] = env('TENANT_DEFAULT_ID');
            }
            $this->tenant_id = $get['tenantid'];
        }

32. By overriding the method in [[yii\console\Controller::options()]], specify the options available for the console command (controller/actionID). Edit \console\controllers\AppController.php

PHP
<?php
 
namespace console\controllers;
 
use Yii;
use yii\console\Controller;
use yii\helpers\Console;
 
/**
 * @author Eugene Terentev <eugene@terentev.net>
 */
class AppController extends Controller
{
    public $writablePaths = [
        '@common/runtime',
        '@frontend/runtime',
        '@frontend/web/assets',
        '@backend/runtime',
        '@backend/web/assets',
        '@api/runtime',
        '@api/web/assets',
        '@storage/cache',
        '@storage/web/source'
    ];
 
    public $executablePaths = [
        '@backend/yii',
        '@api/yii',
        '@frontend/yii',
        '@console/yii',
    ];
 
    public $generateKeysPaths = [
        '@base/.env'
    ];
 
    public $tenantid;
 
    public function options($actionID)
    {
        return ['color', 'interactive', 'help', 'tenantid'];
    }
 
    public function actionSetup()
    {
        $this->runAction('set-writable', ['interactive' => $this->interactive]);
        $this->runAction('set-executable', ['interactive' => $this->interactive]);
        $this->runAction('set-keys', ['interactive' => $this->interactive]);
        \Yii::$app->runAction('migrate/up', ['interactive' => $this->interactive]);
        \Yii::$app->runAction('rbac-migrate/up', ['interactive' => $this->interactive]);
    }
 
    public function actionSetWritable()
    {
        $this->setWritable($this->writablePaths);
    }
 
    public function actionSetExecutable()
    {
        $this->setExecutable($this->executablePaths);
    }
 
    public function actionSetKeys()
    {
        $this->setKeys($this->generateKeysPaths);
    }
 
    public function setWritable($paths)
    {
        foreach ($paths as $writable) {
            $writable = Yii::getAlias($writable);
            Console::output("Setting writable: {$writable}");
            @chmod($writable, 0777);
        }
    }
 
    public function setExecutable($paths)
    {
        foreach ($paths as $executable) {
            $executable = Yii::getAlias($executable);
            Console::output("Setting executable: {$executable}");
            @chmod($executable, 0755);
        }
    }
 
    public function setKeys($paths)
    {
        foreach ($paths as $file) {
            $file = Yii::getAlias($file);
            Console::output("Generating keys in {$file}");
            $content = file_get_contents($file);
            $content = preg_replace_callback('/<generated_key>/', function () {
                $length = 32;
                $bytes = openssl_random_pseudo_bytes(32, $cryptoStrong);
                return strtr(substr(base64_encode($bytes), 0, $length), '+/', '_-');
            }, $content);
            file_put_contents($file, $content);
        }
    }
}

33. By overriding the method in [[yii\console\Controller::options()]], specify the options available for the console command (controller/actionID). Edit \console\controllers\MigrateController.php, \console\controllers\RbacMigrateController.php
\console\controllers\MigrateController.php

PHP
<?php
/**
 * Created by PhpStorm.
 * User: Administrator
 * Date: 2018/01/26
 * Time: 17:35
 */
 
namespace console\controllers;
 
use common\traits\TenantMigration;
 
/**
 * 导入 TenantMigration,将 $tenantDb 设置为数据库连接组件
 *
 * @author Qiang Wang <shuijingwanwq@163.com>
 * @since 1.0
 */

class MigrateController extends \yii\console\controllers\MigrateController
{
    use TenantMigration;

    public $tenantid;

    public function options($actionID)
    {
        return ['color', 'interactive', 'help', 'tenantid'];
    }
}

\console\controllers\RbacMigrateController.php

PHP
<?php
 
namespace console\controllers;
 
use yii\console\controllers\MigrateController;
use common\traits\TenantMigration;
 
/**
 * @author Eugene Terentev <eugene@terentev.net>
 */
class RbacMigrateController extends MigrateController
{
    use TenantMigration;
 
    public $tenantid;
 
    public function options($actionID)
    {
        return ['color', 'interactive', 'help', 'tenantid'];
    }
 
    /**
     * Creates a new migration instance.
     * @param string $class the migration class name
     * @return \common\rbac\Migration the migration instance
     */
    protected function createMigration($class)
    {
        $file = $this->migrationPath . DIRECTORY_SEPARATOR . $class . '.php';
        require_once($file);
 
        return new $class();
    }
}

34. Delete all tables in the database, adjust the commands in steps 24, 25, and 26 to .\yii app/setup –tenantid=default, .\yii migrate –tenantid=default, and .\yii rbac-migrate –tenantid=default, run successfully

35. Run the commands: .\yii migrate/create create_news_table, .\yii migrate/create create_news_table –tenantid=default, run successfully, as shown in Figure 18

Run the commands: .\yii migrate/create create_news_table, .\yii migrate/create create_news_table --tenantid=default, run successfully
Figure 18

36. Since the ID of the database application component has already been defined in the controller, revert steps 8 (delete \common\components\db\Migration.php), 9, and 11. In the directory \common\migrations, search for: common\components\db\Migration, and batch replace with: yii\db\Migration, as shown in Figure 19

Since the ID of the database application component has already been defined in the controller, revert steps 8 (delete \common\components\db\Migration.php), 9, and 11. In the directory \common\migrations, search for: common\components\db\Migration, and batch replace with: yii\db\Migration
Figure 19

关于作者 · 持续学习与技术实践

我是一名拥有 15 年以上开发经验的技术从业者,自 2013 年起持续运营个人技术博客,记录工作与个人项目中遇到的真实问题,以及解决问题过程中的探索、实践和思考。

除了技术写作,我也在持续开发和维护自己的独立项目,探索新技术和新工具在实际场景中的应用。我始终相信,没有不值得去解决的问题,也没有不值得去学习的技术。

如果这篇文章对你有所帮助,欢迎浏览博客中的其他文章,也欢迎交流技术经验与想法。

关于我  ·  GitHub  ·  邮件联系