Русский
Русский
English
Статистика
Реклама

Паттерны программирования

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

23.11.2020 18:14:08 | Автор: admin

Привет!


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


Несмотря на то, что я должен сделать оговорку,


Дисклеймер

Этот подход не подойдет в случаях:
1) Если вы пишете что-нибудь сверхбыстрое, и красивый код последнее, о чем думаете
2) Если ваши объекты никогда не используются дважды (например, беспрекословно соблюдается SRP)
3) Если вы настолько ненавидете свойства, что код их содержащий в ваших глазах покрывается блюром


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


TL;DR в самом низу.


Почему зло?


Приведу утрированный пример. Предположим, у нас есть неизменяемый рекорд Integer, определенный следующим образом:


public sealed record Integer(int Value);

У него есть одно свойство Value типа int. Теперь, нам понадобился следующий метод:


public sealed record Integer(int Value){    public Integer Triple() => new Integer(Value * 3);}

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


public int SomeMethod(Integer number){    var tripled = number.Triple();    if (tripled.Value > 5)        return tripled.Value;    else        return 1;}

Вместо того, что бы писать


public int SomeMethod(Integer number)    => number.Tripled > 5 ? number.Tripled.Value : 1;

Красивее, короче, читабельнее, безопаснее. Потенциально, оно также быстрее, если у нас к одному и тому же Tripled происходит обращение не только здесь.


Что нам хочется?


  1. Удобный дизайн кода для его пользователя. Например, я не хочу думать о кешировании при обращении к объекту, я просто хочу от него данные.
  2. Бесплатность обращения к свойству. Время я плачу только за первое обращение, и это никогда не хуже, чем вызов метода (обычно почти как обращение к полю по стоимости).
  3. Удобный дизайн кода для его разработчика. Разрабатывая новый immutable object, я не хочу оверрайдить конструктор, Equals и GetHashCode рекорда просто потому, что я добавил какое-то приватное поле для кеша, которое внезапно ломает мне все сравнения.

Я уже привел пример того, насколько удобнее свойства чем методы, в очень простом случае. А как разработчик объекта, я хочу писать так:


public sealed record Number(int Value){    public int Number Tripled => tripled.GetValue(@this => new Number(@this.Value * 3), @this);    private FieldCache<Number> tripled;}

А вот в жааве

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


А вот как пишут обычно:


Подход 1 (да зачем нам кеш?):


public sealed record Number(int Value){    public int Number Tripled => new Number(@this.Value * 3);}

(очень дорогой по очевидным причинам)


Подход 2 (используем Lazy<T>):


public sealed record Number : IEquatable<Number>{    public int Value { get; init; }  // приходится оверлоадить конструктор, поэтому выносим сюда    public int Number Tripled => tripled.Value;    private Lazy<Number> tripled;    public Number(int value)    {        Value = value;        tripled = new(() => value * 3);  // мы не можем это сделать в конструкторе поля, потому что на тот момент this-а еще не существует    }    // потому что Equals, который генерируется для рекордов, генерируется на основе полей, и поэтому наш Lazy<T> все сломает    public bool Equals(Number number) => Value == number.Value;    // то же самое с GetHashCode    public override int GetHashCode() => Value.GetHashCode();}

Как мы видим, очень сложно и неадекватно становится дизайнить наш объект. А что если там не одно кешируемое свойство, а несколько? За всем придется следить, включая все оверрайды.


Более того, у нас перестанет работать with, который клонирует все ваши поля, кроме указанного(-ых). Ведь он скопирует и ваше поле с Lazy, в котором будет лежать уже неверный кеш.


Подход 3 (используем ConditionalWeakTable):


public sealed record Number{    public Number Tripled => tripled.GetValue(this, @this => new Integer(@this.Value * 3));    private static ConditionalWeakTable<Number, Number> tripled = new();}

Наиболее адекватное решение среди прочих. Но для него придется писать обертку над ValueType так как ConditionalWeakTable принимает только референс-тип. Поэтому такая штука существенно медленнее, чем что-то подобное без оверхеда (по моему бенчмарку получается разница в, по меньшей мере, 6 раз, по сравнению с типом, о котором я расскажу).


Подход 4 (сразу посчитать):


public sealed record Number{    public int Value { get; init; }    public Number Tripled { get; }    public Number(int value)    {        Value = value;        Tripled = new Number { Value = value * 3 };    }}

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


Решение


