Mục lục
- Mục Tiêu Bài Học
- Vấn Đề: Field Internal Bị Lộ Ra Wire
#[serde(skip)]— Ẩn Field Hoàn Toànskip_serializingVsskip_deserializingskip_serializing_if— Skip Theo Điều Kiện#[serde(rename)]— Đổi Tên Field Wire#[serde(rename_all)]— Toàn Bộ Struct- Apply:
UserResponseDtoShop API - Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Phân biệt 4 attribute skip của serde —
skip,skip_serializing,skip_deserializing,skip_serializing_if— mỗi attribute use case riêng. - Ẩn field nhạy cảm (
password_hash) khỏi wire JSON — pattern bảo mật MANDATORY cho mọi response DTO. - Tránh field optional null verbose trong response —
skip_serializing_if = "Option::is_none"omit field khi None. - Đổi tên field wire khác Rust idiomatic với
#[serde(rename = "...")]— map field third-party API, tránh keyword conflict. - Áp
rename_all = "camelCase"cho toàn bộ struct theo convention client JS — và lý do Shop API locksnake_casethay vìcamelCase. - Áp dụng
UserResponseDtoShop API — lần đầu định nghĩa User domain (response DTO + Internal Entity tách bạch, KHÔNG include create/update — đó là B104).
Vấn Đề: Field Internal Bị Lộ Ra Wire
Domain entity load từ DB thường chứa field internal KHÔNG nên expose ra client. Lấy User Shop API làm ví dụ:
// Anti-pattern — entity với derive Serialize trực tiếp
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: UserId,
pub email: String,
pub password_hash: String, // ← KHÔNG được expose!
pub created_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>, // ← soft delete internal
pub login_count: u32, // ← internal analytics
}
Handler trả entity thẳng làm response sẽ leak toàn bộ field ra wire JSON:
{
"id": 1,
"email": "[email protected]",
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$...",
"created_at": "2026-06-14T10:00:00Z",
"deleted_at": null,
"login_count": 42
}
Hậu quả nghiêm trọng:
- Security breach —
password_hashbị lộ; attacker offline brute-force hash hoặc rainbow table attack; dù argon2 chậm, vẫn là bug security critical phải fix ngay. - Information leak —
deleted_attiết lộ user đã bị soft delete (internal business logic),login_counttiết lộ pattern hoạt động (analytics not for public). - API contract pollution — client SDK gen code từ wire JSON sẽ có field thừa client không bao giờ nên dùng; refactor schema sau khó vì client đã rely.
Lỗi này lặp lại nhiều trong industry — Heroku 2020 leak API token qua endpoint /account, GitHub 2018 leak email qua event API. Root cause đều là entity reuse cho response không tách DTO.
Có 2 giải pháp:
- Inline attribute skip — dùng
#[serde(skip)]trên field cần ẩn; giữ 1 struct cho cả entity và response. Đơn giản nhưng dễ accident (dev quên gắn attribute). - Tách struct DTO riêng — Internal Entity KHÔNG derive Serialize, Response DTO derive Serialize chỉ chứa field public; convert qua
From<Entity>. An toàn hơn, lock B45 Shop API.
Bài hôm nay đi qua cả 2 — attribute skip trước để hiểu công cụ, sau đó áp pattern tách DTO production-grade vào Shop API.
#[serde(skip)] — Ẩn Field Hoàn Toàn
skip báo serde bỏ qua field ở cả 2 chiều — KHÔNG serialize ra JSON, KHÔNG nhận từ JSON input:
#[derive(Serialize, Deserialize)]
pub struct User {
pub id: UserId,
pub email: String,
#[serde(skip)]
pub password_hash: String, // KHÔNG xuất, KHÔNG nhận từ JSON
}
JSON output bỏ field hoàn toàn:
{"id": 1, "email": "[email protected]"}
Khi deserialize từ JSON input thiếu password_hash, serde cần biết cách khởi tạo field — mặc định gọi Default::default():
Stringdefault = empty""u32/u64/i32default =0Option<T>default =NoneVec<T>default = emptyvec![]- Custom type cần
#[derive(Default)]hoặc impl thủ công
Pitfall lớn: nếu field type KHÔNG impl Default, compile error. Vd chrono::DateTime<Utc> không có Default (không có "default time" hợp lý) — phải gắn default = "..." chỉ rõ function:
use chrono::{DateTime, Utc};
#[derive(Serialize, Deserialize)]
pub struct AuditEvent {
pub action: String,
#[serde(skip, default = "Utc::now")]
pub recorded_at: DateTime<Utc>, // generate khi deserialize
}
Use case skip trong Shop API thực tế ít — vì pattern lock B45 dùng struct DTO tách bạch (xem step 8). skip chỉ phù hợp khi 1 struct dùng đa mục đích, chấp nhận field internal khởi tạo default.
skip_serializing Vs skip_deserializing
Hai attribute con tách skip theo từng chiều:
skip_serializing: KHÔNG xuất ra JSON nhưng VẪN deserialize từ JSON input.skip_deserializing: serialize ra JSON nhưng KHÔNG nhận từ JSON input (init quaDefaulthoặcdefault = "...").
Use case skip_serializing: field hash internal cần load từ DB query (deserialize từ DB row JSON) nhưng KHÔNG xuất ra response wire. Pitfall: trong thực tế pattern này ít dùng — vì sqlx load row trả struct (KHÔNG qua JSON), tách DTO response + entity rõ ràng vẫn tốt hơn. Lock B45: tránh dùng skip_serializing, ưu tiên tách struct.
Use case skip_deserializing phổ biến hơn — field server-generated mà client KHÔNG được set. Ví dụ id của entity:
#[derive(Serialize, Deserialize)]
pub struct Product {
#[serde(skip_deserializing)]
pub id: ProductId, // server tự generate, client gửi sẽ bị bỏ qua
pub name: String,
pub price: Money,
}
Khi client POST {"id": 999, "name": "iPhone", "price": "25000000.00"}:
- Field
idtrong JSON input bị bỏ qua,ProductIdinit quaDefault(cần impl). - Server gen ID mới qua DB
INSERT ... RETURNING id. - Response serialize trả
idthật.
Field tương tự server-generated: created_at, updated_at, slug (nếu auto-generate từ name).
Pitfall: client gửi id không gây error — bị bỏ qua silent. Một số API muốn reject explicit (vd "id" not allowed in request body"); lúc đó dùng #[serde(deny_unknown_fields)] trên struct + có struct riêng KHÔNG có field id sẽ rõ ràng hơn. Lock B45 Shop API: tách struct CreateXxxDto + ResponseXxxDto thay vì share struct với skip_deserializing — semantic rõ và compile-time safe hơn.
skip_serializing_if — Skip Theo Điều Kiện
Attribute mạnh nhất trong nhóm skip — KHÔNG bỏ field cố định, mà theo predicate runtime. Use case kinh điển: Option<T> = None thì KHÔNG xuất field (tránh null verbose):
use serde::Serialize;
use chrono::{DateTime, Utc};
#[derive(Serialize)]
pub struct UserResponseDto {
pub id: UserId,
pub email: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
}
So sánh JSON wire khi field = None:
// KHÔNG skip_serializing_if:
{
"id": 1,
"email": "[email protected]",
"avatar_url": null,
"phone": null
}
// CÓ skip_serializing_if = "Option::is_none":
{
"id": 1,
"email": "[email protected]"
}
Pros của pattern này:
- Wire gọn hơn — bandwidth saved 30-50% với response nhiều Option field (admin dashboard có 20+ field optional).
- Client semantic rõ — field missing = "không có giá trị", thay null gây nhầm "có giá trị null" trong JS (JS distinguish
undefinedvsnull). - Industry standard — Stripe API, GitHub API, Slack API đều omit null field response — đọc OpenAPI spec sẽ thấy
nullable: falsemặc định cho mọi optional response field.
Pattern lock Shop API: response DTO MANDATORY gắn #[serde(skip_serializing_if = "Option::is_none")] trên mọi Option field. KHÔNG omit attribute "vì lười" — wire format không nhất quán hại reputation API hơn lợi ích viết code nhanh.
Predicate khác — skip_serializing_if nhận đường dẫn tới function path fn(&T) -> bool:
#[derive(Serialize)]
pub struct ProductListResponse {
pub items: Vec<ProductDto>,
// Bỏ field "items" nếu vec empty
#[serde(skip_serializing_if = "Vec::is_empty")]
pub recommendations: Vec<ProductDto>,
// Bỏ field nếu string empty
#[serde(skip_serializing_if = "String::is_empty")]
pub search_query: String,
}
Custom function predicate khi cần logic riêng:
fn is_zero(value: &u32) -> bool {
*value == 0
}
#[derive(Serialize)]
pub struct CartDto {
pub items_count: u32,
#[serde(skip_serializing_if = "is_zero")]
pub discount_amount: u32, // omit khi không giảm giá
}
Pitfall: function predicate phải nhận &T reference (KHÔNG owned T) và trả bool — sai signature compile error khó đọc. Đặt function cùng module với struct để dễ trace.
#[serde(rename)] — Đổi Tên Field Wire
rename = "..." đổi tên field 1-1 — Rust giữ tên idiomatic, wire dùng tên khác. Use case 1: integrate third-party API có field naming khác convention Rust. GitHub User API trả field "login" thay vì "username":
#[derive(Serialize, Deserialize)]
pub struct GitHubUser {
#[serde(rename = "login")]
pub username: String, // wire field "login" (GitHub API)
#[serde(rename = "id")]
pub github_id: u64,
#[serde(rename = "node_id")]
pub node_id: String,
}
Rust code dùng user.username idiomatic; wire JSON khớp GitHub spec:
{
"login": "octocat",
"id": 583231,
"node_id": "MDQ6VXNlcjU4MzIzMQ=="
}
Use case 2: Rust keyword conflict. Vd field tên type hoặc self không hợp lệ trong Rust struct (reserved keyword):
#[derive(Serialize, Deserialize)]
pub struct Webhook {
#[serde(rename = "type")]
pub event_type: String, // Rust `type` là keyword
#[serde(rename = "self")]
pub self_link: String, // Rust `self` là keyword
}
Alternative: dùng raw identifier r#type (Rust 2018+) trong field name nhưng đọc xấu — convention dùng rename sạch hơn.
Mặc định rename apply cả 2 chiều (serialize và deserialize cùng tên wire). Khi cần khác nhau — dùng cú pháp tuple:
#[derive(Serialize, Deserialize)]
pub struct LegacyResponse {
// Wire khi gửi: "userId", khi nhận: "user_id" hoặc "userId"
#[serde(rename(serialize = "userId", deserialize = "user_id"))]
pub user_id: u64,
}
Use case khác nhau theo chiều: API migration giai đoạn dual-write — wire output dùng tên mới "userId" nhưng wire input vẫn nhận tên cũ "user_id" để client cũ chưa migrate vẫn chạy. Sau khi 100% client migrate, gộp về 1 tên đồng nhất.
Note: nếu cần nhận nhiều tên input — dùng #[serde(alias = "...", alias = "...")] đa giá trị (sẽ deep dive ở B46). rename(deserialize) chỉ nhận 1 tên duy nhất.
#[serde(rename_all)] — Toàn Bộ Struct
rename_all convert tên field global cho whole struct theo 1 case style:
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserResponseDto {
pub id: UserId,
pub email_address: String, // wire: "emailAddress"
pub created_at: DateTime<Utc>, // wire: "createdAt"
pub login_count: u32, // wire: "loginCount"
}
Tiện hơn gắn #[serde(rename = "...")] từng field. serde hỗ trợ 8 case style:
// Field Rust | Case style | Wire output
// my_field | lowercase | myfield
// my_field | UPPERCASE | MYFIELD
// my_field | PascalCase | MyField
// my_field | camelCase | myField (JS standard)
// my_field | snake_case | my_field (Python/Ruby standard)
// my_field | SCREAMING_SNAKE_CASE | MY_FIELD (constant convention)
// my_field | kebab-case | my-field (HTML attribute, URL slug)
// my_field | SCREAMING-KEBAB-CASE | MY-FIELD (rare, HTTP header style)
Quy tắc convert: serde xử lý từ tên field gốc Rust (snake_case theo convention), parse thành "words", rồi format theo case đích. Field my_field_name → words ["my", "field", "name"] → "myFieldName" (camelCase) hoặc "my-field-name" (kebab-case).
Shop API decision lock vĩnh viễn từ B45: wire format snake_case cho toàn hệ thống. Lý do:
- Python/Ruby REST tradition — phần lớn REST API mainstream (Stripe, GitHub REST, Shopify, Twilio) dùng
snake_casetrên wire.camelCasephổ biến hơn ở GraphQL/Apollo nhưng không phải REST. - VN ecosystem — đa số REST API VN public (VietQR, MoMo, ZaloPay) dùng
snake_casehoặckebab-case;camelCaseít gặp. - Đồng bộ Rust idiomatic — Rust field naming convention là
snake_case; wiresnake_casekhớp tự nhiên KHÔNG cầnrename_allcho most struct, code gọn. - OpenAPI schema gen — schema field name khớp Rust struct field name, không phải convert qua lại tăng độ phức tạp tooling.
Hệ quả lock:
- KHÔNG dùng
#[serde(rename_all = "camelCase")]cho struct DTO Shop API — đồng nghĩa không bao giờ ép wire formatcamelCasedù client JS expect. - JSX client B85+ frontend handle convert ở layer axios interceptor hoặc tanstack-query transform — convert
snake_casewire →camelCaseJS local; component code vẫn dùnguser.emailAddresscamelCase tự nhiên trong React/Vue. - Chỉ dùng
rename_all = "snake_case"cho enum variant — vì Rust enum variant convention làPascalCase, wire serialize mặc định"Stripe"/"BankTransfer"/"Cod"không khớpsnake_casechuẩn wire;rename_all = "snake_case"convert thành"stripe"/"bank_transfer"/"cod". Lock B43 đã chốt cho enum.
Override per-field: nếu 1 field cần wire khác toàn struct, gắn #[serde(rename = "...")] đè rename_all:
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] // global snake_case
pub struct LegacyDto {
pub user_name: String, // wire: "user_name"
pub created_at: DateTime<Utc>, // wire: "created_at"
#[serde(rename = "ID")]
pub id: u64, // wire: "ID" override
}
Apply: UserResponseDto Shop API
B45 lần đầu định nghĩa User domain Shop API — chỉ Response DTO + Internal Entity, KHÔNG full CRUD (đó là B104). Tạo file mới crates/shop-common/src/dto/user.rs theo pattern lock B41/B42/B43/B44 (mỗi domain 1 module submodule trong folder dto/):
// File: crates/shop-common/src/dto/user.rs
use chrono::{DateTime, Utc};
use serde::Serialize;
use super::UserId;
/// Response DTO cho `GET /api/v1/me` và `GET /api/v1/users/:id` (admin).
///
/// Pattern lock B45:
/// - impl `Serialize` only — response one-way, KHÔNG deserialize từ wire
/// - mọi `Option` field gắn `skip_serializing_if = "Option::is_none"`
/// tránh null verbose wire format
/// - KHÔNG include `password_hash`, `deleted_at`, `login_count` từ User entity
#[derive(Debug, Clone, Serialize)]
pub struct UserResponseDto {
pub id: UserId,
pub email: String,
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_login_at: Option<DateTime<Utc>>,
}
/// Internal User entity — load từ DB row, dùng cross-layer (service, repo,
/// handler) nhưng KHÔNG bao giờ serialize ra wire.
///
/// Pattern lock B45 security:
/// - KHÔNG derive `Serialize` — compiler reject mọi attempt accident leak
/// - chứa field nhạy cảm (`password_hash`) và internal (`deleted_at`,
/// `login_count`) KHÔNG được xuất ra client
/// - convert sang `UserResponseDto` qua `From<User> for UserResponseDto`
/// trước khi trả từ handler
#[derive(Debug, Clone)]
pub struct User {
pub id: UserId,
pub email: String,
pub password_hash: String, // argon2id PHC string — server only
pub display_name: String,
pub avatar_url: Option<String>,
pub phone: Option<String>,
pub created_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
pub deleted_at: Option<DateTime<Utc>>, // soft delete internal
}
/// Conversion explicit — chỉ copy field public, password_hash và deleted_at
/// KHÔNG vào ResponseDto. Pattern lock B45 security.
impl From<User> for UserResponseDto {
fn from(user: User) -> Self {
Self {
id: user.id,
email: user.email,
display_name: user.display_name,
avatar_url: user.avatar_url,
phone: user.phone,
created_at: user.created_at,
last_login_at: user.last_login_at,
// password_hash + deleted_at KHÔNG copy → security pattern
}
}
}
Update crates/shop-common/src/dto/mod.rs thêm submodule user + re-export top-level:
// File: crates/shop-common/src/dto/mod.rs (snippet — chỉ phần B45 thêm)
pub mod payment;
pub mod product;
pub mod types;
pub mod user;
pub use payment::PaymentMethod;
pub use product::{CreateProductDto, SLUG_REGEX, UpdateProductDto};
pub use types::{CategoryId, Money, OrderId, ProductId, UserId};
pub use user::{User, UserResponseDto};
Workspace deps thêm chrono (preview B53 chi tiết — bài hôm nay chỉ wire ra wire format):
# Cargo.toml workspace root
[workspace.dependencies]
chrono = { version = "0.4", features = ["serde"] }
Feature serde kích hoạt impl Serialize/Deserialize cho DateTime<Utc> dưới dạng RFC 3339 string (vd "2026-06-14T10:00:00Z") — lock JSON Format Policy B6 (RFC 3339 UTC suffix Z mọi timestamp). Consume qua chrono.workspace = true ở shop-common.
JSON wire format response khi user mới register (chưa login, chưa avatar, chưa phone):
{
"id": 1,
"email": "[email protected]",
"display_name": "Canh Nguyen",
"created_at": "2026-06-14T10:00:00Z"
}
Ba field avatar_url, phone, last_login_at đều None → omit khỏi wire nhờ skip_serializing_if = "Option::is_none". JSON gọn, semantic rõ "field missing = no value".
Khi user upload avatar + verify phone + đăng nhập lần 2:
{
"id": 1,
"email": "[email protected]",
"display_name": "Canh Nguyen",
"avatar_url": "https://cdn.blogcode.vn/u/1/avatar.webp",
"phone": "+84912345678",
"created_at": "2026-06-14T10:00:00Z",
"last_login_at": "2026-06-14T15:30:00Z"
}
Pattern lock Shop API:
- Internal entity (
User) KHÔNG implSerialize— accident-proof. Dev nào quên rồi viếtJson(user)trả thẳng entity sẽ bị compile errorthe trait `Serialize` is not implemented for `User`. Lỗi này không thể slip qua review hay deploy. - Response DTO (
UserResponseDto) implSerializeonly — KHÔNGDeserializevì response one-way (server → client). Dev nào define endpoint acceptUserResponseDtolàm input cũng bị compile error. From<Entity> for ResponseDtoconvert explicit — KHÔNG dùngIntoauto. Đọc code thấy ngayUserResponseDto::from(user)tại điểm convert; ai thêm field mới phải sửaFromimpl, không thể quên.
Verify compile module mới B45:
cd shop && cargo build -p shop-common
# Output:
# Compiling chrono v0.4.x
# Compiling shop-common v0.1.0
# Finished `dev` profile [unoptimized + debuginfo] target(s)
Suggested commit:
git add crates/shop-common/src/dto/user.rs \
crates/shop-common/src/dto/mod.rs \
Cargo.toml
git commit -m "B45: UserResponseDto + User entity dto/user.rs + chrono workspace dep"
Tổng Kết
- 4 skip attribute mỗi loại có use case riêng:
skip— ẩn cả 2 chiều (yêu cầuDefaultcho field type)skip_serializing— ẩn xuất nhưng vẫn nhận inputskip_deserializing— xuất output, KHÔNG nhận input (id, created_at, updated_at server-generated)skip_serializing_if— ẩn theo predicate runtime (Option::is_none,Vec::is_empty, custom function)
skip_serializing_if = "Option::is_none"MANDATORY mọiOptionfield response DTO Shop API — wire gọn, semantic rõ, đồng nhất Stripe/GitHub/Shopify industry standard.renameđổi tên field wire 1-1 (third-party API integration, Rust keyword conflict); cú pháprename(serialize = "...", deserialize = "...")cho 2 chiều khác nhau (API migration dual-write).rename_allconvert global cho whole struct theo 8 case style; Shop API wire formatsnake_caselock vĩnh viễn KHÔNG dùngrename_allmost struct.rename_all = "snake_case"chỉ dùng cho enum variant (B43 lock) vì Rust enum variant làPascalCasemặc định.- Internal Entity vs Response DTO tách bạch — Entity KHÔNG impl
Serialize(accident-proof), DTO implSerializeonly (response one-way); convert quaFrom<Entity> for ResponseDtoexplicit, KHÔNGIntoauto. UserResponseDtolock fields:id,email,display_name,avatar_url?,phone?,created_at,last_login_at?.password_hash,deleted_at,login_countKHÔNG bao giờ vào response wire — security pattern lock vĩnh viễn.- File path lock B45:
crates/shop-common/src/dto/user.rs— mỗi domain 1 module submodule (đồng nhất lock B41 product, B43 payment, B44 types). - Workspace deps thêm
chrono = { version = "0.4", features = ["serde"] }choDateTime<Utc>ISO 8601 wire format (preview B53 deep dive). - JSX client B85+ frontend handle conversion
snake_casewire →camelCaseJS local ở axios interceptor — không phải ép wire formatcamelCasetừ backend. - Foundation cho B46 (custom serializer + deserializer manual impl), B53 (chrono DateTime ISO 8601 deep dive), B104 (CreateUserDto + UpdateUserDto register flow), B112 (auth handler return
UserResponseDtosau verify password).
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Phân biệt
skip,skip_serializing,skip_deserializing,skip_serializing_if. Use case nào dùng cái nào? Cho ví dụ code mỗi loại. - Field
idcủa entity nên dùng skip attribute nào? Tại sao? Pitfall nếu client cố tình gửiidtrong POST body là gì, và Shop API xử lý thế nào (lock B45)? skip_serializing_if = "Option::is_none"mang lại lợi ích gì cho wire format? Cho ví dụ JSON có và không attribute với response DTO 5 field optional (3 None, 2 Some).- Shop API wire format chọn
snake_casethaycamelCase. Lý do và trade-off với client JS? Client React/Vue xử lý conversion ở đâu? - Pattern Internal Entity vs Response DTO khác nhau ra sao? Tại sao Entity KHÔNG impl
Serialize? Tại sao dùngFrom<Entity>explicit thayIntoauto?
Đáp án
- Phân biệt 4 skip attribute — use case mỗi loại.
skip: ẩn cả serialize VÀ deserialize. Field KHÔNG vào JSON output, KHÔNG nhận từ JSON input (init quaDefault). Use case: field internal runtime cache, lazy field tính từ field khác.#[derive(Serialize, Deserialize)] pub struct Config { pub api_key: String, #[serde(skip)] pub cached_client: Option<HttpClient>, // runtime cache }skip_serializing: KHÔNG xuất ra JSON nhưng VẪN deserialize từ input. Use case ít gặp — field load từ source khác (DB row JSON) cần ẩn ra wire response.#[derive(Serialize, Deserialize)] pub struct InternalRecord { pub id: u64, #[serde(skip_serializing)] pub raw_payload: serde_json::Value, // load từ DB, không xuất wire }skip_deserializing: serialize ra JSON nhưng KHÔNG nhận từ input. Use case phổ biến: field server-generated (id, created_at, updated_at).#[derive(Serialize, Deserialize)] pub struct Product { #[serde(skip_deserializing)] pub id: ProductId, // server gen, client gửi bị bỏ qua pub name: String, pub price: Money, }skip_serializing_if: ẩn theo predicate runtime. Use case kinh điển:Option<T>None thì omit field tránh null verbose.
Quy tắc chọn: bí mật runtime →#[derive(Serialize)] pub struct UserDto { pub id: u64, #[serde(skip_serializing_if = "Option::is_none")] pub avatar_url: Option<String>, #[serde(skip_serializing_if = "Vec::is_empty")] pub tags: Vec<String>, }skip, field load 1 chiều input-only →skip_serializing, field server-gen output-only →skip_deserializing, field optional dạngOption→skip_serializing_if = "Option::is_none". Lock Shop API B45: ưu tiên tách struct DTO riêng thay vì share struct nhiều mục đích — chỉskip_serializing_iflà MANDATORY mọi Option field response DTO; 3 loại còn lại tránh dùng vì pattern struct tách bạch an toàn hơn. - Field
identity nên dùngskip_deserializing— và lý do Shop API tách struct. Nếu giữ 1 struct entity cho cả input/output:skip_deserializingẩn input nhưng vẫn xuất output. Server genidqua DBINSERT ... RETURNING id; client gửi{"id": 999, ...}bị bỏ qua silent, init quaDefault(nếu type impl).
Pitfall: (a) Client cố tình gửi#[derive(Serialize, Deserialize)] pub struct Product { #[serde(skip_deserializing)] pub id: ProductId, pub name: String, pub price: Money, }idKHÔNG gây error — bị bỏ qua silent. API "lenient" nhưng client không biết payload bị reject (gọi POST vớiid: 999hi vọng tạo entity ID cụ thể, nhận response ID 1 do server gen, confused). (b)ProductIdphải implDefaultđể deserialize hoạt động — Shop API hiện chưa implDefaultcho newtype ID (B44 lock 5 derive: Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize — KHÔNG Default). Phải thêm hoặc dùng#[serde(skip_deserializing, default = "...")]chỉ rõ function. (c) Reject explicit khi client gửi field không cho phép: dùng#[serde(deny_unknown_fields)]trên struct — nhưng phải có struct riêng KHÔNG có fieldidthì mới reject; nếu giữ fieldidvớiskip_deserializingthìdeny_unknown_fieldsKHÔNG reject (field tồn tại trong struct, attribute chỉ skip parse value). Cách Shop API xử lý (lock B45): tách 2 struct riêng:
Pros: (a) compile-time safe — handler nhận// Input request body — KHÔNG có id (client KHÔNG được gửi) #[derive(Deserialize)] pub struct CreateProductDto { pub name: String, pub price: Money, } // Output response — CÓ id (server gen sau INSERT) #[derive(Serialize)] pub struct ProductResponseDto { pub id: ProductId, pub name: String, pub price: Money, }ValidatedJson<CreateProductDto>KHÔNG có fieldidnên client gửiidbịdeny_unknown_fieldsreject 400 (nếu enable) hoặc silent skip (nếu disable); (b) semantic rõ — đọc struct biết ngay request vs response shape; (c) tránh phụ thuộcDefaultimpl cho ID newtype. Lock B45 + B41 pattern Shop API. skip_serializing_if = "Option::is_none"lợi ích wire — ví dụ JSON 5 field optional. KHÔNG attribute — verbose với null:
Wire ~210 byte, 3 field{ "id": 1, "email": "[email protected]", "display_name": "Canh", "avatar_url": null, "phone": null, "bio": null, "twitter_handle": "@canh", "website_url": "https://blogcode.vn" }nullkhông mang giá trị. Client JS parseuser.avatar_url === nullphải xử lý case riêng vsundefined. CÓskip_serializing_if = "Option::is_none"— gọn:
Wire ~130 byte (~38% saved). 3 field{ "id": 1, "email": "[email protected]", "display_name": "Canh", "twitter_handle": "@canh", "website_url": "https://blogcode.vn" }Noneomit khỏi wire — client checkuser.avatar_url === undefinedtự nhiên hơn. Lợi ích: (a) bandwidth saved 30-50% với response nhiều Option field — admin dashboard 20+ field optional response 50KB còn 25KB; (b) semantic rõ "field missing = no value" — JS distinguishundefined(field absent) vsnull(field present với value null); (c) industry standard Stripe/GitHub/Shopify/Slack omit null field response — đọc OpenAPI spec sẽ thấynullable: falsemặc định cho optional response field; (d) wire format đẹp hơn cho debugging, log review. Lock Shop API B45: MANDATORY#[serde(skip_serializing_if = "Option::is_none")]mọiOptionfield response DTO — KHÔNG omit attribute "vì lười"; wire không nhất quán hại reputation API hơn lợi ích viết nhanh.- Shop API chọn
snake_casethaycamelCase— lý do và trade-off client JS. Lý do chọnsnake_case: (a) Industry tradition REST — Stripe (customer_id,payment_intent_id), GitHub REST (node_id,created_at), Shopify (order_number,line_items), Twilio đều dùngsnake_case;camelCasephổ biến hơn ở GraphQL/Apollo nhưng không REST mainstream. (b) VN ecosystem — đa số API VN public dùngsnake_case/kebab-case; cộng đồng Rust VN cũng quen Python/Ruby convention. (c) Đồng bộ Rust idiomatic — Rust field convention làsnake_case, wiresnake_casekhớp tự nhiên KHÔNG cầnrename_allmost struct → code gọn hơn 1 dòng/struct. (d) OpenAPI schema gen — field name khớp Rust struct trực tiếp, tooling đơn giản hơn convert qua lại. Trade-off với client JS: React/Vue developer quenuser.emailAddresscamelCase tự nhiên; wiresnake_caseép phải viếtuser.email_addresstrong component code mất idiomatic JS. Cách xử lý lock B85+ frontend: convert ở axios interceptor hoặc tanstack-queryselecttransform:
Library:// Tương đương TypeScript pseudo-code: // axios.interceptors.response.use(response => { // response.data = camelCaseKeys(response.data, { deep: true }); // return response; // }); // // Client component vẫn dùng: // const { data: user } = useUser(); // user.emailAddress // camelCase tự nhiên JScamelcase-keys,humps, hoặc viết function recursive. Cost: 1-time setup interceptor, sau đó component code giữ camelCase JS idiomatic. Trade-off cuối: chọnsnake_casewire ưu tiên backend consistency + industry standard + Rust idiomatic; frontend bear chi phí 1-time conversion layer. Lock Shop API B45 vĩnh viễn: wire formatsnake_case, KHÔNG bao giờ épcamelCase; chỉ dùngrename_all = "snake_case"cho enum variant (B43 lock). - Pattern Internal Entity vs Response DTO — vì sao tách và
Fromexplicit. Khác nhau: Internal Entity (User) load từ DB row, dùng cross-layer service/repo/handler, chứa field nhạy cảm (password_hash) + internal (deleted_at,login_count). Response DTO (UserResponseDto) trả về client wire JSON, chỉ field public, mọi Option gắnskip_serializing_if. Tại sao Entity KHÔNG implSerialize: accident-proof security pattern. Dev nào quên rồi viếtJson(user)trong handler trả entity thẳng sẽ bị compile errorthe trait `Serialize` is not implemented for `User`. Lỗi không thể slip qua review hay deploy — compiler enforce. Nếu Entity derive Serialize, accident có thể slip qua review nếu reviewer không để ý field nhạy cảm; production leak password hash là disaster.
Tại sao dùng// Internal entity — accident-proof #[derive(Debug, Clone)] // KHÔNG Serialize pub struct User { pub id: UserId, pub email: String, pub password_hash: String, pub deleted_at: Option<DateTime<Utc>>, } // Compile error nếu accident: async fn handler() -> Json<User> { // ❌ User KHÔNG impl Serialize let user = repo.find_user(1).await?; Json(user) // ERROR: trait bound `User: Serialize` not satisfied }Fromexplicit thayIntoauto: visibility + maintainability.From<User> for UserResponseDtoimpl rõ ràng trong code:
Pros: (a) Đọc code thấy ngay — convert tại điểmimpl From<User> for UserResponseDto { fn from(user: User) -> Self { Self { id: user.id, email: user.email, display_name: user.display_name, avatar_url: user.avatar_url, phone: user.phone, created_at: user.created_at, last_login_at: user.last_login_at, // password_hash + deleted_at KHÔNG copy → đọc code thấy ngay } } }UserResponseDto::from(user)explicit call site; reviewer trace dễ. (b) Add field mới phải sửaFrom— Rust compile error nếu thêm fieldUserResponseDtomàFromchưa initialize; dev không thể quên copy field từ entity sang DTO. (c) Add field nhạy cảm KHÔNG accident leak — thêm fieldbiometric_hashvào entity,Fromgiữ nguyên không copy, response DTO không có field — security pattern continued.Intoauto qua blanket implimpl<T, U> Into<U> for T where U: From<T>hoạt động sau khi defineFromnhưng KHÔNG visible trong code đọc (user.into()ẩn type đích, phải đọc context); dùng explicitUserResponseDto::from(user)rõ hơn. Lock Shop API B45 vĩnh viễn: (i) Internal Entity KHÔNG impl Serialize accident-proof, (ii) Response DTO impl Serialize only response one-way, (iii)From<Entity> for ResponseDtoexplicit conversion KHÔNGIntoauto — đảm bảo security + maintainability + reviewer-friendly code.
Bài Tiếp Theo
Bài 46: Custom Serializer + Deserializer — manual impl serde::Serializer/Deserializer cho format custom, dùng Visitor pattern, áp Vietnamese phone format normalize (0912... ↔ +84912...) cho phone field DTO Shop API.
