Динамические базы данных для ActiveRecord

Как-то давно у меня спрашивали, как сделать хранение пользовательского контента в разных базах данных, а недавно этот же вопрос всплыл на форуме вновь:

Подскажите в общих чертах, как можно реализовать динамическое переключение между базами в зависимости от подключенного пользователя. То есть если залогинился пользователь User1, то подключиться к DB1, если User2 то к DB2.

Где такое может быть полезно? Например, если делаете у себя сервис для клиентов, где хотите, чтобы у каждой компании была своя отдельная база данных. Или если делаете мультисайтовость, когда в одной главной панели управляете товарами пяти своих интернет-магазинов. Мультисайтовость для разных хостингов лучше для безопасности и надёжности реализовывать через API, а не через открытие доступа к SQL серверу для всех или для своего главного IP-адреса. Но такие нюансы рассматривать не будем.

Итак, реализуем поддержку нескольких подключений. При работе через свой Data Mapper можно передавать идентификатор пользователя прямо в методы репозитория:

$post = $this->postsRepository->find($id, $userId);
$post->publish();
$this->postsRepository->persist($post, $userId);

и в него вписать всю логику того, какую базу данных для каждого запроса выбирать. Если используете полноценные ORM, то дальше можете статью не читать. Но задача усложняется при использовании ActiveRecord в том же Yii2 тем, что разные подключения можно указать методам запроса one($db) и all($db):

$post = Post::find()->andWhere(['id' => $postId])->one(Yii::$app->db2);

и нельзя передавать другое подключение $db в присутствующие в ActiveRecord методы save, deleteAll и подобные, которые аргумент $db не принимают и полностью полагаются на свой статический метод:

class ActiveRecord extends BaseActiveRecord
{
    public static function getDb()
    {
        return Yii::$app->getDb();
    }
    ...
}

и работают внутри только с этим единственным подключением static::getDb().

Рассмотрим несколько вариантов переключения баз данных.

Для демонстрации установим yii2-app-basic приложение и запустим команду:

./yii migrate/create create_post_table --useTablePrefix=1 --fields=title:string
./yii migrate

чтобы создать и запустить миграцию:

use yii\db\Migration;
 
class m160826_073936_create_post_table extends Migration
{
    public function up()
    {
        $this->createTable('{{%post}}', [
            'id' => $this->primaryKey(),
            'title' => $this->string(),
        ]);
    }
 
    public function down()
    {
        $this->dropTable('{{%post}}');
    }
}

И добавим для этой таблицы модель:

namespace app\models;
 
use Yii;
use yii\db\ActiveRecord;
 
/**
@property integer $id
@property string $title
 */
class Post extends ActiveRecord
{
    public static function tableName()
    {
        return '{{%post}}';
    }
}

Пользовательские посты будем хранить именно в персональных базах для каждого пользователя вроде этого:

'components' => [
    ...
    'db' => [
        'class' => 'yii\db\Connection',
        'dsn' => 'mysql:host=localhost;dbname=site',
        'username' => 'root',
        'password' => '',
        'charset' => 'utf8',
    ],
    'db_user_1' => [
        'class' => 'yii\db\Connection',
        'dsn' => 'mysql:host=localhost;dbname=user_1',
        'username' => 'root',
        'password' => '',
        'charset' => 'utf8',
    ],
],

Но такой пример с ручным объявлением db_user_1, db_user_2 для тысяч пользователей рассматривать не будем.

Вместо этого можно объявить новое подключение userDb и определить его анонимной функцией:

'components' => [
    ...
    'db' => [
        'class' => 'yii\db\Connection',
        'dsn' => 'mysql:host=localhost;dbname=site',
        'username' => 'root',
        'password' => '',
        'charset' => 'utf8',
    ],
    'userDb' => function () {
        if ($user = Yii::$app->get('user', false)) {
            $userId = !$user->getIsGuest() ? $user->getId() : 0;
        } else {
            $userId = 0;
        }
        return Yii::createObject([
            'class' => 'yii\db\Connection',
            'dsn' => 'mysql:host=localhost;dbname=user_' . $userId,
            'username' => 'root',
            'password' => '',
            'charset' => 'utf8',
        ]);
    },
],

и переопределить метод getDb() на использование этого подключения:

class Post extends ActiveRecord
{
    public static function getDb()
    {
        return Yii::$app->userDb;
    }
 
    ...
}

Это проще всего, но мы не сможем на лету подменить соединение, если администратор захочет посмотреть записи конкретного пользователя. И не сможем работать одновременно с базами нескольких пользователей.

Можно накостылить и так:

$oldDb = Yii::$app->db;
$db = Yii::$app->set('db', Yii::$app->userDb);
$post->save();
Yii::$app->set('db', $oldDb);

