Создание сервиса краткосрочных ссылок на Rust

Введение

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

Шаг 1: Подготовка системы

1.1 Обновление системы

Откройте терминал и выполните обновление системы:

sudo apt update
sudo apt upgrade -y

1.2 Установка необходимых зависимостей

Установим все необходимые пакеты:

sudo apt install build-essential libssl-dev pkg-config sqlite3 curl -y

Объяснение:

  • build-essential — содержит компиляторы и инструменты сборки
  • libssl-dev — библиотеки для SSL/TLS
  • pkg-config — инструмент для определения параметров компиляции
  • sqlite3 — легковесная встраиваемая база данных
  • curl — утилита для выполнения HTTP-запросов

Шаг 2: Установка Rust

2.1 Установка Rust и Cargo

Выполните команду для установки Rust:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

При появлении запроса нажмите Enter для выбора стандартной установки.

2.2 Настройка окружения

Загрузите переменные окружения:

source ~/.cargo/env

2.3 Проверка установки

Проверьте, что Rust установлен правильно:

rustc --version
cargo --version

Вы должны увидеть версии компилятора и пакетного менеджера.

Шаг 3: Создание проекта

3.1 Создание нового проекта

Создайте новый проект на Rust:

cargo new short_url_service
cd short_url_service

3.2 Проверка структуры проекта

Посмотрите, что создалось:

ls -la

Вы должны увидеть:

  • Cargo.toml — файл конфигурации проекта
  • src/ — директория с исходным кодом
  • src/main.rs — главный файл программы

Шаг 4: Настройка зависимостей

4.1 Редактирование Cargo.toml

Откройте файл Cargo.toml в текстовом редакторе:

nano Cargo.toml

Замените его содержимое следующим текстом:

[package]
name = "short_url_service"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] }
uuid = { version = "1.0", features = ["v4", "fast-rng", "macro-diagnostics"] }
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.5", features = ["cors", "fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rand = "0.8"
dotenv = "0.15"

4.2 Установка sqlx-cli (инструмент для работы с базой данных)

Установите инструмент для работы с SQLX:

cargo install sqlx-cli --no-default-features --features sqlite

Шаг 5: Создание структуры данных

5.1 Создание файла моделей

Создайте файл для определения структур данных:

nano src/models.rs

Добавьте следующий код:

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

#[derive(Deserialize)]
pub struct CreateUrlRequest {
    pub url: String,
    #[serde(default = "default_ttl")]
    pub ttl_hours: i64,
}

fn default_ttl() -> i64 {
    24 // По умолчанию срок действия 24 часа
}

#[derive(Serialize)]
pub struct Url {
    pub id: i64,
    pub short_code: String,
    pub original_url: String,
    pub created_at: DateTime,
    pub expires_at: DateTime,
}

Шаг 6: Создание работы с базой данных

6.1 Создание файла работы с базой

nano src/database.rs

Добавьте следующий код:

use sqlx::{SqlitePool, Row};
use crate::models::{Url, CreateUrlRequest};
use chrono::{Utc, Duration};
use uuid::Uuid;

#[derive(Clone)]
pub struct Database {
    pub pool: SqlitePool,
}

impl Database {
    pub async fn new(database_url: &str) -> Result {
        let pool = SqlitePool::connect(database_url).await?;

        // Создаем таблицы если их нет
        sqlx::query(
            "CREATE TABLE IF NOT EXISTS urls (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                short_code TEXT UNIQUE NOT NULL,
                original_url TEXT NOT NULL,
                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
                expires_at DATETIME NOT NULL
            )"
        ).execute(&pool).await?;

        Ok(Database { pool })
    }

    pub async fn create_url(&self, request: CreateUrlRequest) -> Result {
        let short_code = self.generate_unique_code().await?;
        let now = Utc::now();
        let expires_at = now + Duration::hours(request.ttl_hours);

        sqlx::query("INSERT INTO urls (short_code, original_url, expires_at) VALUES (?, ?, ?)")
            .bind(&short_code)
            .bind(&request.url)
            .bind(expires_at)
            .execute(&self.pool)
            .await?;

        let row = sqlx::query("SELECT id, short_code, original_url, created_at, expires_at FROM urls WHERE short_code = ?")
            .bind(&short_code)
            .fetch_one(&self.pool)
            .await?;

        let url = Url {
            id: row.get("id"),
            short_code: row.get("short_code"),
            original_url: row.get("original_url"),
            created_at: row.get("created_at"),
            expires_at: row.get("expires_at"),
        };

        Ok(url)
    }

    pub async fn get_url_by_code(&self, code: &str) -> Result, sqlx::Error> {
        let row = sqlx::query("SELECT id, short_code, original_url, created_at, expires_at FROM urls WHERE short_code = ? AND expires_at > ?")
            .bind(code)
            .bind(Utc::now())
            .fetch_optional(&self.pool)
            .await?;

        if let Some(row) = row {
            let url = Url {
                id: row.get("id"),
                short_code: row.get("short_code"),
                original_url: row.get("original_url"),
                created_at: row.get("created_at"),
                expires_at: row.get("expires_at"),
            };
            Ok(Some(url))
        } else {
            Ok(None)
        }
    }

    pub async fn delete_expired_urls(&self) -> Result {
        let result = sqlx::query("DELETE FROM urls WHERE expires_at <= ?")
            .bind(Utc::now())
            .execute(&self.pool)
            .await?;

        Ok(result.rows_affected())
    }

    async fn generate_unique_code(&self) -> Result {
        loop {
            let code = Uuid::new_v4().to_string()[..8].to_uppercase();

            let row = sqlx::query("SELECT 1 as count FROM urls WHERE short_code = ?")
                .bind(&code)
                .fetch_optional(&self.pool)
                .await?;

            if row.is_none() {
                return Ok(code);
            }
        }
    }
}

Шаг 7: Создание обработчиков HTTP-запросов

7.1 Создание файла обработчиков

nano src/handlers.rs

Добавьте следующий код:

use axum::{
    extract::{Path, State, Json},
    http::StatusCode,
    response::{Html, Redirect},
};
use serde_json::json;
use crate::database::Database;
use crate::models::{CreateUrlRequest, Url};

pub async fn index() -> Html<&'static str> {
    Html(include_str!("../templates/index.html"))
}

pub async fn create_short_url(
    State(db): State,
    Json(payload): Json,
) -> Result, StatusCode> {
    if !is_valid_url(&payload.url) {
        return Err(StatusCode::BAD_REQUEST);
    }

    match db.create_url(payload).await {
        Ok(url) => Ok(Json(json!({
            "short_url": format!("/{}", url.short_code),
            "expires_at": url.expires_at,
            "message": "URL created successfully"
        }))),
        Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    }
}

pub async fn redirect_to_original(
    Path(code): Path,
    State(db): State,
) -> Result {
    match db.get_url_by_code(&code).await {
        Ok(Some(url_obj)) => Ok(Redirect::permanent(&url_obj.original_url)),
        Ok(None) => Err(StatusCode::NOT_FOUND),
        Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    }
}

pub async fn health_check() -> Json {
    Json(json!({"status": "ok"}))
}

fn is_valid_url(url: &str) -> bool {
    url.starts_with("http://") || url.starts_with("https://")
}

Шаг 8: Создание HTML шаблона

8.1 Создание директории для шаблонов

mkdir templates

8.2 Создание HTML файла

nano templates/index.html

