Всем приветы! Меня зовут Ваня, я медиаинженер и занимаюсь разработкой видеоплатформы в Ozon — в основном бэкендом.
В апреле 2022 года мы презентовали сервис Ozon Моменты — ленту коротких видео. Главные фичи, которые мы хотели реализовать:
-
скорость отображения контента: видео должно стартовать максимально быстро, а переходы между роликами должны быть максимально бесшовными;
-
качество контента: видео должно быть приемлемого качества и хорошо выглядеть;
-
размер контента: видеофайл должен быть минимального размера;
-
универсальность контента: видео должно воспроизводиться на любом экране, будь то iPhone 69 Pro Max или тостер от Smeg.
Что мы сделали для реализации вот этого всего и на каких дрожжах, читайте под катом.

Из чего состояла видеоплатформа Ozon
Мы не стали изобретать велосипедов и сделали всё вполне стандартно. На видеоплатформе существует два типа контента:
-
VoD (Video on Demand) — видео по запросу, которое уже полностью готово к просмотру;
-
Live–видео, которое генерируется реалтайм и ещё не завершилось (видеостримы).
VoD-контент двигается примерно по следующему маршруту:

-
Пользователь со своим медиаконтентом отправляется к нам на uploader и пробует загрузить некий файл. На данном этапе мы со стороны видеоплатформы проверяем его на базовую валидность и пригодность (медиафайл ли это вообще, что у него внутри и т. п.). Если всё хорошо, отправляем его в хранилище для оригиналов видео (в нашем случае это S3).
-
После завершения загрузки видеоплатформа ставит в очередь обработки задачу на транскодинг принятого контента.
-
Первый освободившийся транскодер забирает задачу из очереди и делает из оригинального файла пригодный для адаптивного просмотра «букет». В него входят видеофайлы для создания адаптивной раздачи (транскодинг в разные качества и сегментация), вспомогательные медиафайлы (превью, обложка и т. п.) и дополнительная информация о контенте. По окончании всех стадий обработки полученный контент улетает на файловый сервер для дальнейшего хранения и раздачи.
-
Как только контент обработан и записан в хранилище, происходит его анализ на наличие запрещённого содержания: алкоголя, сигарет, порнографии, стороннего контента.
-
После апрува контент может быть отдан наружу для просмотра. Естественно, передача происходит через кэширующие слои и провайдера CDN.
В случае с live-стримингом контент течёт по пайплайну:

-
Пользователь создаёт в админке стримера новый стрим, под который выделяется отдельный транскодер. Сам стример получает endpoint для стриминга на видеоплатформу.
-
Через приложение для стриминга пользователь запускает поток на выделенный endpoint, который проксирует контент на выделенный транскодер. На стороне транскодера поток преобразуется в адаптивный и сегментируется для раздачи пользователям.
-
Когда поток запущен, через админку стримера пользователь публикует свой стрим — и он становится доступен на видеоплатформе другим пользователям. Параллельно стартует запись стрима для возможности его просмотра после завершения трансляции.
-
Live-контент, так же как и VoD, раздаётся через кэширующие слои бэкенда Ozon и CDN провайдера.
Запихиваем всё в ленту
Так как основное наполнение нашей ленты — это видео, то нужно впихнуть всевозможный контент с сохранением всех её фич. Основные типы контента, которые должны быть в ленте и обслуживаться видеоплатформой:
-
видеоотзывы;
-
live-трансляции;
-
развлекательный видеоконтент.
Причём если видеоотзывы и live-трансляции к моменту релиза Моментов (кек) уже существовали и комфортно себя чувствовали на видеоплатформе, то развлекательные ролики были новым типом и требовали к себе подхода в лучших традициях соцсетей.
Пройдёмся по контенту чуть более подробно.
Видеоотзывы
Покупатель, купив определенный товар, может записать о нём видео и загрузить в карточку товара. Это и есть видеоотзыв.

Live-трансляции
Это стримы продавцов. На них могут быть как распродажи с хорошими скидками, действующими во время стрима, так и разнообразные розыгрыши призов с викторинами, песнями и электрошокерами на ведущих (хорошие стримы и конкурсы интересные).

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

