Анонимные животные

Post Thumbnail

Знаете эти милые аватарки с животными в общих Google-документах? Они появляются у каждого, кто открывает файл по ссылке. Выбрать себе зверюшку заранее или как-то на неё повлиять нельзя - ваш аватар назначается случайно. Чтобы узнать, в кого вы "превратились", придется спросить у кого-нибудь из тех, кто в документе вместе с вами. Хочу реализовать похожую механику на kodikapusta.ru

История появления

Как рассказала в Mental Floss представительница Google Кайри Хармон, у этих зверушек давняя история. Ещё в 2012 году сотрудникам компании захотелось оживить скучноватую платформу для работы с документами и добавить в неё немного веселья.

"Тогда анонимных пользователей обозначали длинными цифровыми кодами, вроде Аноним35123512425, - объясняет Хармон. - Команда начала экспериментировать, и во время мозгового штурма родилась аллитерация Anonymous Animals (Анонимные животные). Идею подхватили дизайнеры и нарисовали первые аватарки".

По словам Хармон, уже никто не помнит, с каких именно животных всё началось, но это были самые обычные звери. Со временем список стал пополняться - в нём появились и более необычные существа, включая даже мифических. Так среди аватарок оказались капибара, аксолотль, улыбающаяся саламандра, знаменитый летающий кот Nyan Cat в форме печенья и даже кракен - гигантское морское чудовище из скандинавских легенд.

Разработчики продолжали фантазировать: добавляли, например, исчезнувшие виды вроде кваги - подвида зебры, вымершего ещё в XIX веке. (С тех пор учёные не раз пытались восстановить её черты, скрещивая разных зебр.)

К 2016 году коллекция разрослась до 68 животных. Тогда в ней ещё не было ни лося, ни тигра, ни ламы, не было и джекалопа - легендарного рогатого зайца, чучела которого до сих пор иногда встречаются на американском Западе. Этих зверей добавили значительно позже.

Сейчас в списке 73 животных, и, кажется, на этом решили остановиться. В Google не переживают, что будет, если документ откроют сразу 74 человека - такое случается настолько редко, что придумывать новых зверей просто нет смысла.

Я хочу сделать что-то похожее, только для сайта kodikapusta.ru

Описание реализации

Бекенд буду пилить на Rust. Я все еще стараюсь выучить этот язык и использовать его для чего-то полезного. Неплохо начать с таких игрушечных проектов.

Определился с основным принципом работы. При заходе пользователя на сайт будет срабатывать JS, который сделает вызов нашего бекенда. На бекенде будет сгенерировано новое животное со своим именем, которое сохранится в базу и вернется мне обратно в скрипт. Чтобы отслеживать только активных пользователей и их животных - будем обновлять поле updated_at каждые 30 секунд для каждого активного пользователя.

Сервис будет очень маленьким и с очень простым API.

Запрос на получение списка всех животных:

GET http://localhost/animals
Content-Type: application/json

Ответ:

[
  {
    "created_at": "2026-01-04T13:57:42.213816023Z",
    "id": 65,
    "image": "зебра_.png",
    "name": "утренняя зебра",
    "updated_at": "2026-01-11T04:18:04.894859093Z"
  },
  {
    "created_at": "2026-01-04T13:57:42.213816023Z",
    "id": 27,
    "image": "лань_.png",
    "name": "быстрая лань",
    "updated_at": "2026-01-11T04:18:04.894859093Z"
  }
]

Запрос на добавление нового животного:

POST http://localhost/animals
Content-Type: application/json

Ответ:

{
  "created_at": "2026-01-11T04:19:58.697597054Z",
  "id": 669,
  "image": "выдра_.png",
  "name": "гордая выдра",
  "updated_at": "2026-01-11T04:19:58.697600669Z"
}

Запрос на обновление updated_at у животного с id 65:

PUT http://localhost/animals
Content-Type: application/json

{
    "id":65
}

Генерация нового животного - это присвоение случайного названия и сохранение в базу. Название формируется из двух случайных слов - прилагательного и названия животного, например: "утренняя зебра". Картинка для животного подбирается в зависимости от названия животного.

Реализация на Rust

Я буду использовать axum как и в прошлой статье. Список активных животных будет храниться в простой SQLite базе в табличке animals. При старте приложения если таблички нет, то она будет создаваться автоматически.

use sqlx::{Pool, Sqlite};
use sqlx::Executor;