  1. Итак, начнем с того, что наш ленивый контейнер будет структурой. Зачем лишний раз кучить мучу мучить кучу?
  2. Equals и GetHashCode всегда будут возвращать true и 0 соответственно. Это убивает смысл этих методов, но этот контейнер нам нужен только ради кеша, а значит сам по себе не должен влиять на результаты сравнения двух рекордов или получения хеша. Таким образом, мы не обязаны оверрайдить Equals и GetHashCode для каждого рекорда, пусть об этом думает Рослин.
  3. Допустим любой тип в качестве кешируемого. Лочить будем по холдеру, то есть тому, в ком объявлен наш кеш.
  4. Фабрика передается не в конструкторе, а в методе GetValue, по тому же принципу, как у ConditionalWeakTable. Тогда не придется создавать конструктор и писать спаггети-код, как мы это делали с Lazy<T>.
  5. Чтобы не сломать замечательную операцию with, вместо переменной initialized мы будем сравнивать holder, и в случае изменения референса запускаем фабрику снова.

Коду!


Для начала, так у нас выглядят поля и оверрайденные методы:


public struct FieldCache<T> : IEquatable<FieldCache<T>>{    private T value;    private object holder; // от этой штуки нам нужен ТОЛЬКО референс, смысла делать его generic нет    // как я уже говорил, сделано, чтобы рослиновский Equals не сломался от приватного поля    public bool Equals(FieldCache<T> _) => true;    public override int GetHashCode() => 0;}

И примитивная имплементация GetValue выглядит так:


public struct FieldCache<T> : IEquatable<FieldCache<T>>{        public T GetValue<TThis>(Func<TThis, T> factory, TThis @this) where TThis : class // record - это тоже класс. А ограничение нужно, чтобы тип был референсным        {            // если холдер изменился ИЛИ еще не записывался (например, если он - null)            if (!ReferenceEquals(@this, holder))                lock (@this)                {                    if (!ReferenceEquals(@this, holder))                    {                        // мы передаем в фабрику, потому что наш FieldCache нужен для случаев, когда какие-то кешируемые проперти зависят ТОЛЬКО от полей нашего самого холдера. Можно, конечно, и захватить в передаваемой лямбде, но тогда будет реаллокация каждый раз                        value = factory(@this);                        holder = @this;                    }                }            return value;        }}

Таким образом, мы можем себе позволить такой дизайн:


public sealed record Number(int Value){    public int Number Tripled => tripled.GetValue(@this => new Number(@this.Value * 3), @this);    private FieldCache<Number> tripled;}

Код очень короткий, и его можно найти на гитхабе.


Производительность


Единственное, что быстрее, чем наш наивный FieldCache это встроенный Lazy<T>.


Method Mean
BenchFunction 4,599.1638 ns
Lazy 0.6717 ns
FieldCache 3.6674 ns
ConditionalWeakTable 25.0521 ns

BenchFunction это какие-то сложные страшные вычисления, которые производились бы каждый раз при обращении к методу, поэтому мы хотим его кешировать. Другие три строчки занимают три разных подхода. Как видим, FieldCache<T> немного помедленнее, чем Lazy<T>.


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


Кратый TL;DR или выводы


Хотелка: ленивые кешируемые свойства неизменяемых объектов, зависящие от первичных свойств данных объектов
И известные существующие подходы, по всей видимости, не дают это красиво сделать, поэтому приходится писать свое.

Подробнее..

Recovery mode Пишем простую ORM с возможностью смены БД на лету

23.08.2020 20:09:46 | Автор: admin
image

Привет, Хабр! Карма слита из-за неосторожного комента под холиварной статьей, а значит нужно написать интересный (я надеюсь) пост и реабилитироваться.

Я несколько лет пользуюсь серверным telegram клиентом на php. И как многие пользователи устал от постоянного роста потребления памяти. Некоторые сессии могут занимать от 1 до 8 гигабайт RAM! Поддержка баз данных была уже давно обещана, но подвижек в этом направлении не было. Пришлось решать проблему самому :) Популярность open source проекта, накладывала интересные требования на pull request:

