Added first version stuff.
This commit is contained in:
parent
1bcbc8be54
commit
33715fef02
|
@ -0,0 +1,3 @@
|
||||||
|
.idea/
|
||||||
|
target/
|
||||||
|
*.iml
|
40
README.md
40
README.md
|
@ -1,2 +1,40 @@
|
||||||
# record-net
|
# 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).
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>nl.andrewl</groupId>
|
||||||
|
<artifactId>record-net</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter-api</artifactId>
|
||||||
|
<version>5.8.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-engine -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter-engine</artifactId>
|
||||||
|
<version>5.8.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<!-- Setup for deploying to GitHub packages with mvn deploy. -->
|
||||||
|
<distributionManagement>
|
||||||
|
<repository>
|
||||||
|
<id>github</id>
|
||||||
|
<name>Github record-net Apache Maven Packages</name>
|
||||||
|
<url>https://maven.pkg.github.com/andrewlalis/record-net</url>
|
||||||
|
</repository>
|
||||||
|
</distributionManagement>
|
||||||
|
</project>
|
|
@ -0,0 +1,29 @@
|
||||||
|
package nl.andrewl.record_net;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents any message which can be sent over the network.
|
||||||
|
* <p>
|
||||||
|
* All messages consist of a single byte type identifier, followed by a
|
||||||
|
* payload whose structure depends on the message.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public interface Message {
|
||||||
|
/**
|
||||||
|
* Convenience method to get the serializer for this message's type, using
|
||||||
|
* the static auto-generated set of serializers.
|
||||||
|
* @param <T> The message type.
|
||||||
|
* @return The serializer to use to read and write messages of this type.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
default <T extends Message> MessageTypeSerializer<T> getTypeSerializer() {
|
||||||
|
return MessageTypeSerializer.get((Class<T>) 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package nl.andrewl.record_net;
|
||||||
|
|
||||||
|
import nl.andrewl.record_net.util.ExtendedDataInputStream;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface MessageReader<T extends Message>{
|
||||||
|
/**
|
||||||
|
* Reads all of this message's properties from the given input stream.
|
||||||
|
* <p>
|
||||||
|
* The single byte type identifier has already been read.
|
||||||
|
* </p>
|
||||||
|
* @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;
|
||||||
|
}
|
|
@ -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.
|
||||||
|
* <p>
|
||||||
|
* Also contains methods for automatically generating message type
|
||||||
|
* implementations for standard record-based messages.
|
||||||
|
* </p>
|
||||||
|
* @param <T> 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<T extends Message>(
|
||||||
|
Class<T> messageClass,
|
||||||
|
Function<T, Integer> byteSizeFunction,
|
||||||
|
MessageReader<T> reader,
|
||||||
|
MessageWriter<T> writer
|
||||||
|
) {
|
||||||
|
private static final Map<Class<?>, 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 <T> The type of the message.
|
||||||
|
* @return The message type.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public static <T extends Message> MessageTypeSerializer<T> get(Class<T> messageClass) {
|
||||||
|
return (MessageTypeSerializer<T>) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class<T>) c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a message type instance for a given class, using reflection to
|
||||||
|
* introspect the fields of the message.
|
||||||
|
* <p>
|
||||||
|
* Note that this only works for record-based messages.
|
||||||
|
* </p>
|
||||||
|
* @param messageTypeClass The class of the message type.
|
||||||
|
* @param <T> The type of the message.
|
||||||
|
* @return A message type instance.
|
||||||
|
*/
|
||||||
|
public static <T extends Message> MessageTypeSerializer<T> generateForRecord(Class<T> messageTypeClass) {
|
||||||
|
RecordComponent[] components = messageTypeClass.getRecordComponents();
|
||||||
|
if (components == null) throw new IllegalArgumentException("Cannot generate a MessageTypeSerializer for non-record class " + messageTypeClass.getSimpleName());
|
||||||
|
Constructor<T> 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 <T> The message type.
|
||||||
|
* @return A function that computes the byte size of a message of the given
|
||||||
|
* type.
|
||||||
|
*/
|
||||||
|
private static <T extends Message> Function<T, Integer> 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 <T> The message type.
|
||||||
|
* @return A message reader for the given type.
|
||||||
|
*/
|
||||||
|
private static <T extends Message> MessageReader<T> generateReader(Constructor<T> 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 <T> The type of message.
|
||||||
|
* @return The message writer for the given type.
|
||||||
|
*/
|
||||||
|
private static <T extends Message> MessageWriter<T> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 <T extends Message> 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package nl.andrewl.record_net;
|
||||||
|
|
||||||
|
import nl.andrewl.record_net.util.ExtendedDataOutputStream;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface MessageWriter<T extends Message> {
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
|
@ -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<Byte, MessageTypeSerializer<?>> 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<MessageTypeSerializer<?>, 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<Byte, Class<? extends Message>> 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 <T extends Message> void registerType(int id, Class<T> messageClass) {
|
||||||
|
if (id < 0 || id > 127) throw new IllegalArgumentException("Invalid id.");
|
||||||
|
MessageTypeSerializer<T> 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 <T> The message type.
|
||||||
|
* @throws IOException If an error occurs while writing, or if the message
|
||||||
|
* to write is not supported by this serializer.
|
||||||
|
*/
|
||||||
|
public <T extends Message> 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 <T> The message type.
|
||||||
|
* @throws IOException If an error occurs while writing, or if the message
|
||||||
|
* to write is not supported by this serializer.
|
||||||
|
*/
|
||||||
|
public <T extends Message> byte[] writeMessage(T msg) throws IOException {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(1 + msg.byteSize());
|
||||||
|
writeMessage(msg, out);
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -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 extends Enum<?>> T readEnum(Class<T> 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 extends Message> T[] readArray(MessageTypeSerializer<T> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 <T> The type of items in the array.
|
||||||
|
* @throws IOException If an error occurs while writing.
|
||||||
|
*/
|
||||||
|
public <T extends Message> 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 <T> The type of the message.
|
||||||
|
* @throws IOException If an error occurs while writing.
|
||||||
|
*/
|
||||||
|
public <T extends Message> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package nl.andrewl.record_net.util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple generic pair of two objects.
|
||||||
|
* @param <A> The first object.
|
||||||
|
* @param <B> The second object.
|
||||||
|
*/
|
||||||
|
public record Pair<A, B>(A first, B second) {}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package nl.andrewl.record_net.util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple generic triple of objects.
|
||||||
|
* @param <A> The first object.
|
||||||
|
* @param <B> The second object.
|
||||||
|
* @param <C> The third object.
|
||||||
|
*/
|
||||||
|
public record Triple<A, B, C> (A first, B second, C third) {}
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* Contains some useful one-off utility classes.
|
||||||
|
*/
|
||||||
|
package nl.andrewl.record_net.util;
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {}
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue