Consul and Micronaut - Microservices service discovery
Use Consul service discovery to expose your Micronaut apps.
Authors: Sergio del Amo
Micronaut Version: 2.5.0
1. Getting Started
In this guide, we are going to create three microservices and register them with Consul Service discovery.
Consul is a distributed service mesh to connect, secure, and configure services across any runtime platform and public or private cloud
You will discover how Micronaut eases Consul integration.
2. What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE
-
JDK 1.8 or greater installed with
JAVA_HOME
configured appropriately
3. Solution
We recommend that you follow the instructions in the next sections and create the app step by step. However, you can go right to the completed example.
-
Download and unzip the source
4. Writing the App
Let’s describe the microservices you are going to build through the tutorial.
-
bookcatalogue
- It returns a list of books. It uses a domain consisting of a book name and isbn. -
bookinventory
- It exposes an endpoint to check whether a book has sufficient stock to fulfil an order. It uses a domain consisting of a stock level and isbn. -
bookrecommendation
- It consumes previous services and exposes an endpoint which recommends book names which are in stock.
Initially we are going to hardcode the addresses where the different services are in the bookcatalogue
service.
As shown in the previous image, the bookcatalogue
hardcodes references to its collaborators.
In the second part of this tutorial we are going to use a discovery service.
About registration patterns We will use a self‑registration pattern. Thus, each service instance is responsible for registering and deregistering itself with the service registry. Also, if required, a service instance sends heartbeat requests to prevent its registration from expiring. |
Services register when they start up:
We will use client‑side service discovery, clients query the service registry, select an available instance, and make a request.
If you are using Java or Kotlin and IntelliJ IDEA, make sure you have enabled annotation processing.
4.1. Catalogue Microservice
Create the bookcatalogue
microservice:
mn create-app example.micronaut.bookcatalogue --build=gradle --lang=java
The previous command creates a folder named bookcatalogue
and a Micronaut app inside it.
Create a BooksController
class to handle incoming HTTP requests into the bookcatalogue
microservice:
package example.micronaut;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import java.util.Arrays;
import java.util.List;
@Controller("/books") (1)
public class BooksController {
@Get (2)
public List<Book> index() {
Book buildingMicroservices = new Book("1491950358", "Building Microservices");
Book releaseIt = new Book("1680502395", "Release It!");
Book cidelivery = new Book("0321601912", "Continuous Delivery:");
return Arrays.asList(buildingMicroservices, releaseIt, cidelivery);
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /books |
2 | The @Get annotation is used to map the index method to an HTTP GET request on /books . |
The previous controller responds a List<Book>
. Create the Book
POJO:
package example.micronaut;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import javax.validation.constraints.NotBlank;
@Introspected
public class Book {
@NonNull
@NotBlank
private String isbn;
@NonNull
@NotBlank
private String name;
public Book() {}
public Book(@NonNull @NotBlank String isbn, @NonNull @NotBlank String name) {
this.isbn = isbn;
this.name = name;
}
@NonNull
public String getIsbn() {
return isbn;
}
public void setIsbn(@NonNull String isbn) {
this.isbn = isbn;
}
@NonNull
public String getName() {
return name;
}
public void setName(@NonNull String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Book book = (Book) o;
if (!isbn.equals(book.isbn)) return false;
return name.equals(book.name);
}
@Override
public int hashCode() {
int result = isbn.hashCode();
result = 31 * result + name.hashCode();
return result;
}
}
Write a test:
package example.micronaut;
import io.micronaut.core.type.Argument;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Test;
import javax.inject.Inject;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@MicronautTest (1)
public class BooksControllerTest {
@Inject
@Client("/")
HttpClient client; (2)
@Test
public void testRetrieveBooks() {
HttpRequest request = HttpRequest.GET("/books"); (3)
List books = client.toBlocking().retrieve(request, Argument.listOf(Book.class)); (4)
assertEquals(3, books.size());
assertTrue(books.contains(new Book("1491950358", "Building Microservices")));
assertTrue(books.contains(new Book("1680502395", "Release It!")));
}
}
1 | Annotate the class with @MicronautTest to let Micronaut starts the embedded server and inject the beans. More info: https://micronaut-projects.github.io/micronaut-test/latest/guide/index.html. |
2 | Inject the HttpClient bean in the application context. |
3 | It is easy to create HTTP requests with a fluid API. |
4 | Parse easily JSON into Java objects. |
Edit application.yml
micronaut:
application:
name: bookcatalogue (1)
server:
port: 8081 (2)
1 | Configure the application name. The app name will be use by the discovery service. |
2 | Configure the app to listen at port 8081 |
Create a file named application-test.yml
which is used in the test environment:
micronaut:
server:
port: -1 (1)
1 | Start the micronaut microservice at a random port when running the tests. |
Run the unit test:
bookcatalogue $ ./gradlew test
4.2. Inventory Microservice
Create the bookinventory
microservice:
mn create-app example.micronaut.bookinventory --build=gradle --lang=java
The previous command creates a folder named bookinventory
and a Micronaut app inside it.
Create a Controller:
package example.micronaut;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Produces;
import javax.validation.constraints.NotBlank;
import java.util.Optional;
@Controller("/books") (1)
public class BooksController {
@Produces(MediaType.TEXT_PLAIN) (2)
@Get("/stock/{isbn}") (3)
public Boolean stock(@NotBlank String isbn) { (1)
return bookInventoryByIsbn(isbn).map(bi -> bi.getStock() > 0).orElse(null);
}
private Optional<BookInventory> bookInventoryByIsbn(String isbn) {
if (isbn.equals("1491950358")) {
return Optional.of(new BookInventory(isbn, 4));
} else if (isbn.equals("1680502395")) {
return Optional.of(new BookInventory(isbn, 0));
}
return Optional.empty();
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /books |
2 | By default, Content-Type of Micronaut’s response is application/json : override this with text/plain since we are returning a String, not a JSON object. |
3 | The @Get annotation is used to map the index method to an HTTP GET request on /books/stock/{isbn} . |
Create the POJO used by the controller:
package example.micronaut;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.Objects;
@Introspected
public class BookInventory {
@NonNull
@NotBlank
private String isbn;
@NonNull
@NotNull
private Integer stock;
public BookInventory() {}
public BookInventory(@NonNull @NotBlank String isbn, @NonNull @NotNull Integer stock) {
this.isbn = isbn;
this.stock = stock;
}
@NonNull
public String getIsbn() {
return isbn;
}
public void setIsbn(@NonNull String isbn) {
this.isbn = isbn;
}
@NonNull
public Integer getStock() {
return stock;
}
public void setStock(@NonNull Integer stock) {
this.stock = stock;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BookInventory that = (BookInventory) o;
if (!isbn.equals(that.isbn)) return false;
return stock.equals(that.stock);
}
@Override
public int hashCode() {
int result = isbn.hashCode();
result = 31 * result + stock.hashCode();
return result;
}
}
Write a test:
package example.micronaut;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.RxHttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Test;
import javax.inject.Inject;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@MicronautTest
public class BooksControllerTest {
@Inject
@Client("/")
RxHttpClient rxHttpClient;
@Test
public void testBooksController() {
HttpResponse<Boolean> rsp = rxHttpClient.toBlocking().exchange(HttpRequest.GET("/books/stock/1491950358"), Boolean.class);
assertEquals(rsp.status(), HttpStatus.OK);
assertTrue(rsp.body());
}
@Test
public void testBooksControllerWithNonExistingIsbn() {
HttpClientResponseException thrown = assertThrows(HttpClientResponseException.class, () -> {
rxHttpClient.toBlocking().exchange(HttpRequest.GET("/books/stock/XXXXX"), Boolean.class);
});
assertEquals(
HttpStatus.NOT_FOUND,
thrown.getResponse().getStatus()
);
}
}
Edit application.yml
micronaut:
application:
name: bookinventory (1)
server:
port: 8082 (2)
1 | Configure the application name. The app name will be used later in the tutorial. |
2 | Configure the app to listen at port 8082 |
Create a file named application-test.yml
which is used in the test environment:
micronaut:
server:
port: -1 (1)
1 | Start the micronaut microservice at a random port when running the tests. |
Run the unit test:
bookinventory $ ./gradlew test
4.3. Recommendation Microservice
Create the bookrecommendation
microservice:
mn create-app example.micronaut.bookrecommendation --build=gradle --lang=java
The previous command creates a folder named bookrecommendation
and a Micronaut app inside it.
Create an interface to map operations with bookcatalogue
, and a Micronaut Declarative HTTP Client to consume it.
package example.micronaut;
import io.reactivex.Flowable;
public interface BookCatalogueOperations {
Flowable<Book> findAll();
}
package example.micronaut;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.retry.annotation.Recoverable;
import io.reactivex.Flowable;
@Client("http://localhost:8081") (1)
@Recoverable(api = BookCatalogueOperations.class)
interface BookCatalogueClient extends BookCatalogueOperations {
@Get("/books")
Flowable<Book> findAll();
}
1 | Use @Client to use declarative HTTP Clients |
The client returns a POJO. Create it in the bookrecommendation
:
package example.micronaut;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import javax.validation.constraints.NotBlank;
import java.util.Objects;
@Introspected
public class Book {
@NonNull
@NotBlank
private String isbn;
@NonNull
@NotBlank
private String name;
public Book() {}
public Book(@NonNull @NotBlank String isbn, @NonNull @NotBlank String name) {
this.isbn = isbn;
this.name = name;
}
@NonNull
public String getIsbn() {
return isbn;
}
public void setIsbn(@NonNull String isbn) {
this.isbn = isbn;
}
@NonNull
public String getName() {
return name;
}
public void setName(@NonNull String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Book book = (Book) o;
if (!isbn.equals(book.isbn)) return false;
return name.equals(book.name);
}
@Override
public int hashCode() {
int result = isbn.hashCode();
result = 31 * result + name.hashCode();
return result;
}
}
Create an interface to map operations with bookinventory
, and a Micronaut Declarative HTTP Client to consume it.
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import io.reactivex.Maybe;
import javax.validation.constraints.NotBlank;
public interface BookInventoryOperations {
Maybe<Boolean> stock(@NonNull @NotBlank String isbn);
}
package example.micronaut;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.retry.annotation.Recoverable;
import io.reactivex.Flowable;
@Client("http://localhost:8081") (1)
@Recoverable(api = BookCatalogueOperations.class)
interface BookCatalogueClient extends BookCatalogueOperations {
@Get("/books")
Flowable<Book> findAll();
}
1 | Use @Client to use declarative HTTP Clients |
Create a Controller which injects both clients.
package example.micronaut;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.reactivex.Flowable;
@Controller("/books") (1)
public class BookController {
private final BookCatalogueOperations bookCatalogueOperations;
private final BookInventoryOperations bookInventoryOperations;
public BookController(BookCatalogueOperations bookCatalogueOperations,
BookInventoryOperations bookInventoryOperations) { (2)
this.bookCatalogueOperations = bookCatalogueOperations;
this.bookInventoryOperations = bookInventoryOperations;
}
@Get (3)
public Flowable<BookRecommendation> index() {
return bookCatalogueOperations.findAll()
.flatMapMaybe(b -> bookInventoryOperations.stock(b.getIsbn())
.filter(Boolean::booleanValue)
.map(rsp -> b)
).map(book -> new BookRecommendation(book.getName()));
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /books |
2 | Clients are injected via constructor injection |
3 | The @Get annotation is used to map the index method to an HTTP GET request on /books . |
The previous controller returns a Flowable<BookRecommendation>
. Create the BookRecommendation
POJO:
package example.micronaut;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import javax.validation.constraints.NotBlank;
@Introspected
public class BookRecommendation {
@NonNull
@NotBlank
private String name;
public BookRecommendation() {}
public BookRecommendation(@NonNull @NotBlank String name) {
this.name = name;
}
@NonNull
public String getName() {
return name;
}
public void setName(@NonNull String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BookRecommendation that = (BookRecommendation) o;
return name.equals(that.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
}
BookCatalogueClient
and BookInventoryClient
will fail to consume the bookcatalogue
and bookinventory
during the tests phase.
Using the @Fallback annotation you can declare a fallback implementation of a client that will be picked up and used once all possible retries have been exhausted
Create @Fallback
alternatives in the test
classpath.
package example.micronaut;
import io.micronaut.context.annotation.Requires;
import io.micronaut.context.env.Environment;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.retry.annotation.Fallback;
import io.reactivex.Maybe;
import javax.inject.Singleton;
import javax.validation.constraints.NotBlank;
@Requires(env = Environment.TEST) (1)
@Fallback
@Singleton
public class BookInventoryClientStub implements BookInventoryOperations {
@Override
public Maybe<Boolean> stock(@NonNull @NotBlank String isbn) {
if (isbn.equals("1491950358")) {
return Maybe.just(Boolean.TRUE); (2)
} else if (isbn.equals("1680502395")) {
return Maybe.just(Boolean.FALSE); (3)
}
return Maybe.empty(); (4)
}
}
1 | Make this fallback class to be effective only when the micronaut environment TEST is active |
2 | Here we arbitrarily decided that if everything else fails, that book’s stock would be true |
3 | Similarly, we decided that other book’s stock method would be false |
4 | Finally, any other book will have their stock method return an empty value |
package example.micronaut;
import io.micronaut.context.annotation.Requires;
import io.micronaut.context.env.Environment;
import io.micronaut.retry.annotation.Fallback;
import io.reactivex.Flowable;
import javax.inject.Singleton;
@Requires(env = Environment.TEST)
@Fallback
@Singleton
public class BookCatalogueClientStub implements BookCatalogueOperations {
@Override
public Flowable<Book> findAll() {
Book buildingMicroservices = new Book("1491950358", "Building Microservices");
Book releaseIt = new Book("1680502395", "Release It!");
return Flowable.just(buildingMicroservices, releaseIt);
}
}
Write a test:
package example.micronaut;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.RxStreamingHttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.reactivex.Flowable;
import org.junit.jupiter.api.Test;
import javax.inject.Inject;
import static org.junit.jupiter.api.Assertions.assertEquals;
@MicronautTest
public class BookControllerTest {
@Inject
@Client("/")
RxStreamingHttpClient client;
@Test
public void testRetrieveBooks() {
Flowable<BookRecommendation> books = client.jsonStream(HttpRequest.GET("/books"), BookRecommendation.class);
assertEquals(books.toList().blockingGet().size(), 1);
assertEquals(books.toList().blockingGet().get(0).getName(), "Building Microservices");
}
}
Edit application.yml
micronaut:
application:
name: bookrecommendation (1)
server:
port: 8080 (2)
1 | Configure the application name. The app name will be used later in the tutorial. |
2 | Configure the app to listen at port 8080 |
Create a file named application-test.yml
which is used in the test environment:
micronaut:
server:
port: -1 (1)
1 | Start the micronaut microservice at a random port when running the tests. |
Run the unit test:
bookinventory $ ./gradlew test
4.4. Running the app
Run bookcatalogue
microservice:
To run the application execute ./gradlew run
.
...
14:28:34.034 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 499ms. Server Running: http://localhost:8081
Run bookinventory
microservice:
To run the application execute ./gradlew run
.
...
14:31:13.104 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 506ms. Server Running: http://localhost:8082
Run bookrecommendation
microservice:
To run the application execute ./gradlew run
.
...
14:31:57.389 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 523ms. Server Running: http://localhost:8080
You can run a cURL command to test the whole application:
$ curl http://localhost:8080/books
[{"name":"Building Microservices"}]
5. Consul and Micronaut
5.1. Install Consul via Docker
The quickest way to start using Consul is via Docker:
$ docker run -p 8500:8500 consul
Alternatively you can install and run a local Consul instance.
The following screenshots show how to install/run Consul via Kitematic; graphical user interface for Docker.
Configure ports:
5.2. Book Catalogue
Modify your build file to add the discovery-client
feature.
implementation("io.micronaut.discovery:micronaut-discovery-client")
Append to bookcatalogue
service application.yml
the following snippet:
consul:
client:
registration:
enabled: true
defaultZone: "${CONSUL_HOST:localhost}:${CONSUL_PORT:8500}"
This configuration registers a Micronaut app with Consul with minimal configuration. Discover a more complete list of Configuration options at ConsulConfiguration.
Disable consul registration in tests:
consul:
client:
registration:
enabled: false
5.3. Book Inventory
Modify your build file to add the discovery-client
feature.
implementation("io.micronaut.discovery:micronaut-discovery-client")
Also, modify the application.yml
of the bookinventory
application with the following snippet:
consul:
client:
registration:
enabled: true
defaultZone: "${CONSUL_HOST:localhost}:${CONSUL_PORT:8500}"
Disable consul registration in tests:
consul:
client:
registration:
enabled: false
5.4. Book Recommendation
Modify your build file to add the discovery-client
feature.
implementation("io.micronaut.discovery:micronaut-discovery-client")
Also, append to bookrecommendation
.application.yml
the following snippet:
consul:
client:
registration:
enabled: true
defaultZone: "${CONSUL_HOST:localhost}:${CONSUL_PORT:8500}"
Modify BookInventoryClient
and BookCatalogueClient
to use the service id instead of a harcoded ip.
package example.micronaut;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.retry.annotation.Recoverable;
import io.reactivex.Flowable;
@Client(id = "bookcatalogue") (1)
@Recoverable(api = BookCatalogueOperations.class)
interface BookCatalogueClient extends BookCatalogueOperations {
@Get("/books")
Flowable<Book> findAll();
}
1 | Use the configuration value micronaut.application.name used in bookcatalogue as service id . |
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Consumes;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.client.annotation.Client;
import io.reactivex.Maybe;
import io.micronaut.retry.annotation.Recoverable;
import javax.validation.constraints.NotBlank;
@Client(id = "bookinventory") (1)
@Recoverable(api = BookInventoryOperations.class)
interface BookInventoryClient extends BookInventoryOperations {
@Consumes(MediaType.TEXT_PLAIN)
@Get("/books/stock/{isbn}")
Maybe<Boolean> stock(@NonNull @NotBlank String isbn);
}
1 | Use the configuration value micronaut.application.name used in bookinventory as service id . |
Disable consul registration in tests:
consul:
client:
registration:
enabled: false
5.5. Running the App
Run bookcatalogue
microservice:
bookcatalogue $ ./gradlew run
...
14:28:34.034 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 499ms. Server Running: http://localhost:8081
14:28:34.084 [nioEventLoopGroup-1-3] INFO i.m.d.registration.AutoRegistration - Registered service [bookcatalogue] with Consul
Run bookinventory
microservice:
bookinventory $ ./gradlew run
...
14:31:13.104 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 506ms. Server Running: http://localhost:8082
14:31:13.154 [nioEventLoopGroup-1-3] INFO i.m.d.registration.AutoRegistration - Registered service [bookinventory] with Consul
Run bookrecommendation
microservice:
...
14:31:57.389 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 523ms. Server Running: http://localhost:8080
14:31:57.439 [nioEventLoopGroup-1-3] INFO i.m.d.registration.AutoRegistration - Registered service [bookrecommendation] with Consul
Consul comes with a HTML UI. Open http://localhost:8500/ui in your browser.
You will see the services registered in Consul:
You can run a cURL command to test the whole application:
$ curl http://localhost:8080/books
[{"name":"Building Microservices"}]
6. Generate a Micronaut app’s Native Image with GraalVM
We are going to use GraalVM, the polyglot embeddable virtual machine, to generate a Native image of our Micronaut application.
Native images compiled with GraalVM ahead-of-time improve the startup time and reduce the memory footprint of JVM-based applications.
Use of GraalVM’s native-image tool is only supported in Java or Kotlin projects. Groovy relies heavily on
reflection which is only partially supported by GraalVM.
|
6.1. Native Image generation
The easiest way to install GraalVM is to use SDKMan.io.
# For Java 8
$ sdk install java 21.1.0.r8-grl
# For Java 11
$ sdk install java 21.1.0.r11-grl
You need to install the native-image
component which is not installed by default.
$ gu install native-image
To generate a native image using Gradle run:
$ ./gradlew nativeImage
The native image will be created in build/native-image/application
and can be run with ./build/native-image/application
It is also possible to customize the name of the native image or pass additional parameters to GraalVM:
nativeImage {
args('--verbose')
imageName('mn-graalvm-application') (1)
}
1 | The native image name will now be mn-graalvm-application |
Start the native images for the three microservices and run the same curl
request as before to check that everything works with GraalVM.
7. Next steps
Read more about Consul support inside Micronaut.
8. Help with Micronaut
Object Computing, Inc. (OCI) sponsored the creation of this Guide. A variety of consulting and support services are available.