Danh sách bài viết

Bài 27: Redirect & Serve Static Files

Bài 27 của series Rust RESTful API — đi sâu vào axum::response::Redirect với 4 builder method permanent/to/temporary/found ánh xạ sang 4 status code 308/303/307/302, phân biệt đầy đủ 5 status code redirect 301/302/303/307/308 theo RFC 9110 mục 15.4 trên 2 chiều quyết định permanent vs temporary (client cache vĩnh viễn hay không) và preserve method vs allow change (giữ POST body hay client có thể đổi sang GET), decision tree chọn đúng status code cho từng tình huống Shop API (308 cho API versioning migration, 307 cho temporary maintenance, 303 cho POST-redirect-GET admin form submit), tower-http ServeDir mount folder static qua Router::nest_service và ServeFile cho single file path cố định cần thêm feature fs vào tower-http trong workspace dependencies (lock B10 hiện có trace + cors + compression-gzip), cache header strategy production Cache-Control: public, max-age=31536000, immutable cho asset có hash trong filename (app.abc123.js) qua SetResponseHeaderLayer wrap ServeDir vs no-cache cho dev, Shop API CDN strategy lock B27: KHÔNG tự serve product image / JS bundle / CSS — đẩy toàn bộ asset lên S3 + CloudFront hoặc Bunny CDN để app server tập trung business logic và CDN lo edge caching globally + auto scale + low latency, exception duy nhất là Swagger UI static bundled qua utoipa-swagger-ui (lock B8), pattern redirect 307 từ REST endpoint tới CDN URL khi cần shortcut (vd GET /products/:slug/image redirect 307 tới https://cdn.shop.com/products/<slug>.webp), demo 2 redirect route trong routes/demo_redirect.rs verify status code + Location header qua curl.

13/06/2026
10 phút đọc
1 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Biết tạo redirect response với axum::response::Redirect::to(url) và 4 builder method ánh xạ 4 status code (301/302/303/307/308).
  • Phân biệt được permanent vs temporary redirect (301/308 vs 302/307) — chiều quyết định liên quan đến client cache.
  • Phân biệt được preserve method vs allow change (307/308 vs 301/302/303) — chiều quyết định liên quan đến POST body.
  • Sử dụng tower-http ServeDir cho static folder, ServeFile cho single file (cần feature fs).
  • Cấu hình cache header cho static asset: Cache-Control: public, max-age=31536000, immutable production vs no-cache dev.
  • Hiểu Shop API CDN strategy: asset static (product image, JS, CSS) đẩy CDN, REST API KHÔNG tự host.
2

Redirect Cơ Bản: Redirect::to(url)

axum::response::Redirect là response type built-in cho redirect. Bốn builder method static tạo instance, sai khác duy nhất ở status code emit:

use axum::response::Redirect;

// 1. Redirect::permanent — 308 Permanent Redirect
async fn migrated_v0_to_v1() -> Redirect {
    Redirect::permanent("/api/v1/products")
}

// 2. Redirect::to — 303 See Other (POST-redirect-GET)
async fn login_success() -> Redirect {
    Redirect::to("/dashboard")
}

// 3. Redirect::temporary — 307 Temporary Redirect
async fn maintenance() -> Redirect {
    Redirect::temporary("/maintenance.html")
}

// 4. Redirect::found — 302 Found (legacy)
async fn legacy_handler() -> Redirect {
    Redirect::found("/somewhere")
}

Bốn method khác nhau ở status code: permanent → 308, to → 303, temporary → 307, found → 302. Lưu ý quan trọng: Redirect::permanent trong axum 0.8 trả về 308 Permanent Redirect chứ KHÔNG phải 301 Moved Permanently — khác trực giác từ tên Vietnamese "permanent" đầu tiên gặp. Tham khảo docs.rs/axum/latest/axum/response/struct.Redirect.html.

Cả 4 method đều build response với header Location: <url> tự động + body rỗng. Browser hoặc HTTP client đọc Location header, gửi request mới đến URL đó, lặp cho đến khi hit response non-3xx (default tối đa 10 redirect, qua đó client ngắt với error "too many redirects" tránh loop vô hạn).

Tham số url chấp nhận impl Into<String> — bạn truyền &str literal, String owned, hoặc kết quả format!(...). URL có thể là path relative (/api/v1/products) hoặc absolute URL (https://cdn.shop.com/assets/x.webp) — browser xử lý cả 2.

3

Phân Biệt 5 Status Code Redirect

RFC 9110 mục 15.4 định nghĩa 5 status code redirect chính. Hai chiều quyết định: (a) permanent vs temporary — client cache vĩnh viễn hay không; (b) preserve method/body vs allow change — request tiếp theo dùng method gốc hay client được phép đổi sang GET.

┌──────┬───────────────────────┬───────────┬─────────────────┐
│ Code │ Tên                   │ Permanent │ Preserve method │
├──────┼───────────────────────┼───────────┼─────────────────┤
│ 301  │ Moved Permanently     │ Yes       │ No (có thể đổi) │
│ 302  │ Found                 │ No        │ No (có thể đổi) │
│ 303  │ See Other             │ No        │ No (luôn GET)   │
│ 307  │ Temporary Redirect    │ No        │ Yes             │
│ 308  │ Permanent Redirect    │ Yes       │ Yes             │
└──────┴───────────────────────┴───────────┴─────────────────┘

Chi tiết từng code:

  • 301 Moved Permanently — resource đã chuyển vĩnh viễn. Client cache mạnh (browser nhớ luôn, lần sau tự gửi tới URL mới không hỏi server). Method có thể đổi: lịch sử HTTP/1.0 nhiều browser tự chuyển POST → GET sau 301 dù RFC khuyến nghị giữ method (lý do RFC bổ sung 308 tách bạch). Use case: URL renamed permanently, SEO benefit search engine update index.
  • 302 Found — temporary, method có thể đổi. Semantic mơ hồ từ HTTP/1.0 (tên gốc "Moved Temporarily"), browser thực tế convert POST → GET. RFC 9110 hiện nay khuyến nghị dùng 303 hoặc 307 thay thế — 302 giữ cho backward compat legacy.
  • 303 See Other — tell client GET resource khác (luôn dùng GET cho request tiếp theo bất kể method gốc). Use case kinh điển: POST-redirect-GET pattern — sau khi POST /login thành công, server redirect 303 tới GET /dashboard để tránh resubmit form khi user refresh.
  • 307 Temporary Redirect — temporary, PRESERVE method + body. Client gửi lại request với chính method gốc (POST → POST, PUT → PUT). Use case: temporary maintenance redirect — bạn muốn POST đang in-flight không bị convert sang GET làm mất body.
  • 308 Permanent Redirect — permanent, PRESERVE method + body. Khắc phục ambiguous của 301. Use case: domain migration, API endpoint move (/api/v0/products/api/v1/products) — client gửi POST/PUT tiếp tục được preserve.

Shop API decision lock cho từng tình huống:

  • 301 — chỉ dùng khi rename URL vĩnh viễn cho asset SEO matter (rare, hiện không có endpoint nào). Cẩn thận: browser cache 301 mạnh, sai một lần khó undo (user phải clear cache).
  • 303 — admin form submit POST-redirect-GET (admin dashboard B105 sau khi tạo product redirect tới detail page GET).
  • 307 — temporary maintenance redirect (đẩy traffic sang status page) + shortcut REST endpoint → CDN URL (giữ method gốc cho cache invalidation logic phía CDN).
  • 308 — API versioning migration /api/v0/*/api/v1/* khi sunset version cũ.
4

Decision Tree: Permanent + Method Preserve

Decision tree chọn đúng status code khi bạn cần redirect:

Permanent redirect?
  Yes → Preserve method? → Yes → 308 (Redirect::permanent)
                        → No  → 301 (chưa có wrapper trong axum)
  No  → Preserve method? → Yes → 307 (Redirect::temporary)
                        → No  → 303 (Redirect::to)
                              Lưu ý: KHÔNG dùng 302 nữa,
                              RFC 9110 khuyến nghị 303 hoặc 307

axum 0.8 Redirect chỉ expose 4 builder cho 4 code: 308, 303, 307, 302. Để emit 301 raw, bạn tự build response qua tuple (StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, "/new-url")]):

use axum::http::{header, StatusCode};
use axum::response::IntoResponse;

// Emit 301 raw — axum::response::Redirect không có builder cho 301
async fn rename_permanent() -> impl IntoResponse {
    (
        StatusCode::MOVED_PERMANENTLY,
        [(header::LOCATION, "/new-permanent-url")],
    )
}

Pitfall lock: dùng 301 sai chỗ — browser cache 301 trong nhiều ngày tới vô thời hạn (theo cache header, có khi không hết cho đến khi user clear). Nếu bạn 301 từ /api/v1/users sang URL sai do typo, user truy cập sẽ tự chuyển sang URL sai mãi mà không có cách reset từ phía server. Quy tắc: chỉ 301 khi CHẮC CHẮN 100% URL mới đúng + vĩnh viễn; nghi ngờ thì 307 hoặc 308 (308 cache yếu hơn 301 ở một số browser implementation, dễ rollback hơn).

Mẫu handler cho từng status code Shop API sẽ dùng:

use axum::response::Redirect;

// 308 — API versioning migration (permanent, preserve method)
async fn migrate_v0_to_v1() -> Redirect {
    Redirect::permanent("/api/v1/products")
}

// 307 — temporary maintenance (temporary, preserve method)
async fn maintenance_redirect() -> Redirect {
    Redirect::temporary("/maintenance.html")
}

// 303 — POST-redirect-GET (temporary, force GET)
async fn admin_create_product_success() -> Redirect {
    Redirect::to("/admin/products")
}
5

Serve Static: ServeDir & ServeFile

tower-http cung cấp 2 service cho serve static file:

  • ServeDir — serve toàn bộ folder, request path map sang file path trên disk. Vd ServeDir::new("public") mount với prefix /static, request GET /static/style.css → đọc file ./public/style.css trả về với MIME type tự detect từ extension.
  • ServeFile — serve single file at path cố định. Vd ServeFile::new("assets/favicon.ico") mount tại route /favicon.ico, mọi request đến route đó đều trả về cùng file.

Cả 2 đều là tower::Service chứ không phải axum::handler::Handler — mount qua Router::nest_service hoặc Router::route_service thay vì nest/route:

use axum::Router;
use tower_http::services::{ServeDir, ServeFile};

// Mount folder static qua nest_service (NOT nest)
let static_router = Router::new()
    .nest_service("/static", ServeDir::new("public"))
    .route_service("/favicon.ico", ServeFile::new("assets/favicon.ico"));

Phân biệt nest vs nest_service: nest nhận Router con (đăng ký nhiều route qua axum API), nest_service nhận thẳng tower::Service (single service xử lý mọi request đến prefix). Tương tự route vs route_service cho single path.

Để dùng ServeDirServeFile bạn cần thêm feature fs vào tower-http. Workspace dependencies hiện tại của Shop API (lock B10) đã có trace + cors + compression-gzip, cần extend thêm fs khi thực sự serve static:

# File: Cargo.toml (workspace root)
[workspace.dependencies]
tower-http = { version = "0.6", features = [
    "trace",
    "cors",
    "compression-gzip",
    "fs",        # NEW — cho ServeDir + ServeFile
] }

Shop API hiện tại chưa add feature fs vì lock B27 sẽ áp dụng CDN strategy không tự serve static (chi tiết bước 7). Feature này note add khi cần thực tế (vd serve SPA frontend bundle nếu deploy monorepo).

Behavior khác cần biết của ServeDir:

  • Tự detect MIME type từ file extension qua mime_guess crate (.csstext/css, .jsapplication/javascript, .webpimage/webp).
  • Set ETag header tự động dựa trên file mtime + size — browser conditional GET với If-None-Match → 304 Not Modified tiết kiệm bandwidth.
  • Hỗ trợ pre-compressed file qua method chain .precompressed_gzip(), .precompressed_br() — nếu folder có style.css.br sẵn, request với Accept-Encoding: br trả file đã nén tránh nén lại runtime.
  • Không tự set Cache-Control — bạn phải wrap layer thêm (bước 6).
6

Cache Header Cho Static Files

ServeDir mặc định KHÔNG set Cache-Control — browser fallback heuristic (thường cache vài phút theo Last-Modified). Production cần chiến lược rõ ràng:

  • Production — asset có hash trong filename (app.abc123.js, style.def456.css): set Cache-Control: public, max-age=31536000, immutable — 1 năm + flag immutable báo browser không re-validate. Khi build mới sinh hash khác → filename khác → URL khác → cache hit cũ không ảnh hưởng version mới (cache busting qua filename).
  • Production — file không hash (index.html): Cache-Control: public, max-age=300 hoặc no-cache để browser luôn re-validate, đảm bảo deploy mới user thấy ngay.
  • Dev: Cache-Control: no-cache để browser luôn fetch — mỗi save file thay đổi reflect ngay trong tab đang mở (HMR-like behavior cho static file).

Pattern set Cache-Control qua SetResponseHeaderLayer wrap ServeDir:

use axum::http::{header, HeaderValue};
use axum::Router;
use tower_http::services::ServeDir;
use tower_http::set_header::SetResponseHeaderLayer;

// Static service với pre-compressed asset support
let static_service = ServeDir::new("public")
    .precompressed_gzip()
    .precompressed_br();

// Wrap layer set Cache-Control cho mọi response
let static_router = Router::new()
    .nest_service("/static", static_service)
    .layer(SetResponseHeaderLayer::overriding(
        header::CACHE_CONTROL,
        HeaderValue::from_static("public, max-age=31536000, immutable"),
    ));

SetResponseHeaderLayer::overriding ghi đè header đã có (nếu inner service set Cache-Control trước, layer này override). Biến thể ::if_not_present chỉ set khi chưa có — phù hợp khi inner service đã quyết định cache strategy per file.

Combo với ETagServeDir set tự động: browser gửi If-None-Match: "abc123" trong request tiếp theo, server compare ETag, match → trả 304 Not Modified body rỗng (tiết kiệm bandwidth). Cache-Control max-age dài + ETag conditional GET là combo tối ưu cho static asset production.

Tham khảo: docs.rs/tower-http/latest/tower_http/set_header/index.html cho đầy đủ method SetResponseHeaderLayer.

7

Shop API CDN Strategy

Lock B27 cho Shop API: REST API KHÔNG tự serve static asset (product image, JS bundle, CSS, font). Toàn bộ asset đẩy lên object storage (S3, Cloudflare R2) + serve qua CDN edge (CloudFront, Cloudflare CDN, Bunny CDN).

Bốn lý do:

  • App server tập trung business logic — code Rust optimize cho concurrency + low-latency database access, KHÔNG cần cạnh tranh resource serve image hàng GB.
  • Edge caching globally — CDN có POP (point of presence) ở hàng chục city, user Hà Nội fetch image từ POP Singapore latency ~30ms thay vì cross-Pacific tới app server US ~200ms.
  • Auto scale, không tốn bandwidth backend — Black Friday traffic spike image fetch tăng 100x, CDN absorb hoàn toàn, app server không thấy load này (chỉ thấy /api/v1/products query).
  • Cost — bandwidth CDN ($0.01-0.04/GB) rẻ hơn nhiều bandwidth từ datacenter compute instance ($0.09/GB AWS EC2 egress).

Exception duy nhất Shop API tự serve static: Swagger UI (HTML/CSS/JS bundled qua utoipa-swagger-ui crate lock B8) — vì cần tích hợp chặt với OpenAPI spec runtime, không upload CDN. Bundle ~500KB embed vào binary qua include_bytes! macro, serve trực tiếp từ memory, không touch disk.

Pattern Shop API code: KHÔNG add ServeDir cho /products/:slug/image — thay bằng redirect 307 tới CDN URL:

use axum::extract::Path;
use axum::response::Redirect;

// File: crates/shop-api/src/handlers/products.rs (preview G7)
async fn product_image_redirect(
    Path(slug): Path<String>,
) -> Redirect {
    let cdn_url = format!(
        "https://cdn.shop.com/products/{}.webp",
        slug,
    );
    Redirect::temporary(&cdn_url)
}

Lý do dùng 307 chứ không phải 301: (a) URL CDN có thể đổi khi migrate provider (CloudFront → Bunny), 307 cho phép thay đổi không bị browser cache permanent; (b) preserve method (nếu một ngày bạn thêm HEAD request từ client để check existence không tải body, 307 giữ HEAD, 301 có thể bị browser convert).

Upload flow: B247 (Group Upload) chi tiết presigned URL pattern — client upload trực tiếp từ browser lên S3 qua presigned PUT URL, app server chỉ generate URL có signature thời hạn 5 phút và lưu meta data (file path, size, mime type) vào DB. App server không proxy bytes upload, tránh bottleneck.

8

Apply Vào Shop API: Demo Routes

Demo 2 redirect route thực tế cho Shop API trong file routes/demo_redirect.rs — mount qua router.rs ngang hàng routes/demo_error.rs hiện có. Đây là code preview (chưa lock thay đổi workspace ở B27, chỉ minh họa pattern):

// File: crates/shop-api/src/routes/demo_redirect.rs (preview, sẽ implement khi có nhu cầu thực)
use axum::{response::Redirect, routing::get, Router};
use crate::state::AppState;

pub fn routes() -> Router<AppState> {
    Router::new()
        // 308 — permanent migration (preserve method + body)
        .route(
            "/old/products",
            get(|| async { Redirect::permanent("/api/v1/products") }),
        )
        // 307 — temporary maintenance (preserve method + body)
        .route(
            "/maintenance",
            get(|| async { Redirect::temporary("/maintenance.html") }),
        )
}

Mount qua router.rs (lock B17):

// File: crates/shop-api/src/router.rs (preview, thêm dòng .merge())
pub fn build_router(state: AppState) -> Router {
    Router::new()
        .route("/", get(root))
        .merge(routes::health::routes())
        .merge(routes::version::routes())
        .merge(routes::demo_error::routes())
        .merge(routes::demo_redirect::routes())   // NEW
        .with_state(state)
}

Verify qua curl flag -v in chi tiết request/response gồm status line + Location header:

# 308 Permanent Redirect (preserve method)
curl -v http://localhost:3000/old/products
# < HTTP/1.1 308 Permanent Redirect
# < location: /api/v1/products
# < content-length: 0
# < (body rỗng)
# 307 Temporary Redirect (preserve method, test với POST giữ body)
curl -v -X POST http://localhost:3000/maintenance \
    -H "Content-Type: application/json" \
    -d '{"foo":"bar"}'
# < HTTP/1.1 307 Temporary Redirect
# < location: /maintenance.html
# < content-length: 0
# (Browser sẽ tự POST tiếp tới /maintenance.html với cùng body)

So sánh với curl flag -L tự follow redirect:

# Follow redirect chain với -L
curl -L -v http://localhost:3000/old/products
# Trip 1: GET /old/products → 308 + Location /api/v1/products
# Trip 2: GET /api/v1/products → 200 + JSON envelope products list
# (curl tự gửi request thứ 2 đến Location URL)

Lưu ý debug: nếu test với HTTPie dùng flag --follow tương tự curl -L; Postman mặc định tự follow trừ khi bạn tắt "Automatically follow redirects" trong Settings. Khi viết integration test với axum-test (chi tiết B253), assert status code 307/308 trước khi follow để verify redirect đúng intent.

Suggested commit khi feature redirect thực được implement: B27: add demo_redirect routes (308 versioning + 307 maintenance) + document CDN strategy.

9

Tổng Kết

  • axum::response::Redirect 4 builder method ánh xạ 4 status code: Redirect::permanent308 (permanent, preserve method), Redirect::to303 (POST-redirect-GET, force GET), Redirect::temporary307 (temporary, preserve method), Redirect::found302 (legacy, không khuyến nghị).
  • Permanent vs Temporary: 301/308 client cache vĩnh viễn, 302/303/307 không cache (mỗi request lại hit server).
  • Preserve method: 307/308 giữ POST body cho request tiếp theo, 301/302/303 client có thể đổi sang GET (303 luôn GET).
  • Pitfall: Redirect::permanent trong axum 0.8 trả 308 chứ KHÔNG phải 301 — đọc docs.rs/axum/latest/axum/response/struct.Redirect.html confirm.
  • Để emit 301 raw, tự build response qua tuple (StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, "/url")]) — cẩn thận browser cache 301 mạnh, sai khó undo.
  • tower-http ServeDir serve folder static + ServeFile serve single file — cần thêm feature fs vào tower-http (Shop API workspace lock B10 hiện chỉ có trace + cors + compression-gzip, sẽ add fs khi cần thực tế).
  • Mount tower::Service qua Router::nest_service hoặc Router::route_service (KHÔNG phải nest/route vốn dành cho Router + Handler).
  • Cache header production cho asset có hash trong filename: Cache-Control: public, max-age=31536000, immutable qua SetResponseHeaderLayer wrap ServeDir. ETag tự động set bởi ServeDir cho conditional GET 304 Not Modified.
  • Shop API CDN strategy lock B27: KHÔNG tự serve product image / JS bundle / CSS — đẩy lên S3 hoặc Cloudflare R2 + serve qua CDN (CloudFront, Cloudflare, Bunny). Exception duy nhất là Swagger UI bundled qua utoipa-swagger-ui (lock B8).
  • Pattern redirect 307 từ REST endpoint tới CDN URL khi cần shortcut (vd GET /products/:slug/image redirect 307 tới https://cdn.shop.com/products/<slug>.webp) — 307 vì URL CDN có thể đổi khi migrate provider + preserve method nếu client dùng HEAD.
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Phân biệt 301 vs 308: cái nào permanent? cái nào preserve method? Khi nào nên chọn 308 thay vì 301?
  2. POST /login thành công nên redirect đến /dashboard với status code gì? Vì sao không dùng 302 hoặc 307?
  3. tower-http ServeDirServeFile cần feature gì trong Cargo.toml? Mount qua method nào của Router — khác gì với nest thông thường?
  4. Shop API serve product image qua CDN hay tự host? Tại sao chọn vậy? Exception nào được tự serve static từ app server?
  5. Asset có hash trong filename (app.abc123.js) nên set Cache-Control gì? Vì sao thêm flag immutable? Combo với ETag work như thế nào?
Đáp án
  1. 301 vs 308: cả hai đều permanent (client cache vĩnh viễn). Khác biệt cốt lõi ở method preserve: 301 cho phép client đổi method (lịch sử HTTP/1.0 nhiều browser tự convert POST → GET sau 301 dù RFC khuyến nghị giữ), 308 BẮT BUỘC preserve method + body. RFC 9110 bổ sung 308 vào năm 2015 (RFC 7538) để khắc phục ambiguity của 301. Khi nào chọn 308 thay 301: (a) endpoint có thể nhận POST/PUT/PATCH (API endpoint migration /api/v0/products/api/v1/products cần giữ method + body); (b) muốn dễ rollback hơn — 308 cache yếu hơn 301 ở một số browser implementation, sai có thể fix nhanh hơn. axum 0.8 Redirect::permanent trả 308 (KHÔNG phải 301) — phù hợp default modern. Để emit 301 raw bạn tự build tuple (StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, "/new")]); chỉ dùng khi rename URL vĩnh viễn cho GET-only resource (SEO benefit search engine update index, tận dụng cache 301 cũ hơn).
  2. POST /login thành công redirect đến /dashboard nên dùng 303 See Other (qua Redirect::to("/dashboard")). Vì sao: (a) 303 luôn force GET cho request tiếp theo bất kể method gốc — đúng intent vì /dashboard là GET endpoint render HTML, không nhận POST; (b) tránh issue user refresh trang dashboard browser resubmit POST /login (kinh điển "Confirm Form Resubmission" dialog Chrome — phá UX). KHÔNG dùng 302 vì semantic mơ hồ — RFC 9110 khuyến nghị 303 hoặc 307 thay thế, 302 chỉ giữ cho backward compat legacy. KHÔNG dùng 307 vì 307 preserve method — browser tiếp tục gửi POST /dashboard với body username/password → endpoint dashboard nhận POST không expect → fail hoặc trả 405 Method Not Allowed. Pattern POST-redirect-GET là use case kinh điển của 303, lock cho admin form submit Shop API (B105).
  3. Feature cần thêm: fs vào tower-http trong Cargo.toml workspace dependencies. Shop API lock B10 hiện có 3 feature (trace + cors + compression-gzip), khi cần serve static thực tế add thêm fs — vd: tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "fs"] }. Mount method: ServeDir qua Router::nest_service("/static", ServeDir::new("public"))ServeFile qua Router::route_service("/favicon.ico", ServeFile::new("assets/favicon.ico")). Khác nest/route thông thường: nest nhận Router con (đăng ký nhiều route qua axum API qua các .route() chain), nest_service nhận tower::Service trực tiếp (single service xử lý mọi request đến prefix); ServeDirServeFiletower::Service chứ không phải axum::handler::Handler nên BẮT BUỘC dùng phiên bản _service. Tương tự route vs route_service cho single path.
  4. Shop API serve product image qua CDN (S3 / Cloudflare R2 + CloudFront / Cloudflare CDN / Bunny CDN), KHÔNG tự host từ app server. 4 lý do: (a) App server tập trung business logic — code Rust optimize cho concurrency + low-latency DB access, không nên cạnh tranh resource serve image GB; (b) Edge caching globally — CDN có POP ở hàng chục city, user Hà Nội fetch image từ POP Singapore latency ~30ms thay vì cross-Pacific tới app server US ~200ms; (c) Auto scale, không tốn bandwidth backend — Black Friday spike image fetch 100x CDN absorb hoàn toàn, app server không thấy load này; (d) Cost — bandwidth CDN ($0.01-0.04/GB) rẻ hơn nhiều bandwidth từ datacenter compute instance ($0.09/GB AWS EC2 egress). Exception duy nhất: Swagger UI static (HTML/CSS/JS bundled qua utoipa-swagger-ui crate lock B8) — bundle ~500KB embed vào binary qua include_bytes! macro, serve trực tiếp từ memory, lý do không upload CDN vì cần tích hợp chặt với OpenAPI spec runtime (spec generate code-first từ utoipa derive macro). Upload flow: B247 chi tiết presigned URL — client upload trực tiếp từ browser lên S3, app server chỉ generate signature thời hạn 5 phút và lưu meta data DB, không proxy bytes.
  5. Asset có hash trong filename (app.abc123.js) nên set Cache-Control: public, max-age=31536000, immutable. Giải nghĩa từng directive: (a) public — cho phép cả browser cache lẫn intermediate proxy/CDN cache (khác private chỉ cache browser); (b) max-age=31536000 — 1 năm (60×60×24×365 giây), thời gian browser giữ response trong local cache không hỏi lại server; (c) immutable — flag báo browser không re-validate dù user nhấn refresh (Ctrl+R / Cmd+R), tận dụng tối đa cache, chỉ revalidate khi hard refresh (Ctrl+Shift+R). Vì sao thêm immutable: hash trong filename đảm bảo content không bao giờ đổi (URL app.abc123.js luôn map cùng bytes), build mới sinh hash khác → filename khác → URL khác → cache busting tự nhiên qua filename; flag immutable báo browser skip cả conditional GET (If-None-Match) tiết kiệm 1 round-trip. Combo với ETag: ServeDir set ETag tự động dựa file mtime + size, browser conditional GET với If-None-Match: "abc123" trong request tiếp theo, server compare ETag match → trả 304 Not Modified body rỗng (tiết kiệm bandwidth, browser dùng cached body). Pattern max-age dài + ETag conditional GET tối ưu cho static asset production — first hit full download, subsequent hit trong 1 năm bypass server hoàn toàn (immutable) hoặc 304 nhỏ (không immutable).
11

Bài Tiếp Theo

— chi tiết Router::with_state(state) provide AppState cho mọi handler, State<AppState> extractor trong signature handler, AppState chứa pool + redis + config (preview G6/G18), pattern share state cross-handler, testing với mock state.