// ---------------------------------------------------------------------------- // // Loadbalancing Framework for Photon - Copyright (C) 2018 Exit Games GmbH // // // The RegionHandler class provides methods to ping a list of regions, // to find the one with best ping. // // developer@photonengine.com // ---------------------------------------------------------------------------- #if UNITY_4_7 || UNITY_5 || UNITY_5_3_OR_NEWER #define SUPPORTED_UNITY #endif #if UNITY_WEBGL #define PING_VIA_COROUTINE #endif namespace Photon.Realtime { using System; using System.Text; using System.Threading; using System.Net; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using ExitGames.Client.Photon; using System.Linq; #if SUPPORTED_UNITY using UnityEngine; using Debug = UnityEngine.Debug; #endif #if SUPPORTED_UNITY || NETFX_CORE using Hashtable = ExitGames.Client.Photon.Hashtable; using SupportClass = ExitGames.Client.Photon.SupportClass; #endif /// /// Provides methods to work with Photon's regions (Photon Cloud) and can be use to find the one with best ping. /// /// /// When a client uses a Name Server to fetch the list of available regions, the LoadBalancingClient will create a RegionHandler /// and provide it via the OnRegionListReceived callback, as soon as the list is available. No pings were sent for Best Region selection yet. /// /// Your logic can decide to either connect to one of those regional servers, or it may use PingMinimumOfRegions to test /// which region provides the best ping. Alternatively the client may be set to connect to the Best Region (lowest ping), one or /// more regions get pinged. /// Not all regions will be pinged. As soon as the results are final, the client will connect to the best region, /// so you can check the ping results when connected to the Master Server. /// /// Regions gets pinged 5 times (RegionPinger.Attempts). /// Out of those, the worst rtt is discarded and the best will be counted two times for a weighted average. /// /// Usually UDP will be used to ping the Master Servers. In WebGL, WSS is used instead. /// /// It makes sense to make clients "sticky" to a region when one gets selected. /// This can be achieved by storing the SummaryToCache value, once pinging was done. /// When the client connects again, the previous SummaryToCache helps limiting the number of regions to ping. /// In best case, only the previously selected region gets re-pinged and if the current ping is not much worse, this one region is used again. /// public class RegionHandler { /// The implementation of PhotonPing to use for region pinging (Best Region detection). /// Defaults to null, which means the Type is set automatically. public static Type PingImplementation; /// A list of region names for the Photon Cloud. Set by the result of OpGetRegions(). /// /// Implement ILoadBalancingCallbacks and register for the callbacks to get OnRegionListReceived(RegionHandler regionHandler). /// You can also put a "case OperationCode.GetRegions:" into your OnOperationResponse method to notice when the result is available. /// public List EnabledRegions { get; protected internal set; } private string availableRegionCodes; private Region bestRegionCache; /// /// When PingMinimumOfRegions was called and completed, the BestRegion is identified by best ping. /// public Region BestRegion { get { if (this.EnabledRegions == null) { return null; } if (this.bestRegionCache != null) { return this.bestRegionCache; } this.EnabledRegions.Sort((a, b) => a.Ping.CompareTo(b.Ping)); this.bestRegionCache = this.EnabledRegions[0]; return this.bestRegionCache; } } /// /// This value summarizes the results of pinging currently available regions (after PingMinimumOfRegions finished). /// /// /// This value should be stored in the client by the game logic. /// When connecting again, use it as previous summary to speed up pinging regions and to make the best region sticky for the client. /// public string SummaryToCache { get { if (this.BestRegion != null && this.BestRegion.Ping < RegionPinger.MaxMillisecondsPerPing) { return this.BestRegion.Code + ";" + this.BestRegion.Ping + ";" + this.availableRegionCodes; } return this.availableRegionCodes; } } /// Provides a list of regions and their pings as string. public string GetResults() { StringBuilder sb = new StringBuilder(); sb.AppendFormat("Region Pinging Result: {0}\n", this.BestRegion.ToString()); foreach (RegionPinger region in this.pingerList) { sb.AppendLine(region.GetResults()); } sb.AppendFormat("Previous summary: {0}", this.previousSummaryProvided); return sb.ToString(); } /// Initializes the regions of this RegionHandler with values provided from the Name Server (as OperationResponse for OpGetRegions). public void SetRegions(OperationResponse opGetRegions) { if (opGetRegions.OperationCode != OperationCode.GetRegions) { return; } if (opGetRegions.ReturnCode != ErrorCode.Ok) { return; } string[] regions = opGetRegions[ParameterCode.Region] as string[]; string[] servers = opGetRegions[ParameterCode.Address] as string[]; if (regions == null || servers == null || regions.Length != servers.Length) { //TODO: log error //Debug.LogError("The region arrays from Name Server are not ok. Must be non-null and same length. " + (regions == null) + " " + (servers == null) + "\n" + opGetRegions.ToStringFull()); return; } this.bestRegionCache = null; this.EnabledRegions = new List(regions.Length); for (int i = 0; i < regions.Length; i++) { string server = servers[i]; if (PortToPingOverride != 0) { server = LoadBalancingClient.ReplacePortWithAlternative(servers[i], PortToPingOverride); } Region tmp = new Region(regions[i], server); if (string.IsNullOrEmpty(tmp.Code)) { continue; } this.EnabledRegions.Add(tmp); } Array.Sort(regions); this.availableRegionCodes = string.Join(",", regions); } private readonly List pingerList = new List(); private Action onCompleteCall; private int previousPing; private string previousSummaryProvided; /// If non-zero, this port will be used to ping Master Servers on. protected internal static ushort PortToPingOverride; /// True if the available regions are being pinged currently. public bool IsPinging { get; private set; } /// True if the pinging of regions is being aborted. /// public bool Aborted { get; private set; } /// If the region from a previous BestRegionSummary now has a ping higher than this limit, all regions get pinged again to find a better. Default: 90ms. /// /// Pinging all regions takes time, which is why a BestRegionSummary gets stored. /// If that is available, the Best Region becomes sticky and is used again. /// This limit introduces an exception: Should the pre-defined best region have a ping worse than this, all regions are considered. /// public int BestRegionSummaryPingLimit = 90; #if SUPPORTED_UNITY private MonoBehaviourEmpty emptyMonoBehavior; #endif /// Creates a new RegionHandler. /// If non-zero, this port will be used to ping Master Servers on. public RegionHandler(ushort masterServerPortOverride = 0) { PortToPingOverride = masterServerPortOverride; } /// Starts the process of pinging of all available regions. /// Provide a method to call when all ping results are available. Aborting the pings will also cancel the callback. /// A BestRegionSummary from an earlier RegionHandler run. This makes a selected best region "sticky" and keeps ping times lower. /// If pining the regions gets started now. False if the current state prevent this. public bool PingMinimumOfRegions(Action onCompleteCallback, string previousSummary) { if (this.EnabledRegions == null || this.EnabledRegions.Count == 0) { //TODO: log error //Debug.LogError("No regions available. Maybe all got filtered out or the AppId is not correctly configured."); return false; } if (this.IsPinging) { //TODO: log warning //Debug.LogWarning("PingMinimumOfRegions() skipped, because this RegionHandler is already pinging some regions."); return false; } this.Aborted = false; this.IsPinging = true; this.previousSummaryProvided = previousSummary; #if SUPPORTED_UNITY if (this.emptyMonoBehavior != null) { this.emptyMonoBehavior.SelfDestroy(); } this.emptyMonoBehavior = MonoBehaviourEmpty.BuildInstance(nameof(RegionHandler)); this.emptyMonoBehavior.onCompleteCall = onCompleteCallback; this.onCompleteCall = emptyMonoBehavior.CompleteOnMainThread; #else this.onCompleteCall = onCompleteCallback; #endif if (string.IsNullOrEmpty(previousSummary)) { return this.PingEnabledRegions(); } string[] values = previousSummary.Split(';'); if (values.Length < 3) { return this.PingEnabledRegions(); } int prevBestRegionPing; bool secondValueIsInt = Int32.TryParse(values[1], out prevBestRegionPing); if (!secondValueIsInt) { return this.PingEnabledRegions(); } string prevBestRegionCode = values[0]; string prevAvailableRegionCodes = values[2]; if (string.IsNullOrEmpty(prevBestRegionCode)) { return this.PingEnabledRegions(); } if (string.IsNullOrEmpty(prevAvailableRegionCodes)) { return this.PingEnabledRegions(); } if (!this.availableRegionCodes.Equals(prevAvailableRegionCodes) || !this.availableRegionCodes.Contains(prevBestRegionCode)) { return this.PingEnabledRegions(); } if (prevBestRegionPing >= RegionPinger.PingWhenFailed) { return this.PingEnabledRegions(); } // let's check only the preferred region to detect if it's still "good enough" this.previousPing = prevBestRegionPing; Region preferred = this.EnabledRegions.Find(r => r.Code.Equals(prevBestRegionCode)); RegionPinger singlePinger = new RegionPinger(preferred, this.OnPreferredRegionPinged); lock (this.pingerList) { this.pingerList.Clear(); this.pingerList.Add(singlePinger); } singlePinger.Start(); return true; } /// Calling this will stop pinging the regions and suppress the onComplete callback. public void Abort() { if (this.Aborted) { return; } this.Aborted = true; lock (this.pingerList) { foreach (RegionPinger pinger in this.pingerList) { pinger.Abort(); } } #if SUPPORTED_UNITY if (this.emptyMonoBehavior != null) { this.emptyMonoBehavior.SelfDestroy(); } #endif } private void OnPreferredRegionPinged(Region preferredRegion) { if (preferredRegion.Ping > this.BestRegionSummaryPingLimit || preferredRegion.Ping > this.previousPing * 1.50f) { this.PingEnabledRegions(); } else { this.IsPinging = false; this.onCompleteCall(this); } } private bool PingEnabledRegions() { if (this.EnabledRegions == null || this.EnabledRegions.Count == 0) { //TODO: log //Debug.LogError("No regions available. Maybe all got filtered out or the AppId is not correctly configured."); return false; } lock (this.pingerList) { this.pingerList.Clear(); foreach (Region region in this.EnabledRegions) { RegionPinger rp = new RegionPinger(region, this.OnRegionDone); this.pingerList.Add(rp); rp.Start(); // TODO: check return value } } return true; } private void OnRegionDone(Region region) { lock (this.pingerList) { if (this.IsPinging == false) { return; } this.bestRegionCache = null; foreach (RegionPinger pinger in this.pingerList) { if (!pinger.Done) { return; } } this.IsPinging = false; } if (!this.Aborted) { this.onCompleteCall(this); } } } /// Wraps the ping attempts and workflow for a single region. public class RegionPinger { /// How often to ping a region. public static int Attempts = 5; /// How long to wait maximum for a response. public static int MaxMillisecondsPerPing = 800; // enter a value you're sure some server can beat (have a lower rtt) /// Ping result when pinging failed. public static int PingWhenFailed = Attempts * MaxMillisecondsPerPing; /// Current ping attempt count. public int CurrentAttempt = 0; /// True if all attempts are done or timed out. public bool Done { get; private set; } /// Set to true to abort pining this region. public bool Aborted { get; internal set; } private Action onDoneCall; private PhotonPing ping; private List rttResults; private Region region; private string regionAddress; /// Initializes a RegionPinger for the given region. public RegionPinger(Region region, Action onDoneCallback) { this.region = region; this.region.Ping = PingWhenFailed; this.Done = false; this.onDoneCall = onDoneCallback; } /// Selects the best fitting ping implementation or uses the one set in RegionHandler.PingImplementation. /// PhotonPing instance to use. private PhotonPing GetPingImplementation() { PhotonPing ping = null; // using each type explicitly in the conditional code, makes sure Unity doesn't strip the class / constructor. #if !UNITY_EDITOR && NETFX_CORE if (RegionHandler.PingImplementation == null || RegionHandler.PingImplementation == typeof(PingWindowsStore)) { ping = new PingWindowsStore(); } #elif NATIVE_SOCKETS || NO_SOCKET if (RegionHandler.PingImplementation == null || RegionHandler.PingImplementation == typeof(PingNativeDynamic)) { ping = new PingNativeDynamic(); } #elif UNITY_WEBGL if (RegionHandler.PingImplementation == null || RegionHandler.PingImplementation == typeof(PingHttp)) { ping = new PingHttp(); } #else if (RegionHandler.PingImplementation == null || RegionHandler.PingImplementation == typeof(PingMono)) { ping = new PingMono(); } #endif if (ping == null) { if (RegionHandler.PingImplementation != null) { ping = (PhotonPing)Activator.CreateInstance(RegionHandler.PingImplementation); } } return ping; } /// /// Starts the ping routine for the assigned region. /// /// /// Pinging runs in a ThreadPool worker item or (if needed) in a Thread. /// WebGL runs pinging on the Main Thread as coroutine. /// /// True unless Aborted. public bool Start() { // all addresses for Photon region servers will contain a :port ending. this needs to be removed first. // PhotonPing.StartPing() requires a plain (IP) address without port or protocol-prefix (on all but Windows 8.1 and WebGL platforms). string address = this.region.HostAndPort; int indexOfColon = address.LastIndexOf(':'); if (indexOfColon > 1) { address = address.Substring(0, indexOfColon); } this.regionAddress = ResolveHost(address); this.ping = this.GetPingImplementation(); this.Done = false; this.CurrentAttempt = 0; this.rttResults = new List(Attempts); if (this.Aborted) { return false; } #if PING_VIA_COROUTINE MonoBehaviourEmpty.BuildInstance("RegionPing_" + this.region.Code).StartCoroutineAndDestroy(this.RegionPingCoroutine()); #else bool queued = false; #if !NETFX_CORE try { queued = ThreadPool.QueueUserWorkItem(o => this.RegionPingThreaded()); } catch { queued = false; } #endif if (!queued) { SupportClass.StartBackgroundCalls(this.RegionPingThreaded, 0, "RegionPing_" + this.region.Code + "_" + this.region.Cluster); } #endif return true; } /// Calling this will stop pinging the regions and cancel the onComplete callback. protected internal void Abort() { this.Aborted = true; if (this.ping != null) { this.ping.Dispose(); } } /// Pings the region. To be called by a thread. protected internal bool RegionPingThreaded() { this.region.Ping = PingWhenFailed; int rttSum = 0; int replyCount = 0; Stopwatch sw = new Stopwatch(); for (this.CurrentAttempt = 0; this.CurrentAttempt < Attempts; this.CurrentAttempt++) { if (this.Aborted) { break; } sw.Reset(); sw.Start(); try { this.ping.StartPing(this.regionAddress); } catch (Exception e) { System.Diagnostics.Debug.WriteLine("RegionPinger.RegionPingThreaded() caught exception for ping.StartPing(). Exception: " + e + " Source: " + e.Source + " Message: " + e.Message); break; } while (!this.ping.Done()) { if (sw.ElapsedMilliseconds >= MaxMillisecondsPerPing) { // if ping.Done() did not become true in MaxMillisecondsPerPing, ping.Successful is false and we apply MaxMillisecondsPerPing as rtt below break; } #if !NETFX_CORE System.Threading.Thread.Sleep(1); #endif } sw.Stop(); int rtt = this.ping.Successful ? (int)sw.ElapsedMilliseconds : MaxMillisecondsPerPing; // if the reply didn't match the sent ping this.rttResults.Add(rtt); rttSum += rtt; replyCount++; this.region.Ping = (int)((rttSum) / replyCount); #if !NETFX_CORE int i = 4; while (!this.ping.Done() && i > 0) { i--; System.Threading.Thread.Sleep(100); } System.Threading.Thread.Sleep(10); #endif } //Debug.Log("Done: "+ this.region.Code); this.Done = true; this.ping.Dispose(); int bestRtt = this.rttResults.Min(); int worstRtt = this.rttResults.Max(); int weighedRttSum = rttSum - worstRtt + bestRtt; this.region.Ping = (int)(weighedRttSum / replyCount); // now, we can create a weighted ping value this.onDoneCall(this.region); return false; } #if SUPPORTED_UNITY /// /// Affected by frame-rate of app, as this Coroutine checks the socket for a result once per frame. /// protected internal IEnumerator RegionPingCoroutine() { this.region.Ping = PingWhenFailed; int rttSum = 0; int replyCount = 0; Stopwatch sw = new Stopwatch(); for (this.CurrentAttempt = 0; this.CurrentAttempt < Attempts; this.CurrentAttempt++) { if (this.Aborted) { yield return null; } sw.Reset(); sw.Start(); try { this.ping.StartPing(this.regionAddress); } catch (Exception e) { Debug.Log("RegionPinger.RegionPingCoroutine() caught exception for ping.StartPing(). Exception: " + e + " Source: " + e.Source + " Message: " + e.Message); break; } while (!this.ping.Done()) { if (sw.ElapsedMilliseconds >= MaxMillisecondsPerPing) { // if ping.Done() did not become true in MaxMilliseconsPerPing, ping.Successful is false and we apply MaxMilliseconsPerPing as rtt below break; } yield return new WaitForSecondsRealtime(0.01f); // keep this loop tight, to avoid adding local lag to rtt. } sw.Stop(); int rtt = this.ping.Successful ? (int)sw.ElapsedMilliseconds : MaxMillisecondsPerPing; // if the reply didn't match the sent ping this.rttResults.Add(rtt); rttSum += rtt; replyCount++; this.region.Ping = (int)((rttSum) / replyCount); int i = 4; while (!this.ping.Done() && i > 0) { i--; yield return new WaitForSeconds(0.1f); } yield return new WaitForSeconds(0.1f); } //Debug.Log("Done: "+ this.region.Code); this.Done = true; this.ping.Dispose(); int bestRtt = this.rttResults.Min(); int worstRtt = this.rttResults.Max(); int weighedRttSum = rttSum - worstRtt + bestRtt; this.region.Ping = (int)(weighedRttSum / replyCount); // now, we can create a weighted ping value this.onDoneCall(this.region); yield return null; } #endif /// Gets this region's results as string summary. public string GetResults() { return string.Format("{0}: {1} ({2})", this.region.Code, this.region.Ping, this.rttResults.ToStringFull()); } /// /// Attempts to resolve a hostname into an IP string or returns empty string if that fails. /// /// /// To be compatible with most platforms, the address family is checked like this:
/// if (ipAddress.AddressFamily.ToString().Contains("6")) // ipv6... ///
/// Hostname to resolve. /// IP string or empty string if resolution fails public static string ResolveHost(string hostName) { if (hostName.StartsWith("wss://")) { hostName = hostName.Substring(6); } if (hostName.StartsWith("ws://")) { hostName = hostName.Substring(5); } string ipv4Address = string.Empty; try { #if UNITY_WSA || NETFX_CORE || UNITY_WEBGL return hostName; #else IPAddress[] address = Dns.GetHostAddresses(hostName); if (address.Length == 1) { return address[0].ToString(); } // if we got more addresses, try to pick a IPv6 one // checking ipAddress.ToString() means we don't have to import System.Net.Sockets, which is not available on some platforms (Metro) for (int index = 0; index < address.Length; index++) { IPAddress ipAddress = address[index]; if (ipAddress != null) { if (ipAddress.ToString().Contains(":")) { return ipAddress.ToString(); } if (string.IsNullOrEmpty(ipv4Address)) { ipv4Address = address.ToString(); } } } #endif } catch (System.Exception e) { System.Diagnostics.Debug.WriteLine("RegionPinger.ResolveHost() caught an exception for Dns.GetHostAddresses(). Exception: " + e + " Source: " + e.Source + " Message: " + e.Message); } return ipv4Address; } } #if SUPPORTED_UNITY internal class MonoBehaviourEmpty : MonoBehaviour { internal Action onCompleteCall; private RegionHandler obj; public static MonoBehaviourEmpty BuildInstance(string id = null) { GameObject go = new GameObject(id ?? nameof(MonoBehaviourEmpty)); DontDestroyOnLoad(go); return go.AddComponent(); } public void SelfDestroy() { Destroy(this.gameObject); } void Update() { if (this.obj != null) { this.onCompleteCall(obj); this.obj = null; this.onCompleteCall = null; this.SelfDestroy(); } } public void CompleteOnMainThread(RegionHandler obj) { this.obj = obj; } public void StartCoroutineAndDestroy(IEnumerator coroutine) { StartCoroutine(Routine()); IEnumerator Routine() { yield return coroutine; this.SelfDestroy(); } } } #endif }