Colleagues, hello!
That's Friday, the weekend is still far away, so we hope that the relatively complex text will not embarrass you.
It seems that the modular organization of Java 9 will require a remarkable ingenuity from the programmer, and one of the promising options for adapting to such a wondrous new world is the introduction of dependencies. It was on this occasion that the respected Paul Bakker, one of the authors of the book "
Java 9 Modularity ", spoke out distinctly and interestingly in the O'Reilly blog.
')
Happy reading and do not forget to vote please!
In this article, we will look at how to combine the Java 9 modular system, implement dependencies, and use services to
weaken the connection between modules.
It is almost impossible to imagine such a code base in Java, where there would be no dependency injection. Therefore, unsurprisingly, dependency injection can be very useful to weaken the coherence of the code. Weak connectivity is achieved by hiding the implementation. The weakening of communication is a key factor for ease of support and extensibility of the code. In fact, in Java, this strategy comes down to interface-based programming, rather than specific types.
Consider a real example. In our book,
Java 9 Modularity , we consider an application analyzing the complexity of a given text as a through example. The application exists in two variants: as a CLI (command line interface) and a GUI (graphical user interface). It also uses various algorithms to calculate the complexity of the text. CLI and GUI are two separate modules, and each analytical algorithm is also a separate module. Naturally, CLI and GUI modules depend on analyzers, but they must use only the
Analyzer
interface. The CLI and GUI modules must remain operable without any information about the implementation of the interfaces.
In the long run, this weakening of the connection simplifies the maintenance of the code, since it is clear what each part of the code does, and each module can be changed without affecting the rest of the system and even not quite understanding how it is arranged. This is one of the key concepts of modularity. If the code is organized in the form of modules, it becomes easier to modify individual parts of the system, which is very useful both for support and for extensibility. Please note: when designing for modularity, a ready-made modular system is not required, but if it is, this will greatly simplify everything.
Every time, breaking a similar system into modules, we are faced with a practical problem. How to achieve loose coupling between CLI / GUI and analyzers? Indeed, at some point we will certainly need to create an instance of the implementation class. The classic solution to such a problem is dependency injection, or, in other words, inversion of control. If we use dependency injection, our CLI / GUI code will simply declare that it needs instances of the
Analyzer
interface; this is usually done with annotations. The actual instantiation of the implementation classes and their binding to the CLI / GUI code is done using a framework for dependency injection - popular examples of such frameworks are
Spring and
Guice . This article uses Guice, but the
Java 9 Modularity book also has a detailed Spring-based example.
What is better when working with modules: dependency injection or encapsulation?
Java 9 and the modular system of this version of the language allows you to bring the "decoupling" of the code to a new level. Previously, it was possible to program based on the interfaces, but the implementation classes could not really be hidden. Before Java 9 in Java, in essence, it was impossible to encapsulate classes in a module (and even declare a module, for that matter). The situation changes with the advent of the modular Java 9 system, however, here a number of new problems arise when working with frameworks for dependency injection.
If we study the internal structure of frameworks for dependency injection, it turns out that the framework requires access either for deep reflection or for reading to both implementation classes that need to be implemented, as well as access for deep reflection to those classes into which this instance is supposed to be implemented. With modular system organization, this approach works poorly. Implementation classes must be implemented in their own module, which means that the code located outside the module will be denied access to these classes (even when reflection is applied). The dependency injection framework is just another module that obeys the same modular system rules, which means that the framework will not have access to these classes. So we will have to loosen the encapsulation, which is not good.
Consider the typical Guice setting.
public static void main(String... args) throws IOException { Injector injector = Guice.createInjector( new ColemanModule(), new KincaidModule(), new NextgenSyllableCounterModule(), new NaiveSyllableCounterModule() ); CLI cli = injector.getInstance(CLI.class); cli.analyze(args[0]); }
This main method boots the Guice framework with several Guice modules (do not confuse them with Java 9 modules!). Each module has one or more implementations for the interfaces that we are going to implement. For example, the
ColemanModule
module might look like this.
public class ColemanModule extends AbstractModule{ @Override protected void configure() { Multibinder.newSetBinder(binder(), Analyzer.class) .addBinding().to(ColemanAnalyzer.class); } }
Finally, we define our CLI code with the
@Inject
annotation, telling Guice so that when creating an instance of this class, the framework must inject dependencies.
public class CLI { private final Set<Analyzer> analyzers; @Inject public CLI(Set<Analyzer> analyzers) { this.analyzers = analyzers; }
The main method is in the module along with the CLI class. The
ColemanAnalyzer
implementation
ColemanAnalyzer
and the
ColemanAnalyzer
module
ColemanModule
also in the module together. Ideally, both of these classes should be encapsulated, since both are classes of implementation. Our CLI module should not be directly dependent on them. Unfortunately this is not possible. We will have to export the exports package containing the
ColemanModule
, since we need it for the initial loading of Guice. Secondly, you will need to open the package containing the
ColemanAnalyzer
, as well as the package with the CLI, since deep reflection is required to instantiate the Guice classes. We now have a connection between the CLI module and each analyzer module, as shown in the following figure. This is very bad!
Fig. 1. Dependencies between modules: strong bindingDo these new problems indicate that modules are hard to work with? By no means! Modules finally allow us to encapsulate the code, and this is a serious step towards such “weakly coupled” design that we are striving for. Existing frameworks are not designed for these new features, so we may need to slightly revise the work with them. However, now I will show you what a great workaround for this case is offered by Guice.
Before solving this problem, let's consider what the modular system itself offers in order to provide work with encapsulated types of implementations between different modules.
Using services as an alternative to dependency injectionThe modular system has a special feature built in to loosen the communication between the modules. Using services, the module can declare that it provides an interface implementation. Other modules may declare that they use this interface. The module system transmits implementations to the module that uses the service; moreover, the module does not need to read the type of implementation;
The following is the module descriptor, and this module declares that it provides the service implementation. Note:
Analyzer
is a regular Java interface, and
ColemanAnalyzer
is a regular Java class that implements the
Analyzer
interface.
module easytext.analysis.coleman { requires easytext.analysis.api; provides javamodularity.easytext.analysis.api.Analyzer with javamodularity.easytext.analysis.coleman.Coleman; }
The CLI module must declare that it uses the
Analyzer
service. It also needs a module that exports the
Analyzer
interface, and the
Coleman
module is not required.
module easytext.cli { requires easytext.analysis.api; uses javamodularity.easytext.analysis.api.Analyzer; }
Now in the CLI code, you can use the
ServiceLoader
API to get implementations provided by other modules. It may have zero or more implementations, depending on which analyzer modules you have installed.
Iterable<Analyzer> analyzers =. ServiceLoader.load(Analyzer.class); for(Analyzer analyzer: analyzers) { System.out.println(analyzer.getName() + ": " + analyzer.analyze(sentences)); }
This new service-based design is presented in the following figure. As you can see, services are great for weakening communication between modules, and since this approach is designed specifically for a system of modules, it does not require to sacrifice encapsulation in the same way as it would have if using a framework to introduce dependencies like Guice. Services are not identical to dependency injection, because the
ServiceLoader
API is looking for implementations, not dependencies being implemented, but the service approach solves the same problem. In many practical situations, it is wiser to use services, rather than relying on external frameworks.
Fig. 2. The weakening of the binding with the help of servicesWhat if we still want to use Guice, because we have to work with the existing code base based on Guice — or if we simply like the declarative nature of dependency injection? Is it possible to combine this framework with a modular system? It turns out that the combination with Guice is a very beautiful solution!
Combining dependency injection with servicesAs we have seen, the main problem with Guice is the emergence of a direct connection between the CLI / GUI module and the analyzer modules. The thing is, we need
AbstractModule
classes to boot Guice. What if you could eliminate this step altogether and provide
AbstractModule
classes as services?
module easytext.algorithm.coleman { requires easytext.algorithm.api; requires guice; requires guice.multibindings; provides com.google.inject.AbstractModule with javamodularity.easytext.algorithm.coleman.guice.ColemanModule; opens javamodularity.easytext.algorithm.coleman; }
The implementation package still needs to remain open to deep reflection, since Guice needs to be able to instantiate classes. The problem is small, because here we don’t add to the code any unnecessary links that would be worth getting rid of.
From the CLI / GUI side, you can do an initial load, Guice, by finding the
AbstractModule
implementation using the
ServiceLoader
. No more binding to implementation modules!
Injector injector = Guice.createInjector( ServiceLoader.load(AbstractModule.class)); CLI cli = injector.getInstance(CLI.class);
So, we briefly discussed how dependency injection helps to deal with code connectivity, and what complications are possible when working with existing dependency injection frameworks, for example, with Guice, when we encapsulate our code in modules. Services can serve as a built-in alternative to dependencies, and the combination of services and Guice is convenient when introducing dependencies, and in this case it is also not necessary to refuse encapsulation.
SourceAll the source code for this article is posted on
GitHub . There are two branches: one using services, as shown in the last example, and the other without them.