Что нового

Как я оптимизировал xenforo

  • Автор Автор Guru
  • Дата публикации Дата публикации
  • Время чтения статьи Время чтения статьи 13 мин чтения
  • Теги Теги
    debug preload
История о том, как я загнал главную страницу форума с 88 запросов до 15, выяснил, что половину работы делал впустую один невинный аддон, и в конце снял ещё четверть серверного времени строчкой в конфиге — не сломав при этом ничего из того, что работало.

Содержание

Повод заглянуть под капот​


Форум у меня работает быстро, и поводов лезть внутрь вроде бы не было. Но «быстро» — это ощущение, а я хотел цифру. У XenForo есть встроенная debug-панель: добавляешь в config.php флаг — и внизу каждой страницы появляется сводка по времени, памяти и, главное, полный список SQL-запросов с таймингами и EXPLAIN. Включил, привязав к своему IP, открыл несколько типовых страниц и стал смотреть.

⚠️ Сразу важное написал:
Debug, привязанный к IP, кажется безобидным — но если у вас работает гостевой page cache (а к концу этой статьи он будет работать), есть тонкий момент. Ядро сохраняет в кэш то, что отрендерило. Если страницу для кэша сгенерирует ваш заход с debug — панель со всеми SQL-запросами и путями сервера ляжет в кэш и будет отдаваться всем подряд несколько минут. Поэтому правило железное: померили — выключили $config['debug']['enabled'], и только потом тестируем кэш.

Тайминги по страницам оказались разные. Статьи и темы — отличные, 9–19 запросов, трогать нечего. А вот главная под залогиненным пользователем выбивалась из ряда: 88–90 запросов. Гостевая главная при этом показывала 49 — тоже подозрительно много для страницы, которая по идее должна отдаваться из кэша почти целиком. Стало понятно, куда копать.

Что показал debug: главная под пользователем​


Я выгрузил полный список всех 88 запросов и стал читать его глазами. И почти сразу в нём проступил паттерн — один и тот же запрос, повторяющийся снова и снова:

Код:
SELECT * FROM xf_sp_watermark_permanent WHERE attachment_id = ?

Он шёл по три раза на каждое вложение, да ещё и повторно для тех же вложений в разных местах страницы. Я насчитал около сорока таких запросов из восьмидесяти восьми — почти половину. И каждый возвращал пустоту: no matching row. То есть это был чистый холостой трафик к базе — мотор работал, а машина стояла.

Остальные запросы оказались здоровым ядром: визитёр со своими правами, дерево узлов, маркеры прочитанного, пара выборок виджетов. Их трогать смысла нет — это та работа, которую страница обязана делать. А вот сорок пустых запросов к таблице водяных знаков — это была аномалия с конкретным источником.

Дымящийся пистолет: водяной знак и N+1​


Таблица xf_sp_watermark_permanent принадлежит аддону Spolzer Watermark — он умеет накладывать водяные знаки на вложения. Я полез в его исходники и нашёл механику целиком. Цепочка такая: метод getThumbnailUrl() на каждой картинке вызывает canServeWatermarkedAttachment(), тот — hasStoredWatermark(), а тот — getPermanentRow($id), который и лезет в базу. Никакого кэша по пути нет ни на одном уровне.

Дальше всё складывается в идеальный шторм. Шаблоны AMS запрашивают URL миниатюры по три раза на вложение — обычная версия, retina и прямая ссылка. Это уже ×3. А главная показывает одни и те же статьи сразу в двух виджетах — «Сейчас в тренде» и «Свежие статьи». Это ещё ×2. Перемножаем на десяток вложений — и вот они, сорок одинаковых запросов в пустую таблицу.

Это классическая N+1: вместо одного запроса на всё мы делаем по запросу на каждый элемент. Хрестоматийный антипаттерн, и лечится он хрестоматийно — кэшированием.

Почему я правлю не аддон, а расширение​


Первый соблазн — открыть файл аддона и дописать кэш прямо там. Так делать нельзя: при первом же обновлении Spolzer Watermark мои правки затрёт, и проблема вернётся молча. XenForo для таких случаев даёт штатный механизм — Class Extensions: ты объявляешь свой класс, который наследует оригинальный, и переопределяешь только нужные методы. Оригинал остаётся нетронутым, обновления ему не страшны.