Но это перебьёт настройки базы всего сайта. При использовании yii\log\DbTarget системные логи запросов внутри исполнения save() могут записаться не в ту базу.

Можно подменять именно userDb вместо общей базы:

$oldDb = Yii::$app->userDb;
$db = Yii::$app->set('userDb', Yii::$app->userDb143);
$post->save();
Yii::$app->set('userDb', $oldDb);

Или для изменения результата статического getDb() можно добавить специальное поле:

class Post extends ActiveRecord
{
    public static $db;
 
    public static function getDb()
    {
        return self::$db;
    }
}

и присваивать туда нужное подключение:

Post::$db = Yii::$app->userDb143;
$post->save();

Но как при этом создавать свои подключения вроде Yii::$app->userDb143 для самих пользователей?

Вместо этого костылестроения придумаем некое отдельное конфигурируемое хранилище подключений, из которого сможем получать нужные.

Реализация UserDbLocator

Просто объявить 'userDb' => function () с возвратом разных результатов для каждого запроса в components мы не можем, так как в ServiceLocator все объекты создаются только один раз и для следующих вызовов кешируются.

Вместо дёргания самих подключений из Yii::$app удобнее зарегистрировать компонент-фабрику для создания и хранения этих подключений.

Например, создать UserDbLocator с указанием ему шаблона для генерации каждого экземпляра и зарегистрировать его так:

'components' => [
    'db' => [...],
    'userDbLocator' => [
        'class' => 'app\components\UserDbLocator',
        'connection' => [
            'class' => 'yii\db\Connection',
            'dsn' => 'mysql:host=localhost;dbname=user_{id}',
            'username' => 'user_{id}',
            'password' => 'xxx',
            'charset' => 'utf8',
        ],
    ],
],

И он уже на основе ID залогиненного пользователя будет возвращать нужное соединение.

И именно его мы будем использовать в своём коде как любой другой компонент приложения:

$userDb = Yii::$app->userDbLocator->getDb();

и его же впишем в статическом методе getDb() наших сущностей:

class Post extends ActiveRecord
{
    public static function getDb()
    {
        return Yii::$app->get('userDbLocator')->getDb();
    }
}

А для администратора сайта при необходимости можно организовать переключение $userId методом switchId():

class PostController extends Controller
{
    public function actionUpdate($user_id, $id)
    {
        Yii::$app->userDbLocator->switchId($user_id);
        $model = $this->findModel($Id);
        if ($model->load(Yii::$app->request->post()) && $model->save()) {
            return $this->redirect(['view', 'user_id' => $user_id, 'id' => $model->id]);
        }
        return $this->render('update', ['model' => $model]);
    }
}

С требованиями определились. Реализовать это можно примерно так:

namespace app\components;
 
use Yii;
use yii\base\Component;
use yii\base\InvalidConfigException;
 
class UserDbLocator extends Component
{
    public $connection;
    public $component = 'db_{id}';
    public $defaultId = 0;
 
    private $activeId;
 
    public function init()
    {
        if (!is_array($this->connection)) {
            throw new InvalidConfigException('User connection must be set as an array.');
        }
        parent::init();
    }
 
    public function getDb()
    {
        $dbId = $this->getDbId();
        if (!Yii::$app->has($dbId)) {
            Yii::$app->set($dbId, $this->buildConnection());
        }
        return Yii::$app->get($dbId);
    }
 
    public function switchId($id)
    {
        return $this->activeId = $id;
    }
 
    private function getDbId()
    {
        return $this->replacePlaceholder($this->component);
    }
 
    private function buildConnection()
    {
       return array_map([$this, 'replacePlaceholder'], $this->connection);
    }
 
    private function replacePlaceholder($value)
    {
        return str_replace('{id}', $this->getActiveId(), $value);
    }
 
    private function getActiveId()
    {
        if ($this->activeId === null) {
            $user = Yii::$app->get('user', false);
            if ($user && !$user->getIsGuest()) {
                $this->activeId = $user->getId();
            } else {
                $this->activeId = $this->defaultId;
            }
        }
        return $this->activeId;
    }
}

Здесь в методе getDb() мы генерируем название компонента и его определение, заменяя везде {id} на ID выбранного или текущего пользователя. И, чтобы не возиться с созданием и кешированием объекта вручную, помещаем его в ServiceLocator через Yii::$app->set(...).

По умолчанию getActiveId() будет возвращать ID залогиненного пользователя. Но администратору можно будет переключить его вручную через switchId($userId) и вернуть назад с помощью switchId(null):

Yii::$app->userDbLocator->switchId($otherUserId);
$post->save();
Yii::$app->userDbLocator->switchId(null);

Далее мы упростим этот вызов, а пока...

Обобщим до DynamicLocator

Провайдер UserDbLocator получился негибкий, так как умеет получать только подключения к базе:

Yii::$app->get('userDbLocator')->getDb();

Но он таким же образом может работать с любым другим шаблоном. Поэтому было бы удобно обобщить его до провайдера, который можно было использовать многократно для создания разных компонентов.

Для этого можно переименовать его в DynamicLocator и его поле connection переименовать в template. И ещё можно добавить возможность определения шаблонов в виде анонимных функций, принимающих активный $userId:

'components' => [
    'userDbLocator' => [
        'class' => 'app\components\DynamicLocator',
        'component' => 'db_user_{id}',
        'template' => [
            'class' => 'yii\db\Connection',
            'dsn' => 'mysql:host=localhost;dbname=user_{id}',
            'username' => 'root',
            'password' => '',
            'charset' => 'utf8',
        ],
    ],
    'userCacheLocator' => [
        'class' => 'app\components\DynamicLocator',
        'component' => 'cache_user_{id}',
        'template' => function ($userId) {
            ...
        },
    ],
],

А сам метод getDb() переименовать в get() для получения инстанса своего компонента:

Yii::$app->get('userDbLocator')->get();
Yii::$app->get('userCacheLocator')->get();

И ещё неплохо бы помимо предыдущего переключения активного пользователя администратором:

Yii::$app->userDbLocator->switchId($userId);
Yii::$app->userDbLocator->get();

добавить явную передачу $userId в метод get:

Yii::$app->userDbLocator->get();
Yii::$app->userDbLocator->get($userId);

Для этого всего немного перепишем компонент:

namespace app\components;
 
use Yii;
use yii\base\Component;
use yii\base\InvalidConfigException;
use yii\di\Instance;
 
class DynamicLocator extends Component
{
    public $component;
    public $template;
    public $defaultId = 0;
 
    public $activeId;
 
    public function init()
    {
        if (empty($this->component)) {
            throw new InvalidConfigException('Component must be set.');
        }
        if (empty($this->template)) {
            throw new InvalidConfigException('Template must be set.');
        }
        parent::init();
    }
 
    public function get($id = null)
    {
        $componentId = $this->getComponentId($id);
        if (!Yii::$app->has($componentId)) {
            Yii::$app->set($componentId, $this->buildComponent($id));
        }
        return Yii::$app->get($componentId);
    }
 
    public function switchId($id)
    {
        return $this->activeId = $id;
    }
 
    private function getComponentId($id)
    {
        return call_user_func($this->replacer($id), $this->component);
    }
 
    private function buildComponent($id)
    {
        if (is_array($this->template)) {
            return array_map($this->replacer($id), $this->template);
        } else {
            return call_user_func($this->template, $id);
        }
    }
 
    private function replacer($id)
    {
        return function ($value) use ($id) {
            return str_replace('{id}', $this->getActiveId($id), $value);
        };
    }
 
    private function getActiveId($id)
    {
        if ($id !== null ) {
            return $id;
        }
        if ($this->activeId === null) {
            $user = Yii::$app->get('user', false);
            if ($user && !$user->getIsGuest()) {
                $this->activeId = $user->getId();
            } else {
                $this->activeId = $this->defaultId;
            }
        }
        return $this->activeId;
    }
}

Помимо переименования переменных и методов мы дополнили метод buildComponent() поддержкой анонимных функций.

Теперь конфигурируем:

'components' => [
    'userDbLocator' => [
        'class' => 'app\components\DynamicLocator',
        'component' => 'db_user_{id}',
        'template' => [
            'class' => 'yii\db\Connection',
            'dsn' => 'mysql:host=localhost;dbname=user_{id}',
            'username' => 'user_{id}',
            'password' => '',
            'charset' => 'utf8',
        ],
    ],
],

и используем в Post:

class Post extends ActiveRecord
{
    public static function getDb()
    {
        return Yii::$app->get('userDbLocator')->get();
    }
}

и, как и раньше, автоматически получаем подключение к базе активного в данный момент пользователя.

Обобщим до DynamicServiceLocator

В предыдущем варианте наш провайдер работал только с одним компонентом. Мы подключали несколько копий провайдера с разными настройками в template.

Вместо этого можно сделать один DynamicServiceLocator, работающий с несколькими компонентами сразу.

Имеющиеся варианты вызова от разных источников:

Yii::$app->get('userDbLocator')->get();
Yii::$app->get('userCacheLocator')->get();

можно преобразовать в обобщённый вызов по имени компонента от одного источника:

Yii::$app->get('dynamicLocator')->get('db');
Yii::$app->get('dynamicLocator')->get('log');

То есть по поведению он должен напоминать стандартный класс yii\di\ServiceLocator, объектом которого является сам $app, компоненты которого мы дергаем через Yii::$app->get('db').

Также можно добавить возможность ручного указания ID:

$userDb = Yii::$app->dynamicLocator->get('db', $userId);

