Micronaut Multitenancy Propagation

Learn how to Micronaut helps you implement Multitenancy in a set of Microservices.

Authors: Sergio del Amo

Micronaut Version: 1.0.0.RC2

1 Getting Started

Lets describe the microservices you are going to build through the tutorial.

  • gateway - A microservice which resolves a Tenant ID with a Cookie and propagates the Tenant ID to outgoing requests via HTTP Header.

  • books - A microservice which uses GORM to provide data access layer with multitenacy support.

The next diagram illustrates the flow:

tokenpropagation

1.1 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

1.2 Solution

We recommend you to follow the instructions in the next sections and create the app step by step. However, you can go right to the completed example.

or

Then, cd into the complete folder which you will find in the root project of the downloaded/cloned project.

2 Writing the App

We are going to write the app first without token propagation. Then we are going to configure token propagation and you will see how much code we can get rid of.

2.1 Gateway

Create the microservice:

mn create-app example.micronaut.gateway

2.1.1 Multitenancy Configuration

Multi-Tenancy, as it relates to software development, is when a single instance of an application is used to service multiple clients (tenants) in a way that each tenants' data is isolated from the other.

To use the Micronaut’s multitenancy capabilities you must have the multitenancy dependency on your classpath. For example in build.gradle:

gateway/build.gradle
    compile "io.micronaut:multitenancy"
gateway/src/main/resources/application.yml
micronaut:
...

    multitenancy:
        propagation:
            enabled: true (1)
            service-id-regex: 'books' (2)
        tenantresolver:
            cookie:
                enabled: true (3)
        tenantwriter:
            httpheader:
                enabled: true (4)
1 Enable tenant propagation.
2 Propagate the resolved tenant ID only in requests going to a particular set of services. In our example, we define a regex to match the service id books.
3 In the gateway we use the CookieTenantResolver which resolves the current tenant from an HTTP cookie.
4 We propagate the tenant with a HttpHeaderTenantWriter which writes the current tenant to a HTTP Header.

2.1.2 Http Server Filter

The Micronaut HTTP server supports the ability to apply filters to request/response processing in a similar, but reactive, way to Servlet filters in traditional Java applications.

Create a filter to redirect to /tenant if you attempt to access / without a cookie. That it is to say, in a situation where the application is not able to resolve the tenant ID.

gateway/src/main/java/example/micronaut/HomePageFilter.java
package example.micronaut;

import io.micronaut.core.async.publisher.Publishers;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.http.annotation.Filter;
import io.micronaut.http.filter.OncePerRequestHttpServerFilter;
import io.micronaut.http.filter.ServerFilterChain;
import io.micronaut.multitenancy.exceptions.TenantNotFoundException;
import io.micronaut.multitenancy.tenantresolver.CookieTenantResolverConfiguration;
import io.micronaut.multitenancy.tenantresolver.TenantResolver;
import org.reactivestreams.Publisher;

import java.net.URI;

@Filter("/") (1)
public class HomePageFilter extends OncePerRequestHttpServerFilter {

    private final TenantResolver tenantResolver;

    public HomePageFilter(TenantResolver tenantResolver) { (2)
        this.tenantResolver = tenantResolver;
    }

    @Override
    protected Publisher<MutableHttpResponse<?>> doFilterOnce(HttpRequest<?> request, ServerFilterChain chain) {
        try {
            tenantResolver.resolveTenantIdentifier();
        } catch (TenantNotFoundException e) {
            return Publishers.just(HttpResponse.seeOther(URI.create("/tenant")));
        }
        return chain.proceed(request);
    }
}
1 You can match only a subset of paths with a server filter.
2 Constructor injection.

2.1.3 Http Client

Create an interface to encapsulate the communication with the books microservice which we will create shortly.

gateway/src/main/java/example/micronaut/BookFetcher.java
package example.micronaut;

import io.reactivex.Single;

import java.util.List;

public interface BookFetcher {

    Single<List<String>> fetchBooks();
}
gateway/src/main/java/example/micronaut/BookClient.java
package example.micronaut;

import io.micronaut.context.annotation.Requires;
import io.micronaut.context.env.Environment;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.client.annotation.Client;
import io.reactivex.Single;

import java.util.List;

@Client("books") (1)
@Requires(notEnv = Environment.TEST) (2)
public interface BookClient extends BookFetcher {

    @Override
    @Get("/books") (3)
    Single<List<String>> fetchBooks();
}
1 The @Client annotation uses a service id which matches the regular expression we defined in the propagation configuration.
2 We don’t want to load this bean in the test environment.
3 We configure the path /books and HTTP method of the endpoint exposed by books.

