Pull to refresh
61.51
Делаем средства разработки бизнес-приложений

Как мы работаем с ресурсами в веб-приложениях

Reading time14 min
Views5.9K

Приложения, созданные на платформе 1С:Предприятие, могут быть развернуты в трёхзвенной архитектуре (Клиент – Сервер приложений – СУБД). Клиентская часть приложения может работать, в частности, как веб-клиент, в браузере. Веб-клиент – это довольно сложный фреймворк на JavaScript, отвечающий за отображение пользовательского интерфейса и исполнение клиентского кода на встроенном языке. Одна из задач, которая стояла перед нами в ходе разработки веб-клиента – это корректная работа с различного рода ресурсами (в первую очередь – их своевременное освобождение).

Мы провели анализ существующих подходов и хотим рассказать вам об этом.

Рассмотрим, какие ресурсы требуется аккуратно контролировать и оптимизировать:

  • Подписки на прикладные события внутри самого приложения, например, подписка для обновления списков при обновлении элементов, и т.д.

  • Различные браузерные API

    • MutationObserver и ResizeObserver. Эти инструменты предоставляют возможность наблюдать и реагировать на изменения в DOM-дереве и размерах элементов. Но их правильное использование требует усилий по освобождению ресурсов после завершения наблюдения.

    • WebSocket

    • И просто подписки на браузерные события

  • Некоторые ресурсы могут существовать как на клиентской, так и на серверной стороне. Например, когда на сервере сохраняется какое-то состояние, соответствующее клиенту:

    • Выборка из базы данных, поиск

    • Блокировки объектов

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

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

Блокировка редактирования тоже является ресурсом, хранящимся на сервере, который надо своевременно освобождать.
Блокировка редактирования тоже является ресурсом, хранящимся на сервере, который надо своевременно освобождать.

Основные способы управления ресурсами

Существует несколько фундаментальных стратегий управления ресурсами, ниже рассмотрим их подробнее:

  • Полностью ручной вызов dispose() / unsubscribe() / close()

  • Использование реактивности (Mobx и аналоги)

  • Счётчик ссылок

  • Сборщик мусора

  • FinalizationRegistry

Ручное управление ресурсами

Это, пожалуй, самый простой (но иногда и очень трудоемкий) способ управления ресурсами. Он позволяет полностью контролировать время жизни ресурса. В React, как правило, захват ресурса помещается в componentDidMount/componentWillUnmount или используется useEffect() в случае функциональных компонентов.

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

Использование реактивности

Реактивность, предоставляемая такими библиотеками как Mobx или $mol, открывает новые перспективы и позволяет разрешить значительную часть сложностей, связанных с управлением ресурсами в веб-приложениях. Во многих случаях ресурсы необходимы исключительно текущему «живому» компоненту, и их время жизни эффективно управляется используемым фреймворком или библиотекой.

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

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

Давайте рассмотрим пример использования хелпера fromResource из библиотеки Mobx. Этот хелпер предоставляет удобный способ создания ресурса, который существует только в течение времени, пока он активно используется в наблюдаемом контексте, например, в отрендеренном компоненте.

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

Функция fromResource принимает три параметра:

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

  • Функция освобождения

  • Начальное значение

В примере ниже создается объект, возвращающий текущую дату. Эта дата будет автоматически обновляться благодаря setInterval-у; интервал будет автоматически очищаться, когда он больше не нужен.

//упрощенная реализация now() из mobx-utils
function makeTimer(): IResource<number>    {
    let timerId = -1;
    
    return fromResource(
        (udpateTimer) => {
            updateTimer(Date.now());
            timerId = setInterval(() => { updateTimer(Date.now()); }, kUpdateInterval);
        },
        () => { clearInterval(timerId); },
        Date.now()
    );
}

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

@observer
class Timer extends React.Component    {
    private timer = makeTimer();

    public render()    {
        return <span>
            {this.timer.current()}
        </span>;
    }
}

Процесс работы может быть описан следующим образом:

  1. Компонент активируется и начинает рендерить свое содержимое. Внутри метода render() компонента происходит получение значения ресурса (this.timer.current()), и таймер начинает тикать.

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

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

Счетчик ссылок

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

Плюсы:

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

  • Гибкость: собственная реализация счетчика ссылок и оберток дает возможность создавать более сложные и специфические сценарии управления ресурсами. Можно реализовывать сложные сценарии, например, не копировать объект, если ссылка на него была последней и теряется после выполнения операции.

Минусы:

  • Использование объектов-оберток требует больше рутины и дополнительного кода. Это может увеличить объем работы при разработке и поддержке приложения.

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

Сборщик мусора

Самый простой вариант реализации сборщика мусора выглядит примерно так:

  • При каждом создании ресурса он регистрируется в глобальном реестре ресурсов. Это позволяет отслеживать все созданные ресурсы в приложении.

  • В определенный момент времени приложение запускает процесс проверки "достижимости" ресурсов. На этом этапе ресурсы помечаются как недостижимые.

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

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

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

Рассмотрим этот подход на примере простого веб-приложения из двух страниц – страницы презентации (слайдов) и страницы пользователя.

Страница презентаций содержит массив презентаций из двух презентаций – SysDevCon.pptx и SysDevConBackup.pptx. Эти презентации и являются ресурсами, которые надо своевременно освобождать.

Заведем специальное перечисление ResourceState для хранения состояния ресурса (используемый / неиспользуемый). Каждый ресурс реализует интерфейс IResource и имеет методы для установки состояния, получения состояния и освобождения.

Каждый объект, который может хранить ресурс, реализует интерфейс IResourceHolder, позволяющий пометить ресурс как используемый. Приложение состоит из двух страниц и в своем методе markResources() вызывает соответствующие методы у каждой страницы.

Страница презентаций вызывает markResources() для каждого своего файла.

Глобальный реестр ресурсов ResourceRoot позволяет создавать файлы путем вызова метода createFile; при этом файл будет занесен в глобальный список ресурсов.

Метод collectUnusedResources() освобождает неиспользуемые ресурсы, его примерная реализация приведена в исходнике ниже. Вначале все ресурсы помечаются как неиспользуемые, после чего происходит вызов метода markResources() от всех точек входа в приложение. Этот метод должен рекурсивно пометить все ресурсы как используемые, после чего все ресурсы, которые отмечены как неиспользуемые (не отметились как используемые в ходе обхода из всех точек входа в приложение), удаляются.

// Состояние ресурса - используемый или нет.
enum ResourceState {
    Unused = 0,
    Used = 1
}

// Все объекты, требующие контроля за временем жизни должны реализовывать этот интерфейс. 
interface IResource {
    // Метод освобождения ресурсов, его вызывает сборщик мусора, когда обнаруживает, что ресурс не достижим из всех точек входа в приложение
    dispose(): void;

    // Получения и установка состояние ресурса
    setSate(state: ResourceState): void;
    getState(): ResourceState;
}


// А этот интерфейс реализуют все точки входа в приложения, все контейнеры, все коллекции, и т.д.
interface IResourceHolder    {
    markResources(): void;
}


/* Страница */
abstract class Page implements IResourceHolder    {
    public abstract markResources(): void;
}

/* Приложение */
class App implements IResourceHolder    {
    private presentations!: Page;
    private users!: Page;

    public markResources(): void    {
        // Приложение является точкой входа и владеет страницам презентаций и пользователей
        this.presentations.markResources();
        this.users.markResources();
    }
}

class PresentationPage extends Page    {
    // Страница презентаций владеет файлами, которые и являются ресурсами
    private files: IResource[] = [];

    public markResources(): void    {
        for (const file of this.files)    {
            files.setState(ResourceState.Used);
        }
    }
}

class ResourceRoot    {
    private allResources: IResource[] = [];
    private app!: App;
    
    // Всё создание ресурсов проходит через глобальный реестр, либо ресурсы должны в нём регистрироваться
    public createFile(name: string): IResource    {
        const file = new File(name);
        this.allResources.push(file);
        return file;
    }

    public collectUnusedResources(): void 
    {
        // Шаг 1: маркируем все ресурсы как неиспользуемые
        for (const res of this.allResources)    {
            res.setState(ReosurceState.Unused);
        }

        // Шаг 2: проходим по всем точкам входа в приложение, в данном примере это только само приложение, и вызываем маркировку ресурсов, которые достижимы из точек входа
        this.app.markResources();

        // Шаг 3: проверяем, какие из ресурсов остались не отмеченными как Используемые. Все такие ресурсы можно удалять
        for (const res of this.allResources)    {
            if (res.getState() != ResourceState.Used)    {
                res.dispose();
            }
        }
    }
}

Посмотрим, что будет, если второй файл SysDevConBackup.pptx будет удалён из массива презентаций.

Рекурсивный обход достижимости не сможет отметить его как “Используемый”, и на шаге 3 система вызовет у него res.dispose().

FinalizationRegistry

FinalizationRegistry - это современное браузерное API, предназначенное для управления временем жизни объектов и ресурсов в JavaScript-приложениях. С помощью FinalizationRegistry можно зарегистрировать объекты, для которых будут автоматически вызван коллбек для финализации ресурсов, когда на них больше не остается сильных ссылок.

FinalizationRegistry взаимодействует с WeakRef, который представляет собой "слабую ссылку" на объект. Слабые ссылки не удерживают объект в памяти, и если на объект нет сильных ссылок, то он подлежит сборке мусора.

На данный момент это API реализовано в большинстве популярных браузеров.

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

Данный сервис имеет методы подписки и отписки на события обновления какой-либо из сущностей.

abstract class EntityNotifyService {
    public static INSTANCE: EntityNotifyService;

    private listeners: Set<((event: Event) => void)> = new Set();

    public subscribeListUpdate(listener: (event: Event) => void    {
        this.listeners.add(listener);
    }

    public unsubscribeListUpdate(listener: (event: Event) => void): void    {
        this.listeners.delete(listener);
    }
}

Класс DynamicList, отображающий списки сущностей, использует этот сервис: в конструкторе он подписывается на обновление, в методе dispose() – отписывается. В данном случае надо всегда вызывать метод dispose(), чтобы избежать утечек памяти:

class DynamicList {
    public constructor() {
        EntityNotifyService.INSATNCE.subscribeListUpdate(this.listener)
    }

    public dispose {
        EntityNotifyService.INSATNCE.unsubscribeListUpdate(this.listener)
    }

    private listener = () => {
        this.refreshList()
    }
}

Так может выглядеть использование сервиса и объекта DynamicList:

В методе componentDidmount() создается объект DynamicList, в методе componentWillUnmount() надо не забыть вызвать list.stop(), метод render() отображает данные, полученные из этого объекта.

@observer
class ListComponent extends React.Component    {
    private list!: DynamicList;

    /** @override */
    public componentDidMount() {
        this.list = new DynamicList();
    }

    /** @override */
    public componentWillUnmount() {
        this.list.stop();
    }

    public render() {
        return <span>
            {this.list.getData()}
        </span>;
    }
}

В случае использования функциональных компонентов все делается примерно так же, используется useEffect, в котором создается list, завершается вызов очисткой, где вызывается метод stop().

useEffect(() => {
    list.current = new DynamicList();

    return () =>    {
        list.current?.stop();
    }
}, []);

На рисунке ниже показан граф ссылок на объекты.

Сервис EntityNotifyService хранит ссылку на подписчика, подписчик через замыкание имеет ссылку на класс DynamicList, который содержит обратную ссылку на подписчика. Если вызвать метод dispose, то будет разорвана связь между сервисом и подписчиком, в результате чего объект DyanamicList будет утилизирован сборщиком мусора, так как на него не останется других ссылок.

Давайте посмотрим, как FinalizationRegistry может упростить этот процесс, устраняя необходимость вручную вызывать метод dispose().

Рассмотрим класс WeakRef. Он включает в себя два метода: первый - конструктор, принимающий объект, и второй - метод deref(), который возвращает сам объект или undefined в случае, если объект был удален сборщиком мусора.

declare class WeakRef<T extends object> {
    constructor(target?: T);

    /** возвращает объект target, или undefined, если target был собран сборщиком мусора*/
    deref(): T | undefined;
}

WeakRef не создает жестких ссылок на объекты, и поэтому объект target может быть удален сборщиком мусора если на него не осталось жестких ссылок.

Мы будем использовать WeakRef для сохранения ссылки на подписчика. Создавая слабую ссылку на объект подписчика listener, мы позволяем сборщику мусора удалять объект listener, если на него больше нет других активных ссылок. Когда происходит событие, на которое listener подписывался, мы просто вызываем метод deref() у слабой ссылки. Если
объект все еще существует в памяти, deref() вернёт ссылку на него и мы можем успешно вызвать обработчик.

abstract class EntityNotifyService    {
    public static INSTANCE: EntityNotifyService;

    private listeners: Set<((event: Event) => void)> = new Set();

    public subscribeListUpdate(listener: (event: Event) => void): void    {
        const weakListener = new WeakRef(listener);
        this.listeners.add((event) =>    {
            weakListener.deref()?.(event);
        });        
    }

    public unsubscribeListUpdate(listener: (event: Event) => void): void    {
        this.listeners.delete(listener);
    }
}

Ниже – граф ссылок для этого нового варианта.

Обратите внимание, что стрелка от WeakRef к Listener – пунктирная, это означает, что ссылка слабая, и если на DynamicList не осталось ссылок – он может быть собран сборщиком мусора:

После этого WeakRef.unref() начнет возвращать udefined, но при этом возникает проблема: сам WeakRef остается в массиве подписчиков, и хорошо бы его оттуда удалить.

Для этих целей как раз и служит FinalizationRegistry. Рассмотрим его интерфейс:

declare class FinalizationRegistry    {
    constructor(cleanupCallback: (heldValue: any) => void);
    
    /** Регистрирует объект в регистре
    Параметры: target – целевой объект
               heldValue – значение, которое будет передано в финализатор
               unregisterToken – токен, с помощью которого можно отменить регистрацию */
    register(target: object, heldValue: any, unregisterToken?: object): void;

    /** Разрегистрирует объект по переданному токену
     *  Параметры: unregisterToken – токен, который был указан при регистрации
     */
    unregister(unregisterToken: object): void;
}

В конструкторе FinalizationRegistry передаётся специальная функция очистки, которая будет вызываться после того, как объект будет собран сборщиком мусора. Для того, чтобы система начала отслеживать время жизни объекта надо вызвать функцию register(), куда передаётся три параметра: целевой объект, специальное значение, которое будет передано в функцию очистки и токен, с помощью которого можно отменить подписку на время жизни этого объекта.

Вот как можно использовать это в нашем сервисе: мы создаем FinalizationRegistry, который в своем методе очистки будет вызывать отписку от события обновления списка. В FinalizationRegistry мы следим за обработчиком listener, поэтому, когда он будет уничтожен (когда его соберёт сборщик мусора), вызовется метод очистки.

weakWrapper позволяет не хранить жестко ссылки на listener, чтобы объект listener мог быть уничтожен и собран сборщиком мусора.

abstract class EntityNotifyService    {
    
    public listenersRegistry = new FinalizationRegistry((listeners) => {
        this.unsubscribeLstUpdate(listener);
    });

    public subscribeListUpdate(listener: (event: Event) => void): void {
        const weakListener = new WeakRef(listener);
        const weakWrapper = (event: Event) => {
            weakListener.deref()?.(event);
        }
        // В качестве heldValue указываем weakWrapper, который мы и будем удалять из списка подписчиков
        this.listenersRegistry.register(listener, weakWrapper);
        this.listeners.add(weakWrapper);
    }
}

В результате полностью отпала необходимость следить за временем жизни объекта DynamicList. Как только React удалит объект компонента, в котором использовался DynamicList, сборщик мусора сможет собрать его, т.к. на него больше нет никаких ссылок. Наш FinalizationRegistry узнает об этом и вызовет функцию отписки у сервиса.

@observer
class ListComponent2 extends React.Component {
    private list!: DynamicList = new DynamicList();;

    public render() {
        return <span>
            {this.list.getData()}
        </span>;
    }
}

Ограничения FinalizationRegistry

У FinalizationRegistry есть ограничения:

  • FinalizationRegistry поддерживает только объекты. Нельзя использовать его для отслеживания удаления не объектных типов данных, таких, как строки.

  • Значение heldValue не может совпадать с самим объектом, так как на heldValue создается жесткая ссылка.

  • unregisterToken также должен быть объектом. Если его не указать, то невозможно будет отменить регистрацию.

Есть также особенности вызова коллбэка финализации:

  • Коллбэк может быть вызван не сразу после сборки мусора.

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

  • Вызов коллбэка может не происходить вообще:

    • Например, если вся страница была полностью закрыта.

    • Или если сам объект FinalizationRegistry был удален.

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

Важно быть осторожным, чтобы не "потерять" объект. В следующем примере в качестве подписчика передаётся лямбда-функция, но на эту лямбда-функцию нет других ссылок. В результате она будет сразу же удалена сборщиком мусора (так как внутри самого EntityNotifyService только слабые ссылки через WeakRef), и объект DynamicList никогда не будет уведомлен о каких-либо изменениях.

abstract class DynamicList {
    public constructor() {
        EntityNotifyService.INSTANCE.subscribeListUpdate(() => {
            console.log(‘never called’);
        )};
    }
}

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

Отладка FinalizationRegistry

Несколько слов про отладку FinalizationRegistry и отлов утечек памяти в Chrome. В Chrome есть инструменты разработчика, в которых отдельная вкладка «Память» позволяет сделать снимок кучи памяти (heap).

Там будут показаны все объекты, под которые выделена память в веб-странице.

Если мы подозреваем, что при каком-то действии происходит утечка памяти, мы можем выполнить это действие на странице и сделать второй снимок памяти, после чего сравнить оба снимка, выбрав пункт “Сравнение” в меню:

При сравнении будут показаны все созданные и удаленные объекты и размер отведенной им памяти.

Также есть специальный режим, который позволяет увидеть все объекты, под которые была выделена память после первого снимка до момента второго снимка.

Для каждого объекта можно посмотреть путь, по которому он доступен. Для этого надо выделить конкретный объект в верхнем списке и путь объекта будет показан в нижней панели. Нельзя сказать, что это идеальный инструмент, он показывает много “лишней” и часто “дублирующейся“ информации, иногда сам по себе сохраняет ссылки на объекты, мешая их освобождению, но ничего лучше нам пока найти не удалось. Если знаете что-то удобнее – пишите в комментариях!

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

В завершение скажем, что в веб-клиенте 1С:Предприятия 8 для управления ресурсами мы используем сборщик мусора. Мы не использовали FinalizationRegistry, т.к. на момент написания веб-клиента FinalizationRegistry ещё не существовало. С появлением FinalizationRegistry мы задумались о переходе на него, но окончательного решения пока не приняли.

При разработке же технологии 1С:Предприятие.Элемент мы используем FinalizationRegistry.

На этом на сегодня все, до новых встреч в нашем блоге!

Tags:
Hubs:
Total votes 19: ↑19 and ↓0+19
Comments8

Articles

Information

Website
www.1c.ru
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия