Bootiful CMS part 2 - Multi-module Single-page App

Splitting the monolith single page web application with Spring Boot, AngularJS, NetFlix Zuul, Netflix Eureka and Spring Session.

In this blog post, we are going to put an API Gateway in front of our CMS editor. This gateway will handle authentication and serve the backbone of the single-page app. We will build the gateway with Netflix Zuul, as provided by Spring Cloud. Because Zuul requires a discovery server, we will also add Netflix Eureka to the mix, and to provide single-sign on between our gateway and the backend apps behind it, we will add distributed sessions using Spring Session.

Discovery

The first piece of this puzzle is the discovery server. The purpose of a discovery server is to help with load balancing and fail-over of services. Every service registers itself with the discovery server using an application name, and then a client can request an instance of that service from the discovery service. In our case, the gateway needs this discovery server to find instances of our app backends (so yes, we will have load balancing!). Spring Cloud offers such a discovery service with Netflix Eureka, so we’ll use that.

Build with Gradle

Let’s start with the Gradle build file again.

editor/post/post-frontend/build.gradle
wrapper.gradleVersion = '2.5'

def vJavaLang = '1.8'

buildscript {
    ext.springRepo = 'http://repo.spring.io/libs-release'
    repositories {
        maven { url springRepo }
    }
    dependencies {
        classpath "org.springframework.boot:spring-boot-gradle-plugin:1.2.5.RELEASE"
    }
}

apply plugin: 'java'
apply plugin: 'spring-boot'

targetCompatibility = vJavaLang
sourceCompatibility = vJavaLang
repositories {
    maven { url springRepo }
}

dependencies {
  compile 'org.springframework.cloud:spring-cloud-starter-eureka:1.0.3.RELEASE'
  compile 'org.springframework.cloud:spring-cloud-starter-eureka-server:1.0.3.RELEASE'
}

Except for the dependencies, this is the same build file as our post-backend in the previous post. The discovery server is also a Spring Boot application, depending on 2 starters from Spring Cloud: the Eureka client and the Eureka Server.

Application

Well, we’re almost done. All we need now is an Application class and a configuration file.

discovery/src/main/java/be/beeworks/discovery/Application
package be.beeworks.discovery;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.web.SpringBootServletInitializer;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer
public class Application extends SpringBootServletInitializer {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(Application.class);
    }
}

That’s right, our discovery server is 20 lines of code. Talk about a microservice! All the magic is done by the EnableEurekaServer annotation. When running this application, we will have a Eureka server running on http://localhost:8761/, including a nice dashboard. But let’s add the configuration first.

discovery/src/main/resources/application.yml
server:
  port: 8761
spring:
  application:
    name: eureka-server
eureka:
  client:
    registerWithEureka: false
    fetchRegistry: false

The last 2 properties make sure that this Eureka server doesn’t try to register with other Eureka servers, so it should act as a standalone instance. Eureka servers have fail-over built in, so by default they will try to register with their peers.

Start the application with gradle bootRun and you should see this dashboard:

Eureka

Gateway

Now that we have the discovery server set up, we can build the gateway. The gateway is basically a reverse proxy we put in front of our backend applications, providing load balancing and fail-over and handling a number of cross-cutting concerns, like authentication. Again, Spring Cloud provides such a gateway, and again it is built by Netflix: Zuul.

Build with Gradle

The gateway will consist of a backend and a frontend, so we’ll set up a multi-project Gradle build again:

editor/gateway/build.gradle
wrapper.gradleVersion = '2.5'
editor/gateway/settings.gradle
include 'gateway-backend'
include 'gateway-frontend'

gateway-backend

The backend build file again looks familiar:

editor/gateway/gateway-backend/build.gradle
def vJavaLang = '1.8'

buildscript {
    ext.springRepo = 'http://repo.spring.io/libs-release'
    repositories {
        maven { url springRepo }
    }
    dependencies {
        classpath "org.springframework.boot:spring-boot-gradle-plugin:1.2.5.RELEASE"
    }
}

apply plugin: 'java'
apply plugin: 'spring-boot'

targetCompatibility = vJavaLang
sourceCompatibility = vJavaLang
repositories {
    maven { url springRepo }
}

dependencies {
    compile("org.springframework.boot:spring-boot-starter-web")
    compile("org.springframework.boot:spring-boot-starter-actuator")
    compile("org.springframework.boot:spring-boot-starter-thymeleaf")
    compile 'org.springframework.cloud:spring-cloud-starter-eureka:1.0.3.RELEASE'
    compile("org.springframework.boot:spring-boot-starter-security")
    compile("org.springframework.session:spring-session:1.0.1.RELEASE")
    compile("org.springframework.boot:spring-boot-starter-redis")
    compile 'org.springframework.cloud:spring-cloud-starter-zuul:1.0.3.RELEASE'
    compile project(':gateway-frontend')
}

Lets go over the dependencies:

  • starter-web and starter-actuator provide us with the webapp with Spring Actuator, just like in the post backend

  • thymeleaf will be used to create the index page of our combined single-page app

  • the eureka client is used to register with the discovery server

  • we’re adding security to the gateway, so we need the security starter

  • spring-session will be used for distributed sessions, making single sign-on possible

  • spring-session by default uses Redis for storage

  • Zuul is the gateway implementation from Netflix

  • The gateway also has a front-end, same as the post-backend

gateway-frontend

Like in the post-frontend, we’re building with Gulp and Gradle.

