Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.
Week 11
Up to this point, our web applications have required a single request from a client and a response from the server. Web page content remains static until a new request is made.
WebSockets are built into web browsers and provide 2-way communication between the browser and the server. This allows them to talk back and forth continuously, like a phone call.
With web sockets, applications can allow a communication channel to be left open after the initial request and response is made.
Multiple users who connect can interact live. When a user sends a new request, the server can respond by sending data out to everyone connected.
In this lesson, we will create a chat app where each client makes a request to a shared server.
req.getRequestDispatcher("group-chat.jsp").forward(req, resp);
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<h2>Chat</h2>
<form id="messageForm">
<div class="form-group">
<label for="userName">Your Name</label>
<input type="text" class="form-control" id="userName">
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea class="form-control" id="message" name="message" rows="5"></textarea>
</div>
<button type="submit" class="btn btn-primary">Send</button>
</form>
<!-- Error Notification Placeholder -->
<div id="errorText" class="alert alert-danger d-none" role="alert"></div>
<!-- Message Placeholder -->
<div id="messages"></div>
</div>
</div>
</div>
console.log("file found");
<script src="scripts/group-chat.js"></script>
#messages {
border: 1px solid black;
height: 300px;
padding: 10px;
overflow-y: auto;
}
#messages div {
margin-bottom: 10px;
}
#messages p {
padding: 5px 10px;
margin: 0;
border-radius: 10px;
}
#messages span {
padding: 5px;
font-size: 12px;
font-style: italic;
}
#messages .in {
margin-right: 100px;
}
#messages .in p {
background-color: hsl(210, 0%, 70%);
}
#messages .out {
margin-left: 100px;
}
#messages .out p {
background-color: hsl(210, 100%, 50%);
color: white;
}
<link rel="stylesheet" href="styles/group-chat.css">
{"name":"Marc","message":"Hello world"}
Client-side JavaScript will read the form data, package it in a JSON string and send that to the server.
The server will interpret the JSON object to find all the data inside.
The server will create a repackaged JSON object and respond by sending it back to all subscribed client's browser.
To implement this on the server, create a Java class called MyJson. Put it in the "shared" package.
import jakarta.json.Json;
import jakarta.json.JsonObject;
import java.io.StringWriter;
public class MyJson {
private JsonObject json;
public MyJson(JsonObject json) {
this.json = json;
}
public JsonObject getJson() {
return json;
}
public void setJson(JsonObject json) {
this.json = json;
}
public String toString() {
StringWriter writer = new StringWriter();
Json.createWriter(writer).write(this.json);
return writer.toString();
}
}
The constructor takes a JsonObject as a parameter. Getter and setter methods are added to handle that JsonObject.
The toString() method converts the JsonObject to a String format that resembles JSON. It uses the createWriter method of the Json class to create a JsonWriter object by passing it a StringWriter.
A StringWriter is better suited for transmission than a standard String object.
The JsonWriter write method then takes our JsonObject and converts it to a String representing the JSON version of the data.
The MyJson class can be used for all Websocket-related projects.
Create a new Java class called MyDecoder to handles the decoding of incoming json data. Data is read in as JSON Strings. The result is a MyJson object.
When a client sends json through the WebSocket, the web server uses the MyDecoder class to translate JSON into a MyJson object.
Have this class implement the Decoder.Text<T> interface.
Implement the abstract methods decode(String s) and willDecode(String s)
public class MyDecoder implements Decoder.Text<MyJson> {
@Override
public MyJson decode(String s) throws DecodeException {
JsonObject jsonObject = Json.createReader(new StringReader(s)).readObject();
return new MyJson(jsonObject);
}
@Override
public boolean willDecode(String s) {
boolean result;
try {
JsonObject jsonObject = Json.createReader(new StringReader(s)).readObject();
result = true;
} catch (JsonException jex) {
result = false;
}
return result;
}
}
Create a new Java class called MyEncoder to handles converting a MyJson object into a JSON string for transmission.
public class MyEncoder implements Encoder.Text<MyJson> {
@Override
public String encode(MyJson myJson) throws EncodeException {
return myJson.toString();
}
}
An endpoint is where the socket connects to the server. Tomcat will direct requests to the endpoint as they arrive.
Create a new WebSocket Endpoint class called GroupChatEndpoint. The server will use this file to respond to requests over the WebSocket.
Change the annotation to include the endpoint value along with a list of one or more encoder and decoder classes.
@ServerEndpoint(
value = "/group-chat/endpoint",
encoders = {MyEncoder.class},
decoders = {MyDecoder.class}
)
This class needs to maintain a collection of connected Session objects. They are different from the HttpSession object. They are the session connections that will be notified when a new message comes in.
private static final Set<Session> subscribers = Collections.synchronizedSet(new HashSet<Session>());
HashSet is one of the fundamental data structures in the Java Collections API. It is similar to a HashMap, but does not store keys/value pairs.
Both HashSet and HashMap do not allow duplicate values. There can be at most one null value. It doesn't maintain insertion order and is not thread-safe.
Create an onOpen method to be called every time a client connects (from a web browser) the web server.
@OnOpen
public void onOpen(Session session) {
subscribers.add(session);
System.out.println("Subscriber count: " + subscribers.size());
}
This class will eventually maintain a collection of connected Session objects. A session is a client connection that will be notified when a new message comes in. They are different from the HttpSession object.
The onClose method is called when a connected client asks to cancel the connection.
@OnClose
public void onClose(Session session) {
subscribers.remove(session);
System.out.println("Subscriber count: " + subscribers.size());
}
@OnError
public void onError(Throwable throwable) {
System.err.println("ERROR: " + throwable.getMessage());
}
onError() describes the actions to perform when something goes wrong. In this case we will display an error one the webpage. This will be called if the endpoint URL is incorrect.
A JavaScript file will contain all the WebSocket functionality required by the client side of the application.
Declare a variable representing the full URi to the Group Chat Endpoint. When deployed to Azure, "ws:" needs to be "wss:".
var wsProtocol = 'ws://';
if (window.location.protocol === 'https:') {
wsProtocol = 'wss://';
}
const wsUri = wsProtocol + document.location.host + document.location.pathname + "endpoint";
const websocket = new WebSocket(wsUri);
Next, the file establishes onopen, close, and onerror functions that respond to the WebSocket’s events.
websocket.onopen = function (event) {
console.log("opened websocket: " + wsUri);
};
function displayError(msg) {
const errorText = document.getElementById("errorText");
errorText.innerText = msg;
errorText.classList.remove("d-none"); // Displays the errors message
}
// You do not need websocket.close or websocket.onerror
@OnMessage
public void onMessage(MyJson myJson, Session session) throws IOException, EncodeException {
System.out.println(myJson);
for (Session subscriber : subscribers) {
if (!subscriber.equals(session)) {
subscriber.getBasicRemote().sendObject(myJson);
}
}
}
const messageForm = document.getElementById("messageForm");
messageForm.addEventListener("submit", function(event) {
event.preventDefault(); // Do not send WS data to a servlet
// Remove any previous error message
resetErrorMessage();
// Require the user's name
const userName = document.getElementById("userName").value;
if(userName === "") {
displayError("Name is required");
return;
}
// Require the message form field
const message = document.getElementById("message").value;
if(message === "") {
displayError("Message is required");
return;
}
// Build a JSON object and convert it to a string so it can be sent
const json = JSON.stringify({"name": userName, "message": message});
// Send the message
sendMessage(json);
// Reset the next message so it's ready for the next message
resetMessageBox();
// Update the message output box just like we would with an incoming message
});
function resetErrorMessage() {
const errorText = document.getElementById("errorText");
errorText.classList.add("d-none"); // Hides the errors message
errorText.innerText = ""; // Resets the previous error message
}
// this function is called to send a message to the server endpoint
function sendMessage(json) {
// Only send JSON if the websocket is open
if(websocket.readyState === websocket.OPEN) {
websocket.send(json);
}
}
function resetMessageBox() {
const message = document.getElementById("message");
message.value = ""; // removes any existing text from message box
message.focus(); // moves the cursor's focus to the message form field
}
websocket.onmessage = function (event) {
updateTextArea(event.data, "in");
};
/*
* Update the textarea by appending the supplied text to the text that is
* already there. The text shows up as JSON, so it has to be parsed into
* a JSON object to let us retrieve the data.
*/
function updateMessages(data, inOut) {
// Parse the data as JSON so the fields can be accessed
const json = JSON.parse(data);
// Use the JSON notation to retrieve the data fields
const name = json.name;
const message = json.message;
// Build the text to display then show it
let result = (inOut === "in") ? '<div class="in">' : '<div class="out">';
result += `<p>${message}</p>`;
result += `<span>${(inOut === "in") ? name : "Me"}</span>`;
result += "</div>";
const messageBox = document.getElementById("messages");
messageBox.innerHTML += result;
// Attempt to move the scrolling of the textarea to show the lowest item
messageBox.scrollTop = messageBox.scrollHeight;
// TODO: Extra Credit, only scroll to the bottom if the scrollbar is already at the bottom. Don't scroll down if the user has scrolled up.
}
messageForm.addEventListener("submit", function(event) {
// Code omitted
// Update the message output box just like we would with an incoming message
updateTextArea(json, "out");
});
By Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.