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!