Upgrading AISmessages from Java 8 to Java 9

Motivation

AISmessages was born in 2011. It was originally written for Java 7 and later upgraded for Java 8 (lambda expressions). Now is the time to upgrade it for Java 9 – with the biggest new feature being an encapsulation mechanism called modules.

Java 11 is already out – and Java 12 is just around the corner. But a lot of organizations are still at Java 8 considering an upgrade to Java 9 – and getting from 8 to 9 seems like to biggest hurdle for many, because it may require some reorganization of source code – and the understanding of a new concept as opposed to “just” a few new keywords.

This blog is a brief walk-through of the steps I want through to migrate AISmessage from Java 8 to become a Java 9 module. 

Introduction to modules

A Java module is a new way to modularize applications and to express dependencies between modules.

A module encapsulates a number of classes and makes just a subset of these visible outside of the module. In this way a module can hide a number of classes for its internal implementation of functionality – and only expose the few necessary classes as an API to the outside world. The modularity provided by this mechanism is a lot stronger than what could previously be achieved using visibility modifiers such as public, private and protected. A module is also said to “encapsulate” its implementation.

It is possible to explicitly name each module and to state its dependencies. 

In this post I am not going to compete with the large number of thorough and well-written introductions to Java 9 modules which is available for free on the Internet. Instead I will focus on how modules are applied to AISmessages.

All formal details of Java 9 modules can be found in JCP-376.

And a very good introduction is provided by Mark Reinhold in “The State of the Module System“. Jakob Jenkov has also written a good tutoral on “Java Modules“, and so has Baeldung with “A Guide to Java 9 Modularity“.

Introducing modules in AISmessages

module-info.java

AISmessages will only contain a single Java module. Therefore we can use the compiler’s “single module mode” as specified in JEP-261. What this means is, that we can introduce modules simply by adding a file called module-info.java in the src/main/java folder. No other reorganization of files or folders is required.

Projects consisting of multiple module do not have this advantage – they need to reorganize their folder structure a bit, so that each Java module gets its own top level folder named after the module.

The module-info.java file initially looks like this:

module dk.tbsalling.ais.messages {
}

This declares a new Java 9 module with the name of dk.tbsalling.ais.messages. This alone is not very useful – for other Java applications to be able to use packages inside the dk.tbsalling.ais.messages module we need to declare these packages for export. AISmessages has never been structure with this in mind – so we may need to reorganize the packages and classes of AISmessages a bit – but for now we will start by exporting the dk.tbsalling.aismessages package, which is done like this:

module dk.tbsalling.ais.messages {
    exports dk.tbsalling.aismessages;
}

Notably the module declaration does not contain any requires keywords, which would express dependencies to other modules. This is a hallmark feature of AISmessages.

Migrating code to Java 9 standard modules

Now if we compile AISmessages with this – an important thing is discovered: In file AISMessage.java there are import statements for package java.beans.IntrospectionException – which now resides in module java.beans.IntrospectionException. This is not one of the Java standard modules (which can be seen with java --list-modules) and would therefore require a requires keyword in the module-info.java file. Since this is unwanted by design method dataFields() in the AISMessage.java file was modified to avoid dependencies to classes outside the standard Java modules. The changes to can be seen in commit b93c06a4.  

Next it would be discovered from compiler errors, that AISmessages also depends on package java.util.logging – which now resides in module java.logging. This is also not one of the standard Java modules. Luckily Java 9 contains a new logging mechanism to replace good old java.util.logging (JUL) – and this new logging mechanism is contained in the standard modules. Hence a lot of classes in AISmessages were migrated from JUL to Java 9 logging. The actual code changes are contained in commits 4c197d39 and d7537a67.

Creating a demo application

AISmessages was never designed with modules in mind. But now with module-info.java in place we need to think about which exact classes need to be exported, and which not. One way to do this is a create a demo application depending on and using AISmessages. It could have the following structure:

aisdemo tbsalling$ tree 
.
├── pom.xml
├── src
│   ├── main
│   │   ├── java
│   │   │   ├── dk
│   │   │   │   └── tbsalling
│   │   │   │   └── ais
│   │   │   │   └── demo
│   │   │   │   └── DemoApp.java
│   │   │   └── module-info.java

where pom.xml has the following contents:

<?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>

    <groupId>dk.tbsalling.ais</groupId>
    <artifactId>demo</artifactId>
    <version>1.0-SNAPSHOT</version>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.0</version>
                <configuration>
                    <source>9</source>
                    <target>9</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
    <packaging>jar</packaging>
    <name>aisapp</name>
    <description>AISDemo is a demo application for AISmessages</description>
    <url>http://tbsalling.dk</url>

    <dependencies>
        <dependency>
            <groupId>dk.tbsalling</groupId>
            <artifactId>aismessages</artifactId>
            <version>3.0.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

</project>

The maven-compiler-plugin is explicitly stated because we need a version newer than 3.7.0 to work with modules – and also maven itself must be newer than 3.5.0.

The demo application is itself a module – and it must be declared to depend on AISmessages; hence its module-info.java file looks like:

module dk.tbsalling.ais.demo {
    requires dk.tbsalling.ais.messages;
}

And finally the demo project contains a single class which will flow a number of NMEA armoured AIS messages into AISmessages for decoding:

package dk.tbsalling.ais.demo;

import dk.tbsalling.aismessages.AISInputStreamReader;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;

public class DemoApp {

    public static void main(String[] args) {
        new DemoApp().runDemo();
    }

    public void runDemo() {
        InputStream inputStream = new ByteArrayInputStream(demoNmeaStrings.getBytes());

        System.out.println("AISMessages Demo App");
        System.out.println("--------------------");

        AISInputStreamReader streamReader = new AISInputStreamReader(inputStream, aisMessage ->
                System.out.println("Received AIS message from MMSI " + aisMessage.getSourceMmsi().getMMSI() + ": " + aisMessage)
        );

        try {
            streamReader.run();
        } catch (IOException e) {
            throw new RuntimeException(e.getMessage(), e);
        }
    }

    private final String demoNmeaStrings = new String(
            "!AIVDM,1,1,,A,18UG;P0012G?Uq4EdHa=c;7@051@,0*53\n" +
                    "!AIVDM,2,1,1,,539L8BT29ked@90F220I8TE<h4pB22222222220o1p?4400Ht00000000000,0*49\n" +
                    "!AIVDM,2,2,1,,00000000008,2*6C\n" +
                    "!AIVDM,1,1,,A,15NIrB0001G?endE`CpIgQSN08K6,0*02\n" +
                    "!AIVDM,1,1,,B,152Hn;?P00G@K34EWE0d>?wN28KB,0*12\n" +
                    ...
                    "!AIVDM,1,1,,A,152SGj001to?TvlEg4`H?6UL08Jo,0*36"
    );

}

The complete implementation of the demo application is available on github: https://github.com/tbsalling/aisdemo.

Exporting proper packages

For the demo application to work it quickly becomes evident that AISmessages needs to export the following packages:

module dk.tbsalling.ais.messages {
    exports dk.tbsalling.aismessages;
    exports dk.tbsalling.aismessages.ais;
    exports dk.tbsalling.aismessages.ais.exceptions;
    exports dk.tbsalling.aismessages.ais.messages;
    exports dk.tbsalling.aismessages.ais.messages.types;
    exports dk.tbsalling.aismessages.ais.messages.asm;
}

so the module-info.java file of AISmessages has been extended to accomodate this. This has the side-effect that the socket-based classes nmea/NMEAMessageSocketClient.java and demo/SocketDemoApp.java are no longer reachable from outside the module. This is acceptable because the main entry point to AISmessages nowadays is AISInputStreamReader.java. The other two have therefore been marked as deprecated and will at some point be deleted or moved to the new aisdemo project.

Availability

