這篇文章算SignalR的進階應用,還不知道SignalR是啥的朋友,可先參考91與小朱的介紹文再繼續閱讀:

手邊有個需求,打算開發由單一主控端操作多個Client程式的架構,為確保控制端與被控端間能持續不斷交換訊息,傳統上,多會用Socket、TcpClient等在二者間建立長期連線來滿足需求。前陣子剛好看到SignalR除了JavaScript Client Library外,也提供了SignalR.Client讓.NET程式也能像JavaScript一樣輕鬆引用,於是我興起了用SignalR試做遠端遙控機制的想法。

直接看雛型更容易理解我想搞什麼飛機。底下是一個ASP.NET MVC網站,搭配模擬Client端用的簡單Console Application(SignalRClient.exe)一起運作。網頁可顯示目前已連線的Client端資訊,透過介面操作可傳送文字給特定的Client端以Console.WriteLine()輸出,並可要求指定的Client端自行關閉,另外也支援All選項,以便一次傳訊給所有Client。如以下圖示,我開了三個SignalRClient.exe,分別命名為C1, C2, C3,網頁上便會即時出現這三個加入的Client名稱;選All,按Send傳一段"全體注意... "文字,接著再測試分別對C1, C2, C3傳達3, 3, 23的神祕數字組合。

看完操作說明,回頭看看程式該怎麼寫。

首先需要在ASP.NET MVC中增加一個CommHub類別(記得還要註冊路由),用一個static Dictionary保存目前Client的資訊,在Disconnect()時移除Client資訊,另外宣告了Register(string clientName)讓Client連線後呼叫註冊,註冊後視為已連線並以ConnectionId為Key加入Dictionary中。當註冊名稱為"Console"時,該Client會接收到目前已連線的Client端清單。由於每一個連線都有唯一的ConnectionId(一個隨機產生的GUID),在傳送訊息時,可透過Clients[connection_id].ClientMethodName()呼叫特定Client的ClientMethodName(),執行指定邏輯。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using SignalR;
using SignalR.Hubs;
using System.Threading.Tasks;
 
namespace WebCommander.Models
{
    //保存Client識別資料的物件
    public class ClientInfo
    {
        public string ConnId { get; set; }
        public string ClientName { get; set; }
    }
 
    public class CommHub : Hub, IDisconnect, IConnected
    {
        public static Dictionary<string, ClientInfo> CurrClients =
            new Dictionary<string, ClientInfo>();
        //連線關閉時觸發
        public Task Disconnect()
        {
            string cid = Context.ConnectionId;
            lock (CurrClients)
            {
                if (CurrClients.ContainsKey(cid))
                {
                    CurrClients.Remove(cid);
                    UpdateConsoleStats();
                }
            }
            return null;
        }
        //連線建立時觸發
        public Task Connect()
        {
            string cid = Context.ConnectionId;
 
            return null;
        }
        //重新連線時觸發
        public Task Reconnect(IEnumerable<string> groups)
        {
            return null;
        }
        //ClientName為Console時,可用來接收控制資訊
        static string consoleCid = null;
        private void UpdateConsoleStats()
        {
            if (string.IsNullOrEmpty(consoleCid))
                return;
            Clients[consoleCid].RefreshStats(CurrClients);
        }
        //註冊Client識別名稱
        public void Register(string clientName)
        {
            string cid = Context.ConnectionId;
            lock (CurrClients)
            {
                //命名為Console時,作為接收控制資料之用
                if (clientName == "Console")
                {
                    consoleCid = cid;
                } //其餘連線加入Dictionary中
                else if (!CurrClients.ContainsKey(cid))
                {
                    CurrClients.Add(cid, new ClientInfo() { 
                        ConnId = cid,
                        ClientName = clientName
                    });
                }
                //將目前連線的Client資料傳送給Console Client
                UpdateConsoleStats();
            }
        }
 
    }
}

網頁介面部分,乃利用/Home/Index.cshtml加入適當JavaScript實做,借用knockoutJs處理Client清單轉為<input type="radio">以及選取特定Client的部分;引用~/SignalR/hubs後,就可使用$.connetion.commHub跟Server端的CommHub搭上線(注意: CommHub及Register在JavaScript端會被轉為commHub及register的小寫字首寫法,我在此處跌了一跤),connection.start()後呼叫commHub.register("Console")將自己註冊為Console Client,並宣告RefreshStats(dict)以接收最新的連線Client清單。但要注意start()採非同步執行的,要等其完成才能呼叫.register(),這裡可借用jQuery deferred物件的特性,將.register()放在start().done(fn)裡確保執行順序。另外,按下Send及Close鈕,則會帶入選取的Client ConnectionId呼叫HomeController的另外兩個Action: Send及CloseClient。

@{
    ViewBag.Title = "SignalR Remote Controller Demo";
}
<div>
<input id="txtMsg" />
<!-- data-bind="enable: connId()" 當vm.connId有值時才可使用-->
<input type="button" value="Send" id="btnSend" data-bind="enable: connId()" />
<input type="button" value="Close" id="btnClose" data-bind="enable: connId()" />
</div>
<div>Clients:</div>
<div id="ulClients" data-bind="foreach: clients">
    <div>
        <!-- 將value設成ConnId, 選取與否的結果寫入vm.connId -->
        <input type="radio" name="client" 
               data-bind="value: ConnId, checked: $parent.connId" />
        <span data-bind="text: ClientName"></span>
    </div>
</div>
 
@section scripts {
<script src="@Url.Content("~/Scripts/jquery.signalR-0.5.2.js")" ></script>
<script src="@Url.Content("~/SignalR/hubs")" ></script>
<script src="@Url.Content("~/Scripts/knockout-2.0.0.js")"></script>
<script>
    $(function () {
        //使用knockoutJs處理UI資料同步
        var vm = {
            clients: ko.observableArray([]),
            connId: ko.observable()
        };
        ko.applyBindings(vm);
 
        //與SignalR Hub建立連線
        var commHub = $.connection.commHub;
        //收到Hub傳送Client清單時,更新顯示,此處命名要與Server端一致
        commHub.RefreshStats = function(dict) {
            vm.clients.removeAll();
            vm.clients.push({ ConnId:"*", ClientName:"All" });
            vm.connId(false);
            for (var cid in dict) {
                vm.clients.push(dict[cid]);
            }
        };
        $.connection.hub.start()
        .done(function() {
            //利用deferred done()在連線完成後呼叫Server端的Register()
            //注意: 在JavaScript中,方法名稱會被轉為小寫起始
            commHub.register("Console");
         });
 
        //送出訊息給指定Client
        $("#btnSend").click(function () {
            $.post("@Url.Content("~/Home/Send")", { 
                cid: vm.connId(),
                msg: $("#txtMsg").val() 
            }, function(r) {
            });
        });
        //下達指令給Client要求關閉
        $("#btnClose").click(function() {
            $.post("@Url.Content("~/Home/CloseClient")", { 
                cid: vm.connId()
            }, function(r) {
            });
        });
    });
</script>
}

前面提到HomeController的兩個Action: Send及CloseClient,寫法很簡單,先檢查cid參數是否為"*",決定要呼叫全部Client或指定Client的ShowMessage(msg)與Exit()。Clients挺有意思,它是個dynamic型別,Server端不需要也無從知道Client有哪些Method,故配上什麼方法名稱都可以編譯過關,實際執行時,Server端會以JSON格式與前端溝通,執行JavaScript端函數或觸發SignalR.Client模擬的事件。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using SignalR;
using WebCommander.Models;
 
namespace WebCommander.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            return View();
        }
 
        [HttpPost]
        public ActionResult Send(string cid, string msg)
        {
            var context = GlobalHost.ConnectionManager.GetHubContext<CommHub>();
            if (cid == "*") //對所有Client傳送
                //注意: Clients是dynamic,故ShowMessage等方法名稱只要跟Client可以對應即可
                context.Clients.ShowMessage(msg);
            else 
                //利用Clients[connection_id]指定特定的Client, 呼叫其ShowMessage()
                context.Clients[cid].ShowMessage(msg);
            return Content("OK");
        }
 
        [HttpPost]
        public ActionResult CloseClient(string cid)
        {
            var context = GlobalHost.ConnectionManager.GetHubContext<CommHub>();
            if (cid == "*")
                context.Clients.Exit();
            else
                context.Clients[cid].Exit();    
            return Content("OK");
        }
    }
}

SignalR .NET Client的寫法可參考官方文件,引用方式與JavaScript相近,都需要宣告連線、宣告方法、建立連線等步驟,但C#不像JavaScript是動態語言可動態宣告方法,在SignalR.Client必須用On(method_name, Action<T>)類似掛載事件的語法宣告Client端的方法供Server端觸發,例如在HomeController中預期Clients具有ShowMessage(msg)及Exit()兩個方法,在此就要宣告On("ShowMessage", msg => …);及On("Exit", () => { … });加以匹配。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using SignalR.Client;
using SignalR.Client.Hubs;
using System.Threading;
 
