Real Time Polling App with Java and JavaScript

Published: February 28, 2018  •  java, javascript

This blog post is based on a Traversy Media video. If you are not already know Traversy, you should definitely checkout his content. He publishes a lot of free videos on his YouTube channel, has courses on Udemy and other resources, visit his homepage to find out more. Great learning resources for full stack developers.

In this blog post we will create a polling application where users can vote for their favourite operating system and see the vote result in real time in a pie chart. It's not a one-to-one copy of Traversy's project (although I copied the HTML code) and is using a different technology stack behind the scenes.

Here is a list of all the tools and libraries we will use in this project

Client

Library Homepage
Parcel https://parceljs.org/
echarts https://ecomfe.github.io/echarts-doc/public/en/index.html
muicss https://www.muicss.com/
uuid https://www.npmjs.com/package/uuid
Polyfills classlist-polyfill, core-js, event-source-polyfill, whatwg-fetch

Server

Library Homepage
Maven https://maven.apache.org/
Spring https://projects.spring.io/spring-framework/
Spring Boot https://projects.spring.io/spring-boot/
sse-eventbus https://github.com/ralscha/sse-eventbus
MapDB http://www.mapdb.org/

Overview

The visible part of the application is an HTML page consisting of a form where users can vote for their favourite operating system and a pie chart that shows the polling result in real time overview png

Behind the scenes we have a JavaScript application that sends the user vote to a Spring Boot server with a POST request. The JavaScript application also opens a server-sent event connection to the server and receives the vote result in real time as soon as users are voting, it then displays the result in a pie chart.

The server is written in Java and is using Spring and Spring Boot. It handles the vote POST request, stores the poll data in a MapDB database to make it persistent and broadcasts the current vote result to all connected clients via server-sent events.

overview


Server

As usual, I start my Spring Boot application with a visit to the https://start.spring.io page. For this project we only need the Web dependency.

Then we add the additional dependencies to the pom.xml

    <dependency>
      <groupId>ch.rasc</groupId>
      <artifactId>sse-eventbus</artifactId>
      <version>1.1.6</version>
    </dependency>

    <dependency>
        <groupId>org.mapdb</groupId>
        <artifactId>mapdb</artifactId>
        <version>3.0.5</version>
    </dependency>

pom.xml

Next we open the main class (I renamed it to Application.java) and enable the sse event bus with the @EnableSseEventBus annotation. Behind the scenes this configures a SseEventBus singleton, that we can inject into other spring managed beans.

@SpringBootApplication
@EnableSseEventBus
public class Application {

Application.java

Next we create a custom resource configuration.

@Configuration
class ResourceConfig implements WebMvcConfigurer {

  @Autowired
  private Environment environment;

  @Override
  public void addResourceHandlers(ResourceHandlerRegistry registry) {
    if (this.environment.acceptsProfiles("development")) {
      String userDir = System.getProperty("user.dir");
      registry.addResourceHandler("/**")
          .addResourceLocations(Paths.get(userDir, "../client/dist").toUri().toString())
          .setCachePeriod(0);
    }
    else {
      registry.addResourceHandler("/index.html")
          .addResourceLocations("classpath:/static/")
          .setCacheControl(CacheControl.noCache()).resourceChain(false)
          .addResolver(new GzipResourceResolver());

      registry.addResourceHandler("/**").addResourceLocations("classpath:/static/")
          .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic())
          .resourceChain(false).addResolver(new GzipResourceResolver());
    }
  }

  @Override
  public void addViewControllers(ViewControllerRegistry registry) {
    registry.addViewController("/").setViewName("redirect:/index.html");
  }

}

ResourceConfig.java

If the application runs with the "development" profile the configuration sets the web root to "../client/dist". This way we don't have to restart the Spring Boot service whenever we change code on the client side during development.

When we start a production build, the build system bundles the client code into the server JAR file and for that we configure the resource handler to look for these resources in the classpath:/static/ folder. The configuration also makes sure that the index.html is served with a nocache caching header and the other resources are sent with a cache control header with an expiry date one year in the future. The client build system (Parcel) precompresses the resources with gzip, so we add the GzipResourceResolver that is able to serve these precompressed files. This saves a few cpu cycles because the server no longer needs to compress resources on the fly.

The configuration also adds a redirect configuration from / to index.html.

