Danh sách bài viết

Bài 46: Custom Serializer + Deserializer Với Visitor Pattern

Bài 46 của series Rust RESTful API — phân biệt 3 mức custom serde theo độ phức tạp: mức 1 #[derive(Serialize, Deserialize)] cho type Rust standard (B41 đã cover), mức 2 #[serde(serialize_with = "...", deserialize_with = "...")] function per-field cho transform 1-off (B42 helper deserialize_optional_field đã preview), mức 3 manual impl Serialize + impl Deserialize cho newtype có rule normalize phức tạp không derive được; Visitor pattern bản chất chuẩn industry — Deserializer KHÔNG biết format input đang là JSON/YAML/TOML nên cần Visitor với 12 method visit_str/visit_string/visit_i64/visit_u64/visit_bool/visit_map/visit_seq/... mỗi method handle 1 JSON primitive cụ thể, format deserializer khi gặp value gọi method phù hợp với type gặp; implement Phone newtype VN pub struct Phone(String) inner private bảo đảm invariant normalized — constructor Phone::new(raw: &str) -> Result<Self, PhoneError> nhận 3 format input 0912345678 (số 0 prefix) HOẶC +84912345678 (international format) HOẶC 84912345678 (không có dấu +) và normalize về +84912345678 wire format chuẩn quốc tế; accessor as_str() -> &str + into_inner() -> String ergonomic API; PhoneError thiserror enum 1 variant Invalid(String) wrap raw input cho debugging; impl Serialize 1 dòng serializer.serialize_str(&self.0) xuất raw string đã normalized; impl Deserialize + PhoneVisitor với 4 phần MANDATORY: type Value = Phone output type, expecting(&mut Formatter) báo cho serde format expect cho error message khi không match ("a Vietnamese phone number string (0xxx, 84xxx, or +84xxx)"), visit_str<E>(self, value: &str) nhận borrow string JSON deserializer thường gọi, visit_string<E>(self, value: String) nhận owned string format khác có thể gọi — implement cả 2 method cho flexibility cross-format (JSON gọi visit_str, YAML có thể gọi visit_string); workflow deserialize: JSON value "0912345678" → serde gọi deserializer.deserialize_str(PhoneVisitor) gợi ý format string → JSON deserializer extract &str raw → gọi visitor.visit_str("0912345678") → Visitor gọi Phone::new(value) normalize → return Phone("+84912345678"); khác nhau Deserializer::deserialize_str vs Visitor visit_str: deserialize_str là gợi ý cho format ("tôi expect string đây") để format optimize parse, visit_str là callback cho user code ("đây là string tôi parse được, xử lý nó"); pattern lock validate trong Deserialize sớm hơn validator crate — fail fast ở extract trước khi handler chạy (validator chạy trong ValidatedJson sau extract); apply Phone Shop API cho 2 nơi: (1) PaymentMethod::Cod { phone: Phone } refactor B43 phone: String + #[validate(regex(path = *PHONE_REGEX))] sang phone: Phone — validate đã handled trong Deserialize của Phone không cần validator crate; (2) UserResponseDto.phone: Option<Phone> refactor B45 Option<String> sang Option<Phone> — From<User> for UserResponseDto update handle parse từ String DB qua user.phone.and_then(|p| Phone::new(&p).ok()) drop invalid phone silent (DB load không fail); JSON wire input client gửi 3 format được accept, output server response luôn normalized +84912345678 consistent; error parse fail (phone invalid) trả {"error": "invalid JSON: phone number invalid: ABC123", "code": "BAD_REQUEST"} 400; file path lock: extend crates/shop-common/src/dto/types.rs (B44 đã có 5 newtype, thêm Phone + PhoneError + impl + Visitor + normalize_vietnam_phone private fn), update dto/mod.rs re-export Phone + PhoneError, update dto/payment.rs + dto/user.rs dùng Phone; decision lock B46 vĩnh viễn: 3 mức custom serde pick by complexity (derive < with fn < manual impl Visitor), Visitor pattern MANDATORY mọi manual Deserialize impl Shop API, visit_str + visit_string đồng implement cho format flexibility, expecting method MANDATORY mọi Visitor error message rõ format, inner private (Type(String)) cho newtype có invariant normalized, validate trong Deserialize fail fast sớm hơn validator, constructor new() Result-returning thay From cho normalize fallible, Phone normalize 3 format VN lock (0xxx → +84xxx, 84xxx → +84xxx, +84xxx pass through), Phone PARTIAL replace PHONE_REGEX B43 (PaymentMethod::Cod giờ dùng Phone, validator constant vẫn lock cho future use case String field), pattern reuse cho Email B105 + refactor Money B54 (Decimal precision validate); foundation cho B47 (Vec, HashMap collection trong JSON — size limit chống DoS), B53 (chrono DateTime custom format multi-input), B105 (Email newtype wrap String validate format).

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

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

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

  • Hiểu khi nào #[derive(Serialize, Deserialize)] KHÔNG đủ, cần manual impl trait.
  • Phân biệt 3 cách custom: deserialize_with function per-field, serialize_with function per-field, manual impl Serialize/Deserialize cho whole type.
  • Hiểu Visitor pattern — cách serde traverse format không biết trước (JSON, YAML, TOML, MsgPack).
  • Implement Phone newtype tự normalize format 0912...+84912... về wire chuẩn quốc tế.
  • Phân biệt Deserializer::deserialize_str (gợi ý format cho parser) vs Visitor visit_str (callback nhận string).
  • Áp Phone cho PaymentMethod::Cod (B43 refactor) và UserResponseDto (B45 refactor) — wire format consistent +84xxxxxxxxx.
