我的服裝DRP之即時通訊——為WCF增加UDP綁定(應用篇)
發個牢騷,博客園發博文竟然不能寫副標題。這篇既為我的服裝DRP系列第二篇,也給為WCF增加UDP綁定系列收個尾。原本我打算記錄開發過程中遇到的一些問題和個人見解,不過寫到一半發現要寫的東西實在太多,有些問題甚至不好描述,又擔心誤導讀者,就作罷了。
說到即時通訊大伙都會第一時間想到QQ等聊天軟件,似乎跟服裝DRP八竿子打不著。即時通信翻譯自Instant Messaging,如果我把它解釋為即時消息推送,再將其放之于企業應用中就好理解了。舉例:上級給下級發貨,下級能第一時間知道貨已發出,就用不著打電話詢問或滿心期待地頻繁刷新列表;下級店鋪賣出一單,正在為銷售淡季發愁的老板看到蹦出的提示消息,瞬間有了信心……
這個功能對不明真相的客戶并沒有多少吸引力,因為大部分CS軟件似乎都能做到這一點,只不過——或多或少延遲個幾秒或幾分鐘,當然客戶對延遲一無所知。但是做技術的知道這個延遲代表什么:頻繁地訪問數據源,頻繁地將“最新”數據與本地數據作比較[or直接使用獲取到的數據]刷新UI。假設對數據實時精度控制為1分鐘,有1000個客戶端運行,平均每個客戶端對10種數據類型感興趣(比如數據類型(即時通信中可稱為消息類型)包括入庫、發貨、零售和調撥,或者基礎資料的修改等等),那么每分鐘就會產生額外的10000次的數據庫的訪問量,注意大部分訪問都沒有任何作用(除了副作用),而且假如沒有合理地設置篩選條件[及其它改善手段],那么訪問產生的數據量,大部分也可能是無用的。另外,合理的數據結構和邏輯設計以滿足對各類數據類型的提示也是個不小的難點,畢竟數據類型多種多樣,就單個數據類來說,也有多個屬性,假如用戶對其中的某些屬性感興趣,如何設計一種方式使得數據庫中某條記錄的某些字段變動時能檢索到,嘖嘖,水很深喲。
注意:即時通訊和BS幾乎沒關系,BS應用先天不足,只能采用定時讀取數據庫的方式來模擬即時通信,同上述的大部分CS軟件。也許用插件能行,但是插件本質上也是CS中的C。上回說到BS的缺點,這里又能加上一條,呵呵,開個玩笑。
請時刻注意本文所說的IM并非單純的聊天軟件,而是為企業應用系統服務的輔助類工具。它應該具有相對獨立性、良好的擴展性和簡便的應用性(應用是對用戶和開發人員兩者來說的,用戶能方便的使用它,開發人員能方便地將它接入系統)。按照本系列慣例,列客戶關注的幾個功能需求:
- 在線用戶管理(這在大中型服裝企業比較有用,能有效跟進各個分支機構的分銷系統使用情況);
- 系統消息廣播;
- 單點登錄(當已有相同賬號在線時,兩種處理方式,一是登錄失敗,一是仿QQ,將原在線用戶踢下線;用數據庫方式能實現前一種。);
- 業務事件成功后可自動[對N個目標客戶端]發送消息;
- 用戶接收消息權限管理(是否能接收某個類型的消息);
- 消息提示;
- 消息查詢(目前并未提供往期歷史消息查詢)
- 企業通訊工具(重點是美工,推后)
- ……
需求看似挺多,其實技術實現起來難點就一個:UDP打洞。單純打洞而言,直接用Socket編碼相當簡單。不過為了提升自己對WCF的理解,我決定使用WCF來完成,后來發現這真是自討苦吃(一些知識要點記錄在為WCF增加UDP綁定(儲備篇))中。依托WCF框架進行UDP通信與直接使用Socket相比,也有很多好處,比如消息的傳遞被封裝為方法的調用,更符合咱“高層開發者”的口味。WCF原生支持的綁定類型并沒有給實現打洞提供太多可用信息(TCP等若干綁定能獲取發送端IPEndPoint信息),因此我使用微軟后來提供的UDP綁定封裝示例,并增加了設置通信端口和獲取發送端IPEndPoint的功能,這兩者是實現打洞的前提,此處不予贅述。下面關注業務代碼。
1 /// <summary>
2 /// 用戶終端
3 /// </summary>
4 [DataContract]
5 public class UserPoint
6 {
7 /// <summary>
8 /// 用戶標識
9 /// </summary>
10 [DataMember]
11 public string UserGuid { get; set; }
12
13 [DataMember]
14 public int UserID { get; set; }
15 [DataMember]
16 public string UserName { get; set; }
17 [DataMember]
18 public int OrganizationID { get; set; }
19 [DataMember]
20 public string OrganizationName { get; set; }
21
22 /// <summary>
23 /// 用戶主機用于偵聽和發送消息的網絡地址(和端口)
24 /// </summary>
25 [DataMember]
26 public string NetPointAddress { get; set; }
27
28 public string UDPIMIPPort
29 {
30 get
31 {
32 if (string.IsNullOrEmpty(NetPointAddress))
33 return "";
34 else
35 return "soap.udp://" + NetPointAddress;
36 }
37 }
38
39 //給子類使用
40 //WCF不支持繼承,可以使用KnowType,子類并非定義在當前程序集,此處用顯式轉換
41 public UserPoint ConvertToBase()
42 {
43 return new UserPoint
44 {
45 OrganizationID = this.OrganizationID,
46 OrganizationName = this.OrganizationName,
47 UserID = this.UserID,
48 UserName = this.UserName,
49 NetPointAddress = this.NetPointAddress,
50 UserGuid = this.UserGuid
51 };
52 }
53 }
接著定義服務契約,由于客戶端會相互通信,在打洞時服務端也會調用客戶端方法,因此所有客戶端在運行時也要寄宿服務。
服務端:
1 [ServiceContract(Namespace = "http://www.tuoxie.com/erp/")]
2 public interface IServerService
3 {
4 /// <summary>
5 /// 用戶登入[到服務器端用戶列表]
6 /// </summary>
7 [OperationContract(IsOneWay = true)]
8 void UserLogin(UserPoint user);
9
10 /// <summary>
11 /// 用戶登出[移出服務器端用戶列表]
12 /// </summary>
13 [OperationContract(IsOneWay = true)]
14 void UserLogout(UserPoint user);
15
16 /// <summary>
17 /// 叫用戶A給用戶B方向發一條消息(打洞)
18 /// </summary>
19 /// <param name="callingUser">打洞方</param>
20 /// <param name="waitingUserID">等待方標識</param>
21 [OperationContract(IsOneWay = true)]
22 void CallUserToPunchHole(UserPoint callingUser, string waitingUserGuid);
23
24 /// <summary>
25 /// 維持映射端口
26 /// </summary>
27 [OperationContract(IsOneWay = true)]
28 void HoldMyPort();
29 }
注意已映射端口在一段時間不使用后會自動失效。我在本地測試時,100秒端口還能用,能相互通信,120秒后失效,服務器再通過原先端口給客戶端發送訊息,客戶端不再接收到。為了維持有效性,需要客戶端定時給服務器發送消息,即心跳檢測(反之應該也可以?)。HoldMyPort就是這個作用,一般實現為空方法。
客戶端[服務]:
1 /// <summary>
2 /// 客戶端服務,主要用來接收各種消息
3 /// </summary>
4 [ServiceContract(Namespace = "http://www.tuoxie.com/erp/")]
5 public interface IClientService
6 {
7 /// <summary>
8 /// 用戶上線通知
9 /// </summary>
10 [OperationContract(IsOneWay = true)]
11 void NotifyWhenUserLogin(UserPoint user);
12
13 /// <summary>
14 /// 用戶下線通知
15 /// </summary>
16 [OperationContract(IsOneWay = true)]
17 void NotifyWhenUserLogout(UserPoint user);
18
19 /// <summary>
20 /// 消息通知
21 /// </summary>
22 [OperationContract(IsOneWay = true)]
23 void NotifyMessage(IMessage message);
24
25 /// <summary>
26 /// 打洞
27 /// </summary>
28 [OperationContract(IsOneWay = true)]
29 void NotifyPunchHole(UserPoint waitingUser);
30
31 /// <summary>
32 /// sbody say "hi" to me
33 /// <remarks>屬于打洞過程</remarks>
34 /// </summary>
35 [OperationContract(IsOneWay = true)]
36 void SayHi(UserPoint callingUser);
37
38 /// <summary>
39 /// 踢我下線
40 /// </summary>
41 [OperationContract(IsOneWay = true)]
42 void KickOff(UserPoint user);
43 }
當用戶登錄系統時,發送訊息給服務器,服務端將執行下述方法:
1 public void UserLogin(UserPoint user)
2 {
3 var users = MainWindowVM.OnlineUsers.Where(o => o.UserID == user.UserID).ToArray();
4 lock (((ICollection)MainWindowVM.OnlineUsers).SyncRoot)
5 {
6 if (users.Count() > 0)
7 {
8 Parallel.ForEach(users, u =>
9 {
10 MainWindowVM.OnlineUsers.Remove(u);
11 ServerService.InvokeClientService(u, service => service.KickOff(u.ConvertToBase()));
12 });
13 }
14 }
15 OperationContext context = OperationContext.Current;
16 //獲取傳進的消息屬性
17 MessageProperties properties = context.IncomingMessageProperties;
18 //獲取消息發送的遠程終結點IP和端口
19 IPEndPoint endpoint = properties[RemoteEndpointMessageProperty.Name] as IPEndPoint;
20 user.NetPointAddress = endpoint.ToString();
21 lock (((ICollection)MainWindowVM.OnlineUsers).SyncRoot)
22 {
23 MainWindowVM.OnlineUsers.Add(new ServerUserPoint(user) { LoginTime = DateTime.Now });
24 }
25 NotifyWhenUserLogin(user);
26 }
27
28 /// <summary>
29 /// 通知所有在線用戶有新用戶上線了
30 /// </summary>
31 /// <param name="user">上線用戶</param>
32 private void NotifyWhenUserLogin(UserPoint user)
33 {
34 lock (((ICollection)MainWindowVM.OnlineUsers).SyncRoot)//避免在循環過程中集合被修改
35 {
36 for (int i = 0; i < MainWindowVM.OnlineUsers.Count; i++)
37 {
38 var u = MainWindowVM.OnlineUsers.ElementAtOrDefault(i);
39 if (u != null && u.UserID != user.UserID)
40 InvokeClientService(u, service => service.NotifyWhenUserLogin(user));
41 }
42 }
43 }
測試該方法需要三臺最好處于不同局域網內的機子,其中一臺通過NAT映射為公網服務器。
單點登錄:當有相同賬號用戶在線或系統管理員在服務端使用了踢TA下線的功能后,該賬號已在線用戶將被強制退出系統。原本面對這樣的需求,我們常常在用戶數據表中增加一個標識用戶是否在線的字段,當用戶登錄成功置為1,退出則置為0。但這只能實現后續用戶登錄失敗,而不會給已在線用戶帶來任何影響,另外會帶來一個發生率較高的問題:系統異常退出,極端的情況諸如斷電,那么用戶以后就再也登錄不了了,除非增加一個重置狀態的功能,假如用戶數多的話,那系統管理員就有的忙了。無論如何,這不是一個好的方法。假如有一天,客戶希望取消同時在線數的限制,或者,取消部分用戶的同時在線數限制,那么開發人員就有的忙了。有了IM,一切都變得相當輕松。我們只要在用戶登入IM時進行相應的處理即可,我們甚至可以決定哪些用戶不能重復登入,哪些可以重復登入。由于IM相對獨立,改動起來比較方便,而且IM服務端只運行在服務器上,也不存在部署問題。強制用戶退出只需要請求相應客戶端的KickOff操作,此時客戶端扮演服務端的角色。
接下來到了重點:打洞。少年們兩眼綻放出異樣的光芒,卻不知道當事者的辛苦。其實關鍵代碼相當簡單。
1 public static void SendMessageTo(ClientUserPoint user, IMessage message)
2 {
3 Action invokeAction = () =>
4 {
5 InvokeClientService(user, service => service.NotifyMessage(message));
6 };
7 if (user.IsTrustMe)//信任用戶(已經建立信任連接)不需要打洞
8 {
9 invokeAction();
10 }
11 else
12 {
13 Action action = () =>
14 {
15 int maxTryCount = 3;//最大嘗試次數
16 for (int i = 0; i < maxTryCount && !user.IsTrustMe; i++)
17 {
18 InvokeClientService(user, service => service.SayHi(CurrentUser));//我先打招呼
19 InvokeServerService(service => service.CallUserToPunchHole(user.ConvertToBase(), CurrentUser.UserGuid));//服務器叫對方給我打招呼
20 Thread.Sleep(500);
21 }
22 if (user.IsTrustMe)
23 {
24 invokeAction();
25 }
26 };
27 action.BeginInvoke(null, null);
28 }
29 }
這里有個問題,當通信雙方處于相同局域網,應該期望它們直接通信,省略打洞步驟。方法是在用戶登錄時將本機IP和端口號(未映射)同時發送到服務端,當客戶端A和客戶端B的映射IP相同則說明他們處于同一內網,然后根據本機地址直連通信。不過這應該有兩個問題需要解決:當局域網內存在多級子網NAT,A、B分屬不同層,那么它們還要進行內部局域網打洞;本機IP有時候并不能準確獲取,特別有些軟件能生成虛擬IP。
在打洞成功后我們將對方的IsTrustMe設置成true。
1 public void SayHi(UserPoint callingUser)
2 {
3 if(VMGlobal.CurrentUser != null)
4 {
5 var user = IMHelper.OnlineUsers.Find(o => o.UserGuid == callingUser.UserGuid);
6 if (user != null)
7 user.IsTrustMe = true;
8 }
9 }
現在就可以直連通信咯。
經測試,打洞過程一般嘗試1次就能連接成功,此處每次等待500毫秒。
關于組播。原本打算采用組播的方式群發消息(包括所有終端用戶其它用戶上下線的提示消息),不成想,路由器默認情況下是不會轉發組播包的,必須在路由器上進行配置才行,解決該問題需要網管進行配合,不是編程就能解決的。而且一般的路由器都不支持組播,也就是說,目前很多路由器不支持組播協議,所以,局域網的路由器不會將這個組播信息傳輸出去,so,外面的電腦以及路由根本就不知道你這個組播的信息。有專門支持組播的路由,不過貌似價格不菲。如果路由器不支持組播的話,那么你的交換機就把你的組播數據當成廣播數據了,廣播只能在局域網里面。(該段話來自網絡)。按照這個說法,外部組播數據想要進入內網也困難重重(對or錯?)。因此我改用循環發送方式。
最后截個消息查詢和消息接收權限的圖,消息接收權限設置我目前將之放入角色管理中。
至此,IM核心功能基本實現完畢,能滿足目前系統的需求(還有大數據傳輸等問題暫時未涉及到就不考慮了)。所謂企業通訊工具不過是在此基礎上功能的累加,以后再加入吧。:)
后記:竊以為消息提示只是IM基本輔助功能,IM還能幫助系統即時刷新。舉例:當權限管理員為我新增了幾個模塊權限,按照平常的做法,需要我注銷后重新登錄才能看到,現在只要將新增的模塊信息發送給我,我這邊系統自動將它們構造進左側菜單樹中即可;我正在下拉框中選擇下級機構準備為他發貨,下拉框中的數據項突然增加了一個,原因是機構管理員錄入了一個新機構;……
轉載本文請注明出處:http://www.rzrgm.cn/newton/archive/2013/01/26/2877500.html

浙公網安備 33010602011771號