Статьи / Бекэнд на fastify для няшных котят (aura8)
Мы разработали приложение, где пользователь может свайпать картинки, и с учетом лайков пользователя мы генерируем новые картинки. Ниже рассказ, с чем интересным мы столкнулись во время разработки этого приложения.
Выбор ID
Самое сложное в разработке, это придумывать имена переменным и функциям. И еще сложнее придумать формат ID для сущностей.
В этом проекте будет много картинок, и много пользователей, поэтому ID в формате GUID, или UUID, это слишком накладно для индексации в таблицах, и дорого с точки зрения нагрузки на бекэнд. Нужен ID, который «инкрементный», то есть новый ID добавляется в конец индекса в таблице, и в тоже время ID должен быть уникальный.
Мы остановились на ID вида 19bc19e00ab-eh5kkeh99q7, где первая половина это время, переведенное в HEX. А вторая половина ID это случайный набор символов. Тире между этими двумя половинами не имеет смысла, но так нагляднее, и легче читается.
function generateID(): string {
const timestamp = Date.now().toString(16);
const randomPart = generateRandomSequence( 11 );
return timestamp + '-' + randomPart;
}
Такой ID условно инкрементный, так как меняется с каждой миллисекундой, и достаточно уникальный.
Делать ли поле created в таблицах?
У выбранного формата ID есть небольшой плюс, не нужно делать в таблице поле «Дата создания», так как дату создания можно декодировать из ID. Да, немного неудобно строить фильтры для выборки по дате создания, но отсутствие дополнительного поля перевешивает неудобства выборки.
Bloom фильтры
Предполагается, что приложение будет работать с высокой нагрузкой, тысячи одновременных сессий, и десятки тысяч картинок для построения ленты изображений для пользователя. При этом бекэнд приложения должен быть очень дешевым в плане потребления ресурсов процессора и памяти. Самая ресурсоемкая задача, это учитывать какие картинки пользователь уже смотрел, и подбор следующих картинок для просмотра пользователя. Очевидно, что «традиционное» решение через SQL-запросы к базе данных, это очень накладно и медленно, и неприменимо. Поэтому мы используем bloom фильтр.
Если вдруг вы не в теме, то bloom фильтр, это очень быстрый фильтр, который с 99,9999% вероятности определяет, что проверяемого ID нет в массиве из десятков тысяч ID. В плане нагрузки на процессор и память, экономия колоссальная.
Авторизация в приложении
Не хочется заставлять пользователей авторизоваться в приложении, так как пользователи не любят авторизацию. Кроме того, приложения без авторизации легче проходят модерацию в магазинах приложений.
Но идентифицировать пользователей нужно. Поэтому при установке приложения генерируем ID, и сохраняем в приложении.
Что, если какие либо энтузиасты будут генерировать эти ID и отправлять на бекэнд? Добавляем limitRate, и проверяем новые ID на корректность. Про limitRate ниже, а вот логика проверки новых пользователей.
1. При старте бекэнда загружаем все ID существующих пользователей в Bloom фильтр.
2. Запрос на добавление пользователя, новый ID проверяем на корректность сначала на уровне fastify, объявив JSON схему запроса:
fastify.post( '/backend', {
schema: {
body: {
type: 'object',
required: [ 'device_id' ],
properties: {
device_id: {
type: 'string',
minLength: 23,
maxLength: 23,
pattern: '^[a-z0-9]{11}-[a-z0-9]{11}$'
}
}
}
}
3. Если новый ID прошел проверку в fastify, то проверяем ID по bloom фильтру пользователей. Если ID нет в массиве пользователей, то это новый пользователь.
4. Проверяем ID нового пользователя на корректность.
Первая половина ID, это закодированная в ID дата, эта дата должна быть в периоде между датой запуска нашего проекта, и датой сегодня.
Как проверить вторую половину, которая случайный набор символов? Если энтузиасты будут генерировать ID типа 19bc19e00ab-aaaaaaaaaaaa? Вторую половину ID проверяем на энтропию, энтропия должна быть больше 2.
function getEntropyString( str: string ): number {
const frequencies = new Map();
for (const char of str) {
frequencies.set(char, (frequencies.get(char) || 0) + 1);
}
return [...frequencies.values()].reduce((acc, count) => {
const p = count / str.length;
return acc - p * Math.log2(p);
}, 0);
}
5. Если новый ID корректный, то добавляем его в массив bloom фильтра существующих пользователей, и отправляем INSERT в таблицу пользователей, не ожидая завершение INSERT. Да, есть проблема, с вставкой нового пользователя может что то пойти не так, и по хорошему нужно сначала завершить вставку, обработать ошибку, и только потом считать этого пользователя за существующего. Но это накладно с точки зрения нагрузки на бекэнд, смиримся с тем, что есть вероятность потери какого то пользователя после рестарта бекэнда. Под «потерей пользователя» подразумевается, что при рестарте бекэнда, этот пользователь будет считаться новым, по нему не сохранится история свайпов картинок.
Статичный контент через nginx
Бекенд приложения собирает статистику лайков по картинкам, генерирует новые картинки, и складывает новые картинки в папки, структура папок Год — Месяц — День. В приложении картинки подгружаются по URL. Но как отдавать статичный контент с бекэнда, чтобы это происходило с минимальной нагрузкой на сервер? Да, в fastify есть возможность раздавать статичный контент, и это реализовано хорошо. Но еще лучше на бекэнд сервере запустить nginx, под отдельным доменом, и настроить раздачу контента из папки, куда бекэнд приложение складывает картинки.
Серия картинок для пользователя
Для пользователя генерируем новую серию URL картинок для просмотра, сериями по 10 штук. ID картинок в серии, добавляем в bloom фильтр «Просмотренные картинки» для каждого пользователя. При генерации новой серии проверяем, была ли эта картинка в предыдущих сериях, по bloom фильтру для этого пользователя.
Таким образом, бекэнд и мобильное приложение общаются сериями по 10 картинок.
1. При старте мобильного приложения загружается две серии по 10 штук.
2. Далее, каждые 10 лайков, мобильное приложение отправляет реакции на бекэнд, и получает новую серию 10 картинок.
Пока пользователь лайкает серию из 10 картинок, приложение отдает лайки по предыдущим 10 картинкам, и получает новую серию из 10 следующих картинок.
Ограничение по количеству запросов
В fastify есть rateLimit, ограничивающий количество запросов от одного пользователя. RateLimit можно настроить на использование redis как хранилища IP адресов и времени последних запросов. Но если бекэнд сервер пока один, то можно обойтись без redis, хранить все в памяти node.js приложения.
Проверка лайков по картинкам
Мобильное приложение собирает реакции пользователей, и отправляет реакции на бекэнд пачками, по 10 штук. Одна реакция это ID картинки, и лайк или дизлайк. Опять же, как решить проблему с энтузиастами, которые могут генерировать эти реакции, и завалить бекэнд? Добавляем limitRate, и проверяем реакции по следующей логике:
1. ID пользователя и ID картинки должны пройти проверку на уровне fastify схемы. Реакции, лайк или дизлайк, можно также проверять как enum в fastify схеме.
2. ID пользователя должен пройти проверку по bloom фильтру «Существующие пользователи».
3. ID картинки должен пройти проверку по bloom фильтру «Просмотренные картинки» для этого пользователя.
4. На всякий случай, проверяем лайки на чередование, если пользователь просто смахивает картинки в одну сторону, или чередует влево — вправо, то эта серия реакция не заслуживает доверия, и игнорируется:
function trustLikeInBatch10( batch: string[] ): boolean {
if( batch.length != feedLimit ) return false;
const likes = batch.filter( v => v === 'like' ).length;
if( likes === 0 || likes === feedLimit ) return false;
const switches = batch.slice(1).reduce( ( acc, val, i ) => acc + ( val !== batch[ i ] ? 1 : 0 ), 0 );
if( switches >= 8 ) return false;
return true;
}
5. Если ID пользователя, и ID картинки, прошли проверки, то эту реакцию можно отправлять как INSERT в таблицу реакция в базу данных, причем опять, не ждем завершения вставки, и не обрабатываем ошибки вставки, чтобы уменьшить нагрузку на бекэнд. Более того, в таблице реакций не делаем CONSTRAINT FOREIGN KEY по ID таблицы пользователей, и по ID таблицы картинок, так это лишняя нагрузка на базу данных при вставке. Хотя отсутствие CONSTRAINT FOREIGN KEY в связанных таблицах выглядит очень непривычно.
Маскируем ошибки валидации fastify
Снова вспомним про энтузиастов, которые могут попробовать положить бекэнд приложения, из любопытства, или еще непонятно для чего. Чтобы чуть усложнить жизнь таким энтузиастам, мы маскируем ошибки fastify при валидации JSON схемы запроса. По умолчанию fastify сообщает в ответе, в чем именно ошибка. Если в объявленной схеме указана длина, или паттерн, то при ошибке fastify в ответе укажет, в чем именно проблема.
Поэтому в fastify route добавляем перехват ошибки:
fastify.setErrorHandler((
error: FastifyError,
request: FastifyRequest,
reply: FastifyReply
) => {
console.error( 'setErrorHandler', error );
countErrorStatistics();
reply.code(200).send( 'Hello World!' );
});
Теоретически, мобильное приложение тоже может получить в ответе от бекэнда вместо нормальной ошибки, вот такую замаскированную ошибку. Это нужно учитывать на стороне мобильного приложения.
Сделать тип из массива строк
Небольшая проблема, у которой нашлось решение. Есть какой то массив строк, например перечень состояний картинки. Картинка может быть новой, может быть в состоянии генерации, в состоянии ошибки, и т.п. В тоже время, в коде бекэнда у переменных, в которых хранится состояние картинки, должен быть тип, допускающий присвоение только таких значений. То есть в коде должен быть и массив строк, и тип с этими строками. В TypeScript нашлась элегантная конструкция для этого:
// Usually it's like this:
const imageStateArray1 = [ 'new', 'broken', 'gpt', 'ready', 'download', 'done', 'bad' ];
type tpImageState1 = 'new' | 'broken' | 'gpt' | 'ready' | 'download' | 'done' | 'bad';
// or better yet:
const imageStateArray2 = [ 'new', 'broken', 'gpt', 'ready', 'download', 'done', 'bad' ] as const;
type tpImageState2 = ( typeof imageStateArray2 )[ number ];
Резюме по проекту
Резюме, фактически приложение может жить само по себе, без базы данных. После старта бекэнд приложения, после загрузки ID пользователей, и ID картинок, в память приложения, из запросов к базе данных осталась только вставка новых пользователей, и вставка реакций. И эти вставки происходят без await, чтобы уменьшить время обработки запросов. Внутри контроллера, который обрабатывает запросы со стороны мобильного приложения, только математика и работа с bloom фильтрами.
Что дальше
В следующих постах расскажу о последовательности промтов для генерации картинок, а также собственно о мобильном приложении.







