今天我們要做一個圖片相關的 api 圖片往往佔據了大部分的流量,一個高效的圖片處理服務可以大幅降低頻寬成本、提升載入速度,
仿間很多網站都需要圖片壓縮去實現加速以及節省流量。我們今天學習的內容嘗試支援多種圖片格式轉換、品質調整和尺寸縮放。
image crate 處理多種圖片格式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,
}
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)
    }
}
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();
}
# 基本壓縮 (預設 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 天的內容了,每個內容寫得過於詳盡或導致篇幅過長,所以我目前權衡之下,會希望把內容寫得更全面和仔細