1. Overview
1.1. What is Bootique
Bootique is a platform for building container-less runnable Java applications. It is an ideal technology for microservices, as it allows you to create a fully-functional app with minimal-to-no setup. Though it is not limited to a specific kind of application (or to the "micro" size) and can be used for REST services, webapps, runnable jobs, DB migrations, JavaFX GUI apps or really anything else.
Unlike traditional container-based apps, Bootique allows you to control your main()
method and create Java apps that behave like simple executable commands:
java -jar my.jar [arguments]
Each Bootique app can be started with a YAML configuration, or configured with shell variables and is ideally suited for Docker / cloud deployments.
Compared to other products in this space Bootique has a focus on modularity and clean pluggable architecture. It is built on top of a dependency injection container, and pretty much anything in Bootique can be customized/overridden to your liking.
1.2. Java Version
Java 11 or newer is required.
1.3. Build System
Bootique apps can be built using any Java build system (Ant, Maven, Gradle, etc). Examples in the documentation are based on Maven.
2. Programming
The simplest "main" class in a Bootique application looks like this:
public class Application implements BQModule {
public static void main(String[] args) {
Bootique.main(args);
}
@Override
public void configure(Binder binder) {
// configure services
}
}
Alternatively, if you want to customize the application structure, you can use Bootique
builder API. Most apps would need to specify at least the following:
public static void main(String[] args) {
Bootique.app(args) (1)
.autoLoadModules() (2)
.exec() (3)
.exit(); (4)
}
1 | Starts the builder and captures startup arguments (usually passed from the surrounding environment) |
2 | Enables module auto-loading |
3 | Creates Bootique runtime and executes it. The action executed depends on the startup arguments above |
4 | Exits the JVM passing the exit code to the OS |
Step 3 (the exec(..)
method) is actually two steps combined in one for convenience. Let’s expand it further to demonstrate the BQRuntime
object, that is the entry point of the application:
public static void main(String[] args) {
BQRuntime runtime = Bootique.app(args)
.autoLoadModules()
.createRuntime(); (1)
runtime.run() (2)
.exit();
}
1 | Creates a BQRuntime object |
2 | Executes the runtime run() method |
BQRuntime
holds all app objects (often called "services") created by Bootique. For all intents and purposes, BQRuntime
is the "application". You can peek inside the runtime to access its objects. E.g., here we are retrieving a Cli
instance that is defined in Bootique and describes the command line arguments our app was started with:
Cli cli = runtime.getInstance(Cli.class);
What objects exist in the runtime and how they are initialized is determined by the "modules" discovered and loaded by Bootique
, so next let’s discuss what modules are.
2.1. Modules
A module is a class that implements BQModule
. Modules serve as "blueprints" for Bootique to create application objects via the "bindings" API and dependency injection (DI).
Colloquially, the term "modules" is also applied to .jar files that contain those classes together with a bunch of "regular" classes. So we will use the word interchangeably. |
Here are two examples of the Bootique standard modules:
-
bootique-jetty
that integrates Jetty server into your application -
bootique-jdbc
that allows to work with relational databases
It is helpful for you to think of your own code as being "organized into modules" as well. A simple custom module is shown below with an example of an object binding API that will be discussed later:
public class MyModule implements BQModule {
@Override
public void configure(Binder binder) {
binder.bind(MyService.class).to(MyServiceImpl.class);
}
}
By convention, all classes that logically belong to a module are packaged in a separate .jar . Though nothing prevents you from keeping multiple BQModules in a single .jar , or spreading your code across multiple jars. Other than autoloading descriptors explained below, there are no special packaging requirements for modules. They are all loaded into a single BQRuntime . |
Java Platform Module System (JPMS) is conceptually close to Bootique modules, and we would love to fully align the two. Unfortunately, it is our experience that forcing the users to adhere to JPMS everywhere would result in lots of limitations and invariable productivity loss, so we are intentionally ignoring possible JPMS synergies. |
2.1.1. Modules (Auto) Loading
When a Bootique app is started, it loads object definitions from all modules it is aware of. Those can come from two places:
-
Modules explicitly added to Bootique:
Bootique.app(args).module(MyModule.class)
-
"Auto-loadable" modules
The first category is self-explanatory and allows to manually specify which modules should be in the app:
Bootique.app()
.module(JettyModule.class) (1)
.module(binder -> binder.bind(MyService.class).to(MyServiceImpl.class)) (2)
.exec();
1 | Adding a module by BQModule type |
2 | Adding a module as a lambda |
The second category is modules that will be discovered automatically by looking for META-INF/services/io.bootique.BQModule
files on the classpath, and getting the names of modules from those files:
Bootique.app()
.autoLoadModules() (1)
.module(binder -> binder.bind(MyService.class).to(MyServiceImpl.class)) (2)
.exec();
1 | Module auto-loading needs to be turned on explicitly |
2 | Auto-loading can be combined with explicit modules |
In this example, instead of adding JettyModule
explicitly, we turned on auto-loading allowing Bootique to locate and load this standard module (assuming it was added to the application as a dependency). When writing your own auto-loadable modules, you would add a line to META-INF/services/io.bootique.BQModule
, containing the fully-qualified name of the BQModule Java class. E.g.:
com.foo.MyModule
If you have more than one module in a given project, you should put all the names in this file, one per line. The result of autoloading, is that you can manage inclusion/exclusion of modules purely via the build system instead of in the code. Auto-loading is the recommended mechanism for assembling your app in almost all cases. All the standard Bootique modules are autoloadable.
2.1.2. Modules Metadata
BQModule
has a method called crate()
that you can optionally implement to expose module metadata to Bootique:
@Override
public ModuleCrate crate() {
return ModuleCrate.of(this)
.description("Module that does something useful")
.config("mymodulewithcrate", MyConfig.class)
.build();
}
The returned "crate" can contain information about module configuration structure, its deprecation status, etc. While you can write modules without explicit "crates", providing a "crate" with as much information about the module as possible, would help Bootique to help your users (and your future self) to understand what the module does, and how to use it.
2.1.3. Deprecating Modules
TODO
2.2. Objects Assembly
As mentioned above, BQRuntime
is a holder of all application objects, and modules are blueprints to create those objects. You’d write your own module code to define how your objects should be created and initialized. This is done via bindings and "provider" methods, and with the assistance of the dependency injection (DI) mechanism built into Bootique.
2.2.1. Dependency Injection
Let’s start with how you can leverage DI when creating your objects. Objects can annotate their fields and/or constructors with @Inject
to indicate which internal variables they want to be provided ("injected") by the Bootique DI environment. To demonstrate this, we will show a couple of implementations of a simple "hello" service that returns a greeting:
public interface Hello {
String sayHello();
}
An implementation of this service would need another service called UserNameService
that returns a user name and needs to be injected. Here is a field injection flavor:
public class HelloService1 implements Hello {
@Inject
private UserNameService nameService;
@Override
public String sayHello() {
return "Hello, " + nameService.getUserName() + "!";
}
}
And here is a constructor flavor:
public class HelloService2 implements Hello {
private final UserNameService nameService;
@Inject
public HelloService2(UserNameService nameService) {
this.nameService = nameService;
}
@Override
public String sayHello() {
return "Hello, " + nameService.getUserName() + "!";
}
}
Both flavors are identical in terms of functionality. Constructor variety is more verbose, but arguably results in a cleaner API, as the object can be created if needed without a DI environment.
2.2.2. Bindings
Regardless of which flavor was selected, the implementation needs to be bound in the module class:
@Override
public void configure(Binder binder) {
binder.bind(Hello.class)
.to(HelloService2.class)
.inSingletonScope();
}
This creates a permanent association between Hello
interface and a specific implementation. Hello
can now be injected into other services. A single instance of HelloService2
is created automatically by the DI container, with a real object transparently provided for the "nameService".
2.2.3. Provider Methods in Modules
If the service construction requires additional assembly logic, you can create simple "provider" methods in the module class. Any method of a module class annotated with @Provides
is treated a "provider" method. E.g.:
public class HelloModule3 implements BQModule {
@Singleton
@Provides
Hello provideHello(UserNameService nameService) { (1)
return new HelloService3(nameService);
}
@Override
public void configure(Binder binder) { (2)
}
}
1 | Provider method that does both assembly and binding of the Hello service. |
2 | configure(..) method may be empty, or contain other bindings |
What makes provideHello
method a "provider" is the @Provides
annotation. The access modifier is irrelevant (can be "public", "protected", etc.). The method does both assembly and binding of the "Hello" service. The return type (Hello
) is used as the binding key. And while there is no @Inject
annotation in use anywhere, injection in fact also happens here - all method arguments ("nameService" in this case) are injected by the DI environment.
2.2.4. Provider Objects
If more complex logic or lots of dependencies are required, instead of a provider method, you can create a provider class that implements Provider<MyService>
. E.g.:
public class HelloService3Provider implements Provider<Hello> {
@Inject
private UserNameService nameService;
@Override
public Hello get() {
return new HelloService3(nameService);
}
}
It needs to be bound to an object declaration via toProvider(..)
method;
@Override
public void configure(Binder binder) {
binder.bind(Hello.class)
.toProvider(HelloService3Provider.class)
.inSingletonScope();
}
Provider object has the same rules for injection of its own dependencies as regular objects, i.e. either via annotated fields or an annotated constructor.
2.2.5. Injection in Collections
TODO
2.2.6. Injection and Generics
TODO
2.3. Configuration
Bootique app can accept external configuration in different forms, coming from different sources. Internally, all configuration sources are combined following the rules described below into a single JSON-like tree object, that is used to initialize properties of some application objects.
Common configuration formats are JSON or YAML. For simplicity, we’ll focus on YAML, but the two are interchangeable. Other sources are shell variables, Java properties and even cloud secrets managers. No matter which source (or combination of them) you use, all of them end up in a single tree object whose nodes match the structures of the application Java objects they will ultimately be converted to.
Here is an example YAML file:
log:
level: warn
appenders:
- type: file
logFormat: '%c{20}: %m%n'
file: target/logback/debug.log
jetty:
context: /myapp
connectors:
- port: 12009
By convention, the top-level keys correspond to the names of modules that use a given configuration. In the example above, log
subtree configures bootique-logback
module, while jetty
configures bootique-jetty
.
For most standard modules, configuration formats are described in the module documentation. But each Bootique application contains a -H command that displays the full config structure for that app’s collection of modules. The output of this command is always the most accurate reference. |
2.3.1. Configuration Basics
Consider the following YAML:
my:
intProperty: 55
stringProperty: 'Hello, world!'
and the following object with matching property names:
@BQConfig (1)
public class MyObject {
final SomeOtherService soService;
private int intProperty;
private String stringProperty;
@Inject
public MyObject(SomeOtherService soService) { (2)
this.soService = soService;
}
@BQConfigProperty (3)
public void setIntProperty(int i) {
this.intProperty = i;
}
@BQConfigProperty (4)
public void setStringProperty(String s) {
this.stringProperty = s;
}
public void doSomething() {
// ..
}
}
1 | The optional BQConfig annotation helps to include this object in the output of the -H command |
2 | Objects created from configuration can also inject any dependencies just like other Bootique objects |
3 | A setter used to load intProperty into the object. The optional BQConfigProperty annotation ensures the property is included in the output of the -H command |
4 | A setter used to load stringProperty into the object. The optional BQConfigProperty annotation ensures the property is included in the output of the -H command |
To create MyObject
and load configuration values into it, we can use the following API:
@Singleton
@Provides
public MyObject createMyService(ConfigurationFactory configFactory) {
return configFactory.config(MyObject.class, "my");
}
In this example, MyObject
obtains its properties separately from constructor injection and configuration. ConfigurationFactory
is the object available in the core Bootique that holds the main config tree and serves as a factory for configuration-aware objects. The structure of MyObject
is very simple, but it can be as complex as needed, containing nested objects, arrays, maps, etc. Internally Bootique uses Jackson framework to bind YAML/JSON to Java objects, so all the features of Jackson can be used to craft configuration.
2.3.2. Object Factories
Very often configuration-aware objects are not retained by the app, but are created only to serve as factories of other objects, and then discarded. For instance, we can turn MyObject
above into MyFactory
that creates an object called MyService
:
@BQConfig
public class MyFactory {
private int intProperty;
private String stringProperty;
@BQConfigProperty
public void setIntProperty(int i) {
this.intProperty = i;
}
@BQConfigProperty
public void setStringProperty(String s) {
this.stringProperty = s;
}
// factory method
public MyService createMyService(SomeOtherService soService) {
return new MyServiceImpl(soService, intProperty, stringProperty);
}
}
@Singleton
@Provides
public MyService provideMyService(
ConfigurationFactory configFactory,
SomeOtherService service) {
return configFactory.config(MyFactory.class, "my").createMyService(service);
}
Also, here instead of injecting SomeOtherService
into the factory, we injected it in the provider method. This is just another flavor. This is not specific to factories of course.
All configuration-aware objects in Bootique standard modules are factories. We think this is the best pattern, and we recommend it to everyone, as it separates the actual application object from its configuration and factory code. This results in more "compact" and fully immutable objects. Though of course, the choice is ultimately left to the user. |
2.3.3. Configuration Loading
Configuration is multi-layered and can come from different sources:
-
Config files (defined in the module code or passed on the command line)
-
Java properties
-
Environment variables
-
Cloud secrets managers
-
Custom configuration loaders
The load sequence of the sources is roughly this: (1) config files from modules → (2) config files from command line → (3) properties → (4) env variables. The sources with the higher load order override values from the sources with the lower order.
2.3.4. Configuration Files from Modules
One way to pass a config file to a Bootique app in the code.
BQCoreModule.extend(binder).addConfig("classpath:com/foo/default.yml");
Such configuration should be known at compile time and is usually embedded in the app, and referenced via the special classpath:…
URL. A primary motivation for this style is to provide application default configuration. More than one configuration can be contributed. Individual modules might load their own defaults independently of each other. The order of multiple configs loaded via this mechanism is undefined and should not be relied upon.
2.3.5. Configuration Files from CLI
Config files can be specified on the command line with the --config
option. It takes a file (or a URL) as a parameter. You can use --config
multiple times to specify more than one file. The files will be loaded in the order of their appearance on the command line.
2.3.6. Configuration Properties
YAML file can be thought of as a set of nested properties. E.g. the following config
my:
prop1: val1
prop2: val2
can be represented as two properties (my.prop1
, my.prop2
) that are assigned some values. Bootique takes advantage of this structural equivalence and allows to defined individual configuration values via Java properties. All configuration properties are prefixed with bq.
. This "namespace" helps to avoid random naming conflicts with properties otherwise present in the system.
Properties can be provided via BQCoreModule
extender:
class MyModule implements BQModule {
public void configure(Binder binder) {
BQCoreModule.extend(binder)
.setProperty("bq.my.prop1", "valX")
.setProperty("bq.my.prop2", "valY");
}
}
or come from system properties:
java -Dbq.my.prop1=valX -Dbq.my.prop2=valY -jar myapp.jar
According to the Bootique authors' opinion, properties is the worst mechanism for app configuration, as there are so many better options. However, there is a common valid use case for them - building configurations dynamically in the code. |
2.3.7. Configuration Environment Variables
Bootique allows to use environment variables to specify/override configuration values. Variables work similar to JVM properties, but have a number of advantages:
-
Provide customized application environment without changing the launch script
-
A natural approach for CI/CD, container and cloud environments
-
Good for credentials. Unlike YAML, vars usually don’t end up in version control, and unlike Java properties, they are not visible in the process list
-
Appear explicitly in the app help
To declare variables associated with configuration values, use the following API (notice that no bq.
prefix is necessary here to identify the configuration value):
class MyModule implements BQModule {
public void configure(Binder binder) {
BQCoreModule.extend(binder)
.declareVar("my.prop1", "P1")
.declareVar("my.prop2", "P2");
}
}
So now a person running the app may set the above configuration as
export P1=valX
export P2=valY
Moreover, explicitly declared vars will automatically appear in the application help, assisting the admins in configuring your app
$ java -jar myapp-1.0.jar --help
...
ENVIRONMENT
P1
Sets value of some property.
P2
Sets value of some other property.
2.3.8. CLI Configuration Aliases
It is possible to make fixed configuration inclusion conditional on the presence of a certain command line option:
OptionMetadata o = OptionMetadata.builder("qa")
.description("when present, uses QA config")
.build();
BQCoreModule.extend(binder)
.addOption(o)
.mapConfigResource(o.getName(), "classpath:a/b/qa.yml");
Also, it is possible to link a single configuration value to a named CLI option:
OptionMetadata o1 = OptionMetadata.builder("db")
.description("specifies database URL")
.valueOptionalWithDefault("jdbc:mysql://127.0.0.1:3306/mydb")
.build();
BQCoreModule.extend(binder)
.addOption(o1)
.mapConfigPath(o1.getName(), "jdbc.mydb.url");
This adds a new --db
option to the app that can be used to set JDBC URL of a datasource called "mydb". If value is not specified, the default one will be used.
2.3.9. Polymorphic Configuration Objects
A powerful feature of Jackson is the ability to dynamically create subclasses of the configuration objects. Bootique takes full advantage of this. E.g. imagine a logging module that needs "appenders" to output its log messages (file appender, console appender, syslog appender, etc.). The framework might not be aware of all possible appenders its users might come up with in the future. Yet it still wants to have the ability to instantiate any of them, based solely on the data coming from YAML. Moreover, each appender will have its own set of incompatible configuration properties. In fact this is exactly the situation with bootique-logback
module.
Here is how you ensure that such a polymorphic configuration is possible. Let’s start with a simple class hierarchy:
public abstract class BaseType {
// ...
}
public class ConcreteType1 extends BaseType {
// ...
}
public class ConcreteType2 extends BaseType {
// ...
}
Now let’s create a matching set of factories to create one of the concrete subtypes of BaseType
. Let’s use Jackson annotations to link specific types of symbolic names to be used in YAML below:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME,
property = "type",
defaultImpl = ConcreteTypeFactory1.class)
public abstract class BaseTypeFactory implements PolymorphicConfiguration {
public abstract BaseType create();
}
@JsonTypeName("type1")
public class ConcreteTypeFactory1 extends BaseTypeFactory {
@Override
public BaseType create() {
return new ConcreteType1();
}
}
@JsonTypeName("type2")
public class ConcreteTypeFactory2 extends BaseTypeFactory {
@Override
public BaseType create() {
return new ConcreteType2();
}
}
After that we need to create a service provider file called META-INF/service/io.bootique.config.PolymorphicConfiguration
where all the types participating in the hierarchy are listed (including the supertype):
com.foo.BaseTypeFactory
com.foo.ConcreteTypeFactory1
com.foo.ConcreteTypeFactory2
This should be enough to work with configuration like this:
my:
type: type2
someVar: someVal
The service of BaseType
is bound in Bootique DI using the standard ConfigurationFactory
approach described above. Depending on the YAML config, one of the subclasses of BaseType
will be created:
@Provides
public BaseType provideBaseType(ConfigurationFactory configFactory) {
return configFactory
.config(BaseTypeFactory.class, "my")
.create();
}
If another module decides to create yet another subclass of BaseType, it will need to create its own META-INF/service/io.bootique.config.PolymorphicConfiguration
file and add a new factory name there.
2.4. Commands
Bootique runtime contains a set of commands coming from Bootique core and from all the modules currently in effect in the app. On startup Bootique attempts to map command-line arguments to a single command type. If no match is found, a default command is executed (which is normally a "help" command). To list all available commands, the app can be run with --help
option (in most cases running without any options will have the same effect). E.g.:
$ java -jar myapp-1.0.jar --help
NAME
com.foo.MyApp
OPTIONS
-c yaml_location, --config=yaml_location
Specifies YAML config location, which can be a file path or a URL.
-h, --help
Prints this message.
-H, --help-config
Prints information about application modules and their configuration
options.
-s, --server
Starts Jetty server.
2.4.1. Writing Commands
Most common commands are already available in various standard modules, still often you’d need to write your own. To do that, first create a command class. It should implement io.bootique.command.Command
interface, though usually it more practical to extend io.bootique.command.CommandWithMetadata
and provide some metadata used in help and elsewhere:
public class MyCommand extends CommandWithMetadata {
private static CommandMetadata createMetadata() {
return CommandMetadata.builder(MyCommand.class)
.description("My command does something important.")
.build();
}
public MyCommand() {
super(createMetadata());
}
@Override
public CommandOutcome run(Cli cli) {
// ... run the command here....
return CommandOutcome.succeeded();
}
}
The command initializes metadata in constructor and implements the "run" method to run its code. The return CommandOutcome object instructs Bootique what to do when the command finishes. The object contains desired system exit code, and exceptions that occurred during execution. To make the new command available to Bootique, add it to `BQCoreModule’s extender, as was already shown above:
public class MyModule implements BQModule {
@Override
public void configure(Binder binder) {
BQCoreModule.extend(binder).addCommand(MyCommand.class);
}
}
To implement a "daemon" command running forever until it receives an OS signal (e.g. a web server waiting for user requests) , do something like this:
@Override
public CommandOutcome run(Cli cli) {
// ... start some process in a different thread ....
// now wait till the app is stopped from another thread
// or the JVM is terminated
try {
Thread.currentThread().join();
} catch (InterruptedException e) {
// ignore exception or log if needed
}
return CommandOutcome.succeeded();
}
2.4.2. Injection in Commands
Commands can inject services, just like most other classes in Bootique. There are some specifics though. Since commands are sometimes instantiated, but not executed (e.g. when --help
is run that lists all commands), it is often desirable to avoid immediate instantiation of all dependencies of a given command. So a common pattern with commands is to inject javax.inject.Provider
instead of direct dependency:
@Inject
private Provider<SomeService> provider;
@Override
public CommandOutcome run(Cli cli) {
provider.get().someMethod();
}
2.4.3. Decorating Commands
Each command typically does a single well-defined thing, such as starting a web server, executing a job, etc. But very often in addition to that main thing you need to do other things. E.g. when a web server is started, you might also want to run a few more commands:
-
Before starting the server, run a health check to verify that any external services the app might depend upon are alive.
-
Start a job scheduler in the background.
-
Start a monitoring "heartbeat" thread.
To run all these "secondary" commands when the main command is invoked, Bootique provides command decorator API. First you create a decorator policy object that specifies one or more secondary commands and their invocation strategy (either before the main command, or in parallel with it). Second you "decorate" the main command with that policy:
CommandDecorator extraCommands = CommandDecorator
.beforeRun(CustomHealthcheckCommand.class)
.alsoRun(ScheduleCommand.class)
.alsoRun(HeartbeatCommand.class);
BQCoreModule.extend(binder).decorateCommand(ServerCommand.class, extraCommands);
Based on the specified policy Bootique figures out the sequence of execution and runs the main and the secondary commands.
2.5. Options
2.5.1. Simple Options
In addition to commands, the app can define "options". Options are not associated with any runnable java code, and simply pass command-line values to commands and services. E.g. the standard “--config” option is used to locate configuration file(s). Unrecognized options cause application startup errors. To be recognized, options need to be "contributed" to Bootique similar to commands:
OptionMetadata option = OptionMetadata
.builder("email", "An admin email address")
.valueRequired("email_address")
.build();
BQCoreModule.extend(binder).addOption(option);
To read a value of the option, a service should inject io.bootique.cli.Cli
object (commands also get this object as a parameter to "run") :
@Inject
private Cli cli;
public void doSomething() {
Collection<String> emails = cli.optionStrings("email");
// do something with option values....
}
2.5.2. Configuration Options
While you can process your own options as described above, options often are just aliases to enable certain pieces of configuration. Bootique supports three flavors of associating options with configuration. Let’s demonstrate them here.
-
Option value sets a config property:
// Starting the app with "--my-opt=x" will set "jobs.myjob.param" value to "x" BQCoreModule.extend(binder) .addOption(OptionMetadata.builder("my-opt").build()) .mapConfigPath("my-opt", "jobs.myjob.param");
-
Option presence sets a property to a predefined value:
// Starting the app with "--my-opt" will set "jobs.myjob.param" value to "y" BQCoreModule.extend(binder) .addOption(OptionMetadata.builder("my-opt").valueOptionalWithDefault("y").build()) .mapConfigPath("my-opt", "jobs.myjob.param");
-
Option presence loads a config resource, such as a YAML file:
// Starting the app with "--my-opt" is equivalent to starting with "--config=classpath:xyz.yml" BQCoreModule.extend(binder) .addOption(OptionMetadata.builder("my-opt").build()) .mapConfigResource("my-opt", "classpath:xyz.yml");
The order of config-bound options on the command line is significant, just as the order of “--config” parameters. Bootique merges configuration associated with options from left to right, overriding any preceding configuration if there is an overlap.
2.6. Logging
2.6.1. Loggers in the Code
Standard Bootique modules use SLF4J internally, as it is the most convenient least common denominator framework, and can be easily bridged to other logging implementations. Your apps or modules are not required to use SLF4J, though if they do, it will likely reduce the amount of bridging needed to route all logs to a single destination.
2.6.2. Configurable Logging with Logback
For better control over logging a standard module called bootique-logback
is available, that integrates Logback framework in the app. It seamlessly bridges SLF4J (so you keep using SLF4J in the code), and allows to configure logging via YAML config file, including appenders (file, console, etc.) and per class/package log levels. Just like any other module, bootique-logback
can be enabled by simply adding it to the pom.xml dependencies, assuming autoLoadModules()
is in effect:
Maven
<dependency>
<groupId>io.bootique.logback</groupId>
<artifactId>bootique-logback</artifactId>
</dependency>
Gradle
{
implementation: 'io.bootique.logback:bootique-logback'
}
See bootique-logback
module documentation for further details.
2.6.3. BootLogger
To perform logging during startup, before DI environment is available and YAML configuration is processed, Bootique uses a special service called BootLogger
, that is not dependent on SLF4J and is not automatically bridged to Logback. It provides an abstraction for writing to stdout / stderr, as well as conditional "trace" logs sent to stderr. To enable Bootique trace logs, start the app with -Dbq.trace
as described in the deployment section.
BootLogger is injectable, in case your own code needs to use it. If the default BootLogger behavior is not satisfactory, it can be overridden right in the main(..)
method, as unlike other services, you may need to change it before DI is available:
public class Application {
public static void main(String[] args) {
Bootique.app(args).bootLogger(new MyBootLogger()).createRuntime().run();
}
}
3. Testing
3.1. Bootique and Testing
Bootique is uniquely suitable to be used as a test framework. Within a single test it allows you to start and stop multiple in-process runtimes, each with а distinct set of modules and configurations. Bootique test facilities are controlled with a small number of annotations: @BQTest
, @BQTestTool
and @BQApp
, that will be described below.
This chapter is about the core test framework. For module-specific test APIs (e.g. bootique-jdbc-junit5
, bootique-jetty-junit5
), check documentation of those modules. They all follow the same base principles described here.
To start using Bootique test extensions, import the following module in the "test" scope:
Maven
<dependency>
<groupId>io.bootique</groupId>
<artifactId>bootique-junit5</artifactId>
<scope>test</scope>
</dependency>
Gradle
{
testImplementation: 'io.bootique:bootique-junit5'
}
Each test class using Bootique extensions must be annotated with @BQTest
:
@BQTest
public class MyTest {
}
Doing that tells Bootique to manage lifecycle of test BQRuntimes
and test "tools".
3.2. Test BQRuntimes
BQRuntime
is an object representing an entire app (i.e. an object whose state and behavior you are checking in your test). You can declare BQRuntime
as a static field of the test class, initialize it the way you would initialize an application (yes, using the same factory method from Bootique
class), and annotate with @BQApp
to let Bootique start and stop it when appropriate:
@BQApp
final static BQRuntime app = Bootique
.app("--server", "--config", "classpath:test.yml")
.autoLoadModules()
.createRuntime();
The "app" will be started by Bootique before all the tests in this class, and will be shutdown after all the tests are finished. You can declare any number of such "apps" within a test to emulate fairly complex systems. And as you can see in the example, you can pass any command or configuration as arguments to the app(..)
method (that emulates a command-line invocation).
Sometimes you just need a runtime instance, and do not want to execute any commands. For this case use @BQApp(skipRun=true)
:
@BQApp(skipRun = true)
final static BQRuntime app = Bootique
.app("--config", "classpath:test.yml")
.autoLoadModules()
.createRuntime();
3.3. Test Tools and Scopes
Bootique provides a number of test tools that help you customize the apps for the test environment (e.g. TcDbTester
tool from bootique-jdbc-junit5-testcontainers
starts a test database and supplies a "module" to the app with DataSource configuration to access that DB; JettyTester
from bootique-jetty
configures HTTP connector to listen on a random unoccupied port and provides a Java client to send requests to the server, etc.).
Tools are declared as fields in a test class, each annotated with @BQTestTool
. A tool can exist in a certain scope (as defined by @BQTestTool(value=…)
. "Scope" determines when the tool is initialized and shutdown during the test. The following scopes are available:
-
BQTestScope.TEST_CLASS
: A scope of a single test class. Roughly corresponds to@BeforeAll
/@AfterAll
from JUnit. All tools in this scope must be declared as static variables. -
BQTestScope.TEST_METHOD
: A scope of a single test method. Roughly corresponds to@BeforeEach
/@AfterEach
from JUnit. -
BQTestScope.GLOBAL
: Allows a given tool to be shared with more than one test class. JUnit has no analog of a "global" scope, but it is quite useful for expensive reusable resources (e.g. Docker containers), so Bootique supports it for all the tools. -
BQTestScope.IMPLIED
: This is the default scope of@BQTestTool
. If the field is static it is equivalent toTEST_CLASS
, if non-static -TEST_METHOD
. This way most of the time you don’t need to explicitly define the scope, but just need to declare your tool as static or instance variable.
Some examples:
// implied scope is TEST_CLASS
@BQTestTool
final static MyTool tool1 = new MyTool();
// implied scope is TEST_METHOD
@BQTestTool
final MyTool tool2 = new MyTool();
// explicit GLOBAL scope. The variable must be static
@BQTestTool(value = BQTestScope.GLOBAL)
final static MyTool tool3 = new MyTool();
3.4. BQTestFactory Tool
A very common test tool is BQTestFactory
. It allows to create and run BQRuntimes
on the fly inside test class methods, and as such is a more flexible alternative to @BQApp
annotation:
@BQTestTool
final BQTestFactory testFactory = new BQTestFactory();
BQTestFactory.app()
does roughly the same thing as Bootique.app()
, with an important difference that all created runtimes will be automatically shut down at the end of the factory scope. E.g. in the following example testFactory
would shut down any runtimes created in each test method right after that method finishes (since testFactory
is an instance variable, the default scope is TEST_METHOD
):
@Test
public void abc() {
CommandOutcome result = testFactory.app("--server")
// ensure all classpath modules are included
.autoLoadModules()
// add an adhoc module specific to the test
.module(binder -> binder.bind(MyService.class).to(MyServiceImpl.class))
.run();
// ...
}
If you need the runtime instance to poke inside the app’s DI container, you can call createRuntime()
instead of run()
:
@Test
public void xyz() {
BQRuntime app = testFactory.app("--server")
.autoLoadModules()
.createRuntime();
// ...
}
BQRuntime
of course has its own run()
method, so you can both inspect the runtime and run a command.
3.5. Common Test Scenarios
Among the things that can be tested are runtime services with real dependencies, standard output of full Bootique applications (i.e. the stuff that would be printed to the console if this were a real app), network services using real network connections (e.g. your REST API’s), and so on. Some examples are given below, outlining the common techniques.
3.5.1. Testing Injectable Services
Services can be obtained from test runtime, their methods called, and assertions made about the results of the call:
@BQApp(skipRun = true)
static final BQRuntime app = Bootique
.app("--config=src/test/resources/my.yml")
.createRuntime();
@Test
public void service() {
MyService service = app.getInstance(MyService.class);
assertEquals("xyz", service.someMethod());
}
3.5.2. Testing Network Services
If a test command starts a web server or some other network service, it can be accessed via a URL right after running the server. E.g.:
// a tool from "bootique-jetty-junit5". Doesn't require @BQTestTool,
// as it has no lifecycle of its own
static final JettyTester jetty = JettyTester.create();
@BQApp
static final BQRuntime app = Bootique
.app("--server")
.module(jetty.moduleReplacingConnectors())
.createRuntime();
@Test
public void server() {
Response response = jetty.getTarget().path("/somepath").request().get();
JettyTester.assertOk(response);
}
3.5.3. Testing Commands
You can run the app in the test and check the values of the exit code and stdin
and stderr
contents:
@Test
public void command() {
TestIO io = TestIO.noTrace();
CommandOutcome outcome = testFactory
.app("--help")
.bootLogger(io.getBootLogger())
.run();
assertEquals(0, outcome.getExitCode());
assertTrue(io.getStdout().contains("--help"));
assertTrue(io.getStdout().contains("--config"));
}
3.5.4. Testing Module Validity
When you are writing your own modules, you may want to check that they are configured properly for autoloading (i.e. META-INF/services/io.bootique.BQModule
is present in the expected place and contains the right provider), and their configuration is consistent. There’s a helper class to check for it:
@Test
public void autoLoadable() {
BQModuleTester.of(MyModule.class)
.testAutoLoadable()
.testConfig();
}
4. Assembly and Deployment
This chapter discusses how to package Bootique apps for deployment/distribution and how to run them. We are going to present two approaches that produce cross-platform runnable applications - "Runnable Jar with Dependencies" and "Runnable Jar with "lib" Folder". They only differ in how dependencies are packaged and referenced.
Ultimately any Bootique app is just a Java app with the main(..)
method and hence can be executed using java
command. With that understanding you can come up with your own custom packaging strategies.
4.1. Runnable Jar with Dependencies
Under this approach application classes, resources and all classes and resources from dependency jars are packaged in a single "fat" runnable jar. In Maven this can be accomplished with maven-shade-plugin
:
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.3</version>
<configuration>
<createDependencyReducedPom>true</createDependencyReducedPom>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${main.class}</mainClass>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.ApacheLicenseResourceTransformer"/>
<transformer implementation="org.apache.maven.plugins.shade.resource.ApacheNoticeResourceTransformer"/>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
</plugin>
</plugins>
</build>
Make sure you set the main.class
property in the POM to the appropriate class value from your application or to io.bootique.Bootique
:
<properties>
<main.class>com.foo.Application</main.class>
</properties>
Once the pom is configured, you can assemble and run the jar. E.g.:
mvn clean package
java -jar target/myapp-1.0.jar
4.2. Runnable Jar with "lib" Folder
"Jar-with-dependencies" packaging described above is extremely convenient. It produces a single file that is easy to move around and execute. It is not without downsides though:
-
It is incompatible with Java Platform Module System (JPMS). Java allows only one
module-info.class
file per.jar
. So if your app or its dependencies contain one or more of those module descriptors,maven-shade-plugin
won’t be able to package them properly. -
It is incompatible with multi-release jar files. Actually there’s no technical reason why
maven-shade-plugin
can’t repackage such jars correctly, but as of this writing (plugin version 3.2.1) it doesn’t, losing Java version-specific code.
An alternative way of packaging that does not have these limitations is a folder with a runnable application jar at the root level and all dependency jars in the lib/
folder next to it:
my-app-1.0/
# Runnable jar with classpath in MANIFEST.MF referencing "lib/*"
my-app-1.0.jar
# Dependencies folder
lib/
bootique-X.X.jar
slf4j-api-1.7.25.jar
...
This folder is usually archived into a single .tar.gz
or .zip
file. It would then be unpacked on the machine where the application needs to run.
Creating such packaging with Maven involves maven-jar-plugin
, maven-dependency-plugin
and maven-assembly-plugin
. First let’s create the folder structure:
<properties>
<main.class>com.foo.Application</main.class>
</properties>
...
<build>
<plugins>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<mainClass>${main.class}</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
<useUniqueVersions>false</useUniqueVersions>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<configuration>
<includeScope>runtime</includeScope>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
</configuration>
<executions>
<execution>
<id>assembly</id>
<phase>prepare-package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
The above configuration places both main jar and "lib/" folder under target/
, so you can build and run the app like this:
$ mvn clean package
$ java -jar target/myapp-1.0.jar
To prepare the app for distribution as a single archive, you will need to add an assembly step. Start by creating an assembly.xml
descriptor file:
<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.0.0 https://maven.apache.org/xsd/assembly-2.0.0.xsd">
<id>tar.gz</id>
<formats>
<format>tar.gz</format>
</formats>
<fileSets>
<fileSet>
<directory>${project.build.directory}</directory>
<useDefaultExcludes>true</useDefaultExcludes>
<outputDirectory>./</outputDirectory>
<includes>
<include>${project.artifactId}-${project.version}.jar</include>
<include>lib/</include>
</includes>
</fileSet>
</fileSets>
</assembly>
Now configure maven-assembly-plugin
:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.1</version>
<configuration>
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<descriptor>assembly.xml</descriptor>
</descriptors>
<tarLongFileMode>posix</tarLongFileMode>
</configuration>
<executions>
<execution>
<id>assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
After you rerun packaging again, you should see my-app-1.0.tar.gz
file in the target
folder. This file can be sent to the end users or copied to your servers and unpacked there:
$ mvn clean package
$ ls target/*.tar.gz
my-app-1.0.tar.gz
An extra benefit of such packaging is that you can include any additional files with your application distro, such as installation instructions, custom startup scripts, licenses, etc. All of this is configured in assembly.xml . |
4.3. Tracing Bootique Startup
To see what modules are loaded, to view full app configuration tree and to trace other events that happen on startup, run your app with -Dbq.trace
option. E.g.:
$ java -Dbq.trace -jar target/myapp-1.0.jar --server
You may see an output like this:
Skipping module 'JerseyModule' provided by 'JerseyModuleProvider' (already provided by 'Bootique')...
Adding module 'BQCoreModule' provided by 'Bootique'...
Adding module 'JerseyModule' provided by 'Bootique'...
Adding module 'JettyModule' provided by 'JettyModuleProvider'...
Adding module 'LogbackModule' provided by 'LogbackModuleProvider'...
Merged configuration: {"log":{"logFormat":"[%d{\"dd/MMM/yyyy:HH:mm:ss,SSS\"}]
%t %p %X{txid:-?} %X{principal:-?} %c{1}: %m%n%ex"},"trace":""}
Printing configuration may expose sensitive information, like database passwords, etc. Make sure you use -Dbq.trace for debugging only and don’t leave it on permanently in a deployment environment. |