Introduction
This article is part 2 in a series, and covers information using broadcasting and notifications on a self-hosted Windows service using SignalR. Please see my previous tip on SignalR with web applications part 1 here that also contains valuable introductory information and using SignalR with a Server Broadcast ASP article here.
To use SignalR 2, your application must use the .NET Framework 4.5. If you use the .NET Framework 4.0, then you must use SignalR 1.x.
To be clear, the SignalR application in that article allowed for peer-to-peer communication using SignalR, while this Windows Service application will allow for broadcasting with notifications.
SignalR is certainly very useful in web application environments, but it is also tremendously useful if you require real-time communications on the Windows desktop in an industrial setting.
You can download the sample project from here.
Creating the Service
Start by creating a Windows service in Visual Studio, ensuring that you are running with Administrative privileges, and that your project uses .NET 4.5 or greater. Rename the service to CurrencyExchangeService
after it has been created:
Then, type this at the package manager console, ensuring that the default project is your service project:
PM> Install-Package Microsoft.AspNet.SignalR.SelfHost
PM> Install-Package ServiceProcess.Helpers
PM> Install-Package Microsoft.Owin.Cors
The latter is required for cross-domain support, for the case where applications host SignalR and a web page in different domains--in this example, the SignalR server and client will be on different ports.
Ensure that your Program.cs has the following code, which allows you to debug the service from within Visual Studio or run it like a normal service when installed:
using ServiceProcess.Helpers;
using System;
using System.Collections.Generic;
using System.Data;
using System.ServiceProcess;
namespace SignalRBroadcastServiceSample
{
static class Program
{
private static readonly List<ServiceBase> _servicesToRun = new List<ServiceBase>();
static void Main()
{
_servicesToRun.Add(CurrencyExchangeService.Instance);
if (Environment.UserInteractive)
{
_servicesToRun.ToArray().LoadServices();
}
else
{
ServiceBase.Run(_servicesToRun.ToArray());
}
}
}
}
Registering SignalR Middleware
Add the following class to your service project:
using System;
using System.Threading.Tasks;
using Microsoft.Owin;
using Owin;
[assembly: OwinStartup(typeof(SignalRBroadcastServiceSample.Startup))]
namespace SignalRBroadcastServiceSample
{
public class Startup
{
public void Configuration(IAppBuilder app)
{
app.MapSignalR();
}
}
}
The above MapSignalR
code defines the route that clients will use to connect to your Hub.
The default route URL that clients will use to connect to your Hub is "/signalr". Say, you had a folder in your project named signalr so you did not want to use this URL, then you could create a custom URL with this on the server:
app.MapSignalR("/mycustomurl", new HubConfiguration());
Then, you would use this to specify the custom URL on your client:
var hubConnection = new HubConnection("http://contoso.com/mycustomurl", useDefaultUrl: false);
Adding SignalR Code to Service
Add this Currency.cs class to a separate library project:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SignalrDomain
{
public class Currency
{
private decimal _usdValue;
public string CurrencySign { get; set; }
public decimal Open { get; private set; }
public decimal Low { get; private set; }
public decimal High { get; private set; }
public decimal LastChange { get; private set; }
public decimal RateChange
{
get
{
return USDValue - Open;
}
}
public double PercentChange
{
get
{
return (double)Math.Round(RateChange / USDValue, 4);
}
}
public decimal USDValue
{
get
{
return _usdValue;
}
set
{
if (_usdValue == value)
{
return;
}
LastChange = value - _usdValue;
_usdValue = value;
if (Open == 0)
{
Open = _usdValue;
}
if (_usdValue < Low || Low == 0)
{
Low = _usdValue;
}
if (_usdValue > High)
{
High = _usdValue;
}
}
}
}
}
Hub Object
The Hub
object is instantiated for you by the SignalR Hubs pipeline so you do not need to instantiate the Hub
class or call its methods from your own code on the server.
The Hub
class instances are transient so you cannot use them to maintain state from one method call to another. You can maintain state in a database, static
variable or a different class if needed.
If you want to broadcast messages to specific named groups, then the named groups are defined within your Hub
class.
The public
methods in your Hub
class can be called by clients.
You can define multiple Hub
classes in your application, where the connection will be shared. Groups, on the other hand, are separate for each Hub
class, and should be defined within a Hub
.
If you want to use a different Hub
name than the name of your Hub
class, then use this attribute:
[HubName("PascalCaseMyChatHub")]
Add this CurrencyExchangeHub.cs file to your service project, where the public
methods are what can be called from your clients. Data is communicated between the server and the client using JSON, and SignalR handles the binding of complex objects automatically.
The CurrencyExchangeHub
class, which derives from the SignalR Hub
class and will handle receiving connections and method calls from clients:
using Microsoft.AspNet.SignalR;
using Microsoft.AspNet.SignalR.Hubs;
using Microsoft.Owin;
using SignalrDomain;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SignalRBroadcastServiceSample
{
public class CurrencyExchangeHub : Hub
{
private readonly CurrencyExchangeService _currencyExchangeHub;
public CurrencyExchangeHub() :
this(CurrencyExchangeService.Instance)
{
}
public CurrencyExchangeHub(CurrencyExchangeService currencyExchange)
{
_currencyExchangeHub = currencyExchange;
}
public IEnumerable<Currency> GetAllCurrencies()
{
return _currencyExchangeHub.GetAllCurrencies();
}
public string GetMarketState()
{
return _currencyExchangeHub.MarketState.ToString();
}
public bool OpenMarket()
{
_currencyExchangeHub.OpenMarket();
return true;
}
public bool CloseMarket()
{
_currencyExchangeHub.CloseMarket();
return true;
}
public bool Reset()
{
_currencyExchangeHub.Reset();
return true;
}
}
}
Please note that if you anticipate some of your calls taking quite some time to complete, then you can perform an asynchronous call instead to ensure the application stays responsive:
public async IEnumerable<Currency> GetAllCurrencies()
{
IEnumerable<Currency> currencies = new IEnumerable<Currency>();
Task loadCurrenciesTask = Task.Factory.StartNew(() => LoadCurrencies(currencies));
await loadCurrenciesTask;
return currencies;
}
private static void LoadCurrencies(IEnumerable<Currency> currencies)
{
currencies = _currencyExchangeHub.GetAllCurrencies();
}
Add this to your CurrencyExchangeService.cs file:
using Microsoft.AspNet.SignalR;
using Microsoft.AspNet.SignalR.Hubs;
using Microsoft.Owin.Hosting;
using SignalrDomain;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ServiceProcess;
using System.Threading;
namespace SignalRBroadcastServiceSample
{
public partial class CurrencyExchangeService : ServiceBase
{
private Thread mainThread;
private bool isRunning = true;
private Random random = new Random();
protected override void OnStart(string[] args)
{
WebApp.Start("http://localhost:8083");
LoadDefaultCurrencies();
mainThread = new Thread(new ParameterizedThreadStart(this.RunService));
mainThread.Start(DateTime.MaxValue);
}
protected override void OnStop()
{
mainThread.Join();
}
public void RunService(object timeToComplete)
{
DateTime dtTimeToComplete = timeToComplete != null ?
Convert.ToDateTime(timeToComplete) : DateTime.MaxValue;
while (isRunning && DateTime.UtcNow < dtTimeToComplete)
{
Thread.Sleep(15000);
NotifyAllClients();
}
}
private void NotifyAllClients()
{
Currency currency = new Currency();
currency.CurrencySign = "CAD";
currency.USDValue = random.Next();
BroadcastCurrencyRate(currency);
Clients.All.NotifyChange(currency);
}
#region "SignalR code"
private readonly static Lazy<CurrencyExchangeService>
_instance = new Lazy<CurrencyExchangeService>(
() => new CurrencyExchangeService
(GlobalHost.ConnectionManager.GetHubContext<CurrencyExchangeHub>().Clients));
private readonly object _marketStateLock = new object();
private readonly object _updateCurrencyRatesLock = new object();
private readonly ConcurrentDictionary<string,
Currency> _currencies = new ConcurrentDictionary<string, Currency>();
private readonly double _rangePercent = 0.002;
private readonly TimeSpan _updateInterval = TimeSpan.FromMilliseconds(250);
public TimeSpan UpdateInterval
{
get { return _updateInterval; }
}
private readonly Random _updateOrNotRandom = new Random();
private Timer _timer;
private volatile bool _updatingCurrencyRates;
private volatile MarketState _marketState;
public CurrencyExchangeService(IHubConnectionContext<dynamic> clients)
{
InitializeComponent();
Clients = clients;
}
public static CurrencyExchangeService Instance
{
get
{
return _instance.Value;
}
}
private IHubConnectionContext<dynamic> Clients
{
get;
set;
}
public MarketState MarketState
{
get { return _marketState; }
private set { _marketState = value; }
}
public IEnumerable<Currency> GetAllCurrencies()
{
return _currencies.Values;
}
public bool OpenMarket()
{
bool returnCode = false;
lock (_marketStateLock)
{
if (MarketState != MarketState.Open)
{
_timer = new Timer(UpdateCurrencyRates, null, _updateInterval, _updateInterval);
MarketState = MarketState.Open;
BroadcastMarketStateChange(MarketState.Open);
}
}
returnCode = true;
return returnCode;
}
public bool CloseMarket()
{
bool returnCode = false;
lock (_marketStateLock)
{
if (MarketState == MarketState.Open)
{
if (_timer != null)
{
_timer.Dispose();
}
MarketState = MarketState.Closed;
BroadcastMarketStateChange(MarketState.Closed);
}
}
returnCode = true;
return returnCode;
}
public bool Reset()
{
bool returnCode = false;
lock (_marketStateLock)
{
if (MarketState != MarketState.Closed)
{
throw new InvalidOperationException
("Market must be closed before it can be reset.");
}
LoadDefaultCurrencies();
BroadcastMarketReset();
}
returnCode = true;
return returnCode;
}
private void LoadDefaultCurrencies()
{
_currencies.Clear();
var currencies = new List<Currency>
{
new Currency { CurrencySign = "USD", USDValue = 1.00m },
new Currency { CurrencySign = "CAD", USDValue = 0.85m },
new Currency { CurrencySign = "EUR", USDValue = 1.25m }
};
currencies.ForEach(currency => _currencies.TryAdd(currency.CurrencySign, currency));
}
private void UpdateCurrencyRates(object state)
{
lock (_updateCurrencyRatesLock)
{
if (!_updatingCurrencyRates)
{
_updatingCurrencyRates = true;
foreach (var currency in _currencies.Values)
{
if (TryUpdateCurrencyRate(currency))
{
BroadcastCurrencyRate(currency);
}
}
_updatingCurrencyRates = false;
}
}
}
private bool TryUpdateCurrencyRate(Currency currency)
{
var r = _updateOrNotRandom.NextDouble();
if (r > 0.1)
{
return false;
}
var random = new Random((int)Math.Floor(currency.USDValue));
var percentChange = random.NextDouble() * _rangePercent;
var pos = random.NextDouble() > 0.51;
var change = Math.Round(currency.USDValue * (decimal)percentChange, 2);
change = pos ? change : -change;
currency.USDValue += change;
return true;
}
private void BroadcastMarketStateChange(MarketState marketState)
{
switch (marketState)
{
case MarketState.Open:
Clients.All.marketOpened();
break;
case MarketState.Closed:
Clients.All.marketClosed();
break;
default:
break;
}
}
private void BroadcastMarketReset()
{
Clients.All.marketReset();
}
private void BroadcastCurrencyRate(Currency currency)
{
Clients.All.updateCurrencyRate(currency);
}
}
public enum MarketState
{
Closed,
Open
}
#endregion
}
Clients.All
means to broadcast to all clients. You could also send a message to everyone except those specified by a connection ID by calling Clients.AllExcept(connectionId1, connectionId2).updateCurrencyRate(currency)
. To learn how to specify which clients or groups of clients, see here and here.
If you want to allow SignalR clients to connect from other computers, then change localhost to + in the URL passed in to WebApp.Start
.
Next, add a unit testing library to your solution, where you first add the SignalRPackage
from the Package Manager Console:
PM> Install-Package Microsoft.AspNet.SignalR.SelfHost
PM> Install-Package Microsoft.AspNet.SignalR.Client
Now add the following code:
using System;
using System.ServiceProcess;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SignalRBroadcastServiceSample;
namespace UnitTests
{
[TestClass]
public class TestCurrencyExchangeService
{
#region Additional test attributes
[ClassInitialize()]
public static void MyClassInitialize(TestContext testContext)
{
}
[ClassCleanup()]
public static void MyClassCleanup()
{
}
[TestInitialize()]
public void MyTestInitialize()
{
}
[TestCleanup()]
public void MyTestCleanup()
{
}
#endregion
[TestMethod]
public void TestClientGetMarketStateFromHub()
{
PrivateObject privateObject = new PrivateObject(_service);
privateObject.Invoke("OnStart", new object[] { null });
using (HubConnection hub = new HubConnection(String.Format("http://{0}:8084", "localhost")))
{
IHubProxy proxy = hub.CreateHubProxy("CurrencyExchangeHub");
hub.Start().Wait();
var state = proxy.Invoke<string>("GetMarketState").Result;
Assert.IsNotNull(state);
Assert.IsTrue(state.Length > 0);
}
}
[TestMethod]
public void TestClientGetAllCurrenciesFromHub()
{
PrivateObject privateObject = new PrivateObject(_service);
privateObject.Invoke("OnStart", new object[] { null });
using (HubConnection hub = new HubConnection(String.Format("http://{0}:8084", "localhost")))
{
IHubProxy proxy = hub.CreateHubProxy("CurrencyExchangeHub");
hub.Start().Wait();
var currencies = proxy.Invoke<ienumerable<currency>>("GetAllCurrencies").Result;
Assert.IsNotNull(currencies);
Assert.IsTrue(currencies.ToString().Length > 0);
}
}
[TestMethod]
public void TestClientOpenCloseMarketFromHub()
{
PrivateObject privateObject = new PrivateObject(_service);
privateObject.Invoke("OnStart", new object[] { null });
using (HubConnection hub = new HubConnection(String.Format("http://{0}:8084", "localhost")))
{
IHubProxy proxy = hub.CreateHubProxy("CurrencyExchangeHub");
hub.Start().Wait();
var state = proxy.Invoke<bool>("OpenMarket").Result;
Assert.IsNotNull(state);
Assert.IsTrue(state == true);
state = proxy.Invoke<bool>("CloseMarket").Result;
Assert.IsNotNull(state);
Assert.IsTrue(state == true);
}
}
[TestMethod]
public void TestGetMarketStateFromHub()
{
CurrencyExchangeHub hub = new CurrencyExchangeHub(CurrencyExchangeService.Instance);
var state = hub.GetMarketState();
Assert.IsNotNull(state);
}
[TestMethod]
public void TestOpenCloseMarket()
{
var currencies = CurrencyExchangeService.Instance.GetAllCurrencies();
Assert.IsNotNull(currencies);
bool expected = true;
bool actual = CurrencyExchangeService.Instance.OpenMarket();
Assert.AreEqual(expected, actual);
actual = CurrencyExchangeService.Instance.OpenMarket();
Assert.AreEqual(expected, actual);
}
[TestMethod]
public void TestOpenCloseMarketFromHub()
{
var hub = new CurrencyExchangeHub(CurrencyExchangeService.Instance);
var currencies = hub.GetAllCurrencies();
Assert.IsNotNull(currencies);
bool expected = true;
bool actual = hub.OpenMarket();
Assert.AreEqual(expected, actual);
actual = hub.OpenMarket();
Assert.AreEqual(expected, actual);
}
}
}</bool></bool></ienumerable<currency></string>
If you build and run the above tests, they should pass.
Next, add a console project to your solution and name it Client
. Then open the NuGet package manager console and type this command:
PM> Install-Package Microsoft.AspNet.SignalR.Client
Now add the following CommunicationHandler
class to your project:
using Microsoft.AspNet.SignalR.Client;
using SignalrDomain;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Client
{
public static class CommunicationHandler
{
public static string ExecuteMethod(string method, string args, string serverUri, string hubName)
{
var hubConnection = new HubConnection("http://localhost:8083");
IHubProxy currencyExchangeHubProxy = hubConnection.CreateHubProxy("CurrencyExchangeHub");
currencyExchangeHubProxy.On<Currency>("NotifyChange", HandleNotify);
hubConnection.Start().Wait();
var result = currencyExchangeHubProxy.Invoke<string>(method).Result;
return result;
}
private static void HandleNotify(Currency currency)
{
Console.WriteLine("Currency " + currency.CurrencySign + ", Rate = " + currency.USDValue);
}
}
}
Also, update the Program
class in your console project to this:
using System;
using System.Diagnostics;
using System.Net;
namespace Client
{
class Program
{
static void Main(string[] args)
{
var state = CommunicationHandler.ExecuteMethod("GetMarketState",
"", IPAddress.Any.ToString(), "CurrencyExchangeHub");
Console.WriteLine("Market State is " + state);
if (state == "Closed")
{
var returnCode = CommunicationHandler.ExecuteMethod
("OpenMarket", "", IPAddress.Any.ToString(), "CurrencyExchangeHub");
Debug.Assert(returnCode == "True");
Console.WriteLine("Market State is Open");
}
Console.ReadLine();
}
}
}
To view the application running, first set the SignalRBroadcastServiceSample
project as the startup project in one instance of Visual Studio, and press the place button when it appears. Then open another instance of Visual Studio, and this time set the Client
project as startup. Now press F5 to test the application. Every fifteen seconds, the console should receive a broadcasted currency rate update.
History
Acknowledgements
Please note that I got a lot of the ideas for this article from Patrick Fletcher`s article here and Tom Dykstra and Tom FitzMacken's article here.
References