在 Web 開發中,處理檔案上傳和建構 API 是常見的需求。本文將示範如何使用 Rust 的 Actix-web 框架,結合 Diesel ORM 和 Awmp 函式庫,開發一個支援檔案上傳的 RESTful API。首先,我們會利用 Awmp 解析多部份 HTTP 請求,提取檔案和相關欄位資料。接著,考量安全性,我們將探討如何安全地儲存上傳的檔案,例如限制檔案型別、隨機化檔名等。後續將使用 Diesel 將資料寫入資料函式庫,並示範 API 端點的建立與測試,包含錯誤處理和輸入驗證,確保 API 的穩定性和安全性。最後,我們將探討如何透過 Actix-web 的機制,重用 API 路由設定,簡化程式碼並提升維護性。
使用 Diesel 與 Awmp 處理多部份 HTTP 請求及檔案上傳
在開發 RESTful API 時,處理檔案上傳和多部份 HTTP 請求是一項常見的需求。本篇文章將介紹如何使用 Diesel 和 Awmp 在 Actix-web 框架中實作這一功能。
處理多部份 HTTP 請求
Actix-web 提供了 awmp 這個 crate 來處理多部份 HTTP 請求。awmp 可以解析 multipart/form-data 請求,並將檔案和文字欄位提取出來。
取得檔案和文字欄位
首先,我們需要使用 awmp::Parts 來提取請求中的檔案和文字欄位。parts.files 包含了上傳的檔案,而 parts.texts 則包含了文字欄位。
let file_path = parts
.files
.take("image")
.pop()
.and_then(|f| f.persist_in("./image").ok())
.unwrap_or_default();
let text_fields: HashMap<_, _> = parts.texts.as_pairs().into_iter().collect();
安全儲存上傳檔案
在儲存上傳檔案時,我們需要考慮安全性問題。直接將檔案儲存到指定目錄可能會導致安全漏洞,例如上傳惡意檔案或覆寫其他檔案。
@startuml
skinparam backgroundColor #FEFEFE
skinparam sequenceArrowThickness 2
title Diesel Awmp Actix Web 檔案上傳與 API 開發
actor "客戶端" as client
participant "API Gateway" as gateway
participant "認證服務" as auth
participant "業務服務" as service
database "資料庫" as db
queue "訊息佇列" as mq
client -> gateway : HTTP 請求
gateway -> auth : 驗證 Token
auth --> gateway : 認證結果
alt 認證成功
gateway -> service : 轉發請求
service -> db : 查詢/更新資料
db --> service : 回傳結果
service -> mq : 發送事件
service --> gateway : 回應資料
gateway --> client : HTTP 200 OK
else 認證失敗
gateway --> client : HTTP 401 Unauthorized
end
@enduml最佳實踐
為了提高安全性,我們可以採取以下措施:
- 限制檔案副檔名:只允許特定的副檔名。
- 檢查檔案型別:驗證檔案型別是否與副檔名相符。
- 掃描病毒:使用防毒軟體掃描上傳檔案。
- 隨機化檔名:避免使用原始檔名,改用隨機產生的檔名。
- 使用第三方服務:考慮使用第三方檔案上傳服務,以降低安全風險。
使用 Diesel 插入資料到資料函式庫
在取得檔案和文字欄位後,我們需要將資料插入到資料函式庫中。使用 Diesel 可以簡化這一步驟。
定義 Model
首先,我們需要定義一個 Model 來對應資料函式庫表格。
#[derive(Insertable, Serialize)]
#[diesel(table_name = cats)]
pub struct NewCat {
pub name: String,
pub image_path: String,
}
插入資料
接著,我們可以使用 diesel::insert_into 將資料插入到資料函式庫中。
let new_cat = NewCat {
name: text_fields.get("name").unwrap().to_string(),
image_path: file_path.to_string_lossy().to_string()
};
web::block(move ||
diesel::insert_into(cats)
.values(&new_cat)
.execute(&mut connection)
)
.await
.map_err(error::ErrorInternalServerError)?
.map_err(error::ErrorInternalServerError)?;
回應 HTTP 請求
最後,我們可以回應 HTTP 請求,傳回 201 Created 狀態碼。
Ok(HttpResponse::Created().finish())
自動化API測試的實施與組態重用
在開發過程中,手動測試API不僅耗時耗力,還可能導致測試不夠頻繁,從而延遲發現問題。Rust內建的單元測試功能允許對個別函式進行測試,而本章節重點介紹如何使用Actix-web提供的輔助功能進行整合測試,啟動真實的HTTP伺服器並傳送測試請求。
設定資料函式庫連線池
在進行整合測試之前,首先需要設定資料函式庫連線池。以下程式碼展示瞭如何建立一個可重用的setup_database函式來初始化資料函式庫連線池:
fn setup_database() -> DbPool {
let database_url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let manager = ConnectionManager::<PgConnection>::new(&database_url);
r2d2::Pool::builder()
.build(manager)
.expect("Failed to create DB connection pool.")
}
內容解密:
env::var("DATABASE_URL"):從環境變數中讀取資料函式庫URL,如果未設定則丟擲錯誤。ConnectionManager::<PgConnection>::new(&database_url):建立PostgreSQL連線管理器。r2d2::Pool::builder().build(manager):建立連線池,使用r2d2函式倉管理資料函式庫連線。
編寫整合測試
使用Actix-web的測試功能,可以輕鬆地對API端點進行整合測試。以下範例展示瞭如何測試/api/cats端點:
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{test, App};
#[actix_web::test]
async fn test_cats_endpoint_get() {
let pool = setup_database();
let mut app = test::init_service(
App::new().app_data(web::Data::new(pool.clone()))
.route("/api/cats", web::get().to(cats_endpoint)),
).await;
let req = test::TestRequest::get().uri("/api/cats").to_request();
let resp = test::call_service(&mut app, req).await;
assert!(resp.status().is_success());
}
}
內容解密:
#[cfg(test)]:表示該模組僅在執行測試時編譯。test::init_service:初始化測試服務,傳入一個App例項。test::TestRequest::get():建立一個GET請求。test::call_service:傳送請求並取得回應。assert!(resp.status().is_success()):斷言回應狀態碼在200-299之間,表示成功。
重用組態以減少重複程式碼
當服務的路由越來越多時,在main函式和測試函式中重複組態路由會增加維護難度。Actix-web提供了App::configure方法來重用組態。以下範例展示瞭如何抽取組態邏輯到api_config函式中:
fn api_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api")
.route("/cats", web::get().to(cats_endpoint))
.route("/add_cat", web::post().to(add_cat_endpoint)),
);
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let pool = setup_database();
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(pool.clone()))
.configure(api_config) // 使用組態
// 其他服務組態...
})
.bind("127.0.0.1:8080")?
.run()
.await
}
內容解密:
api_config函式接收一個&mut web::ServiceConfig,用於組態/api範圍內的路由。- 在
main函式中,透過.configure(api_config)重用組態邏輯。 - 在測試模組中,同樣可以使用
.configure(api_config)來保持組態的一致性。
5.8 建立貓咪詳細資料 API
在前面的章節中,我們建立了一個簡單的貓咪 API,但它無法滿足更進階的需求,例如查詢引數、輸入驗證和錯誤處理。因此,我們將重建一個能夠傳回單個貓咪詳細資料的 API。
首先,讓我們看看前端如何呼叫這個 API。在清單 5-9 中,每個貓咪的名字都是一個指向 /cat.html?id=${cat.id} 的連結。這個頁面目前還不存在,所以我們需要在 static/cat.html 中建立它,並將清單 5-22 中的程式碼貼上進去。
清單 5-22. 單個貓咪詳細資料頁面
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Cat</title>
</head>
<body>
<h1 id="name">Loading...</h1>
<img id="image" />
<p>
<a href="/">Back</a>
</p>
<script charset="utf-8">
const urlParams = new URLSearchParams(window.location.search)
const cat_id = urlParams.get("id")
document.addEventListener("DOMContentLoaded", () => {
fetch(`/api/cat/${cat_id}`)
.then((response) => response.json())
.then((cat) => {
document.getElementById("name").innerText = cat.name
document.getElementById("image").src = cat.image_path
document.title = cat.name
})
})
</script>
</body>
</html>
這個連結會開啟 cat.html 頁面,並傳遞一個查詢引數(例如 ?id=1)。這個 ID 查詢引數透過建立一個新的 URLSearchParams(window.location.search) 物件並呼叫其 .get() 方法來提取。有了貓咪的 ID,我們就可以使用 fetch 呼叫 /api/cat/${cat_id} API。這個 API 有一個路徑引數用於 ID,並且應該以 JSON 格式傳回貓咪的詳細資料(包括名字和圖片路徑)。
清單 5-23. 貓咪 API 的簡單實作
use serde::Deserialize;
// ...
#[derive(Deserialize)]
struct CatEndpointPath {
id: i32,
}
async fn cat_endpoint(
pool: web::Data<DbPool>,
cat_id: web::Path<CatEndpointPath>,
) -> Result<HttpResponse, Error> {
let mut connection = pool.get().expect("無法從池中取得資料函式庫連線");
let cat_data = web::block(move || {
cats.filter(id.eq(cat_id.id))
.first::<Cat>(&mut connection)
})
.await?
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(cat_data))
}
// ...
fn api_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api")
.route("/cats", web::get().to(cats_endpoint))
.route("/add_cat", web::get().to(add_cat_endpoint))
.route("/cat/{id}", web::get().to(cat_endpoint)),
);
}
內容解密:
CatEndpointPath結構體:使用serde::Deserialize派生宏來反序列化路徑引數,包含一個id欄位,用於提取 URL 中的貓咪 ID。cat_endpoint函式:處理對/api/cat/{id}的請求,從資料函式庫池中取得連線,並根據提供的 ID 查詢貓咪資料。若查詢成功,則傳回包含貓咪資料的 JSON 回應。api_config函式:組態 API 路由,將/api/cat/{id}路徑對映到cat_endpoint處理函式。
這個實作存在幾個問題:
- 如果無法從連線池取得連線,它將會 panic 並傳回 500 錯誤。
- 如果 ID 在資料函式庫中不存在,我們會得到一個 500 Internal Server Error。
- 如果路徑中的 ID 不是整數(例如
/api/cat/abc),它將傳回一個 404 錯誤,訊息為 “can not parse ‘abc’ to a i16”。 - 如果 ID 是整數,但不在正確的範圍內(例如負數),我們會得到一個 400 Bad Request 錯誤。
改進錯誤處理
為瞭解決上述問題,我們需要改進錯誤處理機制,包括:
- 當 ID 無效時傳回 400 錯誤(例如不是數字、超出範圍)。
- 當 ID 在資料函式庫中不存在時傳回 404 錯誤。
- 當無法從池中取得連線時傳回 500 錯誤。
- 能夠自定義錯誤訊息。
- 使程式碼中的錯誤發生位置和原因更明確。
5.9 輸入驗證
讓我們首先處理輸入驗證。我們知道貓咪的 ID 可能會以多種方式出錯。如果它不是整數,Actix-web 的型別安全提取器將傳回一個 404 錯誤。這個錯誤可以自定義,但我們稍後再處理。讓我們首先處理 ID 是整數,但不在合理範圍內的情況。
由於我們的貓咪 ID 使用 id SERIAL PRIMARY KEY 架構,PostgreSQL 將從 1 開始,每次插入新行時增加 1。因此,ID 不能小於 1。假設我們只允許使用者新增唯一的貓咪品種到網站,那麼根據國際貓協會(TICA)的認可,只有 71 個標準化的品種。如果我們保留一些緩衝,並假設未來貓咪品種可能會翻倍,那麼我們將會有大約 71 × 2 = 142 ≈ 150 個品種。因此,我們可以檢查貓咪的 ID 是否在 1 到 150 之間(包含),如果不是,我們可以簡單地拒絕請求,甚至不用查詢資料函式庫。
async fn cat_endpoint(
pool: web::Data<DbPool>,
cat_id: web::Path<CatEndpointPath>,
) -> Result<HttpResponse, Error> {
if cat_id.id < 1 || cat_id.id > 150 {
return Ok(HttpResponse::BadRequest().json("Invalid cat ID"));
}
let mut connection = pool.get().map_err(error::ErrorInternalServerError)?;
let cat_data = web::block(move || {
cats.filter(id.eq(cat_id.id))
.first::<Cat>(&mut connection)
})
.await?
.map_err(|_| HttpResponse::NotFound().json("Cat not found"))?;
Ok(HttpResponse::Ok().json(cat_data))
}
內容解密:
- ID 範圍檢查:首先檢查
cat_id.id是否在有效範圍內(1 到 150)。如果不在,則立即傳回 400 Bad Request 回應。 - 錯誤處理改進:使用
map_err將錯誤對映為更具描述性的 HTTP 回應。例如,將無法取得資料函式庫連線的錯誤對映為 500 Internal Server Error,將查詢不到貓咪資料的錯誤對映為 404 Not Found。