All Projects → neuecc → Photonwire

neuecc / Photonwire

Typed Asynchronous RPC Layer for Photon Server + Unity

PhotonWire

Typed Asynchronous RPC Layer for Photon Server + Unity

What is PhotonWire?

PhotonWire is built on Exit Games's Photon Server. PhotonWire provides client-server RPC with Photon Unity Native SDK and server-server RPC with Photon Server SDK. PhotonWire mainly aims to fully controll server side logic.

  • TypeSafe, Server-Server uses dynamic proxy, Client-Server uses T4 pre-generate
  • HighPerformance, Fully Asynchronous(Server is async/await, Client is UniRx) and pre-generated serializer by MsgPack
  • Fully integrated with Visual Studio and Roslyn Analyzer
  • Tools, PhotonWire.HubInvoker can invoke API directly

Transparent debugger in Visual Studio, Unity -> Photon Server -> Unity.

bothdebug

PhotonWire.HubInvoker is powerful API debugging tool.

image

Getting Started - Server

In Visual Studio(2015 or higher), create new .NET 4.6(or higher) Class Library Project. For example sample project name - GettingStarted.Server.

In Package Manager Console, add PhotonWire NuGet package.

It includes PhotonWire.Server and PhotonWire.Analyzer.

Package does not includes Photon SDK, please download from Photon Server SDK. Server Project needs lib/ExitGamesLibs.dll, lib/Photon.SocketServer.dll and lib/PhotonHostRuntimeInterfaces.dll.

using PhotonWire.Server;

namespace GettingStarted.Server
{
    // Application Entrypoint for Photon Server.
    public class Startup : PhotonWireApplicationBase
    {

    }
}

Okay, Let's create API! Add C# class file MyFirstHub.cs.

using PhotonWire.Server;

namespace GettingStarted.Server
{
    [Hub(0)]
    public class MyFirstHub : Hub
    {
        [Operation(0)]
        public int Sum(int x, int y)
        {
            return x + y;
        }
    }
}

image

Hub Type needs HubAttribute and Method needs OperationAttribute. PhotonWire.Analyzer detects there rules. You may only follow it.

Configuration sample. config file must be UTF8 without BOM.

<?xml version="1.0" encoding="utf-8"?>
<Configuration>
    <!-- Manual -->
    <!-- http://doc.photonengine.com/en/onpremise/current/reference/server-config-settings -->

    <!-- Instances -->
    <GettingStarted>
        <IOPool>
            <NumThreads>8</NumThreads>
        </IOPool>

        <!-- .NET 4.5~6's CLRVersion is v4.0 -->
        <Runtime
            Assembly="PhotonHostRuntime, Culture=neutral"
            Type="PhotonHostRuntime.PhotonDomainManager"
            CLRVersion="v4.0"
            UnhandledExceptionPolicy="Ignore">
        </Runtime>

        <!-- Configuration of listeners -->
        <TCPListeners>
            <TCPListener
                IPAddress="127.0.0.1"
                Port="4530"
                ListenBacklog="1000"
                InactivityTimeout="60000">
            </TCPListener>
        </TCPListeners>

        <!-- Applications -->
        <Applications Default="GettingStarted.Server" PassUnknownAppsToDefaultApp="true">
            <Application
                Name="GettingStarted.Server"
                BaseDirectory="GettingStarted.Server"
                Assembly="GettingStarted.Server" 
                Type="GettingStarted.Server.Startup"
                EnableShadowCopy="true"
                EnableAutoRestart="true"
                ForceAutoRestart="true"
                ApplicationRootDirectory="PhotonLibs">
            </Application>
        </Applications>

    </GettingStarted>
</Configuration>

And modify property, Copy to Output Directory Copy always

image

Here is the result of Project Structure.

image

Start and Debug Server Codes on Visual Studio

PhotonWire application is hosted by PhotonSocketServer.exe. PhotonSocketServer.exe is in Photon Server SDK, copy from deploy/bin_64 to $(SolutionDir)\PhotonLibs\bin_Win64.

Open Project Properties -> Build Events, write Post-build event commandline:

xcopy "$(TargetDir)*.*" "$(SolutionDir)\PhotonLibs\$(ProjectName)\bin\" /Y /Q

In Debug tab, set up three definitions.

image

// Start external program:
/* Absolute Dir Paths */\PhotonLibs\bin_Win64\PhotonSocketServer.exe

// Star Options, Command line arguments:
/debug GettingStarted /config GettingStarted.Server\bin\PhotonServer.config

// Star Options, Working directory:
/* Absolute Path */\PhotonLibs\

Press F5 to start debugging. If cannot start debugging, please see log. Log exists under PhotonLibs\log. If encounts Exception: CXMLDocument::LoadFromString(), please check config file encoding, must be UTF8 without BOM.

Let's try to invoke from test client. PhotonWire.HubInvoker is hub api testing tool. It exists at $(SolutionDir)\packages\PhotonWire.1.0.0\tools\PhotonWire.HubInvoker\PhotonWire.HubInvoker.exe.

Configuration,

ProcessPath | Argument | WorkingDirectory is same as Visual Studio's Debug Tab. DllPath is /* Absolute Path */\PhotonLibs\GettingStarted.Server\bin\GettingStarted.Server.dll

Press Reload button, you can see like following image.

image

At first, press Connect button to connect target server. And Invoke method, please try x = 100, y = 300 and press Send button.

In visual studio, if set the breakpoint, you can step debugging and see, modify variables.

image

and HubInvoker shows return value at log.

Connecting : 127.0.0.1:4530 GettingStarted
Connect:True
+ MyFirstHub/Sum:400

There are basic steps of create server code.

Getting Started - Unity Client

Download and Import PhotonWire.UnityClient.unitypackage from release page. If encounts Unhandled Exception: System.Reflection.ReflectionTypeLoadException: The classes in the module cannot be loaded., Please change Build Settings -> Optimization -> Api Compatibility Level -> .NET 2.0.

image

PhotonWire's Unity Client needs additional SDK.

  • Download Photon Server SDK and pick lib/Photon3Unity3D.dll to Assets\Plugins\Dll.
  • Import UniRx from asset store.

Add Unity Generated Projects to Solution.

image

You can choose Unity generated solution based project or Standard solution based project. Benefit of Unity generated based is better integrated with Unity Editor(You can double click source code!) but solution path becomes strange.

Search Assets/Plugins/PhotonWire/PhotonWireProxy.tt under GettingStarted.UnityClient.CSharp.Plugins and configure it, change the dll path and assemblyName. This file is typed client generator of server definition.

<#@ assembly name="$(SolutionDir)\GettingStarted.Server\bin\Debug\MsgPack.dll" #>
<#@ assembly name="$(SolutionDir)\GettingStarted.Server\bin\Debug\GettingStarted.Server.dll" #>
<#
    // 1. ↑Change path to Photon Server Project's DLL and Server MsgPack(not client) DLL

    // 2. Make Configuration -----------------------

    var namespaceName = "GettingStarted.Client"; // namespace of generated code
    var assemblyName = "GettingStarted.Server"; // Photon Server Project's assembly name
    var baseHubName = "Hub`1";  // <T> is `1, If you use base hub, change to like FooHub`1.
    var useAsyncSuffix = true; // If true FooAsync

    // If WPF, use "DispatcherScheduler.Current"
    // If ConsoleApp, use "CurrentThreadScheduler.Instance"
    // If Unity, use "Scheduler.MainThread"
    var mainthreadSchedulerInstance = "Scheduler.MainThread";
    
    // End of Configuration-----------------

Right click -> Run Custom Tool generates typed client(PhotonWireProxy.Generated.cs).

image

Setup has been completed! Let's connect PhotonServer. Put uGUI Button to scene and attach following PhotonButton script.

using ExitGames.Client.Photon;
using PhotonWire.Client;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace GettingStarted.Client
{
    public class PhotonButton : MonoBehaviour
    {
        // set from inspector
        public Button button;

        ObservablePhotonPeer peer;
        MyFirstHubProxy proxy;

        void Start()
        {
            // Create Photon Connection
            peer = new ObservablePhotonPeer(ConnectionProtocol.Tcp, peerName: "PhotonTest");

            // Create typed server rpc proxy
            proxy = peer.CreateTypedHub<MyFirstHubProxy>();

            // Async Connect(return IObservable)
            peer.ConnectAsync("127.0.0.1:4530", "GettingStarted.Server")
                .Subscribe(x =>
                {
                    UnityEngine.Debug.Log("IsConnected?" + x);
                });


            button.OnClickAsObservable().Subscribe(_ =>
            {

                // Invoke.Method calls server method and receive result.
                proxy.Invoke.SumAsync(100, 300)
                    .Subscribe(x => Debug.Log("Server Return:" + x));

            });
        }

        void OnDestroy()
        {
            // disconnect peer.
            peer.Dispose();
        }
    }

}

and press button, you can see Server Return:400.

If shows Windows -> PhotonWire, you can see connection stae and graph of sent, received bytes.

image

Debugging both Server and Unity, I recommend use SwitchStartupProject extension.

Create Photon + Unity multi startup.

image

And debug it, top is server, bottom is unity.

bothdebug

Getting Started - .NET Client

.NET Client can use ASP.NET, ConsoleApplication, WPF, etc.

Getting Started - Sharing Classes

PhotonWire supports complex type serialize by MsgPack. At first, share request/response type both server and client.

Create .NET 3.5 Class Library Project - GettingStarted.Share and add the Person.cs.

namespace GettingStarted.Share
{
    public class Person
    {
        public int Age { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
    }
}

Open project property window, Build Events -> Post-build event command line, add the following copy dll code.

xcopy "$(TargetDir)*.*" "$(SolutionDir)\GettingStarted.UnityClient\Assets\Plugins\Dll\" /Y /Q

Move to GettingStareted.Server, reference project GettingStarted.Share and add new method in MyFirstHub.

[Operation(1)]
public Person CreatePerson(int seed)
{
    var rand = new Random(seed);

    return new Person
    {
        FirstName = "Yoshifumi",
        LastName = "Kawai",
        Age = rand.Next(0, 100)
    };
}

Maybe you encount error message, response type must be DataContract. You can modify quick fix.

image

And add the reference System.Runtime.Serialization to GettingStarted.Share.

Build GettingStarted.Server, and Run Custom Tool of PhotonWireProxy.tt.

// Unity Button Click
proxy.Invoke.CreatePersonAsync(Random.Range(0, 100))
    .Subscribe(x =>
    {
        UnityEngine.Debug.Log(x.FirstName + " " + x.LastName + " Age:" + x.Age);
    });

Response deserialization is multi threaded and finally return to main thread by UniRx so deserialization does not affect performance. Furthermore deserializer is used pre-generated optimized serializer.

[System.CodeDom.Compiler.GeneratedCodeAttribute("MsgPack.Serialization.CodeDomSerializers.CodeDomSerializerBuilder", "0.6.0.0")]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
public class GettingStarted_Share_PersonSerializer : MsgPack.Serialization.MessagePackSerializer<GettingStarted.Share.Person> {
        
    private MsgPack.Serialization.MessagePackSerializer<int> _serializer0;
        
    private MsgPack.Serialization.MessagePackSerializer<string> _serializer1;
        
    public GettingStarted_Share_PersonSerializer(MsgPack.Serialization.SerializationContext context) : 
            base(context) {
        MsgPack.Serialization.PolymorphismSchema schema0 = default(MsgPack.Serialization.PolymorphismSchema);
        schema0 = null;
        this._serializer0 = context.GetSerializer<int>(schema0);
        MsgPack.Serialization.PolymorphismSchema schema1 = default(MsgPack.Serialization.PolymorphismSchema);
        schema1 = null;
        this._serializer1 = context.GetSerializer<string>(schema1);
    }
        
    protected override void PackToCore(MsgPack.Packer packer, GettingStarted.Share.Person objectTree) {
        packer.PackArrayHeader(3);
        this._serializer0.PackTo(packer, objectTree.Age);
        this._serializer1.PackTo(packer, objectTree.FirstName);
        this._serializer1.PackTo(packer, objectTree.LastName);
    }
        
    protected override GettingStarted.Share.Person UnpackFromCore(MsgPack.Unpacker unpacker) {
        GettingStarted.Share.Person result = default(GettingStarted.Share.Person);
        result = new GettingStarted.Share.Person();
        
        int unpacked = default(int);
        int itemsCount = default(int);
        itemsCount = MsgPack.Serialization.UnpackHelpers.GetItemsCount(unpacker);
        System.Nullable<int> nullable = default(System.Nullable<int>);
        if ((unpacked < itemsCount)) {
            nullable = MsgPack.Serialization.UnpackHelpers.UnpackNullableInt32Value(unpacker, typeof(GettingStarted.Share.Person), "Int32 Age");
        }
        if (nullable.HasValue) {
            result.Age = nullable.Value;
        }
        unpacked = (unpacked + 1);
        string nullable0 = default(string);
        if ((unpacked < itemsCount)) {
            nullable0 = MsgPack.Serialization.UnpackHelpers.UnpackStringValue(unpacker, typeof(GettingStarted.Share.Person), "System.String FirstName");
        }
        if (((nullable0 == null) 
                    == false)) {
            result.FirstName = nullable0;
        }
        unpacked = (unpacked + 1);
        string nullable1 = default(string);
        if ((unpacked < itemsCount)) {
            nullable1 = MsgPack.Serialization.UnpackHelpers.UnpackStringValue(unpacker, typeof(GettingStarted.Share.Person), "System.String LastName");
        }
        if (((nullable1 == null) 
                    == false)) {
            result.LastName = nullable1;
        }
        unpacked = (unpacked + 1);
        
        return result;
    }
}

Startup Configuration

Override the Startup methods, you can configure options.

public class Startup : PhotonWireApplicationBase
{
    // When throw exception, returns exception information.
    public override bool IsDebugMode
    {
        get
        {
            return true;
        }
    }

    // connected peer is server to server? 
    protected override bool IsServerToServerPeer(InitRequest initRequest)
    {
        return (initRequest.ApplicationId == "MyMaster");
    }

    // initialize, if needs server to server connection, write here.
    protected override void SetupCore()
    {
        var _ = ConnectToOutboundServerAsync(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 4530), "MyMaster");
    }

    // tear down
    protected override void TearDownCore()
    {
        base.TearDownCore();
    }
}

More options, see reference.

Hub

Hub concept is highly inspired by ASP.NET SignalR so SignalR's document is maybe useful.

Hub supported typed client broadcast.

// define client interface.
public interface ITutorialClient
{
    [Operation(0)]
    void GroupBroadcastMessage(string message);
}

// Hub<TClient>
[Hub(100)]
public class Tutorial : PhotonWire.Server.Hub<ITutorialClient>
{
    [Operation(2)]
    public void BroadcastAll(string message)
    {
        // Get ClientProxy from Clients property, choose target and Invoke.
        this.Clients.All.GroupBroadcastMessage(message);
    }
}

Hub have two instance property, OperationContext and Clients. OperationContext is information per operation. It has Items - per operation storage(IDictionary<object, object>), Peer - client connection of this operation, Peer.Items - per peer lifetime storage(ConcurrentDictionary<object, object>) and more.

Peer.RegisterDisconnectAction is sometimes important.

this.Context.Peer.RegisterDisconnectAction((reasonCode, readonDetail) =>
{
    // do when disconnected.
});

Clients is proxy of broadcaster. All is broadcast to all server, Target is only send to target peer, and more.

Group is multipurpose channel. You can add/remove per peer Peer.AddGroup/RemoveGroup. And can use from Clients.

[Operation(3)]
public void RegisterGroup(string groupName)
{
    // Group is registered by per connection(peer)
    this.Context.Peer.AddGroup(groupName);
}

[Operation(4)]
public void BroadcastTo(string groupName, string message)
{
    // Get ITutorialClient -> Invoke method
    this.Clients.Group(groupName).GroupBroadcastMessage(message);
}

Operation response supports async/await.

[Operation(1)]
public async Task<string> GetHtml(string url)
{
    var httpClient = new HttpClient();
    var result = await httpClient.GetStringAsync(url);

    // Photon's String deserialize size limitation
    var cut = result.Substring(0, Math.Min(result.Length, short.MaxValue - 5000));

    return cut;
}

Server to Server

PhotonWire supports Server to Server. Server to Server connection also use Hub system. PhotonWire provides three hubs.

  • ClientPeer - Hub
  • OutboundS2SPeer - ServerHub
  • InboundS2SPeer - ReceiveServerHub

Implements ServerHub.

// 1. Inherit ServerHub
// 2. Add HubAttribute
[Hub(54)]
public class MasterTutorial : PhotonWire.Server.ServerToServer.ServerHub
{
    // 3. Create virtual, async method
    // 4. Add OperationAttribute
    [Operation(0)]
    public virtual async Task<int> Multiply(int x, int y)
    {
        return x * y;
    }
}

Call from Hub.

[Operation(5)]
public async Task<int> ServerToServer(int x, int y)
{
    var mul = await GetServerHubProxy<MasterTutorial>().Single.Multiply(x, y);
    
    // If is not in Hub, You can get ClientProxy from global PeerManager
    // PeerManager.GetServerHubContext<MasterTutorial>().Clients.Single.Multiply(x, y);
    
    return mul;
}

GetServerHubProxy is magic by dynamic proxy.

image

ReceiveServerHub is similar with ServerHub.

[Hub(10)]
public class BroadcasterReceiveServerHub : ReceiveServerHub
{
    [Operation(20)]
    public virtual async Task Broadcast(string group, string msg)
    {
        // Send to clients.
        this.GetClientsProxy<Tutorial, ITutorialClient>()
            .Group(group)
            .GroupBroadcastMessage(msg);
    }
}

Call from ServerHub.

[Operation(1)]
public virtual async Task Broadcast(string group, string message)
{
    // Invoke all receive server hubs
    await GetReceiveServerHubProxy<BroadcasterReceiveServerHub>()
        .All.Invoke(x => x.Broadcast(group, message));
}