2

Khi Nào Cần Custom Serializer/Deserializer

serde cho phép tùy biến ở 3 mức từ đơn giản → phức tạp:

Mức    | Tên                                       | Phạm vi      | Effort
1      | #[derive(Serialize, Deserialize)]         | Whole type   | Low
2      | #[serde(serialize_with / deserialize_with)] | Per-field   | Medium
3      | Manual impl Serialize / impl Deserialize  | Whole type   | High

Mức 1 đã đủ cho 95% type Rust standard — String, i64, DateTime<Utc> (với feature serde), struct DTO, enum tagged. Mức 2 đã preview ở B42 với helper deserialize_optional_field cho double-Option PATCH semantic.

Mức 3 cần khi 1 trong 4 case:

  • Type third-party không có derive macro — wrap struct external crate KHÔNG impl Serialize, phải tự implement.
  • Cần handle nhiều format input linh hoạt — field có thể nhận cả string lẫn số (vd ID đến từ JSON "123" string format hoặc 123 number format).
  • Normalize / transform value trong quá trình deserialize — input phong phú, output chuẩn hóa.
  • Newtype có rule validate phức tạp hơn validator crate hỗ trợ — vd state machine, parse format đặc thù.

Use case Shop API B46: Phone field nhận 3 format input từ client:

// Client A gửi format số 0 prefix (phổ biến VN):
{"phone": "0912345678"}

// Client B gửi format quốc tế đầy đủ (chuẩn E.164):
{"phone": "+84912345678"}

// Client C gửi format không có dấu + (SMS gateway thường):
{"phone": "84912345678"}

Cả 3 đều hợp lệ về mặt nghiệp vụ. Server cần normalize về 1 format chuẩn quốc tế +84912345678 để DB lưu nhất quán, đối soát với SMS gateway / Stripe / VietQR không miss-match format. Validator crate B41 chỉ check regex (PHONE_REGEX ^(0|\+84)(\d{9,10})$ lock B43) nhưng KHÔNG transform — sau validate vẫn giữ format gốc, code downstream phải normalize thủ công ở nhiều chỗ.

Giải pháp gọn: tạo Phone newtype với manual impl Serialize/Deserialize — deserialize tự normalize, serialize xuất raw normalized; mọi nơi dùng Phone tự động có invariant đúng format.

3

serialize_with / deserialize_with — Mức 2 Recap

Trước khi nhảy thẳng mức 3, xem mức 2 đã làm được gì cho use case Phone. Mức 2 gắn function path vào field qua attribute:

use serde::{Deserialize, Deserializer, Serialize, Serializer};

#[derive(Serialize, Deserialize)]
pub struct UserDto {
    #[serde(serialize_with = "serialize_phone")]
    #[serde(deserialize_with = "deserialize_phone")]
    pub phone: String,
}

fn serialize_phone<S>(phone: &str, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    let normalized = normalize_phone(phone).unwrap_or_else(|_| phone.to_string());
    serializer.serialize_str(&normalized)
}

fn deserialize_phone<'de, D>(deserializer: D) -> Result<String, D::Error>
where
    D: Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    normalize_phone(&s).map_err(serde::de::Error::custom)
}

fn normalize_phone(raw: &str) -> Result<String, String> {
    // logic normalize ...
    Ok(raw.to_string())
}

Cách này hoạt động nhưng có 3 nhược điểm:

  • Mỗi field phải lặp 2 attributeUserResponseDto.phone, PaymentMethod::Cod.phone, AddressDto.phone tương lai đều phải gắn lại — dễ quên sót 1 nơi, format không đồng nhất.
  • Type vẫn là String — mất type safety; hàm nhận fn send_sms(to: &str) không phân biệt được "đây là phone đã normalize" hay "đây là user input chưa normalize".
  • Code repetition — function path string KHÔNG type-check, đổi tên hàm phải sửa mọi attribute string.

Giải pháp: gói invariant vào type — tạo Phone newtype với impl Serialize/Deserialize riêng. Mức 3 này giải quyết cả 3 nhược điểm trên cùng lúc.

4

Manual impl Serialize — Serializer Trait

Serializer trait có method tương ứng với mỗi JSON primitive: serialize_str (string), serialize_i64 (signed integer), serialize_u64 (unsigned), serialize_bool (boolean), serialize_seq (array), serialize_map (object), serialize_none/serialize_some (null/value). Impl Serialize cho type bằng cách gọi method phù hợp.