namespace SingalRClient
{
    class Program
    {
        static void Main(string[] args)
        {
            //輸入識別名稱
            Console.Write("Please input client name: ");
            string clientName = Console.ReadLine();
            //連線SignalR Hub
            var connection = new HubConnection("http://localhost:19636/");
            IHubProxy commHub = connection.CreateProxy("CommHub");
            //顯示Hub傳入的文字訊息
            commHub.On("ShowMessage", msg => Console.WriteLine(msg));
            //利用done旗標決定程式中止
            bool done = false;
            //當Hub要求執行Exit()時,將done設為true
            commHub.On("Exit", () => { done = true; });
            //建立連線,連線建立完成後向Hub註冊識別名稱
            connection.Start().ContinueWith(task =>
            {
                if (!task.IsFaulted) 
                    //連線成功時呼叫Server端方法register()
                    commHub.Invoke("register", clientName);
                else
                    done = true;
            });
            //維持程式執行迴圈
            while (!done)
            {
                Thread.Sleep(100);
            }
            //主動結束連線
            connection.Stop();
        }
    }
}

就這樣,輕輕鬆鬆就實現了用Web遙控多個Console Application的理想,大家一起為SignalR按個讚吧!!


Comments

# by gattaca

我還以為這次的範例會搭配Knockout...

# by Jeffrey

to gattaca, RefreshStats()的部分的確是透過knockoutJs更新UI呈現Client選項,但倒沒有為ko 100%塑形直接可用的專屬資料源就是了。

# by gattaca

knockout與mvc的project樣板整合 http://knockoutmvc.com/HelloWorld 屬於VS的擴充功能 http://visualstudiogallery.msdn.microsoft.com/e980098c-10b5-4535-b7f1-f0c47193c595

# by Jeffrey

to gattaca, 謝謝你的補充。

# by gattaca

用SignalR與knockout就是為了減少程式碼, 而knockout的data-bind在html中是字串綁定,弱型別要填資料庫欄位很辛苦, 在cshtml中想使用C#強型別來填, 除了前述的knockoutmvc樣板外, 是否還有更好的做法? 這篇只解決一半 http://userinexperience.com/?p=656

# by Jeffrey

to gattaca, 您所提的議題已有點牽涉設計哲學的差異(既然已達"哲學"層次,有些就只是個人偏好,無好壞之別,所以我的看法純供參考), 使用MVC Helper依C#物件的強型別自動產生HTML的做法,在排版與UI邏輯要做細緻客製化時,常會受到嚴重挑戰,實務上常無法滿足使用者的規格要求,要克服限制耗費的心力,有時會抵銷其帶來的好處。 在踩過一些坑洞後,我漸漸偏好將CRUD UI設計方式導向透過T4之類的樣版機制產生粗模,後續再用手工雕成使用者要求要求的各種機車樣子。當然,這種做法一旦遇到物件類別事後修正,就必須以手工在前端做對應的調整,對開發人員來說耗工較多。但回到現實面上,能少寫程式固然很好,但無法驗收一切都是枉然。 簡單的結論是MVC Helper或Dynamic Data之類的自動化魔法,在面對較刁鑽的客製需求時,常會遇到不少麻煩,有時得比手工打造UI耗費更多的心力解決,每每要使用前,我會持較保守的態度評估。我覺得較易成功的做法是提供一些方便的Library、Pluging減少手工打造的難度,至於高度自動化的省力UI開發,就隨綠不強求囉!

# by gattaca

自動化的確是我首重的考量,十年來微軟的操作介面從MFC,WinForm,WPF再到Metro, 資料模型十年前的舊書The Data Model Resource Book到現在都還很有用,鑽研UI會賺錢(專案收入)卻不保值,看到蔡學鏞在用Rebol在搞下一代的程式語言,就覺得自己沒甚麼進步,只畫UML的活動類別狀態三個圖後就把.Net程式外包出去成為我現在的目標了

# by Jeffrey

to gattaca, 哈! 推"只畫UML的活動類別狀態三個圖後就把.Net程式外包出去",很迷人的目標。期待你努力成果的分享~~

# by Frank

黑暗大您好,我參考您這篇文章,實作一次, 發現現在版本和當時的有些不一樣了,部分方法也有修改, 我將不一樣的地方寫下,也有註明引用自您的blog, 不知道這樣可以嗎?文章位置:http://jhshen.blogspot.tw/2013/02/signalr.html 如有不宜的地方請和我說,我會盡快移除或修正,謝謝您的分享。 zhshen AT gmail.com

# by Jeffrey

to Frank, 謝謝你補充修改對照,非常清楚詳細,看來0.5到1.0變動處不少! 這就是追趕技術前端時少不了的困擾吧。

Post a comment