See also my blog post about Parcel and Maven where I describe this approach.


The main application logic is located in the PollController class.

The constructor of the class creates or opens the MapDB database and initializes the votes with zeros, if the database is empty.
MapDB provides not only Maps, but also Sets, Lists and Queues that are backed by off-heap or on-disk storage. It's a combination between a collection library and a database engine. For this example we use a Map that is backed by a file (./counter.db) and therefore is persistent and survives a server restart.

    this.db = DBMaker.fileDB("./counter.db").transactionEnable().make();
    this.pollMap = this.db.hashMap("polls", Serializer.STRING, Serializer.LONG)
        .createOrOpen();

    for (String os : oss) {
      this.pollMap.putIfAbsent(os, 0L);
    }

PollController.java

The method poll() implements the POST endpoint that handles the user vote. First it inserts the vote into the database.

  @PostMapping("/poll")
  @ResponseStatus(code = HttpStatus.NO_CONTENT)
  public void poll(@RequestBody String os) {
    this.pollMap.merge(os, 1L, (oldValue, one) -> oldValue + one);
    this.db.commit();
    sendPollData(null);
  }

PollController.java

After that, the method broadcast the new result to all connected clients with a server-sent event. The payload of the server-sent event is a simple comma separated string (e.g. 10,11,4,1), each number represents the current voting total for one of the four operating systems ("Windows", "macOS", "Linux", "Other")

  private void sendPollData(String clientId) {
    StringBuilder sb = new StringBuilder(10);

    for (int i = 0; i < oss.length; i++) {
      sb.append(this.pollMap.get(oss[i]));
      if (i < oss.length - 1) {
        sb.append(',');
      }
    }

    SseEvent.Builder builder = SseEvent.builder().data(sb.toString());
    if (clientId != null) {
      builder.addClientId(clientId);
    }
    this.eventBus.handleEvent(builder.build());
  }

PollController.java

The register() method implements the endpoint for the initial server-sent event request. The sse-eventbus library requires the client to send a unique id that identifies the client. The method then registers the client and sends the current voting result back to the browser. This way the client does not have to wait until somebody votes and instead immediately receives the current voting result and is able to display the pie chart.

  @GetMapping("/register/{id}")
  public SseEmitter register(@PathVariable("id") String id,
      HttpServletResponse response) {
    response.setHeader("Cache-Control", "no-store");
    SseEmitter sseEmitter = this.eventBus.createSseEmitter(id, SseEvent.DEFAULT_EVENT);

    // send the initial data only to this client
    sendPollData(id);

    return sseEmitter;
  }

PollController.java

For bundling the client and server into one package we use the frontend-maven-plugin and maven-antrun-plugin. You find more information about this setup in my blog post about Parcel and Maven.


Client

The client dependencies are managed by npm. We start this project in an empty directory and call npm init to create the package.json file. Then create a src directory and install the dependencies.

npm install echarts
npm install muicss
npm install uuid

The application should also support older browsers so we install a few polyfills. If you don't target older browser you only need event-source-polyfill because Microsoft Edge browser supports most modern standards except server-sent events.

npm install classlist-polyfill
npm install core-js
npm install event-source-polyfill
npm install whatwg-fetch

The application will load the polyfills conditionally with Parcel's built-in support for code splitting, this is a technique I described in this blog post.

Create a new JavaScript file that just imports the polyfills

import "event-source-polyfill";
import "whatwg-fetch";
import "classlist-polyfill";

polyfill.js

The entry point of the application (main.js) checks if the browser supports all necessary functions natively. If not, it loads the polyfill.js dynamically into the browser and then starts the application (init()).

import {init} from './app';
import "core-js/modules/es6.promise";
import "core-js/modules/es6.array.iterator";

if (browserSupportsAllFeatures()) {
    init();
} else {
    import('./polyfill').then(() => init()).catch(e => console.log(e));
}

function browserSupportsAllFeatures() {
    return window.EventSource && window.fetch;
}

main.js

The main application method checks if the user has already voted. It does that by checking for an entry in localStorage. This is a very rudimentary check and not recommended for real world usage, an experienced user can simply delete this entry in the storage with the browser developer tools.

