個人服裝DRP之即時通信——爲WCF增長UDP綁定(應用篇)

 

發個牢騷,博客園發博文居然不能寫副標題。這篇既爲個人服裝DRP系列第二篇,也給爲WCF增長UDP綁定系列收個尾。本來我打算記錄開發過程當中遇到的一些問題和我的看法,不過寫到一半發現要寫的東西實在太多,有些問題甚至很差描述,又擔憂誤導讀者,就做罷了。html

說到即時通信大夥都會第一時間想到QQ等聊天軟件,彷佛跟服裝DRP八竿子打不着。即時通訊翻譯自Instant Messaging,若是我把它解釋爲即時消息推送,再將其放之於企業應用中就好理解了。舉例:上級給下級發貨,下級能第一時間知道貨已發出,就用不着打電話詢問或滿心期待地頻繁刷新列表;下級店鋪賣出一單,正在爲銷售淡季發愁的老闆看到蹦出的提示消息,瞬間有了信心……數據庫

這個功能對不明真相的客戶並無多少吸引力,由於大部分CS軟件彷佛都能作到這一點,只不過——或多或少延遲個幾秒或幾分鐘,固然客戶對延遲一無所知。可是作技術的知道這個延遲表明什麼:頻繁地訪問數據源,頻繁地將「最新」數據與本地數據做比較[or直接使用獲取到的數據]刷新UI。假設對數據實時精度控制爲1分鐘,有1000個客戶端運行,平均每一個客戶端對10種數據類型感興趣(好比數據類型(即時通訊中可稱爲消息類型)包括入庫、發貨、零售和調撥,或者基礎資料的修改等等),那麼每分鐘就會產生額外的10000次的數據庫的訪問量,注意大部分訪問都沒有任何做用(除了反作用),並且假如沒有合理地設置篩選條件[及其它改善手段],那麼訪問產生的數據量,大部分也多是無用的。另外,合理的數據結構和邏輯設計以知足對各種數據類型的提示也是個不小的難點,畢竟數據類型多種多樣,就單個數據類來講,也有多個屬性,假如用戶對其中的某些屬性感興趣,如何設計一種方式使得數據庫中某條記錄的某些字段變更時能檢索到,嘖嘖,水很深喲。編程

注意:即時通信和BS幾乎不要緊,BS應用先天不足,只能採用定時讀取數據庫的方式來模擬即時通訊,同上述的大部分CS軟件。也許用插件能行,可是插件本質上也是CS中的C。上回說到BS的缺點,這裏又能加上一條,呵呵,開個玩笑。服務器

請時刻注意本文所說的IM並不是單純的聊天軟件,而是爲企業應用系統服務的輔助類工具。它應該具備相對獨立性、良好的擴展性和簡便的應用性(應用是對用戶和開發人員二者來講的,用戶能方便的使用它,開發人員能方便地將它接入系統)。按照本系列慣例,列客戶關注的幾個功能需求:網絡

  1. 在線用戶管理(這在大中型服裝企業比較有用,能有效跟進各個分支機構的分銷系統使用狀況);
  2. 系統消息廣播;
  3. 單點登陸(當已有相同帳號在線時,兩種處理方式,一是登陸失敗,一是仿QQ,將原在線用戶踢下線;用數據庫方式能實現前一種。); 
  4. 業務事件成功後可自動[對N個目標客戶端]發送消息;
  5. 用戶接收消息權限管理(是否能接收某個類型的消息);
  6. 消息提示;
  7. 消息查詢(目前並未提供往期歷史消息查詢)
  8. 企業通信工具(重點是美工,推後)
  9. ……

需求看似挺多,其實技術實現起來難點就一個: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就是這個做用,通常實現爲空方法。post

客戶端[服務]: 測試

複製代碼
 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.cnblogs.com/newton/archive/2013/01/26/2877500.html

相關文章
相關標籤/搜索