The version of AISmessages upgraded to be a Java 9 module is available from

Conclusion / wrap-up

This post describes to process of migrating AISmessages to become a Java 9 module and demonstrated that it works using a small demo application.

The last commit prior to the introduction of Java 9 is tagged last-java8-commit.

The following files were changed in the introduction of AISmessages as a Java 9 module:

  • Created
    • src/main/java/module-info.java
  • Updated
    • pom.xml
    • src/main/java/dk/tbsalling/aismessages/ais/messages/AISMessage.java
    • src/main/java/dk/tbsalling/aismessages/ais/messages/DataLinkManagement.java
    • src/main/java/dk/tbsalling/aismessages/ais/messages/types/SOTDMACommunicationState.java
    • src/main/java/dk/tbsalling/aismessages/nmea/NMEAMessageHandler.java
    • src/main/java/dk/tbsalling/aismessages/nmea/NMEAMessageInputStreamReader.java
    • src/main/java/dk/tbsalling/aismessages/nmea/NMEAMessageSocketClient.java
    • src/test/java/dk/tbsalling/aismessages/ais/messages/AidToNavigationReportTest.java

Creating, sharing and running a Docker image to decode AIS messages

Recently, I showed how to use AISMessages to quickly build a Spring Boot based HTTP/JSON-service capable of converting NMEA-armoured AIS messages into JSON-based parsed AIS messages (what are AIS messages?). Now we want to make it even easier to get this AIS decoder service running, by building and sharing a Docker image of the service, which can easily be downloaded and spun up by anyone using Docker.

Getting ready

To get going we first want to make sure, that Docker is installed. For our purposes, we will use Docker for Mac. So go and grab that if you don’t already have it. If you are preferring a certain package manager or are using a different operating system, you will have to get Docker for that – take a look at “Get Started with Docker”.

With Docker properly installed, you should be able to run:

$ docker version

and get a sensible output.

Second we clone the source code of our AISdecoder into a folder on the local harddrive:

$ git clone https://github.com/tbsalling/aisdecoder.git

For the sake of getting the repository in the right starting state, we will rewind it a bit to the following commit:

$ git checkout 7c02cbcef2ff273ab157e41fa71b193ae3304a93

And finally we compile the project in order to produce the binary artifact that we will run using Docker:

$ ./gradlew build
...
BUILD SUCCESSFUL in 4s
5 actionable tasks: 5 executed
$

Now we are ready – and this is the file that we want to run in a Docker container:

$ ls -lh build/libs/
total 32000
-rw-r--r--  1 tbsalling  staff    16M 24 Sep 11:30 aisdecoder-0.0.1-SNAPSHOT.jar

Adding the Dockerfile

With all prerequisites in place, the first thing we want to do is to add a Dockerfile to the project. The Dockerfile describes how Docker should build the Docker image. It could look like this:

FROM openjdk:11-jre-slim
MAINTAINER Thomas Borg Salling "tbsalling@tbsalling.dk"
COPY build/libs/aisdecoder-0.0.1-SNAPSHOT.jar /app/aisdecoder.war
ENTRYPOINT ["java", "-jar", "/app/aisdecoder.war"]
EXPOSE 8080/tcp

The FROM keyword specifies the Docker base image. The rest of the Dockerfile can roughly be considered to be changes or additions to this base image. Base images can be self-built or they can be searched or browsed on e.g. Docker Hub, where contributors upload and share images. We choose to use the openjdk:11-jre-slim image from Docker Hub, which is a Linux-based image with the OpenJDK version of Java 11 pre-installed. For a Spring Boot-based Java SE application this is a good start.

The MAINTAINER keyword mainly adds meta information to the image and is not very important here.

COPY on the other hand is quite important here. It copies the compiled .war-file from your local developer machine into the generated Docker image – and places it in folder /app as aisdecoder.war.

ENTRYPOINT defines the executable command, that will be fired by default when running a container based on this image using docker run ... later on. As you can probably see, this is equivalent to java -jar /app/aisdecoder.war – i.e. a command line based launch of our Java SE application.