editor/gateway/gateway-frontend/packages.json
{
  "name": "gateway-frontend",
  "version": "1.0.0",
  "description": "Frontend of the BeeWorks CMS Lite",
  "main": "/",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "/"
  },
  "author": "Jurgen Lust",
  "license": "/",
  "dependencies": {
    "del": "^1.1.1",
    "gulp": "^3.8.11",
    "gulp-angular-templatecache": "^1.5.0",
    "gulp-concat": "^2.5.2",
    "gulp-connect": "^2.2.0",
    "gulp-minify-css": "^1.0.0",
    "gulp-open": "^0.3.2",
    "gulp-sourcemaps": "^1.5.0",
    "gulp-uglify": "^1.1.0",
    "gulp-uncache": "^0.3.6",
    "gulp-util": "^3.0.4",
    "gulp-bower": "^0.0.10",
    "karma": "^0.12.31",
    "q": "^1.2.0",
    "run-sequence": "^1.0.2"
  }
}
editor/gateway/gateway-frontend/bower.json
{
  "name": "gateway-frontend",
  "version": "1.0.0",
  "homepage": "/",
  "description": "Frontend of the BeeWorks CMS Lite",
  "main": "/",
  "authors": [
    "Jurgen Lust"
  ],
  "license": "/",
  "dependencies": {
    "angular": "~1.3.14",
    "angular-route": "~1.3.14",
    "angular-mocks": "~1.3.14",
    "bootstrap": "~3.3.4",
    "toastr": "~2.1.1",
    "lodash": "~3.5.0",
    "fontawesome": "~4.3.0"
  }
}
editor/gateway/gateway-frontend/gulpfile.js
(function() {
    'use strict';
    var gulp = require('gulp'),
        bower = require('gulp-bower'),
        concat = require('gulp-concat'),
        sourcemaps = require('gulp-sourcemaps'),
        templateCache = require('gulp-angular-templatecache'),
        connect = require('gulp-connect'),
        gOpen = require('gulp-open'),
        del = require('del'),
        Q = require('Q'),
        cssMinify = require('gulp-minify-css'),
        uglify = require('gulp-uglify'),
        util = require('gulp-util'),
        uncache = require('gulp-uncache'),
        runSequence = require('run-sequence');
    var environment = 'dev';
    var target = function(filename) {
        var dir = 'build/' + environment;
        if (filename) {
            return dir + '/' + filename;
        } else return dir;
    }
    var jsDependencies = [
            'bower_components/jquery/dist/jquery.js',
            'bower_components/angular/angular.js',
            'bower_components/angular-route/angular-route.js',
            'bower_components/bootstrap/dist/js/bootstrap.js',
            'bower_components/lodash/lodash.js',
            'bower_components/angular-spinner/angular-spinner.js',
            'bower_components/toastr/toastr.js'
        ],
        cssDependencies = [
            'bower_components/bootstrap/dist/css/bootstrap.css',
            'bower_components/toastr/toastr.css',
            'bower_components/fontawesome/css/font-awesome.css'
        ];

    /* install bower dependencies for this project */
    gulp.task('bower-install', function() {
        return bower();
    });
    /* compile AngularJS html templates to Javascript */
    gulp.task('template-cache', function() {
        return gulp.src('src/app/**/*.html')
            .pipe(templateCache({
                module: 'app.about',
                root: 'app/'
            }))
            .pipe(concat('scripts.js'))
            .pipe(gulp.dest(target()));
    });
    /* bundle Javascript libraries and project scripts in 1 Javascript file */
    gulp.task('compile-javascript-libraries', ['bower-install'], function() {
        return gulp.src(jsDependencies)
            .pipe(sourcemaps.init())
            .pipe(concat('libs.js'))
            .pipe(sourcemaps.write('./'))
            .pipe(gulp.dest(target()));
    });
    /* bundle project scripts in 1 Javascript file */
    gulp.task('compile-javascript', ['template-cache', 'compile-javascript-libraries'], function() {
        return gulp.src(['src/app/**/*.js', target('scripts.js')])
            .pipe(sourcemaps.init())
            .pipe(concat('scripts.js'))
            .pipe(sourcemaps.write('./'))
            .pipe(gulp.dest(target()));
    });
    /* bundle library and project CSS files in 1 CSS file */
    gulp.task('compile-css', ['bower-install'], function() {
        return gulp.src(cssDependencies.concat(['src/style/screen.css']))
            .pipe(sourcemaps.init())
            .pipe(concat('screen.css'))
            .pipe(sourcemaps.write('./'))
            .pipe(gulp.dest(target()));
    });
    /* make sure the CSS and JS files are refreshed in the browser */
    gulp.task('uncache-index', function() {
        return gulp.src(['src/index.html'])
            .pipe(uncache({
                append: 'time'
            }))
            .pipe(gulp.dest(target()))
            .pipe(connect.reload());
    });
    gulp.task('copy-fonts', function() {
        return gulp.src('bower_components/fontawesome/fonts/*.*')
            .pipe(gulp.dest(target('fonts')));
    });
    gulp.task('copy-rest-sample-data', function() {
        return gulp.src('src/rest/*')
            .pipe(gulp.dest(target()))
            .pipe(connect.reload());
    })
    gulp.task('clear-dev', function() {
        var deferred = Q.defer();
        del([target('**/*.*'), target('fonts'), target('test')], function() {
            deferred.resolve();
        });
        return deferred.promise;
    });
    /* interaction */
    gulp.task('start-server', function() {
        connect.server({
            livereload: true,
            root: target()
        });
    });
    gulp.task('watch-changes', function() {
        gulp.watch(['src/app/*.js', 'src/app/**/*.js'], function() {
            runSequence('compile-javascript', 'uncache-index');
        });
        gulp.watch('src/app/**/*.html', function() {
            runSequence('compile-javascript', 'uncache-index');
        });
        gulp.watch('src/style/screen.css', function() {
            runSequence('compile-css', 'uncache-index');
        });
        gulp.watch('src/index.html', function() {
            runSequence('uncache-index');
        });
        gulp.watch('src/rest/*', function() {
            runSequence('copy-rest-sample-data');
        });
    });
    gulp.task('open-browser', function() {
        var options = {
            url: 'http://localhost:8080'
        };
        return gulp.src('./index.html')
            .pipe(gOpen('', options));
    });
    gulp.task('clean', ['clear-dev'], function() {});
    /* prepare the app for distribution */
    gulp.task('build', function() {
        environment = 'dist';
        runSequence('clear-dev', 'compile-javascript', 'compile-css', 'uncache-index', 'copy-fonts');
    });
    gulp.task('default', function() {
        runSequence('clear-dev', 'compile-javascript',
            'compile-css', 'uncache-index', 'copy-fonts', 'copy-rest-sample-data',
            function() {
                gulp.run('start-server');
                gulp.run('watch-changes');
                gulp.run('open-browser');
            });
    });

}());
editor/gateway/gateway-frontend/build.gradle
buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.moowork.gradle:gradle-gulp-plugin:0.10'
    }
}
apply plugin: 'java'
apply plugin: 'com.moowork.gulp'
node {
    download=true
    // Version of node to use.
    version = '0.12.7'
    // Version of npm to use.
    npmVersion = '2.12.1'
}
installGulp.dependsOn npm_install
task('gulp_bower-install').dependsOn installGulp
gulp_build.dependsOn 'gulp_bower-install'
jar.dependsOn gulp_build
jar {
    from 'build/dist'
    eachFile { details ->
        details.path = details.path.startsWith('META-INF') ?: 'META-INF/resources/static/'+details.path
    }
    includeEmptyDirs = false
}
task cleanBower(type: Delete) {
    delete 'bower_components'
}
clean.dependsOn cleanBower
clean.dependsOn npm_cache_clean

