In this post I will show how we can connect with Gatling to a Socket.IO server which uses a custom parser using the MessagePack serialization format.

This is the second post about Socket.IO testing with Gatling. In the first post:

I showed an example of connecting to a Socket.IO server using the default parser which uses text messages.

What is MessagePack?

MessagePack is a binary serialization format. It has the advantage that payloads are usually smaller especially if they contain many numbers. The disadvantage is that Socket.IO messages serialized with MessagePack are harder to debug in the Network tab of the browser.

The msgpack Socket.IO server

The server is the same as in the previous post with the only difference being the added customer parser option.

const { Server } = require("socket.io");
const cors = require("cors");
const customParser = require("@skgdev/socket.io-msgpack-javascript");

const io = new Server({
  transports: ["websocket", "polling"],
  cors: {
    origin: "*",
    methods: ["GET", "POST"],
  },
  parser: customParser.build({
    encoder: {
      ignoreUndefined: true,
    },
  }),
});

io.on("connection", (socket) => {
  console.log("a user connected to the socket.io server: " + socket.id);

  socket.on("message", (msg) => {
    console.log("message: " + msg);
    io.emit("broadcast", "they say: " + msg);
  });

  socket.on("disconnect", () => {
    console.log("user disconnected: " + socket.id);
  });
});

io.listen(5555);

The simulation scenario

I will use the same example that I had in my previous post. For quick reference here is the Gatling scenario using the default Socket.IO parser.

ScenarioBuilder scene = scenario("WebSocket")
  .exec(ws("Connect WS").connect("/socket.io/?EIO=4&transport=websocket"))
  .exec(ws("Connect to Socket.IO").sendText("40"))
  .pause(1)
  .exec(ws("Say hi")
          .sendText("42[\"message\",\"Hi\"]")
          .await(30).on(
                  ws.checkTextMessage("checkMessage")
                          .check(regex(".*broadcast...([^\"]*)")
                                  .saveAs("response"))))
  .exec(session -> {
      System.out.println("response: " + session.get("response"));
      return session;
  })
  .exec(ws("Disconnect from Socket.IO").sendText("41"))
  .exec(ws("Close WS").close());

Exchanged messages were in a text format, "40" for connecting, "42[....]" for sending and receiving message frames and "41" for disconnecting.

Initially, I thought that I needed to use Message Pack in order to decode these text messages, but I found out that I was wrong (after several attempts and several hours).

Required changes

Sending and receiving data

According to the Socket.IO documentation https://socket.io/docs/v4/socket-io-protocol/#sending-and-receiving-data the exchange of data has the following payload:

{ type: EVENT, namespace: "/", data: ["foo"] }

So the text representation (above) is the result of the default parser encoding this payload.

I actually discovered that the payload description is not exactly correct. By debugging (this time using a msg-pack client I wrote, since Postman does not support binary messages), I found out that the namespace is actually represented with the nsp key in the payload. So the payload for a send event looks something like:

{ "type": 2, "nsp": "/", "data": ["message", "Hi"] }

