Remote Methods: Introduction

Remote Methods: Introduction

Say you're writing a multiplayer game, for example, and you would like to express your remote calls as something readable and succinct like string nickname = await Server.AuthenticateAsync("Requested_Nickname") instead of as

string nickname = null;
stream.WritePacketId(PacketId.AuthenticateName);
stream.WriteInt(myClientId);
stream.WriteInt(client.CreateCallbackContext(PacketId.AuthenticateNameResp, packet => {
    nickname = packet.ReadString();
}));
stream.WriteString("Requested_Nickname");

client.SendPacket(stream);

while(nickname == null) ; // What if the server never returns?
return nickname;

Callback soup! Isn't this what modern innovations have strived to avoid? We have await now, for heaven's sake!

While it is possible to wrap all of your callback soup in TaskCompletionSource<T> so that on the outside it seems better, but wouldn't it be better to build one generic solution instead of a lot of copy-paste code? In the .NET world, there is a little-known feature of the standard library known as proxies. There are plenty of articles already that explain the concepts and implementation details of remoting, so I will for this series of articles omit this information in favor of how you can leverage these features.

By the end of this series you will be able to define a remote API like so

public interface IAuthenticationService
{
    [RpcMethod(PacketId.Authenticate, PacketId.AuthenticateResp)]
    bool Authenticate(string username, string token, out int clientId);

    [RpcMethod(PacketId.GetServerInformation, PacketId.GetServerInformationResp)]
    ServerInformation[] GetServerInfo();

    [RpcMethod(PacketId.AnnounceServer)]
    void AnnounceServer(ServerInformation server);
}

and consume it with simple, readable code like servers = authenticator.GetServerInfo(). You will also be able to implement this API on the server-side like you would expect:

private class AuthenticationServer : IAuthenticationService
{
    ...
    bool Authenticate(string username, string token, out int clientId)
    {
        if(WebService.CheckUsernameTokenPair(username, token))
        {
            clientId = -1;
            return false;
        }
        clientId = GenerateClientId();
        return true;
    }
    ...
}

First Thing's First

The first step is to create a lightweight wrapper around the MemoryStream class. This wrapper will provide methods similar to a unified BinaryReader and BinaryWriter and be called NetStream.

The NetStream class is a general-use wrapper around the MemoryStream for two reasons: this allows the class to be used in a very general manner and the already included NetworkStream is not thread-safe when working in the manner in which this will be used. What we want to do is convert arbitrary objects to a binary representation and prepare them for transport over the network. That's exactly what the NetStream will do -- nothing more.

Each of the methods are defined in a standard way:

[NetStreamReader(typeof(double))]
public double ReadDouble()
{
    return BitConverter.ToDouble(Read(sizeof(double)), 0);
}
[NetStreamWriter(typeof(double))]
public void Write(double value)
{
    byte[] bytes = BitConverter.GetBytes(value);
    WriteRaw(bytes);
}

This is the basis of your copy-paste code and the only place where you might want to do so.

Basic Reflection Magic

If you thought we could make it through this without using Reflection, you were wrong. Our generalized reader that could arbitrarily read any object (so long as a reader is defined) looks like so:

public object ReadObject(Type type)
{
    if (type == null)
    {
        throw new ArgumentNullException(nameof(type));
    }
    var reader = FindReaderFor(type);
    if (reader == null)
    {
        if (type.IsArray)
        {
            int length = ReadInt32();
            object[] values = new object[length];
            for (int i = 0; i < length; i++)
            {
                values[i] = ReadObject(type.GetElementType());
            }
            return values;
        }
        throw new Exception($"No reader exists for {type.Name}!");
    }
    if (!reader.IsStatic)
    {
        return reader.Invoke(this, null);
    }
    else
    {
        return reader.Invoke(null, new[] { this });
    }
}
private MethodInfo FindReaderFor(Type type)
{
    return _Readers.Where(g => type.IsAssignableFrom(g.Key)).FirstOrDefault().Value;
}

The writer works in much the same way.

How does one find and validate readers, you might ask? The answer is this monstrosity:

public static void RegisterMethods(Type type)
{
    MethodInfo[] methods = type.GetMethods();
    IEnumerable<MethodInfo> readerMethods = methods.Where(g => g.HasAttribute<NetStreamReaderAttribute>());
    IEnumerable<MethodInfo> writerMethods = methods.Where(g => g.HasAttribute<NetStreamWriterAttribute>());

    List<string> errors = new List<string>();

    foreach (var method in readerMethods)
    {
        bool hasErrors = false;
        var readerType = method.GetCustomAttribute<NetStreamReaderAttribute>().ReadType;
        var parameters = method.GetParameters();
        int offset = 0;

        ... Error validation omitted.

        if (!hasErrors)
        {
            _Readers.Add(readerType, method);
        }
        else
        ...
    }

    foreach (var method in writerMethods)
    {
        bool hasErrors = false;
        var writerType = method.GetCustomAttribute<NetStreamWriterAttribute>().WriteType;
        ParameterInfo[] parameters = method.GetParameters();
        int offset = 0;

        ... Error validation omitted again.
        if (!hasErrors)
        {
            _Writers.Add(writerType, method);
        }
        else
        ...
    }
    if (errors.Count > 0)
    {
        ... Exception generation omitted.
    }
}

This method consumes a Type and combs through its static and instance methods looking for readers and writers. All an extension library need do is create readers and writers for a type and register the extension methods with a simple NetStream.RegisterMethods(...)!

The HasAttribute extension is just a simple GetCustomAttribute<T>() != null.

Conclusion

That's everything for this post. I'm currently taking a break from the Building a Language series while I do further research into the subject. This series came about when I decided to begin rewriting Pantheon from the bottom up in order to have a more stable platform for some of my projects. In addition, the server terminal was getting bloated to the point of almost started reading my email.

Lookout for new posts, probably around the weekends.