Finally, EXPOSE 8080/tcp tells Docker, that a container launched from this image listens on the specified network port at runtime. In this case in listens for TCP-based network traffic on port 8080 – which is exactly where the embedded Tomcat in our Java SE application listens. So any traffic going to port 8080 of this container will go to our own embedded Tomcat.

To learn more Dockerfile keywords – and details of those used here – it is a good idea to familiarize yourself with the reference documentation for Dockerfiles.

Building the Docker image

With the Dockerfile in place it is time to actually create the Docker image. This can be done like this:

$ docker build .
Sending build context to Docker daemon  16.95MB
Step 1/5 : FROM openjdk:11-jre-slim
 ---> 422e4d3c11a7
Step 2/5 : MAINTAINER Thomas Borg Salling "tbsalling@tbsalling.dk"
 ---> Using cache
 ---> 4ce7f868ea8b
Step 3/5 : COPY build/libs/aisdecoder-0.0.1-SNAPSHOT.jar /app/aisdecoder.war
 ---> Using cache
 ---> 7a277936c416
Step 4/5 : ENTRYPOINT ["java", "-jar", "/app/aisdecoder.war"]
 ---> Using cache
 ---> 97e0dee65253
Step 5/5 : EXPOSE 8080/tcp
 ---> Running in a10e415ccf76
Removing intermediate container a10e415ccf76
 ---> 9f37cd551132
Successfully built 9f37cd551132

This means, that Docker has now successfully built an image identified by 9f37cd551132.

Running the Docker image as a container

To spawn a container based on this image, we can issue the following command line:

$ docker run -p 8080:8080 9f37cd551132

This instructs Docker to launch a new container based on image 9f37cd551132. Thanks to the -p option docker will bind to port 8080 on the local host and forward traffic on this port to port 8080 inside the Docker container. In effect, this causes traffic on port 8080 on the host machine to be forwarded to the embedded Tomcat inside our Java SE application running in the Docker container.

Using the container

So, with our Docker container running, we can now reach its functionality by sending HTTP traffic to port 8080 on our host machine. Like this:

$ curl -X POST http://localhost:8080/decode -H 'Content-Type: application/json' -d '[ "!AIVDM,1,1,,A,18UG;P0012G?Uq4EdHa=c;7@051@,0*53" ]'

… which (as we have seen previously) will result in this output:

[{"nmeaMessages":[{"rawMessage":"!AIVDM,1,1,,A,18UG;P0012G?Uq4EdHa=c;7@051@,0*53","valid":true,"sequenceNumber":null,"radioChannelCode":"A","checksum":83,"numberOfFragments":1,"fragmentNumber":1,"messageType":"AIVDM","encodedPayload":"18UG;P0012G?Uq4EdHa=c;7@051@","fillBits":0}],"metadata":{"source":"SRC1","received":"2018-09-24T09:33:45.690187Z","decoderVersion":"2.2.2","category":"AIS"},"repeatIndicator":0,"sourceMmsi":{"mmsi":576048000},"navigationStatus":"UnderwayUsingEngine","rateOfTurn":0,"speedOverGround":6.6,"positionAccuracy":false,"latitude":37.912167,"longitude":-122.42299,"courseOverGround":350.0,"trueHeading":355,"second":40,"specialManeuverIndicator":"NotAvailable","raimFlag":false,"communicationState":{"syncState":"UTCDirect","slotTimeout":1,"numberOfReceivedStations":null,"slotNumber":null,"utcHour":8,"utcMinute":20,"slotOffset":null},"messageType":"PositionReportClassAScheduled","transponderClass":"A","valid":true}]

Building a Docker image with a tag

You may have experienced that 9f37cd551132 is not a terribly easy “name” to remember. Actually it is a hash value – and it changes dramatically with every little change to our image. To ease this, Docker supports associating descriptive – easier-to-remember – names with these hashes. So Docker images can be built with a name like this:

$ docker build -t tbsalling/aisdecoder:latest .
...
Successfully built 9f37cd551132
Successfully tagged tbsalling/aisdecoder:latest

Here tbsalling/aisdecoder is the repository name, and latest is the actual tag name. So now tbsalling/aisdecoder:latest points to the image with hash value 9f37cd551132.

Read more about Docker’s valid tags.

Running a Docker container using a tag

With a tag name in place, a Docker container can be spun up like this:

$ docker run -p 8080:8080 tbsalling/aisdecoder:latest

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.0.5.RELEASE)

2018-09-24 11:05:20.737  INFO 1 --- [           main] d.t.a.d.a.AisdecoderApplication          : Starting AisdecoderApplication on 8c6771c64973 with PID 1 (/app/aisdecoder.war started by root in /)
2018-09-24 11:05:2
...

Uploading an image to Docker hub

With all this work done – wouldn’t it be nice if we could share the result? This would allow anyone to run aisdecoder locally by just downloading our Docker image and run it in a Docker container? Luckily this is possible by uploading our image to Docker Hub.

To do that first make sure, that you have a valid account on https://hub.docker.com. Then, from the command line login like this:

$ docker login --username=tbsalling
Password: 
Login Succeeded

With the image already built (see above) we can now push (upload) it to Docker Hub using the repository and tag names like this:

$ docker push tbsalling/aisdecoder:latest
The push refers to repository [docker.io/tbsalling/aisdecoder]
308058d2da0d: Pushed 
7d2319767e1d: Mounted from library/openjdk 
36fdef6aaa51: Mounted from library/openjdk 
0854ef12fba3: Mounted from library/openjdk 
9a27a9751438: Mounted from library/openjdk 
f9af8abefa4e: Mounted from library/openjdk 
latest: digest: sha256:0f93b0c3b65b794ce628d135515732c0f8a0fa826a8ccb0df9882086cd0d29dd size: 1577

Now the image is uploaded to Docker Hub. You can see that for yourself by visiting https://hub.docker.com/r/tbsalling/aisdecoder/.

Now let us logout of Docker Hub so that we could be anyone in the public:

$ docker logout
Removing login credentials for https://index.docker.io/v1/

Any random person (with Docker installed…) can now pull (download) and run this image as simple as:

$ docker pull tbsalling/aisdecoder:latest
latest: Pulling from tbsalling/aisdecoder
Digest: sha256:0f93b0c3b65b794ce628d135515732c0f8a0fa826a8ccb0df9882086cd0d29dd
Status: Image is up to date for tbsalling/aisdecoder:latest

$ docker run tbsalling/aisdecoder:latest

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.0.5.RELEASE)

2018-09-24 11:12:30.158  INFO 1 --- [           main] d.t.a.d.a.AisdecoderApplication          : Starting AisdecoderApplication on 4ab60bf292ce with PID 1 (/app/aisdecoder.war started by root in /)

Conclusion

In this post I have shown how to create a Docker image containing an HTTP/JSON-based decoder for NMEA messages with AIS-contents. I showed how to upload and share this image via Docker Hub – and demonstrated how anyone can pull this image and run the AIS decoder from scratch with just 2 command line instructions (provided Docker is already installed).

Have fun 🙂

Creating a Spring Boot based AIS message decoder

To demonstrate how easy it is to parse AIS messages (what is an AIS message?) with my open source library AISmessages, this post shows how to create a Spring Boot based microservice which can receive NMEA strings via HTTP and respond with the decoded AIS messages in JSON format.

So – for an HTTP request with a JSON array of NMEA strings like this:

POST http://localhost:8080/decode

Content-Type: application/json

[ 
  "!AIVDM,1,1,,A,18UG;P0012G?Uq4EdHa=c;7@051@,0*53",
  "!AIVDM,2,1,0,B,539S:k40000000c3G04PPh63<00000000080000o1PVG2uGD:00000000000,0*34",
  "!AIVDM,2,2,0,B,00000000000,2*27"
]

… we would like a response like this:

[
  {
    "repeatIndicator":0,
    "sourceMmsi": { "mmsi":576048000 },
    "navigationStatus":"UnderwayUsingEngine",
    "rateOfTurn":0,
    "speedOverGround":6.6,
    "positionAccuracy":false,
    "latitude":37.912167,
    "longitude":-122.42299,
    "courseOverGround":350.0
    "trueHeading":355,
    "second":40,
    "specialManeuverIndicator":"NotAvailable",
    "raimFlag":false,
    "communicationState": {
      "syncState":"UTCDirect",
      "slotTimeout":1,
      "numberOfReceivedStations":null,
      "slotNumber":null,
      "utcHour":8,
      "utcMinute":20,
      "slotOffset":null
    },
    "messageType":"PositionReportClassAScheduled",
    "transponderClass":"A",
    "valid":true
  }
]

Initializing a new Spring Boot project

A quick way to build such a service is to use Spring MVC. So, first we need to initialize a new Spring Boot project. An easy way to do this is to visit https://start.spring.io and fill in the form like this:


Generate and download the resulting project. Then move it to a suitable directory on your machine and unzip it like this:

$ mv ~/Downloads/aisdecoder.zip .
$ unzip aisdecoder.zip 
Archive:  aisdecoder.zip
   creating: aisdecoder/
  inflating: aisdecoder/gradlew      
   creating: aisdecoder/gradle/
   creating: aisdecoder/gradle/wrapper/
   creating: aisdecoder/src/
   creating: aisdecoder/src/main/
   creating: aisdecoder/src/main/java/
   creating: aisdecoder/src/main/java/dk/
   creating: aisdecoder/src/main/java/dk/tbsalling/
   creating: aisdecoder/src/main/java/dk/tbsalling/ais/
   creating: aisdecoder/src/main/java/dk/tbsalling/ais/decoder/
   creating: aisdecoder/src/main/java/dk/tbsalling/ais/decoder/aisdecoder/
   creating: aisdecoder/src/main/resources/
   creating: aisdecoder/src/main/resources/static/
   creating: aisdecoder/src/main/resources/templates/
   creating: aisdecoder/src/test/
   creating: aisdecoder/src/test/java/
   creating: aisdecoder/src/test/java/dk/
   creating: aisdecoder/src/test/java/dk/tbsalling/
   creating: aisdecoder/src/test/java/dk/tbsalling/ais/
   creating: aisdecoder/src/test/java/dk/tbsalling/ais/decoder/
   creating: aisdecoder/src/test/java/dk/tbsalling/ais/decoder/aisdecoder/
  inflating: aisdecoder/.gitignore   
  inflating: aisdecoder/build.gradle  
  inflating: aisdecoder/gradle/wrapper/gradle-wrapper.jar  
  inflating: aisdecoder/gradle/wrapper/gradle-wrapper.properties  
  inflating: aisdecoder/gradlew.bat  
  inflating: aisdecoder/settings.gradle  
  inflating: aisdecoder/src/main/java/dk/tbsalling/ais/decoder/aisdecoder/AisdecoderApplication.java  
  inflating: aisdecoder/src/main/resources/application.properties  
  inflating: aisdecoder/src/test/java/dk/tbsalling/ais/decoder/aisdecoder/AisdecoderApplicationTests.java 

As a smoke test we will first build new freshly, unmodified project - this is done with Gradle like this:

$ cd aisdecoder
$ ./gradlew build
... <a lot of build information>
BUILD SUCCESSFUL in 15s
5 actionable tasks: 5 executed

With the boiler plate project just built, we should see that it runs:

$ ./gradlew bootRun

> Task :bootRun

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.0.5.RELEASE)

... <a lot of log output omitted>

o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2018-09-13 07:59:01.475  INFO 10989 --- [           main] d.t.a.d.a.AisdecoderApplication          : Started AisdecoderApplication in 1.7 seconds (JVM running for 2.008)
<=========----> 75% EXECUTING [21s]
> :bootRun

All seems well. The Spring MVC web application is running - but not doing much useful yet.

Adding custom code

Adding AISmessages as a dependency

The first thing we will do, is to add AISmessages as a dependency. This is done by adding this line into build.gradle:

...
dependencies {
  compile group: 'dk.tbsalling', name: 'aismessages', version: '2.2.3'   
  ...
}
...

Adding Spring MVC Controller

Next we will add the Spring MVC controller which handle incoming HTTP requests. This controller should be able to receive a JSON array of NMEA strings and output a JSON array of AIS messages.

So, in folder src/main/java/dk/tbsalling/ais/decoder/ we add file AisdecoderController.java like this:

package dk.tbsalling.ais.decoder.aisdecoder;

import dk.tbsalling.aismessages.ais.messages.AISMessage;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import java.util.Collections;
import java.util.List;

@RestController
public class AisdecoderController {

    @RequestMapping(
        value = "/decode",
        method = RequestMethod.POST,
        consumes = MediaType.APPLICATION_JSON_VALUE,
        produces = MediaType.APPLICATION_JSON_VALUE
    )
    public List<AISMessage> decode(@RequestBody List<String> nmea) {
        return Collections.emptyList();
    }

}

This class is discovered by Spring through classpath scanning at startup, and handles incoming HTTP POST requests headed for URI /decode - such as http://localhost:8080/decode. The current implementation is mostly boiler plate and does nothing useful.

What we want it to do, is to call a service class which can convert the received NMEA strings into AIS messages. Like this:

@RestController
public class AisdecoderController {

    @Autowired
    private AisdecoderService aisdecoderService;

    ...

    public List<AISMessage> decode(@RequestBody List<String> nmea) {
        return aisdecoderService.decode(nmea);
    }

}

Adding AIS decode service

Then we need need the AisdecoderService. This is the most custom part of the code and where the real work happens. It should receive a list of n NMEA messages, convert and return these as a list of m AIS messages.

We start by adding to src/main/java/dk/tbsalling/ais/decoder/ the class AisdecoderService.java:

package dk.tbsalling.ais.decoder.aisdecoder;

import dk.tbsalling.aismessages.ais.messages.AISMessage;

import java.util.List;

@Service
@RequestScope
public class AisdecoderService {
    
    public List<AISMessage> decode(List<String> nmea) {
    }
    
}

So - how do we implement the decode method? The key here is class NMEAMessageHandler from AISmessages. NMEAMessageHandler is a class which can keep consuming NMEA messages and perform a callback whenever the received messages result in the successful decoding of a complete AIS message. Sometimes NMEA messages and AIS messages correspond 1:1 - other times it takes 2 NMEA messages to decode 1 AIS message.

So - we will extend AisdecoderService like this:

...
public class AisdecoderService implements Consumer<AISMessage> {
    
    public List<AISMessage> decode(List<String> nmeaMessagesAsStrings) {
        NMEAMessageHandler nmeaMessageHandler = new NMEAMessageHandler("SRC1", this);
    }
    
    @Override
    public void accept(AISMessage aisMessage) {
        aisMessages.add(aisMessage);
    }
}

Now the decode()-method initializes a NMEAMessageHandler. This handler is handed this (the decoder itself) to that it can make callbacks whenever an AIS message is fully constructed. To be used for callbacks, the AisdecoderService needs to implement the Consumer<AISMessage> interface.

Next, we need to start feeding the NMEA messages to the NMEAMessageHandler. One way to do that is this loop which iterates over all the NMEA strings and passes each one to the NMEAMessageHandler:

...
public class AisdecoderService implements Consumer<AISMessage> {
    ...
    public List<AISMessage> decode(List<String> nmeaMessagesAsStrings) {
       ...
        // Decode all received messages
        nmeaMessagesAsStrings.forEach(nmeaMessageAsString -> {
            try {
                NMEAMessage nmeaMessage = NMEAMessage.fromString(nmeaMessageAsString);
                nmeaMessageHandler.accept(nmeaMessage);
            } catch(NMEAParseException e) {
                System.err.printf(e.getMessage());
            }
        });
    }
    ...
}

Everytime the NMEA message handler can put the NMEA pieces together a complete AIS message, a callback is made to the AisdecoderService#accept(AISMessage msg) method. This method needs to store i AIS messages in a list, so that they can be returned by the decoder later:


public class AisdecoderService implements Consumer<AISMessage> {
    ...
    private final List<AISMessage> aisMessages = new LinkedList<>();

    @Override
    public void accept(AISMessage aisMessage) {
        aisMessages.add(aisMessage);
    }
    ...
}

Finally, in the decode() method, we must deal with the situation, where there are no more NMEA messages. This calls for a flush of the NMEAMessageHandler and return of the collected AIS messages:

...
public class AisdecoderService implements Consumer<AISMessage> {
    ...
    public List<AISMessage> decode(List<String> nmeaMessagesAsStrings) {
         ...
        // Flush receiver for unparsed message fragments
        List unparsedMessages = nmeaMessageHandler.flush();
        unparsedMessages.forEach(unparsedMessage -> {
            System.err.println("NMEA message not used: " + unparsedMessage);
        });

        // Return result
        return aisMessages;
    }
    ...
}

Complete code

The complete code resulting from the above can be viewed on Github: https://github.com/tbsalling/aisdecoder/tree/ready/spring-boot-webservice

Run it yourself

The code can then be cloned, compiled, run and invoked like this:

$ git clone https://github.com/tbsalling/aisdecoder.git
Cloning into 'aisdecoder'...
remote: Counting objects: 32, done.
remote: Compressing objects: 100% (18/18), done.
remote: Total 32 (delta 0), reused 32 (delta 0), pack-reused 0
Unpacking objects: 100% (32/32), done.
$ cd aisdecoder/
$ git checkout 7c02cbcef2ff273ab157e41fa71b193ae3304a93
$ ./gradlew build
...
BUILD SUCCESSFUL in 5s
$ ./gradlew bootRun

> Task :bootRun

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.0.5.RELEASE)

...
<=========----> 75% EXECUTING [42s]
> :bootRun

Then, in a separate terminal window the web service can be invoked with e.g. Postman or curl (on Linux or MacOS), like this:

$ curl -X POST http://localhost:8080/decode -H 'Content-Type: application/json' -d '[ "!AIVDM,1,1,,A,18UG;P0012G?Uq4EdHa=c;7@051@,0*53" ]'
[{"nmeaMessages":[{"rawMessage":"!AIVDM,1,1,,A,18UG;P0012G?Uq4EdHa=c;7@051@,0*53","valid":true,"sequenceNumber":null,"numberOfFragments":1,"fragmentNumber":1,"radioChannelCode":"A","checksum":83,"messageType":"AIVDM","encodedPayload":"18UG;P0012G?Uq4EdHa=c;7@051@","fillBits":0}],"metadata":{"source":"SRC1","received":"2018-09-13T10:28:19.661343Z","decoderVersion":"2.2.2","category":"AIS"},"repeatIndicator":0,"sourceMmsi":{"mmsi":576048000},"navigationStatus":"UnderwayUsingEngine","rateOfTurn":0,"speedOverGround":6.6,"positionAccuracy":false,"latitude":37.912167,"longitude":-122.42299,"courseOverGround":350.0,"trueHeading":355,"second":40,"specialManeuverIndicator":"NotAvailable","raimFlag":false,"communicationState":{"syncState":"UTCDirect","slotTimeout":1,"numberOfReceivedStations":null,"slotNumber":null,"utcHour":8,"utcMinute":20,"slotOffset":null},"messageType":"PositionReportClassAScheduled","transponderClass":"A","valid":true}]

That's it 🙂