У меня уже был свой служебный аддон под такие оптимизации — назову его условно Boost. В него я и добавил расширение репозитория водяных знаков. Логика двухуровневая, и обе ступени работают в пределах одного запроса (репозиторий в XenForo — singleton на запрос, так что это безопасно):

  1. Memo по attachment_id. Повторные обращения к тому же вложению берут результат из памяти, в базу не ходят. Это сразу схлопывает те самые ×3 и дубли между виджетами.
  2. Короткое замыкание по COUNT. Если в таблице постоянных водяных знаков нет ни одной строки — а у меня их ноль, постоянный режим не используется, — то точечный запрос на каждое вложение заведомо вернёт пустоту. Вместо этого делаю один COUNT(*) на весь запрос: таблица пуста — отвечаем null без похода в базу.

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

Эффект: вместо примерно сорока запросов к водяным знакам — ноль точечных, ценой одного COUNT. Это убрало почти половину нагрузки главной разом.

💡 На заметку написал:
Прежде чем переопределять чужой метод расширением, полезно свериться, как именно фреймворк резолвит этот класс — проходит ли он вообще через систему расширений. В XenForo репозитории создаются через Manager::getRepository(), а тот прогоняет имя класса через extendClass(). Значит, репозиторий аддона расширяется штатно, как и любой свой.

Гостевой page cache: он уже работает​


Разобравшись с пользователем, я вернулся к гостю и его 49 запросам. Гипотеза была такая: реклама на сайте крутится через Siropu Ads Manager, и она помечает страницы некэшируемыми — оттого page cache гостям и не отдаётся.

Прежде чем чинить, я решил проверить, а так ли это вообще. Самый чистый способ — посмотреть, ставит ли ядро заголовок X-XF-Cache-Status при отдаче из кэша. Два запроса подряд без кук:

Bash:
curl -s -D- -o /dev/null https://sysadmin.guru/ | grep -i 'x-xf-cache\|set-cookie'
curl -s -D- -o /dev/null https://sysadmin.guru/ | grep -i 'x-xf-cache\|set-cookie'

И тут меня ждал сюрприз: второй запрос вернул X-XF-Cache-Status: HIT. То есть гостевой page cache уже работал. Гость получает готовый HTML из кэша, и те «49 запросов», что я видел в debug, — это был артефакт самого измерения: debug-панель привязана к моему IP, а под ней страница каждый раз генерируется заново, мимо кэша. Реальный гость с улицы видит страницу из памяти и одну-две записи активности, не больше.

Это важный урок: инструмент измерения может искажать измеряемое. Я чуть не бросился чинить то, что и так работало, — спасла привычка сначала проверить факт, а потом действовать.

Реклама оказалась не врагом, а союзником​


Раз page cache работает, возник логичный вопрос: а как же реклама? Если страница кэшируется целиком, откуда на ней свежие объявления и как считаются показы? Я полез в исходники Ads Manager — и обнаружил, что аддон не просто совместим с page cache, а спроектирован под него. Три находки:

  • Аддон подписан на событие page_cache_id и дописывает к ключу кэша тип устройства. Страницы кэшируются раздельно для desktop, mobile и tablet — мобильному гостю не достанется десктопная вёрстка.
  • Показы и клики считаются на клиенте. После загрузки страницы скрипт шлёт отдельный POST на трекинг, и только этот фоновый запрос обновляет счётчики и ставит дедуп-куку. На сам HTML-ответ страницы аддон кук не вешает — поэтому ядро и признаёт страницу кэшируемой. Деньги при этом не теряются: показы считаются даже на закэшированных страницах.
  • Тот единственный «рекламный» запрос, что мелькал в моём debug, на поверку выполнялся только для меня — он обёрнут в проверку is_admin. Для гостей и обычных участников его нет вовсе.

Вывод неожиданный, но приятный: моя исходная гипотеза была неверна, и чинить здесь нечего. А на будущее я нашёл в том же аддоне родной механизм ленивой подгрузки отдельных рекламных позиций через AJAX — если когда-нибудь понадобится гео-таргетинг или ротация чаще, чем раз в несколько минут, позицию можно сделать «живой» поверх закэшированного HTML. Но это на потом.

Ещё две N+1: Featured и обложки трендинга​


Вернувшись к пользовательской главной уже после фикса водяных знаков, я снял свежий debug — и в нём, по той же схеме, проступила вторая пара N+1.

Первая — связь Featured. Шаблоны карточек статей обращаются к $article.Featured, и если эта связь не была подгружена джойном заранее, XenForo делает отдельный SELECT по xf_xa_ams_article_feature на каждую статью. Избранных статей у меня нет — таблица пуста, — так что все эти запросы снова холостые. Лечится тем же приёмом: один COUNT на запрос, и при пустой таблице связь сразу отдаётся как null без точечных выборок.

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

Эффект identity map, который удваивал запросы​


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

В XenForo есть identity map: одна и та же сущность за время запроса существует в единственном экземпляре. Виджет трендинга первым загружал «голые» статьи без обложек и клал их в identity map. А следующий виджет, «Свежие статьи», запрашивал те же статьи уже со своими джойнами по обложкам — но получал из identity map уже готовые «голые» экземпляры, и его собственные джойны отбрасывались. В итоге обложки дотягивались поштучно в обоих виджетах, и N+1 удваивалась.

Решение — расширить хендлер трендинга так, чтобы он сразу грузил обложки (и CoverImage, и данные вложения, и Featured) тремя LEFT JOIN. Тогда в identity map попадают уже полные сущности, «Свежие статьи» переиспользуют их как есть — и десятки точечных запросов превращаются в три джойна. Каждую связь я добавляю только если она реально существует в структуре сущности — страховка на случай, если будущая версия AMS что-то переименует.

💡 На заметку написал:
Identity map — палка о двух концах. Она экономит запросы, переиспользуя сущности, но если первый, кто загрузил сущность, поскупился на связи — все последующие потребители унаследуют его скупость. Поэтому грузить связи выгоднее всего там, где сущность попадает в работу первой.

После этих двух фиксов свежий debug показал 15 запросов на пользовательской главной вместо исходных 88. И что приятно — наш страховочный COUNT по таблице избранного в трейсе даже не выполнился ни разу: хендлер трендинга теперь джойнит Featured сразу, связи приходят готовыми, и до короткого замыкания дело не доходит. Оно осталось спящей подстраховкой для других страниц.

Счётчики гостей и роботов, которых нет​


Попутно я выключил у себя запись активности гостей и роботов — она мне не нужна, а базу нагружает. Но тут вылезла логическая нестыковка: раз активность гостей и роботов не пишется, то в таблице xf_session_activity их нет — и счётчики «гостей онлайн», и фильтры /online/?type=guest и ?type=robot показывают бессмыслицу. Данных-то нет.

Это я тоже закрыл расширением, привязав всё к той самой опции — при выключенной опции форум ведёт себя штатно. Когда опция включена:

  • вкладки «Гости» и «Роботы» на странице /online/ скрываются модификацией шаблона;
  • прямые ссылки ?type=guest и ?type=robot объявляются невалидным фильтром на уровне репозитория — контроллер сам сбрасывает их на «Все»;
  • в подвале виджета «Сейчас на форуме» вместо «всего X (участников Y, гостей Z)» остаётся честное «Сейчас в сети: N».

Строки для модификаций шаблонов я брал байт-в-байт из мастер-шаблонов своей версии XenForo и проверял на уникальность — если будущее обновление их изменит, модификация просто не применится (это видно в админке), и ничего не сломается.

OPcache preload: минус четверть времени​


Дальше я сделал то, что должен был сделать раньше: посмотрел на структуру времени. А она такая. Запросы к базе после всех фиксов занимают 14–18 мс из общего времени страницы. Остальные ~100 мс — это чистый PHP: автозагрузка трёх с лишним сотен файлов, рендеринг шаблонов, гидрация сущностей. Дальше воевать с SQL стало бессмысленно — это битва за 13% территории. Бить надо было по PHP-рантайму.

Инструмент для этого в PHP 8.5 есть — opcache.preload. Идея: скомпилировать ядро движка и горячие библиотеки в общую память OPcache один раз при старте PHP-FPM, чтобы с каждого запроса исчезла возня автозагрузчика и компиляция файлов. Я написал скрипт предзагрузки, который компилирует ядро XenForo и нужные vendor-зависимости через opcache_compile_file().

INI:
opcache.preload=/usr/www/sysadmin/sg-preload.php
opcache.preload_user=www-data
opcache.memory_consumption=256
opcache.max_accelerated_files=30000

Тут есть две тонкости, на которых легко обжечься.

Первая: не выключайте проверку времени файлов. Типовые гайды советуют opcache.validate_timestamps=0 ради скорости. Для XenForo это ловушка: движок на лету перекомпилирует шаблоны и фразы в internal_data/code_cache/, и с отключённой проверкой вы получите вечно протухшие шаблоны после каждой правки. Правильный компромисс — opcache.revalidate_freq=30: один дешёвый stat() на файл раз в полминуты, syscall-шум почти исчезает, а правки подхватываются.

Вторая: код аддонов в preload не кладём. Расширения XenForo наследуют динамические псевдоклассы XFCP_*, которых на этапе предзагрузки ещё не существует. Компиляция пройдёт, но пользы мало, а код аддонов меняется чаще ядра.

И главное — после preload появляется обязательный ритуал: каждое обновление кода (ядра, аддонов, PHP) требует systemctl restart php8.5-fpm. Обычные файлы подхватятся сами по revalidate, а предзагруженное ядро обновляется только рестартом.

Сколько это дало в цифрах — отдельная история, потому что намерить правду оказалось сложнее, чем починить.

Миниатюры, которые зря переспрашивали сервер​


Когда основное было сделано, я открыл вкладку Network на повторном заходе — просто полюбоваться — и зацепился взглядом за восемь миниатюр статей. Каждая висела по 70–100 мс со статусом 304. Первая мысль рефлекторная: непорядок, гоним в кэш.

И снова хорошо, что я сперва присмотрелся к строчкам, а не к секундам. Статус — 304, не 200. Размер — 310 байт, не вес картинки. 304 значит «Not Modified»: браузер спрашивает «миниатюра не менялась?», сервер отвечает «нет, бери свою из кэша». Сама картинка не передаётся — те 310 байт это пустой ответ с заголовками. То есть в кэше браузера она уже лежит, пользователь видит её мгновенно. Эти 70 мс — не передача данных, а цена самого вопроса: круг до сервера плюс лёгкая работа PHP, чтобы ответить «не менялось».

Почему браузер вообще переспрашивает? Заголовки ответа:

Код:
Cache-Control: private, no-cache, max-age=0
Expires: Thu, 19 Nov 1981 08:52:00 GMT

no-cache здесь и есть приказ «бери из кэша только переспросив», а Expires из 1981 года — древняя заглушка «протухло сорок лет назад». И тут я чуть не закрыл тему выводом, который казался очевидным: XenForo осознанно метит вложения некэшируемыми, потому что проверяет права доступа — картинка из закрытого раздела не должна осесть в кэше и утечь. Логично же. Не трогаем, это защита.

Но я заметил путь, по которому шла миниатюра: /watermark/thumb/. Это не штатный механизм вложений ядра — это аддон водяных знаков. Тот самый, с которого началась вся история про N+1. И раз заголовки ставит его код, а не ядро, я не стал гадать про права — просто открыл исходник. А там оказалось вот что: аддон честно реализовал условное кэширование — выставляет ETag, Last-Modified, отвечает 304 на совпадении. Но поверх этого остался дефолтный заголовок ядра private, no-cache, который автор просто забыл снять. Не защита, не замысел — пропущенная строчка.

И снова та же развилка, что преследовала меня всю дорогу: по заголовкам казалось «фреймворк сознательно защищает вложения, не лезь», а в коде оказалось «аддон не довёл отдачу до конца». Разница между тем, что кажется снаружи, и тем, что написано внутри.

Чинится это аккуратно, но с одной важной оговоркой. Контроллер аддона перед каждой отдачей вызывает canView() — проверку прав. Поэтому поставить public нельзя: закэшированную общим прокси картинку потом отдадут без проверки, и закрытое вложение утечёт. А вот private — можно: он разрешает кэшировать только приватному кэшу самого браузера, не прокси и не CDN. В кэш пользователя попадает лишь то, что ему и так разрешено видеть, права остаются на месте. Я заменил расширением private, no-cache, max-age=0 на private, max-age=604800 — неделя. Те самые 304 превратились в «кэш памяти», 0 мс, без переспроса.

💡 На заметку написал:
Разница между public и private в Cache-Control — это ровно граница между «ускорил» и «открыл приватные данные». public разрешает кэшировать кому угодно по пути, включая прокси и CDN; private — только конечному браузеру. На любом маршруте, который проверяет права доступа, public обходит эту проверку для всех, кто получит файл из общего кэша. Если сомневаетесь — private.

Масштаб, как и с предыдущими мелочами, честный: выигрыш косметический. Картинки и так лежали в кэше браузера и показывались мгновенно — я убрал лишь восемь фоновых переспросов на повторных заходах да снял с PHP столько же ненужных пробуждений. Скорость, которую видит посетитель, не изменилась. Но поправить стоило — хотя бы потому, что причина была не «так задумано», а «забыли».

Как правильно замерять (и как я трижды ошибся)​


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

Грабля первая: внешний curl. Замер снаружи показал разницу в 183 мс. Но абсолютные числа были подозрительно большими — 510 мс против 693. Разгадка: внешний curl меряет не только работу сервера, а ещё и канал до него, и TLS-хендшейк на каждой итерации. Серверная работа тонула в транспорте.

Грабля вторая: localhost по HTTP. Тогда я стал бить по 127.0.0.1 — и получил 0.5 мс. Полмиллисекунды! За такое время PHP не стартует, XenForo не грузится. Оказалось, curl http://127.0.0.1/ получал от nginx мгновенный редирект 301 на https и до PHP вообще не доходил. Я мерил скорость, с которой nginx говорит «иди на https».

Грабля третья: localhost по HTTPS без keepalive. Через --resolve я пошёл по https на петлю — и получил ~400 мс, втрое больше реального серверного времени. Через петлю время не может вырасти втрое; виноват был TLS-хендшейк, который curl делал заново на каждый из 20 запросов.

Правда вылезла только когда я снял замеры из реального браузера и выгрузил HAR. В HAR есть фаза wait — это чистое время сервера от «запрос ушёл» до «первый байт пришёл», на уже установленном keepalive-соединении, без TLS и без канала. Четыре замера с preload против четырёх без:

Код:
С preload:   медиана 232 мс  (диапазон 213–358)
Без preload: медиана 499 мс  (диапазон 439–579)

Вот теперь чисто. Два набора не пересекаются вообще, минимумы дают 213 против 439. Preload снимает порядка 230–260 мс — примерно половину серверного времени под админом.

Почему так много? Под админкой XenForo линкует заметно больше кода, чем под обычным посетителем, а на форуме с десятком аддонов граф классов огромный. Без preload весь этот граф разрешается заново на каждый запрос; preload разрешает иерархии наследования один раз при старте. Чем больше кодовая база и чем тяжелее путь — тем жирнее выигрыш. Админ под нагруженным форумом — это худший случай для холодной линковки, поэтому здесь эффект максимальный. Обычный посетитель в абсолюте выиграет меньше, но направление то же.

💡 На заметку написал:
Главный урок этого замера даже не про preload, а про методику: убирайте из измерения всё, что не измеряете. Сеть, TLS, редиректы — каждый из них умеет подмешать сотни миллисекунд и увести вывод в сторону. HAR из браузера с его раздельными фазами wait и receive оказался честнее любого curl.

Чему меня научили ложные цифры​


Если из всей этой истории выкинуть аддоны, запросы и preload и оставить что-то одно — я бы оставил вот это. За время работы инструмент наблюдения соврал мне дважды, причём по-разному, и каждый раз едва не увёл в сторону.

Первый раз — debug-панель. Она показала гостю 49 запросов на главной, и я уже занёс руку чинить «некэшируемую» страницу. А страница кэшировалась прекрасно — это был HIT. Просто debug привязан к моему IP, а под ним XenForo не отдаёт кэш, чтобы не закэшировать заодно и отладочную панель. То есть сам акт наблюдения отключил то, за чем я наблюдал: я мерил не страницу, а страницу-под-микроскопом, а это другой объект.

