Скучное вступление
Не так давно, мне довелось участвовать в разработке некоего программно-аппаратного комплекса для одной американской компании. Разрабатывал я бэкенд, немного фронтенд, сращивал устройства с облаком (IoT то бишь). Стек технологий был обозначен четко. Ни в право, ни в лево — enterprise, одним словом. В определенный момент меня перекинули в помощь на фронтенд POS (Point of Sale) веб приложения.
Проблема. Становится интересней
Всё бы ничего, но веб приложение разрабатывалось для работы в 6 тыс. офисах по всей территории Америки (для начала). Где, как оказалось, с интернетом могут быть проблемы. Да да, в той самой, продвинутой Америке! Проблемы с покрытием не только проводного интернета, но и мобильной связью! Т.е. плохой интернет канал (часто, мобильный) — вполне себе обычная история для небольших американских городов.
А это же POS… Тут, понимаешь, клиенты стоят, надо инвойс быстро распечатать… Тормозов быть не должно! И livesearch… Были обсуждения, прикидки, в итоге — не стали грузить бэкенд запросами (трафик, опять же). Сошлись на том, что веб приложение должно по-максимуму подгружать данные и делать, тот же поиск, локально. Речь идет, конечно, о данных, размер которых позволяет это сделать.
Данных фронтенд тянул много, из разных сервисов. Как следствие — большой трафик и долгая загрузка страниц. В общем — беда.
Часть проблем решается бэкендом (сжатие, гео-кластеринг и тп), но это — отдельная история, сейчас только о фронтенде.
Кэш
Первое, что было сразу сделано (и это логично) — кэширование и хранение получаемых данных локально. По возможности — долго, насколько позволяет секьюрити. Для каких-то данных лучше использовать localStorage, для других — sessionStorage, а что не помещается — можно просто хранить в памяти. Мы использовали ангуляр, поэтому нам вполне подошел для этих целей angular-cache.
Отчасти, это решило проблему — при первом обращении к какой-либо странице, подгружались требуемые ей ресурсы. Далее, ресурсы уже брались из кэша.
Сделали, конечно, инвалидацию кэша и тп. Трафик сократился, но отклик при первоначальном обращении к страницам оставался непозволительно большим.
Фоновая подгрузка ресурсов
Следующим логичным шагом стала фоновая подгрузка ресурсов. Пока мы находимся на главной странице, смотрим сообщения, алерты и тп — происходит фоновая загрузка ресурсов для скрытых разделов. Идея в том, что когда пользователь переключится на нужный раздел, данные (или хотя бы их часть) будут уже в кэше, загрузка не потребуется, отклик страницы уменьшится. Первый вариант самый простой:
$q.all([
Service1.preLoad(),
Service2.preLoad(),
…
ServiceN.preLoad()
]);
Где Service.preLoad() — функция, возвращающая promise ресурса страницы.
Но есть проблема — в этом случае все промисы выполняются одновременно, т.е. одновременно тянутся все 100500 ресурсов. А канал-то — не резиновый! Плюс у нас мега-запрос к стороннему сервису, который долго и медленно качал много данных. В итоге все параллельные запросы выполнялись по времени почти столько же, сколько и этот мега-запрос.
Окей, загрузим по-порядку:
Service1.preLoad()
.then().Service2.preLoad()
…
.then().ServiceN.preLoad();
Оно, воде как, стало лучше, но не очень — если пользователь сразу шел в “неправильный” раздел — будем ждать прогрузки очереди, пока не доберемся до ресурсов этого раздела. В общем, тут бесконечная эвристика. Что-то лучше пачкой загружать, что-то отдельно в параллели, и тд и тп… Хотелось, однако, какого-то более системного подхода.
Очередь
И опять логичный шаг — положить загрузку всех ресурсов в динамическую очередь. Приоритетную. Если заходим в раздел, а его данные (или их часть) еще в очереди — мы повышаем их приоритет и они загружаются в первую очередь. В итоге отклик <= чем было. Мелочь, а приятно.
Так то оно так, только есть еще одно, не резиновое место — размер кэша.
Система ресурсов
Чем глубже погружался в эту проблему, тем сильнее возникало дежавю — я это уже проходил! Ограниченные ресурсы памяти, фоновая загрузка, выгрузка, приоритеты… это же… это же — типичная система ресурсов игрового движка! Едешь на машинке — локации подгружаются, что далеко позади — выгружаются. Еще термин специальный был для игровых движков — поддержка стримминга… Лет 5 жизни я провел в геймдеве, это было круто…
Так вот. Получается, веб приложение, по сути — аналог ресурсоемкой игры. Только здесь у нас не локации — а страницы/разделы. Страницы тянут ресурсы и складывают в кэш, а он — ограниченный в размере, надо что-то выгружать. Т.е. аналогия уместна.
Проблема веб приложения, что и в геймдеве — предсказать где будет пользователь, чтобы загрузить ресурсы заранее. Решение №1 — дизайн, конечно. Направить пользователя по предсказуемому (а иногда, и единственному) маршруту. Что не удается решить дизайном — статистика + эвристика.
Выгрузка — то же самое. Только уже надо понять, что выгрузить в первую очередь. Задаем приоритеты самим ресурсам, выгружаем ресурсы с наиболее низким приоритетом. Или грохаем целыми пачками.
Вариантов реализации — масса. Самый предсказуемый — предпроцессинг, ручной либо автоматический. Мы должны указать, какие ресурсы в локации/разделе нам будут нужны, а каким-то снизить приоритет.
LOD
И тут Остапа понесло… В смысле, в голову полезли геймдев-трюки, применимые в веб разработке. Один из них — LOD (Level of Detail), уровень детализации. В геймдеве он применяется для текстур и моделей. Можно сразу прогрузить мир с минимальным уровнем детализации, а стриммить уже детализированные модели текстуры. И игрок всегда что-то видит, и даже, может играть.
Т.е. нужна система LOD для загружаемых данных! Для веб подходит самый примитивный вариант — два уровня детализации. Сначала грузим начальные данные, которые видит пользователь (первые страницы таблиц, например).
Данных получается мало, грузятся быстро. А бэкграундом… бэкграундом грузятся уже LOD’ы “по-тяжелее”.
Компрессия
Впихнуть невпихуемое — почти стандартная задача игродела. Ну так давайте раздвинем границы localStorage! Берем какой-нибудь LZ-компрессор и вперед! Да, но localStorage может хранить только строки… Ну, тогда сгодится, например, lz-string.js. Компрессия уже не та, но даже -20%, когда в распоряжении всего 5Мб — это совсем не плохо! И как бонус — секьюрные дела, в localStorage будет не открытый текст, а китайские знаки.
А дальше-то, что?
Дальше… дальше мысль несется к неизведанным глубинам. В памяти всплывает VFS (Virtual File System) — прослойка между ресурсной системой игры и файловой системой операционной системы. Обычно, всё крутится вокруг data-файла, к которому можно обратится как к файловой системе. Прочитать файл, записать… А что если сделать VRC (Virtual REST Call)!? Тогда ведь можно поддержать работу веб приложения вообще при отсутствии интернет соединения! В какай-то степени, конечно, но всё же.
Контроллеры общаются с менеджером ресурсов. Он что может отдать — отдает сразу, все остальные запросы отдаются VRC. А он, в свою очередь, уже самостоятельно синхронизирует своё состояние с бэкендом и, по мере загрузки, информирует об этом.
Когда говорят про оффлайновую работу веб приложения, обязательно проскальзывает Meteor. Круто, конечно, но мы находились в жестких рамках стека разработки. Предлагаемый же вариант, можно реализовать практически на любом фреймворке. С оговорками, конечно, но можно.
Но статья конкретно не про это. А про то, как порой неожиданно, всплывает опыт давно минувших дел…
Приятного кодирования, друзья!