基础理论
请求和响应报文结构
3.1.1 请求报文:
3.1.2 响应报文:
由于聊天软件客户端和服务器的通信都要携带信息,所以这里客户端请求的方式都是POST方式。
客户端和服务器的报文主体都是JSON报文
JSON 语法规则
JSON 语法是 JavaScript 对象表示语法的子集。
数据在名称/值对中
数据由逗号分隔
大括号 {} 保存对象
中括号 [] 保存数组,数组可以包含多个对象
JSON 数据的书写格式
key : value
{
"sites": [
{ "name":"菜鸟教程" , "url":"www.runoob.com" },
{ "name":"google" , "url":"www.google.com" },
{ "name":"微博" , "url":"www.weibo.com" }
]
}
套接字(Socket),是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议栈进行交互的接口。
第一步:建立一个用于通信的Socket对象
第二步:使用bind绑定IP地址和端口号
第三步:使用listen监听客户端
第四步:使用accept中断程序直到连接上客户端
第五步:接收来自客户端的请求
第六步:返回客户端需要的数据
第七步:如果接收到客户端已关闭连接信息就关闭服务器端
第一步:建立一个用于通信的Socket对象
第二步:根据指定的IP和端口connet服务器
第三步:连接成功后向服务器端发送数据请求
第四步:接收服务器返回的请求数据
第五步:如果还需要请求数据继续发送请求
第六步:如果不需要请求数据就关闭客户端并给服务器发送关闭连接信息
线程生命周期中的各种状态:
C#调用线程库
Thread thread = new Thread(Function);//将线程绑定函数
thread.IsBackground = true;//线程是否为后台线程
thread.Start(obj);//传入参数
void Function(Obj obj);
这里用的是Access数据库,注册和获得用户在线列表需要SQL SELECT语句和INSERT INTO语句
SELECT 语句用于从数据库中选取数据。结果被存储在一个结果表中,称为结果集。
SQL SELECT 语法
SELECT column_name,column_name
FROM table_name;
SELECT * FROM table_name;
INSERT INTO 语句用于向表中插入新记录。
SQL INSERT INTO 语法
INSERT INTO 语句可以有两种编写形式。
第一种形式无需指定要插入数据的列名,只需提供被插入的值即可:
INSERT INTO table_name
VALUES (value1,value2,value3,...);
第二种形式需要指定列名及被插入的值:
INSERT INTO table_name (column1,column2,column3,...)
VALUES (value1,value2,value3,...);
C# 图形化界面的设计比较容易,将控件拖到到主窗口,即可引用该控件。
主要的控件属性有
属性名 | 作用 |
---|---|
Name | 控件名 |
Font | 显示的字体样式 |
BackColor | 背景颜色 |
Text | 显示文本信息 |
添加动作
双击控件即可生成点击事件函数,或者用属性中的事件添加动作
主界面
登录界面
注册界面
传输文件界面
聊天软件最重要的功能是信息通信,其他的功能都是在通信的基础上进行展开的。所以首先实现的功能是信息通信。
网络连接用到的变量
//网络连接
public Socket ServerSocket;//用于监听的套接字
public Socket SocketAccept;//绑定客户端的套接字
public Socket socket; //当前处理的套接字
Dictionary<int, Socket> LinkAll;//存储当前所有绑定客户端的套接字
private string Message = "";//聊天记录
private static int MAX_STREAM_SIZE = 10 * 1024 * 1024; // 10MB
UserInstruction userInstruction = new UserInstruction();//接收指令格式
SendInstruction sendInstruction = new SendInstruction();//发送指令格式
socket是基于TCP协议
首先服务器端要开启监听,管理员要输入开启监听的端口号,IP地址为自己。
private void button_listen_Click(object sender, EventArgs e)
{
ServerSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
button_listen.Enabled = false;//不准重复打开监听
//获得自己的IP地址
IP_SHOW.Text = GetLocalIp();
//绑定IP地址和端口号
IPAddress IP = IPAddress.Parse(IP_SHOW.Text);
int Port = int.Parse(port_text.Text);
IPEndPoint iPEndPoint = new IPEndPoint(IP, Port);
//尝试绑定
try
{
//使用Bind()进行绑定
ServerSocket.Bind(iPEndPoint);
//最大监听数量
ServerSocket.Listen(10);
//使用多线程
thread = new Thread(Listen);//绑定函数
thread.IsBackground = true;
thread.Start(ServerSocket);//传入参数
}
catch(Exception e1)
{
MessageBox.Show("服务器错误"+e1.ToString());
}
}
public string GetLocalIp()
{
///获取本地的IP地址
string AddressIP = string.Empty;
foreach (IPAddress _IPAddress in Dns.GetHostEntry(Dns.GetHostName()).AddressList)
{
if (_IPAddress.AddressFamily.ToString() == "InterNetwork")
{
AddressIP = _IPAddress.ToString();
break;
}
}
return AddressIP;
}
服务器端开启监听后会监听客户端的TCP连接
这里使用多线程,因为如果不开启多线程,窗口界面会卡死
void Listen(Object obj)
{
SocketAccept = obj as Socket;//将参数转换为socket
//与客户端进行连接
while (true)
{
try
{
socket = SocketAccept.Accept();
Thread thread = new Thread(Receive);
thread.IsBackground = true;
thread.Start(socket);
}
catch(Exception e3)
{
Console.WriteLine(e3.ToString()+thread.ManagedThreadId);
}
}
}
socket = SocketAccept.Accept()接收客户端的连接,再次为已经建立好的TCP连接建立Receive线程用于接收客户端的消息
//接收客户端消息
void Receive(Object obj)
{
Socket rightnow_socket = obj as Socket; //创建用于通信的套接字
while (true)
{
try
{
Console.WriteLine(socket.RemoteEndPoint.ToString() + "Running 1_1...");
//接收数据
byte[] receive_temp = new byte[MAX_STREAM_SIZE];
int len = rightnow_socket.Receive(receive_temp);
Console.WriteLine(socket.RemoteEndPoint.ToString() + "Running 1_2...");
byte[] receive = new byte[len];
Array.Copy(receive_temp, receive, len);
Console.WriteLine(socket.RemoteEndPoint.ToString() + "Running 1_3...");
//处理接收数据
if (receive.Length > 0)
{
Console.WriteLine(socket.RemoteEndPoint.ToString() + "Running 2...");
//在服务器显示
string httpnews = Encoding.UTF8.GetString(receive, 0, len);
if (len < 500)
{
//如果文件太大则不显示
Insert("客户端", httpnews);
}
//处理发来的消息
if (DealHttpAsk(receive))
{
socket = rightnow_socket;
Console.WriteLine(socket.RemoteEndPoint.ToString() + "Running 3...");
//符合指令格式
DealInstruction();//处理指令
}
else
{
Console.WriteLine(socket.RemoteEndPoint.ToString() + "Exit 1...");
return;
}
}
}
catch(SocketException e3)
{
Console.WriteLine("Exit 2...");
//服务器有TCP断开的清空检查是否有断线
UpdateLinkAll();
SendUserOnlineInfo();//发送给所有用户状态
int x = e3.ErrorCode;
if (e3.ErrorCode == 10054)
{
Console.WriteLine( "Exit 3...");
//如果是客户端断开则不处理
return;
}
MessageBox.Show(e3.ToString());
Console.WriteLine( "Exit 4...");
return;
}
finally
{
//Thread.CurrentThread.Join();
Thread t = Thread.CurrentThread;
Console.WriteLine(t.IsAlive.ToString()+"\n"+ t.ExecutionContext +"\n" + t.ThreadState + "\n" + t.ManagedThreadId);
}
}
}
这里有一个Bug,找了好久,就是在建立线程的时候,我之前用的是类成员变量socket作为Thread Start的参数,但是出现当多个用户在线的时候,线程会自动断开,这里将建立的TCP连接作为局部变量,线程才不会主动退出,具体原因,还不太清楚。
private void Client_Load(object sender, EventArgs e)
{
//执行新线程时跨线程资源访问检查会提示报错,所以这里关闭检测
Control.CheckForIllegalCrossThreadCalls = false;
}
这里双击主窗口,添加一句Control.CheckForIllegalCrossThreadCalls = false;允许跨线程访问。
void StartListen()
{
ClientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
button_listen.Enabled = false;//不准重复打开监听
//获得要连接的IP地址和端口号
IPAddress IP = IPAddress.Parse(IP_TEXT.Text);
ip = IP_TEXT.Text;
port = int.Parse(port_text.Text);
//绑定IP地址和端口号
IPEndPoint iPEndPoint = new IPEndPoint(IP, port);
try
{
ClientSocket.Connect(iPEndPoint);//连接服务器
button_listen.Enabled = false;
Thread thread = new Thread(Receive);//绑定监听函数
thread.IsBackground = true;
thread.Start(ClientSocket);
}
catch(Exception e5)
{
MessageBox.Show(e5.ToString());
}
}
接收服务器的信息
void Receive(Object sk)
{
Socket socketRec = sk as Socket;
while (true)
{
try
{
byte[] receive_temp = new byte[MAX_STREAM_SIZE];
int len = socketRec.Receive(receive_temp);
byte[] receive = new byte[len];
Array.Copy(receive_temp, receive, len);
//对服务器发来的HTTP进行解析
if (receive.Length > 0)
{
string httpnews = Encoding.UTF8.GetString(receive, 0, len);
if (DealHttpRequest(receive))
{
//符合指令格式
//检查是否是发给自己的或者是群发消息或者是注册信息或者是更新列表
if (id == sendInstruction.RecieveID || sendInstruction.Type == Send_Instruction_Type.Register || sendInstruction.Type == Send_Instruction_Type.Refresh
|| (sendInstruction.Type == Send_Instruction_Type.Message && (sendInstruction.SendID == 0
|| sendInstruction.RecieveID == -1)))//一个是服务器的群发,一个是客户端的群发
{
DealInstruction();//处理指令
}
}
}
}
catch (Exception e)
{
MessageBox.Show("接收出错" + e.ToString());
}
finally
{
//清楚接收服务器请求其他信息
sendInstruction.OtherInforamtion.Clear();
}
}
}
按照HTTP请求报文的格式,这里选用POST格式,初始化报文头的值,重写Tostring()函数,将HTTPHeader对象转换成字符串形式,正文用JSON格式。
public class HTTPHeader
{
public String Http_method { get; set; }//请求方式
public String Http_url { get; set; }//URL地址
public String Http_protocol_versionstring { get; set; }//HTTP版本
public Hashtable HttpHeaders = new Hashtable();
public HTTPHeader()
{
//默认初始化构造函数
Http_method = "POST";
Http_url = "/";
Http_protocol_versionstring = "HTTP/1.1";
HttpHeaders["Host"] = Client.ip+":"+Client.port;
HttpHeaders["Connection"] = "Keep-Alive";
HttpHeaders["Content-Type"] = "application/json; charset=UTF-8";
HttpHeaders["Content-Length"] = "0";
}
public override string ToString()
{
//换行符的ASCII码是: 10,用'\n'表示。回车符的ASCII码是: 13,用'\r'表示
string headerstr = "";
headerstr = Http_method + " " + Http_url + " " + Http_protocol_versionstring + "\r\n";
string Header_line = "";
foreach (string key in HttpHeaders.Keys)
{
Header_line = Header_line + key + ":" + HttpHeaders[key] + "\r\n";
}
return headerstr+Header_line+"\r\n";
}
}
按照HTTP响应报文的格式,初始化报文头的值,重写Tostring()函数,将HTTPHeader对象转换成字符串形式,正文用JSON格式。
public class HTTPHeader
{
public String Http_protocol_versionstring { get; set; }//HTTP版本
public int Http_State { get; set; }//响应状态
public String Http_phrase { get; set; }//短语
public Hashtable HttpHeaders = new Hashtable();//首部行
public HTTPHeader()
{
//默认初始化构造函数
Http_protocol_versionstring = "HTTP/1.1";
Http_State = 200;//成功状态
Http_phrase = "OK";
HttpHeaders["Date"]= DateTime.Now.ToLocalTime().ToString(); //当前时间
HttpHeaders["Content-Type"] = "application/json; charset=UTF-8";//传输文件的方式
HttpHeaders["Content-Length"] = "0";//正文大小
}
public override string ToString()
{
//换行符的ASCII码是: 10,用'\n'表示。回车符的ASCII码是: 13,用'\r'表示
string headerstr = "";
headerstr = Http_protocol_versionstring + " " + Http_State.ToString() + " " + Http_phrase + "\r\n";
string Header_line = "";
foreach (string key in HttpHeaders.Keys)
{
Header_line = Header_line + key + ":" + HttpHeaders[key] + "\r\n";
}
return headerstr + Header_line + "\r\n";
}
}
//指令类型
enum Instruction_Type
{
SignIn = 1,//登录
Register,//注册
Talk_One,//单人聊天
Talk_All,//多人聊天
Download_File,//下载文件
Upload_File//上传文件
}
//定义指令结构体
class UserInstruction
{
public int ID { get; set; } //用户账号id(id=-1代表注册)
public string Name { get; set; } //用户名
public Instruction_Type Type { get; set; }//指令类型
public string PassWord { get; set; }//密码
public Dictionary<string, string> OtherInforamtion = new Dictionary<string, string>();//其他信息
}
//发送指令格式
enum Send_Instruction_Type
{
SignIn = 1,//登录
Register,//注册
Message,//消息
Refresh,//更新用户状态
Download_File,//下载文件
Upload_File//上传文件
}
//定义发送给客户端的指令结构体
class SendInstruction
{
public int SendID { get; set; }//发送者的ID,普通用户为5位编号,服务器为0
public int RecieveID { get; set; }//接收者的ID,用于检测接收方是不是收到自己的消息
public Send_Instruction_Type Type { get; set; }//指令类型
public Dictionary<string, string> OtherInforamtion=new Dictionary<string, string>();//其他信息
}
服务器和客户端都有一样的UserInstruction和SendInstruction
客户端发送的指令请求UserInstruction序列化放在JSON报文中,服务器反序列化成UserInstruction,判断指令,并做出响应,将响应的指令放在SendInstruction中并序列化发给客户端,客户端再反序列化为SendInstruction,通过解析SendInstruction指令修改客户端当前的状态。这里用LitJson将对象序列化为JSON格式,并反序列化为对象。
将HTTP报文头和JSON字符串添加到一起,形成HTTP+JSON报文,服务器和客户端的通信相对于基于HTTP通信,但底层仍然为TCP通信。
按行读取HTTP报文
//按行读取接收报文
private string ReadLine(byte[] recieve,ref int loc)
{
int next_char;
string data = "";
while (true)
{
next_char = recieve[loc++];
if (next_char == '\n')
{
break;
}
else if (next_char == '\r')
{
continue;
}
else if (next_char == '\0')
{
break;
}
data += Convert.ToChar(next_char);
}
return data;
}
读取正文JSON
string readJson(byte[] recieve, ref int loc)
{
string data = "";
StringBuilder str = new StringBuilder();
for(int i=loc;i<recieve.Length;i++)
{
str.Append(Convert.ToChar(recieve[i]));
}
data = ""+ str;
return data;
}
判断请求行是否合法
//判断请求行是否合法
bool RequestLineOk(byte[] recieve, ref int loc)
{
string readRequestLine = ReadLine(recieve,ref loc);
string[] tokens = readRequestLine.Split(' ');
if (tokens.Length != 3)
{
//报文请求行错误
return false;
}
//提取请求行信息
http_method = tokens[0].ToUpper();
http_url = tokens[1];
http_protocol_versionstring = tokens[2];
//请求类型只接受POST
if(!http_method.Equals("POST"))
{
return false;
}
//符号请求行格式
return true;
}
处理首部行
//处理首部行
public bool ReadHeaders(byte[] recieve, ref int loc)
{
string line;
while ((line = ReadLine(recieve, ref loc)) != null)
{
if (line.Equals(""))
{
//首部读取完成
return true;
}
int separator = line.IndexOf(':');
if (separator == -1)
{
//格式错误,不处理
return false;
}
string name = line.Substring(0, separator);
int pos = separator + 1;
while ((pos < line.Length) && (line[pos] == ' '))
{
pos++; // strip any spaces
}
string value = line.Substring(pos, line.Length - pos);
httpHeaders[name] = value;
}
return true;
}
将JSON报文转换为指令格式
// 处理JSON报文
void DealJson(byte[] recieve, ref int loc)
{
string json = readJson(recieve, ref loc);
userInstruction = JsonMapper.ToObject<UserInstruction>(json);
}
处理客户端请求
//处理客户端的HTTP请求
bool DealHttpAsk(byte[] recieve)
{
try
{
int loc = 0;//字符处理位置
bool requestline = RequestLineOk(recieve, ref loc);//读取请求行
if (requestline)
{
//符合HTTP请求报文继续分解
bool headerright = ReadHeaders(recieve, ref loc);
//继续分解正文
if (headerright)
{
//查看正文格式是否为内容格式
if (httpHeaders.ContainsKey("Content-Type"))
{
//是否支持Json格式
string support_type = Convert.ToString(httpHeaders["Content-Type"]);
if (support_type.Contains("application/json"))
{
//分析Json报文
DealJson(recieve, ref loc);
return true;
}
else
{
//不含有Json报文
return false;
}
}
else
{
//没有Json格式不处理
return false;
}
}
else
{
return false;
}
}
else
{
//不处理该报文
return false;
}
}
catch(Exception e)
{
MessageBox.Show(e.ToString());
return false;
}
}
服务器端特有的处理方式
根据客户端的请求做出响应(分析指令)
//分析指令
bool DealInstruction()
{
switch (userInstruction.Type)
{
case Instruction_Type.SignIn:
//登录
int state = CheckLogin();
if (state == 1)
{
//发送成功登录报文
SuccessLogin();
//插入一条成功登录信息
Insert(userInstruction.ID.ToString(), "来了");
//更新列表
GetUserInfo();
//通知其他用户
SendUserOnlineInfo();
}
else
{
FailureLogin(state);
}
break;
case Instruction_Type.Register:
//注册
int id = AnswerRegister();
//发送成功注册报文
SuccessRegister(id);
break;
case Instruction_Type.Talk_One:
//一对一聊天
ClientSendOneUser();
break;
case Instruction_Type.Talk_All:
//群发消息
ClientSendAllUser();
break;
case Instruction_Type.Download_File:
//下载文件
SolveDownloadFile();
break;
case Instruction_Type.Upload_File:
//上传文件
SolveUploadFile();
break;
default:
MessageBox.Show("指令格式错误,系统出错请检查系统");
break;
}
return true;
}
客户端端特有的处理方式
//处理接收指令信息
void DealInstruction()
{
switch (sendInstruction.Type)
{
case Send_Instruction_Type.SignIn:
//登录
SolveLogin();
break;
case Send_Instruction_Type.Register:
//注册
SolveRegister();
break;
case Send_Instruction_Type.Refresh:
//更新列表
SolveRefresh();
break;
case Send_Instruction_Type.Message:
//接收消息
SovleMessage();
break;
case Send_Instruction_Type.Download_File:
//下载文件
SolveDownloadFile();
break;
case Send_Instruction_Type.Upload_File:
//上传文件
SovleUploadFile();
break;
}
}
登录功能是客户端发起登录请求,其中每一个用户都有独一无二的ID,用户输入密码,客户端从数据库中查找有没有该用户,如果有密码是否正确,并将登录请求的结果返回给客户端。
首先用户输入服务器的IP地址和端口号建立TCP连接,然后弹出登录界面,用户输入用户ID和密码
点击连接
输入密码和账号
1.登录成功
2.登录失败
具体实现方式
private void button_listen_Click(object sender, EventArgs e)
{
Login l = new Login();
l.ShowDialog();
l.GetInformation(ref username,ref password, ref id, ref new_user);
StartListen();
ID_LABEL.Text = id.ToString();//显示自己的ID号
//登录
if (!new_user)
{
//生成登录指令
userInstruction.ID = id;
userInstruction.Name = username;
userInstruction.PassWord = password;
userInstruction.Type = Instruction_Type.SignIn;
Send(CreateHTTP());
}
//注册
else
{
//生成注册指令
userInstruction.ID = -1;//ID为-1表示没有ID
userInstruction.Name = username;//注册的用户名
userInstruction.PassWord = password;//用户密码
userInstruction.Type = Instruction_Type.Register;//注册指令
Send(CreateHTTP());
button_listen.Enabled = true;//重新登录
}
}
点开连接按钮,会生成一个Login窗口
private string username; //用户名
private string password; //密码
private int id; //账号ID
private bool new_user; //是否为注册操作
Login的成员变量,用户输入信息点下登录按钮将文本框的信息提取到变量中,再通过GetInformation获得用户 输入的信息到主窗口。
主窗口再赋值给userInstruction中,生成HTTP报文发给服务器。
//检查登录信息
int CheckLogin()
{
//1->登录成功 0->不存在该用户 -1->账户存在,密码错误
//提取用户ID和密码
int id = userInstruction.ID;
string password = userInstruction.PassWord;
//从数据库中找是否有该id
// 执行查询语句
adapter = new OleDbDataAdapter("Select ID,Password from users", conn);
// 通过适配器把表的数据填充到内存ds
adapter.Fill(ds);
for (int i = 0; i < ds.Tables[0].Rows.Count; i++)
{
if (int.Parse(ds.Tables[0].Rows[i][0].ToString()) == id)
{
//提取密码
if (password.Equals(ds.Tables[0].Rows[i][1].ToString()))
{
//账户密码正确
//添加用户ID和TCP连接的映射
//是否有重复登录
InsertLinkAll(id, socket);
return 1;
}
else
{
//存在账户,密码错误
return -1;
}
}
}
return 0;
}
这里会查询数据库,如果存在该用户,并且密码一样,则发送成功登录报文,并且添加映射关系。
这里为了实现多人聊天,以及单人聊天,用了一个Map表示映射关系,id->socket,只有当登录成功才会加入该Map,并且会在服务器发送消息前,检查是否Map中的socket是否正常连接,若断开则从Map中去除。并且每当有用户登录成功的时候,服务器都会给每个在线的用户发送一条某用户登录成功消息。从而更新用户在线列表,同时当有人断线,也会发一条退线消息,从而更新用户在线列表。
switch (userInstruction.Type)
{
case Instruction_Type.SignIn:
//登录
int state = CheckLogin();
if (state == 1)
{
//发送成功登录报文
SuccessLogin();
//插入一条成功登录信息
Insert(userInstruction.ID.ToString(), "来了");
//更新列表
GetUserInfo();
//通知其他用户
SendUserOnlineInfo();
}
else
{
FailureLogin(state);
}
break;
类似于登录功能,客户端发送用户名和密码,服务器会读取数据库中的ID,并重新生成一个随机并且不重复的4位ID号,发给客户端后,将注册用户的ID,用户名,密码插入到数据库,这样用户重新登录账号密码即可。
客户端申请账号
两次输入的密码必须相同
注册成功返回ID账号
服务器向数据库插入一条用户信息
服务器端更新用户列表
客户端更新用户列表
public void GetUserInfo()
{
//清空列表
dataGridView_User.Rows.Clear();
// 执行查询语句
adapter = new OleDbDataAdapter("Select ID,UserName from users", conn);
// 在内存中创建一个DataTable,用来存放、修改数据库表
ds = new DataSet();
// 通过适配器把表的数据填充到内存ds
adapter.Fill(ds);
//读取
for(int i = 0; i < ds.Tables[0].Rows.Count; i++)
{
dataGridView_User.Rows.Add();
//按行读取数据
for (int j = 0; j < ds.Tables[0].Columns.Count; j++)
{
//按列读取
dataGridView_User.Rows[i].Cells[j].Value = ds.Tables[0].Rows[i][j].ToString();
}
}
ds.Reset();
//显示在线信息
Dictionary<int, Socket>.KeyCollection ids = LinkAll.Keys;
//清空用户状态
for (int i = 0; i < dataGridView_User.RowCount; i++)
{
dataGridView_User.Rows[i].Cells[2].Value = "💤";
}
//清空发送对象列表
TalkList.Items.Clear();
TalkList.Items.Add("全体成员");
foreach (int id in ids)
{
string idstr = id.ToString();
//更新添加用户信息表状态
for (int i = 0; i < dataGridView_User.RowCount; i++)
{
if (idstr.Equals(dataGridView_User.Rows[i].Cells[0].Value.ToString()))
{
//在线显示为⭐
dataGridView_User.Rows[i].Cells[2].Value = "⭐";
//发送对象更新
TalkList.Items.Add(dataGridView_User.Rows[i].Cells[0].Value.ToString());
}
}
}
}
服务器端更新用户列表
void SendUserOnlineInfo()
{
//生成报文头部
HTTPHeader httpheader = new HTTPHeader();
//生成JSON报文
sendInstruction.SendID = 0;//服务器端发送
sendInstruction.Type = Send_Instruction_Type.Refresh;//更新用户列表
sendInstruction.RecieveID = 0;//接收者ID不用管
Dictionary<int, Socket>.KeyCollection ids = LinkAll.Keys;
foreach (int id in ids)
{
sendInstruction.OtherInforamtion[id.ToString()] = "online";
}
string json = JsonMapper.ToJson(sendInstruction);
httpheader.HttpHeaders["Content-Length"] = json.Length.ToString();
string http = httpheader.ToString() + json;
//利用TCP协议将HTTP报文发送
SendAllUser(http);
}
向每一个在线客户端发送用户在线的ID
//对LinkAll进行更新操作
void UpdateLinkAll()
{
bool update = false;//是否更新
List<int> lost_id = new List<int>();
//是否掉线
foreach (KeyValuePair<int, Socket> link in LinkAll)
{
if (link.Value.Poll(1000, SelectMode.SelectRead))
{
update = true;
link.Value.Close();
//LinkAll.Remove(link.Key);
lost_id.Add(link.Key);
}
}
if (update)
{
//删除不在线的TCP连接
for(int i = 0; i < lost_id.Count; i++)
{
LinkAll.Remove(lost_id[i]);
}
//修改在线状态表
GetUserInfo();
//重新发送在线状态报文
SendUserOnlineInfo();
}
}
检查是否断线,每当服务器在发送消息出现异常,大概率是断线,所以在服务器发送消息并出现异常的时候,会检查一下当前TCP连接是否在线,如果不在线则删除该TCP连接,并通过GetUserInfo()更新服务器端在线列表,再通过SendUserOnlineInfo()向所有在线用户发送在线ID。
服务器群发消息只要遍历在线用户id->socket,并将文本框的信息添加到响应报文中,发给每一个在线用户即可,即socket.send。客户端群发消息要向服务器发出一条群发消息报文,通过服务器转发给所有的在线用户。
三个用户同时收到
所有用户均能收到
void ClientSendAllUser()
{
int id = userInstruction.ID;
string message = userInstruction.OtherInforamtion["message"];
HTTPHeader httpheader = new HTTPHeader();
//Insert(id.ToString(), message);
sendInstruction.SendID = id;
sendInstruction.RecieveID = -1;
sendInstruction.Type = Send_Instruction_Type.Message;
sendInstruction.OtherInforamtion["message"] = message;
string json = JsonMapper.ToJson(sendInstruction);
httpheader.HttpHeaders["Content-Length"] = json.Length.ToString();
string http = httpheader.ToString() + json;
SendAllUser(http);
}
服务器将客户端发来的HTTP报文中提取userInstruction的ID和OtherInforamtion[“message”]保存到sendInstruction中,生成响应HTTP报文,并通过函数SendAllUser(http)发送给所有在线用户。
//发给所有用户
void SendAllUser(string message)
{
Insert("服务器",message);
Dictionary<int, Socket>.ValueCollection links = LinkAll.Values;
foreach (Socket link in links)
{
byte[] send = new byte[MAX_STREAM_SIZE];
send = Encoding.UTF8.GetBytes(message);
link.Send(send);
}
//清除之前请求的特殊信息
//userInstruction.OtherInforamtion.Clear();
sendInstruction.OtherInforamtion.Clear();
}
每次在给客户端发送完sendInstruction时,都要将sendInstruction.OtherInforamtion.Clear()清空,因为OtherInforamtion相对于每一个指令的特有指令,防止出现将多余的数据发出去,导致逻辑错误。
单人聊天的功能相对于在多人聊天的功能上加了过滤功能,客户端通过userInstruction中的RecieveID遍历在线用户id->socket的映射关系,发给指定的用户。服务器一样,发给指定的用户。
void SendSpecailUser(int id,string message)
{
if (message.Length <= 300)
{
Insert("服务器", message);
}
if (LinkAll.Keys.Contains(id))
{
//是否包含该ID的TCP连接
byte[] send = new byte[MAX_STREAM_SIZE];
send = Encoding.UTF8.GetBytes(message);
LinkAll[id].Send(send);
}
//清除之前请求的特殊信息
sendInstruction.OtherInforamtion.Clear();
}
其他与群发消息大同小异。
需要注意的是登录,注册并没有将用户id和socket加入到在线映射Map中,所以通过临时通信
//临时发送消息
void TemporarySend(string message)
{
Insert("服务器", message);
//检查临时通信是否断开
if (socket.Poll(1000, SelectMode.SelectRead))
{
//已经断开则不发送临时消息
socket.Close();
}
else
{
byte[] send = new byte[MAX_STREAM_SIZE];
send = Encoding.UTF8.GetBytes(message);
socket.Send(send);
}
//清除之前请求的特殊信息
sendInstruction.OtherInforamtion.Clear();
}
服务器单人聊天
只有3号收到
客户端单人聊天
首先用户向服务器发起上传文件申请,请求报文包括文件大小,文件名等信息,服务器收到请求后,判断一下文件大小等信息,这里我设置最大接收10M文件,若读者想用更大的文件大小,则扩大每次接收和发送的最大字节大小,或者分段发送(该功能没有写)。若符合要求则发出同意响应报文,并发给客户端服务器存放文件的位置,其实是想设置用户可以选择存储的位置,但是时间原因,就简化了功能,只有一个存储位置。然后客户端调用IO库,将文件流转换为字符串放入JSON报文中,服务器将把JSON报文中的文件字符串反转成Byte流,并输入到文件中。但是出现的问题就是有数据缺失,原因是string转换为byte时相对于原来的byte会缺失,这是强制转换过程中数据缺失的问题。我采用的方法是将Byte流转换为十六进制字符串,再将十六进制字符串转换为byte,这样就不会有数据缺失。
客户端上传文件
选择传输的对象为全体成员,即保存到服务器,发给指定用户文件的功能由于时间问题没有实现,以及接收框没有实现,读者可以自己添加这些功能。
点击发送文件,选择想要发送的文件
获得文件信息并显示,这时关闭窗口即可发送。
服务器发送可以发送响应报文,并提示存储位置
文件传输结束
服务器收到客户端上传的文件,并保存。
客户端下载文件
点击保存位置
输入接收文件名,点击接收文件,开始接收
文件传输成功
class FileStruct
{
//设置文件结构体
public string file_name { set; get; }//文件名
public long file_size { get; set; }//文件大小
public string file_sendpath { get; set; }//文件发送位置
public string file_receivepath { get; set; }//文件接收位置
public string file_downfilename { get; set; }//向服务器下载的文件
public int file_operatetype { get; set; }//操作类型
}
文件结构体
void SendFile()
{
userInstruction.ID = id;
userInstruction.Name = name;
userInstruction.PassWord = password;
userInstruction.Type = Instruction_Type.Upload_File;
userInstruction.OtherInforamtion["file_name"] = fileinfo.file_name;
userInstruction.OtherInforamtion["file_size"] = fileinfo.file_size.ToString();
userInstruction.OtherInforamtion["file_location"] = sendInstruction.OtherInforamtion["file_location"];
userInstruction.OtherInforamtion["file_state"] = "2";//表示客户端正在发送文件中
//生成一个缓存读取文件空间
byte[] filebuffer = new byte[MAX_STREAM_SIZE];
//以开发和读取方式对文件进行读取
using (FileStream stream = new FileStream(fileinfo.file_sendpath, FileMode.Open, FileAccess.Read))
{
while(true)
{
int readlen = stream.Read(filebuffer, 0, MAX_STREAM_SIZE);
userInstruction.OtherInforamtion["file_send_size"] = readlen.ToString();
byte[] send;
if (readlen == 0)
{
MessageBox.Show("文件传送结束");
break;
}
if(readlen < MAX_STREAM_SIZE)
{
//如果读取的小于最大长度
send = new byte[readlen];
Array.Copy(filebuffer, send, readlen);
}
else
{
send = filebuffer;
}
//将要发送的文件转换为string
string send_file_content = ToHexString(send);
if (listTalk.SelectedItem.ToString().Equals("全体成员"))
{
userInstruction.OtherInforamtion["receive_id"] = "0";
}
else
{
userInstruction.OtherInforamtion["receive_id"] = listTalk.SelectedItem.ToString();
}
userInstruction.OtherInforamtion["content"] = send_file_content;
//发送给服务器
Send(CreateHTTP());
}
}
}
//防止string->byte->string有文件缺失
public string ToHexString(byte[] bytes) // 0xae00cf => "AE00CF "
{
string hexString = string.Empty;
if (bytes != null)
{
StringBuilder strB = new StringBuilder();
for (int i = 0; i < bytes.Length; i++)
{
strB.Append(bytes[i].ToString("X2"));
}
hexString = strB.ToString();
}
return hexString;
}
public static byte[] GetBytes(string hexString)
{
int byteLength = hexString.Length / 2; byte[] bytes = new byte[byteLength]; string hex; int j = 0; for (int i = 0; i < bytes.Length; i++)
{
hex = new String(new Char[] { hexString[j], hexString[j + 1] });
bytes[i] = Convert.ToByte(hex, 16);
j = j + 2;
}
return bytes;
}
客户端发送文件过程
//处理上传文件请求
void SolveUploadFile()
{
int file_state = int.Parse(userInstruction.OtherInforamtion["file_state"]);
//判断文件传输类型
if(file_state == 1)
{
//客户端发来的上传请求
int rec_id = int.Parse(userInstruction.OtherInforamtion["receive_id"]);
if (rec_id == 0 && int.Parse(userInstruction.OtherInforamtion["file_size"])<=MAX_STREAM_SIZE)
{
//传给服务器的
//如果文件合理,发送通过上传文件报文
SuccessAskSendFile(userInstruction.ID);
}
else
{
//传给特定用户,是否存在,是否在线,直接传给他
}
}
else if (file_state == 2)
{
int sendto = int.Parse(userInstruction.OtherInforamtion["receive_id"]);
//客户端正在发送信息
if (sendto == 0)
{
//发送给的是服务器
string save_file_path = userInstruction.OtherInforamtion["file_location"]+ "\\"+userInstruction.OtherInforamtion["file_name"];
//有没有存在该文件
if (File.Exists(save_file_path))
{
File.Delete(save_file_path);
}
//新建一个
FileStream fs = File.Create(save_file_path);
fs.Close();
//缓存读取
string file_content = userInstruction.OtherInforamtion["content"];
byte[] fileread = new byte[MAX_STREAM_SIZE];
fileread = GetBytes(file_content);
FileStream fscreat = new FileStream(save_file_path, FileMode.Append, FileAccess.Write);
{
fscreat.Write(fileread, 0, fileread.Length);
}
fscreat.Close();
}
else
{
//发送给的是指定用户
}
}
}
服务器接收文件过程
客户端下载文件和服务器上传类似。
//因为电脑位置的改变需要手动更新的变量
//服务器文件存储的位置
string file_location_server = "C:\\Users\\33907\\Desktop\\ChatRoom\\ChatRoom\\ChatRoom\\Server\\FileReceive";
//ACCESS数据库存储的位置
private string connStr = @"Provider = Microsoft.ACE.OLEDB.12.0; Data Source = C:\Users\33907\Desktop\ChatRoom\ChatRoom\ChatRoom\Server\UserInfo.accdb";//数据库保持地址
connStr需要修改为自己ACCESS存储的位置
//服务器文件存储的位置
string file_location_server = "C:\\Users\\33907\\Desktop\\ChatRoom\\ChatRoom\\ChatRoom\\Server\\FileReceive";
下载多人聊天软件
软件可能有些没有发现的bug,由于时间问题,作者的界面可能不太友好,请读者见谅。由于本人代码能力以及思考的局限性,可能会出现错误,请读者见谅,仅供参考!