Redis

We’re using Redis for our distributed sessions, so we need to install it. On a Mac, just brew install redis, on Windows it’s a little bit harder. Or you could use Docker. Once installed, start the Redis server with this command:

redis-server

Application

gateway-backend

As with all Spring Boot applications, we need an Application class first.

editor/gateway/gateway-backend/src/main/java/be/beeworks/editor/Application.java
package be.beeworks.editor;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.web.SpringBootServletInitializer;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;

@SpringBootApplication
@EnableEurekaClient
@EnableZuulProxy
@EnableRedisHttpSession
@EnableGlobalMethodSecurity(securedEnabled = true)
public class Application extends SpringBootServletInitializer {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(Application.class);
    }
}

There’s quite a lot going on here:

  • We have a Spring Boot application

  • We want to register the application with the discovery server, so we enable the Eureka client

  • We’re using Zuul as the implementation of our gateway, so enable that

  • We’re using Redis for distributed sessions

We also need some configuration:

editor/gateway/gateway-backend/src/main/resources/application.yml
server:
  port: 8080
spring:
  application:
    name: editor-gateway
zuul:
  routes:
    posts:
      path: /posts/**
      serviceId: backend-post
      stripPrefix: false
security:
  user:
    name: user
    password: password
    role: USER, VIEW_POST
  sessions: ALWAYS

The most interesting part here is the routes section. We’re telling Zuul that everything under /posts/ should be forwarded to the post backend. The stripPrefix setting means that the /posts/ path should be kept intact on the post backend. Without this setting, the /posts/ path of the gateway would be forwarded to the root context of the post backend. We’re also supplying a default user here, so we can log into the application with username user and password password. The default user will have the roles USER and VIEW_POST. In a more realistic gateway, we would use LDAP or Active Directory to log in, or use some other identity provider. When logging in, we always want to start a session, so the Principal would be available in the backend applications as well (through Redis).

Since the gateway also has a frontend, we want the same Resource Configuration as in the post backend:

editor/gateway/gateway-backend/src/main/java/be/beeworks/editor/ResourceConfiguration.java
package be.beeworks.editor;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@Configuration
public class ResourceConfiguration extends WebMvcConfigurerAdapter {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**").addResourceLocations("classpath:/META-INF/resources/static/");
    }
}

Remember from the previous post that this enables the webapp to retrieve static resources from the frontend jar-file.

Index page

The last thing we need to do in the gateway backend is build a controller for the index page. This index page will be responsible for combining the frontends of all the applications the gateway is a proxy for, into a single-page app.

editor/gateway/gateway-backend/src/main/java/be/beeworks/editor/controller/IndexController.java
package be.beeworks.editor.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.ZuulProperties;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.ArrayList;
import java.util.List;

@Controller
public class IndexController {
    private static final Logger logger = LoggerFactory.getLogger(IndexController.class);

    @Autowired
    private ZuulProperties zuulProperties;

    @RequestMapping("/")
    @Secured({"ROLE_USER"})
    public String index(Model model) {
        List<FrontendModule> frontendModules = new ArrayList<>();
        zuulProperties.getRoutes().keySet().forEach(r -> frontendModules.add(new FrontendModule(r)));
        model.addAttribute("modules", frontendModules);
        return "index";
    }

    public class FrontendModule {
        private final String path;
        FrontendModule(String path) {
            this.path = path;
        }
        public String getPath() {
            return path;
        }
        public String getName() {
            return "app." + path;
        }
        public String getCss() {
            return path + "/screen.css";
        }
        public String getScript() {
            return path + "/scripts.js";
        }
    }
}

This controller for the index page retrieves all the routes from the Zuul proxy, and adds them to the model for the Thymeleaf template.

editor/gateway/gateway-backend/src/main/resources/templates/index.html
<!DOCTYPE html>
<html lang="en" ng-app="app">
<head>
    <meta charset="UTF-8" />
    <title>BeeWorks CMS Lite</title>
    <link href="screen.css" rel="stylesheet"/>
    <link th:each="module : ${modules}" th:href="${module.css}" rel="stylesheet" />
</head>
<body ng-cloak="true">
<nav class="navbar navbar-inverse navbar-fixed-top">
    <div class="container-fluid">
        <div class="navbar-header">
            <a class="navbar-brand" href="#/">BeeWorks CMS Lite</a>
        </div>
        <div id="navbar" class="navbar-collapse collapse">
            <ul class="nav navbar-nav">
                <li th:each="module : ${modules}"><a th:href="${'#/' + module.path}" th:text="${module.path}">Menu item</a></li>
            </ul>
        </div>
    </div>
</nav>
<div ng-view="true"></div>
<script src="libs.js" type="text/javascript"></script>
<script th:each="module : ${modules}" th:src="${module.script}" type="text/javascript"></script>
<script src="scripts.js" type="text/javascript"></script>
<script th:inline="javascript">
    /*<![CDATA[*/
    (function(){
        'use strict';
        var frontendModules = /*[[${modules}]]*/ [];
        var app = angular.module('app', ['ngRoute']);
        frontendModules.map(function(frontendModule) {
            app.requires.push(frontendModule.name);
        });
        app.requires.push('app.about');
        // register our dependencies
        app.constant('_', _);
        app.constant('toastr', toastr);
    }());
    /*]]>*/
</script>
</body>
</html>

This is basically the same page as the index page for the post backend, with some special features:

  • apart from the screen.css from the gateway frontend, the screen.css files of each of the proxied applications is added as well. Given our current configuration, this means /posts/screen.css is added. This file is then fetched from the post application.

  • the libs.js file is the concatenation of all the libraries we use: angular, jquery, …​

  • apart from the scripts.js from the gateway frontend, the scripts.js files of each of the proxied applications is added as well. In this case, this means /posts/scripts.js, which is also fetched from the post application.

  • In the main menu, a menu item is added for every proxied application.

gateway-frontend

The gateway frontend isn’t particularly interesting. It contains the same CSS and Javascript libraries as the post frontend, and adds an About module which displays the status from the gateway Spring Actuator health endpoint.

editor/gateway/gateway-frontend/src/app/module/about/config.js
(function(){
    'use strict';
    var module = angular.module('app.about', ['ngRoute']);
    function config($routeProvider){
        $routeProvider.when('/about', {
            templateUrl: 'app/module/about/view/index.html'
        }).otherwise({
                templateUrl: 'app/module/about/view/index.html'
        });
    }
    config.$inject = ['$routeProvider'];
    module.config(config);
}());

Note the otherwise here: the default home page of the application will be the about page.

editor/gateway/gateway-frontend/src/app/module/about/controller/About_indexController.js
(function(){
    'use strict';
    angular.module('app.about').controller('About_indexController', Constructor);
    Constructor.$injector = 'AboutService';
    function Constructor(AboutService){
        var vm = this;
        function initVm(){
            vm.refresh = function() {
                AboutService.getStatus().then(function(status) { vm.status = status.message });
            };
            vm.refresh();
        }
        initVm();
    }
}());
editor/gateway/gateway-frontend/src/app/module/about/service/AboutService.js
(function() {
    'use strict';
    angular.module('app.about').factory('AboutService', Implementation);
    Implementation.$injector = '$http';
    function Implementation($http) {
        return {
            getStatus: getStatus
        };
        function getStatus() {
            return $http.get('/health').then(function(response) {
                return {
                    message: response.data.status + " - " + now()
                };
            });
        }
        function now() {
            return new Date().getTime();
        }
    }
}());
editor/gateway/gateway-frontend/src/app/module/about/view/index.html
<div class="container-fluid" ng-controller="About_indexController as aboutVm">
    <h1><i class="fa fa-info-circle"></i>&nbsp;About the editor</h1>
    <p>{{aboutVm.status}}</p>
    <a ng-click="aboutVm.refresh()">refresh</a>
</div>
editor/gateway/gateway-frontend/src/screen.css
body{
    margin-top: 50px;
}
[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak {
  display: none !important;
}
textarea {
    resize: none;
}

We’re also including a static version of our index page. This won’t be used when running on top of the backend, but it can be used when developing the gateway frontend standalone.

editor/gateway/gateway-frontend/src/index.html
<!DOCTYPE html>
<html lang="en" ng-app="app">
    <head>
        <meta charset="UTF-8">
        <title>BeeWorks CMS Lite</title>
        <!--uncache(rename:false)-->
        <link href="screen.css" rel="stylesheet"/>
        <!--enduncache-->
    </head>
    <body ng-cloak>
        <nav class="navbar navbar-inverse navbar-fixed-top">
            <div class="container-fluid">
                <div class="navbar-header">
                    <a class="navbar-brand" href="#/">Gateway standalone</a>
                </div>
                <div id="navbar" class="navbar-collapse collapse">
                    <ul class="nav navbar-nav">
                        <li><a href="#/about" title="About">About</a></li>
                    </ul>
                </div>
            </div>
        </nav>
        <div ng-view></div>
        <!--uncache(rename:false)-->
        <script src="libs.js" type="text/javascript"></script>
        <script src="scripts.js" type="text/javascript"></script>
        <!--enduncache-->
        <script type="text/javascript">
            (function(){
                'use strict';
                var angularModules = ['app.about'];
                var app = angular.module('app', ['ngRoute']);
                angularModules.map( function(module) {
                    app.requires.push(module);
                });
                // register our dependencies
                app.constant('_', _);
                app.constant('toastr', toastr);
            }());
        </script>
    </body>
</html>

And finally, also for standalone frontend development, the static REST resource for the about page:

editor/gateway/gateway-frontend/src/rest/health
{ "status": "sample status from JSON file" }

OK, it’s time to try the standalone frontend of the gateway.

cd editor/gateway/gateway-frontend
npm install
gulp

This should result in your browser opening the page http://localhost:8080/, showing this:

Gateway Standalone v1

We can also start the backend application, although the post route won’t work yet (we still need some to make some changes to the post backend). First we need to start the redis server:

redis-server

Next, start the discovery server, in a new terminal window:

cd discovery
gradle bootRun

And finally, start the gateway server, again in a new terminal window:

cd editor/gateway/gateway-backend
gradle bootRun

Once it is started, after about 30 seconds the editor-gateway application will appear in the Eureka dashboard on http://localhost:8761/ :

Eureka showing Gateway

And the gateway application will be running on http://localhost:8080/, although it will be a blank page, because Angular can’t find the app.posts module. That’s because we still need to do some changes in the post backend and start it. But at least the health page works on http://localhost:8080/health :

{"description":"Spring Cloud Eureka Discovery Client","status":"UP","discovery":{"description":"Spring Cloud Eureka Discovery Client","status":"UP","discoveryClient":{"description":"Spring Cloud Eureka Discovery Client","status":"UP","services":["editor-gateway"]}},"diskSpace":{"status":"UP","free":254856380416,"threshold":10485760},"redis":{"status":"UP","version":"3.0.2"},"hystrix":{"status":"UP"}}

Post backend changes

If we want the post backend to participate in the distributed sessions we will need to add Spring Session. And the gateway should be able to discover it, so we need to add the Eureka client. And finally, we need to add an extra resource mapping for the gateway to find the post frontend static resources.

We need to add the following dependencies to the post backend Gradle build file:

editor/post/post-backend/build.gradle
...
    compile 'org.springframework.cloud:spring-cloud-starter-eureka:1.0.3.RELEASE'
    compile("org.springframework.session:spring-session:1.0.1.RELEASE")
    compile("org.springframework.boot:spring-boot-starter-redis")
...

Next add Eureka and Redis sessions to the Application.java:

editor/post/post-backend/src/main/java/be/beeworks/editor/post/Application.java
...
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

@EnableEurekaClient
@EnableRedisHttpSession
...

Then add the extra resource mapping to ResourceConfiguration.java

editor/post/post-backend/src/main/java/be/beeworks/editor/post/ResourceConfiguration.java
...
        registry.addResourceHandler("/posts/**").addResourceLocations("classpath:/META-INF/resources/static/");
...

And finally tell Spring Security never to start a session in the post backend. The gateway is responsible for that:

editor/post/post-backend/src/main/resources/application.yml
security:
  sessions: NEVER

Running everything

Make sure the redis server, discovery server and gateway are still running, as instructed above. Now start the post backend:

cd editor/post/post-backend
gradle bootRun

Wait 30 seconds, and the post backend should appear in the Eureka dashboard, on http://localhost:8761/ :

Eureka showing Gateway and Backend

Now, we can try to load the gateway again on http://localhost:8080/ :

Gateway showing posts from post-backend

It doesn’t look very spectacular in the browser, but what happens here is that the gateway is constructing its single-page app with parts forwarded to the post backend. The basic auth login is handled by the gateway, which creates a session in Redis, containing the user Principal. The post backend, which is also secured, picks up this session and no longer needs the user to authenticate.

Conclusion

We have covered quite a bit of ground here. We’ve moved from a classic single-page webapp to a setup where we can independently deploy single-page webapps, which will be picked up by the gateway and composed into one big single-page webapp. Authentication is handled by the gateway, distributed to the app backends with Redis, and the backends can handle their authorization requirements independently from each other.

The front-end split-up is not perfect however. With old-fashioned JSP-based web applications, you can do a complete separation, with little or no side effects. The setup we built here has a couple of downsides:

  • all frontend libraries need to be in the gateway libs.js.

  • the apps are composed into 1 javascript application, so there is a risk of side effects, for example when 2 apps define the same angular modules.

An alternative could be to put the frontend completely in the gateway, and use the gateway only for the backend REST calls.

Also note that we used the default settings for Zuul, with no real fallback for the underlying Hystrix, so there is work to be done there as well.

Next post

In the next post we will extract the post repository into its own microservice, with client-side load-balancing and resilience, and we will add another editor backend. Both editor backend will use the newly created post service.