Data Serialization in Rust with Serde
In our digital age, data is the lifeblood of countless applications. But there’s a tiny secret behind the scenes: the art and science of…
In our digital age, data is the lifeblood of countless applications. But there’s a tiny secret behind the scenes: the art and science of data conversion. Think about it — how does data smoothly transition from complex structures to a neat, shareable format and back? Enter the world of serialisation.
If you’re working with Rust, one hero in this realm stands head and shoulders above the rest: Serde. Not only is it powerful, but it’s also remarkably friendly to use. So, whether you’re looking to simplify data storage, facilitate data transfer, or are just curious about Rust’s capabilities, join us as we explore data serialisation with Serde.
Ready to dive in? Let’s go! 🚀
What is Serialization?
Serialisation converts complex data structures, such as structs, enums, and other composite types, into a flat, byte-based representation. The reverse process, called deserialisation, involves converting that byte-based representation to its original structure.
Why Serde?
There are several reasons why Serde stands out in Rust’s ecosystem:
- Performance: Serde is known for its high-speed serialisation and deserialisation.
- Flexibility: It supports numerous data formats, including JSON, TOML, YAML, and Binary.
- Customizability: Serde allows developers to define their types’ custom serialisation and deserialisation logic.
Getting Started with Serde
To start using Serde, you need to include the Serde library and its dependencies in your Cargo.toml
:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
In this example, we’ll focus on JSON serialisation.
Serialising and Deserialising with Serde
Here’s a basic example of serialising and deserialising a Rust struct with Serde:
extern crate serde;
extern crate serde_json;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Person {
name: String,
age: u8,
}
fn main() {
let person = Person { name: "Alice".to_string(), age: 30 };
// Serialize to JSON string
let serialized = serde_json::to_string(&person).unwrap();
println!("Serialized: {}", serialized);
// Deserialize from JSON string
let deserialized: Person = serde_json::from_str(&serialized).unwrap();
println!("Deserialized: {:?}", deserialized);
}
Implementing Custom Data Types
Imagine you want to store a date in the format dd-MM-yyyy
. Rust's standard library doesn't use this format by default, so you need a custom implementation:
use serde::{Serialize, Deserialize, Serializer, Deserializer};
use chrono::{NaiveDate, Datelike, FormatError};
#[derive(Debug)]
struct CustomDate(NaiveDate);
impl Serialize for CustomDate {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let formatted = format!("{:02}-{:02}-{}", self.0.day(), self.0.month(), self.0.year());
serializer.serialize_str(&formatted)
}
}
impl<'de> Deserialize<'de> for CustomDate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
NaiveDate::parse_from_str(&s, "%d-%m-%Y").map_err(serde::de::Error::custom).map(CustomDate)
}
}
Working with External Structs
Sometimes, you might want to serialise or deserialise types from external crates that don’t implement Serialize
and Deserialize
. With Serde, you can use newtype pattern:
use std::net::IpAddr;
#[derive(Serialize, Deserialize)]
struct SerializableIpAddr(#[serde(with = "ip_addr_as_string")] IpAddr);
mod ip_addr_as_string {
use std::net::IpAddr;
use serde::{Serialize, Deserialize, Serializer, Deserializer};
pub fn serialize<S>(addr: &IpAddr, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&addr.to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<IpAddr, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
Conditionally Skip Fields
You might want to skip serialising certain fields based on their values in certain scenarios. Serde allows conditional checks for this:
#[derive(Serialize, Deserialize, Debug)]
struct Product {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
In this example, if description
is None
, it will be skipped during serialisation.
Use of Enums to Represent Variants
Enums in Rust can be used effectively to represent various data variants, especially when working with JSON objects that could have different structures:
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
enum Event {
Login { username: String, timestamp: u64 },
Logout { user_id: u32, timestamp: u64 },
}
This will serialise to either:
{
"type": "Login",
"username": "Alice",
"timestamp": 1633287843
}
Or:
{
"type": "Logout",
"user_id": 42,
"timestamp": 1633287843
}
Handling Nested JSON with Serde
One common scenario is dealing with nested JSON data. Let’s explore how Serde can be leveraged for such structures.
Imagine you have the following JSON:
{
"user": {
"name": "Alice",
"contact": {
"email": "alice@example.com",
"phone": "1234567890"
},
"address": {
"city": "Wonderland",
"zipcode": "12345"
}
}
}
With Serde, you can easily create nested Rust structs to represent this data:
#[derive(Serialize, Deserialize, Debug)]
struct User {
name: String,
contact: Contact,
address: Address,
}
#[derive(Serialize, Deserialize, Debug)]
struct Contact {
email: String,
phone: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct Address {
city: String,
zipcode: String,
}
Serialising Collections
Collections like Vec, HashMap, and others often represent lists or dictionaries in Rust. With Serde, you can effortlessly serialise and deserialise these data structures:
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Debug)]
struct Inventory {
items: Vec<String>,
stock: HashMap<String, u32>,
}
fn main() {
let inv = Inventory {
items: vec!["apple".to_string(), "banana".to_string()],
stock: [("apple".to_string(), 50), ("banana".to_string(), 30)]
.iter().cloned().collect(),
};
let serialized = serde_json::to_string(&inv).unwrap();
println!("Serialized: {}", serialized);
let deserialized: Inventory = serde_json::from_str(&serialized).unwrap();
println!("Deserialized: {:?}", deserialized);
}
Error Handling with Serde
Error handling is crucial when working with external data. Serde’s error type provides comprehensive information about what went wrong during serialisation or deserialisation:
fn main() {
let data = r#" { "name": "Alice", "age": "thirty" } "#;
let result: Result<Person, _> = serde_json::from_str(data);
match result {
Ok(_) => println!("Successfully deserialized data."),
Err(err) => println!("Deserialization error: {}", err),
}
}
In the above example, deserialising the string “thirty” into a u8 will produce an error, which is then printed out.
Using Serde with Other Formats
While JSON is one of the most popular data formats, Serde supports many others. For instance, to work with TOML:
- Add
serde
andtoml
to yourCargo.toml
:
[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "0.5"
2. Serialize and deserialize:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Config {
database: Database,
}
#[derive(Serialize, Deserialize, Debug)]
struct Database {
user: String,
password: String,
}
fn main() {
let toml_str = r#"
[database]
user = "admin"
password = "password123"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
println!("{:?}", config);
}
Similarly, Serde can be paired with other crates like serde_yaml
for YAML and bincode
for binary serialisation, broadening its utility.
Renaming and Ignoring Fields
Sometimes the serialised representation of your data doesn’t align with your Rust struct’s field names. In such cases, Serde provides attributes to rename or ignore fields:
#[derive(Serialize, Deserialize, Debug)]
struct Person {
#[serde(rename = "fullName")]
name: String,
age: u8,
#[serde(skip_serializing_if = "Option::is_none")]
phone_number: Option<String>,
}
In the above example, the name
field will be serialised as fullName
and the phone_number
field will only be serialised if it contains a value.
Default Values
When deserialising data, it’s common to encounter missing fields. Serde provides a way to specify default values:
#[derive(Serialize, Deserialize, Debug)]
struct Person {
name: String,
#[serde(default = "default_age")]
age: u8,
}
fn default_age() -> u8 {
18
}
If the age is absent during deserialisation, it will default to 18.
Flattening
There are instances when nested structures can be flattened during serialisation. Serde’s flatten
attribute comes in handy:
#[derive(Serialize, Deserialize, Debug)]
struct Address {
city: String,
country: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct Person {
name: String,
age: u8,
#[serde(flatten)]
address: Address,
}
With flattening, instead of having a nested JSON object for the address, both city
and country
will be at the same level as name
and age
.
Read more articles about Rust in my Rust Programming Library!
Happy coding and keep those gears turning! 🦀🔧🚀
Read more articles about Rust in my Rust Programming Library!
Visit my Blog for more articles, news, and software engineering stuff!
Follow me on Medium, LinkedIn, and Twitter.
All the best,
CTO | Tech Lead | Senior Software Engineer | Cloud Solutions Architect | Rust 🦀 | Golang | Java | ML AI & Statistics | Web3 & Blockchain