  1. Обратная совместимость. Все существующие сессии должны продолжить работать в новой версии (сессия это сериализованный инстанс приложения в файле);
  2. Свобода выбора БД. Возможность менять тип хранилища без потери данных и в любой момент, так как у пользователей разные конфигурации окружения;
  3. Расширяемость. Простота добавления новых типов баз данных;
  4. Сохранить интерфейс. Код приложения, работающий с данными, не должен меняться;
  5. Асинхронность. Проект использует amphp, поэтому все операции с базами должны быть неблокирующими;

За подробностями приглашаю всех под кат.


Что будем переносить



Большую часть памяти в MadelineProto занимают данные о чатах, пользователях и файлах. Например, в кэше пиров (peer), у меня насчитывается более 20 тысяч записей. Это все пользователи, которых когда либо видел аккаунт (включая участников всех групп), а так же каналы, боты и группы. Чем старше и активнее аккаунт, тем больше данных в будет в памяти. Это десятки и сотни мегабайт, и большая часть из них не используется. Но очищать кэш целиком нельзя, потому что телеграмм сразу будет жестко ограничивать аккаунт при попытках многократно получать одни и те же данные. Например, после пересоздания сессии на моем публичном демо сервере, телеграм в течении недели на большинство запросов отвечал ошибкой FLOOD_WAIT и ничего толком не работало. После того как кеш прогрелся все пришло в норму.

С точки зрения кода эти данные хранятся в виде массивов в свойствах пары классов.

Архитектура


Исходя из требований родилась схема:
  • Все тяжелые массивы заменяем на объекты реализующие ArrayAccess;
  • Для каждого типа базы данных создаем свои классы, наследующие базовый;
  • Обьекты создаются и записываются в свойства во время __consrtuct и __awake;
  • Абстрактная фабрика выбирает нужный класс для объекта, в зависимости от выбранной базы данных в настройках приложения;
  • Если в приложении уже есть другой тип хранилища, то считываем оттуда все данные и записываем массив в новое хранилище.


Проблемы асинхронного мира


Перым делом я создал интерфейсы и класс для хранения массивов в памяти. Это был вариант по умолчанию, идентичный по поведению старой версии программы. В первый вечер я был очень воодушевлен успехами прототипа. Код получался красивый и простой. Пока не обнаружилось, что нельзя использовать генераторы внутри методов интерфейса Iterator и внутри методов отвечающих за unset и isset.

Тут нужно пояснить, что amphp использует синтаксис генераторов для реализации асинхронности в php. Yield становится аналогом async await из js. Если какой-то метод использует асинхронность, то что бы получить из него результат, нужно дождаться этого результата в коде с помощью yield. Например:

<?phpinclude 'vendor/autoload.php';$MadelineProto = new \danog\MadelineProto\API('session.madeline');$MadelineProto->async(true);$MadelineProto->loop(function() use($MadelineProto) {    $myAsyncFunction = function() use($MadelineProto): \Generator {        $me = yield $MadelineProto->start();        yield $MadelineProto->echo(json_encode($me, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));    };    yield $myAsyncFunction();});


Если из строки
yield $myAsyncFunction();
убрать yield, то приложение будет завершено до того, как этот код будет выполнен. Мы не получим результат.

Добавить yield перед вызовом методов и функций не очень сложно. Но так как используется интерфейс ArrayAccess, то методы не вызываются напрямую. Например, unset() вызывает метод offsetUnset(), а isset() offsetIsset(). Аналогичная ситуация с итераторами типа foreach при использовании интерфейса Iterator.

Добавление yield перед встроенными методами вызывает ошибку, так как эти методы не предназначены для работы с генераторами. Пришлось пойти на компромис и переписать код на использование собственных методов. К счастью таких мест было очень мало. В большинстве случаев массивы использовались для чтения или записи по ключу. Этот функционал с генераторами отлично подружился.

Получился такой интерфейс:

<?phpuse Amp\Producer;use Amp\Promise;interface DbArray extends DbType, \ArrayAccess, \Countable{    public function getArrayCopy(): Promise;    public function isset($key): Promise;    public function offsetGet($offset): Promise;    public function offsetSet($offset, $value);    public function offsetUnset($offset): Promise;    public function count(): Promise;    public function getIterator(): Producer;    /**     * @deprecated     * @internal     * @see DbArray::isset();     *     * @param mixed $offset     *     * @return bool     */    public function offsetExists($offset);}


Примеры работы с данными

<?php...//Чтение$existingChat = yield $this->chats[$user['id']];//Запись. yield $this->chats[$user['id']] = $user;//Можно использовать без yield, тогда мы не ждем записи в базу и код выполняется дальше.$this->chats[$user['id']] = $user;//unsetyield $this->chats->offsetUnset($id);//foreach$iterator = $this->chats->getIterator();while (yield $iterator->advance()) {    [$key, $value] = $iterator->getCurrent();    //обрабатываем элементы массива}


Хранение данных



Самый простой способ хранить данные в сериализованном виде. От использования json пришлось отказаться ради поддержки объектов. У таблицы две основных колонки: ключ и значение.

Пример sql запроса на создание таблицы:
            CREATE TABLE IF NOT EXISTS `{$this->table}`            (                `key` VARCHAR(255) NOT NULL,                `value` MEDIUMBLOB NULL,                `ts` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,                PRIMARY KEY (`key`)            )            ENGINE = InnoDB            CHARACTER SET 'utf8mb4'             COLLATE 'utf8mb4_general_ci'


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

Так как primary ключ без автоинкремента, то и вставку и обновление данных можно делать одним запросом, как в обычном массиве:

INSERT INTO `{$this->table}`             SET `key` = :index, `value` = :value             ON DUPLICATE KEY UPDATE `value` = :value


На каждую переменную создается таблица с именем в формате %id_аккаунта%_%класс%_%имя_переменной%. Но при первом старте приложения никакого аккаунта еще нет. В таком случае приходится генерировать случайный временный id с префиксом tmp. При каждом запуске класс каждой переменной проверяет не появился ли id аккаунта. Если id есть, то таблицы будут переименованы.

Индексы


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

Например, есть массив/таблица chats. Ключ в ней это id чата. Но часто приходится искать по username. Когда приложение хранило данные в массивах, то поиск по username осуществлялся обычным перебором массива в foreach. Такой перебор работал с приемлемой скоростью в памяти, но не в базе. Поэтому была создана еще одна таблица/массив и соответствющее свойство в классе. В ключе username, в значении id чата. Единственный минус такого подхода приходится писать дополнительный код для синхронизации двух таблиц.

Кэширование


Локальная mysql работает быстро, но немного кеширования никогда не помешает. Особенно если одно и тоже значение используется несколько раз подряд. Например, сначала проверяем наличие чата в базе, а потом достаем из него какие-нибудь данные.
Был написан простенький велосипед trait.

<?phpnamespace danog\MadelineProto\Db;use Amp\Loop;use danog\MadelineProto\Logger;trait ArrayCacheTrait{    /**     * Values stored in this format:     * [     *      [     *          'value' => mixed,     *          'ttl' => int     *      ],     *      ...     * ].     * @var array     */    protected array $cache = [];    protected string $ttl = '+5 minutes';    private string $ttlCheckInterval = '+1 minute';    protected function getCache(string $key, $default = null)    {        $cacheItem = $this->cache[$key] ?? null;        $result = $default;        if (\is_array($cacheItem)) {            $result = $cacheItem['value'];            $this->cache[$key]['ttl'] = \strtotime($this->ttl);        }        return $result;    }    /**     * Save item in cache.     *     * @param string $key     * @param $value     */    protected function setCache(string $key, $value): void    {        $this->cache[$key] = [            'value' => $value,            'ttl' => \strtotime($this->ttl),        ];    }    /**     * Remove key from cache.     *     * @param string $key     */    protected function unsetCache(string $key): void    {        unset($this->cache[$key]);    }    protected function startCacheCleanupLoop(): void    {        Loop::repeat(\strtotime($this->ttlCheckInterval, 0) * 1000, fn () => $this->cleanupCache());    }    /**     * Remove all keys from cache.     */    protected function cleanupCache(): void    {        $now = \time();        $oldKeys = [];        foreach ($this->cache as $cacheKey => $cacheValue) {            if ($cacheValue['ttl'] < $now) {                $oldKeys[] = $cacheKey;            }        }        foreach ($oldKeys as $oldKey) {            $this->unsetCache($oldKey);        }        Logger::log(            \sprintf(                "cache for table:%s; keys left: %s; keys removed: %s",                $this->table,                \count($this->cache),                \count($oldKeys)            ),            Logger::VERBOSE        );    }}


Особое внимание хочется обратить на startCacheCleanupLoop. Благодаря магии amphp инвалидация кеша максимально простая. Коллбэк запускается по указанному интервалу, проходит по всем значениям и сморит на поле ts, в котором хранится timestamp последнего обращения к этому элементу. Если обращение было более 5 минут назад (конфигурируется в настройках), то элемент удаляется. C помощью amphp очень легко реализовать аналог ttl из redis или memcache. Все это происходит в фоне и не блокирует основной поток.

C помощью кеша и асинхронности ускоряется не только чтение, но и запись.

Вот исходный код метода, записывающего данные в базу.
/**     * Set value for an offset.     *     * @link https://php.net/manual/en/arrayiterator.offsetset.php     *     * @param string $index <p>     * The index to set for.     * </p>     * @param $value     *     * @throws \Throwable     */    public function offsetSet($index, $value): Promise    {        if ($this->getCache($index) === $value) {            return call(fn () =>null);        }        $this->setCache($index, $value);        $request = $this->request(            "            INSERT INTO `{$this->table}`             SET `key` = :index, `value` = :value             ON DUPLICATE KEY UPDATE `value` = :value        ",            [                'index' => $index,                'value' => \serialize($value),            ]        );        //Ensure that cache is synced with latest insert in case of concurrent requests.        $request->onResolve(fn () => $this->setCache($index, $value));        return $request;    }


$this->request создает Promise, который записывает данные асинхронно. А операции с кешом происходят синхронно. То есть можно не дожидаться записи в базу и при этом быть уверенным, что операции чтения сразу начнут возвращать новые данные.

Очень полезным оказался метод onResolve из amphp. После завершения вставки данные будут еще раз записаны в кэш. Если какая-то операция записи запоздает и кэш и база начнут отличаться, то кэш обновится значением записанным в базу последним. Т.е. наш кэш снова станет консистентен с базой.

Исходный код



Ссылка на pull request: github.com/danog/MadelineProto/pull/808

А вот так просто другой пользователь добавил поддержку postgre: github.com/danog/MadelineProto/pull/846.
Потребовалось всего 5 минут, что бы написать инструкцию для него: github.com/danog/MadelineProto/pull/808#issuecomment-666344285

Количество кода можно было бы сократить, если вынести дублирующиеся методы в общий абстрактный класс SqlArray.

One more thing



Было замечено, что во время скачивания медиафайлов из telegram стандартный garbage collector php не справляется с работой и куски файла остаются в памяти. Обычно размер утечек совпадал с размером файла. Возможная причина: garbage collector автоматически срабатывает, когда накапливается 10 000 ссылок. В нашем случае ссылок было мало (десятки), но каждая могла ссылаться на мегабайты данных в памяти. Изучать тысячи строк кода с реализацией mtproto было очень лень. Почему бы сначала не попробовать элегантный костыль с \gc_collect_cycles();?

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

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

<?phpnamespace danog\MadelineProto\MTProtoTools;use Amp\Loop;use danog\MadelineProto\Logger;class GarbageCollector{    /**     * Ensure only one instance of GarbageCollector     * when multiple instances of MadelineProto running.     * @var bool     */    public static bool $lock = false;    /**     * How often will check memory.     * @var int     */    public static int $checkIntervalMs = 1000;    /**     * Next cleanup will be triggered when memory consumption will increase by this amount.     * @var int     */    public static int $memoryDiffMb = 1;    /**     * Memory consumption after last cleanup.     * @var int     */    private static int $memoryConsumption = 0;    public static function start(): void    {        if (static::$lock) {            return;        }        static::$lock = true;        Loop::repeat(static::$checkIntervalMs, static function () {            $currentMemory = static::getMemoryConsumption();            if ($currentMemory > static::$memoryConsumption + static::$memoryDiffMb) {                \gc_collect_cycles();                static::$memoryConsumption = static::getMemoryConsumption();                $cleanedMemory = $currentMemory - static::$memoryConsumption;                Logger::log("gc_collect_cycles done. Cleaned memory: $cleanedMemory Mb", Logger::VERBOSE);            }        });    }    private static function getMemoryConsumption(): int    {        $memory = \round(\memory_get_usage()/1024/1024, 1);        Logger::log("Memory consumption: $memory Mb", Logger::ULTRA_VERBOSE);        return (int) $memory;    }}
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru