利用SignalR實現遠端程式遙控功能
10 | 69,735 |
這篇文章算SignalR的進階應用,還不知道SignalR是啥的朋友,可先參考91與小朱的介紹文再繼續閱讀:
- [.NET]SignalR簡介 - 建立 realtime 的網站 by 91
- [.NET] SignalR: 一個改變 Web 應用開發觀念的開發方式 by 小朱
- [.NET][SignalR] 體驗 SignalR: Hello by 小朱
- [.NET][SignalR] 由 Server 呼叫 JavaScript–使用 SignalR 實作 Push 訊息模式 by 小朱
手邊有個需求,打算開發由單一主控端操作多個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變動處不少! 這就是追趕技術前端時少不了的困擾吧。