Binding traits

#[provides] trait

A trait can be provided using the #[provides] binding.

    #[provides]
    pub fn provide_i32_maker(impl_: I32MakerImpl) -> Box<dyn I32Maker> {
        Box::new(impl_)
    }

However, Lockjaw is going to be particular when trying to request a trait object from the dependency graph. The concrete implementation of the trait may contain reference to the component, but ideally this is not something the consumer of the trait should care about, so Lockjaw enforces that any trait it provides must not outlive the component. The worst case 'ComponentLifetime is assumed, so consumers don't have to change when it actually happens.

The Box returned by the component must be bound by the component's lifetime(same as self).

#[component(modules: [MyModule])]
trait ProvideComponent {
    fn i32_maker(&'_ self) -> Box<dyn I32Maker + '_>;
}

#[binds] trait

While #[provides] kind of works, binding an implementation to a trait interface is a common operation so Lockjaw has the #[binds] attribute to make them easier to use.

For an interface and an implementation:

pub trait Logger {
    fn log(&self, msg: &str);
}

struct StdoutLogger;

#[injectable]
impl StdoutLogger {
    #[inject]
    pub fn new() -> StdoutLogger {
        StdoutLogger
    }
}

impl Logger for StdoutLogger {
    fn log(&self, msg: &str) {
        println!("{}", msg);
    }
}

#[binds] can be used to create binding that says "when the Logger interface is needed, use StdoutLogger as the actual implementation":

    #[binds]
    pub fn bind_stdout_logger(_impl: StdoutLogger) -> Cl<dyn Logger> {}

The method body must be empty, as Lockjaw will replace it.

TheCl in the return type means Component lifetimed, which is a wrapper around a type forcing it not outlive the component. Having this wrapper makes it easier for the compiler to deduce the lifetime.

With the binding defined the Logger can now be used by other classes, without caring about the actual implementation.

pub struct Greeter<'component> {
    logger: Cl<'component, dyn Logger>,
}

#[injectable]
impl Greeter<'_> {
    #[inject]
    pub fn new(logger: Cl<dyn Logger>) -> Greeter {
        Greeter { logger }
    }

    pub fn greet(&self) {
        self.logger.log("helloworld!");
    }
}

Note that Logger still has to be injected as Cl<dyn Logger>, and Greeter is also bound by the lifetime of the component.

Unit testing with dependency injection

StdoutLogger writes its output straight to the console, so it is hard to verify Greeter actually sends the correct thing. While we can give StdoutLogger special apis to memorize what it logs and give access to tests, having test code in prod is generally bad practice.

Instead we can use dependency injection to replace the environment Greeter runs in. We can create a TestLogger that writes the logs to memory and can read it later, bind it to the Logger with a module, and install the module in a component for test that has all test bindings. We are than able to test Greeter without adding test code to the Greeter itself:

#[cfg(test)]
pub mod testing {
    use crate::{Greeter, Logger};
    use lockjaw::{component, injectable, module, Cl};

    static mut MESSAGES: Vec<String> = Vec::new();

    pub struct TestLogger;

    #[injectable]
    impl TestLogger {
        #[inject]
        pub fn new() -> TestLogger {
            TestLogger
        }

        pub fn get_messages(&self) -> Vec<String> {
            unsafe { MESSAGES.clone() }
        }
    }

    impl Logger for TestLogger {
        fn log(&self, msg: &str) {
            unsafe { MESSAGES.push(msg.to_owned()) }
        }
    }

    pub struct TestModule;

    #[module]
    impl TestModule {
        #[binds]
        pub fn bind_test_logger(_impl: TestLogger) -> Cl<dyn Logger> {}
    }

    #[component(modules: [TestModule])]
    pub trait TestComponent {
        fn greeter(&self) -> Greeter;
        fn test_logger(&self) -> TestLogger;
    }

    #[test]
    fn test() {
        let component: Box<dyn TestComponent> = <dyn TestComponent>::build();

        component.greeter().greet();

        assert_eq!(
            component.test_logger().get_messages(),
            vec!["helloworld!".to_owned()]
        );
    }
}

Generally, a library should also provide a test implementation and a module that binds the test implementation. The consumer of the library can then test by installing the test module instead of the real module. This allows test infrastructure to easily be shared. Some kind of test scaffolding can also be created to auto generate the component and inject objects into tests, but that is out of scope for lockjaw itself.

Note that in the TestLogger MESSAGES is a static mutable, since log() and get_messages() is going to be called on different instances of TestLogger. This is unsafe and bad, so in the next chapter we will discuss how to handle this by forcing a single instance of TestLogger to be shared among everything that uses it.

Source of this chapter