Define Phone newtype với inner String private trong crates/shop-common/src/dto/types.rs (extend B44):

// File: crates/shop-common/src/dto/types.rs (extend B44)
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde::de::{self, Visitor};
use std::fmt;
use thiserror::Error;

/// Số điện thoại VN đã normalize về format quốc tế `+84xxxxxxxxx`.
///
/// Inner String **private** đảm bảo invariant: mọi instance đều normalized.
/// Constructor `Phone::new()` là cách duy nhất tạo `Phone` từ raw input.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Phone(String);  // ← private inner

impl Phone {
    /// Parse + normalize 3 format input về `+84xxxxxxxxx`.
    pub fn new(raw: &str) -> Result<Self, PhoneError> {
        let normalized = normalize_vietnam_phone(raw)?;
        Ok(Phone(normalized))
    }

    /// Borrow inner string đã normalized.
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Tiêu thụ Phone trả String inner (vd khi cần dạng owned vào DB).
    pub fn into_inner(self) -> String {
        self.0
    }
}

#[derive(Debug, Clone, Error)]
pub enum PhoneError {
    #[error("phone number invalid: {0}")]
    Invalid(String),
}

Helper private normalize_vietnam_phone cùng module xử lý 3 format input:

fn normalize_vietnam_phone(raw: &str) -> Result<String, PhoneError> {
    // Strip khoảng trắng + dấu gạch nối user thường gõ (vd "0912-345-678")
    let cleaned: String = raw
        .chars()
        .filter(|c| c.is_ascii_digit() || *c == '+')
        .collect();

    let normalized = if let Some(rest) = cleaned.strip_prefix("+84") {
        format!("+84{}", rest)
    } else if let Some(rest) = cleaned.strip_prefix("84") {
        format!("+84{}", rest)
    } else if let Some(rest) = cleaned.strip_prefix('0') {
        format!("+84{}", rest)
    } else {
        return Err(PhoneError::Invalid(raw.to_string()));
    };

    // Validate length sau normalize: +84 + 9 chữ số = 12 ký tự total
    if normalized.len() != 12 {
        return Err(PhoneError::Invalid(raw.to_string()));
    }

    Ok(normalized)
}

Impl Serialize 1 dòng — xuất raw string đã normalized:

impl Serialize for Phone {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_str(&self.0)
    }
}

Trade-off: Serializer generic S nên 1 impl chạy cho mọi format (JSON, YAML, TOML, MsgPack) — KHÔNG cần viết riêng cho từng format. Method serialize_str là interface chuẩn, mỗi format implement riêng cách encode string đó (JSON quote escape, YAML literal, MsgPack length-prefix).

Test serialize:

let phone = Phone::new("0912345678").unwrap();
let json = serde_json::to_string(&phone).unwrap();
assert_eq!(json, "\"+84912345678\"");  // wire format chuẩn
5

Manual impl Deserialize + Visitor Pattern

Deserialize phức tạp hơn vì Deserializer KHÔNG biết format input đang là gì (JSON, YAML, TOML, ...) ở thời điểm runtime. Mỗi format encode value khác nhau:

  • JSON encode string "0912345678" với double-quote escape.
  • YAML encode 0912345678 không quote nếu là plain scalar.
  • MsgPack encode binary length-prefix.

serde giải bài toán này bằng Visitor pattern — bạn cung cấp 1 struct Visitor với method visit_str/visit_i64/visit_map/... mỗi method handle 1 loại value. Format deserializer khi parse được value sẽ gọi method phù hợp với type gặp.

Impl Deserialize cho Phone chỉ 1 dòng — gọi deserializer.deserialize_str(PhoneVisitor):

impl<'de> Deserialize<'de> for Phone {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        deserializer.deserialize_str(PhoneVisitor)
    }
}

struct PhoneVisitor;

impl<'de> Visitor<'de> for PhoneVisitor {
    type Value = Phone;

    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
        formatter.write_str("a Vietnamese phone number string (0xxx, 84xxx, or +84xxx)")
    }

    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
    where
        E: de::Error,
    {
        Phone::new(value).map_err(de::Error::custom)
    }

    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
    where
        E: de::Error,
    {
        Phone::new(&value).map_err(de::Error::custom)
    }
}

Workflow deserialize khi JSON gửi {"phone": "0912345678"}:

1. serde_json parse value `"0912345678"` (JSON string)
2. serde gọi `Phone::deserialize(json_deserializer)`
3. Phone gọi `json_deserializer.deserialize_str(PhoneVisitor)`
   → gợi ý cho JSON parser: "tôi expect string đây"
4. JSON deserializer extract &str raw từ buffer
   → gọi `visitor.visit_str("0912345678")`
5. Visitor gọi `Phone::new("0912345678")` → normalize → Phone("+84912345678")
6. Return Phone successful, serde tiếp tục deserialize field khác

