Ещё один служебный пост, который я покажу коллегам в качестве примера того, как не надо делать.
Предыстория: в проекте есть некоторая система пользовательских прав, довольно простая. Права определяются правилами, описывающими доступы (как это описание парсится и обрабатывается — неважно). Правила атомарны, но их можно объединять в роли; и роли и правила могут быть прикреплены к пользователю.
В какой-то момент (перед демонстрацией заказчику) обнаруживается, что новое, вот только что созданное правило, не применяется. Демка, впрочем, срывается по другой причине:
А мы начинаем копать, тратим время и силы и даже немножко спорим и ругаемся. Раньше такой проблемы не возникало, иначе кто-нибудь да заметил бы.
Строятся разные предположения (неправильно написанное правило? ошибка парсера в граничном случае? козни тёмных сил?), но достоверно удаётся выяснить одно: правило срабатывает сразу после очистки кеша приложения или работает всегда при отключённом кешировании.
Нахожу по документации фреймворка возможную причину: класс обработчика правил связан с url-генератором (ему нужно проверять, куда идёт запрос), а url-генератор по умолчанию использует какое-то кеширование. Как точно всё это связано — непонятно, но отключение этого поведения вроде бы чинит проблему.
Но это не решение, поскольку влияет на скорость, и по хорошему — надо разобраться. Возможно, достаточно будет сбрасывать нужную часть кеша при добавлении правил. Берусь копать, и довольно случайно добираюсь до кода, который должен получить набор прав по id пользователя:
Посмотрев историю изменений этого участка, вижу, что в кеш тело функции обернули совсем недавно, это отчасти объясняет, почему проблемы не было раньше. Не вспомнили же о нём, как о наиболее очевидной причине проблемы, скорее всего, потому, что подобные ужасы подсознание стремится исторгнуть из головы.
Кеширование втиснуто сюда явно чтобы хоть как-то избежать многократного исполнения запроса. Функция эта дёргается на каждый чих каждого пользователя (как можно сделать экономную проверку прав — тема отдельная), и закешировать права на час — понятное решение в условиях когда надо быстробляещёвчера.
Да, понятно, что это ведёт к задержке изменения прав пользователя, но куда большее зло в том, что кеширование маскирует настоящую проблему — невероятно плохой способ получения списка прав из базы данных.
Как бы поступил бы хороший разработчик: он бы воспользовался возможностями фреймворка, описав в ActiveRecord-моделях таблиц все связи между ними. В результате фреймворк построил бы близкий к оптимальному запрос на основании этих описаний. Более того: если бы при создании этих таблиц разработчик корректно указал им foreign keys, то генератор моделей и связи бы описал самостоятельно. Но если встретите такого разработчика — не спугните, это редкий вид, занесённый в красную книгу.
Как поступил бы средней руки кодер: сделал бы через ActiveRecord поиск в каждой из таблиц, а потом объединял результаты в array_merge(). Надо понимать, что "средний уровень" означает "наиболее часто встречающийся", а не "делающий норм".
Неопытный программист, не знающий фреймворк, написал бы сырой SQL-запрос, и почитав ответы на stackoverflow, делал бы запрос через $connection->createCommand("select * from...").
Но то, что написано здесь, находится за гранью добра и зла, а точнее — где-то в Восточной Бенгалии. Автор сих скорбных строк взял самые плохие стороны всех вышеперечисленных подходов и объединил их в этакого говнотрона.
Давайте же отринем брезгливость, и разберём им тут написанное.
Если перевести с тамильского на человеческий мысль, попытка выразить которую тут заложена, получится что-то вроде:
выбрать список прав из первой таблицы ($query1),
выбрать список прав из второй таблицы ($query2),
объединить результат (union()),
и из этого объединения выбрать список прав ещё раз, отфильтровав по уникальности (distinct()).
Способ, которым это сделано — ужасен. Во-первых, запросы написаны частично на SQL, а частично — на построителе запросов фреймворка. Это убивает все возможности навигации по коду: IDE не поймёт, какие таблицы тут задействованы, и не поймёт, что тут генерируется SQL-запрос, который можно бы было проанализировать. Да и человек не поймёт, если честно — читабельность никакая.
Во-вторых, SQL-запрос, который здесь генерируется, написан человеком, явно не понимающим, что такое реляционные базы данных. Тут, похоже, имеется нечто среднее между верой во всемогущество БД ("я как-нибудь объясню, чего я хочу, а движок всё за меня сделает лучшим образом") и похуизмом ("ну работает же!").
Сам получившийся запрос выглядит так:
Там, где есть union и вложенные выборки — там можно ожидать появления временных таблиц, что уже нехорошо. Но давайте взглянем на план выполнения запроса:
(для базового понимания признаков хуёвости достаточно ознакомиться с вот этой статьёй)
и статистику исполнения:
Три временные таблицы (одна на join, одна на union и одна на distinct), и 342 вставки в них. Вставки у запроса, которому нужно "просто спросить"!
Безусловно, один такой запрос БД не нагнёт. Но время его выполнения будет расти пропорционально количеству записей в таблицах (в геометрической прогрессии, если я правильно прикинул), а вызывается он, напомню, постоянно.
Думаю, это отличный пример того, как делать не надо; теперь можно ответить на вопрос "а как надо?".
Надо, как я и писал выше, указать связи между ActiveRecord-моделями задействованных таблиц (пользователи/правила/роли):
И через эти связи составить ActiveQuery-запрос:
Который фреймворк преобразует в SQL-запрос:
Профилирование этого запроса показывает куда более приятную картину:
Хотя и не идеальную: для фильтрации уникальных результатов через DISTINCT, одна временная таблица всё-таки создаётся. Попытаться решить это можно по разному: подумать над условием, изучить структуру таблиц и подобрать оптимальный JOIN, настроить сервер MySQL на генерацию временных таблиц в памяти — это уже отдельная тема, в которой я не так уж и силён. Вполне нормальное решение — оставить, как есть, занявшись оптимизациями выше: достаточно сделать обращение к этому запросу не столь частым, и станет совсем хорошо.
И домашнее задание: один разработчик добавил кеширование, чтобы избежать проблем, созданных другим разработчиком. Однако, он мог добиться того же результата не внося никаких правок в код (и, при этом, избежав проблем с устареванием кеша). Вопрос: как он мог это сделать?
Предыстория: в проекте есть некоторая система пользовательских прав, довольно простая. Права определяются правилами, описывающими доступы (как это описание парсится и обрабатывается — неважно). Правила атомарны, но их можно объединять в роли; и роли и правила могут быть прикреплены к пользователю.
В какой-то момент (перед демонстрацией заказчику) обнаруживается, что новое, вот только что созданное правило, не применяется. Демка, впрочем, срывается по другой причине:
А мы начинаем копать, тратим время и силы и даже немножко спорим и ругаемся. Раньше такой проблемы не возникало, иначе кто-нибудь да заметил бы.
Строятся разные предположения (неправильно написанное правило? ошибка парсера в граничном случае? козни тёмных сил?), но достоверно удаётся выяснить одно: правило срабатывает сразу после очистки кеша приложения или работает всегда при отключённом кешировании.
Нахожу по документации фреймворка возможную причину: класс обработчика правил связан с url-генератором (ему нужно проверять, куда идёт запрос), а url-генератор по умолчанию использует какое-то кеширование. Как точно всё это связано — непонятно, но отключение этого поведения вроде бы чинит проблему.
Но это не решение, поскольку влияет на скорость, и по хорошему — надо разобраться. Возможно, достаточно будет сбрасывать нужную часть кеша при добавлении правил. Берусь копать, и довольно случайно добираюсь до кода, который должен получить набор прав по id пользователя:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | public static function xSqlForRulesByUser($attribute, $id = null): array { $id = $id ?? Yii::$app->user->id; $result = Yii::$app->cache->get($id."_rules"); if (!$result) { $query1 = (new Query()) ->select("ru__1.$attribute") ->from('auth_rules ru__1') ->innerJoin('auth_roles_rules roru1', 'roru1.id_rule = ru__1.id') ->innerJoin('auth_user_roles usro1', 'usro1.role_id = roru1.id_role AND usro1.user_id = :u_id') ->addParams([':u_id' => $id]); $query2 = (new Query()) ->select("ru__2.$attribute") ->from('auth_rules ru__2') ->innerJoin('auth_user_rules usru2', 'usru2.id_rule = ru__2.id AND usru2.user_id = :u_id') ->addParams([':u_id' => $id]); $query1->union($query2); $result = (new Query()); $s = $result->select("tmp.$attribute") ->distinct() ->from(['tmp' => $query1]) ->column(); Yii::$app->cache->set($id."_rules", $s, 3600); return $s; } return $result; } |
Посмотрев историю изменений этого участка, вижу, что в кеш тело функции обернули совсем недавно, это отчасти объясняет, почему проблемы не было раньше. Не вспомнили же о нём, как о наиболее очевидной причине проблемы, скорее всего, потому, что подобные ужасы подсознание стремится исторгнуть из головы.
Кеширование втиснуто сюда явно чтобы хоть как-то избежать многократного исполнения запроса. Функция эта дёргается на каждый чих каждого пользователя (как можно сделать экономную проверку прав — тема отдельная), и закешировать права на час — понятное решение в условиях когда надо быстробляещёвчера.
Да, понятно, что это ведёт к задержке изменения прав пользователя, но куда большее зло в том, что кеширование маскирует настоящую проблему — невероятно плохой способ получения списка прав из базы данных.
Как бы поступил бы хороший разработчик: он бы воспользовался возможностями фреймворка, описав в ActiveRecord-моделях таблиц все связи между ними. В результате фреймворк построил бы близкий к оптимальному запрос на основании этих описаний. Более того: если бы при создании этих таблиц разработчик корректно указал им foreign keys, то генератор моделей и связи бы описал самостоятельно. Но если встретите такого разработчика — не спугните, это редкий вид, занесённый в красную книгу.
Как поступил бы средней руки кодер: сделал бы через ActiveRecord поиск в каждой из таблиц, а потом объединял результаты в array_merge(). Надо понимать, что "средний уровень" означает "наиболее часто встречающийся", а не "делающий норм".
Неопытный программист, не знающий фреймворк, написал бы сырой SQL-запрос, и почитав ответы на stackoverflow, делал бы запрос через $connection->createCommand("select * from...").
Но то, что написано здесь, находится за гранью добра и зла, а точнее — где-то в Восточной Бенгалии. Автор сих скорбных строк взял самые плохие стороны всех вышеперечисленных подходов и объединил их в этакого говнотрона.
Давайте же отринем брезгливость, и разберём им тут написанное.
Если перевести с тамильского на человеческий мысль, попытка выразить которую тут заложена, получится что-то вроде:
выбрать список прав из первой таблицы ($query1),
выбрать список прав из второй таблицы ($query2),
объединить результат (union()),
и из этого объединения выбрать список прав ещё раз, отфильтровав по уникальности (distinct()).
Способ, которым это сделано — ужасен. Во-первых, запросы написаны частично на SQL, а частично — на построителе запросов фреймворка. Это убивает все возможности навигации по коду: IDE не поймёт, какие таблицы тут задействованы, и не поймёт, что тут генерируется SQL-запрос, который можно бы было проанализировать. Да и человек не поймёт, если честно — читабельность никакая.
Во-вторых, SQL-запрос, который здесь генерируется, написан человеком, явно не понимающим, что такое реляционные базы данных. Тут, похоже, имеется нечто среднее между верой во всемогущество БД ("я как-нибудь объясню, чего я хочу, а движок всё за меня сделает лучшим образом") и похуизмом ("ну работает же!").
Сам получившийся запрос выглядит так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | SELECT DISTINCT `tmp`.`value` FROM ((SELECT `ru__1`.`value` FROM `auth_rules` `ru__1` INNER JOIN `auth_roles_rules` `roru1` ON roru1.id_rule = ru__1.id INNER JOIN `auth_user_roles` `usro1` ON usro1.role_id = roru1.id_role AND usro1.user_id = 1) UNION (SELECT `ru__2`.`value` FROM `auth_rules` `ru__2` INNER JOIN `auth_user_rules` `usru2` ON usru2.id_rule = ru__2.id AND usru2.user_id = 1)) `tmp` |
Там, где есть union и вложенные выборки — там можно ожидать появления временных таблиц, что уже нехорошо. Но давайте взглянем на план выполнения запроса:
(для базового понимания признаков хуёвости достаточно ознакомиться с вот этой статьёй)
и статистику исполнения:
Три временные таблицы (одна на join, одна на union и одна на distinct), и 342 вставки в них. Вставки у запроса, которому нужно "просто спросить"!
Безусловно, один такой запрос БД не нагнёт. Но время его выполнения будет расти пропорционально количеству записей в таблицах (в геометрической прогрессии, если я правильно прикинул), а вызывается он, напомню, постоянно.
Думаю, это отличный пример того, как делать не надо; теперь можно ответить на вопрос "а как надо?".
Надо, как я и писал выше, указать связи между ActiveRecord-моделями задействованных таблиц (пользователи/правила/роли):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | <?php declare(strict_types = 1); namespace app\models; use yii\db\ActiveQuery; use yii\db\ActiveRecord; use yii\helpers\ArrayHelper; /** * ... * * @property ActiveQuery|AuthRolesRules[] $relAuthRolesRules -- связь к набору прав, входящих в роль * @property ActiveQuery|AuthUserRoles[] $relAuthUserRoles -- набор ролей, в которые входит право * @property ActiveQuery|AuthUserRules[] $relAuthUserRules -- связь к таблице пользователей, которым назначено это право напрямую */ class AuthRules extends ActiveRecord { /*...*/ /** * @return AuthRolesRules[]|ActiveQuery */ public function getRelAuthRolesRules() { return $this->hasMany(AuthRolesRules::class, ['id_rule' => 'id']); } /** * @return AuthUserRoles[]|ActiveQuery */ public function getRelAuthUserRoles() { return $this->hasMany(AuthUserRoles::class, ['role_id' => 'id_role'])->via('relAuthRolesRules'); } /** * @return AuthUserRules[]|ActiveQuery */ public function getRelAuthUserRules() { return $this->hasMany(AuthUserRules::class, ['id_rule' => 'id']); } } |
И через эти связи составить ActiveQuery-запрос:
1 2 3 4 5 6 7 | AuthRules::find() ->distinct() ->select('value') ->joinWith(['relAuthUserRules directUsers', 'relAuthUserRoles rolesUsers']) ->where(['directUsers.user_id' => $userId]) ->orWhere(['rolesUsers.user_id'=> $userId]) ->all(); |
Который фреймворк преобразует в SQL-запрос:
1 2 3 4 5 6 7 8 9 10 11 | SELECT DISTINCT `value` FROM `auth_rules` LEFT JOIN `auth_user_rules` `directUsers` ON `auth_rules`.`id` = `directUsers`.`id_rule` LEFT JOIN `auth_roles_rules` ON `auth_rules`.`id` = `auth_roles_rules`.`id_rule` LEFT JOIN `auth_user_roles` `rolesUsers` ON `auth_roles_rules`.`id_role` = `rolesUsers`.`role_id` WHERE (`directUsers`.`user_id` = 1) OR (`rolesUsers`.`user_id` = 1) |
Профилирование этого запроса показывает куда более приятную картину:
Хотя и не идеальную: для фильтрации уникальных результатов через DISTINCT, одна временная таблица всё-таки создаётся. Попытаться решить это можно по разному: подумать над условием, изучить структуру таблиц и подобрать оптимальный JOIN, настроить сервер MySQL на генерацию временных таблиц в памяти — это уже отдельная тема, в которой я не так уж и силён. Вполне нормальное решение — оставить, как есть, занявшись оптимизациями выше: достаточно сделать обращение к этому запросу не столь частым, и станет совсем хорошо.
И домашнее задание: один разработчик добавил кеширование, чтобы избежать проблем, созданных другим разработчиком. Однако, он мог добиться того же результата не внося никаких правок в код (и, при этом, избежав проблем с устареванием кеша). Вопрос: как он мог это сделать?