и оставить предыдущий вариант для переключения активного ID администратором:

Yii::$app->dynamicLocator->switchId($userId);
...
Yii::$app->dynamicLocator->switchId(null);

В конфигурации мы будем объявлять его один раз, передавая массив определяемых компонетов:

'components' => [
    'dynamicLocator' => [
        'class' => 'app\components\DynamicServiceLocator',
        'components' => [
            'db' => [
                'class' => 'yii\db\Connection',
                'dsn' => 'mysql:host=localhost;dbname=user_{id}',
                'username' => 'root',
                'password' => 'root',
                'charset' => 'utf8',
            ],
            'cache' => function ($id) {
                ...
            }
        ],
    ],
],

Теперь немного переработаем исходный код.

Наш прошлый код работал с компонентами и обрабатывал ID активного пользователя прямо в методе getActiveId():

class DynamicLocator
{
    ...
 
    private function getActiveId($id)
    {
        if ($id !== null ) {
            return $id;
        }
        if ($this->activeId === null) {
            $user = Yii::$app->get('user', false);
            if ($user && !$user->getIsGuest()) {
                $this->activeId = $user->getId();
            } else {
                $this->activeId = $this->defaultId;
            }
        }
        return $this->activeId;
    }
}

Если работа с компонентами неизменна и универсальна, то работа с пользователем может поменяться. Это может понадобиться, например, если в каком-то проекте отдельная база создаётся для компании, а не для каждого сотрудника. В этом случае строку:

$this->activeId = $user->getId();

придётся переписать на более продвинутую:

$this->activeId = $user->identity->company_id;

Как предоставить возможность переписывать данный фрагмент в разных проектах?

Если хотим повторно использовать свой компонент (или вообще выложить его на GitHub), то нужно предоставить себе и другим программистам возможность дорабатывать компонент без изменения его исходного кода.

Во-первых, можно использовать наследование. Для этого можно вместо private обозначить метод getActiveId как protected, чтобы в другом проекте можно было отнаследоваться с переопределением метода:

class MyDynamicLocator extends DynamicLocator
{
    protected function getActiveId() {
        ...
    }
}

и сменить класс в конфигурации:

'components' => [
    'dynamicLocator' => [
        'class' => 'app\components\MyDynamicLocator',
]

Но тогда и поле activeId нужно обозначать как protected. Да и весь метод:

protected function getActiveId($id)
{
    if ($id !== null ) {
        return $id;
    }
    if ($this->activeId === null) {
        ...
        else {
            $this->activeId = $this->defaultId;
        }
    }
    return $this->activeId;
}

переопределять при наследовании неудобно, так как мы хотим менять только одну строку, обозначенную здесь многоточием.

Наследование предоставляется простой сменой модификаторой доступа всех изменяемых внутренностей класса с private на protected. Делается быстро, но пользоваться этим неудобно, так как надо помнить, какие переменные вроде activeIdи defaultId в базовом классе есть и для чего они нужны. И при переименовании этого всего в базовом классе нужно переписывать и всех наследников.

Делается быстро, но использовать неудобно. Получаем слишком сильную связанность с внутренностями базового класса.

Можно эту строку вынести в какой-либо шаблонный protected-метод, который вызывать как $this->getOriginalActiveId($this->default) и переопределять в наследниках. Так уже будет удобнее.

Во-вторых, вместо наследования можно воспользоваться композицией. А именно, просто вынести изменяемый код метода getActiveId() и поле default в отдельный объект. И тогда этот код основного компонента:

class DynamicLocator extends Component
{
    public $defaultId;
 
    private $activeId;
 
    ...
 
    public function switchId($id)
    {
        return $this->activeId = $id;
    }
 
    private function getActiveId($id)
    {
        if ($id !== null ) {
            return $id;
        }
        if ($this->activeId === null) {
            $user = Yii::$app->get('user', false);
            if ($user && !$user->getIsGuest()) {
                $this->activeId = $user->getId();
            } else {
                $this->activeId = $this->defaultId;
            }
        }
        return $this->activeId;
    }
}

перепишется на такой:

class DynamicLocator extends Component
{
    ...
 
    public function switchId($id)
    {
        $this->forcedId = $id;
    }
 
    private function getCurrentId($id)
    {
        if ($id !== null ) {
            return $id;
        }
        if ($this->forcedId !== null ) {
            return $this->forcedId;
        }
        return $this->activeId->get();
    }
}

А от объекта activeId в данном случае понядобится только наличие метода get(). Этот факт можно обозначить интерфейсом:

interface ActiveIdInterface
{
    public function get();
}

Теперь достаточно будет передать любой класс, который будет реализовывать данный интерфейс. В стиле Yii2 он может выглядеть так:

class ActiveUserId extends Object implements ActiveIdInterface
{
    public $default = '';
 
    public function get()
    {
        $user = Yii::$app->get('user', false);
        if ($user && !$user->getIsGuest()) {
            return $user->getId();
        }
        return $this->default;
    }
}

или с использованием контейнера внедрения зависимостей:

class ActiveUserId implements ActiveIdInterface
{
    private $user;
    private $default;
 
    public function __construct(\yii\web\User $user = null, $default = '')
    {
        $this->user = $user;
        $this->default = $default;
    }
 
    public function get()
    {
        return $this->user && !$this->user->getIsGuest() ? $user->getId() : $this->default;
    }
}

Но будем в этот раз использовать стиль фреймворка, так как с другими фреймворками этот код использовать не собираемся.

Теперь мы сможем получать подключение для текущего пользователя внутри ActiveRecord:

class Post extends ActiveRecord
{
    public static function getDb()
    {
        return Yii::$app->get('dynamicLocator')->get('db');
    }
}

или для текущего или любого другого пользователя снаружи:

$currentUserDb = Yii::$app->dynamicLocator->get('db');
$anotherUserDb = Yii::$app->dynamicLocator->get('db', $userId);

И можем в нужный момент переключать текущего пользователя:

Yii::$app->dynamicLocator->switchId($otherUserId);
$post->save();
Yii::$app->dynamicLocator->switchId(null);

чтобы сохранение производилось в нужную базу данных.

Но в этом сейчас имеется несколько проблем. Они усугубляется при необходимости делать, например, вложенные операции. При этом нужно будет запомнить старый $oldId до операции и вернуть его на место после:

$oldId = $dynamicLocator->getActiveId();
$dynamicLocator->switchId($otherUserId);
$post->save();
$dynamicLocator->switchId($oldId);

Первая проблема – эстетическая: нужно всюду вписывать кучу постороннего кода.

Вторая – неатомарность. Вместо одной строки нужно поддерживать все три. И при этом нужно постоянно помнить о правильности их написания, иначе можно забыть или перепутать строки и переменные.

Третья – нарушение инкапсуляции. Теперь метод getActiveId() нужно делать публичным, чтобы все его могли дёргать для запоминания старого состояния.

Четвёртая – наличие глобальных переменных:

У нашего компонента имеется публичный метод switchId($id), позволяющий переключать внутреннее состояние компонента любому наружному коду. Такая общедоступная «глобальная переменная» может привести к проблеме любой общедоступности: в какой-то момент станет не очень понятно, кто и когда её переключил и кто забыл её «положить на место».

Для избежания последней проблемы хороший компонент не должен внутри себя хранить переключаемого снаружи состояния, чтобы разные модули сайта, которые его используют, не ломали работу друг друга хаотическими переключениями.

Но как быть, если всё-таки переключать нужно? В функциональном подходе это реализуется с помощью неизменяемых (Immutable) объектов. Для этого в методах set() и switchId() вместо изменения поля мы просто создаём новый объект-клон с другими $components и $id:

class DynamicLocator
{    
    private $components = [];
    private $activeId;
    private $forcedId;
 
    public function __construct(array $components, $forcedId, ActiveIdInterface $activeId)
    {
        $this->components = $components;
        $this->forcedId = $forcedId;
        $this->activeId = $activeId;
    }
 
    public function set($componentId, $definition)
    {
        $newComponents = array_merge($this->components, [$componentId => $definition]);
        return new self($newComponents, $this->activeId);
    }
 
    public function switchId($id)
    {
        return new self($this->components, $id, $this->activeId);
    }
 
    ...
}

Поля объекта задаются при конструировании и больше никогда не меняются. Вместо смены поля здесь через new self() создаётся новый объект с новыми данными. Теперь если разные модули приложения где-то у себя пробуют менять идентификатор, они получают свою копию и никак не влияют на другие модули, использующие свои отдельные клоны:

$locator = new DynamicLocator([], new ActiveUserId(Yii::$app->user, 0));
 
$locator2 = $Locator->set('db', [
    'username' => 'user_{id}',
]);
 
$locator3 = $Locator2->switchId(5);
 
echo $locator2->db->username; // user_1
echo $locator3->db->username; // user_5

Так можно наплодить нужное количество независимых друг от друга компонентов. Но зачем?

В обычном коде – незачем. Но если попробуете реализовать честную многопоточность в PHP7 с pthreads, где в консольном контроллере будете в нескольких потоках вызывать одновременно switchId() у одного и того же Yii::$app->locator, то увидите удивительную путаницу. Вместо этого можно либо каждому потоку дать свой независимый клон $locator, либо, что более корректно, избавиться от хранения состояния c «записывающим» методом switchId(), пользуясь только «читающим» методом get('db', $userId) для извлечения нужного подключения.

Итак, в функциональном подходе это реализуется доступом ко всему только на чтение, а с ActiveRecord такая многопоточность не реализуется. Мы не можем хранить несколько объектов подключения внутри разных извлечённых экземпляров $post, чтобы два вызова метода save():

$post1 = Post::find()->andWhere(['id' => 5])->one($db1);
$post2 = Post::find()->andWhere(['id' => 5])->one($db2);
 
$post1->save();
$post2->save();

записали объект в разные базы, так как $post1 и $post2 при сохранении дёргают один и тот же статический static::getDb():

class ActiveRecord extends BaseActiveRecord
{
    public static function getDb()
    {
        return Yii::$app->db;
    }
    ...
}

а всё статическое всегда имеется в общем единственном экземпляре. Именно при попытках сделать несколько вариаций одного и того же понимаешь, что синглтоны – зло.

Пожтому для подмены этого глобальной подключения мы и сделали некий первоначальный монструозный вариант:

$db1 = $dynamicLocator->get('db', $userId1);
$post1 = Post::find()->andWhere(['id' => 5])->one($db1);
...
$oldId = $dynamicLocator->getActiveId();
$dynamicLocator->switchId($userId1);
$post1->save();
$dynamicLocator->switchId($oldId);

Вторую такую конструкцию, как мы уже говорили, использовать неудобно. Можно ли все эти действия упростить и выполнять одним вызовом? И как избежать опечаток и забывчивости программиста?

Это всё можно упростить обрамлением нашего кода в блок:

$post = $dynamicLocator->doWith($userId, function () {
    return Post::findOne(5);
});
 
...
 
$dynamicLocator->doWith($userId, function () use ($post) {
    $post->save();
});

Пусть метод doWith() внутри себя переключает идентификатор, выполняет нашу функцию и после выполнения возвращает всё обратно:

public function doWith($id, callable $function)
{
    $oldId = $this->getCurrentId(null);
    $this->forcedId = $id;
    $result = call_user_func($function, $this);
    $this->forcedId = $oldId;
    return $result;
}

Это удобнее использовать и меньше вероятность что-то перепутать. И при этом можно удалить метод switchId, а метод getActiveId сделать приватным.

У нас не осталось глобального изменяемого снаружи через switchId($id) состояния внутри компонента. Мы можем не переживать, что кто-то его нечаянно изменит значение во вложенном коде и забудет вернуть его назад.

Для многопоточности такой хак не подойдёт, так как объект внутри всё-таки меняется при вызове doWork() и каждый поток будет мешать соседям. Но для однопоточного исполнения мы осуществили полную эмуляцию в рамках синглтонного статического метода getDb() в ActiveRecord.

Приступим теперь к реализации самого провайдера.

Похожесть со стандартным фреймворковским ServiceLocator даёт нам возможность использовать прямо этот класс как базу для нашего компонента. Отнаследуемся от него и переопределим методы get() и clear():

namespace app\components;
 
use Yii;
use yii\di\Instance;
use yii\di\ServiceLocator;
 
class DynamicServiceLocator extends ServiceLocator
{
    /**
     * @var ActiveIdInterface
     */
    public $activeId;
 
    private $forcedId;
 
    public function init()
    {
        $this->activeId = Instance::ensure($this->activeId, 'app\components\ActiveIdInterface');
        parent::init();
    }
 
    public function get($componentId, $id = null, $throwException = true)
    {
        $serviceId = $this->generateServiceId($componentId, $id);
        if (!parent::has($serviceId)) {
            parent::set($serviceId, $this->buildDefinition($componentId, $id));
        }
        return parent::get($serviceId, $throwException);
    }
 
    public function clear($componentId, $id)
    {
        parent::clear($componentId);
        parent::clear($this->generateServiceId($componentId, $id));
    }
 
    public function doWith($id, callable $function)
    {
        $oldId = $this->getCurrentId(null);
        $this->forcedId = $id;
        $result = call_user_func($function, $this);
        $this->forcedId = $oldId;
        return $result;
    }
 
    private function generateServiceId($componentId, $id)
    {
        return 'dynamic_' . $componentId . '_' . $this->getCurrentId($id);
    }
 
    private function buildDefinition($componentId, $id)
    {
        $definitions = $this->getComponents();
        if (!array_key_exists($componentId, $definitions)) {
            return null;
        };
        $definition = $definitions[$componentId];
        if (is_array($definition)) {
            $currentId = $this->getCurrentId($id);
            array_walk_recursive(
                $definition,
                function (&$value) use ($currentId) {
                    if (is_string($value)) {
                        $value = str_replace('{id}', $currentId, $value);
                    }
                }
            );
            return $definition;
        } elseif (is_object($definition) && $definition instanceof \Closure) {
            return Yii::$container->invoke($definition, [$this->getCurrentId($id)]);
        } else {
            return $definition;
        }
    }
 
    private function getCurrentId($id)
    {
        if (!empty($id)) {
            return $id;
        }
        if (!empty($this->forcedId)) {
            return $this->forcedId;
        }
        return $this->activeId->get();
    }
}

Внутри метода buildDefinition() мы не можем обращаться напрямую к определениям приватного массива родительского класса вроде $this->_components[$id], поэтому воспользовались вызовом метода getComponents(). И здесь мы реализовали полный обход определений из components через array_walk_recursive, чтобы уметь производить замену {id} даже во вложенных массивах.

Далее нам нужно приложить интерфейс детектора активного пользователя:

namespace app\components;
 
interface ActiveIdInterface
{
    public function get();
}

и его простейшую реализацию в рамках Yii2:

namespace app\components;
 
use Yii;
use yii\base\Object;
 
class ActiveUserId extends Object implements ActiveIdInterface
{
    public $default = '';
 
    public function get()
    {
        $user = Yii::$app->get('user', false);
        if ($user && !$user->getIsGuest()) {
            return $user->getId();
        }
        return $this->default;
    }
}

Далее подключаем наш провайдер в конфигурационном файле:

'components' => [
    'user' => [...],
    'db' => [...],
    ...
    'dynamicLocator' => [
        'class' => 'app\components\DynamicServiceLocator',
        'activeId' => [
            'class' => 'app\components\ActiveUserId',
            'default' => 0,
        ],
        'components' => [
            'db' => [
                'class' => 'yii\db\Connection',
                'dsn' => 'mysql:host=localhost;dbname=user_{id}',
                'username' => 'root',
                'password' => 'root',
                'charset' => 'utf8',
            ],
            'cache' => function ($id) {
                // ...
            }
        ],
    ],
],

и прописываем в классе Post:

class Post extends ActiveRecord
{
    public static function getDb()
    {
        return Yii::$app->get('dynamicLocator')->get('db');
    }
}

Теперь многопользовательский контроллер администратора можно переделать под вывод и редактирование записей любого выбранного пользователя (а не только собственные) простым обрамлением любого участка кода конструкцией doWith:

class PostController extends Controller
{
    public function actionIndex($user_id = null)
    {
        return Yii::$app->dynamicLocator->doWith($user_id, function () {
            $dataProvider = new ActiveDataProvider([
                'query' => Post::find()->with('category'),
            ]);
            return $this->render('index', [
                'dataProvider' => $dataProvider,
            ]);
        }
    }
 
    public function actionCreate($id, $user_id = null)
    {
        return Yii::$app->dynamicLocator->doWith($user_id, function () use ($id) {
            $model = new Post();
            if ($model->load(Yii::$app->request->post()) && $model->save()) {
                return $this->redirect(['view', 'id' => $model->id]);
            }
            return $this->render('create', [
                'model' => $model,
            ]); 
        }
    }
}

а все остальные контроллеры на сайте останутся неизменными. На этом динамический провайдер компонентов готов.

Другие статьи

Вчера после окончания интенсива провели бесплатный дополнительный урок с ответами на вопросы по ООП. Поговорили ещё раз об ответственностях контроллеров, немного про MVC, View Model и фреймворки, повторили критерии выноса кода в отдельные компоненты. Личных вопросов не было, поэтому с разрешения участников выкладываю запись для всеобщего просмотра.

Представляю наше главное событие осени. Снова запускаю уже многим полюбившийся супер-мега-интенсив по объектно-ориентированному программированию. Что в третьем потоке будет такого эпического и чем он будет отличаться от предыдущих?

Второй мастер-класс по функциональному программированию в PHP на примере написания многопоточного парсера активных участников форума. Рассмотрели базовую реализацию многопоточности в PHP и отличия от других языков программирования.

Комментарии

 

Ильдар Камалов

спасибо, Дмитрий!

Ответить

 

lynicidn

привет :) что за бред ты пишешь, какие динамические локаторы? что за базроутер ты создаешь? ты про фабрики слышал вообще? вот и своди все к Yii:$app->get('dbFactory')->create(Yii::$app->user);

class DbFactory extends Component
{
    public function create(User $user) 
    {
         //тут делаешь логику создания синглтонов баз данных юзеров
    }
}

и в любом месте можно достать базу

$db = Yii::$app->get('dbFactory')->create(User::findByLogin('admin'));
Ответить

 

Дмитрий Елисеев

> вот и своди все к Yii::$app->dbFactory->create(Yii::$app->user);

Всё свёдено к локатору Yii::$app->dbLocator->get(Yii::$app->user->id), который при необходимости дёргает фабрику createConnection($id) для создания подключений.

> и в любом месте можно достать базу по ->create(User::findByLogin('admin'));

И как этот код теперь использовать в ActiveRecord для $post->save()?

> ты про фабрики слышал вообще?

Штука, которая создаёт - это Factory с методом create.
Штука, которая хранит - это Registry с методами set и get.
Штука, которая создаёт и хранит - это Locator с get, дёргающий Factory::create.

Советовать хранить синглтоны в фабрике - это ...

Ответить

 

lynicidn

Yii::$app->dbLocator->get(Yii::$app->user->id),

это точно ооп? а то передается какоето число

Ответить

 

Дмитрий Елисеев

Передаётся скалярный идентификатор. Можете сделать VO, если простые числа и строки не любите.

Ответить

 

lynicidn

> который при необходимости дёргает фабрику createConnection($id) для создания подключений.

не много ли компонентов для одной логики?

Ответить

 

Дмитрий Елисеев

Кеширующий публичный метод get и приватный метод-фабрика create. Две логики - два метода.

Ответить

 

lynicidn

как то так

class UserDbFactory
{
    public function getConnection(\yii\web\IdentityInterface $user = null)
    {
        if ($user === null) { //guest
            return Yii::$app->db;
        }
        if (!isset($userDb[$user->id])) {
            $userDb[$user->id] = Yii::createObject(['class' => 'yii\db\Connection', [...]]);
        }
        return $userDb[$user->id];
    }
}
class MyAr extends \yii\db\ActiveRecord
{
    public static function getDb()
    {
        return Yii::$app->get('userDbFactory')->getConnection(Yii::$app->user->identity);
    }
}
Ответить

 

Дмитрий Елисеев

1) Ну и как администратор сделает $post->save() другого пользователя? И как это использовать в консоли?

2) UserDbFactory - это локатор, вызывающий фабрику Yii::createObject(...).

Ответить

 

lynicidn

1.вытащить с одной базы. подменить юзера и сохранить в другую?

Ответить

 

Дмитрий Елисеев

Понятно. Чукча не читатель.

Ответить

 

lynicidn

Понятно. Извращенцы на линии
мне вот только интересно ты понимаешь, что выборка считает бд со статики и 1 раз?
если уж тебе так хочется менять bd от момента выборки до момента сохранения, то делать это можно через статический сеттер

class myAr extends
{
    public function __construct($config = [])
    {
        Yii::$container->invoke([$this, 'setUser']);
        parent__construct($config);
    }

    public static function setUser(Identityinterface $user)...
}

прекращай нести херню в массы, придумывать шаблоны проектирования и учить говнокоду

Ответить

 

Дмитрий Елисеев

> мне вот только интересно ты понимаешь, что выборка считает бд со статики и 1 раз?

Понимаю. Не понимаю лишь к чему это высказывание здесь появилось.

> если уж тебе так хочется менять bd от момента выборки до момента сохранения

Опять чукча не читатель.

> то делать это можно через статический сеттер

С таким же успехом можно присваивать в статический Post::$db напрямую или через setDb().

> прекращай придумывать шаблоны проектирования

Вы здесь весьма дерзко фабрику с кешированием придумали. Видимо читаете всё также, как мои статьи.