Phân biệt quan trọng:

  • Deserializer::deserialize_str — bạn nói với format parser "tôi expect 1 string". Format optimize parse (vd JSON skip {/[ prefix scan, đi thẳng vào string parsing).
  • Visitor visit_str — format parser gọi ngược lại bạn với value đã parse: "đây là string tôi parse được, xử lý đi".

Hai chiều khác nhau — deserialize_*hint cho format, visit_*callback nhận value.

Tại sao cần cả visit_str + visit_string:

  • visit_str(&str) — nhận borrow; JSON deserializer (serde_json) thường gọi method này khi string nằm trong buffer parse, KHÔNG phải allocate String mới.
  • visit_string(String) — nhận owned; một số format (vd serde_yaml với escape sequence, hoặc lazy deserializer) tự allocate String rồi pass owned vào.

Implement cả 2 method giúp Phone hoạt động với mọi format serde-aware. Trace code: cả 2 đều delegate cho Phone::new() normalize — chỉ khác signature input.

Test deserialize 3 format input:

let p1: Phone = serde_json::from_str("\"0912345678\"").unwrap();
let p2: Phone = serde_json::from_str("\"+84912345678\"").unwrap();
let p3: Phone = serde_json::from_str("\"84912345678\"").unwrap();

assert_eq!(p1.as_str(), "+84912345678");
assert_eq!(p2.as_str(), "+84912345678");
assert_eq!(p3.as_str(), "+84912345678");
// → cả 3 format đều normalize về wire chuẩn quốc tế

Visitor pattern là industry standard — mọi crate serde-aware (sqlx, validator, jsonwebtoken, ...) đều theo. Hiểu pattern này giúp đọc source code và implement deserialize cho mọi type custom Shop API tương lai.

6

expecting Method — Error Message Cho Người Đọc

Method expecting trong Visitor báo cho serde "type này expect input dạng gì" — dùng cho error message khi value gặp KHÔNG match Visitor method nào:

fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
    formatter.write_str("a Vietnamese phone number string (0xxx, 84xxx, or +84xxx)")
}

Khi client gửi {"phone": 12345} (số thay vì string), workflow:

1. JSON parse value 12345 → integer i64
2. serde gọi `Phone::deserialize(json_de)`
3. Phone gọi `json_de.deserialize_str(PhoneVisitor)`
4. JSON deserializer phát hiện current value là integer, không phải string
   → KHÔNG gọi `visit_str` hay `visit_string`
   → fallback gọi `visit_i64(12345)` — nhưng PhoneVisitor không impl method này
   → default impl trong Visitor trait trả error với content từ `expecting()`
5. Error message return cho client:
   "invalid type: integer `12345`, expected a Vietnamese phone number string
    (0xxx, 84xxx, or +84xxx)"

Message rõ ràng "expected XXX, got YYY" giúp client debug nhanh — không cần đọc source server. Pattern lock Shop API: expecting luôn viết rõ format chấp nhận, có ví dụ cụ thể trong dấu ngoặc, KHÔNG generic "a phone" không actionable.

So sánh 2 phong cách expecting:

// Tốt — actionable, có ví dụ format
formatter.write_str("a Vietnamese phone number string (0xxx, 84xxx, or +84xxx)")

// Xấu — generic, client không biết format gì hợp lệ
formatter.write_str("a phone")

Error message rõ format chấp nhận cũng phù hợp error envelope chuẩn Shop API lock B16 — khi extractor trả 400 BAD_REQUEST, body envelope {"error": "...", "code": "BAD_REQUEST", "request_id": "..."} chứa luôn nội dung expecting giúp debug nhanh ở dev tool browser hoặc log monitoring.

7

Apply: Refactor PaymentMethod::Cod + UserResponseDto

Áp Phone newtype vào 2 nơi đã define ở B43 (PaymentMethod) và B45 (User). Update crates/shop-common/src/dto/mod.rs re-export top-level:

// File: crates/shop-common/src/dto/mod.rs (snippet — chỉ phần B46 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, Phone, PhoneError, ProductId, UserId};
pub use user::{User, UserResponseDto};

Refactor crates/shop-common/src/dto/payment.rs — variant Cod đổi type phone: String sang phone: Phone:

// File: crates/shop-common/src/dto/payment.rs (refactor B46)
use serde::{Deserialize, Serialize};
use validator::Validate;

use crate::dto::Phone;  // ← import Phone từ dto/types.rs

#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PaymentMethod {
    Stripe {
        #[validate(length(min = 10, max = 200))]
        payment_intent_id: String,
        #[validate(length(min = 10, max = 200))]
        customer_id: String,
    },
    BankTransfer {
        #[validate(length(min = 2, max = 100))]
        bank_name: String,
        #[validate(regex(path = *super::ACCOUNT_NUMBER_REGEX))]
        account_number: String,
    },
    Cod {
        phone: Phone,  // ← Phone newtype thay String + validate regex
    },
}

Note: validator crate trên field phone bị bỏ vì validate đã chạy trong Deserialize của Phone (fail fast ở extract trước cả khi ValidatedJson::from_request gọi value.validate()?). PHONE_REGEX constant B43 vẫn lock ở dto/mod.rs cho future use case String field (vd AddressDto.phone nếu không muốn dùng Phone newtype).

Refactor crates/shop-common/src/dto/user.rs — field phone: Option<String> đổi sang Option<Phone>:

// File: crates/shop-common/src/dto/user.rs (refactor B46)
use chrono::{DateTime, Utc};
use serde::Serialize;

use super::{Phone, UserId};

#[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<Phone>,  // ← Phone newtype thay String

    pub created_at: DateTime<Utc>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_login_at: Option<DateTime<Utc>>,
}

#[derive(Debug, Clone)]
pub struct User {
    pub id: UserId,
    pub email: String,
    pub password_hash: String,
    pub display_name: String,
    pub avatar_url: Option<String>,
    pub phone: Option<String>,  // ← DB cột TEXT, vẫn String raw
    pub created_at: DateTime<Utc>,
    pub last_login_at: Option<DateTime<Utc>>,
    pub deleted_at: Option<DateTime<Utc>>,
}

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,
            // Parse từ String DB qua Phone::new; drop invalid silent (DB load không fail)
            phone: user.phone.and_then(|p| Phone::new(&p).ok()),
            created_at: user.created_at,
            last_login_at: user.last_login_at,
        }
    }
}

Note quan trọng:

  • User.phone giữ Option<String> raw — đại diện cột DB TEXT NULL, có thể chứa data lịch sử chưa normalize từ migrate cũ.
  • From<User> for UserResponseDto parse qua Phone::new(&p).ok() — invalid phone bị drop silent ở response (không throw error, không leak data sai format ra wire).
  • Endpoint write (B104 register, PATCH profile) sẽ dùng Phone trong input DTO — fail fast ở extract nếu user gửi phone invalid.

JSON wire input client gửi 3 format (Cod):

// Client A — format số 0 prefix:
{"type": "cod", "phone": "0912345678"}

// Client B — format quốc tế đầy đủ:
{"type": "cod", "phone": "+84912345678"}

// Client C — format không có dấu +:
{"type": "cod", "phone": "84912345678"}

Cả 3 đều được accept, server normalize về 1 format. Response server trả luôn format chuẩn quốc tế:

{
  "type": "cod",
  "phone": "+84912345678"
}

Error khi phone invalid (client gửi "phone": "ABC123"):

{
  "error": "invalid JSON: phone number invalid: ABC123",
  "code": "BAD_REQUEST",
  "request_id": ""
}

HTTP status 400 — lock AppJson extractor B32 map JsonDataError sang AppError::BadRequest qua envelope chuẩn.

Verify compile sau refactor:

cd shop && cargo build -p shop-common
# Output:
# Compiling shop-common v0.1.0
# Finished `dev` profile [unoptimized + debuginfo] target(s)

Suggested commit:

git add crates/shop-common/src/dto/types.rs \
        crates/shop-common/src/dto/mod.rs \
        crates/shop-common/src/dto/payment.rs \
        crates/shop-common/src/dto/user.rs

git commit -m "B46: Phone newtype + Visitor pattern + refactor PaymentMethod::Cod + UserResponseDto"
8

So Sánh 3 Mức Custom

Bảng decision matrix lock Shop API:

Mức     | Use case                                  | Effort | Type safety
1 derive| Type Rust standard, struct DTO            | Low    | Yes
2 fn    | 1-2 field cần transform 1-off             | Medium | No (vẫn primitive)
3 impl  | Newtype có rule normalize phức tạp        | High   | Yes (type level)

Shop API decision lock vĩnh viễn:

  • Mức 1 cho 95% type — ID newtype (UserId, ProductId, OrderId, CategoryId B44), struct DTO (CreateProductDto B41/B44, UpdateProductDto B42, UserResponseDto B45), enum tagged (PaymentMethod B43).
  • Mức 2 cho transform 1-off field — helper deserialize_optional_field B42 cho double-Option PATCH semantic; field tương tự sau này dùng deserialize_with = "fn_path".
  • Mức 3 cho newtype có rule normalizePhone B46 (normalize 3 format VN), Money B54 sẽ refactor (Decimal precision validate), Email B105 sẽ wrap (lowercase normalize + DNS check optional), future Url wrap (parse scheme + host validate).

Pattern: KHÔNG over-engineer — chỉ lên mức cao khi rule không thể derive được. Mức 1 đơn giản, dễ đọc — ưu tiên dùng đến hết khả năng trước khi nhảy lên mức 2 hoặc 3.

Visitor lib có sẵn — crate serde_with cung cấp pre-built Visitor cho format phổ biến (URL, IP, Duration, base64, hex, comma-separated list). Đáng tham khảo nếu nhiều field cần transform format chuẩn — tránh viết lại Visitor cho mỗi loại. Shop API hiện chưa add serde_withPhone đã đủ cho rule VN-specific, nhưng B53 (chrono DateTime custom format) sẽ cân nhắc add khi cần parse multi-format datetime input.

