Event Driven Hello World Program

Event-driven microservices can be straightforward to describe before they are implemented, tested and maintained. They are also highly responsive to new information in real time, with latencies in Java of below 10 microseconds 99.99% of the time depending on the functionality of the small independently deployable microservice. 

 

In this introductory article, we use an example event-driven Hello World program (a programming paradigm where the program flow is determined by events) to step through behaviour-driven development, where we describe the behaviour the business needs first as test data, and writing a very simple microservice which turns input events like this

say: Hello World

Into outputs like this, by adding an exclamation point

say: Hello World!  # <- adds an exclamation point

All the code for this example is available on GitHub.

When modelling Event-Driven systems, a useful pattern is to have event-driven core systems with gateways connecting to external systems that might not be event driven. To keep a clear separation of concern, business logic such as making a decision based on market data, or processing an order, is placed in the event-driven microservices, as these are the easiest to test, with the gateways connecting to external clients and systems being as thin as possible so they are only concerned with acting as adapters and avoid containing significant business logic. 

Domain-Driven Design is a focus of determining the requirements from domain experts. Their requirements are  further divided into event-driven microservices. Where the information is passed as a series of events between the micoservices.

The requirements for each internal microservices can be described in YAML, for Behaviour-Driven Development.


Figure 1- Gateways connect internal services to external systems

All examples are in the Chronicle-Queue-Demo/hello-world module.

A Simple Event-Driven Contract

We model events as asynchronous method calls without arguments, or one-to-many arguments e.g.

public interface Says {
   void say(String words);
}

This is the simplest Hello World example to get started. We can add to this interface other event types (methods) with multiple parameters. Parameters don’t have to be just primitives, they can also be complex data structures such as Data Transfer Objects.

There is no assumption about how the events produced by the microservice will be processed. It might be record but otherwise ignored for now, processed immediately by a single microservice, or read by multiple downstream microservices some time later.  Thus, it doesn’t return a value. Any results will be emitted as events from the respective event handlers. In programming, an event handler is a callback routine that can operate asynchronously.

External Event Producers and Consumers

Often we need to integrate with the client’s external systems. As this is a simple “Hello World” example, let’s imagine that instead of external systems connected via gateways we have a simple program that reads input from the console to provide upstream events and another simple program to write to the console, acting as a downstream gateway.

public class SaysInput {
   public static void input(Says says) throws IOException {
       BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
       for (String line; ((line = br.readLine()) != null); )
           says.say(line);
   }
}


public class SaysOutput implements Says {
   public void say(String words) {
       System.out.println(words);
   }
}

These can be integrated easily as the output of one is wired to the input of the other.

public class RecordInputToConsoleMain {
   public static void main(String[] args) throws IOException {
       // Writes text in each call to say(line) to the console
       final Says says = new SaysOutput();
       // Takes each line input and calls say(line) each time
       SaysInput.input(says);
   }
}

We can also record everything the producer performs to YAML to build tests later.

public class RecordInputAsYamlMain {
   public static void main(String[] args) throws IOException {
       // obtains a proxy that writes to the PrintStream the method calls and their  arguments
       final Says says = Wires.recordAsYaml(Says.class, System.out);
       // Takes each line input and calls say(theLine) each time
       SaysInput.input(says);
   }
}

Use the following to replay the output from a file.

public class ReplayOutputMain {
   public static void main(String[] args) throws IOException {
    // Reads the content of a Yaml file specified in args[0] and feeds it to SaysOutput.
     Wires.replay(args[0], new SaysOutput());
   }
}

Unit Tests for the RecordAsYaml and Replay Methods

To test the functionality of recordAsYaml and replay methods in isolation and verify if they work as suggested above, the following unit tests were developed.  Having lots of text in unit tests is cumbersome, and in the next section you can see how this text can be taken from files.

public class WiresTest extends WireTestCommon {
@Test
public void recordAsYaml() {
   ByteArrayOutputStream baos = new ByteArrayOutputStream();
   PrintStream ps = new PrintStream(baos);
   Says says = Wires.recordAsYaml(Says.class, ps);
   says.say("One");
   says.say("Two");
   says.say("Three");


   assertEquals("" +
           "---\n" +
           "say: One\n" +
           "...\n" +
           "---\n" +
           "say: Two\n" +
           "...\n" +
           "---\n" +
           "say: Three\n" +
           "...\n",
           new String(baos.toByteArray(), StandardCharsets.ISO_8859_1));
}


@Test
public void replay() throws IOException {
   ByteArrayOutputStream baos = new ByteArrayOutputStream();
   PrintStream ps = new PrintStream(baos);
   Says says = Wires.recordAsYaml(Says.class, ps);
   says.say("zero");
   Wires.replay("=" +
           "---\n" +
           "say: One\n" +
           "...\n" +
           "---\n" +
           "say: Two\n" +
           "...\n" +
           "---\n" +
           "say: Three\n" +
           "...\n",says);


   assertEquals("" +
           "---\n" +
           "say: zero\n" +
           "...\n" +
           "---\n" +
           "say: One\n" +
           "...\n" +
           "---\n" +
           "say: Two\n" +
           "...\n" +
           "---\n" +
           "say: Three\n" +
           "...\n", new String(baos.toByteArray(), StandardCharsets.ISO_8859_1));
}


interface Says {
   void say(String word);
}
}

By recording and replaying using YAML, our microservices are written, tested and debugged easily without any involvement of the messaging layer.

Let’s add a microservice as a data processor as a class that can have one or more event types. This microservice gets input events as text messages and adds an exclamation mark to them and relays them to the output gateway.

public class AddsExclamation implements Says {
   private final Says out;

   public AddsExclamation(Says out) {
       this.out = out;
   }

   public void say(String words) {
       this.out.say(words + "!");
   }
}


Figure 2- A microservice that adds exclamation marks to input messages.

A Single-Threaded Event-Driven Process

We can combine these all stages in one process, one thread. While this is unlikely to be useful in production, putting microservices into a single thread makes it easier to test and debug.

public class DirectWithExclamationMain {
   public static void main(String[] args) throws IOException {
       SaysInput.input(new AddsExclamation(new SaysOutput()));
   }
}

Testing a Single Event-Driven Service

Instead of embedding large amounts of text in a test, we can read resource files. This makes them easier to read and maintain.

public class AddsExclamationTest {
   @Test
   public void say() throws IOException {
YamlTester yt = YamlTester.runTest(AddsExclamation.class, "says");
assertEquals(yt.expected(), yt.actual());
   }
}


Let’s update the input to see how easy it is to maintain this test. I will change the second input to Hello World and run the test again.

src/test/resources/says/in.yaml
---
say: One
...
---
say: Hello World
...
---
say: Three
...

Not only does the test fail, I can click on the differences to see clearly why.

At this point I can either fix the test, or I can accept the change by copying and pasting the actual result over the expected result in the out.yaml file.

In the next post, we will see how to implement more realistic example processing orders, and automate many microservice tests from the configuration.  This provides a basis for creating highly performant, deterministic, redundant microservices

Conclusion

This article shows the outline of creating and testing a simple microservice, which provides the basis for microservices which are easy to deploy and maintain.

Peter Lawrey

Peter Lawrey is a Java Champion and Oracle Code One alumnus. Peter likes to inspire developers to improve the craftsmanship of their solutions and his popular blog “Vanilla Java” has had over 4 million views. Peter is the founder and architect of Chronicle Software. He has one of the top number of answers for Java and JVM on StackOverflow.com (~13K).