15 releases
0.1.17 | May 27, 2025 |
---|---|
0.1.16 | May 27, 2025 |
#320 in Encoding
771 downloads per month
Used in 2 crates
(via senax-common)
140KB
3K
SLoC
senax-encoder
A fast, compact, and schema-evolution-friendly binary serialization library for Rust.
- Supports struct/enum encoding with field/variant IDs for forward/backward compatibility
- Efficient encoding for primitives, collections, Option, String, bytes, and popular crates (chrono, uuid, ulid, rust_decimal, indexmap, fxhash, ahash, smol_str, serde_json)
- Custom derive macros for ergonomic usage
- Feature-gated support for optional dependencies
Features
- Compact, efficient encoding for a wide range of types (primitives, collections, Option, String, bytes, chrono, uuid, ulid, rust_decimal, indexmap, serde_json)
- Schema evolution and version compatibility via field/variant IDs and tag-based format
- Attribute macros for fine-grained control (custom IDs, default values, skip encode/decode, renaming, compact ID encoding)
- Feature flags for optional support of popular crates
- Suitable for network protocols, storage, and applications requiring forward/backward compatibility
Attribute Macros
You can control encoding/decoding behavior using the following attributes:
#[senax(id = N)]
— Assigns a custom field or variant ID (u64). Ensures stable wire format across versions.#[senax(default)]
— If a field is missing during decoding, its value is set toDefault::default()
instead of causing an error. ForOption<T>
, this meansNone
.#[senax(skip_encode)]
— This field is not written during encoding. On decode, it is set toDefault::default()
.#[senax(skip_decode)]
— This field is ignored during decoding and always set toDefault::default()
. It is still encoded if present.#[senax(skip_default)]
— This field is not written during encoding if its value equals the default value. On decode, missing fields are set toDefault::default()
.#[senax(rename = "name")]
— Use the given string as the logical field/variant name for ID calculation. Useful for renaming fields/variants while keeping the same wire format.
Feature Flags
The following optional features enable support for popular crates and types:
External Crate Support
chrono
— Enables encoding/decoding ofchrono::DateTime
,NaiveDate
, andNaiveTime
types.uuid
— Enables encoding/decoding ofuuid::Uuid
.ulid
— Enables encoding/decoding ofulid::Ulid
(shares the same tag as UUID for binary compatibility).rust_decimal
— Enables encoding/decoding ofrust_decimal::Decimal
.indexmap
— Enables encoding/decoding ofIndexMap
andIndexSet
collections.fxhash
— Enables encoding/decoding offxhash::FxHashMap
andfxhash::FxHashSet
(fast hash collections).ahash
— Enables encoding/decoding ofahash::AHashMap
andahash::AHashSet
(high-performance hash collections).smol_str
— Enables encoding/decoding ofsmol_str::SmolStr
(small string optimization).serde_json
— Enables encoding/decoding ofserde_json::Value
for dynamic JSON data.
Quick Start
Add to your Cargo.toml
:
[dependencies]
senax-encoder = "0.1"
Basic usage:
use senax_encoder::{Encode, Decode};
#[derive(Encode, Decode, Debug, PartialEq)]
struct User {
id: u32,
name: String,
email: Option<String>,
}
let user = User { id: 42, name: "Alice".into(), email: Some("alice@example.com".into()) };
// Schema evolution support (with field IDs)
let mut bytes = senax_encoder::encode(&user).unwrap();
let decoded: User = senax_encoder::decode(&mut bytes).unwrap();
assert_eq!(user, decoded);
// Compact encoding (without field IDs, smaller size)
let mut packed = senax_encoder::pack(&user).unwrap();
let unpacked: User = senax_encoder::unpack(&mut packed).unwrap();
assert_eq!(user, unpacked);
Usage
1. Derive macros for automatic implementation
#[derive(Encode, Decode)]
struct MyStruct {
#[senax(id=1)]
foo: u32,
bar: Option<String>,
}
2. Binary encode/decode
let mut bytes = senax_encoder::encode(&value)?;
let value2: MyStruct = senax_encoder::decode(&mut bytes)?;
3. Compact pack/unpack (without schema evolution)
// Pack for maximum compactness (no field IDs, smaller size)
let mut bytes = senax_encoder::pack(&value)?;
let value2: MyStruct = senax_encoder::unpack(&mut bytes)?;
// Note: pack/unpack is field-order dependent and doesn't support schema evolution
// Use when you need maximum performance and size optimization
4. Schema evolution (adding/removing/changing fields)
- Field IDs are automatically generated from field names (CRC64) by default.
- Use
#[senax(id=...)]
only if you need to resolve a collision.
- Use
- Because mapping is by field ID (u64):
- Old struct → new struct:
- New fields of type
Option
becomeNone
if missing. - New required fields without
default
will cause a decode error if missing.
- New fields of type
- New struct → old struct: unknown fields are automatically skipped.
- Old struct → new struct:
- No field names are stored, only u64 IDs, so field addition/removal/reordering/type changes are robust.
5. Feature flags
- Enable only the types you need:
indexmap
,chrono
,rust_decimal
,uuid
,ulid
,serde_json
, etc. - Minimizes dependencies and build time.
Supported Types
Core Types (always available)
- Primitives:
u8~u128
,i8~i128
,f32
,f64
,bool
,String
,Bytes
(zero-copy binary data) - Option, Vec, arrays, HashMap, BTreeMap, Set, Tuple, Enum, Struct, Arc, Box
Feature-gated Types
When respective features are enabled:
- chrono:
DateTime<Utc>
,DateTime<Local>
,NaiveDate
,NaiveTime
- uuid:
Uuid
- ulid:
Ulid
- rust_decimal:
Decimal
- indexmap:
IndexMap
,IndexSet
- fxhash:
FxHashMap
,FxHashSet
(fast hash collections) - ahash:
AHashMap
,AHashSet
(high-performance hash collections) - smol_str:
SmolStr
(small string optimization) - serde_json:
Value
(dynamic JSON data)
Type Compatibility and Cross-Decoding
The senax-encoder supports automatic type conversion for compatible types during decoding, enabling schema evolution. However, certain conversions are not supported due to precision or data loss concerns.
✅ Supported Cross-Type Decoding
- Integer types: Any unsigned integer can be decoded as a larger unsigned integer (e.g.,
u16
→u32
) - Signed integers: Can be decoded as larger signed integers if the value fits within the target range
- Unsigned to signed: Supported if the value fits within the signed type's positive range
- Floating point:
f64
can be decoded asf32
(with potential precision loss) - Container expansion:
T
can be decoded asOption<T>
❌ Unsupported Cross-Type Decoding
- f32 to f64: Not supported due to precision ambiguity. Use consistent float types or handle conversion manually.
- Signed to unsigned: Negative values cannot be decoded as unsigned types
- Integer overflow: Values too large for the target type will cause decode errors
- Container shrinking:
Option<T>
cannot be automatically decoded asT
(use explicit handling)
⚠️ Important Notes
- Type changes are automatically applied when compatible, but incompatible conversions will result in decode errors.
- Always test schema evolution scenarios with actual data before deploying changes.
- For critical applications, prefer explicit type versioning over relying on automatic conversion.
- Float precision: When working with floating-point numbers, use the same precision consistently to avoid conversion issues.
Example of compatible schema evolution:
// Version 1
#[derive(Encode, Decode)]
struct User {
id: u32, // Will be compatible with u64 in v2
name: String,
}
// Version 2 - Compatible changes
#[derive(Encode, Decode)]
struct User {
id: u64, // ✅ u32 → u64 automatic conversion
name: String,
email: Option<String>, // ✅ New optional field
#[senax(default)]
age: u32, // ✅ New field with default
}
Custom Encoder/Decoder Implementation
When implementing custom Encoder
and Decoder
traits for your types, follow these important guidelines to ensure proper binary format consistency:
✅ Best Practices
- Single encode call: Each value should be encoded with exactly one
encode()
call that writes all necessary data atomically. - Use tuples for multiple values: If you need to encode multiple related values, group them into a tuple rather than making separate encode calls.
- Error handling: Always check for insufficient data in your decoder and return appropriate errors.
❌ Common Mistakes to Avoid
// ❌ WRONG: Multiple separate encode calls
impl Encoder for MyType {
fn encode(&self, writer: &mut BytesMut) -> Result<()> {
self.field1.encode(writer)?; // First encode call
self.field2.encode(writer)?; // Second encode call - WRONG!
Ok(())
}
}
// ✅ CORRECT: Single encode call with tuple
impl Encoder for MyType {
fn encode(&self, writer: &mut BytesMut) -> Result<()> {
(self.field1, self.field2).encode(writer) // Single encode call
}
}
Manual Implementation Example
use senax_encoder::{Encoder, Decoder, EncoderError};
use bytes::{BytesMut, Bytes};
struct Point3D {
x: f64,
y: f64,
z: f64,
}
impl Encoder for Point3D {
fn encode(&self, writer: &mut BytesMut) -> senax_encoder::Result<()> {
// ✅ Encode as single tuple
(self.x, self.y, self.z).encode(writer)
}
fn is_default(&self) -> bool {
self.x == 0.0 && self.y == 0.0 && self.z == 0.0
}
}
impl Decoder for Point3D {
fn decode(reader: &mut Bytes) -> senax_encoder::Result<Self> {
// ✅ Decode the same tuple structure
let (x, y, z) = <(f64, f64, f64)>::decode(reader)?;
Ok(Point3D { x, y, z })
}
}
Advanced Example with Complex Data
struct CustomFormat {
header: String,
data: Vec<u8>,
checksum: u32,
}
impl Encoder for CustomFormat {
fn encode(&self, writer: &mut BytesMut) -> senax_encoder::Result<()> {
// ✅ Group all fields into a single tuple
(
&self.header,
&self.data,
self.checksum
).encode(writer)
}
fn is_default(&self) -> bool {
self.header.is_empty() && self.data.is_empty() && self.checksum == 0
}
}
impl Decoder for CustomFormat {
fn decode(reader: &mut Bytes) -> senax_encoder::Result<Self> {
// ✅ Decode the same tuple structure
let (header, data, checksum) = <(String, Vec<u8>, u32)>::decode(reader)?;
Ok(CustomFormat { header, data, checksum })
}
}
Why This Matters
- Format consistency: Each value gets exactly one tag in the binary format
- Schema evolution: The library can properly skip unknown fields during forward/backward compatibility
Note: For most use cases, prefer using #[derive(Encode, Decode)]
which automatically follows these best practices.
Dependencies
~0.9–2.5MB
~47K SLoC