9

Tổng Kết

  • 3 mức custom serde: derive (mức 1), with fn per-field (mức 2), manual impl + Visitor (mức 3) — pick theo độ phức tạp rule.
  • Visitor pattern chuẩn industry — serde traverse format không biết trước (JSON, YAML, TOML, MsgPack) qua method visit_* cho mỗi primitive.
  • visit_str + visit_string cùng impl — handle JSON borrow (&str) + format khác owned (String) cho cross-format flexibility.
  • expecting method quan trọng cho error message — luôn ghi rõ format chấp nhận có ví dụ cụ thể, KHÔNG generic.
  • Phân biệt Deserializer::deserialize_str vs Visitor visit_str — đầu là hint cho format parser, sau là callback nhận value.
  • Phone newtype Shop API lock — normalize 3 format VN (0xxx / 84xxx / +84xxx) về wire chuẩn quốc tế +84xxxxxxxxx.
  • Inner String private (Phone(String) KHÔNG (pub String)) — đảm bảo invariant normalized chỉ qua constructor Phone::new().
  • Constructor Phone::new() Result-returning + accessor as_str() + into_inner() — ergonomic API thay From<String> infallible (parse có thể fail).
  • Validate trong Deserialize sớm hơn validator crate — fail fast ở extract trước khi handler chạy.
  • Apply pattern: PaymentMethod::Cod (B43 refactor) + UserResponseDto.phone (B45 refactor) + future CreateUserDto B104, AddressDto B105.
  • File path lock: dto/types.rs extend (B44 5 newtype + B46 Phone + PhoneError + normalize_vietnam_phone private).
  • From<User> for UserResponseDto handle parse — String DB qua Phone::new(&p).ok(), drop invalid silent (DB load không fail).
  • Phone PARTIAL replace PHONE_REGEX B43 — PaymentMethod::Cod dùng Phone, regex constant vẫn lock cho future String field.
  • Foundation cho B47 (Vec, HashMap collection trong JSON — size limit chống DoS), B53 (chrono DateTime custom format multi-input), B105 (Email newtype wrap String).
10

Bài Tập Củng Cố

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

  1. 3 mức custom serde khác nhau ra sao? Cho ví dụ use case mỗi mức và lý do chọn (effort vs type safety trade-off).
  2. Visitor pattern là gì? Tại sao cần visit_str + visit_string cùng impl? Format nào gọi method nào?
  3. expecting method dùng để làm gì? Cho ví dụ error message tốt vs xấu khi client gửi {"phone": 12345}.
  4. Tại sao Phone dùng inner private (Phone(String)) thay (pub String)? Lợi ích vs trade-off ergonomic.
  5. Khác nhau giữa Deserializer::deserialize_str và Visitor visit_str? Liên hệ pattern Rust traverse type không biết format.
