Handling Circular References with OnceLock, like `@Lazy` in SpringBoot

Greetings.
I've been working on developing web services with Rust and found dealing with circular references between several beans is quite challenging. For example, let's consider two services, UserServices and ShoppingListServices, which are mutually dependent. While ShoppingListServices needs UserServices for user identification, UserServices may also require ShoppingListServices to verify the hesitations of users and send some notifications.
In Java, we can use static import or frameworks like SpringBoot, because Java has GC and does not rely on the RAII. But in Rust, it is hard to solve this problem without huge reconstructions.
After the stabilization of OnceLock, I think I have found an elegant solution. Here's some toy code::

use std::any::{type_name, Any};
use std::collections::HashMap;
use std::ops::Deref;
use std::sync::{Arc, OnceLock, RwLock, Weak};

//a trait for all beans can be constructed using `AppContext`
pub trait BuildFromContext<E> {
    //`extras` are additional params for bean construction which
    //can be customized by user
    fn build_from(ctx: &AppContext, extras: E) -> Self;
}

//the context to store all beans
pub struct AppContext {
    inner: Arc<RwLock<AppContextInner>>,
}

impl AppContext {
    //init method
    pub fn new() -> Self {
        Self {
            inner: Arc::new(RwLock::new(AppContextInner {
                bean_map: Default::default(),
            })),
        }
    }

    //the method `acquire_bean_or_init` only requires immutable reference, so
    //the beans that implement `BuildFromContext` can invoke it
    pub fn acquire_bean_or_init<T>(&self, name: &'static str) -> BeanRef<T>
    where
        T: Send + Sync + 'static,
    {
        let arc_ref = self
            .inner
            .write()
            .expect("unexpected lock")
            .bean_map
            .entry(type_name::<T>())
            .or_insert(HashMap::new())
            .entry(name)
            .or_insert_with(||Arc::new(OnceLock::<T>::new()))
            .clone()
            .downcast::<OnceLock<T>>()
            .expect("unexpected cast error");

        BeanRef {
            inner: Arc::downgrade(&arc_ref),
        }
    }

    //in contrast, this method is only invoked during the initialization, to
    //register all beans
    pub fn construct_bean<T, E>(&mut self, name: &'static str, extras: E)
    where
        T: Send + Sync + BuildFromContext<E> + 'static,
    {
        let bean = T::build_from(&*self, extras);
        self.inner
            .write()
            .expect("unexpected lock")
            .bean_map
            .entry(type_name::<T>())
            .or_insert(HashMap::new())
            .entry(name)
            .or_insert_with(||Arc::new(OnceLock::<T>::new()))
            .clone()
            .downcast::<OnceLock<T>>()
            .expect("unexpected cast error")
            .set(bean)
            .ok()
            .expect("bean is initialized twice");
    }
}

pub struct AppContextInner {
    bean_map: HashMap<&'static str, HashMap<&'static str, Arc<dyn Any + Send + Sync>>>,
}

//the weak reference of bean, avoiding circular references
pub struct BeanRef<T> {
    inner: Weak<OnceLock<T>>,
}

impl<T> BeanRef<T> {
    //acquire the bean, if corresponding app context is dropped, there will
    //be a panic
    pub fn acquire(&self) -> RefWrapper<T> {
        let arc_ref = self
            .inner
            .upgrade()
            .expect("bean acquired after app context drop");
        RefWrapper(arc_ref)
    }
}

//make the usage easier
pub struct RefWrapper<T>(Arc<OnceLock<T>>);

impl<T> Deref for RefWrapper<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        self.0.get().expect("bean is not initialized properly")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    pub struct ServiceA {
        svc_b: BeanRef<ServiceB>,

        dao: BeanRef<DaoC>,
    }

    impl ServiceA {
        pub fn check(&self) {
            println!("svc a is ready");
        }
    }

    impl Drop for ServiceA {
        fn drop(&mut self) {
            println!("svc a is dropped");
        }
    }

    impl BuildFromContext<()> for ServiceA {
        fn build_from(ctx: &AppContext, extras: ()) -> Self {
            ServiceA {
                svc_b: ctx.acquire_bean_or_init("b"),

                dao: ctx.acquire_bean_or_init("c"),
            }
        }
    }

    pub struct ServiceB {
        svc_a: BeanRef<ServiceA>,

        dao: BeanRef<DaoC>,

        config_val: u32,
    }

    impl Drop for ServiceB {
        fn drop(&mut self) {
            println!("svc b is dropped");
        }
    }

    impl ServiceB {
        pub fn check(&self) {
            println!("svc b is ready");
        }
    }

    impl BuildFromContext<u32> for ServiceB {
        fn build_from(ctx: &AppContext, extras: u32) -> Self {
            ServiceB {
                svc_a: ctx.acquire_bean_or_init("a"),
                dao: ctx.acquire_bean_or_init("c"),
                config_val: extras,
            }
        }
    }

    pub struct DaoC {
        inner_map: HashMap<String, String>,
    }

    impl Drop for DaoC {
        fn drop(&mut self) {
            println!("dao c is dropped");
        }
    }

    impl DaoC {
        pub fn check(&self) {
            println!("dao c is ready");
        }
    }

    impl BuildFromContext<HashMap<String, String>> for DaoC {
        fn build_from(ctx: &AppContext, extras: HashMap<String, String>) -> Self {
            DaoC { inner_map: extras }
        }
    }

    #[test]
    fn it_works() {
        let mut ctx = AppContext::new();

        //register beans with circular references
        ctx.construct_bean::<ServiceA, _>("a", ());
        ctx.construct_bean::<ServiceB, _>("b", 32);
        ctx.construct_bean::<DaoC, _>("c", HashMap::new());

        //test each bean
        let svc_a = ctx.acquire_bean_or_init::<ServiceA>("a");
        svc_a.acquire().check();

        let svc_b = ctx.acquire_bean_or_init::<ServiceB>("b");
        svc_b.acquire().check();

        let dao_c = ctx.acquire_bean_or_init::<DaoC>("c");
        dao_c.acquire().check();

        //finally, all beans should be dropped
    }
}

The target of this toy implementation is to utilize OnceLock for lazy loading of beans, similar to the @Lazy annotation in SpringBoot. This allows AppContext to be registered globally, and the drop function of each bean can also be invoked before the process termination.

I would appreciate it if you could review my code and point out any flaws or unsafe practices. Thanks so much!

This always constructs the OnceLock. If the entry already exists, then it drops the newly-constructed one. Try or_insert_with.

RwLock isn't as performant as a Mutex. It has a theoretical advantage in some cases, but that rarely works out because of its higher overhead. You only write-lock it, so the theoretical advantage doesn't apply.

If you pass bean in as an argument (T) to ConstructBean, then it can have a more conventional constructor. BuildFromContext becomes unnecessary. You still end up with the same name uniqueness guarantee.

You'll save memory and likely get a speed bump from using only a single HashMap:

HashMap<(&'static str, &'static str), Arc...>

A downside to this change is it would prevent efficiently iterating through all beans of a given type.

I see none! I believe it can be used as-is.

You may want to also consider once_cell and its forthcoming port to the standard library, LazyLock. This could save you some of the annoying efforts of manually initializing the OnceLock.

1 Like

Thanks for your advice! I just fixed it