Добавьте следующий HTML код:

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Сервис краткосрочных ссылок</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .container {
            background: white;
            padding: 30px;
            border-radius: 10px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        h1 {
            color: #333;
            text-align: center;
        }
        .form-group {
            margin-bottom: 20px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            font-weight: bold;
        }
        input[type="url"], input[type="number"] {
            width: 100%;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 5px;
            box-sizing: border-box;
        }
        button {
            background-color: #007bff;
            color: white;
            padding: 12px 24px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            font-size: 16px;
        }
        button:hover {
            background-color: #0056b3;
        }
        .result {
            margin-top: 20px;
            padding: 15px;
            background-color: #d4edda;
            border: 1px solid #c3e6cb;
            border-radius: 5px;
            display: none;
        }
        .error {
            background-color: #f8d7da;
            border-color: #f5c6cb;
        }
        .input-row {
            display: flex;
            gap: 10px;
        }
        .input-row > div {
            flex: 1;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Создание краткосрочной ссылки</h1>
        <form id="urlForm">
            <div class="input-row">
                <div class="form-group">
                    <label for="url">Длинная ссылка:</label>
                    <input type="url" id="url" name="url" required placeholder="https://example.com">
                </div>
                <div class="form-group">
                    <label for="ttl">Срок действия (часы):</label>
                    <input type="number" id="ttl" name="ttl" min="1" max="168" value="24" required>
                </div>
            </div>
            <button type="submit">Создать короткую ссылку</button>
        </form>

        <div id="result" class="result"></div>
    </div>

    <script>
        document.getElementById('urlForm').addEventListener('submit', async (e) => {
            e.preventDefault();

            const url = document.getElementById('url').value;
            const ttl = parseInt(document.getElementById('ttl').value) || 24;

            try {
                const response = await fetch('/api/create', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({
                        url: url,
                        ttl_hours: ttl
                    })
                });

                const data = await response.json();
                const resultDiv = document.getElementById('result');

                if (response.ok) {
                    resultDiv.innerHTML = `
                        <strong>Короткая ссылка создана!</strong><br>
                        <a href="${data.short_url}" target="_blank">${window.location.origin}${data.short_url}</a><br>
                        Срок действия: ${new Date(data.expires_at).toLocaleString('ru-RU')}
                    `;
                    resultDiv.className = 'result';
                    resultDiv.style.display = 'block';
                } else {
                    resultDiv.innerHTML = `<strong>Ошибка:</strong> ${data.message || 'Не удалось создать ссылку'}`;
                    resultDiv.className = 'result error';
                    resultDiv.style.display = 'block';
                }
            } catch (error) {
                console.error('Error:', error);
                const resultDiv = document.getElementById('result');
                resultDiv.innerHTML = '<strong>Ошибка:</strong> Произошла ошибка при создании ссылки';
                resultDiv.className = 'result error';
                resultDiv.style.display = 'block';
            }
        });
    </script>
</body>
</html>

Шаг 9: Редактирование основного файла

9.1 Замена содержимого main.rs

nano src/main.rs

Замените содержимое на следующий код:

use axum::{
    extract::State,
    http::StatusCode,
    response::Html,
    routing::{get, post},
    Router,
};
use tracing_subscriber;
use dotenv::dotenv;

mod models;
mod database;
mod handlers;

use database::Database;
use handlers::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Загрузим переменные окружения
    dotenv().ok();

    // Настройка логирования
    tracing_subscriber::fmt::init();

    // Создание пула подключения к базе данных
    let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:urls.db".to_string());
    let db = Database::new(&database_url).await?;

    // Запуск фоновой задачи для очистки истекших ссылок
    start_cleanup_task(db.clone());

    // Создание роутера
    let app = Router::new()
        .route("/", get(index))
        .route("/api/create", post(create_short_url))
        .route("/health", get(health_check))
        .route("/:code", get(redirect_to_original))
        .with_state(db);

    // Запуск сервера
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server running on http://0.0.0.0:3000");

    axum::serve(listener, app).await.unwrap();

    Ok(())
}

fn start_cleanup_task(db: Database) {
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(600)); // каждые 10 минут

        loop {
            interval.tick().await;

            match db.delete_expired_urls().await {
                Ok(count) => println!("Очищено {} истекших ссылок", count),
                Err(e) => eprintln!("Ошибка при очистке: {}", e),
            }
        }
    });
}

Шаг 10: Создание файла переменных окружения

10.1 Создание .env файла

echo "DATABASE_URL=sqlite:urls.db" > .env

Шаг 11: Создание файла базы данных

11.1 Создание пустой базы данных

Перед запуском приложения создадим файл базы данных:

touch urls.db
chmod 666 urls.db

Шаг 12: Сборка и запуск приложения

12.1 Сборка проекта

cargo build

Если сборка проходит успешно (без ошибок), переходим к следующему шагу.

12.2 Запуск приложения

cargo run

Вы должны увидеть сообщение: Server running on http://0.0.0.0:3000

Шаг 13: Тестирование работы

13.1 В новом терминале проверьте здоровье сервиса

curl http://localhost:3000/health

Должен вернуться JSON: {"status":"ok"}

13.2 Создайте тестовую короткую ссылку

curl -X POST http://localhost:3000/api/create \
  -H "Content-Type: application/json" \
  -d '{"url": "https://www.rust-lang.org", "ttl_hours": 24}'

13.3 Проверьте, что файл базы данных создался

ls -la urls.db

Шаг 14: Использование сервиса

14.1 Через веб-интерфейс

Откройте в браузере: http://ваш-сервер-ип:3000

14.2 Через API

# Создание короткой ссылки
curl -X POST http://ваш-сервер-ип:3000/api/create \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "ttl_hours": 12}'

# Проверка состояния
curl http://ваш-сервер-ип:3000/health

Шаг 15: Запуск в фоне (опционально)

15.1 Запуск в фоне

nohup cargo run > app.log 2>&1 &

15.2 Проверка запущенного процесса

jobs

Заключение

Поздравляем! Вы успешно создали сервис краткосрочных ссылок на Rust. Приложение:

  • Принимает длинные URL и создает короткие ссылки
  • Автоматически удаляет истекшие ссылки
  • Имеет веб-интерфейс для удобства использования
  • Обеспечивает высокую производительность благодаря Rust
  • Использует SQLite для хранения данных

Этот проект отлично подходит для изучения:

  • Языка программирования Rust
  • Веб-разработки с использованием Axum
  • Работы с базами данных через SQLX
  • Асинхронного программирования в Rust