Đáp án
  1. 3 mức custom serde — use case + trade-off. Mức 1 #[derive(Serialize, Deserialize)]: dùng cho type Rust standard có sẵn impl trait (String, i64, DateTime<Utc> với feature serde), struct DTO field đơn giản, enum tagged. Effort thấp (1 dòng attribute), type safety tốt (struct riêng), lock cho 95% case Shop API: ID newtype, CreateProductDto B41, UpdateProductDto B42, PaymentMethod B43, UserResponseDto B45.
    #[derive(Serialize, Deserialize)]
    pub struct UserDto {
        pub id: u64,
        pub name: String,
    }
    Mức 2 #[serde(serialize_with / deserialize_with = "fn_name")]: dùng cho 1-2 field cần transform 1-off, type không cần đổi (vẫn primitive). Effort vừa (1 function path string per field per direction), type safety KHÔNG (vẫn primitive nên dev khác không phân biệt được "đã transform" vs "raw"); pitfall function path string không type-check, đổi tên hàm phải sửa attribute. Lock Shop API: helper deserialize_optional_field B42 cho double-Option PATCH.
    #[derive(Serialize, Deserialize)]
    pub struct UpdateDto {
        #[serde(default, deserialize_with = "deserialize_optional_field")]
        pub description: Option<Option<String>>,
    }
    Mức 3 manual impl Serialize + impl Deserialize: dùng cho newtype có rule normalize / validate phức tạp (Phone B46, Email B105, Money refactor B54 với Decimal precision), type third-party không có derive macro. Effort cao (Visitor pattern + 4 method MANDATORY type Value/expecting/visit_str/visit_string), type safety tốt nhất (type level, mọi nơi dùng tự động có invariant). Lock Shop API: chỉ lên mức 3 khi rule không derive được — KHÔNG over-engineer.
    impl Serialize for Phone {
        fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
            ser.serialize_str(&self.0)
        }
    }
    Quy tắc chọn: type Rust standard → mức 1; 1-off transform field cần normalize không đổi type → mức 2; newtype với invariant phức tạp (normalize, validate complex) → mức 3. Phone case Shop API: mức 1 (#[serde(transparent)]) chỉ wire raw string, KHÔNG normalize; mức 2 lặp attribute cho mỗi field phone, type vẫn String mất safety; mức 3 đúng — Phone newtype tự normalize ở deserialize, mọi nơi dùng tự động có invariant.
  2. Visitor pattern + visit_str vs visit_string. Visitor pattern = cách serde traverse format input KHÔNG biết trước (JSON, YAML, TOML, MsgPack). Mỗi format encode value khác nhau (JSON quote escape, YAML literal, MsgPack length-prefix); serde không bake hardcode 1 format vào Deserialize impl của user. Giải pháp: user cung cấp 1 struct Visitor với method visit_str/visit_i64/visit_u64/visit_bool/visit_map/visit_seq/... mỗi method handle 1 loại JSON primitive; format deserializer khi parse được value gọi method phù hợp với type gặp. Visitor có 4 phần MANDATORY: type Value = Output (output type), expecting (báo format expect cho error message), 1+ method visit_* cho input cần handle.
    impl<'de> Visitor<'de> for PhoneVisitor {
        type Value = Phone;
        fn expecting(&self, f: &mut Formatter) -> fmt::Result {
            f.write_str("a Vietnamese phone number string (0xxx, 84xxx, or +84xxx)")
        }
        fn visit_str<E: de::Error>(self, v: &str) -> Result<Phone, E> {
            Phone::new(v).map_err(de::Error::custom)
        }
        fn visit_string<E: de::Error>(self, v: String) -> Result<Phone, E> {
            Phone::new(&v).map_err(de::Error::custom)
        }
    }
    Tại sao cần cả 2 method: visit_str(&str) nhận borrow string từ buffer parse — JSON deserializer (serde_json) thường gọi method này khi string nằm thẳng trong buffer không cần allocate String mới (zero-copy). visit_string(String) nhận owned string — một số format (serde_yaml với escape sequence cần allocate buffer mới, lazy deserializer pre-build String) gọi method này. Format nào gọi method nào: JSON đơn giản (không escape) → visit_str borrow zero-copy; JSON với escape sequence ("abc\nxyz") → một số impl gọi visit_string owned vì cần buffer rebuild; YAML/TOML tùy parser. Implement cả 2 method là pattern lock — flexibility cross-format, fallback method có default impl trả error nếu không impl. Code Phone case: 2 method đều delegate Phone::new() — chỉ khác signature input (borrow vs owned). Visitor pattern là industry standard — sqlx, validator, jsonwebtoken, mọi crate serde-aware đều theo.
  3. expecting method + error message tốt vs xấu. Mục đích: báo cho serde "type này expect input dạng gì" — dùng cho error message khi value gặp KHÔNG match Visitor method nào. Default impl mọi visit_* KHÔNG override trả error với content từ expecting(). Workflow khi client gửi {"phone": 12345} (integer thay string): (a) JSON parse value 12345 → integer i64; (b) serde gọi Phone::deserialize(json_de)json_de.deserialize_str(PhoneVisitor) (gợi ý format string cho parser); (c) JSON deserializer phát hiện current value là integer không phải string, KHÔNG gọi visit_str/visit_string; (d) fallback gọi visit_i64(12345) — PhoneVisitor không impl method này nên default trả error với content expecting(). Error tốt (Phone case):
    invalid type: integer `12345`, expected a Vietnamese phone number string
    (0xxx, 84xxx, or +84xxx) at line 1 column 18
    Client đọc message biết ngay: (i) lỗi type sai (integer thay string), (ii) format expect cụ thể (0xxx / 84xxx / +84xxx) với 3 ví dụ, (iii) vị trí lỗi (line 1 column 18) trong JSON body. Có thể fix client ngay không cần đọc source server. Error xấu (generic expecting):
    invalid type: integer `12345`, expected a phone at line 1 column 18
    Client biết phone type sai nhưng KHÔNG biết format gì hợp lệ — phải đọc OpenAPI doc, source code, hoặc trial-error mới biết. Tăng support ticket + frustration. Pattern lock Shop API: expecting luôn (i) chỉ rõ kind (string/integer/object/...), (ii) có ví dụ cụ thể trong dấu ngoặc, (iii) KHÔNG generic noun như "phone"/"user"/"id" không actionable. Áp cho mọi Visitor Shop API tương lai — Email B105 "a valid email address string ([email protected])", Money B54 "a decimal number string (\"25000.00\")", Url wrap "a valid HTTPS URL (https://example.com)". Lợi ích Shop API integration với envelope error chuẩn B16: extractor AppJson map JsonDataError sang AppError::BadRequest, envelope {error, code, request_id} chứa luôn content expecting → client debug nhanh ở dev tool browser hoặc log monitoring.
  4. Inner private Phone(String) vs (pub String) — lợi ích + trade-off. Inner private (Phone(String) — tuple struct với field index 0 KHÔNG đánh dấu pub): chỉ code trong module dto/types.rs truy cập trực tiếp phone.0 được; code ngoài module phải dùng accessor as_str() hoặc into_inner(). Inner public (Phone(pub String)): mọi code dùng Phone đều truy cập phone.0 trực tiếp như field public bình thường (B44 lock UserId/ProductId pattern). Lợi ích inner private: (i) Đảm bảo invariant normalized — cách duy nhất tạo Phone là qua Phone::new(raw) Result-returning có normalize logic; dev không thể bypass tạo Phone("0912345678") raw chưa normalize qua tuple constructor. (ii) Compile-time enforce — refactor logic normalize chỉ sửa 1 chỗ normalize_vietnam_phone() + Phone::new(), mọi nơi dùng Phone tự động có format chuẩn không cần audit code. (iii) Security pattern cho Email B105 sau này — Email cần lowercase normalize + DNS check optional; nếu inner public dev có thể tạo Email("[email protected]") chưa lowercase → leak vào DB unique constraint fail. Trade-off ergonomic: phải viết accessor method as_str() -> &str + into_inner() -> String thay vì truy cập phone.0 direct. Code consume verbose hơn 1-2 char: format!("send to {}", phone.as_str()) thay format!("send to {}", phone.0); SQL bind sqlx::query!("... $1", phone.into_inner()) thay phone.0. Acceptable trade-off — giá 1 method call cho security pattern + invariant guarantee. Phân biệt với UserId/ProductId B44 pub field: ID newtype KHÔNG có invariant phức tạp (chỉ wrap u64 cho type safety phân biệt UserId vs ProductId compile-time), không có rule normalize / validate runtime → inner public OK; dev tạo UserId(42) direct không break gì. Phone/Email/Money có rule fallible parse → inner private MANDATORY. Pattern lock Shop API B46: newtype có invariant runtime (Phone B46, Email B105, Money refactor B54) MANDATORY inner private + constructor Result-returning; newtype chỉ type safety zero-cost (UserId, ProductId, OrderId, CategoryId B44) thì inner public OK với pub u64.
  5. Deserializer::deserialize_str vs Visitor visit_str. Hai method tên giống nhau nhưng vai trò ngược chiều — đây là điểm dễ confuse cho dev mới với serde. Deserializer::deserialize_str(visitor): method trên trait Deserializer (do format implement: serde_json, serde_yaml, serde_toml, ...). Bạn gọi method này để báo cho format parser: "tôi expect 1 string value, optimize parse cho tôi". Format parser nhận hint, optimize: vd serde_json skip {/[ prefix scan, đi thẳng vào string parsing không phải check toàn bộ JSON type. Method KHÔNG return value — return Result<V::Value, Error> sau khi gọi back vào visitor bạn cung cấp.
    impl<'de> Deserialize<'de> for Phone {
        fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
            de.deserialize_str(PhoneVisitor)  // ← gợi ý format: "tôi expect string"
        }
    }
    Visitor visit_str(&str): method trên trait Visitor (do user implement: bạn). Format parser gọi method này để callback nhận value: "đây là string tôi parse được, xử lý nó". Method nhận &str đã parse, return Result<Phone, E> sau khi convert.
    impl<'de> Visitor<'de> for PhoneVisitor {
        type Value = Phone;
        fn visit_str<E: de::Error>(self, value: &str) -> Result<Phone, E> {
            Phone::new(value).map_err(de::Error::custom)  // ← callback xử lý value
        }
    }
    2 chiều khác nhau: deserialize_*hint bạn gửi cho format ("tôi expect kind này"), visit_*callback format gọi ngược bạn ("đây là value tôi parse được"). Trade-off hint: cho phép format optimize parse path; format có thể IGNORE hint và gọi visit_* method khác nếu value thực tế khác kind hint (vd JSON gặp integer khi user hint string → fallback gọi visit_i64 không phải visit_str). Liên hệ pattern Rust traverse type không biết format: tương tự pattern double dispatch trong OOP (Visitor pattern Gang of Four 1994) — 1 object gặp data, KHÔNG biết concrete type của data, nên dispatch qua interface chung; data implement method, object cung cấp visitor handle method tương ứng kind data. Ưu điểm: extend kind mới (vd format MsgPack thêm bytes primitive) chỉ cần add method mới vào Visitor trait + format implement, KHÔNG break user impl cũ (default impl các method mới fallback error). Khác Visitor OOP GoF: serde Visitor type-driven (Rust trait generic) thay vì dynamic dispatch (OOP virtual function); zero-cost abstraction compile xuống direct call sau monomorphization. Pattern Rust traverse format-agnostic cũng dùng trong: std::fmt::Formatter (write_str / write_fmt cho Display impl chạy với mọi formatter), tokio::io::AsyncRead (poll_read trên reader generic chạy mọi backend file/socket), Iterator::next (lazy traverse element type-driven). Hiểu serde Visitor giúp đọc source ecosystem Rust rộng hơn.
11

Bài Tiếp Theo

— collection serde behaviour: Vec<T>, HashMap<K,V>, BTreeMap<K,V>, HashSet<T>; key non-String trong Map; size limit chống DoS với serde_json::from_reader; áp ProductListResponse Shop API.