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.
PhotonWire.HubInvoker is powerful API debugging tool.
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.
- PM> Install-Package PhotonWire
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;
}
}
}
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
Here is the result of Project Structure.
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.
// 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.
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.
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.
PhotonWire's Unity Client needs additional SDK.
- Download Photon Server SDK and pick
lib/Photon3Unity3D.dll
toAssets\Plugins\Dll
. - Import UniRx from asset store.
Add Unity Generated Projects to Solution.
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
).
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.
Debugging both Server and Unity, I recommend use SwitchStartupProject extension.
Create Photon + Unity multi startup.
And debug it, top is server, bottom is unity.
Getting Started - .NET Client
.NET Client can use ASP.NET, ConsoleApplication, WPF, etc.
- PM> Install-Package PhotonWire.Client
- Download Photon Server SDK and pick
lib/ExitGamesLibs.dll
andlib/Photon3DotNet.dll
.
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.
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.
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 */);
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.
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.