From 33715fef02cd330dbd773cd408fa62e5188bce2e Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Sat, 16 Apr 2022 13:03:21 +0200 Subject: [PATCH] Added first version stuff. --- .gitignore | 3 + README.md | 40 ++++- pom.xml | 43 +++++ .../java/nl/andrewl/record_net/Message.java | 29 ++++ .../nl/andrewl/record_net/MessageReader.java | 19 +++ .../record_net/MessageTypeSerializer.java | 131 ++++++++++++++ .../nl/andrewl/record_net/MessageUtils.java | 79 +++++++++ .../nl/andrewl/record_net/MessageWriter.java | 16 ++ .../nl/andrewl/record_net/Serializer.java | 124 ++++++++++++++ .../nl/andrewl/record_net/package-info.java | 5 + .../util/ExtendedDataInputStream.java | 93 ++++++++++ .../util/ExtendedDataOutputStream.java | 161 ++++++++++++++++++ .../java/nl/andrewl/record_net/util/Pair.java | 8 + .../nl/andrewl/record_net/util/Triple.java | 9 + .../andrewl/record_net/util/package-info.java | 4 + .../record_net/MessageTypeSerializerTest.java | 34 ++++ .../andrewl/record_net/MessageUtilsTest.java | 27 +++ .../nl/andrewl/record_net/SerializerTest.java | 29 ++++ .../andrewl/record_net/msg/ChatMessage.java | 9 + .../util/ExtendedDataOutputStreamTest.java | 33 ++++ 20 files changed, 895 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/nl/andrewl/record_net/Message.java create mode 100644 src/main/java/nl/andrewl/record_net/MessageReader.java create mode 100644 src/main/java/nl/andrewl/record_net/MessageTypeSerializer.java create mode 100644 src/main/java/nl/andrewl/record_net/MessageUtils.java create mode 100644 src/main/java/nl/andrewl/record_net/MessageWriter.java create mode 100644 src/main/java/nl/andrewl/record_net/Serializer.java create mode 100644 src/main/java/nl/andrewl/record_net/package-info.java create mode 100644 src/main/java/nl/andrewl/record_net/util/ExtendedDataInputStream.java create mode 100644 src/main/java/nl/andrewl/record_net/util/ExtendedDataOutputStream.java create mode 100644 src/main/java/nl/andrewl/record_net/util/Pair.java create mode 100644 src/main/java/nl/andrewl/record_net/util/Triple.java create mode 100644 src/main/java/nl/andrewl/record_net/util/package-info.java create mode 100644 src/test/java/nl/andrewl/record_net/MessageTypeSerializerTest.java create mode 100644 src/test/java/nl/andrewl/record_net/MessageUtilsTest.java create mode 100644 src/test/java/nl/andrewl/record_net/SerializerTest.java create mode 100644 src/test/java/nl/andrewl/record_net/msg/ChatMessage.java create mode 100644 src/test/java/nl/andrewl/record_net/util/ExtendedDataOutputStreamTest.java 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>) 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) type.getComponentType()); + return this.readArray(messageType); + } else if (Message.class.isAssignableFrom(type)) { + var messageType = MessageTypeSerializer.get((Class) 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()); + } +}