Configure the urls for the service id books. Modify application.yml

gateway/src/main/resources/application.yml
micronaut:
...

    http:
        services:
            books: (1)
                urls:
                - "http://localhost:8081" (2)
1 Same id we used with @Client
2 url where the books service will reside.

2.1.4 Home Controller

The views module provides support for view rendering on the server side and does so by rendering views on the I/O thread pool in order to avoid blocking the Netty event loop. Add the views dependency to build.gradle:

gateway/build.gradle
    compile "io.micronaut:views"

Micronaut includes HandlebarsViewsRenderer which uses the Handlebars.java project.

In addition to the views dependency, add the following dependency to build.gradle

gateway/build.gradle
    runtime "com.github.jknack:handlebars:4.1.0"

Create a view which we will use in the HomeController.

gateway/src/main/resources/views/home.hbs
<!DOCTYPE html>
<html>
<head>
    <title>{{ pagetitle }}</title>
</head>
<body>
<ul>
{{#each books}}
    <li><span>{{ this }}</span></li>
{{/each}}
</ul>
<p><a href="/tenant">change tenant</a></p>
</body>
</html>

Create a HomeController which invokes BookClient::fetchBooks() and renders the books using the previous handlebar view.

gateway/src/main/java/example/micronaut/HomeController.java
package example.micronaut;

import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.views.View;
import io.reactivex.Single;

import java.util.HashMap;
import java.util.Map;

@Controller("/") (1)
public class HomeController {

    private final BookFetcher bookFetcher;

    public HomeController(BookFetcher bookFetcher) { (2)
        this.bookFetcher = bookFetcher;
    }

    @View("home") (3)
    @Get (4)
    Single<HttpResponse<Map<String, Object>>> index() { (5)
        return bookFetcher.fetchBooks().map(books -> {
            Map<String, Object> model = new HashMap<>();
            model.put("pagetitle", "Home");
            model.put("books", books);
            return HttpResponse.ok(model);
        });
    }

}
1 Annotate with io.micronaut.http.annotation.Controller to designate a class as a Micronaut’s controller.
2 Constructor dependency injection
3 Use @View annotation to indicate the view name which should be used to render a view for the route.
4 You can specify the HTTP verb that a controller’s action responds to. To respond to a GET request, use the io.micronaut.http.annotation.Get annotation.
5 A RxJava Single is returned with the value read from the server.

2.1.5 Cookie Redirect

HomePageFilter redirects to /tenant when the tenant ID is not resolved. Create TenantController to handle that endpoint:

gateway/src/main/java/example/micronaut/TenantController.java
package example.micronaut;

import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.cookie.Cookie;
import io.micronaut.multitenancy.tenantresolver.CookieTenantResolverConfiguration;
import io.micronaut.views.View;

import java.net.URI;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

@Controller("/tenant") (1)
public class TenantController {
    private final CookieTenantResolverConfiguration cookieTenantResolverConfiguration;

    public TenantController(CookieTenantResolverConfiguration cookieTenantResolverConfiguration) { (2)
        this.cookieTenantResolverConfiguration = cookieTenantResolverConfiguration;
    }

    @View("tenant") (3)
    @Get (4)
    public Map<String, Object> index() {
        Map<String, Object> model = new HashMap<>();
        model.put("pagetitle", "Tenants");
        model.put("tenants", Arrays.asList("sherlock", "watson"));
        return model;
    }

    @Get("/{tenant}") (5)
    public HttpResponse tenant(String tenant) {
        final String path = "/";
        final String cookieName = cookieTenantResolverConfiguration.getCookiename();
        return HttpResponse.status(HttpStatus.FOUND).headers((headers) ->
                headers.location(URI.create(path))
        ).cookie(Cookie.of(cookieName, tenant).path(path)); (6)
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /tenant.
2 Constructor injection of CookieTenantResolverConfiguration. A configuration object which is used by the CookieTenantResolver`
3 Use @View annotation to indicate the view name which should be used to render a view for the route.
4 You can specify the HTTP verb that a controller’s action responds to. To respond to a GET request, use the io.micronaut.http.annotation.Get annotation.
5 Define a path variable tenant.
6 Do a 302 redirect to / setting a cookie with the selected tenant ID.

The previous controller renders the tenant view.

gateway/src/main/resources/views/tenant.hbs
<!DOCTYPE html>
<html>
<head>
    <title>{{ pagetitle }}</title>
</head>
<body>
<ul>
{{#each tenants}}
    <li><a href="/tenant/{{ this }}">{{ this }}</a></li>
{{/each}}
</ul>

<p><a href="/">Go to Home</a></p>
</body>
</html>

2.1.6 Tests

Add inject-groovy as a testCompile dependency to define beans in Groovy in the test classpath.

gateway/build.gradle
    testCompile "io.micronaut:inject-groovy"

Provide a BookFetcher bean replacement for the Test environment.

gateway/src/test/groovy/example/micronaut/MockBookFetcher.groovy
package example.micronaut

import io.micronaut.context.annotation.Requires
import io.micronaut.context.env.Environment
import io.reactivex.Single

import javax.inject.Singleton

@Singleton
@Requires(env = Environment.TEST)
class MockBookFetcher implements BookFetcher {

    @Override
    Single<List<String>> fetchBooks() {
        Single.just(Arrays.asList("The Empty Hearse",
                "The Hounds of Baskerville",
                "The Woman",
                "The Six Thatchers",
                "The Aluminium Crutch",
                "The Speckled Blonde",
                "The Geek Interpreter",
                "The Great Game",
                "The Blind Banker",
                "A Study in Pink"))
    }
}

Create a tests which verifies the flow using Geb.

gateway/src/test/groovy/example/micronaut/HomePageSpec.groovy
package example.micronaut

import geb.spock.GebSpec
import io.micronaut.context.ApplicationContext
import io.micronaut.runtime.server.EmbeddedServer
import spock.lang.AutoCleanup
import spock.lang.IgnoreIf
import spock.lang.Shared

class HomePageSpec extends GebSpec {
    @Shared
    @AutoCleanup
    EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer) (1)

    @IgnoreIf({ !(sys['geb.env'] in ['chrome', 'firefox']) })
    def "verify tenant can be selected works"() {
        given:
        browser.baseUrl = "http://localhost:${embeddedServer.port}"

        when:
        go "/"

        then:
        at TenantPage

        when:
        TenantPage page = browser.page(TenantPage)
        page.select("sherlock")

        then:
        at HomePage

        when:
        HomePage homePage = browser.page(HomePage)

        then:
        homePage.numberOfBooks()
    }
}
1 To run the application from a unit test you can use the EmbeddedServer interface.

2.2 Books Microservice

Create the microservice:

mn create-app example.micronaut.books --lang groovy

2.2.1 GORM

GORM is a powerful Groovy-based data access toolkit for the JVM. To use it in Micronaut add the following dependencies to build.gradle:

books/build.gradle
    compile("io.micronaut.configuration:hibernate-gorm") {
        exclude group: 'org.grails', module: 'grails-datastore-gorm-hibernate5'
    }
    compile "org.grails:grails-datastore-gorm-hibernate5:6.1.9.RELEASE"
    runtime "com.h2database:h2:1.4.192"
    runtime "org.apache.tomcat:tomcat-jdbc:8.5.0"
    runtime "org.apache.tomcat.embed:tomcat-embed-logging-log4j:8.5.0"
    runtime "org.slf4j:slf4j-api:1.7.10"

Configure multiple data sources as described in the GORM Multiple Data Sources documentation.

books/src/main/resources/application.yml
hibernate:
    hbm2ddl:
        auto: 'update'
dataSources:
    sherlock:
        url: 'jdbc:h2:mem:sherlockDb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE'
        username: 'sa'
        password: ''
        driverClassName: 'org.h2.Driver'
    watson:
        url: 'jdbc:h2:mem:watsonDb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE'
        username: 'sa'
        password: ''
        driverClassName: 'org.h2.Driver'

2.2.2 Domain

GORM supports several tenancy modes. In this tutorial we use DATABASE where a separate database with a separate connection pool is used to store each tenants data.

Add the following configuration to application.yml

books/src/main/resources/application.yml
grails:
    gorm:
        multiTenancy:
            mode: DATABASE (1)
            tenantResolverClass: 'io.micronaut.multitenancy.gorm.HttpHeaderTenantResolver' (2)
1 Use DATABASE mode.
2 Use HttpHeaderTenantResolver which resolves the tenant ID from an HTTP header. Remember we configured the gateway microservice to propagate the tenant ID in an HTTP Header.

Create a GORM Entity to persist books:

books/src/main/groovy/example/micronaut/Book.groovy
package example.micronaut

import grails.gorm.MultiTenant
import grails.gorm.annotation.Entity
import org.grails.datastore.gorm.GormEntity

@Entity (1)
class Book implements GormEntity<Book>, (2)
                MultiTenant { (3)
    String title

    static constraints = {
        title nullable: false, blank: false
    }
}
1 GORM entities are annotated with grails.persistence.Entity.
2 Use of GormEntity is merely to aid IDE support outside of Grails. When used inside a Grails context, some IDEs will use the grails-app/domain location as a hint to enable code completion.
3 Implement the MultiTenant trait in the GORM entities you want to be regarded as multi tenant.

2.2.3 Data Service

GORM Data Services take the work out of implemented service layer logic by adding the ability to automatically implement abstract classes or interfaces using GORM logic.

books/src/main/groovy/example/micronaut/BookService.groovy
package example.micronaut

import grails.gorm.multitenancy.CurrentTenant
import grails.gorm.services.Service

@CurrentTenant (1)
@Service(Book) (2)
interface BookService {
    Book save(String title)
    List<Book> findAll()
}
1 Resolve the current tenant for the context of a class or method
2 The @Service annotation is an AST transformation that will automatically implement the service for you.

2.2.4 Controller

Create a controller to expose the /books endpoint.

books/src/main/groovy/example/micronaut/BookController.groovy
package example.micronaut

import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get

@Controller("/books") (1)
class BookController {

    private final BookService bookService

    BookController(BookService bookService) { (2)
        this.bookService = bookService
    }

    @Get
    List<String> index() {
        bookService.findAll()*.title
    }
}
1 Annotate with io.micronaut.http.annotation.Controller to designate a class as a Micronaut’s controller.
2 Constructor dependency injection.

2.2.5 Application Startup event

To listen to an event, register a bean that implements ApplicationEventListener where the generic type is the type of event the listener should be executed for.

We want to listen for the StartupEvent to save some elements in the databases when the app starts:

books/src/main/groovy/example/micronaut/Bootstrap.groovy
package example.micronaut

import grails.gorm.multitenancy.Tenants
import io.micronaut.context.event.ApplicationEventListener
import io.micronaut.context.event.StartupEvent

import javax.inject.Inject
import javax.inject.Singleton

@Singleton (1)
class Bootstrap implements ApplicationEventListener<StartupEvent> { (2)

    @Inject (3)
    BookService bookService

    @Override
    void onApplicationEvent(StartupEvent event) {

        Tenants.withId("sherlock") { (4)
            bookService.save('Sherlock diary')
        }
        Tenants.withId("watson") { (4)
            bookService.save('Watson diary')
        }
    }
}
1 To register a Singleton in Micronaut’s application context, annotate your class with javax.inject.Singleton.
2 Listen to StartupEvent.
3 Field injection
4 You can specify the tenant ID with the Tenants.withId method.

2.2.6 Tests

Create a tests which verifies the behaviour. We received the books belonging to the tenant which we send via an HTTP header.

books/src/test/groovy/example/micronaut/BookControllerSpec.groovy
package example.micronaut

import io.micronaut.context.ApplicationContext
import io.micronaut.core.type.Argument
import io.micronaut.http.HttpRequest
import io.micronaut.http.client.RxHttpClient
import io.micronaut.runtime.server.EmbeddedServer
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification

class BookControllerSpec extends Specification {

    @Shared
    @AutoCleanup
    EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer)

    @Shared
    @AutoCleanup
    RxHttpClient client = embeddedServer.applicationContext.createBean(RxHttpClient, embeddedServer.getURL())

    void "test hello world response"() {
        when:
        HttpRequest request = HttpRequest.GET('/books').header("tenantId", "sherlock")
        List<String> rsp  = client.toBlocking().retrieve(request, Argument.of(List, String))

        then:
        rsp
        rsp == ['Sherlock diary']

        when:
        request = HttpRequest.GET('/books').header("tenantId", "watson")
        rsp  = client.toBlocking().retrieve(request, Argument.of(List, String))

        then:
        rsp
        rsp == ['Watson diary']
    }


}

3 Running the App

Run both microservices:

books $ ./gradlew run
> Task :complete/books:run
18:29:26.500 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 671ms. Server Running: http://localhost:8081
<=========----> 75% EXECUTING [10s]
gateway $ ./gradlew run

> Task :complete/gateway:run
18:28:35.723 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 707ms. Server Running: http://localhost:8080

You can visit http://localhost:8080 and change tenant and see the book list change:

multitenancy

4 Next Steps

Read more about Multitenancy inside Micronaut and GORM Multitenancy support.