А что по технологиям?
На момент проектирования Ozon Моментов на видеоплатформе весь контент раздавался по HLS в адаптиве. Немного теории:
HLS
HTTP Live Streaming — протокол, придуманный корпорацией Apple, который позволяет предоставлять пользователю контент в виде нарезанных на кусочки (чанки) фрагментов, готовых к воспроизведению отдельно друг от друга. Может использоваться и для VoD, и для live-трансляции. Поддерживает возможность шифрования контента, разные качество, кодеки, языки, субтитры внутри одного HLS-потока.
В состав HLS обычно входят:
-
Мастер манифест, в котором указываются доступные качества контента, их параметры (разрешение, кодеки, количество каналов звука и так далее) и ссылки на скачивание этих качеств.
Пример:
#EXTM3U #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=779162,RESOLUTION=360x640,FRAME-RATE=30.000,CODECS="avc1.4d401e" index-f1-v1.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1428476,RESOLUTION=540x960,FRAME-RATE=30.000,CODECS="avc1.4d401f" index-f2-v1.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2523337,RESOLUTION=720x1280,FRAME-RATE=30.000,CODECS="avc1.4d401f" index-f3-v1.m3u8 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=5480232,RESOLUTION=1080x1920,FRAME-RATE=30.000,CODECS="avc1.4d4028" index-f4-v1.m3u8
-
Медиаплейлисты, в которых описаны все сегменты выбранного качества, параметры сегментов и ссылки на них.
Пример:
#EXTM3U #EXT-X-VERSION:7 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-MAP:URI="stream_0_init.m4s" #EXTINF:2.000000, stream_0_seg_1.m4s #EXTINF:2.000000, stream_0_seg_2.m4s #EXTINF:2.000000, stream_0_seg_3.m4s #EXTINF:0.633333, stream_0_seg_4.m4s #EXT-X-ENDLIST
-
Медиасегменты, которые являются кусочками (чанками) медиаконтента.
Адаптивное вещание
Это способ раздачи контента для его воспроизведения без буферизаций на стороне клиента с учётом пропускной способности его интернет-соединения. Для адаптивного стриминга исходный медиапоток транскодируется в потоки разного размера и веса (например, в качество 1080р, 720р, 480р и 360р), которые режутся в одних и тех же местах на сегменты для синхронизации перехода с одного качества на другое. Картинка для наглядности:

Кодеки мы решили использовать самые стандартные:
-
H.264 — для видео;
-
AAC — для аудио.
Выбирали кодеки по простому правилу: чем распространённее, тем лучше. H.264 — это самый распространённый кодек, который может быть декодирован на 99,9% устройств с экраном на борту. AAC также считается стандартным для устройств, способных воспроизводить цифровые звуки.
Примечание: естественно, есть более навороченные кодеки с лучшей степенью сжатия (типа H.265 или Opus), но мы их не используем (пока что) в связи с тем, что есть необходимость свести к минимуму используемые типы контента.
В данном решении всё вроде бы выглядит неплохо:
-
адаптив для пользователей с разным каналом (будь они в центре Москвы на 10G или в деревне Средние Петушки на тонком канале от вышки времён «верните мне мой 2007-ой»);
-
протокол стриминга поддерживается если не каждым, то почти каждым плеером (Siemens ME45 не в счёт);
-
software-плееры практически не требуют каких-то сакральных знаний от клиентского разработчика (Plug and Play: добавил ссылку в плеер и — оно работает).
Но так как лента должна не только работать, но и работать быстро, то в таком решении хранения и раздачи контента будут проблемы. Вот основные из них:
-
время старта будет страдать из-за большой вложенности адаптивных плейлистов HLS (плеер сначала идёт за адаптивным плейлистом со ссылками на медиаплейлисты, потом — за медиаплейлистом с медиасегментами, потом — за init-чанками с метаинформацией о потоках для инициализации плеера, и только после этого в плеер загружается сам закодированный кодеками медиа-payload);
-
время перехода от одного видео к другому будет страдать из-за проблем с предзагрузкой HLS (например, в iOS сделать это по-простому не получится в связи с правилами безопасности системы, которая не даст воспроизвести контент до тех пор, пока он не будет скачан полностью).
Как минимум эти вещи без каких-либо внушительных затрат (разработка своего протокола, написание своих плееров, обеспечение их корректной работы и пр.) делают прямое использование HLS в ленте неприемлемым.
Получается, что для реализации видеоленты в нужном нам виде требуется что-то простое, что-то универсальное и легко интегрируемое… И имя ему (внезапно) MP4.
MP4 — это формат медиаконтейнера, являющийся частью стандарта MPEG-4. Используется для упаковки цифровых видео- и аудиопотоков, субтитров, афиш и метаданных, которые определены группой специалистов MPEG. Как и большинство современных медиаконтейнеров, MPEG-4 предусматривает возможность показа видео через интернет. Вместе с файлом передаются метаданные, содержащие необходимую для вещания информацию. Контейнер позволяет упаковывать несколько видео- и аудиопотоков, а также субтитров.
МР4-файл состоит из частей (так называемых атомов), которые отвечают за определённое поведение MP4-демультиплексора (грубо говоря, распаковщика MP4-контейнера). Вот пример структуры файла:

Атомы имеют в своей структуре несколько важных полей:

-
SIZE — размер атома в байтах. Он нужен для корректности демультиплексирования структуры файла.
-
TYPE — тип атома (например, упомянутые выше MOOV, FTYP, MDAT и пр.). Он указывается для корректного раскладывания вложенности атома.
-
LARGESIZE — размер атома, в случае если параметра SIZE не хватает и атом слишком большой. Для этого в поле SIZE проставляется 1, оно игнорируется — и используется LARGESIZE.
-
DATA — непосредственно данные атома.
Атомы бывают верхнеуровневыми и вложенными. Все атомы в рамках этой статьи описывать не имеет смысла (их достаточно много, при желании можно почитать о них тут), но основные три описать всё же стоит:
-
[FTYP] — атом, указывающий на то, что файл формата MP4 (грубо говоря — MIME type);
-
[MDAT] — атом, в котором находится payload для декодера;
-
[MOOV] — атом, в котором описываются все дорожки внутри MP4-файла и их параметры для корректного декодирования.
В файле эти атомы обычно стоят в следующем порядке:
[FTYP] [MDAT] [MOOV]
Это обусловлено следующим:
-
при транскодировании медиадекодер сразу записывает полезные данные в файл;
-
как только весь ролик корректно транскодируется, атом [MDAT] закрывается, а мультиплексор понимает выходные параметры файла (его медиапотоки, их длительность и пр.);
-
в конце дописывается атом [MOOV] с указанием структуры и информации о файле.
При такой структуре MP4 есть проблемы с воспроизведением — файл не может проигрываться по HTTP сразу. Объяснение простое: для воспроизведения нужно сначала инициализировать декодеры и только после этого закидывать payload в топку плеера. Но так как все параметры для декодера находятся в атоме [MOOV] (а он, напоминаю, располагается в самом конце файла), то клиенту нужно скачать весь файл, чтобы добраться до заветных настроек. В случае если файл достаточно большой (четырёх-часовая лекция о пользе тепличных огурцов и силе земли), то быстрый старт получить вам вряд ли удастся.
Примечание: в настоящее время некоторые плееры оснащены умным воспроизведением MP4-файлов и могут сами использовать range-запросы для получения атома [MOOV] отдельно (например, Google Chrome так умеет), но уповать на это, естественно, не надо.
Для того чтобы файл сразу был доступен для воспроизведения без полной загрузки, нужно перенести атом [MOOV] ближе к его началу. Хороший MP4-файл, пригодный к неистово быстрому воспроизведению по HTTP, имеет следующую структуру:
[FTYP] [MOOV] [MDAT]
Что ж, поехали делать модно и красиво!
Итак, что же надо изменить в текущей схеме, чтобы всё получилось? По большому счёту нужно научиться отдавать контент в двух видах (HLS и MP4), при этом максимально избегая дублирования контента в хранилище и поддерживая функциональность, которая уже есть в видеоплатформе Ozon.
Для этого нужно:
-
изменяем специальные настройки для транскодера, который будет генерировать MP4-файлы с атомом [MOOV] в хедере;
-
реализовать возможность раздачи таких файлов в виде HLS-адаптива;
-
подготовить основные и дополнительные файлы для визуально бесшовного воспроизведения;
-
сделать так, чтобы live-трансляции могли быть встроены в видеоленту.
Первое и главное, что нужно реализовать, — это раздача MP4-файлов в адаптивном HLS без дублирования контента. Серебряной пулей для этого будет модуль для Nginx от команды Kaltura. Основная фича этого модуля, нужная нам — это то, что он позволяет из обыкновенных MP4 нарезать на лету адаптив в HLS. Для этого нужно всего ничего:
-
развернуть Nginx с этим модулем и настроенным конфиг-файлом для раздачи нарезанного HLS (делаем Docker-контейнер с нужными версиями и конфигурациями, чтобы по нажатию кнопки поднимать N инстансов в модных кубернетесах);
-
указать upstream на что-то, откуда будут отдаваться MP4-файлы (это может быть локальное хранилище, но в нашем случае это S3);
-
указать место, откуда можно получать конфигурационные JSON-файлы, содержащие ссылки на MP4-файлы для нарезания в HLS (в нашем случае — отдельная ручка микросервиса для отдачи таких конфигурационных файлов по ID контента).
Теперь то, что получилось, нужно аккуратно вклинить в имеющийся флоу раздачи контента. Так как на походы до S3 за MP4-файлами тратится много времени, то наилучшим вариантом будет разместить ноды с nginx-vod-module максимально близко к хранилищу MP4-файлов и к проксирующей ноде. Также было бы неплохо максимально быстро ходить и до ручки микросервиса с конфигурационными файлами для контента. Таким образом, новая схема с nginx-vod-module получилась такая:

Главная задача решена. Теперь нужно подготовить файлы для удобной раздачи в формате MP4, но чтобы при этом иметь возможность раздавать контент через nginx-vod-module. Для этого на транскодере генерируем MP4-файлы, в которых переписываем структуру, перенося атом [MOOV] в начало.
Примечание:
nginx-vod-moduleумеет на лету преобразовывать файлы из обычных не подготовленных для раздачи по HTTP файлов MP4 уже с перенесённым атомом [MOOV]. Но зачем, если можно раздавать уже предподготовленные файлы напрямую из S3?
А что про стримы?
С генерацией VoD-контента вроде как всё хорошо и понятно. Но что делать с live-потоками? Стримы генерируются в адаптивный HLS, который не совсем удовлетворяет нас в задаче наполнения контентом ленты коротких видео. Завернуть их в MP4 нельзя, так как для этого нужен кастомный плеер (чего мы стараемся избежать). Вдобавок MP4 не поддерживает адаптивность, а с учётом качества покрытия и работы беспроводных и мобильных сетей адаптивный битрейт на трансляциях просто необходим. Ну и вдобавок хочется весь контент, поступающий в ленту, свести к одному типу, чтобы облегчить жизнь и разработчикам, и пользователям.
Новая цель — сделать из адаптивного живого HLS-потока простой и легковоспроизводимый MP4-файл.
Так как в случае с таким видеопревью перед нами не стоит задача отдавать наисвежайший контент, мы попробовали сделать фоновый воркер, который будет на стороне транскодера собирать из самых свежих медиасегментов (чанков) простой MP4-файл. Этот воркер перекладывает контент из живого HLS-потока в MP4-контейнер и отдаёт его как VoD-контент со всеми вытекающими плюшками. Убедившись, что такой вариант всех устраивает, нужно было сделать так, чтобы исключить возможность перезаписи файла во время его отдачи с транскодера и без дополнительных вызовов переупаковщиков.
Так как в FFmpeg (да, у нас, как почти у всех, для транскодирования видео используется именно он) есть возможность генерировать один набор полезных данных и укладывать их как душе угодно в рамках одной команды, то флоу внутри FFmpeg был немного допилен для генерации нескольких видов контента. А именно:
-
оставили HLS-генерацию как есть (она нас более чем устраивает);
-
добавили MP4-мультиплексор для одного из качеств, которое летит в HLS-адаптив, и укладывали его как есть (контент не требует повторного транскодинга);
-
также настроили MP4-мультиплексор для сегментирования входящего контента частями, генерируя новый MP4-файл в определённую дельту времени;
-
обновлённый файл записывали в отдельный плейлист, по которому можно контролировать последний из сгенерированных файлов, содержащий последнюю часть стрима.
Схема до изменений выглядела следующим образом:

А так она выглядит после:

Таким образом, в рамках одного запуска FFmpeg мы генерируем привычный всем live HLS-адаптив, а рядышком складываем обновляемые сегменты в формате MP4 с возможностью быстрого просмотра по HTTP. Именно последние раздаются на клиент, что решает задачу интеграции стримов в текущую парадигму ленты коротких видео.
А что там по клиентам?
На клиентах для реализации проигрывания коротких видео всё достаточно стандартно:
Мы взяли базовые плееры по основным двум причинам:
-
Они разработаны самими производителями мобильных платформ. Поэтому меньше вероятность того, что при обновлении ОС плеер упадёт на спину и скажет: «У меня лапки, ошибка “146%_callibration_ololo_dudulka”, и вообще не трогайте меня».
-
Скорость разработки. Так как разбираться в том, как работает кастомный плеер на С/С++ с использованием GStreamer под капотом и сооружением HW-ускоренного пайплайна, без лишней необходимости не было ни желания, ни возможности.
Примечание: вставлять код стандартных плееров я, конечно же, не буду, так как его легко найти в интернетах (для особо ленивых: iOS, Android).
Проанализировав макеты и решения других компаний и сопоставив дедлайны на реализацию минимальной, но прилично выглядящей функциональности, мы составили требования к видеоплеерам в ленте. Вот основные:
-
Возможность использования аппаратного ускорения.
-
Работа более чем одного плеера одновременно.
-
Быстрый старт.
Рассмотрим их поближе.
1. Возможность аппаратного ускорения
Так как рендеринг видео — очень энерго- и ресурсозатратная вещь, то этот пункт особенно важен. Если декодировать и рендерить видео на CPU, то это скажется на улетающей в ноль батарее и (особенно на старых устройствах) тормозах при пользовании устройства. Чтобы разгрузить CPU для осуществления более нужных вычислений, необходимо использовать плееры с аппаратным ускорением декодирования.
iOS: AVPlayer автоматически использует аппаратное декодирование для H.264 из коробки.
Android: внутри ExoPlayer также существует поддержка аппаратного декодирования для H.264.
2. Работа более чем одного плеера одновременно
Законом не запрещено нагенерировать сразу кучу плееров и использовать их когда и как вздумается. Но нужно понимать, что ресурс мобильного устройства не бесконечен, и при запуске 100500 плееров одновременно заставить их работать с аппаратным ускорением, увы, не получится. А я напомню: видео весьма затратно декодировать по ресурсам. Поэтому нужно научиться правильно переиспользовать плееры, чтобы не убивать драгоценное время на переинициализацию и прочее.
Видеолента в Ozon Моментах спроектирована так, что одновременно пользователь не может видеть больше чем три плеера:
-
предыдущий,
-
текущий,
-
следующий.
Пример:

Эти три плеера идут «паровозиком». Как только происходит перелистывание видео вперёд и скрывается последний плеер, он становится вперёд. Таким же образом работает свайп на предыдущий видеоплеер.
Что самое приятное: можно запустить три плеера одновременно с аппаратным ускорением даже на достаточно старом Android-устройстве, что положительно скажется на автономности батареи и работоспособности устройства в целом.
3. Быстрый старт
Медленный старт видео — это комплексная проблема. Замедляет время старта огромное количество факторов:
-
криво собранный плеер (плеер инициализируется только при поступлении в него атома с метаинформацией);
-
тяжёлый контент (завышенные параметры качества для UGC);
-
проблемы сети (потери на мобильной сети + большое RTT = медленный старт).
Примечание: UGC (user generated content) — контент, созданный пользователями, например селфи-танцы с подругой или видео с пёсиком, который смешно чихает.
Так как в этой части статьи мы говорим про клиент, то очевидно, что нужно лезть в плеер.
В первой итерации мы просто поставили плееры как есть, но производительность нас не устроила. Так как сердце видеоленты — плеер, перво-наперво идём смотреть его настройки. Основная жалоба заключалась в том, что видеоплеер очень медленно стартует, что указывало на проблему начальной буферизации.
При изменении настроек буферизации тоже нужно найти золотую середину:
-
если сильно раздуть буфер плеера, то можно очень долго не начинать проигрывать контент и время старта просядет;
-
если сделать буфер минимальным, то время старта будет крайне мало, но пользователь будет постоянно отправляться в буферизацию (видеть тот самый бесящий кружок лоадера) и качество пользовательского опыта значительно просядет.
Буфер обычно указывается во времени.
iOS: В AVPlayer на устройствах Apple за это отвечает метод preferredForwardBufferDuration класса AVPlayerItem. Параметр задаётся в секундах. Apple в документации пишут, что все за вас сами сделают, если задать параметр 0 (или оставить нетронутым).
Пример:
let url = URL(string: “https://example/path/to/video/sample.mp4") let asset = AVAsset(url: url) let playerItem = AVPlayerItem(asset: asset) playerItem.preferredForwardBufferDuration = newBufferDurationValue let player = AVPlayer(playerItem: playerItem)
Android: В плеере на устройствах с операционкой от Google за это отвечает метод setBufferDurationsMs класса DefaultLoadControl. Параметр задаётся в миллисекундах. Он обладает параметрами:
-
minBufferMs— минимальный буфер (default: 50000); -
maxBufferMs— максимальный буфер (default: 50000); -
bufferForPlaybackMs— буфер для старта плейбэка (default: 2500); -
bufferForPlaybackAfterRebufferMs— буфер для выхода из буферизации (default: 5000).
Пример:
DefaultLoadControl loadControl = new DefaultLoadControl .Builder() .setBufferDurationsMs(minBufferMs, maxBufferMs, bufferForPlaybackMs, bufferForPlaybackAfterRebufferMs) .createDefaultLoadControl();
Покрутив эти параметры, старт плееров на обеих платформах стал гораздо бодрее, но этого всё ещё было недостаточно, так как при быстром пролистывании видеотайтлов были заметны тормоза и лаги. Полезли, как говорится, глубже…
Чтобы отсутствие видеопотока при быстром пролистывании не бросалось в глаза, мы делаем следующее:
-
на стороне бэкенда забираем из видеоролика первый фрейм, упаковываем его в JPEG и отправляем в хранилище (это достаточно быстрая операция, учитывая, что первый фрейм находится в самом начале и весь файл декодировать не нужно);
-
на стороне микросервиса, который занимается выдачей списка видео для мобильных клиентов, появляется дополнительное поле, в котором указана ссылка до этого JPEG-файла;
-
клиент при запросе выдачи ленты получает список видео с этими ссылками и заранее скачивает их, сохраняя у себя локально (они весят всего ничего, поэтому скачивание происходит достаточно быстро);
-
та часть экрана, в которой находится видеоплеер, перекрывается картинкой с первым кадром и уже готова для отображения контента пользователю, даже если видео вообще ещё даже не начинало скачиваться. По готовности плеера (он инициализирован и набрал буфер) картинка скрывается — и видео начинает проигрываться.
Это дало более приятный и бесшовный переход между элементами в ленте видео, но и этого оказалось недостаточно. Мы заметили неприятный эффект: даже при наличии первого кадра заглушкой контент мог долго лететь до клиента, что создавало «неловкие мгновения ожидания» проигрывания видео.
Для решения этой проблемы решили сделать prefetch-схему для заблаговременной загрузки контента. Идея проста: в момент загрузки N-ного ролика в фоне идёт загрузка N+1,2,3… — причём только первых нескольких секунд, а не видео целиком. Когда приходит очередь проигрывать следующий ролик, мы смотрим его сразу же, так как начало ролика уже загружено, а оставшийся контент докачивается по необходимости. Такой подход решил проблему ступора при проигрывании следующего видео в тех случаях, когда клиент не успел загрузить видео.
Как оно вышло в итоге
В итоге мы получили ленту коротких видео, состоящих из разнообразного контента: видеообзоров, развлекательных видео и live-трансляций. Местами — вполне себе залипательно.
Естественно, есть ещё вещи, которые нужно допиливать и тюнить, но это уже совсем другая история.

ссылка на оригинал статьи https://habr.com/ru/company/ozontech/blog/690596/
Добавить комментарий