iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
Rust

Rust 實戰專案集:30 個漸進式專案從工具到服務系列 第 15

圖片壓縮 API - 提供圖片壓縮和格式轉換服務

  • 分享至 

  • xImage
  •  

前言

今天我們要做一個圖片相關的 api 圖片往往佔據了大部分的流量,一個高效的圖片處理服務可以大幅降低頻寬成本、提升載入速度,
仿間很多網站都需要圖片壓縮去實現加速以及節省流量。我們今天學習的內容嘗試支援多種圖片格式轉換、品質調整和尺寸縮放。

今日專案目標

  • 使用 image crate 處理多種圖片格式
  • 實作 RESTful API 接收和回傳二進位資料
  • 理解圖片編碼參數對檔案大小的影響
  • 處理大型檔案上傳的記憶體管理
  • 實作非同步檔案處理

開始專案

cargo new image-compression-api
cd image-compression-api

依賴

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["fs", "cors", "limit"] }
image = "0.25"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1.0"
bytes = "1.5"

這裡建立結構

use serde::Deserialize;

#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum ImageFormat {
    Jpeg,
    Png,
    Webp,
    Gif,
}

#[derive(Debug, Deserialize)]
pub struct CompressionOptions {
    #[serde(default)]
    pub format: Option<ImageFormat>,
    
    #[serde(default = "default_quality")]
    pub quality: u8,
    
    #[serde(default)]
    pub width: Option<u32>,
    
    #[serde(default)]
    pub height: Option<u32>,
    
    #[serde(default = "default_true")]
    pub maintain_aspect_ratio: bool,
}

fn default_quality() -> u8 {
    80
}

fn default_true() -> bool {
    true
}

圖片處理邏輯

src/processor.rs

use image::{ImageFormat as ImgFormat, DynamicImage, imageops::FilterType};
use anyhow::{Result, Context};
use bytes::Bytes;
use std::io::Cursor;

pub struct ImageProcessor;

impl ImageProcessor {
    pub fn process(
        input_bytes: &[u8],
        options: CompressionOptions,
    ) -> Result<Vec<u8>> {
        // 載入圖片
        let img = image::load_from_memory(input_bytes)
            .context("Failed to decode image")?;
        
        // 調整尺寸
        let resized_img = Self::resize_image(img, &options)?;
        
        // 編碼為指定格式
        Self::encode_image(resized_img, &options)
    }
    
    fn resize_image(
        img: DynamicImage,
        options: &CompressionOptions,
    ) -> Result<DynamicImage> {
        match (options.width, options.height) {
            (None, None) => Ok(img),
            (Some(width), None) => {
                if options.maintain_aspect_ratio {
                    Ok(img.resize(width, u32::MAX, FilterType::Lanczos3))
                } else {
                    Ok(img.resize_exact(width, img.height(), FilterType::Lanczos3))
                }
            }
            (None, Some(height)) => {
                if options.maintain_aspect_ratio {
                    Ok(img.resize(u32::MAX, height, FilterType::Lanczos3))
                } else {
                    Ok(img.resize_exact(img.width(), height, FilterType::Lanczos3))
                }
            }
            (Some(width), Some(height)) => {
                if options.maintain_aspect_ratio {
                    Ok(img.resize(width, height, FilterType::Lanczos3))
                } else {
                    Ok(img.resize_exact(width, height, FilterType::Lanczos3))
                }
            }
        }
    }
    
    fn encode_image(
        img: DynamicImage,
        options: &CompressionOptions,
    ) -> Result<Vec<u8>> {
        let mut buffer = Vec::new();
        let mut cursor = Cursor::new(&mut buffer);
        
        match options.format.as_ref() {
            Some(ImageFormat::Jpeg) | None => {
                let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
                    &mut cursor,
                    options.quality,
                );
                img.write_with_encoder(encoder)
                    .context("Failed to encode JPEG")?;
            }
            Some(ImageFormat::Png) => {
                img.write_to(&mut cursor, ImgFormat::Png)
                    .context("Failed to encode PNG")?;
            }
            Some(ImageFormat::Webp) => {
                img.write_to(&mut cursor, ImgFormat::WebP)
                    .context("Failed to encode WebP")?;
            }
            Some(ImageFormat::Gif) => {
                img.write_to(&mut cursor, ImgFormat::Gif)
                    .context("Failed to encode GIF")?;
            }
        }
        
        Ok(buffer)
    }
    
    pub fn get_info(input_bytes: &[u8]) -> Result<ImageInfo> {
        let img = image::load_from_memory(input_bytes)
            .context("Failed to decode image")?;
        
        let format = image::guess_format(input_bytes)
            .context("Failed to guess format")?;
        
        Ok(ImageInfo {
            width: img.width(),
            height: img.height(),
            format: format!("{:?}", format),
            size_bytes: input_bytes.len(),
        })
    }
}

#[derive(Debug, serde::Serialize)]
pub struct ImageInfo {
    pub width: u32,
    pub height: u32,
    pub format: String,
    pub size_bytes: usize,
}

這裡建立 restful api

src/routes.rs

use axum::{
    extract::{Multipart, Query},
    http::{header, StatusCode},
    response::{IntoResponse, Response},
    routing::{get, post},
    Json, Router,
};

mod processor;
use processor::{CompressionOptions, ImageProcessor, ImageInfo};

pub fn create_routes() -> Router {
    Router::new()
        .route("/", get(health_check))
        .route("/compress", post(compress_image))
        .route("/info", post(get_image_info))
}

async fn health_check() -> &'static str {
    "Image Compression API is running"
}

async fn compress_image(
    Query(options): Query<CompressionOptions>,
    mut multipart: Multipart,
) -> Result<Response, ApiError> {
    // 讀取上傳的圖片
    let mut image_data: Option<Vec<u8>> = None;
    
    while let Some(field) = multipart.next_field().await? {
        if field.name() == Some("image") {
            image_data = Some(field.bytes().await?.to_vec());
            break;
        }
    }
    
    let image_data = image_data
        .ok_or_else(|| ApiError::BadRequest("No image provided".to_string()))?;
    
    // 處理圖片
    let processed = ImageProcessor::process(&image_data, options.clone())
        .map_err(|e| ApiError::ProcessingError(e.to_string()))?;
    
    // 決定 Content-Type
    let content_type = match options.format {
        Some(processor::ImageFormat::Jpeg) => "image/jpeg",
        Some(processor::ImageFormat::Png) => "image/png",
        Some(processor::ImageFormat::Webp) => "image/webp",
        Some(processor::ImageFormat::Gif) => "image/gif",
        None => "image/jpeg",
    };
    
    Ok((
        StatusCode::OK,
        [(header::CONTENT_TYPE, content_type)],
        processed,
    )
        .into_response())
}

async fn get_image_info(
    mut multipart: Multipart,
) -> Result<Json<ImageInfo>, ApiError> {
    let mut image_data: Option<Vec<u8>> = None;
    
    while let Some(field) = multipart.next_field().await? {
        if field.name() == Some("image") {
            image_data = Some(field.bytes().await?.to_vec());
            break;
        }
    }
    
    let image_data = image_data
        .ok_or_else(|| ApiError::BadRequest("No image provided".to_string()))?;
    
    let info = ImageProcessor::get_info(&image_data)
        .map_err(|e| ApiError::ProcessingError(e.to_string()))?;
    
    Ok(Json(info))
}

// 錯誤處理
#[derive(Debug)]
enum ApiError {
    BadRequest(String),
    ProcessingError(String),
    MultipartError(axum::extract::multipart::MultipartError),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
            ApiError::ProcessingError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
            ApiError::MultipartError(e) => {
                (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e))
            }
        };
        
        (status, Json(serde_json::json!({ "error": message }))).into_response()
    }
}

impl From<axum::extract::multipart::MultipartError> for ApiError {
    fn from(err: axum::extract::multipart::MultipartError) -> Self {
        ApiError::MultipartError(err)
    }
}

main.rs

use axum::Router;
use tower_http::{
    cors::CorsLayer,
    limit::RequestBodyLimitLayer,
};
use tracing_subscriber;

mod routes;

#[tokio::main]
async fn main() {
    // 初始化日誌
    tracing_subscriber::fmt::init();
    
    // 建立應用
    let app = Router::new()
        .nest("/api", routes::create_routes())
        .layer(CorsLayer::permissive())
        .layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)); // 10MB 限制
    
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    
    tracing::info!("Server running on http://127.0.0.1:3000");
    
    axum::serve(listener, app).await.unwrap();
}

curl 進行測試

# 基本壓縮 (預設 JPEG, 品質 80)
curl -X POST http://localhost:3000/api/compress \
  -F "image=@test.jpg" \
  --output compressed.jpg

# 轉換為 WebP 格式,品質 90
curl -X POST "http://localhost:3000/api/compress?format=webp&quality=90" \
  -F "image=@test.jpg" \
  --output compressed.webp

# 縮放寬度為 800px,保持比例
curl -X POST "http://localhost:3000/api/compress?width=800" \
  -F "image=@test.jpg" \
  --output resized.jpg

# 固定尺寸 800x600,不保持比例
curl -X POST "http://localhost:3000/api/compress?width=800&height=600&maintain_aspect_ratio=false" \
  -F "image=@test.jpg" \
  --output cropped.jpg

# 取得圖片資訊
curl -X POST http://localhost:3000/api/info \
  -F "image=@test.jpg"

小語

這裏也寫一半了,我相信,我個人寫的內容為進階內容, 目前有考慮如果完賽後,在我個人的部落格做更詳細的解說和詳解
那可能不是 30 天的內容了,每個內容寫得過於詳盡或導致篇幅過長,所以我目前權衡之下,會希望把內容寫得更全面和仔細


上一篇
Webhook 接收器 - 處理 GitHub/GitLab webhooks
下一篇
即時聊天室 - 使用 WebSocket 實現即時通訊
系列文
Rust 實戰專案集:30 個漸進式專案從工具到服務16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言