Based on if the user has already voted or not, the GUI displays an information that he has already voted or shows the operating system selection.
The init() method then registers a click listener on the vote button. The handler sends the user choice with a POST request to the /poll endpoint, and inserts the hasVoted entry into localStorage.

export function init() {

    const alreadyVoted = localStorage.getItem('hasVoted');
    document.getElementById('hasVotedAlreadyErrorMsg').classList.toggle('hidden', !alreadyVoted);
    document.getElementById('vote-form').classList.toggle('hidden', alreadyVoted);

    const voteButton = document.getElementById('vote-button');

    voteButton.addEventListener('click', e => {
        localStorage.setItem('hasVoted', true)
        const choice = document.querySelector('input[name=os]:checked').value;

        fetch('poll', {
            method: 'POST',
            body: choice
        }).then(() => {
            document.getElementById('voted').classList.remove('hidden');
            document.getElementById('hasVotedAlreadyErrorMsg').classList.add('hidden');
            document.getElementById('vote-form').classList.add('hidden');
        }).catch((e) => console.log(e));
    });

app.js

The next part of the init() method configures the pie chart, starts the server-sent events connection with new EventSource(...) and registers a message listener for incoming events.
The handler splits the comma separated string coming from the server, calculates the total and updates the chart.

    const eventSource = new EventSource(`register/${uuidv4()}`);
    eventSource.addEventListener('message', response => {
        const pollData = response.data.split(',').map(Number);
        const total = pollData.reduce((accumulator, currentValue) => accumulator + currentValue);

        chart.setOption({
            title: {
                text: `Total Votes: ${total}`
            },
            series: {
                data: [
                    { value: pollData[0], name: oss[0] },
                    { value: pollData[1], name: oss[1] },
                    { value: pollData[2], name: oss[2] },
                    { value: pollData[3], name: oss[3] }
                ],
            }
        });

    }, false);

app.js


CSS

The CSS file only contains two rules. The main purpose is to import the MUI CSS library. This is a lightweight CSS framework that implements Google's Material design.

@import '../node_modules/muicss/dist/css/mui.min.css';

body {
  font-family: "Roboto", "Helvetica Neue", Helvetica, Arial;
}

.hidden { 
  display: none; 
}

main.css

This is already everything we need to implement for the client.

As mentioned above we use Parcel for building and bundling the application. First we need to install it

npm install parcel-bundler -D

We also install shx to get support for platform independent shell commands and the parcel-plugin-compress plugin that precompresses all resources with gzip and brotli.

npm install shx -D
npm install parcel-plugin-compress -D

Out of the box, Parcel already transpiles our code into ECMAScript 5 code with Babel, we don't have to configure anything. We can configure the target platforms with a browserlist query in package.json.

  "browserslist": [
    "> 1%",
    "last 2 versions"
  ]

package.json

Then we add three scripts to the scripts section of the package.json file.

  "scripts": {
    "prebuild": "shx rm -rf dist/*",
    "build": "parcel build src/index.html --public-url ./",
    "watch": "parcel watch src/index.html --public-url ./"
  },

package.json

The prebuild script deletes everything from the dist folder before npm executes the build script.
The watch script is used during development where Parcel starts a file watcher and rebuilds changed resources on the fly.

The watch option only starts a file watcher, it does not start a http server like the parcel src/index.html command. Because we serve the content with the Spring Boot application, there is no need for an additional web server during development.
Serving the client application like this has the advantage that we don't need a CORS configuration, if you are planning to install client resources together with the Spring Boot application in the jar file.

If you want to install the client and server on different origins (different protocol, host or port) you need to enable CORS on the PollController.

@Controller
@CrossOrigin
public class PollController {

If everything is in place you can test the application.
First build the client with the watch command from the client directory.

npm run watch

Then start the server from the server directory

.\mvnw.cmd spring-boot:run -Dspring.profiles.active=development

If you are on a macOS or Linux computer you can start Maven with the ./mvnw.sh script.

Open the browser with the URL http://localhost:8080 and you should see the application.


For creating a production build execute this command from the server directory.

.\mvnw.cmd clean package

This command automatically starts the npm run build command in the client folder and copies the client code from the client/dist folder into the jar file.

If the build was successful you can start the application with

java -jar target\poll-0.0.1.jar

Test the application with the URL http://localhost:8080


You find the complete source code for the project on GitHub:
https://github.com/ralscha/blog/tree/master/poll