Второй раз — замер времени, и тут инструмент врал трижды подряд (про это был отдельный раздел): curl снаружи мерил мой канал до сервера, localhost ловил редирект на https, curl без keepalive переустанавливал шифрование на каждой итерации. Три попытки — три разных неправильных числа, пока HAR из браузера не дал чистую серверную фазу. А когда я следом полез проверять TLS-хендшейк, та же болезнь поджидала и там — но это уже сюжет для отдельной заметки.

Складывается простой принцип. Цифра, которую выдаёт инструмент, — это цифра про связку «объект плюс инструмент», а не про объект. Ни debug-панель, ни curl не врали в техническом смысле — они честно измеряли ровно то, что измеряли. Врал я, когда принимал их число за свойство сервера.

Дешёвая защита от этого — не верить одному измерению, а сверять его с другим, добытым принципиально иначе. Серверное время у меня в итоге пришло с трёх сторон: page time из debug-панели изнутри PHP, фаза wait из браузерного HAR и попытки curl снаружи. Когда два метода расходятся втрое — странный не объект, врёт метод, и надо искать, который из двух. Сошлись в итоге debug и HAR, а curl с его полусекундами оказался лишним — он мерил транспорт, а не сервер.

Это знание обошлось мне дороже по времени, чем сами фиксы. Но оно и ценнее: аддон я починил один раз, а привычку проверять цифру фактом утащу с собой в каждую следующую задачу.

Итоги​


Путь получился длинный, поэтому соберу всё в одну картину:

  • Главная под участником: 88 → 15 запросов. Три устранённых N+1 (водяные знаки, Featured, обложки трендинга) плюс починка эффекта identity map.
  • Гость: отдача из page cache (HIT). Он, как выяснилось, работал и так — а Ads Manager ему не мешает, потому что считает показы на клиенте.
  • Preload: минус ~230 мс серверного времени на тяжёлом пути — измерено по HAR.
  • Лишние счётчики и фильтры гостей/роботов скрыты, когда их активность не пишется.

И несколько выводов, которые я забираю с собой:

  1. Сначала измеряй, потом чини. Я дважды чуть не бросился чинить то, что работало (гостевой кэш), и трижды получил ложные цифры из-за методики замера. Факт надо проверять, а не додумывать.
  2. N+1 прячется в шаблонах. Самые жирные потери были не в кривых запросах, а в невинных обращениях к связям сущностей, размноженных циклом по карточкам. Ищи повторяющиеся одинаковые запросы в debug — это первый признак.
  3. Правь чужой код расширением, а не напильником. Class Extensions переживают обновления; правки в файлах аддона — нет.
  4. Инструмент измерения искажает измеряемое. Debug-панель раздувала счётчик запросов, внешний curl — время. Это нормально, надо просто про это помнить.
  5. Знай, где остановиться. После preload форум упёрся в разумный потолок. Дальше лежат вещи вроде Redis вместо локального SQLite (выигрыш — единицы миллисекунд) или CDN (в рунете — отдельная боль). Гнаться за абсолютным минимумом ради цифры, которую видишь только ты в debug-панели, — плохая сделка. Сайт стал быстрым; на этом я и остановился.

И последнее, ещё раз, потому что это важно: после всех замеров не забудьте выключить $config['debug']. С живым page cache это уже не вопрос гигиены, а вопрос того, чтобы ваша внутренняя кухня не уехала в кэш на всеобщее обозрение.
Об авторе
Guru
Василий, cистемный админ /gnu/linux/windows/macos/mikrotik/troubleshooter, создатель сайта
Интересуюсь всем что делает инфраструктуру быстрой и надёжной
Открыт к общению и проектам, написать мне можно через форму или в личном сообщении

❗ Если есть пожелания по обзору какого-либо вопроса не представленного на сайте - пиши в комментариях

Комментарии

Нет комментариев для отображения.

Информация о статье

Автор
Guru
Время чтения статьи
13 мин чтения
Просмотры
9
Посл. обновление

Ещё в Linux

Ещё от Guru

Поделиться этой статьёй

Назад
Верх