Of course the namespace and the data can be different. "/" is the main (default) namespace. The data array can hold many elements. Typically, the first element is the event name (that the server is listening to with socket.on("message", ...) and the rest are the exchanged data.

Serialization with MessagePack for Java

In order to send binary serialized messages on the wire, I used the MessagePack for Java library.

The easiest way to create the JSON payload and decode it with MessagePack is to create a POJO for the payload:

class Packet {
  public int type;
  public String nsp;
  public List<String> data;

  public Packet() {
  }

  public Packet(int type, String nsp, List<String> data) {
    this.type = type;
    this.nsp = nsp;
    this.data = data;
  }
}

then instantiate an objectMapper:

ObjectMapper objectMapper = new ObjectMapper(new MessagePackFactory());

and serialize as bytes:

byte[] packSendBytes;

try {
  packSendBytes = objectMapper.writeValueAsBytes(
      new Packet(2, "/", Arrays.asList("message", "hi")));
} catch (JsonProcessingException e) {
  e.printStackTrace();
}

Then, in the simulation, I used sendBytes (instead of sendText) to send the byte[] array:

      .exec(ws("Say hi").sendBytes(packSendBytes)

Connecting and disconnecting

The same Packet POJO class worked for the connection also:

byte[] packConnectBytes;
// surround with try/catch
packConnectBytes = objectMapper.writeValueAsBytes(
    new Packet(0, "/", Arrays.asList()));

Unfortunately, it did not work for the disconnection message. The server did not except the extra data field. I had to create a different POJO DisconnectPacket for this case. (I thought to use inheritance but …)

class DisconnectPacket {
  public int type;
  public String nsp;

  public DisconnectPacket(String nsp) {
    type = 1;
    this.nsp = nsp;
  }
}
byte[] packDisconnectBytes;
// surround with try/catch
packDisconnectBytes = objectMapper.writeValueAsBytes(
    new DisconnectPacket("/"));

Checking for the server message

The simulation should check that after a client’s message to the message event, the server emits back a message to the broadcast event.

  .exec(ws("Say hi")
      .sendBytes(packSendBytes)
      .await(30).on(
          ws.checkBinaryMessage("checkMessage")
              .check(checkBroadcastEventSaveMessage)))

Note the use of checkBinaryMessage instead of checkTextMessage.

Creating this check involved writing longer and a bit more complex code so I decided to put it in a separate CheckBuilder checkBroadcastEventSaveMessage variable. I also had to write my (first) Gatling transform function to perform the check:

  CheckBuilder checkBroadcastEventSaveMessage = bodyBytes()
      .transform(bytes -> {
        // process the received bytes array
        //    decode the data into a Packet POJO
        // check that the client sent to the `broadcast` event
        // and return the message
      })
      .saveAs("response");

It turned out a bit long but it is not difficult to understand:

  CheckBuilder checkBroadcastEventSaveMessage = bodyBytes()
      .transform(bytes -> {
        Packet packet = null;

        try {
          packet = objectMapper.readValue(bytes, Packet.class);
        } catch (IOException e) {
          throw new RuntimeException(
              "Failed to deserialize incoming bytes to Packet object. ", e);
        }

        String eventName = packet.data.get(0);

        if (!eventName.equals("broadcast")) {
          throw new RuntimeException(
              "Expected broadcast event, got " + eventName + " instead");
        }

        String message = packet.data.get(1);
        return message;
      })
      .saveAs("response");

The complete Gatling Simulation

If I put everything together, we get the following class:

public class SimpleBinSocketIOSimulation extends Simulation {

  HttpProtocolBuilder httpProtocol = http
      .wsBaseUrl("ws://localhost:5555")
      .wsReconnect()
      .wsMaxReconnects(5)
      .wsAutoReplySocketIo4();

  byte[] packConnectBytes;
  byte[] packSendBytes;
  byte[] packDisconnectBytes;

  // Instantiate ObjectMapper for MessagePack
  ObjectMapper objectMapper = new ObjectMapper(new MessagePackFactory());

  /**
   * POJO for the packet object that is sent serialized to the server. Same packet
   * is deserialized from the server.
   */

  static class Packet {
    public int type;
    public String nsp;
    public List<String> data;

    public Packet() {
    }

    public Packet(int type, String nsp, List<String> data) {
      this.type = type;
      this.nsp = nsp;
      this.data = data;
    }
  }

  /**
   * A different POJO is needed for the disconnect packet because the server does
   * not expect the data field and responds with an error if it is present.
   */
  static class DisconnectPacket {
    public int type;
    public String nsp;

    public DisconnectPacket(String nsp) {
      type = 1;
      this.nsp = nsp;
    }
  }

  {
    try {
      packConnectBytes = objectMapper.writeValueAsBytes(
          new Packet(0, "/", Arrays.asList()));
      packSendBytes = objectMapper.writeValueAsBytes(
          new Packet(2, "/", Arrays.asList("message", "hi")));
      packDisconnectBytes = objectMapper.writeValueAsBytes(
          new DisconnectPacket("/"));
    } catch (JsonProcessingException e) {
      e.printStackTrace();
    }
  }

  CheckBuilder checkBroadcastEventSaveMessage = bodyBytes()
      .transform(bytes -> {
        Packet packet = null;

        try {
          packet = objectMapper.readValue(bytes, Packet.class);
        } catch (IOException e) {
          throw new RuntimeException(
              "Failed to deserialize incoming bytes to Packet object. ", e);
        }

        String eventName = packet.data.get(0);

        if (!eventName.equals("broadcast")) {
          throw new RuntimeException(
              "Expected broadcast event, got " + eventName + " instead");
        }

        String message = packet.data.get(1);
        return message;
      }).saveAs("response");

  ScenarioBuilder scene = scenario("WebSocket")
      .exec(ws("Connect WS")
          .connect("/socket.io/?EIO=4&transport=websocket"))
      .exec(ws("Connect to Socket.IO").sendBytes(packConnectBytes))
      .pause(1)
      .exec(ws("Say hi")
          .sendBytes(packSendBytes)
          .await(30).on(
              ws.checkBinaryMessage("checkMessage")
                  .check(checkBroadcastEventSaveMessage)))

      .exec(session -> {
        System.out.println("response: " + session.get("response"));
        return session;
      })
      .exec(ws("Disconnect from Socket.IO").sendBytes(packDisconnectBytes))
      .exec(ws("Close WS").close());

  {

    setUp(scene.injectOpen(atOnceUsers(1))).protocols(httpProtocol);
  }
}

Hopefully, this will be useful for somebody experimenting with Gatling and Socket.IO servers using MessagePack. It was definitely fun for me to complete this small project and write this post.

All this work is part of a project in which I am building a complex Gatling simulation. Probably more things will come out of this project and I will find more opportunities to write and share a post.

Thanks for reading!

Disclaimer

The presented code is not optimal and certainly nor reusable. In a real project I would define the POJO classes in different files and I would create reusable abstractions for the serialization/deserialization and the exchange of messages. I wanted to keep the example very basic and have everything in a single class so that it can easily be copied and reproduced.