Skip to content

feat: 添加 SonicDeserialize derive - #240

Open
liuq19 wants to merge 1 commit into
cloudwego:mainfrom
liuq19:agent/sonic-deserialize-derive-pr
Open

feat: 添加 SonicDeserialize derive#240
liuq19 wants to merge 1 commit into
cloudwego:mainfrom
liuq19:agent/sonic-deserialize-derive-pr

Conversation

@liuq19

@liuq19 liuq19 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

背景

Serde derive 为 named struct 生成字段名分派逻辑时,宽结构体会产生较大的字符串匹配开销。sonic-rs 的 token parser 已经很快,但在字段很多的 typed deserialization 场景里,FieldVisitor::visit_str 仍可能成为主要成本。

这个 PR 增加一个可选的 SonicDeserialize derive,用于在保持官方 serde::Deserialize<'de> 接口不变的前提下,为宽 named struct 生成更快的字段分派代码。

改动方案

  • 新增 sonic-derive proc-macro crate,提供 #[derive(SonicDeserialize)]
  • 新增 root crate derive feature,并在开启 feature 时 re-export sonic_derive::SonicDeserialize
  • 对字段数较少的 struct 使用直接字符串 match;对宽 struct 使用编译期 PHF 表。
  • 支持常见 Serde 子集:rename、deserialize-side renamealias、字段/容器 defaultdeserialize_withskip_deserializingdeny_unknown_fields、map/seq 两种表示。
  • 对暂未覆盖的复杂 Serde 语义采取 fail-closed:generics、enum、flatten、borrow、transparent、from/try_from 等直接编译报错。

没有引入新的 deserialization trait,也没有新增 from_str_fast / from_slice_fast 这类平行入口;生成代码仍实现官方 serde::Deserialize<'de>

影响范围

  • 默认 feature 不变,未开启 derive 时不暴露新宏,也不依赖 phf / sonic-derive
  • 开启 derive 后新增 hidden __private 模块,供 proc-macro expansion 在 downstream crate 中引用。
  • deserialize_with 继续使用 wrapper 形态调用,避免改变现有 custom decoder 语义。
  • 当前能力是 MVP,复杂 Serde 语义不会静默降级,用户需要显式切回官方 #[derive(Deserialize)]

发布注意:root crate 依赖新的 proc-macro crate。正式发布时需要先发布 sonic-derive,再发布开启可选依赖的 root crate;否则 root crate 的 package/publish 检查会在 registry 找不到 derive crate。

测试与验证

  • RUSTUP_TOOLCHAIN=nightly-x86_64-unknown-linux-gnu rustfmt --check sonic-derive/src/lib.rs sonic-derive/src/attr.rs sonic-derive/src/model.rs sonic-derive/src/codegen.rs src/__private.rs tests/sonic_deserialize.rs tests/deserialize_with_benchmark.rs
  • RUSTUP_TOOLCHAIN=nightly-x86_64-unknown-linux-gnu cargo test --manifest-path sonic-derive/Cargo.toml
  • RUSTUP_TOOLCHAIN=nightly-x86_64-unknown-linux-gnu cargo test --release --features derive --test sonic_deserialize -- --nocapture
  • RUSTUP_TOOLCHAIN=nightly-x86_64-unknown-linux-gnu cargo test --release --features derive --test deserialize_with_benchmark -- --nocapture
  • RUSTUP_TOOLCHAIN=nightly-x86_64-unknown-linux-gnu cargo check --no-default-features
  • RUSTUP_TOOLCHAIN=nightly-x86_64-unknown-linux-gnu cargo package --manifest-path sonic-derive/Cargo.toml --allow-dirty --no-verify

The integration tests cover alias/rename/default/skip/deserialize_with/unknown/sequence/missing-field behavior and compare generated output with Serde derive on synthetic wide structs.

Copilot AI lite review requested due to automatic review settings September 3, 2026 05:57
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T06:01:24.870631Z f18676a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.30%. Comparing base (03545a9) to head (f18676a).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #240      +/-   ##
==========================================
+ Coverage   71.16%   72.30%   +1.14%     
==========================================
  Files          42       43       +1     
  Lines        9762     9778      +16     
