diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b4a5277
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.idea/
+target/
+*.iml
diff --git a/README.md b/README.md
index b7fa57e..29fd5b1 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,40 @@
# record-net
-Simple, performant message library for Java.
+Simple, performant message library for Java, using records.
+
+record-net gives you the advantages of reflection, without the runtime costs. By registering message types before starting your work, record-net is able to generate custom serializers and deserializers for all registered message types, which translates to read and write speeds that are nearly equivalent to directly writing bytes to a stream.
+
+Here's an example of how you can use record-net:
+
+```java
+import nl.andrewl.record_net.Message;
+import nl.andrewl.record_net.Serializer;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.Socket;
+
+class Example {
+ record ChatMessage(
+ long timestamp,
+ String username,
+ String msg
+ ) implements Message {}
+
+ public static void main(String[] args) throws IOException {
+ var ser = new Serializer();
+ ser.registerType(1, ChatMessage.class);
+ var socket = new Socket("127.0.0.1", 8081);
+ var bOut = new ByteArrayOutputStream();
+ var msg = new ChatMessage(
+ System.currentTimeMillis(),
+ "andrew",
+ "Hello world!"
+ );
+ ser.writeMessage(msg, socket.getOutputStream());
+ ChatMessage response = (ChatMessage) ser.readMessage(socket.getInputStream());
+ }
+}
+```
+
+## Get record-net
+This project is published as a package on GitHub. You can view available packages [here](https://github.com/andrewlalis/record-net/packages).
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..a9fd0d1
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,43 @@
+
+
+ 4.0.0
+
+ nl.andrewl
+ record-net
+ 1.0.0
+
+
+ 17
+ 17
+ UTF-8
+
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ 5.8.2
+ test
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ 5.8.2
+ test
+
+
+
+
+
+
+ github
+ Github record-net Apache Maven Packages
+ https://maven.pkg.github.com/andrewlalis/record-net
+
+
+
\ No newline at end of file
diff --git a/src/main/java/nl/andrewl/record_net/Message.java b/src/main/java/nl/andrewl/record_net/Message.java
new file mode 100644
index 0000000..167d317
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/Message.java
@@ -0,0 +1,29 @@
+package nl.andrewl.record_net;
+
+/**
+ * Represents any message which can be sent over the network.
+ *
+ * All messages consist of a single byte type identifier, followed by a
+ * payload whose structure depends on the message.
+ *
+ */
+public interface Message {
+ /**
+ * Convenience method to get the serializer for this message's type, using
+ * the static auto-generated set of serializers.
+ * @param The message type.
+ * @return The serializer to use to read and write messages of this type.
+ */
+ @SuppressWarnings("unchecked")
+ default MessageTypeSerializer getTypeSerializer() {
+ return MessageTypeSerializer.get((Class) this.getClass());
+ }
+
+ /**
+ * Convenience method to determine the size of this message in bytes.
+ * @return The size of this message, in bytes.
+ */
+ default int byteSize() {
+ return getTypeSerializer().byteSizeFunction().apply(this);
+ }
+}
diff --git a/src/main/java/nl/andrewl/record_net/MessageReader.java b/src/main/java/nl/andrewl/record_net/MessageReader.java
new file mode 100644
index 0000000..a4130b8
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/MessageReader.java
@@ -0,0 +1,19 @@
+package nl.andrewl.record_net;
+
+import nl.andrewl.record_net.util.ExtendedDataInputStream;
+
+import java.io.IOException;
+
+@FunctionalInterface
+public interface MessageReader{
+ /**
+ * Reads all of this message's properties from the given input stream.
+ *
+ * The single byte type identifier has already been read.
+ *
+ * @param in The input stream to read from.
+ * @return The message that was read.
+ * @throws IOException If an error occurs while reading.
+ */
+ T read(ExtendedDataInputStream in) throws IOException;
+}
diff --git a/src/main/java/nl/andrewl/record_net/MessageTypeSerializer.java b/src/main/java/nl/andrewl/record_net/MessageTypeSerializer.java
new file mode 100644
index 0000000..9b25c9f
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/MessageTypeSerializer.java
@@ -0,0 +1,131 @@
+package nl.andrewl.record_net;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.RecordComponent;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * Record containing the components needed to read and write a given message.
+ *
+ * Also contains methods for automatically generating message type
+ * implementations for standard record-based messages.
+ *
+ * @param The type of message.
+ * @param messageClass The class of the message.
+ * @param byteSizeFunction A function that computes the byte size of the message.
+ * @param reader A reader that can read messages from an input stream.
+ * @param writer A writer that write messages from an input stream.
+ */
+public record MessageTypeSerializer(
+ Class messageClass,
+ Function byteSizeFunction,
+ MessageReader reader,
+ MessageWriter writer
+) {
+ private static final Map, MessageTypeSerializer>> generatedMessageTypes = new HashMap<>();
+
+ /**
+ * Gets the {@link MessageTypeSerializer} instance for a given message class, and
+ * generates a new implementation if none exists yet.
+ * @param messageClass The class of the message to get a type for.
+ * @param The type of the message.
+ * @return The message type.
+ */
+ @SuppressWarnings("unchecked")
+ public static MessageTypeSerializer get(Class messageClass) {
+ return (MessageTypeSerializer) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class) c));
+ }
+
+ /**
+ * Generates a message type instance for a given class, using reflection to
+ * introspect the fields of the message.
+ *
+ * Note that this only works for record-based messages.
+ *
+ * @param messageTypeClass The class of the message type.
+ * @param The type of the message.
+ * @return A message type instance.
+ */
+ public static MessageTypeSerializer generateForRecord(Class messageTypeClass) {
+ RecordComponent[] components = messageTypeClass.getRecordComponents();
+ if (components == null) throw new IllegalArgumentException("Cannot generate a MessageTypeSerializer for non-record class " + messageTypeClass.getSimpleName());
+ Constructor constructor;
+ try {
+ constructor = messageTypeClass.getDeclaredConstructor(Arrays.stream(components)
+ .map(RecordComponent::getType).toArray(Class>[]::new));
+ } catch (NoSuchMethodException e) {
+ throw new IllegalArgumentException(e);
+ }
+ return new MessageTypeSerializer<>(
+ messageTypeClass,
+ generateByteSizeFunction(components),
+ generateReader(constructor),
+ generateWriter(components)
+ );
+ }
+
+ /**
+ * Generates a function implementation that counts the byte size of a
+ * message based on the message's record component types.
+ * @param components The list of components that make up the message.
+ * @param The message type.
+ * @return A function that computes the byte size of a message of the given
+ * type.
+ */
+ private static Function generateByteSizeFunction(RecordComponent[] components) {
+ return msg -> {
+ int size = 0;
+ for (var component : components) {
+ try {
+ size += MessageUtils.getByteSize(component.getAccessor().invoke(msg));
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+ return size;
+ };
+ }
+
+ /**
+ * Generates a message reader for the given message constructor method. It
+ * will try to read objects from the input stream according to the
+ * parameters of the canonical constructor of a message record.
+ * @param constructor The canonical constructor of the message record.
+ * @param The message type.
+ * @return A message reader for the given type.
+ */
+ private static MessageReader generateReader(Constructor constructor) {
+ return in -> {
+ Object[] values = new Object[constructor.getParameterCount()];
+ for (int i = 0; i < values.length; i++) {
+ values[i] = in.readObject(constructor.getParameterTypes()[i]);
+ }
+ try {
+ return constructor.newInstance(values);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException(e);
+ }
+ };
+ }
+
+ /**
+ * Generates a message writer for the given message record components.
+ * @param components The record components to write.
+ * @param The type of message.
+ * @return The message writer for the given type.
+ */
+ private static MessageWriter generateWriter(RecordComponent[] components) {
+ return (msg, out) -> {
+ for (var component: components) {
+ try {
+ out.writeObject(component.getAccessor().invoke(msg), component.getType());
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+ };
+ }
+}
diff --git a/src/main/java/nl/andrewl/record_net/MessageUtils.java b/src/main/java/nl/andrewl/record_net/MessageUtils.java
new file mode 100644
index 0000000..97e9226
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/MessageUtils.java
@@ -0,0 +1,79 @@
+package nl.andrewl.record_net;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+/**
+ * Utility class which provides methods for serializing and deserializing complex
+ * data types.
+ */
+public class MessageUtils {
+ public static final int UUID_BYTES = 2 * Long.BYTES;
+ public static final int ENUM_BYTES = Integer.BYTES;
+
+ /**
+ * Gets the number of bytes that the given string will occupy when it is
+ * serialized.
+ * @param s The string. This may be null.
+ * @return The number of bytes used to serialize the string.
+ */
+ public static int getByteSize(String s) {
+ return Integer.BYTES + (s == null ? 0 : s.getBytes(StandardCharsets.UTF_8).length);
+ }
+
+ /**
+ * Gets the number of bytes that all the given strings will occupy when
+ * serialized with a length-prefix encoding.
+ * @param strings The set of strings.
+ * @return The total byte size.
+ */
+ public static int getByteSize(String... strings) {
+ int size = 0;
+ for (var s : strings) {
+ size += getByteSize(s);
+ }
+ return size;
+ }
+
+ public static int getByteSize(Message msg) {
+ return 1 + (msg == null ? 0 : msg.byteSize());
+ }
+
+ public static int getByteSize(T[] items) {
+ int count = Integer.BYTES;
+ for (var item : items) {
+ count += getByteSize(item);
+ }
+ return count;
+ }
+
+ public static int getByteSize(Object o) {
+ if (o instanceof Integer) {
+ return Integer.BYTES;
+ } else if (o instanceof Long) {
+ return Long.BYTES;
+ } else if (o instanceof String) {
+ return getByteSize((String) o);
+ } else if (o instanceof UUID) {
+ return UUID_BYTES;
+ } else if (o instanceof Enum>) {
+ return ENUM_BYTES;
+ } else if (o instanceof byte[]) {
+ return Integer.BYTES + ((byte[]) o).length;
+ } else if (o.getClass().isArray() && Message.class.isAssignableFrom(o.getClass().getComponentType())) {
+ return getByteSize((Message[]) o);
+ } else if (o instanceof Message) {
+ return getByteSize((Message) o);
+ } else {
+ throw new IllegalArgumentException("Unsupported object type: " + o.getClass().getSimpleName());
+ }
+ }
+
+ public static int getByteSize(Object... objects) {
+ int size = 0;
+ for (var o : objects) {
+ size += getByteSize(o);
+ }
+ return size;
+ }
+}
diff --git a/src/main/java/nl/andrewl/record_net/MessageWriter.java b/src/main/java/nl/andrewl/record_net/MessageWriter.java
new file mode 100644
index 0000000..b80decf
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/MessageWriter.java
@@ -0,0 +1,16 @@
+package nl.andrewl.record_net;
+
+import nl.andrewl.record_net.util.ExtendedDataOutputStream;
+
+import java.io.IOException;
+
+@FunctionalInterface
+public interface MessageWriter {
+ /**
+ * Writes this message to the given output stream.
+ * @param msg The message to write.
+ * @param out The output stream to write to.
+ * @throws IOException If an error occurs while writing.
+ */
+ void write(T msg, ExtendedDataOutputStream out) throws IOException;
+}
diff --git a/src/main/java/nl/andrewl/record_net/Serializer.java b/src/main/java/nl/andrewl/record_net/Serializer.java
new file mode 100644
index 0000000..82c2af2
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/Serializer.java
@@ -0,0 +1,124 @@
+package nl.andrewl.record_net;
+
+import nl.andrewl.record_net.util.ExtendedDataInputStream;
+import nl.andrewl.record_net.util.ExtendedDataOutputStream;
+
+import java.io.*;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * This class is responsible for reading and writing messages from streams. It
+ * also defines the set of supported message types, and their associated byte
+ * identifiers, via the {@link Serializer#registerType(int, Class)} method.
+ */
+public class Serializer {
+ /**
+ * The mapping which defines each supported message type and the byte value
+ * used to identify it when reading and writing messages.
+ */
+ private final Map> messageTypes = new HashMap<>();
+
+ /**
+ * An inverse of {@link Serializer#messageTypes} which is used to look up a
+ * message's byte value when you know the class of the message.
+ */
+ private final Map, Byte> inverseMessageTypes = new HashMap<>();
+
+ /**
+ * Constructs a new serializer instance.
+ */
+ public Serializer() {}
+
+ /**
+ * Constructs a serializer using a predefined mapping of message types and
+ * their ids.
+ * @param messageTypes A map containing message types mapped to their ids.
+ */
+ public Serializer(Map> messageTypes) {
+ messageTypes.forEach(this::registerType);
+ }
+
+ /**
+ * Helper method which registers a message type to be supported by the
+ * serializer, by adding it to the normal and inverse mappings.
+ * @param id The byte which will be used to identify messages of the given
+ * class. The value should from 0 to 127.
+ * @param messageClass The type of message associated with the given id.
+ */
+ public synchronized void registerType(int id, Class messageClass) {
+ if (id < 0 || id > 127) throw new IllegalArgumentException("Invalid id.");
+ MessageTypeSerializer type = MessageTypeSerializer.get(messageClass);
+ messageTypes.put((byte)id, type);
+ inverseMessageTypes.put(type, (byte)id);
+ }
+
+ /**
+ * Reads a message from the given input stream and returns it, or throws an
+ * exception if an error occurred while reading from the stream.
+ * @param i The input stream to read from.
+ * @return The message which was read.
+ * @throws IOException If an error occurs while reading, such as trying to
+ * read an unsupported message type, or if a message object could not be
+ * constructed for the incoming data.
+ */
+ public Message readMessage(InputStream i) throws IOException {
+ ExtendedDataInputStream d = new ExtendedDataInputStream(i);
+ byte typeId = d.readByte();
+ var type = messageTypes.get(typeId);
+ if (type == null) {
+ throw new IOException("Unsupported message type: " + typeId);
+ }
+ try {
+ return type.reader().read(d);
+ } catch (IOException e) {
+ throw new IOException("Could not instantiate new message object of type " + type.getClass().getSimpleName(), e);
+ }
+ }
+
+ /**
+ * Reads a message from the given byte array and returns it, or throws an
+ * exception if an error occurred while reading from the stream.
+ * @param data The data to read from.
+ * @return The message which was read.
+ * @throws IOException If an error occurs while reading, such as trying to
+ * read an unsupported message type, or if a message object could not be
+ * constructed for the incoming data.
+ */
+ public Message readMessage(byte[] data) throws IOException {
+ return readMessage(new ByteArrayInputStream(data));
+ }
+
+ /**
+ * Writes a message to the given output stream.
+ * @param msg The message to write.
+ * @param o The output stream to write to.
+ * @param The message type.
+ * @throws IOException If an error occurs while writing, or if the message
+ * to write is not supported by this serializer.
+ */
+ public void writeMessage(T msg, OutputStream o) throws IOException {
+ DataOutputStream d = new DataOutputStream(o);
+ Byte typeId = inverseMessageTypes.get(msg.getTypeSerializer());
+ if (typeId == null) {
+ throw new IOException("Unsupported message type: " + msg.getClass().getSimpleName());
+ }
+ d.writeByte(typeId);
+ msg.getTypeSerializer().writer().write(msg, new ExtendedDataOutputStream(d));
+ d.flush();
+ }
+
+ /**
+ * Writes a message as a byte array.
+ * @param msg The message to write.
+ * @return The bytes that were written.
+ * @param The message type.
+ * @throws IOException If an error occurs while writing, or if the message
+ * to write is not supported by this serializer.
+ */
+ public byte[] writeMessage(T msg) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream(1 + msg.byteSize());
+ writeMessage(msg, out);
+ return out.toByteArray();
+ }
+}
diff --git a/src/main/java/nl/andrewl/record_net/package-info.java b/src/main/java/nl/andrewl/record_net/package-info.java
new file mode 100644
index 0000000..a31b99e
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * Main package containing notably the {@link nl.andrewl.record_net.Serializer}
+ * and other components that may be of use for more fine-grained control.
+ */
+package nl.andrewl.record_net;
\ No newline at end of file
diff --git a/src/main/java/nl/andrewl/record_net/util/ExtendedDataInputStream.java b/src/main/java/nl/andrewl/record_net/util/ExtendedDataInputStream.java
new file mode 100644
index 0000000..8921d5f
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/util/ExtendedDataInputStream.java
@@ -0,0 +1,93 @@
+package nl.andrewl.record_net.util;
+
+import nl.andrewl.record_net.Message;
+import nl.andrewl.record_net.MessageTypeSerializer;
+
+import java.io.ByteArrayInputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Array;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+/**
+ * An extended output stream which contains additional methods for reading more
+ * complex types that are used by the Concord system.
+ */
+public class ExtendedDataInputStream extends DataInputStream {
+ public ExtendedDataInputStream(InputStream in) {
+ super(in);
+ }
+
+ public ExtendedDataInputStream(byte[] data) {
+ this(new ByteArrayInputStream(data));
+ }
+
+ public String readString() throws IOException {
+ int length = super.readInt();
+ if (length == -1) return null;
+ if (length == 0) return "";
+ byte[] data = new byte[length];
+ int read = super.read(data);
+ if (read != length) throw new IOException("Not all bytes of a string of length " + length + " could be read.");
+ return new String(data, StandardCharsets.UTF_8);
+ }
+
+ public > T readEnum(Class e) throws IOException {
+ int ordinal = super.readInt();
+ if (ordinal == -1) return null;
+ return e.getEnumConstants()[ordinal];
+ }
+
+ public UUID readUUID() throws IOException {
+ long a = super.readLong();
+ long b = super.readLong();
+ if (a == -1 && b == -1) {
+ return null;
+ }
+ return new UUID(a, b);
+ }
+
+ @SuppressWarnings("unchecked")
+ public T[] readArray(MessageTypeSerializer type) throws IOException {
+ int length = super.readInt();
+ T[] array = (T[]) Array.newInstance(type.messageClass(), length);
+ for (int i = 0; i < length; i++) {
+ array[i] = type.reader().read(this);
+ }
+ return array;
+ }
+
+ /**
+ * Reads an object from the stream that is of a certain expected type.
+ * @param type The type of object to read.
+ * @return The object that was read.
+ * @throws IOException If an error occurs while reading.
+ */
+ @SuppressWarnings("unchecked")
+ public Object readObject(Class> type) throws IOException {
+ if (type.equals(Integer.class) || type.equals(int.class)) {
+ return this.readInt();
+ } else if (type.equals(Long.class) || type.equals(long.class)) {
+ return this.readLong();
+ } else if (type.equals(String.class)) {
+ return this.readString();
+ } else if (type.equals(UUID.class)) {
+ return this.readUUID();
+ } else if (type.isEnum()) {
+ return this.readEnum((Class extends Enum>>) type);
+ } else if (type.isAssignableFrom(byte[].class)) {
+ int length = this.readInt();
+ return this.readNBytes(length);
+ } else if (type.isArray() && Message.class.isAssignableFrom(type.getComponentType())) {
+ var messageType = MessageTypeSerializer.get((Class extends Message>) type.getComponentType());
+ return this.readArray(messageType);
+ } else if (Message.class.isAssignableFrom(type)) {
+ var messageType = MessageTypeSerializer.get((Class extends Message>) type);
+ return messageType.reader().read(this);
+ } else {
+ throw new IOException("Unsupported object type: " + type.getSimpleName());
+ }
+ }
+}
diff --git a/src/main/java/nl/andrewl/record_net/util/ExtendedDataOutputStream.java b/src/main/java/nl/andrewl/record_net/util/ExtendedDataOutputStream.java
new file mode 100644
index 0000000..03922c6
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/util/ExtendedDataOutputStream.java
@@ -0,0 +1,161 @@
+package nl.andrewl.record_net.util;
+
+import nl.andrewl.record_net.Message;
+
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+/**
+ * An extended version of {@link DataOutputStream} with some extra methods
+ * that help us to write more data.
+ */
+public class ExtendedDataOutputStream extends DataOutputStream {
+ public ExtendedDataOutputStream(OutputStream out) {
+ super(out);
+ }
+
+ /**
+ * Writes a string in length-prefixed form, where the 4-byte length of the
+ * string is written, followed by exactly that many bytes. If the string
+ * is null, then a length of -1 is written, and no bytes following it.
+ * @param s The string to write.
+ * @throws IOException If an error occurs while writing.
+ */
+ public void writeString(String s) throws IOException {
+ if (s == null) {
+ writeInt(-1);
+ } else {
+ writeInt(s.length());
+ write(s.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+
+ public void writeStrings(String... strings) throws IOException {
+ for (var s : strings) {
+ writeString(s);
+ }
+ }
+
+ /**
+ * Writes an enum value as a 4-byte integer using the enum's ordinal
+ * position, or -1 if the given value is null.
+ * @param value The value to write.
+ * @throws IOException If an error occurs while writing.
+ */
+ public void writeEnum(Enum> value) throws IOException {
+ if (value == null) {
+ writeInt(-1);
+ } else {
+ writeInt(value.ordinal());
+ }
+ }
+
+ /**
+ * Writes a UUID as a 16-byte value. If the given UUID is null, then -1
+ * is written twice as two long (8 byte) values.
+ * @param uuid The value to write.
+ * @throws IOException If an error occurs while writing.
+ */
+ public void writeUUID(UUID uuid) throws IOException {
+ if (uuid == null) {
+ writeLong(-1);
+ writeLong(-1);
+ } else {
+ writeLong(uuid.getMostSignificantBits());
+ writeLong(uuid.getLeastSignificantBits());
+ }
+ }
+
+ /**
+ * Writes an array of messages using length-prefixed form. That is, we
+ * first write a 4-byte integer length that specifies how many items are in
+ * the array, followed by writing each element of the array. If the array
+ * is null, a length of -1 is written.
+ * @param array The array to write.
+ * @param The type of items in the array.
+ * @throws IOException If an error occurs while writing.
+ */
+ public void writeArray(T[] array) throws IOException {
+ if (array == null) {
+ writeInt(-1);
+ } else {
+ writeInt(array.length);
+ for (var item : array) writeMessage(item);
+ }
+ }
+
+ public void writeArray(byte[] array) throws IOException {
+ if (array == null) {
+ writeInt(-1);
+ } else {
+ writeInt(array.length);
+ write(array);
+ }
+ }
+
+ public void writeArray(int[] array) throws IOException {
+ if (array == null) {
+ writeInt(-1);
+ } else {
+ writeInt(array.length);
+ for (var item : array) writeInt(item);
+ }
+ }
+
+ public void writeArray(float[] array) throws IOException {
+ if (array == null) {
+ writeInt(-1);
+ } else {
+ writeInt(array.length);
+ for (var item : array) writeFloat(item);
+ }
+ }
+
+ /**
+ * Writes a message using null-prefixed form. That is, we first write a
+ * boolean value which is false only if the message is null. Then, if the
+ * message is not null, we write it to the stream.
+ * @param msg The message to write.
+ * @param The type of the message.
+ * @throws IOException If an error occurs while writing.
+ */
+ public void writeMessage(Message msg) throws IOException {
+ writeBoolean(msg != null);
+ if (msg != null) {
+ msg.getTypeSerializer().writer().write(msg, this);
+ }
+ }
+
+ /**
+ * Writes an object to the stream.
+ * @param o The object to write.
+ * @param type The object's type. This is needed in case the object itself
+ * is null, which may be the case for some strings or ids.
+ * @throws IOException If an error occurs while writing, or if an
+ * unsupported object is supplied.
+ */
+ public void writeObject(Object o, Class> type) throws IOException {
+ if (type.equals(Integer.class) || type.equals(int.class)) {
+ writeInt((Integer) o);
+ } else if (type.equals(Long.class) || type.equals(long.class)) {
+ writeLong((Long) o);
+ } else if (type.equals(String.class)) {
+ writeString((String) o);
+ } else if (type.equals(UUID.class)) {
+ writeUUID((UUID) o);
+ } else if (type.isEnum()) {
+ writeEnum((Enum>) o);
+ } else if (type.equals(byte[].class)) {
+ writeArray((byte[]) o);
+ } else if (type.isArray() && Message.class.isAssignableFrom(type.getComponentType())) {
+ writeArray((Message[]) o);
+ } else if (Message.class.isAssignableFrom(type)) {
+ writeMessage((Message) o);
+ } else {
+ throw new IOException("Unsupported object type: " + o.getClass().getSimpleName());
+ }
+ }
+}
diff --git a/src/main/java/nl/andrewl/record_net/util/Pair.java b/src/main/java/nl/andrewl/record_net/util/Pair.java
new file mode 100644
index 0000000..edc2206
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/util/Pair.java
@@ -0,0 +1,8 @@
+package nl.andrewl.record_net.util;
+
+/**
+ * Simple generic pair of two objects.
+ * @param The first object.
+ * @param The second object.
+ */
+public record Pair(A first, B second) {}
diff --git a/src/main/java/nl/andrewl/record_net/util/Triple.java b/src/main/java/nl/andrewl/record_net/util/Triple.java
new file mode 100644
index 0000000..ea8dc63
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/util/Triple.java
@@ -0,0 +1,9 @@
+package nl.andrewl.record_net.util;
+
+/**
+ * Simple generic triple of objects.
+ * @param The first object.
+ * @param The second object.
+ * @param The third object.
+ */
+public record Triple (A first, B second, C third) {}
diff --git a/src/main/java/nl/andrewl/record_net/util/package-info.java b/src/main/java/nl/andrewl/record_net/util/package-info.java
new file mode 100644
index 0000000..2d9bf2f
--- /dev/null
+++ b/src/main/java/nl/andrewl/record_net/util/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Contains some useful one-off utility classes.
+ */
+package nl.andrewl.record_net.util;
\ No newline at end of file
diff --git a/src/test/java/nl/andrewl/record_net/MessageTypeSerializerTest.java b/src/test/java/nl/andrewl/record_net/MessageTypeSerializerTest.java
new file mode 100644
index 0000000..7d8b974
--- /dev/null
+++ b/src/test/java/nl/andrewl/record_net/MessageTypeSerializerTest.java
@@ -0,0 +1,34 @@
+package nl.andrewl.record_net;
+
+import nl.andrewl.record_net.msg.ChatMessage;
+import nl.andrewl.record_net.util.ExtendedDataInputStream;
+import nl.andrewl.record_net.util.ExtendedDataOutputStream;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class MessageTypeSerializerTest {
+ @Test
+ public void testGenerateForRecord() throws IOException {
+ var s1 = MessageTypeSerializer.get(ChatMessage.class);
+ ChatMessage msg = new ChatMessage("andrew", 123, "Hello world!");
+ int expectedByteSize = 4 + msg.username().length() + 8 + 4 + msg.message().length();
+ assertEquals(expectedByteSize, s1.byteSizeFunction().apply(msg));
+ assertEquals(expectedByteSize, msg.byteSize());
+ ByteArrayOutputStream bOut = new ByteArrayOutputStream();
+ ExtendedDataOutputStream eOut = new ExtendedDataOutputStream(bOut);
+ s1.writer().write(msg, eOut);
+ byte[] data = bOut.toByteArray();
+ assertEquals(expectedByteSize, data.length);
+ ChatMessage readMsg = s1.reader().read(new ExtendedDataInputStream(data));
+ assertEquals(msg, readMsg);
+
+ // Only record classes can be generated.
+ class NonRecordMessage implements Message {}
+ assertThrows(IllegalArgumentException.class, () -> MessageTypeSerializer.get(NonRecordMessage.class));
+ }
+}
diff --git a/src/test/java/nl/andrewl/record_net/MessageUtilsTest.java b/src/test/java/nl/andrewl/record_net/MessageUtilsTest.java
new file mode 100644
index 0000000..20f5fae
--- /dev/null
+++ b/src/test/java/nl/andrewl/record_net/MessageUtilsTest.java
@@ -0,0 +1,27 @@
+package nl.andrewl.record_net;
+
+import nl.andrewl.record_net.msg.ChatMessage;
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.StandardCopyOption;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class MessageUtilsTest {
+ @Test
+ public void testGetByteSize() {
+ assertEquals(4, MessageUtils.getByteSize((String) null));
+ assertEquals(5, MessageUtils.getByteSize("a"));
+ assertEquals(16, MessageUtils.getByteSize("Hello world!"));
+ assertEquals(8, MessageUtils.getByteSize("", ""));
+ assertEquals(10, MessageUtils.getByteSize("a", "b"));
+ Message msg = new ChatMessage("andrew", 123, "Hello world!");
+ int expectedMsgSize = 1 + 4 + 6 + 8 + 4 + 12;
+ assertEquals(1, MessageUtils.getByteSize((Message) null));
+ assertEquals(expectedMsgSize, MessageUtils.getByteSize(msg));
+ assertEquals(4 * expectedMsgSize, MessageUtils.getByteSize(msg, msg, msg, msg));
+ assertEquals(16, MessageUtils.getByteSize(UUID.randomUUID()));
+ assertEquals(4, MessageUtils.getByteSize(StandardCopyOption.ATOMIC_MOVE));
+ }
+}
diff --git a/src/test/java/nl/andrewl/record_net/SerializerTest.java b/src/test/java/nl/andrewl/record_net/SerializerTest.java
new file mode 100644
index 0000000..02d1e98
--- /dev/null
+++ b/src/test/java/nl/andrewl/record_net/SerializerTest.java
@@ -0,0 +1,29 @@
+package nl.andrewl.record_net;
+
+import nl.andrewl.record_net.msg.ChatMessage;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class SerializerTest {
+ @Test
+ public void testReadAndWriteMessage() throws IOException {
+ Serializer s = new Serializer();
+ s.registerType(1, ChatMessage.class);
+
+ ChatMessage msg = new ChatMessage("andrew", 123, "Hello world!");
+
+ ByteArrayOutputStream bOut = new ByteArrayOutputStream();
+ s.writeMessage(msg, bOut);
+ byte[] data = bOut.toByteArray();
+ assertEquals(1 + msg.byteSize(), data.length);
+ assertEquals(data[0], 1);
+
+ ChatMessage readMsg = (ChatMessage) s.readMessage(new ByteArrayInputStream(data));
+ assertEquals(msg, readMsg);
+ }
+}
diff --git a/src/test/java/nl/andrewl/record_net/msg/ChatMessage.java b/src/test/java/nl/andrewl/record_net/msg/ChatMessage.java
new file mode 100644
index 0000000..00f4123
--- /dev/null
+++ b/src/test/java/nl/andrewl/record_net/msg/ChatMessage.java
@@ -0,0 +1,9 @@
+package nl.andrewl.record_net.msg;
+
+import nl.andrewl.record_net.Message;
+
+public record ChatMessage(
+ String username,
+ long timestamp,
+ String message
+) implements Message {}
diff --git a/src/test/java/nl/andrewl/record_net/util/ExtendedDataOutputStreamTest.java b/src/test/java/nl/andrewl/record_net/util/ExtendedDataOutputStreamTest.java
new file mode 100644
index 0000000..f79540c
--- /dev/null
+++ b/src/test/java/nl/andrewl/record_net/util/ExtendedDataOutputStreamTest.java
@@ -0,0 +1,33 @@
+package nl.andrewl.record_net.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class ExtendedDataOutputStreamTest {
+
+ @Test
+ public void testWriteString() throws IOException {
+ ByteArrayOutputStream bOut = new ByteArrayOutputStream();
+ ExtendedDataOutputStream eOut = new ExtendedDataOutputStream(bOut);
+ eOut.writeString("Hello world!");
+ byte[] data = bOut.toByteArray();
+ assertEquals(4 + "Hello world!".length(), data.length);
+ DataInputStream dIn = new DataInputStream(new ByteArrayInputStream(data));
+ assertEquals(12, dIn.readInt());
+ String s = new String(dIn.readNBytes(12));
+ assertEquals("Hello world!", s);
+
+ bOut.reset();
+ eOut.writeString(null);
+ data = bOut.toByteArray();
+ assertEquals(4, data.length);
+ dIn = new DataInputStream(new ByteArrayInputStream(data));
+ assertEquals(-1, dIn.readInt());
+ }
+}