Шумодав внутри форка libwebrtc, GraphQL за восемь часов и аудит API из 402 методов
Разработка Squader, проблемы и помощь AI-агента. А стоит ли?
Несколько последних месяцев я делаю Squader - приложение для поиска тиммейтов в онлайн-игры. Если коротко: анкеты "ищу тиму в CS2/Dota/Valorant" с фильтрами по рангу и роли, кланы с текстовыми и голосовыми каналами, события с календарём, личные чаты и звонки, шеринг экрана на Windows.
Проектов внутри несколько, и все в одном репозитории: мобильное приложение и десктоп на Flutter (Android и Windows из одного кода), бэкенд на .NET с Postgres и Redis, админка на React и публичный сайт для поисковиков. Голос - LiveKit на отдельной машине, события в реальном времени - WebSocket через Centrifugo.
И почти всё это время я пишу код не один, а с ИИ-агентами. Это не статья про "ИИ заменит программистов". Это три с половиной истории о том, где агент реально выручил: что было за проблема, как решили, что из этого вышло. С кодом. В конце - советы и мысли о том, кем теперь становится разработчик.
Кейс 1. Шумоподавление: RNNoise внутри форка libwebrtc
Голосовой чат завёлся быстро - livekit_client на клиенте, свой LiveKit на сервере. А потом посыпались жалобы: "клавиатура барабанит", "слышно соседа по комнате". Шумодав, встроенный в WebRTC, - спектральный: постоянный фон он давит нормально (вентилятор, дорога за окном), но клавиши и голос за стеной - нет. Платный Krisp у LiveKit отпал сразу: он только в их облаке (а сервер у меня свой), и во Flutter-пакете нет Windows.
Ответ, в общем-то, известный - RNNoise от Xiph. Маленькая рекуррентная сеть, обученная отличать речь от всего остального, BSD-лицензия, нативный C. Не "купить и подключить", а именно библиотека. Чтобы впаять её в звонок на Flutter, надо:
- сделать форк
flutter_webrtcи собрать его под Windows - со всеми зависимостями libwebrtc; - врезаться в аудиоконвейер. libwebrtc пускает стороннюю обработку микрофона ровно в одно место -
RTCAudioProcessing::SetCapturePostProcessing- и добраться туда можно только изнутри плагина; - разобраться с частотой. RNNoise работает строго на 48 кГц и строго с кадром в 480 сэмплов. А конвейер WebRTC вызывает обработку на своей рабочей частоте - на живом звонке мне встречалась и 16 кГц, то есть 160 сэмплов в кадре.
Пункт третий - это как раз то место, где такое обычно умирает. Что получилось в итоге (упрощённо, из native/rnnoise_denoiser.h):
// Ядро шумоподавления: RNNoise плюс запирание пауз. Ни одного include из
// libwebrtc - сознательно: ядро собирается и для Windows- и Android-плагинов.
//
// Частота - главная сложность. APM зовёт обработку кадрами по 10 мс, но на своей
// рабочей частоте, и она не обязана быть 48 кГц. Поэтому звук приводится к 48 кГц
// и обратно (RationalResampler) - так же поступает всякий, кто встраивает rnnoise.
class RnnoiseDenoiser {
public:
// Поверх сети - запирание пауз по вероятности голоса, которую сеть и так
// возвращает. Насколько жёстко - решает ступень: 0 - только сеть, 1 - до -30 дБ,
// 2 - до -60 дБ.
void SetLevel(int level);
void ProcessFrame(float* samples, size_t count);
};
Пара решений, которые агент внёс, пока я читал диффы - оба мне самому в голову не пришли бы:
- Шкала сэмплов определяется по первым секундам звука: внешний код не документирует, отдаёт он нормализованные float или "как int16". Ядро различает это само - ошибка тут беззвучна и верить на слово нельзя.
- Запирание пауз поверх сети. RNNoise обучали "не навредить речи", поэтому она осторожничает - и остаточный шум слышен как раз в тишине между словами. Специальная логика доводит эти куски до нуля по вероятности голоса, которую сеть и так возвращает.
На Windows ядро оборачивается в RTCAudioProcessing::CustomProcessing, на Android JNI-мост регистрируется в AudioProcessingController.capturePostProcessing. Со стороны Dart это два метода: setNoiseSuppressor и getNoiseSuppressorStats. Второй возвращает счётчик обработанных кадров - чтобы отличать "включено" от "работает". Счётчик я вывел прямо на экран настроек: пусть человек видит, что фильтр живой.
И бонус из того же форка, который я бы искал сам очень долго. Симптом был странный: "игра тихнет ровно на те минуты, когда в канале появляется второй человек". Оказалось, WebRTC на Windows открывает вывод звука с коммуникационной ролью - и Windows, пока играет "звонок", приглушает все остальные звуки машины. Лечится открытием устройства вывода по индексу, без роли.
Что я из этого вынес: ИИ сильно удешевил мне вход в чужие C++-кодбейсы. Форк libwebrtc раньше был автоматическим "нет, спасибо". Теперь это задача на несколько вечеров - при том что на C++ я не пишу.
Кейс 2. GraphQL поверх REST за восемь часов
Сначала клиент общался с бэкендом по REST: около 400 клиентских методов на 48 контроллерах. Методы я изначально делал мелкими и атомарными - чтобы под одни и те же эндпоинты легко адаптировать и мобилку, и десктоп, и сайт. Работало нормально, но в какой-то момент я начал замечать цепочки запросов друг за другом: экран клана - это 6–8 запросов подряд, и клиент ждёт каждый. Решил переехать на GraphQL не ради моды: пусть запросы выполняются параллельно на сервере внутри кластера, где задержки между сервисами - доли миллисекунды, а не друг за другом с телефона через мобильный интернет.
Переписывать 402 метода на полноценный GraphQL с нуля не хотелось: API уже работает у пользователей. Поэтому схема такая: тонкий прокси только на чтение поверх существующего REST, схема строится автоматически из OpenAPI. Идея моя, реализовывал агент - на бэкенде это заняло около трёх часов, перевод Flutter-клиента ещё около пяти.
Как это устроено (services/graphql, .NET + graphql-dotnet):
- На старте прокси скачивает клиентский OpenAPI-документ бэкенда и по SHA-256 хэшу решает, надо ли перестраивать схему - правки REST не требуют правок прокси. Схема также перестраивается по таймеру и по сигналу.
- Из документа строится схема: тег операции (ASP.NET ставит его по имени контроллера) становится корневым полем, маршрут - листовым внутри.
/api/games/{id}→games.byId. Набралось 186 полей только на чтение. - Резолвер у всех полей один: подставить аргументы GraphQL в шаблон REST-пути, позвать бэкенд и разобрать стандартную обёртку ответа:
public sealed class RestProxyResolver(
string fieldName,
string pathTemplate,
IReadOnlyList<string> pathParameters) : IFieldResolver
{
private async Task<object?> ResolveCoreAsync(IResolveFieldContext context)
{
var caller = services.GetRequiredService<IRestCaller>();
var headers = ForwardedHeaders.Collect(httpContext); // allowlist: Authorization, Accept-Language, X-Device-Id, X-Real-IP
var pathAndQuery = RequestUriBuilder.BuildPathAndQuery(
pathTemplate, pathParameters, CollectArgumentValues(context));
var result = await caller.GetAsync(pathAndQuery, headers, context.CancellationToken);
// 401 -> ошибка с кодом "пора обновить токен"; 429 -> retryAfterSeconds наружу
...
}
}
- Мутаций нет - специально. Вся запись (~240 мест в клиенте), загрузка файлов, обновление токена и realtime остались на REST. Это снимает почти всю сложность GraphQL - кэширование, вложенные мутации, права на поля, - а выгоды миграции даёт целиком.
Что это дало против чистого REST:
- Один запрос вместо шести. Через алиасы можно собрать данные экрана клана - профиль, участники, каналы, события, роли - за один заход. На телефоне в метро это разница между "открылось" и "крутилка".
- Не появилось "версии 2.0" API. Прокси - отдельный сервис, REST не тронут ни строчкой. Если что-то пойдёт не так - переключаю клиента обратно, час работы.
- Самодокументируемость: GraphiQL в проде служит документацией, которая генерируется из того же кода и потому не может устареть.
- Предсказуемые ошибки. Три кода (
REST_ERROR,REST_HTTP_ERROR,TRANSPORT_ERROR), при 429 -retryAfterSeconds, при 401 - сигнал обновить токен. Обработка ошибок стала одинаковой для всех запросов.
На стороне Flutter таблица "REST-маршрут → GraphQL-поле" сгенерирована из контроллеров, так что клиент переехал механически:
/// Ключ - шаблон REST-маршрута, в точности как в OpenAPI-документе,
/// из которого прокси строит схему. Генерируется, правки руками не вносятся.
const Map<String, GqlRoute> gqlRoutes = {
'/anketas/chats/mine': GqlRoute('anketas', 'chatsMine'),
'/anketas/{id}': GqlRoute('anketas', 'byId'),
'/calendar/clans/{id}': GqlRoute('calendar', 'clansById'),
...
};
Главное, что я понял: агент не придумал за меня архитектуру - но её реализация стала стоить копейки. От идеи до рабочих сборок - один рабочий день. И ещё: перед кодом агент сам свёл полный инвентарь всех GET-вызовов клиента в таблицу "экран → запросы → будущие поля". Такой инвентарь на сотни строк обычно никто не делает, потому что "некогда, надо кодить" - а тут он стал почти бесплатным.
Кейс 3. Аудит безопасности: 402 эндпоинта под проверкой
Когда API разрастается до 400+ методов, вопрос "а везде ли стоит авторизация?" превращается в лотерею. Агент прогнал аудит так:
Шаг 0 - сначала документ, а не код. Все 402 клиентских метода выгружены в один документ: контроллер, маршрут, ожидаемый доступ. Дальше аудит шёл строго по списку, каждый эндпоинт отмечается по мере проверки - ничего не теряется, и в конце видно, что покрыто всё.
Шаг 1 - авторизация. Живые запросы во все эндпоинты без токена: 373 вернули 401, 29 - легитимно публичные (логин, публичные профили, версии приложения). Пропущенных проверок не нашлось. По коду контроллеров это глазами проверяется за час-два; живые запросы дольше, но ловят то, чего в коде не видно - когда проверка вроде стоит, но общий код обработки запроса её случайно отключает.
Шаг 2 - каждый эндпоинт проверялся с тремя наборами данных: валидными (должно отработать), невалидными (мусор в параметрах - вместо внутренней ошибки 500 должен прийти аккуратный 400) и неразрешёнными (валидные данные, но чужие: имеет ли право этот пользователь читать или менять этот объект). Последний набор - охота на IDOR: когда по честному, но чужому идентификатору эндпоинт отдаёт то, чего трогать нельзя.
Шаг 3 - ревью всех write-методов на то же самое, но по коду: где проверяется владение объектом, а где просто подразумевается.
Главная находка вылезла на "неразрешённых" данных, и она серьёзнее опечаток в валидаторах. Отдача файлов проверяла только "человек залогинен", а не "этому человеку можно этот файл": эндпоинт после проверки логина просто отдавал файл по идентификатору (GUID). Любой залогиненный мог скачать чужой файл, узнав или перебрав ссылку. А GUID утекает легко - он в url предпросмотра ссылок в чатах. Личные файлы, черновики - всё было доступно любому пользователю приложения.
Фикс - области видимости прямо в модели файла (CloudFiles.ScopeType/ScopeId) и одна проверка перед отдачей:
public async Task<bool> CanReadAsync(CloudFile file, long? userId, ...)
{
// Публичные читаются без сессии: сборки приложения по ссылке и клановая
// графика в карточке приглашения, которую разбирает чужой сервер предпросмотра.
if (file.IsPublic) return true;
if (userId is not { } callerId) return false;
// Свой файл отдаётся всегда: человека, выгнанного из клана, авторство не отбирает.
if (file.OwnerUserId == callerId) return true;
return file.ScopeType switch
{
// Не прикреплён ни к чему и не свой - отказ
FileScopeType.Unbound => false,
FileScopeType.Authenticated => true,
FileScopeType.Clan => await CanReadClanAsync(file.ScopeId!.Value, callerId, ...),
FileScopeType.Chat => await CanReadChatAsync(file.ScopeId!.Value, callerId, ...),
FileScopeType.Team => await _teamService.IsActiveMemberAsync(...),
// Область из будущей версии, которую этот экземпляр не умеет читать. Отказ,
// а не разрешение: неизвестное правило доступа - это не отсутствие правила.
_ => false,
};
}
Посмотрите на последнюю ветку: _ => false. Это не забытый дефолт. Если новая версия кода добавит новый тип области, а старый экземпляр его не знает - будет отказ, а не дыра. В ручном коде такое легко остаётся в категории "ну добавим потом".
Были и мелочи: опросы кланов без валидатора на обновление (вопрос неограниченной длины ронял эндпоинт), поиск людей без потолка на длину запроса. Всё закрыто валидаторами.
После аудита тем же агентом добавлен rate limiting: счётчики на Redis, для запросов без логина (логин, почта) - по IP, для остальных - по действию; при превышении (429) клиенту отдаётся retryAfterSeconds.
Отдельно про Centrifugo. У него есть канальные прокси: перед подпиской пользователя на канал Centrifugo спрашивает у бэкенда, можно ли. Пока этого не было, тихо слушать канал клана мог фактически любой пользователь. Для голосовых каналов кланов и команд включён subscribe_proxy: право "состоит ли пользователь в клане" проверяется на каждом подключении, а не один раз при выдаче:
{
"name": "clan",
"allow_subscribe_for_client": true,
"presence": true,
"subscribe_proxy_enabled": true,
"subscribe_proxy_name": "backend"
}
Плюс: секрет прокси обязателен - без него Centrifugo не запустится, админ-панель выключена, публикация сообщений с клиента запрещена.
Что я вынес. Полный аудит API стал реалистичен для проекта одного человека - и это не "агент считает, что всё хорошо", а проверяемые факты: реальные запросы, реальные коды ответов, отчёт по каждому эндпоинту. Есть старое правило безопасности: стоимость защиты должна быть соразмерна стоимости защищаемых данных. Раньше оно работало против инди-проектов - нормальный аудит стоил дороже, чем один разработчик мог себе позволить. С ИИ аудит дешевеет на порядок, и это правило начинает работать в обе стороны.
Кейс 4. Защита и оптимизация мобильного приложения
Короткая история, но показательная - про знание платформ, которое у обычного Flutter-разработчика размытое.
Скрытие адреса бэкенда в бинарнике. Я был уверен, что флаг --obfuscate прячет константы. Агент показал, что нет: obfuscate переименовывает символы Dart, а строковые литералы не трогает. strings libapp.so на релизной сборке печатает адрес бэкенда как есть. Решение - пакет envied: значение хранится как массив байтов, XOR-ённый с ключом, который перегенерируется на каждой сборке:
@Envied(path: '.env', requireEnvFile: false)
abstract final class Env {
@EnviedField(
varName: 'SQUAD_API_DOMAIN',
obfuscate: true, // XOR с перегенерируемым ключом вместо строкового литерала
defaultValue: 'squader.pro',
)
static final String apiDomain = _Env.apiDomain;
}
В комментарии к классу агент честно записал: это поднимает цену чтения бинарника, но не делает значение секретом - ключ едет в том же бинарнике. Такая честность ("вот что это защищает, а вот что нет") - возможно, самое ценное в работе с агентом.
Привязка сертификата. Пиннинг - это когда приложение проверяет не просто "сертификат сервера валиден", а "выдан конкретным центром сертификации". На Android это делает система через network_security_config.xml, но на Windows его никто не читает, а шифрование в десктопном Flutter делает BoringSSL со своим хранилищем корней. Решение - SecurityContext с пустым хранилищем доверия и ровно тремя корнями Let's Encrypt:
/// withTrustedRoots: false - доверяем только корням ISRG, зашитым ниже.
/// Корням, а не листовому сертификату: лист перевыпускается каждые ~90 дней,
/// пиннинг листа окирпичил бы все установленные копии при следующем обновлении.
/// И предохранитель: после даты _pinsExpire возвращаем null и валидируем как все -
/// если бэкенд уедет с Let's Encrypt, а этот файл забудут поправить,
/// приложения восстановятся сами, а не останутся навсегда офлайн.
Тут два решения, за которые отдельное спасибо: пиннинг корней, а не самого сертификата сервера, и аварийная дата, после которой проверка сама отключается.
Разбивка по архитектурам. Android-сборка бьётся на отдельные нативные библиотеки: arm64-v8a, armeabi-v7a и x86_64 - устройству достаётся только его архитектура. Про x86_64 скажу отдельно: телефонов на нём почти нет, живут эти библиотеки в основном в эмуляторах. Так что вырезание x86_64 из релиза - не только минус десяток мегабайт, но и защита: гонять приложение в эмуляторе, чтобы перехватывать трафик и ковырять его в отладчике, становится заметно неудобнее.
Советы: что реально работает
- Пусть агент сначала пишет документы, а не код. Самые полезные артефакты у меня - не коммиты, а инвентаризации: таблица всех REST-вызовов перед миграцией, список всех эндпоинтов перед аудитом, правила транзакций ("ничего внешнего - пуши, S3, голосовой сервер - внутри транзакции базы"). Документ на 300 строк стоит копейки, а решение на его основе - уже осознанное.
- Комментируйте код - агенты по комментариям понимают его в разы лучше. Один комментарий "зачем эта ветка switch" держит контекст всего решения; без него агент через неделю предложит "оптимизацию", которая ломает замысел.
- Каждый вывод агента должен быть проверяемым действием. "Кажется, авторизация везде" - это ничто. 402 живых запроса без токена - это факт. Просите агента проверять, а не рассуждать.
- Архитектуру решаете вы. Чем точнее постановка, тем меньше шанс получить уверенно выглядящую ерунду. Своего решения нет - просите альтернативы с минусами каждой.
- ИИ удешевляет "правильное, но скучное": аудит, форки, тесты, документацию - всё то, что обычно съедают дедлайны. Тратьте сэкономленное именно туда.
- Читайте диффы. Всегда. Ревью никуда не делось - оно стало важнее, потому что чужого кода в сутки теперь в разы больше.
Что стало лучше. Скорость на рутине и на исследовании (форк C++-библиотеки за вечера!). Исчез паралич чистого листа - "новый экран" стартует за час. Бесплатный второй взгляд на безопасность. И, неожиданно, код стал читабельнее: агент пишет комментарии, объясняющие почему, а не что, - я бы сам часто поленился.
Что стало хуже. Агент уверенно выдумывает API библиотек, особенно свежих версий: livekit_client, .NET 10 - галлюцинации приходится ловить компилятором. Без контроля расползается объём: просишь одно - получаешь "заодно улучшил" три файла. И скорость без дисциплины - это скорость накопления долга, просто долга теперь больше в единицу времени.
Кем стал разработчик и что дальше
Сейчас руками я пишу, может, 10–20% кода. Остальное время - постановка задач, архитектурные решения, чтение диффов и проверка на живом проде. Фактически я стал техлидом маленькой команды, где остальные члены - агенты: они не устают проверять 402 эндпоинта и работают ночью.
Что дальше, как мне видится:
- Аудит станет непрерывным: проверки авторизации и мусорными данными будут гоняться на каждый пул-реквест, а не раз в год.
- Умение читать код станет дороже умения писать. Проверить чужое решение - последний навык, который нельзя делегировать.
- Джунов как класса станет меньше, но путь до сеньора - короче для тех, кто научится управлять агентами. Хуже всех сейчас "середине": достаточно опытному, чтобы доверять своим знаниям, и недостаточно, чтобы ловить агента на ошибках.
- Цена ошибки постановки вырастет. Когда исполнение дешёвое, вся стоимость ошибки переезжает в стадию "что именно мы делаем".
ИИ меня не заменил. Он убрал оправдание "руки не дойдут" - шумоподавление, миграция и аудит дошли за несколько месяцев и уже работают. Если хотите послушать тот самый шумодав в деле и заодно найти себе тиму на вечер - Squader.
Расскажите в комментариях, где ИИ вас выручил (или подставил) - сравним шрамы.
Комментарии (0)