Can I inject this class ?
One of the biggest pain points using injection in Java is that if you missed something, you will find it during runtime.
Many code commits were made where everything passed the code review, and looked legit, just to find out that it doesn’t work due to a missing injection.
Let me share a small example to show how it looks like. Let’s say we have one Guice module, which provider AWS credentials objects and a DDB client:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | public class MyModule extends AbstractModule { private final Regions region; public MyModule(final Regions region) { this.region = region; } @Override protected void configure() { bind(Regions.class) .annotatedWith(named("region")) .toInstance(this.region); } @Provides @Singleton AWSCredentialsProvider getCredentialsProvider() { return DefaultAWSCredentialsProviderChain.getInstance(); } @Provides @Singleton AmazonDynamoDB getDynamoDB(final AWSCredentialsProvider credentialsProvider) { return AmazonDynamoDBClientBuilder.standard() .withCredentials(credentialsProvider) .withRegion(region) .build(); } } |
Then, somewhere we have a class that is using DDB:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | @Singleton public class TableSizeProvider { private final AmazonDynamoDB ddbClient; @Inject public TableSizeProvider(final AmazonDynamoDB ddbClient) { this.ddbClient = ddbClient; } public long getTableSizeBytes(final String tableName) { return ddbClient.describeTable(tableName) .getTable() .getTableSizeBytes(); } } |
The class is injected using Guice, and everyone is happy.
Then, a new feature was requested, to notify the table size via SQS every 60 seconds.
As good engineers, we approached the problem, and added a new scheduled thread that will get the table size and send it to the requested queue:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | @Singleton public class TableSizePeriodicReporter { // In real life, this would sit in a configuration file. private final static String QUEUE_NAME = "TableSizeMonitorQueue"; private final static Duration TIME_BETWEEN_CHECKS = Duration.ofMinutes(1); private final static Set<string> TABLES_TO_MONITOR = ImmutableSet.of("Jobs", "Clients"); private final TableSizeProvider tableSizeProvider; private final AmazonSQS sqsClient; private final ScheduledExecutorService scheduledWorker; private String queueUrl; private final AtomicBoolean started = new AtomicBoolean(false); @Inject public TableSizePeriodicReporter(final TableSizeProvider tableSizeProvider, final AmazonSQS sqsClient) { this.tableSizeProvider = tableSizeProvider; this.sqsClient = sqsClient; this.scheduledWorker = Executors.newSingleThreadScheduledExecutor(); } public void start() { if (!started.getAndSet(true)) { this.queueUrl = sqsClient.getQueueUrl(QUEUE_NAME).getQueueUrl(); this.scheduledWorker.execute(this::reportSize); } } public void shutdown() { this.scheduledWorker.shutdown(); } private void reportSize() { // Go over all the tables, get their size and send in a message TABLES_TO_MONITOR.forEach(tableName -> { final long tableSize = tableSizeProvider.getTableSizeBytes(tableName); final String message = String.format("Table %s size is %d", tableName, tableSize); // We know that queueUrl is set as this method is triggered only internally from this class sqsClient.sendMessage(queueUrl, message); }); // Schedule the next execution scheduledWorker.schedule(this::reportSize, TIME_BETWEEN_CHECKS.toMillis(), TimeUnit.MILLISECONDS); } } |
And it was added also to our Main class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | public class Main { @SneakyThrows(InterruptedException.class) public static void main(final String[] args) { final Regions region = getRegionOrDie(args); final Injector injector = createInjector( new MyModule(region) ); final TableSizePeriodicReporter tableSizeReporter = injector.getInstance(TableSizePeriodicReporter.class); tableSizeReporter.start(); // Oops, looks like we have a dead-lock that keeps the program running. Thread.currentThread().join(); } private static Regions getRegionOrDie(final String[] args) { try { return Regions.fromName(args[0]); } catch(final Exception e) { System.err.printf("Could not parse region from arguments: %s%n", args[0]); System.exit(1); // Make our compiler happy. return null; } } } |
You are happy that you finished your new feature and go to test it. Compilation… Passed! That’s a good sign, no?
You are starting your service and getting an error stating that AmazonSQS class cannot be injected. whoops.
This is a simple example, but think that you’ve just added a new piece to an enterprise software that broke some things, and was not that easy discoverable. Doesn’t sounds fun right?
The solution is to simply add a new @Provides implementation to MyModule which will tell Guice “how” to inject this class. Something similar to this:
1 2 3 4 5 6 7 8 | @Provides @Singleton AmazonSQS getAmazonSQS(final AWSCredentialsProvider credentialsProvider) { return AmazonSQSClientBuilder.standard() .withCredentials(credentialsProvider) .withRegion(region) .build(); } |
But could you find it earlier, and not during runtime when testing your changes ? I say YES!
There is no way to know whenever a class will be injected successfully, without going and injecting it. So, what if you could write a unit test that will try to inject the class ? Sounds easy enough… let’s go and do it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | public class InjectionTest { private final static Regions TEST_REGION = Regions.US_EAST_1; private static Injector injector; @BeforeAll public static void setup() { injector = createInjector(new MyModule(TEST_REGION)); } @Test public void testTableSizePeriodicReporterInjection() { final TableSizePeriodicReporter reporter = injector.getInstance(TableSizePeriodicReporter.class); assertNotNull(reporter); } } |
We got what we wanted – If you will run this unit test, and I suppose you are running your unit tests as part of your build process, you’ll get a failure stating that this class cannot be injected. But can we do it better ?
What if we could do that for all the classes that we care about ? Let’s leverage JUnit5 parameterized tests feature:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | public class InjectionTest { private final static Regions TEST_REGION = Regions.US_EAST_1; private static Injector injector; @BeforeAll public static void setup() { injector = createInjector(new MyModule(TEST_REGION)); } @ParameterizedTest @MethodSource("getClassesToInject") public void testClassInjection(final Class<?> type) { final Object obj = injector.getInstance(type); assertNotNull(obj); } private static Stream<arguments> getClassesToInject() { return Stream.of( Arguments.of(TableSizeProvider.class), Arguments.of(TableSizePeriodicReporter.class) ); } } |
Now, we can provide a list of classes to test for injection and reduce the chances of having injection problems during Runtime.
Yet, I think we can do better. This solution is neat, but still requires you and other team members to go and explicitly add your classes into the test. Luckily for us, we have Reflection, so we can define the list of namespaces to cover, and the test will check all the classes within automatically.
A suggested unit test may look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | public class InjectionTest { private final static Set<string> PACKAGES = ImmutableSet.of( "org.sirotin.example" ); private final static Regions TEST_REGION = Regions.US_EAST_1; private static Injector injector; @BeforeAll public static void setup() { injector = createInjector(new MyModule(TEST_REGION)); } @ParameterizedTest @MethodSource("getClassesToInject") public void testClassInjection(final Class<?> type) { final Object obj = injector.getInstance(type); assertNotNull(obj); } private static Stream<arguments> getClassesToInject() { final List<arguments> result = new ArrayList<>(); PACKAGES.forEach(packageName -> result.addAll(getClassesForPackage(packageName))); return result.stream(); } private static List<arguments> getClassesForPackage(final String packageName) { final Reflections reflections = new Reflections( new ConfigurationBuilder() .setUrls(ClasspathHelper.forPackage(packageName)) .setScanners(new SubTypesScanner(false)) ); final Set<class<?>> types = reflections.getSubTypesOf(Object.class); return types.stream() .filter(InjectionTest::isAnnotatedWithInject) .map(Arguments::of) .collect(Collectors.toList()); } private static boolean isAnnotatedWithInject(final Class<?> clazz) { for(final Constructor<?> constructor : clazz.getConstructors()) { if (constructor.isAnnotationPresent(Inject.class)) { return true; } } return false; } } |
Reflections will find all the classes within the provided list of PACKAGES, and then for each one will try to find a constructor with the @Inject annotation. It will collect all its findings, and pass them as a stream of arguments to the testClassInjection method, that will try to inject them.
The full source code of this example can be found here.
– Alexander