Об этом блоге

Об этом блоге

Добра тебе, читатель.
Это запасной аэродром Великого Позитроника.
А основная писанина — в тележеньке.

среда, 22 апреля 2020 г.

Очередной технофашизм (нет)

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


Предыстория: в проекте есть некоторая система пользовательских прав, довольно простая. Права определяются правилами, описывающими доступы (как это описание парсится и обрабатывается — неважно). Правила атомарны, но их можно объединять в роли; и роли и правила могут быть прикреплены к пользователю.

В какой-то момент (перед демонстрацией заказчику) обнаруживается, что новое, вот только что созданное правило, не применяется. Демка, впрочем, срывается по другой причине:




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

Строятся разные предположения (неправильно написанное правило? ошибка парсера в граничном случае? козни тёмных сил?), но достоверно удаётся выяснить одно: правило срабатывает сразу после очистки кеша приложения или работает всегда при отключённом кешировании.

Нахожу по документации фреймворка возможную причину: класс обработчика правил связан с 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 на генерацию временных таблиц в памяти — это уже отдельная тема, в которой я не так уж и силён. Вполне нормальное решение — оставить, как есть, занявшись оптимизациями выше: достаточно сделать обращение к этому запросу не столь частым, и станет совсем хорошо.

И домашнее задание: один разработчик добавил кеширование, чтобы избежать проблем, созданных другим разработчиком. Однако, он мог добиться того же результата не внося никаких правок в код (и, при этом, избежав проблем с устареванием кеша). Вопрос: как он мог это сделать?

Комментариев нет: