今天我們要做一個圖片相關的 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 天的內容了,每個內容寫得過於詳盡或導致篇幅過長,所以我目前權衡之下,會希望把內容寫得更全面和仔細