commonsensesoftware / more-rs-di

Rust Dependency Injection (DI) framework
MIT License
21 stars 3 forks source link

Resolve transient object as mutable #13

Open Michaelschnabel-DM opened 1 year ago

Michaelschnabel-DM commented 1 year ago

Hi,

is there a way to resolve a transient object as mutable with this package? Right now i use the async feature because some stuff needs to be Arc. But now everything is contained within an Arc but i need to modify some objects after resolution (they are only used locally afterwards) so they would not require an Arc container.

Regards Michael

commonsensesoftware commented 1 year ago

The single writer rules in Rust essentially make it impossible to use anything other than Rc<T> or Arc<T> because a singleton and scoped service instance need to be shared by design. I've run up against this issue myself. There are a few possible workarounds.

Option 1

If you're in control of registration and consumption, then Rc<T> and Arc<T> offer several options to get a mutable pointer if, and only if, the service is transient. These include:

In other contexts, you run the danger of a panic if you perform one of these operations on a non-transient service. I don't know that it is helpful, but strong_count() should always == 1 for a transient service and >= 2 for a singleton or scoped service (the container owns a copy). Any type of branching logic or conditions based on this knowledge likely over-complicates things.

Option 2

Another option is to use interior mutability within the service. This tends to be the most straight forward approach to deal with this challenge, even if it's not ideal.

The OptionsMonitorCache from my more-options crate show an example of such an implementation so that Options can be resolved via DI. In general, Options should be immutable, but they can change due to externalities (ex: a change in a settings file).

Option 3

Refactor to have the injected service serve as a factory. The factory itself can be of any scope and shouldn't have any issue being shared behind Rc<T> or Arc<T>. The thing the factory creates and returns can then be mutable without any other coupling. A zero-byte struct will have very low overhead beyond the requirement of creating it. The viability of this approach will depend on the thing created. It's certainly inconvenient, but then you can create the thing you really want and have it be lock-free.

Conclusion

The biggest challenge about making any approach work is that the lifetime of the service is intentionally opaque. That's par for the course with DI. I'm open to other ideas, but I'm not sure how else this can be addressed. I've mostly gone with Option 2 myself and on occasion used Option 3.

commonsensesoftware commented 1 year ago

I wasn't completely satisfied with my answer and I've been thinking about this for a long, long time. There actually is one more option. Honestly, it's probably the most correct way albeit also the ugliest.

The following will work in long-form. The #[injectable] macro will not work - yet, but you could implement Injectable on your own if you want that capability. It's very straight forward.

use std::sync::Mutex;

#[derive(Default)]
struct Counter {
    count: usize,
}

impl Counter {
    pub fn increment(&mut self) {
        self.count += 1;
    }

    pub fn value(&self) -> usize {
        self.count
    }
}

// optional: if you want the associative functions
impl Injectable for Counter {
    fn inject(lifetime: ServiceLifetime) -> ServiceDescriptor {
        ServiceDescriptorBuilder::<Mutex<Self>, Mutex<Self>>::new(lifetime, Type::of::<Mutex<Self>>())
            .from(|_| ServiceRef::new(Mutex::new(Self::default())))
    }
}

Now you can use it as:

#[test]
fn get_required_should_allow_mutable_service() {
    // arrange
    let provider = ServiceCollection::new()
        .add(Counter::singleton())
        .build_provider()
        .unwrap();

    // act
    let counter1 = provider.get_required::<Mutex<Counter>>();
    let counter2 = provider.get_required::<Mutex<Counter>>();

    counter1.lock().unwrap().increment();
    counter2.lock().unwrap().increment();

    // assert
    assert_eq!(2, counter1.lock().unwrap().value());
}

The one thing I don't really like about this approach is that an extra level of indirection is required through Mutex<T>. It's not entirely obvious that you need to register or request Mutex<TService> to get a writable instance. Aside for the ugliness of registration, it is fairly clear what is happening. If you don't want to hide writes with interior mutability, this is probably the next best approach.

I need to think whether there is better way to make this natural so there isn't a composition duality between registering Counter and Mutex<Counter>, but this is certainly one more workable option.

commonsensesoftware commented 11 months ago

This question has been asked before, so I spent quite a bit of time thinking about it, experimenting (based on the previous response), and I've now added first-class support for mutable services in the 3.0 release. ServiceRef<T> is now Ref<T> (see the release notes). There is also now RefMut<T>. This will map to Rc<RefCell<T>> for sync and Arc<RwLock<T>> for async. They are all supported by macro code generation and services can be registered as read-only or read-write for the same type.

use di::*;

#[injectable]
struct Counter {
    count: usize,
}

impl Counter {
    pub fn increment(&mut self) {
        self.count += 1;
    }

    pub fn value(&self) -> usize {
        self.count
    }
}

You can now register and request a mutable service with first-class support.

fn main() {
    let provider = ServiceCollection::new()
        .add(Counter::transient().as_mut())
        .build_provider()
        .unwrap();

    let counter = provider.get_required_mut::<Counter>();

    counter.borrow_mut().increment();
}

Unfortunately, there still isn't a way to get around Arc because Singleton and Scoped have shared lifetimes and need to be safe in asynchronous contexts. I don't see away around that. Arc for Transient should have little-to-no overhead if it is never cloned (e.g. reference count increased). The container will never hold a reference to it. If you really wanted it out of Arc, the only other approach would be taking it out as described in Option 1. I'm not sure that is worth it, but perhaps.