Ответить

 

lynicidn

твои статьи читаю, так поржать, а напрямую задавать бд - это знать конекшн, что в моем случае спрятано за логикой установки юзера

так что не надо чукче, очкарика включать ;)

Ответить

 

Дмитрий Елисеев

Да запросто мой код используем по вашему способу:

pubic static function getDb()
{
    return Yii::$app->dynamicLocator->get('db', self::$identity->getId());
}

Смысл вашего эпоса-то в чём? Убедить меня переименовать Locator в Factory и использовать не всегда подходящий тип IdentityInterface вместо скаляра?

Ответить

 

slo_nik

Добрый вечер.

привет :) что за бред ты пишешь, какие динамические локаторы? что за базроутер ты создаешь?

Очень интересует Ваш бред по этому поводу. Озадачьте всех прочтением Вашей статьи, где Вы опишите, как Вы видите решение вопроса затронутого в этой статье.

Ответить

 

рома

Когда в одной статье описываешь как делать, потом тут же описываешь недостатки, потом улучшаешь пример, затем опять описываешь недостатки, становится трудно понять с какого момента начинается правильный вариант.

Ответить

 

slo_nik

Понять иногда сложно, но что сложного в том, чтобы перечитать непонятный абзац статьи и если опять не понял - спросить?

В предыдущих статьях Дмитрий описывал как сделать в gridview столбец для статуса, начал с простого варианта, потом перешёл к улучшению кода и закончил вполне рабочим вариантом для многократного использования. Разве это плохо?

Ответить

 

Иван

Поддерживаю. Подход так сказать со всех сторон. При этом полезность статьи зашкаливает. Люблю читать блог Дмитрия именно из-за такого подхода.

Ответить

Оставить комментарий

Войти | Завести аккаунт


(никто не увидит)



Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>