Beyond Linguistics: Real-Time Domain Event Mapping with WebSocket and Spring Boot
Build a scalable real-time notification system using Sprint Boot and WebSocket, focusing on domain event mapping, system design, and more.
Join the DZone community and get the full member experience.
Join For FreeBy definition, a markup language is a system used for annotating a document in a way that is syntactically distinguishable from the text. Essentially, it provides a way to structure and format text using tags or symbols that are embedded within the content. Markup languages are used to define elements within a document, such as headings, paragraphs, lists, links, and images. Hype Text Markup Language (HTML) is the most common of them. There are many other such as XML, SGML, Markdown, MathML, BBCode, to name a few.
This article articulates the need of and presents a minimally working version to what the term “domain markup event mapping” is conferred. Lest an unfamiliar terminology introduced abruptly make the audience assume otherwise, let us illustrate the experience as a real-time commentary of an event, say a cricket match on popular online news media. ESPN, Yahoo cricket, Cricbuzz.com, Star Sports, and BBC are among the top players in this area. I remember how they used to be 15 years ago and and now, they've evolved to cater real-time updates. With advanced backend systems, communication protocols, better design approach and of course, modern browser technologies, they have always been on the top to provide their users the best intuitive updates compensating the absence of audio and video.
Our audience must have noticed that Google and other websites have already adopted animated and visually intuitive UI components to make the user experience better.
This article focuses on the need to provide a domain specific markup event mapper for all such use cases and illustrates an approach to create a minimalistic update system using Spring boot and WebSocket.
Domain Specific Markup Event Mapper:
The client (such as the web browser) receives an event. For the sake of generality, neither the communication protocol nor message format (e.g., whether it is a text or binary message) is assumed. The message converter yields an event object that the client understands, such as a JSON object that the browser side script knows how to handle and render. We now must agree that not all notifications and the events they carry, belong to the same category and therefore, be rendered in the same way.
A running commentary, for example, may be rendered like a scrolling hypertext while a boundary or an over boundary or a fall of a wicket might require special effect as they’re rendered, to stand distinct. The role of the markup look-up engine is to identify a suitable engine given the category of an event. It delegates the event to a specific rendering strategy if one for the category is registered (known) to the system (client-side UI such as a browser). If none is found, there needs to be a fallback strategy. The four components that appear black in the image above are abstractions that the domain landscape must provide as we propose in this article. The rendering techniques for a cricket match must differ from a soccer match and coverage of an electoral poll must be presented in a different way than sports.
We must now wear the developers’ hat and gear up to put the theory into practice. We aim to make the notification system minimalist with the following:
- A media operator section that posts updates.
- The intermediary backend that sends the notifications.
- For simplicity, we will not use any broker or third party cloud messaging system.
- We've chosen vanilla WebSocket as mode of communication although other approaches such as periodical long polls, server-sent-event, SockJS can be used with their respective pros and cons.
- The viewers’ section to consume (experience) the notification.
We create a spring boot application with spring-boot-starter-websocket
and spring-boot-starter-thymeleaf
.
<?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 https://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>3.4.1</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>com.soham.demo</groupId>
<artifactId>visually-appealing-realtime-update</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>visually-appealing-realtime-update</name>
<description>visually-appealing-realtime-update</description>
<url />
<licenses>
<license />
</licenses>
<developers>
<developer />
</developers>
<scm>
<connection />
<developerConnection />
<tag />
<url />
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
We expose a WebSocket end point where clients can connect to establish a WebSocket session.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13
The server responds with a status code 101 (Switching Protocols):
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
We expose an open (non-restricted ) endpoint "score" for clients to connect and configure it to allow traffic from anywhere. This is just for illustration and is not suitable on production grade.
@Configuration
@EnableWebSocket
public class CricketScoreWebSocketConfig implements WebSocketConfigurer {
private static final String PATH_SCORE = "score";
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new TextNotificationHandler(), PATH_SCORE).setAllowedOrigins("*");
}
}
To pour in minimal effort, assuming that there will be exactly one bulletin entry operator, we'll create the class TextNotificationHandler
.
@Slf4j
public class TextNotificationHandler extends TextWebSocketHandler {
private Set<WebSocketSession> sessions = Collections.synchronizedSet(new HashSet<>());
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.debug("afterConnectionEstablished :: session established remote host: {}",session.getRemoteAddress());
sessions.add(session);
log.debug("afterConnectionEstablished :: connection from: {} is added. Current Open session count : {}",session.getRemoteAddress(),sessions.size());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
for (WebSocketSession webSocketSession : sessions) {
if (webSocketSession.isOpen()) {
webSocketSession.sendMessage(message);
}
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.debug("afterConnectionEstablished :: session closed remote host: {}",session.getRemoteAddress());
sessions.remove(session);
log.debug("afterConnectionEstablished :: connection from: {} is removed. Current Open session count : {}",session.getRemoteAddress(),sessions.size());
}
}
Now, we create the two HTML files under src/resource/templates.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Media Operator For XYZ Television Cricket Match IND vs ENG</title>
<style>
body {
margin: 0;
height: 100%;
width: 100%;
}
canvas {
display: block;
}
.container {
display: flex;
height: 100vh;
width: 100%;
}
.left-section {
background-color: lightblue;
width: 80%;
padding: 20px;
box-sizing: border-box;
}
.right-section {
width: 20%;
background-color: lightcoral;
padding: 20px;
box-sizing: border-box;
}
</style>
</head>
<body>
<div class="container">
<div class="left-section">
<canvas id="cricketField"></canvas>
</div>
<div class="right-section">
<textarea rows="10" cols="50" id="tb_Comment" placeholder="Message here"></textarea>
<button onclick="sendTextMsg()">Send</button>
<fieldset>
<legend>Quick Panel</legend>
<button onclick="sendToastMsg('boundary')">Boundary</button>
<button onclick="sendToastMsg('over-boundary')">Over-boundary</button>
<button onclick="sendToastMsg('out')">OUT!!</button>
<button onclick="sendToastMsg('100')">100* NOT OUT</button>
</fieldset>
</div>
</div>
<script>
let socket;
let dto;
window.onload = function () {
socket = new WebSocket("ws://"+window.location.host+"/score");
socket.onmessage = function (event) {
console.log(event);
};
};
const canvas = document.getElementById('cricketField');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function drawCricketField() {
ctx.fillStyle = 'green';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const fieldWidth = 600;
const fieldHeight = 400;
// Draw the oval cricket field
ctx.fillStyle = 'lightgreen';
ctx.beginPath();
ctx.ellipse(centerX, centerY, fieldWidth / 2, fieldHeight / 2, 0, 0, Math.PI * 2);
ctx.fill();
// Draw pitch and creases
ctx.fillStyle = 'white';
ctx.fillRect(centerX - 3, centerY - 150, 6, 300); // Pitch
ctx.fillRect(centerX - 150, centerY - 3, 300, 6); // Crease
// Draw stumps
ctx.fillRect(centerX - 6, centerY - 160, 4, 20);
ctx.fillRect(centerX - 2, centerY - 160, 4, 20);
ctx.fillRect(centerX + 2, centerY - 160, 4, 20);
ctx.fillRect(centerX - 6, centerY + 140, 4, 20);
ctx.fillRect(centerX - 2, centerY + 140, 4, 20);
ctx.fillRect(centerX + 2, centerY + 140, 4, 20);
}
let drawing = false;
let startX = 0;
let startY = 0;
let currentX = 0;
let currentY = 0;
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// console.log(mouseX+" " +mouseY+ctx.isPointInPath(mouseX, mouseY));
// if (ctx.isPointInPath(mouseX, mouseY)) {
drawing = true;
startX = mouseX;
startY = mouseY;
currentX = mouseX;
currentY = mouseY;
// }
});
canvas.addEventListener('mousemove', (e) => {
if (drawing) {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
currentX = mouseX;
currentY = mouseY;
clearCanvas();
drawLine(startX, startY, currentX, currentY);
}
});
canvas.addEventListener('mouseup', () => {
drawing = false;
});
canvas.addEventListener('mouseout', () => {
drawing = false;
});
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCricketField();
}
function drawLine(startX, startY, endX, endY) {
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.strokeStyle = 'red';
ctx.lineWidth = 5; // Increase the line width for better visibility
ctx.stroke();
ctx.closePath();
dto = {};
dto.startX = startX;
dto.startY = startY;
dto.endX = endX;
dto.endY = endY;
sendMessage("VISUAL", dto);
}
function sendMessage(strType, dto) {
dto.id = Date.now();
dto.type = strType;
socket.send(JSON.stringify(dto));
}
function sendTextMsg() {
dto = {};
dto.message = document.getElementById("tb_Comment").value;
sendMessage("TEXT", dto);
}
function sendToastMsg(msg) {
dto = {};
dto.message = msg;
sendMessage("TOAST", dto);
}
drawCricketField();
</script>
</body>
</html>
The full source code is available here . You can also run the application from Docker hub by this command using the target port that you prefer.
docker run -p 9876:9876 sohamsg/dockerhub:websocket-cricket-match-commetary
However, the proposition the author made at the very beginning can now be revisited to understand its usage and need. Currently, the code is written to cater to selected specific use cases considering a cricket match. However, all these codes are created by individual teams/developers, though they were targeting the same thing of course, in different ways and USPs. To help visualize the components, let us take this enum, which is used in the mark up classifier below:
public enum EvtType {
VISUAL,TEXT,TOAST , // Keep adding your event types for another domain
}
class MarkUpClassifierService{
public Optional<EvtType> classifyMessagge(AbstractMessage message){
return classifierStgragey.apply(message);
}
/**
Define your strategy to extract the category of the message.
return empty Optional unless a category is found
*/
private Function<AbstractMessage,Optional<EvtType>> classifierStgragey;
}
The mark up look-up engine looks for a mark up strategy and the mark up implementation simply renders them, fetching the strategies from the server to browser/client only once. A CDN can be used, too!
The perinodal structure looks like this:
interface IMarkup{
public void markup(AbstractMessage message, OutputStream outStream);
}
@Service
@SLF4j
@RequiredArgsConstructor
// We use it as regsitered bean in Spring but this is not specific to any framework
class MarkupLookupService{
private final MarkupRegistry regitry; // We use it as regsitered bean in Spring but this is not specific to any framework
public Optional<IMarkup> lookupMarkup(EvtType evtType){
if(regitry.supports(evtType)){
return registry.get(evtType);//write your look up logic
}else{
return Optional.<IMarkup>empty();
}
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cricket Match IND vs ENG </title>
<style>
body {
margin: 0;
overflow: hidden;
}
canvas {
display: block;
}
.container {
display: flex;
height: 100vh;
width: 100%;
}
.left-section {
background-color: lightblue;
width: 80%;
padding: 20px;
box-sizing: border-box;
}
.right-section {
width: 20%;
background-color: white;
padding: 20px;
box-sizing: border-box;
}
#toast {
visibility: hidden;
min-width: 250px;
margin-left: -125px;
background-color: #333;
color: #fff;
text-align: center;
border-radius: 5px;
padding: 16px;
position: fixed;
z-index: 1;
left: 50%;
bottom: 30px;
font-size: 37px;
}
#toast.show {
visibility: visible;
animation: fadein 0.5s, fadeout 0.5s 2.5s;
}
@keyframes fadein {
from {
top: -50px;
opacity: 0;
}
to {
top: 30px;
opacity: 1;
}
}
@keyframes fadeout {
from {
top: 30px;
opacity: 1;
}
to {
top: -50px;
opacity: 0;
}
}
</style>
</head>
<body>
<div class="container">
<div class="left-section">
<canvas id="cricketField"></canvas>
<div id="toast"></div>
</div>
<div class="right-section">
<textarea rows="10" cols="50" id="tb_Comment" placeholder="Message here"
style="color:gold;background-color:black;read-only:true" readonly></textarea>
<button onclick="sendTextMsg()">Send</button>
</div>
</div>
<script>
let socket;
let dto;
window.onload = function () {
socket = new WebSocket("ws://"+window.location.host+"/score");
socket.onmessage = function (event) {
var d = event.data;
var data = JSON.parse(d);
console.log(data + " " + data.startX);
clearCanvas();
if ("VISUAL" === data.type) {
drawLine(data.startX, data.startY, data.endX, data.endY);
} else if("TEXT"===data.type){
document.getElementById("tb_Comment").value += data.message + "\n";
}else if("TOAST"===data.type){
showToast(data.message);
}else{
console.error("Unsupported message type "+d);
}
};
};
const canvas = document.getElementById('cricketField');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
function drawCricketField() {
ctx.fillStyle = 'green';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const fieldWidth = 600;
const fieldHeight = 400;
// Draw the oval cricket field
ctx.fillStyle = 'lightgreen';
ctx.beginPath();
ctx.ellipse(centerX, centerY, fieldWidth / 2, fieldHeight / 2, 0, 0, Math.PI * 2);
ctx.fill();
// Draw pitch and creases
ctx.fillStyle = 'white';
ctx.fillRect(centerX - 3, centerY - 150, 6, 300); // Pitch
ctx.fillRect(centerX - 150, centerY - 3, 300, 6); // Crease
// Draw stumps
ctx.fillRect(centerX - 6, centerY - 160, 4, 20);
ctx.fillRect(centerX - 2, centerY - 160, 4, 20);
ctx.fillRect(centerX + 2, centerY - 160, 4, 20);
ctx.fillRect(centerX - 6, centerY + 140, 4, 20);
ctx.fillRect(centerX - 2, centerY + 140, 4, 20);
ctx.fillRect(centerX + 2, centerY + 140, 4, 20);
}
let drawing = false;
let startX = 0;
let startY = 0;
let currentX = 0;
let currentY = 0;
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// console.log(mouseX+" " +mouseY+ctx.isPointInPath(mouseX, mouseY));
// if (ctx.isPointInPath(mouseX, mouseY)) {
drawing = true;
startX = mouseX;
startY = mouseY;
currentX = mouseX;
currentY = mouseY;
// }
});
canvas.addEventListener('mousemove', (e) => {
if (drawing) {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
currentX = mouseX;
currentY = mouseY;
clearCanvas();
drawLine(startX, startY, currentX, currentY);
}
});
canvas.addEventListener('mouseup', () => {
drawing = false;
});
canvas.addEventListener('mouseout', () => {
drawing = false;
});
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCricketField();
}
function drawLine(startX, startY, endX, endY) {
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.strokeStyle = 'red';
ctx.lineWidth = 5; // Increase the line width for better visibility
ctx.stroke();
ctx.closePath();
}
function showToast(message) {
var toast = document.getElementById("toast");
toast.className = "show";
toast.textContent = mapToVisual(message);
setTimeout(function () {
toast.className = toast.className.replace("show", "");
}, 3000);
}
function mapToVisual(msg){
switch(msg){
case "100": return "100*";
case "out": return "OUT!!";
case "boundary": return "Boundary";
case "over-boundary": return "Over-boundary";
}
return "";
}
drawCricketField();
</script>
</body>
</html>
The logic to translation of marking up different types of event differently to the client can be done in multiple ways and we list down only a few:
- Write the strategy in client side code as a library (e.g., a JavaScript library).
- The downside is that updating the logic is prone to errors, as with any scripting.
- Caching and CDN—Ensuring the updates reflect and is not cached except beyond current session.
- Writing the strategy in the server side and sending transpiled Script back.
- The client side code and the backend is no more loosely coupled then.
We will cover each approach in detail some other time.
Opinions expressed by DZone contributors are their own.
Comments