Serialization
Serialization is a process that converts arbitrary objects into a set of bytes that can be stored to disk or to send them over the network. An opposite to serialization - deserialization - is a process that restores objects from a given set of bytes. Serialization often used to make save/load functionality in games.
Fyrox has built-in serializer that is used all over the place the engine and which is represented by a Visit
trait.
Visit
name could be confusing, but it is called after well-known Visitor
design pattern.
Serialization and deserialization itself is handled by Visitor
, it can be created in two modes: read and write. See
mode info in respective sections below.
Usage
There are two main ways to implement Visit
trait, each way serves for specific cases. Let's understand which one to
use when.
Proc-macro #[derive(Visit)]
The engine provides proc-macro, that uses code generation to implement Visit
trait for you. All you need to do is
to add #[derive(Visit)]
to your struct/enum. Code generation in most cases is capable to generate typical
implementation for serialization/deserialization. You should prefer proc-macro to manual implementation in most cases.
The macro supports few very useful attributes, that can be added to fields of a struct/enum:
#[visit(optional)]
- forces the engine to ignore any errors that may occur during deserialization, leaving a field's value in default state. Very useful option if you're adding a new field to your structure, otherwise the engine will refuse to continue loading of your struct. In case of scripts, deserialization will stop on missing field, and it will be partially loaded.#[visit(rename = "new_name")]
- replaces the name of a field with given value. Useful if you need to rename a field in the code, but leave backward compatibility with previous versions.#[visit(skip)]
- ignores a field completely. Useful if you don't want to serialize a field at all, or a field is not serializable.
To use the macro, you must import all types related to Visit
trait by use fyrox::core::visitor::prelude::*;
. Here's
an example:
#![allow(unused)] fn main() { #[derive(Visit, Default)] struct MyStruct { foo: u32, #[visit(rename = "baz")] foobar: f32, #[visit(optional)] optional: String, #[visit(skip)] ignored: usize, } }
Manual implementation
Manual implementation of the trait gives you an opportunity to fix compatibility issues, do some specific actions during serialization (logging, for instance). Typical manual implementation could look like this:
#![allow(unused)] fn main() { struct MyStructWithManualVisit { foo: u32, foobar: f32, optional: String, ignored: usize, } impl Visit for MyStructWithManualVisit { fn visit(&mut self, name: &str, visitor: &mut Visitor) -> VisitResult { // Create a region first. let mut region = visitor.enter_region(name)?; // Add fields to it. self.foo.visit("Foo", &mut region)?; // Manually rename the field for serialization. self.foobar.visit("Baz", &mut region)?; // Ignore result for option field. let _ = self.optional.visit("Baz", &mut region); // Ignore `self.ignored` Ok(()) } } }
This code pretty much shows the result of macro expansion from the previous section. As you can see, proc-macro saves you from writing tons of boilerplate code.
Implementing Visit
trait is a first step, the next step is to either serialize an object or deserialize it. See
the following section for more info.
Serialization and Deserialization
To serialize an object all you need to do is to create an instance of a Visitor in either read or write mode and use it like so:
#![allow(unused)] fn main() { async fn visit_my_structure(path: &Path, object: &mut MyStruct, write: bool) -> VisitResult { if write { let mut visitor = Visitor::new(); object.visit("MyObject", &mut visitor)?; // Dump to the path. visitor.save_binary(path) } else { let mut visitor = Visitor::load_binary(path).await?; // Create default instance of an object. let mut my_object = MyStruct::default(); // "Fill" it with contents from visitor. my_object.visit("MyObject", &mut visitor) } } }
The key function here is visit_my_structure
which works in both serialization and deserialization modes depending on
write
flag value.
When write
is true (serialization), we're creating a new empty visitor and filling it with values from our object
and then "dump" its content to binary file.
When write
is false (deserialization), we're loading contents of a file, creating the object in its default state and
then "filling" it with values from the visitor.
Environment
Sometimes there is a need to pass custom data to visit
methods, one of the ways to do this is to use blackboard
field
of the visitor:
#![allow(unused)] fn main() { struct MyStructWithEnv { // ... } struct MyEnvironment { some_data: String, } impl Visit for MyStructWithEnv { fn visit(&mut self, name: &str, visitor: &mut Visitor) -> VisitResult { if let Some(environment) = visitor.blackboard.get::<MyEnvironment>() { println!("{}", environment.some_data); } Ok(()) } } fn serialize_with_environment() { let mut my_object = MyStructWithEnv { // ... }; let mut visitor = Visitor::new(); visitor.blackboard.register(Arc::new(MyEnvironment { some_data: "Foobar".to_owned(), })); my_object.visit("MyObject", &mut visitor).unwrap(); } }
Limitations
All fields of your structure must implement Default
trait, this is essential limitation because deserialization must
have a way to create an instance of an object for you.