Using SignalR 2 Broadcasting and Notifications with a Self-Hosted Windows Service - ...

:

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>();

        /// <summary>
        /// The main entry point for the application.
        /// </summary>
        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)
        {
            // Add configuration code or hub wire up here if needed
            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"); // Must be 
            	//@"http://+:8083" if you want to connect from other computers
            LoadDefaultCurrencies();

            // Start main thread
            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();
            }
        }

        // This line is necessary to perform the broadcasting to all clients
        private void NotifyAllClients()
        {
            Currency currency = new Currency();
            currency.CurrencySign = "CAD";
            currency.USDValue = random.Next();
            BroadcastCurrencyRate(currency);
            Clients.All.NotifyChange(currency);
        }

        #region "SignalR code"

        // Singleton instance
        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>();

        // Currency can go up or down by a percentage of this factor on each change
        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)
        {
            // This function must be re-entrant as it's running as a timer interval handler
            lock (_updateCurrencyRatesLock)
            {
                if (!_updatingCurrencyRates)
                {
                    _updatingCurrencyRates = true;

                    foreach (var currency in _currencies.Values)
                    {
                        if (TryUpdateCurrencyRate(currency))
                        {
                            BroadcastCurrencyRate(currency);
                        }
                    }

                    _updatingCurrencyRates = false;
                }
            }
        }

        private bool TryUpdateCurrencyRate(Currency currency)
        {
            // Randomly choose whether to update this currency or not
            var r = _updateOrNotRandom.NextDouble();
            if (r > 0.1)
            {
                return false;
            }

            // Update the currency price by a random factor of the range percent
            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
        // 
        //You can use the following additional attributes as you write your 
        //tests:
        //Use ClassInitialize to run code before running the first test in the
        //class
        [ClassInitialize()]
        public static void MyClassInitialize(TestContext testContext)
        {
        }

        //Use ClassCleanup to run code after all tests in a class have run
        [ClassCleanup()]
        public static void MyClassCleanup()
        {
        }

        //Use TestInitialize to run code before running each test
        [TestInitialize()]
        public void MyTestInitialize()
        {

        }

        //Use TestCleanup to run code after each test has run
        [TestCleanup()]
        public void MyTestCleanup()
        {

        }

        #endregion

        [TestMethod]
        public void TestClientGetMarketStateFromHub()
        {
            // Make sure to call WebApp.Start:
            PrivateObject privateObject = new PrivateObject(_service);
            privateObject.Invoke("OnStart", new object[] { null });

            // Create client proxy and call hub method
            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()
        {
            // Make sure to call WebApp.Start:
            PrivateObject privateObject = new PrivateObject(_service);
            privateObject.Invoke("OnStart", new object[] { null });

            // Create client proxy and call hub method
            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()
        {
            // Make sure to call WebApp.Start:
            PrivateObject privateObject = new PrivateObject(_service);
            privateObject.Invoke("OnStart", new object[] { null });

            // Create client proxy and call hub method
            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");

            // This line is necessary to subscribe for broadcasting messages
            currencyExchangeHubProxy.On<Currency>("NotifyChange", HandleNotify);

            // Start the connection
            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

  • 2015.04.02: Added details on using asynchronous Task in Hub

  • 2015.04.14: Added more unit tests to verify client proxy

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