(2016年3月更:由于后来了解到GGTalk开源即时通讯系统,因此直接采用了该资源用于项目开发,在此对作者表示由衷的感谢!)
——————————————————————————————————
人在外包公司,身不由己!各种杂七杂八的项目都要做,又没有自己的技术沉淀,每次涉足新的项目都倍感吃力,常常现学现卖,却不免处处碰壁!当然,话说回来,也是自己的水平有限在先,一马配一鞍,无奈也只能留在外包公司。
这不,就在上一周,领导下达一个任务:3天内搭建一个C#即时通讯系统,与原有的办公系统集成。
我正心里犯嘀咕;“网络编程自己就只知道一点皮毛啊,还是大学选修课上听老师讲的那一点东西,别说即时通讯了,以前也就只照着书上的例子写过一个抓包工具当作业交过,彻头彻尾的小白啊,何况都毕业几年了,连“套接字”都快忘了!”
领导补充说:“这个即时通讯系统要尽快完成,之后还有别的的项目。”
我:“······好的”
没办法,就像领导常说的“有条件要上,没有条件创造条件也要上!”,临危受命,唯有逆流而上!
想都别想,写即时通讯总不能从socket写起啊,那样写出来的东西只能读书的时候当作业交给老师看下,然后记一个平时成绩,给领导看那就是找抽!
所以,只能“登高而招,顺风而呼”,园子里大神多,资源也多,找找看有没有可以参考的。(这也是我一直以来的工作方法,呵呵)
终于,看到了一篇轻量级通信引擎StriveEngine通信demo源码研究学习了一下,稍加揣摩,很快就完成了领导所交付的重任!在此要鸣谢该文的作者!
言归正传,接下来就把自己的学习所得以及编写过程详尽的分享给大家!
一·C#即时通信系统界面快照
二·网络消息流与通信协议
首先,网络中的数据是源源不断的二进制流,有如长江之水连绵不绝,那么,即时通讯系统如何从连绵不绝的数据流中准确的识别出一个消息呢?换言之,在悠远绵长的网络数据流中,一个个具体的消息应该如何被界定出来呢?
这就需要用到通信协议。通信协议,一个大家耳熟能详的术语,什么TCP啊、UDP啊、IP啊、ICMP啊,以前学《计算机网络》时,各种协议充斥寰宇。但是,从教科书上抽象的概念中,你真的了解什么是通信协议吗?
回到开始的问题,我想恐怕可以这样来理解:通信协议就是要让消息遵循一定的格式,而这样的格式是参与通信的各方都知晓且遵守的,依据这样的格式,消息就能从数据流中被完整的识别出来。
通信协议的格式通常分为两类:文本消息、二进制消息。
文本协议相对简单,通常使用一个特殊的标记符作为一个消息的结束。这样一来,根据这个特殊的标志符,每个消息之间就有了明确的界限。
二进制协议,通常是由消息头(Header)和消息体(Body)构成的,消息头的长度固定,而且,通过解析消息头,可以知道消息体的长度。如此,我们便可以从网络流中解析出一个个完整的二进制消息。
两种协议各有优劣,虽然文本协议比较简单方便,但是二进制协议更具有普适性,诸如图片啊、文件啊都可以转化为二进制数组,所以我在写即时通讯时采用的是二进制协议。
我定义的二进制协议是:消息头固定为8个字节:前四个字节为一个int,其值表示消息类型;后四个字节也是一个int,其值表示消息体长度。
先来看消息头的定义
1 public class MsgHead 2 { 3 private int messageType; 4 /// <summary> 5 /// 消息类型 6 /// </summary> 7 public int MessageType 8 { 9 get { return messageType; } 10 set { messageType = value; } 11 } 12 13 private int bodyLength; 14 /// <summary> 15 /// 消息体长度 16 /// </summary> 17 public int BodyLength 18 { 19 get { return bodyLength; } 20 set { bodyLength = value; } 21 } 22 23 public const int HeadLength = 8; 24 25 26 public MsgHead(int msgType,int bodyLen) 27 { 28 this.bodyLength = bodyLen; 29 this.messageType = msgType; 30 } 31 32 public byte[] ToStream() 33 { 34 byte[] buff = new byte[MsgHead.HeadLength]; 35 byte[] bodyLenBuff = BitConverter.GetBytes(this.bodyLength); 36 byte[] msgTypeBuff = BitConverter.GetBytes(this.messageType); 37 Buffer.BlockCopy(msgTypeBuff, 0, buff, 0, msgTypeBuff.Length); 38 Buffer.BlockCopy(bodyLenBuff, 0, buff, 4, bodyLenBuff.Length); 39 return buff; 40 } 41 }
然后我们将识别消息的方法封装到一个协议助手类中,即收到消息的时候,明确如下两个问题:1.固定前多少位是消息头。2.如何从消息头中获取消息体长度。
1 public class StreamContractHelper : IStreamContractHelper 2 { 3 /// <summary> 4 /// 消息头长度 5 /// </summary> 6 public int MessageHeaderLength 7 { 8 get { return MsgHead.HeadLength; } 9 } 10 /// <summary> 11 /// 从消息头中解析出消息体长度,从而可以间接取出消息体 12 /// </summary> 13 /// <param name="head"></param> 14 /// <returns></returns> 15 public int ParseMessageBodyLength(byte[] head) 16 { 17 return BitConverter.ToInt32(head,4); 18 } 19 } 20
三·通信协议类
然后我们来定义满足协议的消息基类,其中重点是要定义ToContractStream()方法,使得消息能够序列化成满足协议的二进制流,从而通过网络进行传输。
1 [Serializable] 2 public class BaseMsg 3 { 4 private int msgType; 5 6 public int MsgType 7 { 8 get { return msgType; } 9 set { msgType = value; } 10 } 11 /// <summary> 12 /// 序列化为本次通信协议所规范的二进制消息流 13 /// </summary> 14 /// <returns></returns> 15 public byte[] ToContractStream() 16 { 17 return MsgHelper.BuildMsg(this.msgType, SerializeHelper.SerializeObject(this)); 18 } 19 }
然后我们来看看MsgHelper类的具体实现
1 public static class MsgHelper 2 { 3 /// <summary> 4 /// 构建消息 5 /// </summary> 6 /// <param name="msgType">消息类型</param> 7 /// <param name="msgBody">消息体</param> 8 /// <returns></returns> 9 public static byte[] BuildMsg(int msgType, Byte[] msgBody) 10 { 11 MsgHead msgHead = new MsgHead(msgType, msgBody.Length); 12 //将消息头与消息体拼接起来 13 byte[] msg = BufferJointer.Joint(msgHead.ToStream(), msgBody); 14 return msg; 15 } 16 17 public static byte[] BuildMsg(int msgType, string str) 18 { 19 return MsgHelper.BuildMsg(msgType, Encoding.UTF8.GetBytes(str)); 20 } 21 /// <summary> 22 /// 将二进制数组还原成消息对象 23 /// </summary> 24 /// <typeparam name="T">所要还原成的消息类</typeparam> 25 /// <param name="msg">消息数据</param> 26 /// <returns></returns> 27 public static T DeserializeMsg<T>(byte[] msg) 28 { 29 return (T)SerializeHelper.DeserializeBytes(msg, 8, msg.Length - 8); 30 } 31 }
然后我们再看一个具体的消息类ChatMsg的定义
1 [Serializable] 2 public class ChatMsg:BaseMsg 3 { 4 private string sourceUserID; 5 /// <summary> 6 /// 发送该消息的用户ID 7 /// </summary> 8 public string SourceUserID 9 { 10 get { return sourceUserID; } 11 set { sourceUserID = value; } 12 } 13 private string targetUserID; 14 /// <summary> 15 /// 该消息所发往的用户ID 16 /// </summary> 17 public string TargetUserID 18 { 19 get { return targetUserID; } 20 set { targetUserID = value; } 21 } 22 private DateTime timeSent; 23 /// <summary> 24 /// 该消息的发送时间 25 /// </summary> 26 public DateTime TimeSent 27 { 28 get { return timeSent; } 29 set { timeSent = value; } 30 } 31 private string msgText; 32 /// <summary> 33 /// 该消息的文本内容 /// 34 /// </summary> 35 public string MsgText 36 { 37 get { return msgText; } 38 set { msgText = value; } 39 } 40 /// <summary> 41 /// 构造一个ChatMsg实例 42 /// </summary> 43 /// <param name="_sourceUserID">该消息源用户ID</param> 44 /// <param name="_targetUserID">该消息目标用户ID</param> 45 /// <param name="_MsgText">该消息的文本内容 </param> 46 public ChatMsg(string _sourceUserID, string _targetUserID, string _MsgText) 47 { 48 base.MsgType = Core.MsgType.Chatting; 49 this.sourceUserID = _sourceUserID; 50 this.targetUserID = _targetUserID; 51 this.timeSent = DateTime.Now; 52 this.msgText = _MsgText; 53 } 54 }
四·登录的通信过程
1.客户端发送登陆消息
private void button_login_Click(object sender, EventArgs e) { this.selfID = this.textBox_ID.Text.Trim(); LoginMsg loginMsg = new LoginMsg(this.selfID); this.tcpPassiveEngine.PostMessageToServer(loginMsg.ToContractStream()); }
2.服务端回复登陆消息
1 if (msgType == MsgType.Logining) 2 { 3 LoginMsg loginMsg = MsgHelper.DeserializeMsg<LoginMsg>(msg); 4 this.ReplyLogining(loginMsg, userAddress); 5 //将在线用户告知其他客户端 6 this.TellOtherUser(MsgType.NewOnlineFriend, loginMsg.SrcUserID); 7 } 8 9 /// <summary> 10 /// 回复登陆消息 11 /// </summary> 12 /// <param name="loginMsg"></param> 13 /// <param name="userAddress"></param> 14 private void ReplyLogining(LoginMsg loginMsg, IPEndPoint userAddress) 15 { 16 if (this.onlineManager.Contains(loginMsg.SrcUserID))//重复登录 17 { 18 loginMsg.LogonResult = LogonResult.Repetition; 19 this.tcpServerEngine.SendMessageToClient(userAddress, loginMsg.ToContractStream()); 20 } 21 else//此demo简化处理回复成功,其他验证未处理 22 { 23 this.AddUser(loginMsg.SrcUserID, userAddress); 24 this.ShowOnlineUserCount(); 25 loginMsg.LogonResult = LogonResult.Succeed; 26 this.tcpServerEngine.SendMessageToClient(userAddress, loginMsg.ToContractStream()); 27 } 28 }
3.客户端处理登陆结果
1 private void tcpPassiveEngine_MessageReceived(IPEndPoint userAddress, byte[] msg) 2 { 3 //取出消息类型 4 int msgType = BitConverter.ToInt32(msg, 0); 5 //验证消息类型 6 if (msgType == MsgType.Logining) 7 { 8 LoginMsg loginMsg = MsgHelper.DeserializeMsg<LoginMsg>(msg); 9 if (loginMsg.LogonResult == LogonResult.Succeed) 10 { 11 this.DialogResult = DialogResult.OK; 12 this.tcpPassiveEngine.MessageReceived -= new StriveEngine.CbDelegate<IPEndPoint, byte[]>(tcpPassiveEngine_MessageReceived); 13 } 14 if (loginMsg.LogonResult == LogonResult.Repetition) 15 { 16 MessageBox.Show("登录失败,该账号已经登录!"); 17 } 18 } 19 }
五·即时通信客户端通信过程
1.客户端A发送聊天消息给服务器
1 /// <summary> 2 /// 发送聊天消息 3 /// </summary> 4 /// <param name="sender"></param> 5 /// <param name="e"></param> 6 private void button_send_Click(object sender, EventArgs e) 7 { 8 string chatText = this.richTextBox_Write.Text; 9 if (string.IsNullOrEmpty(chatText)) 10 { 11 MessageBox.Show("消息不能为空"); 12 return; 13 } 14 ChatMsg chatMsg = new ChatMsg(this.selfUserID, this.friendID, chatText); 15 this.tcpPassiveEngine.SendMessageToServer(chatMsg.ToContractStream()); 16 this.ShowChatMsg(chatMsg); 17 }
2.服务端转发聊天消息
1 if (msgType == MsgType.Chatting) 2 { 3 ChatMsg chatMsg = MsgHelper.DeserializeMsg<ChatMsg>(msg); 4 if (this.onlineManager.GetKeyList().Contains(chatMsg.TargetUserID)) 5 { 6 IPEndPoint targetUserAddress = this.onlineManager.Get(chatMsg.TargetUserID).Address; 7 this.tcpServerEngine.SendMessageToClient(targetUserAddress, msg); 8 } 9 }
3.客户端B接收并显示聊天消息
1 void tcpPassiveEngine_MessageReceived(IPEndPoint userAddress, byte[] msg) 2 { 3 //取出消息类型 4 int msgType = BitConverter.ToInt32(msg, 0); 5 //验证消息类型 6 if (msgType == MsgType.Chatting) 7 { 8 ChatMsg chatMsg = MsgHelper.DeserializeMsg<ChatMsg>(msg); 9 this.ShowChatForm(chatMsg.SourceUserID); 10 this.ChatMsgReceived(chatMsg); 11 } 12 } 13 14 /// <summary> 15 /// 显示聊天窗 16 /// </summary> 17 /// <param name="friendUserID">聊天对方用户ID</param> 18 private void ShowChatForm(string friendUserID) 19 { 20 if (this.InvokeRequired) 21 { 22 this.Invoke(new CbGeneric<string>(this.ShowChatForm), friendUserID); 23 } 24 else 25 { 26 ChatForm form = this.chatFormManager.GetForm(friendUserID); 27 if (form == null) 28 { 29 form = new ChatForm(this.selfID, friendUserID, this, this.tcpPassiveEngine); 30 form.Text = string.Format("与{0}对话中···", friendUserID); 31 this.chatFormManager.Add(form); 32 form.Show(); 33 } 34 form.Focus(); 35 } 36 } 37 38 39 /// <summary> 40 /// 显示聊天消息 41 /// </summary> 42 /// <param name="chatMsg"></param> 43 private void ShowChatMsg(ChatMsg chatMsg) 44 { 45 if (this.InvokeRequired) 46 { 47 this.Invoke(new CbGeneric<ChatMsg>(this.formMain_chatMsgReceived), chatMsg); 48 } 49 else 50 { 51 this.richTextBox_display.AppendText(chatMsg.SourceUserID + " " + chatMsg.TimeSent.ToString() + " "); 52 this.richTextBox_display.AppendText(chatMsg.MsgText + " "); 53 this.richTextBox_Write.Clear(); 54 } 55 }
五·C#即时通信源码下载
源码说明:1.客户端与服务端均含有配置文件,可配置进程的IP与端口号。
2.代码均含有详细注释。
3.调试时确保客户端的配置文件相关信息无误,先启动服务端再启动客户端。
4.登录账号与密码均为任意。
5.点击好友头像即可聊天。
下载:Chat.Demo
附相关系列源码:
版权声明:本文为博主原创文章,未经博主允许不得转载。