Skip to content

Latest commit

 

History

History
319 lines (252 loc) · 11.2 KB

di.md

File metadata and controls

319 lines (252 loc) · 11.2 KB

Inject your dependencies

Our feature switch configuration currently loads from a file bundled with our server. Because our deployed code is immutable, we need to re-deploy our server change configuration.

To accommodate loading configuration from a dynamic source, abstract your configuration as a dependency.

This will also help us achieve a hermetic server. In development, we can load from a local file. In testing we can load a mock object, and in production we can fetch configuration from another service.

We'll use Guice to manage our dependency injection for a few reasons:

  • it's very mature, so we can easily find documentation
  • we'll be using Dagger, which is an iteration on Guice, for Android
  • in the near future, we'll use Dagger 2 for client and server DI

Guice defines a few basic concepts:

  • Modules define dependency providers
  • Providers define a function that returns an instance of the dependency we want
  • Injectors inject the dependencies defined by modules into classes

In terms of Jersey, we'll configure it to inject our configuration into our request handler. We can then inject different sources of configuration in our development, production, and test environments.

First, define our module:

package com.example.featureswitchservice;

import com.esotericsoftware.yamlbeans.YamlException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.jaxrs.json.JacksonJsonProvider;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import com.sun.jersey.guice.JerseyServletModule;
import com.sun.jersey.guice.spi.container.servlet.GuiceContainer;

import javax.inject.Named;
import java.io.FileNotFoundException;
import java.util.Map;

public class ProductionModule extends JerseyServletModule {

  @Provides
  @Singleton
  ObjectMapper objectMapper() {
    return new ObjectMapper();
  }

  @Provides
  @Singleton
  JacksonJsonProvider jacksonJsonProvider(ObjectMapper mapper) {
    return new JacksonJsonProvider(mapper);
  }

  @Provides
  @Named("config")
  Map<String, Map<String, String>> get() throws FileNotFoundException, YamlException {
    return FeatureSelector.createFeatureMap();
  }

  @Override
  protected void configureServlets() {
    bind(ConfigResource.class);
    serve("/*").with(GuiceContainer.class);
  }
}

Observe we're still calling createFeatureMap to load config from our resource file. We'll update that after migrating to Guice, so we can run our existing tests to verify we didn't regress as a result of the migration.

Functions with the @Provides annotation will be called by Guice whenever we try to inject an instance of the class returned by the function.

For example, Guice will call jacksonJsonProvider wherever we inject a JacksonJsonProvider instance.

The @Named annotation enables us to differentiate instances of a common class, like Map or String, by name.

The @Singleton annotation tells Guice to only instantiate this object once. Singleton management is a big advantage of DI. Singletons can be very difficult to test, especially if they are implemented using global variables. With DI, we don't need to maintain the code to conditionally instantiate a singleton, and we can easily override the singleton instance in our test environment.

Next, modify the Main class (generated earlier by the Jersey Heroku archetype) to inject these dependencies:

package com.example.featureswitchservice;

import com.google.inject.Guice;
import com.google.inject.Injector;
import com.google.inject.servlet.GuiceFilter;
import com.google.inject.servlet.GuiceServletContextListener;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.DefaultServlet;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.webapp.WebAppContext;

public class Main {
  static final int DEFAULT_PORT = 8080;

  public static int normalizePort(String port) {
    if (port == null || port.isEmpty()) {
      return DEFAULT_PORT;
    } else {
      return Integer.valueOf(port);
    }
  }

  public static Server createServer(int port, GuiceServletContextListener listener) {
    final Server server = new Server(port);
    final WebAppContext root = new WebAppContext();

    // DI
    root.addEventListener(listener);
    root.addFilter(GuiceFilter.class, "/*", null);

    root.setContextPath("/");
    root.setParentLoaderPriority(true);
    final String webappDirLocation = "src/main/webapp/";
    root.setDescriptor(webappDirLocation + "/WEB-INF/web.xml");
    root.setResourceBase(webappDirLocation);
    server.setHandler(root);

    return server;
  }

  public static void main(String[] args) throws Exception{
    int port = normalizePort(System.getenv("PORT"));

    GuiceServletContextListener listener = new GuiceServletContextListener() {

      @Override
      protected Injector getInjector() {
        return Guice.createInjector(new ProductionModule());
      }
    };
    Server server = createServer(port, listener);
    server.start();
    server.join();
  }
}

I've also split out the port normalization, and server instantiation and configuration, in the code above to highlight the creation of a GuiceServletContextListener object, which configures a Guice injector to load our ProductionModule.

Configure our resource class to inject its dependencies:

package com.example.featureswitchservice;

import com.esotericsoftware.yamlbeans.YamlException;

import javax.inject.Inject;
import javax.inject.Named;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import java.io.FileNotFoundException;
import java.util.Map;

@Path("feature_switch_config")
public class ConfigResource {
  @QueryParam("id") String id;
  @QueryParam("os") String os;
  @QueryParam("version") String version;

  Map<String, Map<String, String>> config;

  @Inject
  public ConfigResource(@Named("config") Map<String, Map<String, String>> config) {
    this.config = config;
  }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Map<String, Boolean> get() throws FileNotFoundException, YamlException {
      Input input = FeatureSelector.requireInput(new String[]{id, os, version});
      FeatureSelector selector = new FeatureSelector(this.config);
      Map<String, Boolean> selected = selector.select(input);
      return selected;
    }
}

Note the @Inject annotation on our constructor. Also note the use of @Named to disambiguate which Map we should inject.

Now update the integration tests:

package com.example.featureswitchservice;

import com.esotericsoftware.yamlbeans.YamlException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.inject.Guice;
import com.google.inject.Injector;
import com.google.inject.Provides;
import com.google.inject.servlet.GuiceServletContextListener;
import com.google.inject.util.Modules;
import com.sun.jersey.guice.JerseyServletModule;
import org.eclipse.jetty.server.Server;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import javax.inject.Named;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.WebTarget;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import static org.junit.Assert.assertEquals;

public class ConfigResourceTest {
  Server server;
  WebTarget target;

  @Before
  public void setUp() throws Exception {
    GuiceServletContextListener listener = new GuiceServletContextListener() {

      @Override
      protected Injector getInjector() {
        return Guice.createInjector(Modules.override(new ProductionModule())
            .with(new JerseyServletModule() {
              @Provides
              @Named("config")
              Map<String, Map<String, String>> get() throws FileNotFoundException, YamlException {
                return FeatureSelector.createFeatureMap();
              }
            }));
      }
    };
    server = Main.createServer(Main.DEFAULT_PORT, listener);
    server.start();
    Client client = ClientBuilder.newClient();
    target = client.target(String.format("http://localhost:%d/", Main.DEFAULT_PORT));
  }

  @After
  public void tearDown() throws Exception {
    server.stop();
  }

  @Test
  public void testGet() throws IOException {
    final String json = target
        .path("feature_switch_config")
        .queryParam("id", "123")
        .queryParam("os", "android")
        .queryParam("version", "2.3")
        .request()
        .get(String.class);
    HashMap<String, Boolean> expected = new HashMap<>();
    expected.put("feature_c", true);
    ObjectMapper mapper = new ObjectMapper();
    Map<String, Boolean> actual = mapper.readValue(json, Map.class);
    assertEquals(expected, actual);
  }
}

Observe that we're now instantiating a new injector, which overrides our configuration source. Again, we'll modify the source after completing Guice migration.

Note we're running the same server instantiated in Main. This allows us to integration test our service. We can also add unit tests for our resource class, but I'll defer that to a follow-up change, so this commit is focused just on migrating to Guice.

Modify web.xml to use Guice for all DI:

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.4" xmlns="http://java.sun.com/xml/ns/j2ee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd">

    <!-- Minimal web.xml to bootstrap Guice. All the real config goes on inside GuiceConfig class. -->

    <listener>
        <display-name>GuiceConfig</display-name>
        <listener-class>com.example.featureswitchservice.GuiceListener</listener-class>
    </listener>

    <filter>
        <filter-name>guiceFilter</filter-name>
        <filter-class>com.google.inject.servlet.GuiceFilter</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>guiceFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
</web-app>

I copied this web.xml from the Jersey-Jetty-Guice Maven archetype.

Finally, update pom.xml to include the artifacts we need:

...
<dependency>
    <groupId>com.fasterxml.jackson.jaxrs</groupId>
    <artifactId>jackson-jaxrs-json-provider</artifactId>
    <version>2.4.0</version>
</dependency>
<!--DI-->
<dependency>
    <groupId>com.google.inject</groupId>
    <artifactId>guice</artifactId>
    <version>3.0</version>
</dependency>
<dependency>
    <groupId>com.sun.jersey.contribs</groupId>
    <artifactId>jersey-guice</artifactId>
    <version>1.18</version>
</dependency>
...

We use jackson-jaxrs-json-provider for rendering JSON, guice for Guice, and jersey-guice for Jersey-Guice integration.

Test via curl, run integration tests (mvn test), and commit.