commonsensesoftware / more-rs-di

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

Instantiation of services on demand #1

Closed fairking closed 1 year ago

fairking commented 2 years ago

It is nice to see a simple di crate. I haven't seen other packages which have scoped descriptors implemented. 👍

It would be nice to have a feature when services are instantiated on demand, not immediately when parent service is being created.

For example:

pub struct MyService {
    sp: ServiceProvider,
    // s1: &Service1,
    // s2: &Service2,
}

impl MyService {
    fn new(sp: ServiceProvider) -> Self {
        Self { sp }
    }

    fn s1(&self) -> &Service1 {
        &self.sp.get_required::<Service1>();
    }

    fn s2(&self) -> &Service2 {
        &self.sp.get_required::<Service2>();
    }

   pub fn do_work_one(&self) {
      &self.s1.work_one();
   }

   pub fn do_work_two(&self) {
      &self.s2.work_two();
   }
}

and then somewhere else if we execute the following scope, only one service Service1 will be instantiated:

{
   var service = provider.get_required::<MyService>();
   service.do_work_one();
}

This is very useful when you have many heavy services and you don't want to instantiate them all. In other programming languages usually getter is used (eg. the property looks like this s1: Service1 { get { return &self.sp.get_required::<Service1>(); } }).

Note! Because of this approach it is hard to tell which services are dependent on MyService. But with some macros or just simply by scanning properties/methods and looking for their returning types I assume it is possible.

fairking commented 2 years ago

By the way I came out with the idea that lazy/wrapped properties can be used for that approach. Please have a look at this:

pub struct MyService {
    s1: LazyService<&Service1>,
    s2: LazyService<&Service2>,
}

impl MyService {
    fn new(sp: ServiceProvider) -> Self {
        Self { 
            s1: LazyService<&Service1>::new(sp), // Service1 is not instantiated here
            s2: LazyService<&Service2>::new(sp),
        }
    }

   pub fn do_work_one(&self) {
      &self.s1.inst().work_one(); // Service1 is instantiated here
   }

   pub fn do_work_two(&self) {
      &self.s2.inst().work_two();
   }
}

or

impl MyService {
    fn new(s1: LazyService<&Service1>, s2: LazyService<&Service2>) -> Self {
        Self { s1, s2 }
    }

In case of transient services the LazyService<&Service1> instance should instantiate Service1 only ones. The second time we ask for that service, it should return the same instance. Based on how the standard approach works with regular properties s1: Service1.

commonsensesoftware commented 2 years ago

Yes - you are on the path. You need another registered abstraction. It's not published - yet, but the code and documentation is up for the Options framework, which highlights this concept. I won't cover everything, but here's enough to highlight how it would work.

The Options framework will provide:

pub trait Options<T> {
    fn value(&self) -> &T;
}

Including all the necessary DI bits. The key purpose of this trait is to solve the scenario you are describing - "How do I lazy-evaluate a dependency?". While this is a specific use case, the same process can be used to lazy-load an dependency.

For the sake of brevity, consider that we have some options like this:

pub struct FileSystemOptions {
    root: PathBuf,
}

impl FileSystemOptions {
  pub new(root: PathBuf) -> Self {
    Self { root }
  }
  pub fn root(&self) -> &Path {
    &self.root
  }
}

And then have some component that depends on it:

pub struct FileSystem {
  options: Rc<dyn Options<FileSystemOptions>>
}

impl FileSystem {
  pub new(options: Rc<dyn Options<FileSystemOptions>>) -> Self {
    Self { options }
  }
  pub fn resolve(&self, path: &str) -> String {
    // FileSystemOptions are lazy-evaluated here
    self.options.value().root().join(path).to_string()
  }
}

In your example, I highly recommend the second option where you expose the LazyService hidden dependencies and avoid falling into the Service Locator anti-pattern. Currently, the one and only scenario where I can think of that you'd want/need ServiceProvider as a dependency is for creating scoped services. For example:

struct Executor {
  provider: ServiceProvider
}

impl Executor {
  pub new(provider: ServiceProvider) -> Self {
    Self { provider }
  }
  pub fn run(&self) {
    let scope = self.provider.create_scope();
    let tasks = scope.get_all::<dyn Task>();
    for task in &tasks {
       task.execute();
    }
    // all tasks, their dependencies, and the scoped container are all dropped here
  }
}
commonsensesoftware commented 2 years ago

Oh, one thing I did forget to mention is the challenges of standard Rust lifetimes. Almost everything that comes out of the container is either directly owned by the container itself (Singleton, Scoped) or owned by the caller that requests it (Transient). In most cases, you probably can't have a service that contains a standard borrow or the Borrow Checker will complain.

It is possible to register an existing object as a Singleton into the container, but you typically move the object into the container. The only other option is to clone the entire object going into the container or back it with Rc or Arc before it goes in. Some of the ServiceProvider tests show the ickiness of how that would be setup.

commonsensesoftware commented 1 year ago

While I do think most consumers would probably need lazy-initialization another way, I also agree it shouldn't be hard to have to set it up yourself. I'm definitely against the Service Locator anti-pattern.

For those that need/want it, I've released 2.1 with a new lazy feature that supports out-of-the-box lazy initialization via a simple Lazy<T> struct. Nothing really changes except you get to remove the ickiness of the Service Locator pattern from your code. You can read all the details and how it works in the updated README.

The only thing the documentation doesn't really cover is testing, which I considered in the design. Ultimately, a dependency still has to be resolved by a ServiceProvider. Due to the ownership rules it made sense for Lazy<T> to be struct as opposed to a trait. I suppose I could have make the input Box<dyn Lazy<T>>, but that seems even nastier. Setting up a mock service isn't so simple. All my attempts to make it simple weren't simple. For now, I've decided that if you need to do that, it's best to just create a test ServiceProvider instance with the mock configuration and create the appropriate Lazy<T> instance via the utility functions. The two exceptions are for a missing, optional dependency which can use di::lazy::missing::<T>() and an empty set for a collection of like dependencies which can use di::lazy::empty<T>(). These don't require special setup and may be useful in testing where no setup is required.

I'm going to close this out as I believe your ask as been achieved. Feel free to add additional comments or suggest more enhancements. Thanks for the feedback.