Client Receiver.

// receive per operation
proxy.Receive.GroupBroadcastMessage.Subscribe();

// or receive per client 
proxy.RegisterListener(/* TutorialProxy.ITutorialClient */);

image

Server Cluster

Server to Server connection is setup in Startup. That's all.

public class Startup : PhotonWireApplicationBase
{
    protected override bool IsServerToServerPeer(InitRequest initRequest)
    {
        return (initRequest.ApplicationId == "MyMaster");
    }

    protected override void SetupCore()
    {
        var _ = ConnectToOutboundServerAsync(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 4530), "MyMaster");
    }
}

You can choice own cluster type.

image

PhotonWire supports everything.

Configuration

PhotonWire supports app.config. Here is sample config

<configuration>
    <configSections>
        <section name="photonWire" type="PhotonWire.Server.Configuration.PhotonWireConfigurationSection, PhotonWire.Server" />
    </configSections>
    
    <photonWire>
        <connection>
            <add ipAddress="127.0.0.1" port="4530" applicationName="PhotonSample.MasterServer" />
            <add ipAddress="127.0.0.1" port="4531" applicationName="PhotonSample.MasterServer1" />
            <add ipAddress="127.0.0.1" port="4532" applicationName="PhotonSample.MasterServer2" />
        </connection>
    </photonWire>
</configuration>
public class GameServerStartup : PhotonWire.Server.PhotonWireApplicationBase
{
    // Only Enables GameServer Hub.
    protected override string[] HubTargetTags
    {
        get
        {
            return new[] { "GameServer" };
        }
    }

    protected override void SetupCore()
    {
        // Load from Configuration file.
        foreach (var item in PhotonWire.Server.Configuration.PhotonWireConfigurationSection.GetSection().GetConnectionList())
        {
            var ip = new IPEndPoint(IPAddress.Parse(item.IPAddress), item.Port);
            var _ = ConnectToOutboundServerAsync(ip, item.ApplicationName);
        }
    }
}

Filter

PhotonWire supports OWIN like filter.

public class TestFilter : PhotonWireFilterAttribute
{
    public override async Task<object> Invoke(OperationContext context, Func<Task<object>> next)
    {
        var path = context.Hub.HubName + "/" + context.Method.MethodName;
        try
        {
            Debug.WriteLine("Before:" + path + " - " + context.Peer.PeerKind);
            var result = await next();
            Debug.WriteLine("After:" + path);
            return result;
        }
        catch (Exception ex)
        {
            Debug.WriteLine("Ex " + path + " :" + ex.ToString());
            throw;
        }
        finally
        {
            Debug.WriteLine("Finally:" + path);
        }
    }
}

[Hub(3)]
public class MasterTest : ServerHub
{
    [TestFilter] // use filter
    [Operation(5)]
    public virtual async Task<string> EchoAsync(string msg)
    {
        return msg;
    }
}

CustomError

If you want to returns custom error, you can throw CustomErrorException on server. It can receive client.

// Server
[Operation(0)]
public void ServerError()
{
    throw new CustomErrorException { ErrorMessage = "Custom Error" }; 
}

// Client
proxy.Invoke.ServerError()
    .Catch((CustomErrorException ex) =>
    {
        UnityEngine.Debug.Log(ex.ErrorMessage);
    })
    .Subscribe();

PeerManager

PeerManager is global storage of peer and peer groups.

Logging, Monitoring

Default logging uses EventSource. You can monitor easily by EtwStream.

ObservableEventListener.FromTraceEvent("PhotonWire").DumpWithColor();

Logging point list can see IPhotonWireLogger reference.

References

Available at GitHub/PhotonWire/wiki.

Help & Contribute

Ask me any questions to GitHub issues.

Author Info

Yoshifumi Kawai(a.k.a. neuecc) is a software developer in Japan.
He is the Director/CTO at Grani, Inc.
Grani is a top social game developer in Japan.
He is awarding Microsoft MVP for Visual C# since 2011.
He is known as the creator of UniRx(Reactive Extensions for Unity)

Blog: http://neue.cc/ (Japanese)
Twitter: https://twitter.com/neuecc (Japanese)

License

This library is under the MIT License.

Proxy's API surface is inspired by SignalR and PhotonWire.Server.TypedClientBuilder<T> is based on SignalR's TypedClientBuilder.

Note that the project description data, including the texts, logos, images, and/or trademarks, for each open source project belongs to its rightful owner. If you wish to add or remove any projects, please contact us at [email protected].