在 Yii 2 下,实现多租户的逻辑隔离,即在数据库的每张表中皆存在租户ID字段,自定义活动查询类,在每次查询中默认添加租户ID的查询条件
1、在 Yii 2 Starter Kit 中实现数据库连接时的动态配置,配置属性来源于多租户系统,http://www.shuijingwanwq.com/2018/01/18/2328/ ,多租户的物理隔离实现
2、在公有云中,系统要同时服务于多个租户,多个租户共用一个数据库,因此,需要实现多租户的逻辑隔离,即在数据库的每张表中皆存在租户ID字段,表结构如图1
3、/common/models 目录中的模型类文件仅允许Gii工具所生成,为公共的模型数据层,\common\models\ConfigColumn.php,\common\models\ConfigColumnQuery.php
<?php namespace common\models; use Yii; /** * This is the model class for table "{{%config_column}}". * * @property int $id * @property string $group_id 租户ID * @property string $code 栏目代码 * @property string $name 栏目名称 * @property int $status 状态,-1:删除;0:禁用;1:启用 * @property int $created_at 创建时间 * @property int $updated_at 更新时间 */ class ConfigColumn extends \yii\db\ActiveRecord { /** * {@inheritdoc} */ public static function tableName() { return '{{%config_column}}'; } /** * {@inheritdoc} */ public function rules() { return [ [['group_id', 'code', 'name'], 'required'], [['status', 'created_at', 'updated_at'], 'integer'], [['group_id', 'code', 'name'], 'string', 'max' => 32], [['group_id', 'code'], 'unique', 'targetAttribute' => ['group_id', 'code']], [['group_id', 'name'], 'unique', 'targetAttribute' => ['group_id', 'name']], ]; } /** * {@inheritdoc} */ public function attributeLabels() { return [ 'id' => Yii::t('model/config-column', 'ID'), 'group_id' => Yii::t('model/config-column', 'Group ID'), 'code' => Yii::t('model/config-column', 'Code'), 'name' => Yii::t('model/config-column', 'Name'), 'status' => Yii::t('model/config-column', 'Status'), 'created_at' => Yii::t('model/config-column', 'Created At'), 'updated_at' => Yii::t('model/config-column', 'Updated At'), ]; } /** * {@inheritdoc} * @return ConfigColumnQuery the active query used by this AR class. */ public static function find() { return new ConfigColumnQuery(get_called_class()); } }
<?php namespace common\models; /** * This is the ActiveQuery class for [[ConfigColumn]]. * * @see ConfigColumn */ class ConfigColumnQuery extends \yii\db\ActiveQuery { /*public function active() { return $this->andWhere('[[status]]=1'); }*/ /** * {@inheritdoc} * @return ConfigColumn[]|array */ public function all($db = null) { return parent::all($db); } /** * {@inheritdoc} * @return ConfigColumn|array|null */ public function one($db = null) { return parent::one($db); } }
4、/common/logics 目录中的模型类文件为业务逻辑相关,继承至 /common/models 数据层,为公共的模型逻辑层,\common\logics\ConfigColumn.php
<?php namespace common\logics; use Yii; use yii\behaviors\TimestampBehavior; use yii2tech\ar\softdelete\SoftDeleteBehavior; use yii\helpers\ArrayHelper; class ConfigColumn extends \common\models\ConfigColumn { const STATUS_DELETED = -1; //状态:删除 const STATUS_DISABLED = 0; //状态:禁用 const STATUS_ENABLED = 1; //状态:启用 const SCENARIO_CREATE = 'create'; public function scenarios() { $scenarios = parent::scenarios(); $scenarios[self::SCENARIO_CREATE] = ['code', 'name', 'status']; return $scenarios; } /** * @inheritdoc */ public function behaviors() { return [ 'timestampBehavior' => [ 'class' => TimestampBehavior::className(), 'attributes' => [ self::EVENT_BEFORE_INSERT => ['created_at', 'updated_at'], self::EVENT_BEFORE_UPDATE => 'updated_at', SoftDeleteBehavior::EVENT_BEFORE_SOFT_DELETE => 'updated_at', ] ], 'softDeleteBehavior' => [ 'class' => SoftDeleteBehavior::className(), 'softDeleteAttributeValues' => [ 'status' => self::STATUS_DELETED ], ], ]; } /** * @inheritdoc */ public function rules() { $rules = [ ]; $parentRules = parent::rules(); return ArrayHelper::merge($rules, $parentRules); } /** * {@inheritdoc} * @return ConfigColumnQuery the active query used by this AR class. */ public static function find() { return new ConfigColumnQuery(get_called_class()); } }
5、/common/logics 目录中的模型类文件为业务逻辑相关,继承至 /common/models 数据层,为公共的模型逻辑层,\common\logics\ConfigColumnQuery.php
<?php namespace common\logics; /** * This is the ActiveQuery class for [[ConfigColumn]]. * * @see ConfigColumn */ class ConfigColumnQuery extends \common\models\ConfigColumnQuery { // 不等于 状态:删除 public function notDeleted() { $this->andWhere(['!=', 'status', ConfigColumn::STATUS_DELETED]); } // 等于 状态:禁用 public function disabled() { return $this->andWhere(['status' => ConfigColumn::STATUS_DISABLED]); } // 等于 状态:启用 public function enabled() { return $this->andWhere(['status' => ConfigColumn::STATUS_ENABLED]); } }
6、/api/models 目录中的模型类文件为业务逻辑相关(仅与接口应用相关),继承至 /common/logics 公共逻辑层,\api\models\ConfigColumn.php
<?php namespace api\models; class ConfigColumn extends \common\logics\ConfigColumn { /** * {@inheritdoc} * @return ConfigColumnQuery the active query used by this AR class. */ public static function find() { return new ConfigColumnQuery(get_called_class()); } }
7、/api/models 目录中的模型类文件为业务逻辑相关(仅与接口应用相关),继承至 /common/logics 公共逻辑层,\api\models\ConfigColumnQuery.php,自定义活动查询类,在每次查询中默认添加租户ID的查询条件
<?php /** * Created by PhpStorm. * User: WangQiang * Date: 2018/07/12 * Time: 16:07 */ namespace api\models; use Yii; /** * This is the ActiveQuery class for [[ConfigColumn]]. * * @see ConfigColumn */ class ConfigColumnQuery extends \common\logics\ConfigColumnQuery { // 默认加上一些条件(字段:租户ID 等于 参数:租户ID) public function init() { $this->andOnCondition(['group_id' => Yii::$app->params['groupId']]); parent::init(); } }
8、创建方法,\api\rests\config_column\CreateAction.php
<?php /** * @link http://www.yiiframework.com/ * @copyright Copyright (c) 2008 Yii Software LLC * @license http://www.yiiframework.com/license/ */ namespace api\rests\config_column; use Yii; use yii\base\Model; use yii\helpers\Url; use yii\web\ServerErrorHttpException; class CreateAction extends Action { /** * @var string the scenario to be assigned to the new model before it is validated and saved. */ public $scenario = Model::SCENARIO_DEFAULT; /** * @var string the name of the view action. This property is need to create the URL when the model is successfully created. */ public $viewAction = 'view'; public $createScenario = 'create'; /** * Creates a new model. * @return \yii\db\ActiveRecordInterface the model newly created * @throws ServerErrorHttpException if there is any error when creating the model */ public function run() { if ($this->checkAccess) { call_user_func($this->checkAccess, $this->id); } // 当前用户的身份实例,未认证用户则为 Null $identity = Yii::$app->user->identity; /* @var $model \yii\db\ActiveRecord */ $model = new $this->modelClass([ 'scenario' => $this->createScenario, ]); $model->load(Yii::$app->getRequest()->getBodyParams(), ''); $model->group_id = $identity->group_id; if ($model->save()) { $response = Yii::$app->getResponse(); $response->setStatusCode(201); $id = implode(',', array_values($model->getPrimaryKey(true))); $response->getHeaders()->set('Location', Url::toRoute([$this->viewAction, 'id' => $id], true)); } elseif ($model->hasErrors()) { $response = Yii::$app->getResponse(); $response->setStatusCode(422, 'Data Validation Failed.'); foreach ($model->getFirstErrors() as $message) { $firstErrors = $message; break; } return ['code' => 20004, 'message' => Yii::t('error', Yii::t('error', Yii::t('error', '20004'), ['firstErrors' => $firstErrors]))]; } elseif (!$model->hasErrors()) { throw new ServerErrorHttpException('Failed to create the object for unknown reason.'); } return ['code' => 10000, 'message' => Yii::t('success', '11003'), 'data' => $model]; } }
9、POST http://api.pcs-api.localhost/v1/config-columns?login_id=e56db1b43546a110431ac38409ed8e9e&login_tid=00be7753cb5ddca8bef997fa648e416d ,201响应,符合预期
{ "code": 10000, "message": "创建栏目设置成功", "data": { "code": "wxkm", "name": "无线昆明", "status": "0", "group_id": "015ce30b116ce86058fa6ab4fea4ac63", "created_at": 1531447852, "updated_at": 1531447852, "id": 3 } }
10、查看生成的 SQL 语句,在执行唯一性验证时,由于在每次查询中默认添加租户ID的查询条件,导致 group_id 的条件重复
SELECT EXISTS(SELECT * FROM `pa_config_column` WHERE (`pa_config_column`.`group_id`='015ce30b116ce86058fa6ab4fea4ac63') AND (`pa_config_column`.`code`='wxkm') AND (`group_id`='015ce30b116ce86058fa6ab4fea4ac63')) INSERT INTO `pa_config_column` (`code`, `name`, `status`, `group_id`, `created_at`, `updated_at`) VALUES ('wxkm', '无线昆明', 0, '015ce30b116ce86058fa6ab4fea4ac63', 1531447852, 1531447852)
11、现在执行查询时的规范:无需手动定义 group_id 的查询条件,/common/logics 目录中的模型类文件为业务逻辑相关,继承至 /common/models 数据层,为公共的模型逻辑层,\common\logics\ConfigColumn.php,重新定义验证规则
/** * @inheritdoc */ public function rules() { $rules = [ [['status'], 'in', 'range' => [self::STATUS_DISABLED, self::STATUS_ENABLED]], [['code'], 'match', 'pattern' => '/^[a-z0-9]+$/', 'message' => Yii::t('app', '{attribute} should contain lowercase letters and numbers.')], [['code'], 'unique', 'targetAttribute' => ['code']], [['name'], 'unique', 'targetAttribute' => ['name']], ]; $parentRules = parent::rules(); unset($parentRules[3], $parentRules[4]); return ArrayHelper::merge($rules, $parentRules); }
12、执行 POST 请求后,再次查看生成的 SQL 语句,符合预期,group_id 的条件有且仅有一次
SELECT EXISTS(SELECT * FROM `pa_config_column` WHERE (`pa_config_column`.`code`='wxbj') AND (`group_id`='015ce30b116ce86058fa6ab4fea4ac63')) INSERT INTO `pa_config_column` (`code`, `name`, `status`, `group_id`, `created_at`, `updated_at`) VALUES ('wxbj', '无线北京', 0, '015ce30b116ce86058fa6ab4fea4ac63', 1531448313, 1531448313)
13、POST http://api.pcs-api.localhost/v1/config-columns?login_id=e56db1b43546a110431ac38409ed8e9e&login_tid=00be7753cb5ddca8bef997fa648e416d ,422响应,符合预期
调整 rules() 前的响应
{ "code": 20004, "message": "数据验证失败:The combination \"015ce30b116ce86058fa6ab4fea4ac63\"-\"wxcq\" of 租户ID and 栏目代码 has already been taken." }
调整 rules() 后的响应
{ "code": 20004, "message": "数据验证失败:栏目代码的值\"wxbj\"已经被占用了。" }
近期评论