【C#】支持私聊、多人聊天、图片发的TCP程序
碎碎念
先谈谈我们要实现的效果:客户端可以选择要聊天的对象,或者直接广播消息(类似QQ的私聊和群消息),支持图片发送(简单的)
那么,该如何实现呢?
首先明确的是,要分客户端和服务器端两个部分(废话)
客户端:选择要发送的对象,发送信息。同时有一个线程在监听是否收到新的信息。
服务器端:负责转发收到的消息,并负责管理所有接入的连接
好了有了大体思路后,开始编程吧~
客户端
界面设计
客户端要提供的信息主要是发送对象、发送信息内容,故设计如下:
其中用户名必须提供(这里考虑的比较简单,不需要验证用户名是否重复),发送信息时需要选择目标用户。
编码实现
连接服务器部分
连接服务器和正常的tcp连接没什么区别,由于要考虑到 目标用户 选项刷新的问题,这里必须在建立连接后向服务器发送一条信息告知服务器自己的身份,服务器接收后会再返回一条信息来告知客户端目前服务器在线用户的名称。
因为请求的信息内容、作用不一样,这里使用自定义的“信息格式”,使用$符号来分割,请求格式为 code$message
以下是请求的说明表
故我们可以根据该表写出一个Encode函数:
private String EncodeMessage(String message, int code,String goalName) { switch (code) { case 1://汇报用户名 return "1$" + message; case 2://发送信息 return "2$" + message+"$"+goalName; case 3://断开连接 return "3$" + message; default: return "-1$错误"; } }
紧接着对其进行发送信息功能进行封装:
public void SendMessage(String message, int code, String goalName) { String sendmessage = EncodeMessage(message, code, goalName); try { bw.Write(sendmessage); bw.Flush(); log = DateUtil.getTime() + "发送信息:" + message;//日志 if (code != 1)//1是第一次建立连接的时候发送的自己用户名,所以没必要打印出来,故这里加了一个判断 { textbox_chatbox.AppendText(log); } else { flag_open = true;//该标志是用来控制接收信息的循环的,下面再讲 } } catch//捕获异常是为了防止服务器意外断开连接 { log = DateUtil.getTime() + "服务器已断开连接"; return; } }
好了下面开始主体tcp连接代码:
//全局变量声明 private const int port = 8848; private TcpClient tcpClient; private NetworkStream networkStream; private BinaryReader br; private BinaryWriter bw; private String log = ""; private Boolean flag_open = false; //初始化 private void button_connect_Click(object sender, EventArgs e) { //开始连接服务器,同步方式阻塞进行 IPHostEntry remoteHost = Dns.GetHostEntry(textbox_ip.Text); tcpClient = new TcpClient(); tcpClient.Connect(remoteHost.HostName, port);//阻塞啦!!! if (tcpClient != null) { String username = textBox_name.Text; log = DateUtil.getTime() + "以用户名为 "+username+"连接服务器"; textbox_chatbox.AppendText(log); networkStream = tcpClient.GetStream(); br = new BinaryReader(networkStream); bw = new BinaryWriter(networkStream); SendMessage(username, 1,"");//向服务器发送信息,告诉服务器自己的用户名 Thread thread = new Thread(ReceiveMessage);//开一个新的线程来接收信息 thread.Start(); thread.IsBackground = true;//线程自动关闭 } else { log = DateUtil.getTime() + "连接服务器失败,请重试"; textbox_chatbox.AppendText(log); } }
接收信息部分
为了程序的人性化,接收信息一定是自动接收,这里使用线程来实现。因为接收信息也是阻塞,故新开一个线程并使用while循环一直监听,有消息进来就更新。
因此我们也需要规定服务器发过来的信息的格式,如下图所示:
因此同样我们可以写出解析函数:
private void DecodeMessage(String message) { String[] results = message.Split('$'); int code = int.Parse(results[0]); switch (code) { case 1://更新的是用户 comboBox1.Invoke(updateComboBox, message);//委托,更新下拉框内容 break; case 2://收到信息 String rev = message.Substring(message.IndexOf('$')+1); textbox_chatbox.Invoke(showLog,DateUtil.getTime()+rev);//打印在日志 break; } }
接收信息函数:
public void ReceiveMessage() { while (flag_open) { try { string rcvMsgStr = br.ReadString(); DecodeMessage(rcvMsgStr); } catch { log = DateUtil.getTime() + "服务器已断开连接"; textbox_chatbox.Invoke(showLog,log); return; } } }
对应的委托函数自己根据你的命名写就可以啦~这里就不再赘述
终止连接
终止连接的思路也很简单:向服务器发送消息通知服务器我要下线了,然后关闭相应的流即可。
private void button_stop_Click(object sender, EventArgs e) { SendMessage(textBox_name.Text, 3,""); log = DateUtil.getTime() + "已发起下线请求"; textbox_chatbox.Invoke(showLog, log); flag_open = false; if (bw != null) { bw.Close(); } if (br != null) { br.Close(); } if (tcpClient != null) { tcpClient.Close(); } }
至此客户端基本完成,细节你们可以再优化优化~
服务器端
服务器端是挺复杂的,我的思路是
线程1:循环监听是否有新的客户端连接加入,若有则加入容器中,并向容器中所有的连接广播一下目前在线的客户。
线程n:每一个连接都应该有一个线程循环监听是否有新的消息到来,有则回调给主线程去处理(这样不是很高效但基本满足需求)
界面设计
因为服务器只负责启动、暂停和转发消息,界面只需要日志窗口、状态口和两个按钮即可。(不是我懒)
编码实现
启动服务器部分
启动服务器,就需要开启一个新的线程来循环监听,来一个连接就要存入容器中去管理。
因为写习惯Java了,所以这里容器也选择List<>,首先我们先创建一个Client类来封装一些方法。
在编写客户端的时候我们知道,每一个客户端都应该有相应的名称,所以Client类一定要包括一个名称以及相应的连接类。
public String userName; public TcpClient tcpClient; public BinaryReader br; public BinaryWriter bw;
发送信息函数类似客户端,直接调用bw即可。但接收信息必须是一个线程循环监听,故需要设计一个接口来实现新消息来临就回调传给主线程操作。
public interface ReceiveMessageListener { void getMessage(String accountName,String message); }
顺便把名字传过来可以知道到底是谁发送的消息。
Client类的总体代码如下:
class Client { public String userName; public TcpClient tcpClient; public BinaryReader br; public BinaryWriter bw; public ReceiveMessageListener listener; public bool flag = false; public Client(String userName,TcpClient client,ReceiveMessageListener receiveMessageListener) { this.userName = userName; this.tcpClient = client; this.listener = receiveMessageListener; NetworkStream networkStream = tcpClient.GetStream(); br = new BinaryReader(networkStream); bw = new BinaryWriter(networkStream); Thread thread = new Thread(receiveMessage); thread.Start(); flag = true; thread.IsBackground = true; } public override bool Equals(object obj) { return obj is Client client && userName == client.userName; } public bool sendMessage(String ecodeMessage) { try { bw.Write(ecodeMessage); bw.Flush(); return true; }catch { return false; } } public void receiveMessage() { while (true) { try { String temp = br.ReadString(); listener.getMessage(userName, temp); } catch { return; } } } public void stop() { flag = false; if (bw != null) { bw.Close(); } if (br != null) { br.Close(); } if (tcpClient != null) { tcpClient.Close(); } } public interface ReceiveMessageListener { void getMessage(String accountName,String message); } }
写好Client以后我们就可以准备编写启动服务器的代码了,步骤:启动服务器->监听->新客户来->加入List->更新(广播)用户表->继续监听
private void StartServer() { log = getTime() + "开始启动服务器中。。。"; textBox_log.Invoke(showLog, log); tcpListener = new TcpListener(localAddress, port); tcpListener.Start(); log = getTime() + "IP:" + localAddress + " 端口号:" + port + " 已启用监听"; textBox_log.Invoke(showLog, log); while (true) { try { tcpClient = tcpListener.AcceptTcpClient(); networkStream = tcpClient.GetStream(); br = new BinaryReader(networkStream); bw = new BinaryWriter(networkStream); String accountName =br.ReadString(); accountName = decodeUserName(accountName); log = getTime() + "用户:"+accountName+"已上线"; count++; label_status.Invoke(showNumber); textBox_log.Invoke(showLog, log); clientList.Add(new Client(accountName,tcpClient,listener)); notifyUpdateUserList(); } catch { log = getTime() + "已终止监听"; textBox_log.Invoke(showLog, log); return; } } }
启动服务器只需要开启新线程就行了~
Thread thread = new Thread(StartServer); thread.Start(); thread.IsBackground = true;
更新名称函数:
private void notifyUpdateUserList() { String message = "1" + getCurUserName(); foreach (Client i in clientList) { i.sendMessage(message); } }
private String getCurUserName() { String aa = ""; foreach(Client i in clientList) { aa = aa + "$" + i.userName; } return aa; }
回调接口实现、接收信息处理
在创建Client的时候需要传入一个监听接口,我们自己创建一个类来实现:
根据之前设置的信息传送格式,写出对应的处理函数
public class MyListener : Client.ReceiveMessageListener { public Form1 f; public MyListener(Form1 form) { f = form; } public void getMessage(String accountname,string message) { //TODO string []results = message.Split('$'); if (int.Parse(results[0]) == 2)//发送信息 { String content = results[1]; String goalName = results[2]; f.SendMessageToClient(content,goalName,accountname); }else if (int.Parse(results[0]) ==3)//终止连接 { String content = results[1]; f.stopClientByName(content); } else { //请求add } } }
转发信息的逻辑:拿到目标用户名称,判断是不是所有人(广播)若是则广播,若不是则再去遍历寻找对应的客户再发送。
private void SendMessageToClient(String content,String goalName,String userName) { bool flag = false; if (goalName.Equals("所有人")) { flag = true; } foreach(Client i in clientList) { if (flag) { i.sendMessage("2$广播:" + userName+"说: "+content); } else { if (i.userName.Equals(goalName)) { i.sendMessage("2$" + userName + "说: "+content); return; } } } }
关闭对应客户端连接的思路:遍历
public void stopClientByName(String name) { foreach(Client i in clientList){ if (i.userName.Equals(name)) { i.stop(); count--; label_status.Invoke(showNumber); textBox_log.Invoke(showLog, getTime() + name + "已下线"); clientList.Remove(i); } } }
停止服务器部分
先断开所有在线客户端的连接,再断开总的。
private void button_stop_Click(object sender, EventArgs e) { CloseAllClients(); if (bw != null) { bw.Close(); } if (br != null) { br.Close(); } if (tcpClient != null) { tcpClient.Close(); } if (tcpListener != null) { tcpListener.Stop(); } log = getTime() + "已停止服务器"; textBox_log.Invoke(showLog, log); }
public void CloseAllClients() { foreach(Client i in clientList) { i.stop(); } clientList.Clear(); }
完成。
补充:图片接收发送(二进制数据)
在网上搜寻资料的时候看到有大佬对图片进行Base64编码然后生成字符串来收发,不知道可行与否。如果可行那么直接在原来的规则增加一条图片规则即可。具体方法看这个:点我跳转。
我选择的是直接发送byte数组
设计一下收发规则,在原来的基础上增加:
发送格式:
服务器返回格式:
思路
发送string类型信息给服务器,通知服务器我要发送图片了,并且直接把图片的byte大小传过去
紧接着直接把这个byte数组发过去
服务器接收string信息后根据拿到得大小去读这个byte数组,转发即可。
实现
图片编码
//把byte转图片,支持gif public Image SetByteToImage(byte[] mybyte) { MemoryStream ms = new MemoryStream(mybyte); Image outputImg = Image.FromStream(ms); return outputImg; } //把图片转byte[] 设置读取文件为允许修改 private byte[] SetImageToByteArray(string fileName) { FileStream fs = new FileStream(fileName, FileMode.Open, System.IO.FileAccess.Read, FileShare.ReadWrite); byte[] byteData = new byte[fs.Length]; fs.Read(byteData, 0, byteData.Length); fs.Close(); return byteData; }
选择图片
OpenFileDialog fileDialog = new OpenFileDialog(); fileDialog.Filter = "图片文件(*.jpg,*.gif,*.bmp,*.png)|*.jpg;*.gif;*.bmp;*.png"; DialogResult result = fileDialog.ShowDialog(); if (result == DialogResult.OK) { Pic_dir = fileDialog.FileName;//Pic_dir就是一个string来存放图片地址 pic_show.Image = Image.FromFile(Pic_dir); }
发送图片
byte[] datas =SetImageToByteArray(Pic_dir); bw.Write(datas, 0, datas.Length); bw.Flush();
接收图片
br.ReadBytes(传过来的长度)
服务器端
服务器端改动不大,主要是Client里面要加一个直接读byte的方法
或者修改接口把br返回回来:
public interface ReceiveMessageListener { void getMessage(String accountName,String message, BinaryReader br, BinaryWriter bw); }
效果图
总结
因为代码是我在很短时间内敲出来的,如果有不妥或者不足之处欢迎指正。
当你掌握了一对一(一个客户端和一个服务器端连接)这种形式以后再去看多人聊天,也是很简单的,关键是多线程的使用以及回调。接口返回数据这种形式真的太重要了,在这里用的也非常方便。
同时消息传送格式也很关键,尤其是当你在服务器端加入一些功能后,通信之间传输的是指令还是消息,都必须很好地区别出来。
我在文中的写法不是特别建议,最好是单独抽出来写成一个类,这样以后维护方便、看起来简洁明了,不像我这个都杂在一起了。。。
写本文章主要是总结一下自己编码实现的思路,关键代码都已经放在上面了,相信你按照我的步骤和思路来应该都能做出来,不自己做只是复制粘贴是没用的(而且也没啥专业代码嗯,自己写写呗),当然大佬请绕路。
源代码:ChatBoxDemo
下面放一张运行截图(人格分裂):