==========================================
+ Hits         6947     7070     +123     
+ Misses       2815     2708     -107     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f18676a3ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +15 to +18
let serde_path = container
.serde_path
.clone()
.unwrap_or_else(|| syn::parse_str("::serde").expect("valid serde path"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve Serde without requiring a downstream dependency

When a downstream crate enables sonic-rs/derive but does not also declare serde directly, every expansion fails because ::serde is resolved in that downstream crate, where sonic-rs's transitive dependency is not in the extern prelude. This breaks the natural advertised usage of the re-exported sonic_rs::SonicDeserialize; expose Serde through sonic-rs's private derive support and use that as the default path, while retaining the override for renamed crates.

Useful? React with 👍 / 👎.

Comment on lines +391 to +394
for _ in &field.names {
entries.push((fields_index, id));
fields_index += 1;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Assign numeric identifiers per field rather than per alias

When a non-self-describing deserializer supplies numeric struct-field identifiers and a field has aliases, this loop allocates an index for every alias. Serde's derive assigns numeric indices per deserializable field and treats aliases only as additional string spellings, so for fields first (alias alias) and second, numeric key 1 should select second but this implementation selects first. Increment the numeric index once per field and map only that index to the field.

Useful? React with 👍 / 👎.

Comment thread sonic-derive/src/model.rs
Comment on lines +67 to +70
let canonical = attrs
.deserialize_name
.clone()
.unwrap_or_else(|| ident.to_string());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip the raw-identifier prefix from default field names

When a struct declares an unrenamed raw identifier such as r#type, Ident::to_string() produces r#type, but Serde's external field name is type. Consequently normal input like {"type": 1} is treated as unknown and the derive reports the r#type field missing. Normalize raw identifiers before constructing the canonical wire name.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The current derive codegen has confirmed behavioral mismatches with Serde semantics for sequence skipping and numeric field identifier mapping that can break correctness/compatibility.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR introduces an optional SonicDeserialize derive (via a new sonic-derive proc-macro crate) that generates serde::Deserialize<'de> implementations optimized for wide named structs by using compile-time PHF-based field dispatch while keeping the public deserialization entrypoints unchanged.

Changes:

  • Added sonic-derive proc-macro crate implementing #[derive(SonicDeserialize)] with an MVP Serde attribute subset and fail-closed behavior for unsupported semantics.
  • Added root derive feature that gates sonic_derive::SonicDeserialize re-export and exposes a hidden __private module for downstream macro expansions (including a missing_field helper and phf re-export).
  • Added integration tests/bench-style ignored tests comparing behavior with Serde derive and measuring dispatch overhead.
File summaries
File Description
tests/sonic_deserialize.rs Integration tests for SonicDeserialize correctness and a local benchmark for wide-struct dispatch.
tests/deserialize_with_benchmark.rs Ignored benchmark-style test focusing on deserialize_with overhead comparisons.
src/lib.rs Adds self-crate aliasing, gates __private, and re-exports SonicDeserialize behind the derive feature.
src/__private.rs Hidden support module for macro expansions (PHF re-export and missing-field helper).
sonic-derive/src/model.rs Builds a struct model from syn input and validates MVP constraints.
sonic-derive/src/lib.rs Proc-macro entrypoint for SonicDeserialize and compile-time rejection tests.
sonic-derive/src/codegen.rs Generates optimized Deserialize impls with PHF/match dispatch and map/seq handling.
sonic-derive/src/attr.rs Parses supported serde/sonic attributes into a simplified model.
sonic-derive/Cargo.toml Defines the new proc-macro crate and dependencies.
Cargo.toml Adds optional phf + sonic-derive deps and the new derive feature.
Review details

Suppressed comments (1)

sonic-derive/src/codegen.rs:397

  • numeric_field_entries currently assigns numeric indices once per (canonical+alias) name, which makes visit_u64 depend on how many aliases a field has. Serde's derived visit_u64 expects numeric identifiers to index canonical fields only; aliases should not affect numeric mapping.
fn numeric_field_entries(fields: &[FieldInfo]) -> Vec<(u64, usize)> {
    let mut fields_index = 0u64;
    let mut entries = Vec::new();
    for field in fields {
        if let Some(id) = field.de_id {
            for _ in &field.names {
                entries.push((fields_index, id));
                fields_index += 1;
            }
        }
    }
    entries
  • Files reviewed: 10/10 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +193 to +196
} else {
let missing = skipped_expr(field, &container.default);
quote!(let #var = #missing;)
}
Comment on lines +26 to +33
let field_names: Vec<_> = model
.fields
.iter()
.filter(|field| field.de_id.is_some())
.flat_map(|field| field.names.iter())
.map(|name| LitStr::new(name, Span::call_site()))
.collect();
let numeric_entries = numeric_field_entries(&model.fields);
Comment on lines +345 to +348
#[test]
fn numeric_field_indexes_include_alias_entries() {
let input = vec![(2_u64, 20_i64)].into_iter();
let fast = FastNumericFields::deserialize(serde::de::value::MapDeserializer::<
Comment on lines +328 to +334
#[test]
fn sequence_representation_matches_serde() {
let control: ControlRequired = sonic_rs::from_str("[null,9]").unwrap();
let fast: FastRequired = sonic_rs::from_str("[null,9]").unwrap();
assert_eq!(control.optional, fast.optional);
assert_eq!(control.required, fast.required);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants