1、多租户系统中包含多个租户,每个租户均有其自有的数据库配置信息,现有的需求是通过调用多租户系统的接口,基于响应的主机名、用户名和密码来连接数据库,而不是以应用组件的方式来配置,如图1
2、现在是以应用组件的方式来配置,/common/config/base.php,如图2
3、打开 Dubug,然后查看 Log Messages,db 组件的属性需要在 yii/db/Connection::open 之前完成初始化定义(调用多租户系统的接口),如图3
4、编辑 /common/config/base.php,配置 db 组件的类:common/components/db/Connection,将所有 env 变量注释掉
'db'=>[ 'class'=>'common/components/db/Connection', // 'dsn' => env('DB_DSN'), // 'username' => env('DB_USERNAME'), // 'password' => env('DB_PASSWORD'), // 'tablePrefix' => env('DB_TABLE_PREFIX'), 'charset' => 'utf8', 'enableSchemaCache' => YII_ENV_PROD, ],
5、新建 /common/components/db/Connection.php,继承至 /yii/db/Connection
<?php /** * Created by PhpStorm. * User: Administrator * Date: 2018/01/15 * Time: 16:18 */ namespace common/components/db; use Yii; use yii/helpers/ArrayHelper; /** * 获取租户模块环境配置信息,存储至Redis,注册db组件 * * @author Qiang Wang <shuijingwanwq@163.com> * @since 1.0 */ class Connection extends /yii/db/Connection { public function __construct($config = []) { // ... 配置生效前的初始化过程 $tenantConfig = [ 'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=cmcp-api', 'username' => env('DB_USERNAME'), 'password' => env('DB_PASSWORD'), 'tablePrefix' => env('DB_TABLE_PREFIX'), ]; if (!empty($config)) { $config = ArrayHelper::merge($config, $tenantConfig); } parent::__construct($config); } }
6、重复第3步骤,db 组件的属性已经在 yii/db/Connection::open 之前完成初始化定义(host=localhost 变化为 host=127.0.0.1),如图4
7、现在需要调用多租户系统的接口,以其响应重新赋值 $tenantConfig(建议:此方案仅适用于存在一个租户的情况,如果存在多个租户,建议每个租户对应不同的连接组件,连接组件的命名基于租户ID,而不是对应 db 连接组件),如图5
8、现在还原所有修改,准备实现同时使用多个数据库(每个租户对应不同的连接组件,连接组件的命名基于租户ID),参考网址:https://github.com/yiichina/yii2/blob/master/docs/guide-zh-CN/db-active-record.md ,如图6
9、新建 /common/components/db/ActiveRecord.php,继承至 /yii/db/ActiveRecord,重写 [[yii/db/ActiveRecord::getDb()|getDb()]] 方法
<?php /** * Created by PhpStorm. * User: Administrator * Date: 2018/01/16 * Time: 10:31 */ namespace common/components/db; use Yii; /** * 获取租户模块环境配置信息,存储至Redis,注册数据库连接组件 * * @author Qiang Wang <shuijingwanwq@163.com> * @since 1.0 */ class ActiveRecord extends /yii/db/ActiveRecord { /** * 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 = new /yii/db/Connection([ 'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=cmcp-api', 'username' => env('DB_USERNAME'), 'password' => env('DB_PASSWORD'), 'tablePrefix' => env('DB_TABLE_PREFIX'), 'charset' => 'utf8', 'enableSchemaCache' => YII_ENV_PROD, ]); return $tenantDb; // return Yii::$app->getDb(); } }
10、编辑 /common/models/KeyStorageItem.php,继承至 common/components/db/ActiveRecord
<?php namespace common/models; use Yii; use yii/behaviors/TimestampBehavior; use common/components/db/ActiveRecord; /** * This is the model class for table "key_storage_item". * * @property integer $key * @property integer $value */ class KeyStorageItem extends ActiveRecord { }
11、编辑 /common/config/base.php,注释掉 db 组件,后期建议将 db 组件移至开发环境,以方便于 Gii 的使用
/* 'db'=>[ 'class'=>'yii/db/Connection', 'dsn' => env('DB_DSN'), 'username' => env('DB_USERNAME'), 'password' => env('DB_PASSWORD'), 'tablePrefix' => env('DB_TABLE_PREFIX'), 'charset' => 'utf8', 'enableSchemaCache' => YII_ENV_PROD, ], */
12、打开:http://frontend.cmcp-api.localhost/ ,报错:Failed to instantiate component or class “db”.,原因在于日志目标为数据库表,如图7
13、查看日志组件配置,/common/config/base.php
'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, 'targets' => [ 'db'=>[ 'class' => 'yii/log/DbTarget', 'levels' => ['error', 'warning'], 'except'=>['yii/web/HttpException:*', 'yii/i18n/I18N/*'], 'prefix'=>function () { $url = !Yii::$app->request->isConsoleRequest ? Yii::$app->request->getUrl() : null; return sprintf('[%s][%s]', Yii::$app->id, $url); }, 'logVars'=>[], 'logTable'=>'{{%system_log}}' ] ], ],
14、日志组件必须在引导期间就被加载,因此如果数据库连接是动态配置的,已经晚于引导阶段了,编辑日志组件配置,/common/config/base.php,保存日志消息到文件中
'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, 'targets' => [ 'file'=>[ 'class' => 'yii/log/FileTarget', 'levels' => ['error', 'warning'], 'except' => ['yii/web/HttpException:*', 'yii/i18n/I18N/*'], 'prefix' => function () { $url = !Yii::$app->request->isConsoleRequest ? Yii::$app->request->getUrl() : null; return sprintf('[%s][%s]', Yii::$app->id, $url); }, 'logVars'=>[], ] ], ],
15、打开:http://frontend.cmcp-api.localhost/ ,报错:Unknown component ID: db,如图8
16、将 /common/models 下的所有模型文件,use yii/db/ActiveRecord; 替换为 use common/components/db/ActiveRecord;,如图9
use yii/db/ActiveRecord; 替换为 use common/components/db/ActiveRecord; /yii/db/ActiveRecord 替换为 /common/components/db/ActiveRecord
17、打开:http://frontend.cmcp-api.localhost/ ,报错:Failed to instantiate component or class “db”.,原因在于 RBAC 组件使用数据库表存放数据,如图10
18、编辑 /common/config/base.php,注释掉 RBAC 组件,因为其 db 属性默认值为 db,需要动态调整为租户ID所对应的数据库连接组件
/* 'authManager' => [ 'class' => 'yii/rbac/DbManager', 'itemTable' => '{{%rbac_auth_item}}', 'itemChildTable' => '{{%rbac_auth_item_child}}', 'assignmentTable' => '{{%rbac_auth_assignment}}', 'ruleTable' => '{{%rbac_auth_rule}}' ], */
19、编辑 /common/components/db/ActiveRecord.php,通过 [[yii/di/ServiceLocator::setComponents()]] 方法注册数据库连接组件、RBAC组件
<?php /** * Created by PhpStorm. * User: Administrator * Date: 2018/01/16 * Time: 10:31 */ namespace common/components/db; use Yii; /** * 获取租户模块环境配置信息,存储至Redis,注册数据库连接组件 * * @author Qiang Wang <shuijingwanwq@163.com> * @since 1.0 */ class ActiveRecord extends /yii/db/ActiveRecord { /** * 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 = 'defaultDb'; // 注册数据库连接组件、RBAC组件 Yii::$app->setComponents([ $tenantDb => [ 'class' => 'yii/db/Connection', 'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=cmcp-api', 'username' => env('DB_USERNAME'), 'password' => env('DB_PASSWORD'), 'tablePrefix' => env('DB_TABLE_PREFIX'), 'charset' => 'utf8', 'enableSchemaCache' => YII_ENV_PROD, ], '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 Yii::$app->$tenantDb; } }
20、重复第3步骤,db 组件的属性已经在 yii/db/Connection::open 之前完成初始化定义(host=localhost 变化为 host=127.0.0.1),如图11
21、在 Postman 中,GET http://www.cmcp-api.localhost/v1/pages ,200响应,如图12
22、打开网址:http://backend.cmcp-api.localhost/ ,报错:Unknown component ID: db,如图13
23、编辑 /backend/models/SystemLog.php,继承至 /common/components/db/ActiveRecord,如图14
/yii/db/ActiveRecord 替换为 /common/components/db/ActiveRecord
24、打开网址:http://backend.cmcp-api.localhost/ ,200响应
25、现在需要调用多租户系统的接口,以其响应直接初始化使用数据库连接、RBAC,基于已经安装的 Yii 2 的 HTTP 客户端扩展,如图15
26、编辑 /.env.dist、/.env,新增多租户的相关配置
# 多租户 # ---- TENANT_HOST_INFO = http://wjdev.chinamcloud.com:8600 # HOME URL TENANT_BASE_URL = /interface # BASE URL TENANT_APP_NAME = cmcpapi # 模块英文名称 TENANT_SECRET = 94030d307b160f04b88592cb9bebdd4c # 模块Secret
27、编辑 /common/config/bootstrap.php,设置别名
Yii::setAlias('@tenantUrl', env('TENANT_HOST_INFO') . env('TENANT_BASE_URL'));
28、通过应用组件配置客户端,编辑 /common/config/web.php,如图16
'tenantHttp' => [ 'class' => 'yii/httpclient/Client', 'baseUrl' => Yii::getAlias('@tenantUrl'), 'transport' => 'yii/httpclient/CurlTransport' ],
29、通过配置日志记录 HTTP 发送的请求并分析其执行情况,编辑 /common/config/base.php,如图17
'httpRequest'=>[ 'class' => 'yii/log/FileTarget', 'logFile' => '@runtime/logs/http-request.log', 'categories' => ['yii/httpclient/*'], ]
30、yii2 HTTP 客户端扩展提供了一个可以与 yii 调试模块集成的调试面板,并显示已执行的HTTP 请求,启用调试面板,如图18
$config['modules']['debug'] = [ 'class' => 'yii/debug/Module', 'allowedIPs' => ['127.0.0.1', '::1', '192.168.33.1', '172.17.42.1', '172.17.0.1', '192.168.99.1'], 'panels' => [ 'httpclient' => [ 'class' => 'yii/httpclient/debug/HttpClientPanel', ], ], ];
31、刷新页面之后,查看日志面板,新增 HTTP Client,如图19
32、新建 /common/logics/http/tenant/Env.php,http目录表示此目录下的模型数据源自于HTTP请求,tenant目录表示多租户系统相关的模型,Env.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/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() { $this->app_name = env('TENANT_APP_NAME'); $this->secret = env('TENANT_SECRET'); /* 租户ID后续从请求参数中获取 */ $this->tenant_id = 'default'; $response = Yii::$app->tenantHttp->createRequest() ->setMethod('get') ->setUrl('getTenantEnvs') ->setData([ 'appname' => $this->app_name, 'secret' => $this->secret, 'tenantid1' => $this->tenant_id, ]) ->send(); // 检查响应状态码是否等于20x if ($response->isOk) { // 检查业务逻辑是否成功 if ($response->data['returnCode'] === 0) { return ['message' => $response->data['returnDesc'], 'data' => $response->data['returnData']]; } 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()]))); } } }
33、新建语言包文件,/common/messages/en/model/http/tenant/env.php、/common/messages/zh/model/http/tenant/env.php,如图20
/common/messages/en/model/http/tenant/env.php
return [ 'App Name' => 'English name of the module', 'Secret' => 'Secret module in multi-tenant system', 'Tenant ID' => 'Tenant ID', ];
/common/messages/zh/model/http/tenant/env.php
return [ 'App Name' => '模块英文名称', 'Secret' => '模块Secret', 'Tenant ID' => '租户ID', ];
34、编辑语言包文件,/api/messages/en/error.php、/api/messages/zh/error.php
/api/messages/en/error.php
20005 => 'Multitenant HTTP request failed with status code: {statusCode}', 20006 => 'Multitenant HTTP request failed: {firstErrors}',
/api/messages/zh/error.php
20005 => '多租户HTTP请求失败,状态码:{statusCode}', 20006 => '多租户HTTP请求失败:{firstErrors}',
35、复制 /api/messages/en/app.php、/api/messages/en/error.php、/api/messages/zh/app.php、/api/messages/zh/error.php 至 /common/messages/en/app.php、/common/messages/en/error.php、/common/messages/zh/app.php、/common/messages/zh/error.php,编辑
/common/messages/zh/error.php
return [ 20000 => 'error', 20005 => '多租户HTTP请求失败,状态码:{statusCode}', 20006 => '多租户HTTP请求失败:{firstErrors}', ];
36、编辑 /common/components/db/ActiveRecord.php,数据库配置信息源自于租户环境配置接口
<?php /** * Created by PhpStorm. * User: Administrator * Date: 2018/01/16 * Time: 10:31 */ namespace common/components/db; use Yii; use common/logics/http/tenant/Env; use yii/web/ServerErrorHttpException; /** * 获取租户模块环境配置信息,存储至Redis,注册数据库连接组件 * * @author Qiang Wang <shuijingwanwq@163.com> * @since 1.0 */ class ActiveRecord extends /yii/db/ActiveRecord { /** * 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() { $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]))); } elseif (!$env->hasErrors()) { throw new ServerErrorHttpException('Multi-tenant HTTP requests fail for unknown reasons.'); } } $tenantDb = $tenantEnv['data']['tenantid'] . 'Db'; // 注册数据库连接组件、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, ], '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 Yii::$app->$tenantDb; } }
37、在 Postman 中 GET http://www.cmcp-api.localhost/v1/pages ,200响应,如图21
38、在 Postman 中 GET 多租户的环境配置接口,一个参数是错误的,如图22
39、编辑 /.env,TENANT_APP_NAME的值是错误的
TENANT_APP_NAME = cmcpapi1 # 模块英文名称
40、在 Postman 中 GET http://www.cmcp-api.localhost/v1/pages ,500响应,如图23
41、查看日志,发现接口应用与后台应用分别请求多租户接口的次数为8、139次,后台报错:Unable to send log via yii/log/FileTarget: Exception (Database Exception) ‘yii/db/Exception’ with message ‘SQLSTATE[HY000] [1040] Too many connections’ ,如图24、25
42、检查数据库连接组件、RBAC组件是否被注册,如果已经被注册,则无需覆盖,编辑 /common/components/db/ActiveRecord.php,如图26
// 检查数据库连接组件、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, ], 'authManager' => [ 'class' => 'yii/rbac/DbManager', 'db' => $tenantDb, 'itemTable' => '{{%rbac_auth_item}}', 'itemChildTable' => '{{%rbac_auth_item_child}}', 'assignmentTable' => '{{%rbac_auth_assignment}}', 'ruleTable' => '{{%rbac_auth_rule}}' ], ]); }
43、后台应用报错:’SQLSTATE[HY000] [1040] Too many connections’ 已经解决,如图27
44、至此,数据库连接时的动态配置,配置属性来源于多租户系统已经基本上实现了,后续应该会实现缓存(多租户系统的响应),因为一次请求中,对于多租户系统的HTTP请求多达上百次,而每次HTTP请求平均耗时为100ms左右,如图28
45、开启 Schema 缓存(仅在生产环境中开启),且缓存至 Redis 中,编辑 /.env.dist、/.env,如图29
YII_ENV = prod # Redis # ---- REDIS_HOSTNAME = localhost # 主机名/IP地址 REDIS_PORT = 6379 # 端口 #REDIS_PASSWORD = # 密码 REDIS_DATABASE = 0 # 数据库 # Redis cache # ---- REDIS_CACHE_KEY_PREFIX = ca: # 唯一键前缀
46、编辑 /common/config/base.php,在应用程序配置中配置 Redis 连接,且配置缓存组件
'redisCache' => [ 'class' => 'yii/redis/Cache', 'keyPrefix' => env('REDIS_CACHE_KEY_PREFIX'), // 唯一键前缀 ], 'redis' => [ 'class' => 'yii/redis/Connection', 'hostname' => env('REDIS_HOSTNAME'), 'port' => env('REDIS_PORT'), 'password' => env('REDIS_PASSWORD'), 'database' => env('REDIS_DATABASE'), ],
47、在数据库连接中开启 Schema 缓存,编辑 /common/components/db/ActiveRecord.php
// 检查数据库连接组件、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}}' ], ]); }
48、运行后台应用后,查看 Redis 中的数据,数据表的结构已经被缓存了,如图30
49、实现缓存(多租户系统的响应),编辑 /common/logics/http/tenant/Env.php,运行后台应用后,查看 Redis 中的数据,多租户系统的响应数据已经被缓存了,如图31
<?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/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() { /* 租户ID后续从请求参数中获取 */ $this->tenant_id = 'default'; // 设置多租户数据的缓存键 $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()]))); } } else { return $tenantData; } } }
50、清空Redis,一次请求中,对于多租户系统的HTTP请求仅有1次,如图32
51、默认情况下,缓存中的数据会永久存留,除非它被某些缓存策略强制移除(例如:缓存空间已满,最老的数据会被移除),后续准备实现清除对应缓存数据的接口,以便于多租户系统的数据发生变化时,可以调用清除对应缓存数据的接口
原创文章,作者:Maggie-Hunter,如若转载,请注明出处:https://blog.ytso.com/180928.html