Building a micro service using Spring Boot is quite a lot better than building
everything by hand. But when you want to do something different it's a bit like
eating mcDonalds. It's fast and easy, but not very good for you :-)
I ran into this kind of situation when I tried to add multi-tenant support to my
micro service that was build in Spring Boot.
Multi-tenant support is important to me. Our team runs knowNow a knowledge management
system that is a SaaS solution that allows companies to simplify the way they share knowledge
among colleagues. We offer a subscription service so that the customer doesn't have to worry
about configuring servers, backing up databases etc.
This means we have to run a configuration that is as simple as possible. We don't want
to roll out a service per customer as it is too expensive and too complex to manage.
Instead we allow all customers to access the same number of service instances so that
we only have to worry about load balancing to ensure that everything stays up.
In this post I will show you how I managed to configure a typical microservice
with a database into a multi-tenant version of the same service.
Step 1: Enable tenant selection
The service I'm going to show you is a basic order service with just one method right now.
You can submit a new order through the REST interface. Depending on a HTTP header it will
generate a new sample order in the correct database for that tenant.
In the sample code I use a HTTP header to switch between tenants, but you should not do that!
Instead you should identify the tenant using a field in the authentication token. If you
use JSON web tokens to identify users you will need a new field to the token to identify the tenant.
You may wonder: "Why should I do that?" If you allow the user to switch between tenants using something
simple like a HTTP header you provide too much control to the client. It can switch to whichever tenant it likes.
You should not allow that to happen. Instead, you should control to which tenant the authenticated user belongs
and use that information to switch to the right tenant.
For the sample though, it's not doable to implement that as well as I don't know how you are going to
implement the authentication bits of your micro service.
In Spring Boot you typically use Spring MVC to implement the REST interface for your micro service.
So in order to allow the selection of a tenant with a HTTP header you're going to need to modify the controllers
in your micro service.
Originally it will look like this:
@Controller
public class OrderController {
@Autowired
private OrderRepository orderRepository;
@RequestMapping(path = "/orders", method= RequestMethod.POST)
public ResponseEntity<?> createSampleOrder() {
Order newOrder = new Order(new Date(System.currentTimeMillis()));
orderRepository.save(newOrder);
return ResponseEntity.ok(newOrder);
}
}
It uses a repository to store the order information and return the order object to the client when the operation is completed.
Nothing special here as you might have expected already.
Now to modify this controller to allow for tenant selection through the X-TenantID
HTTP header.
@Controller
public class OrderController {
@Autowired
private OrderRepository orderRepository;
@RequestMapping(path = "/orders", method= RequestMethod.POST)
public ResponseEntity<?> createSampleOrder(@RequestHeader("X-TenantID") String tenantName) {
TenantContext.setCurrentTenant(tenantName);
Order newOrder = new Order(new Date(System.currentTimeMillis()));
orderRepository.save(newOrder);
return ResponseEntity.ok(newOrder);
}
}
All you need to do to allow for tenant selection is to get the tenant identifier from the HTTP request
using the GetRequestHeader
annotation combined with an extra parameter for the method.
After you got the information from the request you need to store it somewhere where the rest of the application
has access to it. It is important to use something that doesn't confuse two requests! The tenant of Request A
can be very different from the tenant of Request B. So don't store the TenantID in a static property.
Instead build a class that stores the tenant information in a ThreadLocal<T>
storage container.
This ensures that the data is stored as part of the current request thread. Since other requests are executed as a
different thread in the service you won't confuse the tenant information between two requests.
public class TenantContext {
private static ThreadLocal<Object> currentTenant = new ThreadLocal<>();
public static void setCurrentTenant(Object tenant) {
currentTenant.set(tenant);
}
public static Object getCurrentTenant() {
return currentTenant.get();
}
}
With these two components modified you're ready to convert the rest of the service.
Step 2: Convert the data access components
The whole multi-tenant thing leans on the correct use of the TenantContext class.
One of the hard things to get right is the data-access layer of your micro service.
There's a ton of ways in which you can switch between tenants. For example, you
can include a column that will identify to which tenant a record belongs.
Another option is to introduce a different schema in the database for each tenant.
Finally you can use different databases for each tenant.
All of the solutions lean on the correct use of the TenantContext
. Apart from that
they are quite different.
I will show you a solution that uses different databases for each tenant. This provides
the maximum level of isolation. You can backup and restore data per tenant and you
don't have to worry about a query giving you the wrong result when you forget to include
the tenant column in the filter criteria.
Don't think that you have a much safer solution though with separate databases.
All of this breaks down as soon as you fail to use the TenantContext correctly.
Lets' move on to modifying the data-access layer to support our multi-tenant strategy.
I'm assuming you use Spring Data JPA as the main technique to access the database.
In order to make this multi-tenant you need to implement a custom data source that allows
the application to switch between different databases.
public class MultitenantDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getCurrentTenant();
}
}
Not much in there, now is there? It turns out Spring JDBC supports our scenario out of the box.
But you need a bit of configuration to configure the data source correctly.
Right now it won't do anything as you haven't configured it as the data source for your application.
For that you need to add a new configuration class:
@Configuration
public class MultitenantConfiguration {
@Autowired
private DataSourceProperties properties;
/**
* Defines the data source for the application
* @return
*/
@Bean
@ConfigurationProperties(
prefix = "spring.datasource"
)
public DataSource dataSource() {
File[] files = Paths.get("tenants").toFile().listFiles();
Map<Object,Object> resolvedDataSources = new HashMap<>();
for(File propertyFile : files) {
Properties tenantProperties = new Properties();
DataSourceBuilder dataSourceBuilder = new DataSourceBuilder(this.getClass().getClassLoader());
try {
tenantProperties.load(new FileInputStream(propertyFile));
String tenantId = tenantProperties.getProperty("name");
// Assumption: The tenant database uses the same driver class
// as the default database that you configure.
dataSourceBuilder.driverClassName(properties.getDriverClassName())
.url(tenantProperties.getProperty("datasource.url"))
.username(tenantProperties.getProperty("datasource.username"))
.password(tenantProperties.getProperty("datasource.password"));
if(properties.getType() != null) {
dataSourceBuilder.type(properties.getType());
}
resolvedDataSources.put(tenantId, dataSourceBuilder.build());
} catch (IOException e) {
// Ooops, tenant could not be loaded. This is bad.
// Stop the application!
e.printStackTrace();
return null;
}
}
// Create the final multi-tenant source.
// It needs a default database to connect to.
// Make sure that the default database is actually an empty tenant database.
// Don't use that for a regular tenant if you want things to be safe!
MultitenantDataSource dataSource = new MultitenantDataSource();
dataSource.setDefaultTargetDataSource(defaultDataSource());
dataSource.setTargetDataSources(resolvedDataSources);
// Call this to finalize the initialization of the data source.
dataSource.afterPropertiesSet();
return dataSource;
}
/**
* Creates the default data source for the application
* @return
*/
private DataSource defaultDataSource() {
DataSourceBuilder dataSourceBuilder = new DataSourceBuilder(this.getClass().getClassLoader())
.driverClassName(properties.getDriverClassName())
.url(properties.getUrl())
.username(properties.getUsername())
.password(properties.getPassword());
if(properties.getType() != null) {
dataSourceBuilder.type(properties.getType());
}
return dataSourceBuilder.build();
}
}
There's a lot to take in here. In order to use the custom data source you need to create a method
in the configuration class called dataSource
. This method returns a new instance of the
MultitenantDataSource
configured using a set of properties files loaded from the tenants folder.
So if you need to add another tenant you create a new properties file in the tenants folder
relative to the JAR file that contains the micro service. Restart the micro service and it will pick
up the new tenant.
Note: You are required to have a default tenant database. This database is NOT used to store
runtime data. It is required so that the entity manager factory and other components in the application
can pick up the correct settings for the query generator JPA uses.
The multi-tenant data source that is configured as the data source for the application and will
use the data sources you create per tenant. This makes it very flexible to use. If you want to use
the tenants from some other source you're welcome to do so.
One important thing to keep in mind here: You can't load them from a configuration database.
This has to do with some odd behavior in Spring Boot. It will load ALL datasources before configuring the
rest of the application. This means that the application will try to load the configuration database
data source and the multi-tenant data source at the same time. The latter however will require the former.
This essentially disables your application completely.
Final thoughts
Although not very complex I think it's good to have a guide like this when you try to convert your
Spring Boot based micro service into a multi-tenant version of that service.
If you're interested in the sources, you can find them here: https://github.com/wmeints/spring-multi-tenant-demo