September 30, 2016

Monster Component in Java with Spring

So yesterday I was browsing twitter and clicked on a link going to Antonio’s blog. I don’t even remember what I was looking for. Thing is I scrolled through the blog items and I stumbled upon the Monster Component in Java EE 7 article, and wow what an achievement. Read it.

https://antoniogoncalves.org/2013/07/03/monster-component-in-java-ee-7/

I definitely needed to do the same thing using the Spring ecosystem! Easy to beat the number of annotations.

After 2 hours and some quirks with JUnit, I got that beloved and polished gem running.

What it is

The class I’ll present below is a Java 8 Spring Boot application which has auto-configuration, persistence (redis), marshalling support, MVC with security, CSRF and form login, RESTful services, SOAP bindings with WSDL generation, WebSocket publishing, task scheduling, caching, code generation (lombok), AOP, lifecycle management and is also a unit test.

That’s a lot of features for just one component counting less than 300 lines of code (excluding comments and imports) and almost 50 annotations.

The project has only one Java file named foo.Cat and a cat.xsd file which is needed for WSDL generation through the use of spring-ws.

This means that we rely on the following dependencies:

spring (boot, ws, websocket, messaging, data-redis, security, cache, test), apache commons (pool2, lang3, collections), embedded-redis, jedis, jackson (core, databind), persistence-api, lombok, aspectj, gson, junit.

What it does

Every 30 seconds the app fetch a random cat image from http://random.cat and inserts it in an embedded redis process.

To add a new cat, just wait each 30 seconds, or if you have one cool picture just browse to http://localhost:8080/new to add a new one.

A RESTful service is available at http://localhost:8080/api/cats.

JSON response

[
  {
    "id":"266d9729-7678-4e5d-8ddb-40d2c5631d0c",
    "url":"http://random.cat/i/065_-_AhrGPRl.gif"
  },
  {
    "id":"ccaa6a40-d3a5-4761-8f85-ca9e5ad8623a",
    "url":"http://random.cat/i/dDVns.gif"
  },
  {
    "id":"d59c4548-c288-4fbf-9998-3b66782ab603",
    "url":"http://random.cat/i/VEcIJ.jpg"
  }
]

The WebSocket stuff can be tested from the home page, below the cats (click Connect then List cats).

If you’re more into the SOAP culture you can send a GetRandomCatRequest at http://localhost:8080/ws to fill your needs. If you want some WSDL kool-aid you can browse http://localhost:8080/ws/cats.wsdl.

SOAP request

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
                  xmlns:gs="http://ws.foo">
   <soapenv:Header/>
   <soapenv:Body>
      <gs:getRandomCatRequest />
   </soapenv:Body>
</soapenv:Envelope>

SOAP response

<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
    <SOAP-ENV:Header/>
    <SOAP-ENV:Body>
      <ns2:getRandomCatResponse xmlns:ns2="http://ws.foo">
        <ns2:cat>
          <ns2:id>49def9b8-13ae-40f3-b25f-7c2fc63a7162</ns2:id>
          <ns2:url>http://random.cat/i/hVU2I6L.jpg</ns2:url>
        </ns2:cat>
      </ns2:getRandomCatResponse>
  </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

🐈🐈🐈

Run it

Grab the code at https://github.com/agrison/catastrophic and run some maven commands.

mvn spring-boot:run  # to run it
mvn test             # to test it

What’s missing?

We could add some spring projects like spring-hateoas to it in order to add some more annotations and entity linking. Don’t limit yourself, just add some more 😄

There are two lombok annotations that I could not add (Builder and AllArgsConstructor) because otherwise it would not satisfy the check made by JUnit regarding the number of constructors of a unit test (meaning only one with no arguments at all).

Sad kitty 😞.

Show me the code!

You will need maven, because everyone loves maven.

Maven deserves some love 💖.

Here is an excerpt of the pom.xml file, we just modified the path to test sources which normally is src/test/… to src/main/java so that our class can also be a unit test.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.4.1.RELEASE</version>
    </parent>

    <artifactId>foo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- ... -->
    </dependencies>

    <build>
        <!-- so that our class can also be a unit test -->
        <testSourceDirectory>src/main/java</testSourceDirectory>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <!-- ... -->
            <!-- xsd to java -->
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>jaxb2-maven-plugin</artifactId>
                <version>1.6</version>
                <executions>
                    <execution>
                        <id>xjc</id>
                        <goals>
                            <goal>xjc</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <schemaDirectory>${project.basedir}/src/main/resources/</schemaDirectory>
                    <outputDirectory>${project.basedir}/src/main/java</outputDirectory>
                    <clearOutputDir>false</clearOutputDir>
                </configuration>
            </plugin>
            <!-- test was moved to src/main/java -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.16</version>
                <configuration>
                    <includes>
                        <include>**/*.java</include>
                    </includes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

And now the king Cat.java file which does everything we’ve been talking about. Just look at the purity of that monster component, the code is flawless and delightful to read. Just take a look at the accumulation of annotations in length order. This is magic.

The code is pretty self explanatory if you already know Spring so I won’t comment every line.

package foo;

// imports skipped

/**
 * @author @algrison
 */
@Getter    // generate getters
@Setter    // generate setters
@Aspect    // we are an aspect
@ToString  // generate toString()
@EnableWs  // SOAP is so enterprisy, we definitely need it
@Endpoint  // Seriously, just read above
@EnableWebMvc   // we want MVC
@EnableCaching  // and we want to cache stuff
@Configuration  // this class can configure itself
@RestController // we want some REST
@XmlRootElement // this component is marshallable
@EnableWebSocket // we want web socket, it's so new-generation
@RedisHash("cat")  // this class is an entity saved in redis
@EnableScheduling  // we want scheduled tasks
@EnableWebSecurity  // and some built-in security
@NoArgsConstructor  // generate no args constructor
@ContextConfiguration  // we want context configuration for unit testing
@SpringBootApplication // this is a Sprint Boot application
@Accessors(chain = true) // getters/setters are chained (ala jQuery)
@EnableAspectJAutoProxy  // we want AspectJ auto proxy
@EnableAutoConfiguration  // and auto configuration
@EnableRedisRepositories  // since it is an entity we want to enable spring data repositories for redis
@EnableWebSocketMessageBroker // we want a broker for web socket messages
@ComponentScan(basePackages = "foo") // we may scan for additional components in package "foo"
@EqualsAndHashCode(callSuper = false) // generate equals() and hashCode()
@Scope(proxyMode = ScopedProxyMode.NO) // Nope
@RunWith(SpringJUnit4ClassRunner.class) // we are also a unit test, but we need specific bootstrapping for Spring
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) // the Spring context could get dirty please clean up
public class Cat extends AbstractWebSocketMessageBrokerConfigurer {
    // ---------- model stuff ----------
    @Id
    private String id;
    @Indexed
    private String url;

    @Autowired
    @JsonIgnore
    KeyValueRepository<Cat, String> repository;

    // ---------- redis ----------
    @Configuration
    @EnableCaching
    public static class Redis extends CachingConfigurerSupport {
        @Bean(initMethod = "start", destroyMethod = "stop")
        public RedisServer redisServer() throws IOException {
            return new RedisServer();
        }
        @Bean
        public RedisConnectionFactory connectionFactory() {
            return new JedisConnectionFactory();
        }
        @Bean
        public RedisTemplate<?, ?> redisTemplate() {
            RedisTemplate<byte[], byte[]> tpl = new RedisTemplate<>();
            tpl.setConnectionFactory(connectionFactory());
            return tpl;
        }
        @Bean
        public KeyValueRepository<Cat, String> crazyRepository() {
            // this is some ugly stuff to create some kind of Spring Data repository with no interface
            return new SimpleKeyValueRepository<>(new ReflectionEntityInformation<Cat, String>(Cat.class), //
                new KeyValueTemplate(new RedisKeyValueAdapter(redisTemplate())));
        }
        @Bean
        public CacheManager cacheManager(RedisTemplate redisTemplate) {
            RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);
            cacheManager.setDefaultExpiration(300);
            return cacheManager;
        }
    }

    // ---------- security ----------
    @Configuration
    @EnableWebSecurity
    public static class Security extends WebSecurityConfigurerAdapter {
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.csrf().ignoringAntMatchers("/ws");
            http.authorizeRequests().antMatchers("/ws").anonymous();
            http.authorizeRequests().antMatchers("/*").authenticated().and().formLogin();
        }
        @Override
        public void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
        }
    }

    // ---------- SOAP ----------
    @EnableWs
    @Configuration
    public static class Soap extends WsConfigurerAdapter {
        @Bean
        public ServletRegistrationBean messageDispatcherServlet(ApplicationContext applicationContext) {
            MessageDispatcherServlet servlet = new MessageDispatcherServlet();
            servlet.setApplicationContext(applicationContext);
            servlet.setTransformWsdlLocations(true);
            return new ServletRegistrationBean(servlet, "/ws/*");
        }
        @Bean(name = "cats")
        public DefaultWsdl11Definition defaultWsdl11Definition(XsdSchema schema) {
            DefaultWsdl11Definition wsdl11Definition = new DefaultWsdl11Definition();
            wsdl11Definition.setPortTypeName("CatThingPort");
            wsdl11Definition.setLocationUri("/ws");
            wsdl11Definition.setTargetNamespace("http://ws.foo");
            wsdl11Definition.setSchema(schema);
            return wsdl11Definition;
        }
        @Bean
        public XsdSchema schema() {
            return new SimpleXsdSchema(new ClassPathResource("cat.xsd"));
        }
    }

    // ---------- SOAP endpoint ----------
    @ResponsePayload
    @PayloadRoot(namespace = "http://ws.foo", localPart = "getRandomCatRequest")
    public GetRandomCatResponse getRandomCat(@RequestPayload GetRandomCatRequest request) {
        GetRandomCatResponse response = new GetRandomCatResponse();
        List<Cat> cats = IteratorUtils.toList(listCats().iterator());
        WsCat cat = new WsCat();
        Cat random = cats.get(new Random().nextInt(cats.size()));
        BeanUtils.copyProperties(random, cat);
        response.setCat(cat);
        return response;
    }

    // ---------- AOP ----------
    @Around("execution(* org.springframework.data.repository.CrudRepository.*(..))")
    public Object aroundRepository(ProceedingJoinPoint pjp) throws Throwable {
        System.out.println("Someone's calling a repository " + //
                pjp.getSignature().getName() + "(" + Arrays.toString(pjp.getArgs()) + ")");
        return pjp.proceed();
    }

    // ---------- internal stuff ----------
    @Cacheable("cats")
    public Iterable<Cat> listCats() {
        return repository.findAll();
    }

    @CacheEvict("cats")
    public Cat addCat(String url) {
        Cat cat = new Cat().setId(UUID.randomUUID().toString()).setUrl(url);
        return repository.save(cat);
    }

    // ---------- scheduling ----------
    @Scheduled(fixedRate = 30000)
    public void generateSomeCat() throws IOException {
        Map<String, String> map = new HashMap<String, String>();
        map = new Gson().fromJson(IOUtils.toString( //
                new URL("http://random.cat/meow").openStream()), map.getClass());
        addCat(map.get("file"));
    }

    // ---------- MVC ----------
    @RequestMapping("/")
    public String homeView() {
        return view("Listing cats", //
            "<table class=table><thead><tr><th>ID<th>Cat<tbody>" + //
            StreamSupport.stream(listCats().spliterator(), false) //
                .map(c -> "<tr><td>" + c.id + "<td><img src=\"" + c.url + "\" width=\"350\"/>") //
                .collect(Collectors.joining()) + "</table>", true);
    }

    @RequestMapping("/new")
    public String newCatView(HttpServletRequest request) {
        CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf");
        return view("Add a cat", //
                "<form method=post name=cat action=\"/new\">" + //
                "<label for=url>URL:</label><input id=url name=url class=\"form-control\"/>" + //
                "<input type=submit class=\"btn btn-info\"/> <input type=hidden name=\"" + //
                csrfToken.getParameterName() + "\" value=\"" + csrfToken.getToken() + "\"/>",
                false);
    }

    @RequestMapping(value = "/new", method = RequestMethod.POST)
    public String newCat(@ModelAttribute("cat") Cat cat) {
        addCat(cat.url);
        return homeView();
    }

    // ---------- REST ----------
    @RequestMapping(value = "/api/cats", method = RequestMethod.GET)
    public Iterable<Cat> restList() {
        return listCats();
    }

    @RequestMapping(value = "/api/cats/{id}", method = RequestMethod.GET)
    public Cat restFind(@PathVariable String id) {
        return repository.findOne(id);
    }

    @RequestMapping(value = "/api/cats", method = RequestMethod.POST)
    public Cat restAdd(@RequestBody Cat cat) {
        return addCat(cat.getUrl());
    }

    // ---------- HTML view ----------
    public String view(String title, String content, boolean websocket) {
        String bootstrap = "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css";
        return String.format("<!DOCTYPE html><title>%s</title><link href=\"%s\" rel=\"stylesheet\">" +
            "<script type=\"text/javascript\" src=\"https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.1/sockjs.min.js\"></script>" +
            "<script type=\"text/javascript\" src=\"https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js\"></script>" +
            "<nav class=\"navbar navbar-inverse navbar-fixed-top\">" +
            "      <div class=\"container\">" +
            "        <div class=\"navbar-header\">" +
            "          <button type=\"button\" class=\"navbar-toggle collapsed\" data-toggle=\"collapse\" data-target=\"#navbar\">" +
            "            <span class=\"sr-only\">Toggle navigation</span>" +
            "            <span class=\"icon-bar\"></span>" +
            "            <span class=\"icon-bar\"></span>" +
            "            <span class=\"icon-bar\"></span>" +
            "          </button>" +
            "          <a class=\"navbar-brand\" href=\"#\"><b>Cat</b>astrophic</a>" +
            "        </div>" +
            "        <div id=\"navbar\" class=\"collapse navbar-collapse\">" +
            "          <ul class=\"nav navbar-nav\">" +
            "            <li class=\"active\"><a href=\"/\">Home</a></li>" +
            "            <li><a href=\"/new\">New cat</a></li>" +
            "            <li><a href=\"https://twitter.com/algrison\">@algrison</a></li>" +
            "          </ul>" +
            "        </div>" +
            "      </div>" +
            "</nav>" +
            "<div class=\"container\" style=\"margin-top: 80px\">" +
            "      <div class=\"starter-template\">" +
            "        <p class=\"lead\">%s</p>" +
            "      </div>" +
            "%s\n" +
            (websocket ? ("<hr/>" +
                "<h2>WebSocket</h2>" +
                "<div class=\"row\">\n" +
                "        <button id=\"connect\" class=\"btn btn-success\" onclick=\"connect();\">Connect</button>\n" +
                "        <button id=\"disconnect\" class=\"btn btn-danger\" disabled=\"disabled\" onclick=\"disconnect();\">Disconnect</button><br/><br/>\n" +
                "        <button id=\"sendNum\" class=\"btn btn-info\" onclick=\"sendList();\">List cats</button>" +
                "    </div>" +
                "<br/><br/><br/><pre id=\"ws\"></pre>" +
                "<script type=\"text/javascript\">\n" +
                "        var stompClient = null; \n" +
                "        function setConnected(connected) {\n" +
                "            document.getElementById('connect').disabled = connected;\n" +
                "            document.getElementById('disconnect').disabled = !connected;\n" +
                "            document.getElementById('ws').innerHTML = '';\n" +
                "        }\n" +
                "        function connect() {\n" +
                "            var socket = new SockJS('/list');\n" +
                "            stompClient = Stomp.over(socket);\n" +
                "            stompClient.connect({}, function(frame) {\n" +
                "                setConnected(true);\n" +
                "                showResult('Connected! - ' + frame);" +
                "                stompClient.subscribe('/topic/cats', function(result){\n" +
                "                    showResult(result.body);\n" +
                "                });\n" +
                "            });\n" +
                "        }\n" +
                "        function disconnect() {\n" +
                "            stompClient.disconnect();\n" +
                "            setConnected(false);\n" +
                "            console.log(\"Disconnected\");\n" +
                "        }\n" +
                "        function sendList() {\n" +
                "            stompClient.send(\"/cats/list\", {}, JSON.stringify({'hello': 'world'}));\n" +
                "        }\n" +
                "        function showResult(message) {\n" +
                "            var response = document.getElementById('ws');\n" +
                "            var p = document.createElement('p');\n" +
                "            p.style.wordWrap = 'break-word';\n" +
                "            p.appendChild(document.createTextNode(message));\n" +
                "            response.appendChild(p);\n" +
                "        }\n" +
                "    </script>") : ""), //
            title, bootstrap, title, content);
    }

    // ---------- PostContruct/PreDestroy ----------
    @PostConstruct
    private void prepare() {
        System.out.println("Hello");
    }

    @PreDestroy
    private void release() {
        System.out.println("Bye");
    }

    // ---------- WebSocket ----------
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/cats");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/list").withSockJS();
    }

    @MessageMapping("/list")
    @SendTo("/topic/cats")
    public Iterable<Cat> wsList() throws Exception {
        return listCats();
    }

    // ---------- Main entry point ----------
    public static void main(String[] args) {
        SpringApplication.run(Cat.class, args); // run the whole stuff
    }

    // ---------- Testing ----------
    @Test
    public void testInsert() {
        Cat cat = addCat("foo");
        Assert.assertThat(repository.findOne(cat.id), CoreMatchers.notNullValue());
    }
}

One can say

While in reality this is awesome 🦄

Conclusion

Make no mistake about it, I do love Java, I do love Spring and the whole ecosystem, it’s highly productive and well thought. It’s hard to get that much more productive in Java without it.

I love the JVM and all these cool languages on it, Clojure, Scala and Kotlin for example.

This was just an experimentation, not a satire of Java, Spring and the whole AbstractSingletonProxyFactoryBean bashing you can find all around the web.

To finish, I’d like to say that the web is full of cats (like this app), so here is my favorite dog on the web.

Until next time!

Alexandre Grison - //grison.me - @algrison