pub async fn db_pool() -> Pool<Sqlite>{
    let opt = sqlx::sqlite::SqliteConnectOptions::new().filename("data/animals.db").create_if_missing(true);

    let pool = sqlx::sqlite::SqlitePool::connect_with(opt).await.unwrap();

    pool.execute("
      CREATE TABLE if not exists animals (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT,
        image TEXT,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
      )
    ").await.unwrap();

    pool
}

Нужно реализовать генератор случайных названий для животных. Эту логику я вынес в отдельный трейт и реализовал в файле generator.rs.

use rand::rng;
use rand::seq::{IndexedRandom};

#[derive(Clone)]
pub struct AnimalNameGenerator {
    adjectives: Vec<&'static str>,
    animals: Vec<&'static str>,
}

impl AnimalNameGenerator {
    pub fn new() -> Self {
        AnimalNameGenerator {
            adjectives: vec![
                "быстрая", "медленная", "умная", "хитрая", "гордая", 
                "смелая", "тихая", "громкая", "пушистая", "грациозная",
                "ловкая", "сильная", "неуклюжая", "зоркая", "верная",
                "игривая", "горячая", "холодная", "солнечная", "лунная",
                "северная", "южная", "лесная", "горная", "речная",
                "морская", "степная", "пустынная", "ночная", "утренняя",
                "зимняя", "весенняя", "летняя", "осенняя", "радужная",
                "серебристая", "золотая", "бронзовая", "мраморная", "полосатая",
            ],
            animals: vec![
                "лань", "рысь", "лиса", "волчица", "пантера",
                "пума", "гепард", "антилопа", "зебра", "газель",
                "ласка", "куница", "соболь", "выдра", "норка",
            ],
        }
    }
    
    pub fn generate(&self) -> String {
        let mut rng = rng();
        let adjective = self.adjectives.choose(&mut rng).unwrap_or(&"быстрая");
        let animal = self.animals.choose(&mut rng).unwrap_or(&"лань");
        
        format!("{} {}", adjective, animal)
    }
}

Логика генерации очень простая - в случайном порядке выбирается одно название животного и одно прилагательное для этого животного. Я не стал заморачиваться с родами и правильными окончаниями, так получается даже веселее. Пример как можно использовать генератор:

let generator = AnimalNameGenerator::new();
let name = generator.generate();

Все картинки животных собраны в папку /assets - там лежат как маленькие иконки, названия которых сохраняются в базу вместе с названиями животных, так и большие картинки для наглядности.

Чтобы в методах иметь доступ к базе и генератору имен животных я добавил AppState, который пробрасывается во все наши ручки. Структура содержит всего два поля:

#[derive(Clone)]
pub struct AppState {
    pub db: Pool<Sqlite>,
    pub generator: AnimalNameGenerator,
}

Создается стейт в main.rs и сразу пробрасывается в ручки через механизм axum.

let state = AppState {
    db: db_pool().await,
    generator: AnimalNameGenerator::new(),
};

let router = Router::new()
    .route("/", get(home))
    .route("/animals", post(add))
    .route("/animals", put(update))
    .route("/animals", get(animals))
    .nest_service("/assets", ServeDir::new("assets"))
    .layer(
        CorsLayer::new()
            .allow_methods(Any)
            .allow_headers(Any)
            .allow_origin(Any),
    )
    .with_state(state);

Тут нужно чуть больше пояснений. Код .nest_service("/assets", ServeDir::new("assets")) нужен для раздачи статичных файлов из папки /assets, в которой лежат картинки животных. А дальше настраиваются CORS.

.layer(
    CorsLayer::new()
        .allow_methods(Any)
        .allow_headers(Any)
        .allow_origin(Any),
)

Это нужно чтобы спокойно сервить нашу API-шку с животными на отдельном домене.

Осталось реализовать три ручки для добавления, изменения и получения списка животных. Я не буду тут приводить весь код (его можно посмотреть на gitflic), покажу только как добавляется новое животное с помощью sqlx.

pub async fn add(State(state): State<AppState>) -> impl IntoResponse {
    let name = state.generator.generate();
    let parts: Vec<&str> = name.split_whitespace().collect();
    let image = format!("{}_.png", parts[1]);

    let res = sqlx::query("INSERT INTO animals (name, image, created_at, updated_at) VALUES ($1, $2, $3, $4)")
        .bind(name)
        .bind(image)
        .bind(Utc::now())
        .bind(Utc::now())
        .execute(&state.db)
        .await;

    match res {
        Ok(res) => {
            let id = res.last_insert_rowid();

            let animal = sqlx::query_as::<_, Animal>("SELECT id, name, image, created_at, updated_at FROM animals WHERE id = ?")
                .bind(id)
                .fetch_one(&state.db)
                .await;

            match animal {
                Ok(animal) => {
                    return (StatusCode::OK, Json(json!(animal))).into_response();
                }
                Err(animal) => {
                    println!("{}", animal);

                    return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", animal)).into_response();
                }
            }
        }
        Err(res) => {
            println!("{}", res);

            return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", res)).into_response();
        }
    }
}

Первым шагом генерируем название животного из прилагательного и существительного, затем выбираем подходящую картинку. Следующим шагом пытаемся сохранить все в базе данных. Если при сохранении получаем ошибку, то отдаем ее в ответе:

// ...
Err(res) => {
    println!("{}", res);

    return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", res)).into_response();
}

При успешном сохранении достаем животное из базы через получение последнего сохраненного ID.

let id = res.last_insert_rowid();

let animal = sqlx::query_as::<_, Animal>("SELECT id, name, image, created_at, updated_at FROM animals WHERE id = ?")
    .bind(id)
    .fetch_one(&state.db)
    .await;

И еще раз обрабатываем ошибку. Теперь, при удачном результате отдаем структуру Animal в ответе:

Ok(animal) => {
    return (StatusCode::OK, Json(json!(animal))).into_response();
}

Эта структура реализована в отдельном файле models.rs:

use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use chrono::{DateTime, Utc};

#[derive(Deserialize, Serialize, FromRow)]
pub struct Animal {
    pub id: i64,
    pub name: String,
    pub image: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

И еще интересно посмотреть, как в axum мы получаем JSON из запроса. Например, вот так это реализовано в методе update.


#[derive(Deserialize, Serialize)]
pub struct UpdateRequest {
    pub id: i64
}

pub async fn update(State(state): State<AppState>, Json(json): Json<UpdateRequest>) -> impl IntoResponse {
    
    //...
}

Для этого нужно сделать отдельную структуру UpdateRequest и указать ее в параметрах обработчика через использование дженерика: Json(json): Json<UpdateRequest>.

Фронтенд

Есть готовый сервис, в котором реализовано нужное API. Осталось все это прикрутить к сайту.

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

const API_BASE_URL = 'https://animals.kodikapusta.ru/animals';
const ASSETS_BASE_URL = 'https://animals.kodikapusta.ru/assets';
const COOKIE_NAME = 'animal_id';
const UPDATE_INTERVAL = 10000; // 10 секунд
const LIST_REFRESH_INTERVAL = 30000; // 30 секунд

// Утилиты для работы с куками
const CookieManager = {
    setCookie(name, value, days = 365) {
        const date = new Date();
        date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
        const expires = "expires=" + date.toUTCString();
        document.cookie = `${name}=${value};${expires};path=/`;
    },

    getCookie(name) {
        const nameEQ = name + "=";
        const ca = document.cookie.split(';');
        for(let i = 0; i < ca.length; i++) {
            let c = ca[i];
            while (c.charAt(0) === ' ') c = c.substring(1);
            if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length);
        }
        return null;
    },

    deleteCookie(name) {
        document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
    }
};

Теперь добавим клиент для работы с нашим API.

// API клиент
const AnimalAPI = {
    async createAnimal() {
        try {
            const response = await fetch(API_BASE_URL, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({})
            });
            
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            
            return await response.json();
        } catch (error) {
            console.error('Ошибка при создании животного:', error);
            throw error;
        }
    },

    async updateAnimal(id) {
        try {
            const response = await fetch(API_BASE_URL, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    "id": parseInt(id)
                })
            });
            
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            
            return await response.json();
        } catch (error) {
            console.error(`Ошибка при обновлении животного ${id}:`, error);
            throw error;
        }
    },

    async getAllAnimals() {
        try {
            const response = await fetch(API_BASE_URL);
            
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            
            return await response.json();
        } catch (error) {
            console.error('Ошибка при получении списка животных:', error);
            throw error;
        }
    }
};

И еще один хелпер, который отрисует список животных в нашем DOM-дереве.

// DOM манипуляции
const DOMManager = {
    updateAllAnimals(animals, currentAnimalId = null) {
        const element = document.getElementById('allAnimalsList');
        if (animals && animals.length > 0) {
            let html = '<div class="animal-list">';
            
            animals.forEach(animal => {
                const isCurrent = currentAnimalId == animal.id;
                const animalName = animal.name;
                const initials = getInitials(animalName);
                
                html += `
                    <div class="animal-circle ${isCurrent ? 'current-animal' : ''}" data-id="${animal.id}">
                        <div class="animal-avatar">
                            ${animal.image 
                                ? `<img src="${ASSETS_BASE_URL}/${animal.image}" alt="${animalName}" class="animal-image">`
                                : `<div class="animal-initials">${initials}</div>`
                            }
                        </div>
                        <div class="animal-tooltip">${animalName}${isCurrent ? ' (вы)' : ''}</div>
                    </div>
                `;
            });
            
            html += '</div>';
            element.innerHTML = html;
            
            // Добавляем обработчики клика по животным
            document.querySelectorAll('.animal-circle').forEach(circle => {
                circle.addEventListener('click', function() {
                    const animalId = this.getAttribute('data-id');
                    console.log('Выбрано животное:', animalId);
                });
            });
        } else {
            element.innerHTML = '<div class="status">Нет активных животных</div>';
        }
    }
};

Осталось реализовать логику периодических обновлений и запустить все нужные таймеры:

// Основная логика приложения
class AnimalApp {
    constructor() {
        this.myAnimalId = null;
        this.updateInterval = null;
        this.listRefreshInterval = null;
    }

    async init() {
        console.log('Инициализация приложения...');
        
        // Получаем ID из куки или создаем новое животное
        this.myAnimalId = CookieManager.getCookie(COOKIE_NAME);
        
        if (this.myAnimalId) {
            console.log('Найдено животное в куках:', this.myAnimalId);
            await this.refreshMyAnimal();
        } else {
            console.log('Создаем новое животное...');
            await this.createNewAnimal();
        }

        // Загружаем список всех животных
        await this.refreshAllAnimals();

        // Запускаем таймеры
        this.startTimers();
    }

    async createNewAnimal() {
        try {
            const animal = await AnimalAPI.createAnimal();
            this.myAnimalId = animal.id;
            CookieManager.setCookie(COOKIE_NAME, animal.id);
            console.log('Создано новое животное:', animal);
        } catch (error) {
            DOMManager.updateMyAnimalStatus('Ошибка при создании животного', true);
        }
    }

    async refreshMyAnimal() {
        try {
            const animal = await AnimalAPI.updateAnimal(this.myAnimalId);
            console.log('Животное обновлено:', animal);
        } catch (error) {
            // Если животное не найдено (например, удалено), создаем новое
            if (error.message.includes('404')) {
                console.log('Животное не найдено, создаем новое...');
                CookieManager.deleteCookie(COOKIE_NAME);
                await this.createNewAnimal();
            } else {
                DOMManager.updateMyAnimalStatus('Ошибка при обновлении животного', true);
            }
        }
    }

    async refreshAllAnimals() {
        try {
            const animals = await AnimalAPI.getAllAnimals();
            DOMManager.updateAllAnimals(animals, this.myAnimalId);
            console.log('Список животных обновлен:', animals.length, 'животных');
        } catch (error) {
            console.log(error);
        }
    }

    startTimers() {
        // Очищаем старые таймеры если есть
        if (this.updateInterval) clearInterval(this.updateInterval);
        if (this.listRefreshInterval) clearInterval(this.listRefreshInterval);

        // Таймер для обновления моего животного
        this.updateInterval = setInterval(async () => {
            await this.refreshMyAnimal();
        }, UPDATE_INTERVAL);

        // Таймер для обновления списка всех животных
        this.listRefreshInterval = setInterval(async () => {
            await this.refreshAllAnimals();
        }, LIST_REFRESH_INTERVAL);

        console.log('Таймеры запущены');
    }

    destroy() {
        if (this.updateInterval) clearInterval(this.updateInterval);
        if (this.listRefreshInterval) clearInterval(this.listRefreshInterval);
        console.log('Приложение остановлено');
    }
}

// Инициализация приложения при загрузке страницы
document.addEventListener('DOMContentLoaded', () => {
    window.animalApp = new AnimalApp();
    window.animalApp.init();
});

// Очистка при закрытии страницы
window.addEventListener('beforeunload', () => {
    if (window.animalApp) {
        window.animalApp.destroy();
    }
});

Тут обратите внимание на логику:

this.myAnimalId = CookieManager.getCookie(COOKIE_NAME);
        
if (this.myAnimalId) {
    console.log('Найдено животное в куках:', this.myAnimalId);
    await this.refreshMyAnimal();
} else {
    console.log('Создаем новое животное...');
    await this.createNewAnimal();
}

Новое животное создается только если не смогли ничего найти в куках.

На этом все. Результат работы сервиса можно посмотреть прямо на этой странице.

Ссылки