Search code examples
serializationrustenumsrpcabstraction

How to abstract over serializable objects being either remote or local in Rust?


I have an enum to represent nodes being either local (referring to the current node) or remote, in which case communication happens through RPC calls:

pub enum Node {
    Local,
    Remote(SocketAddr),
}

I have a struct DB which represents a local database. I need to serialize this Node enum because it is used as metadata to determine where certain key value pairs are stored (either locally or remote). I store this metadata in the same database as the rest of my data so Node::Local refers to the very database it is stored in.

I want the deserialized node enum to contain a reference to the very database it was taken out of so that I could turn that Local variant into Local(DB). Then I would have the same semantics for Local and for Remote variants in that they both contain enough information to execute Node.get(key) and Node.insert(key, value) operations.

With the remote variants, I would just open a connection to the SocketAddr, and make a RPC request, but with the Local variant I have to pattern match in a place where I have a reference to the local database, and apply special logic instead of being able to generally say Node.get(key). The fundamental problem is that Node::Local does not contain enough information to perform a request against a local database while the SocketAddr in Node::Remote is enough to perform RPC calls.

I could solve this by making a custom deserialize method where I passed the current DB as a reference to that deserialization method, but I wonder if there are other good ways to solve this problem.


Solution

  • I ended up representing this distinction as enums. With separate enums for serialization and actual work, because I couldn't serialize the reference to the local database, if you don't have a need for serialization, you can of course elude the NodeRepr enum:

    #[derive(Serialize, Deserialize)]
    pub enum NodeRepr {
        Local,
        Remote(NodeRef),
    }
    
    pub enum Node<'a> {
        Local(&'a LocalDB),
        Remote(NodeRef),
    }
    
    

    The node enum then has impl methods delegating the necessary method calls to either the localdb or the NodeRef methods through match statements.

    impl Node {
        pub async fn get(&self, key: String) -> Option<String> {
            match self {
                Node::Local(db) => db.get(key),
                Node::Remote(noderef) => noderef.get(key).await,
            }
        }
    }
    

    This way of handling things allows us to still retain the information of whether or not a node is local or remote so we can use this information everywhere in our code when necessary. Which is useful because you generally don't want to always hide the information of whether or not something is local or remote.

    If you do always want to hide whether a node is remote or local to enforce code cleanliness for example, you can work with trait objects, where you'd have the LocalDB and NodeRef both implementing the same trait.

    A trait is especially useful because it allows the addition of new node types (outside of local & remote) without having to change any old code. With enums, code that expects there to only be two distinct enum cases will break upon the addition of new node types. Limitations of trait objects can also be overcome by applying classic OOP patterns like the visitor pattern.