rust

How to initialize rarely mutable rust services shared globally with easy access


I'm new to rust and would like to initialize global services to be used across application, but for testing purposes I would like sometimes to override service (mock it or change behavior) and I'm not sure how to do it without unsafe, need to lock or other ugly syntax, I just want a reference to service from getter file_service method.

There is a decent answers about this topic, but it's hard to understand what is the best approach for my use case that will not affect performance much as services will be used across application all the time by multiple endpoints and handlers and will be easy to use.

// unwraps here is fine
pub struct DI {}
impl DI {
    // can't override or use in parallel tests where it can be triggered multiple times
    pub fn init(hash_service: HashService) {
        HASH_SERVICE
            .set(hash_service)
            .expect("Failed to init hash service");
    }


    pub fn hash_service() -> &'static HashService {
        HASH_SERVICE.get_or_init(|| HashService::default())
    }
}

fn use_it(){
    // must be able to directly run service methods without unwraps, locks or anything else
    DI::hash_service().hash("value")
}

static HASH_SERVICE: OnceLock<HashService> = OnceLock::new();

pub struct HashService {
    argon2: Argon2<'static>,
}
impl HashService {
    pub fn hash(&self, value: &str) -> Result<String, ApiErr> {
        // do smth
    }
}

Attempt with RwLock:

pub struct DI {}
impl DI {
    pub fn init(hash_service: HashService) {
        Self::init_hash_service(hash_service);
    }

    pub fn init_hash_service(hash_service: HashService) {
        if let Some(service) = HASH_SERVICE.get() {
            let mut write_guard = service.write().expect("Failed to write hash service");
            *write_guard = hash_service;
        } else {
            HASH_SERVICE
                .set(RwLock::new(hash_service)) 
                // tests fail here, probably attempt to init second time?
                .expect("Failed to init hash service");
        }
    }

    pub fn hash_service() -> RwLockReadGuard<'static, HashService> {
        HASH_SERVICE
            .get_or_init(|| RwLock::new(HashService::default()))
            .read()
            .expect("failed to get hash service")
    }
}

static HASH_SERVICE: OnceLock<RwLock<HashService>> = OnceLock::new();

Test example where I use fast hash function to speed up tests:

#[cfg(test)]
mod tests_unit {
    use serial_test::parallel;

    #[tokio::test]
    #[parallel]
    async fn password_validation() {
        DI::init_hash_service(Self::create_testing_fast_insecure_hash_service());

        ...
    }
}

Solution

  • Since the question explicitly asks for a solution without unsafe, here is an alternative to the accepted answer using safe code. It provides the same behavior, including performance characteristics (i.e. no locking), but it does depend on the well-known crossbeam crate.

    use crossbeam::atomic::AtomicCell;
    
    // (Mostly) drop-in replacement for OnceLock<T> for use in tests
    pub struct ReplaceableSingleton<T: 'static> {
        value: AtomicCell<Option<&'static T>>,
    }
    
    impl<T: 'static> Default for ReplaceableSingleton<T> {
        fn default() -> Self {
            Self::new()
        }
    }
    
    impl<T: 'static> ReplaceableSingleton<T> {
        pub const fn new() -> Self {
            Self {
                value: AtomicCell::new(None),
            }
        }
    
        // Unlike OnceLock::get_or_init(), this does not guarantee that concurrent
        // calls from different threads won't call multiple provided functions.
        pub fn get_or_init(&self, f: impl FnOnce() -> T) -> &T {
            if let Some(value) = self.value.load() {
                return value;
            }
            self.replace(f())
        }
    
        /// Replace the value, leaking the previous one (to avoid invalidating references
        /// previously given out). Use only in tests.
        pub fn replace(&self, new_value: T) -> &T {
            // leaking the value is the simple and safe way to give out references to `T`
            // while allowing replacement
            let new_value = Box::leak(Box::new(new_value));
            self.value.store(Some(new_value));
            new_value
        }
    
        pub fn set(&self, new_value: T) -> Result<&T, T> {
            Ok(self.replace(new_value))
        }
    }
    

    Playground

    A safe solution using only the standard library is also possible, but is not lock-free. (It would use a mutex like this.)