Web开发之ASP.Net:(1)Socket通信及简易聊天程序
1、Socket通信及简易聊天程序
1.1 三级寻址
网络通信中通信的两个进程分别在不同的机器上。在互连网络中,两台机器可能位于不同的网络,这些网络通过网络互连设备(网关,网桥,路由器等)连接。因此需要通过三级寻址来访问网络上其他机器的应用程序:
(1)协议:例如Tcp、FTP、Http协议等;(Http)
(2)IP地址:某一主机可与多个网络相连,必须指定一特定网络地址;同IP下特定的计算机:网络上每一台主机应有其唯一的地址;(IP)
(3)端口号:每一主机上的每一进程应有在该主机上的唯一标识符。(Port)
例如 http 使用80端口 ftp使用21端口 smtp 25端口
端口号:低端口号(默认)中端口号(系统随机分配的)高端口号(推荐用户使用的)
TCP和UDP比较:
传输视频或声音强调速度用UDP 如视频聊天,
发文件的时候,保证数据的完整和正确,用TCP。
UDP不是面向连接的,UDP传送数据前并不与对方建立连接,对接收到的数据也不发送确认信号,发送端不知道数据是否会正确接收,当然也不用重发,所以说UDP是无连接的、不可靠的一种数据传输协议。UDP的开销更小数据传输速率更高,因为不必进行收发数据的确认,所以UDP的实时性更好。
1.2 网络通讯原理
1.2.1 TCP/IP协议
l 应用层 (Application):应用层是个很广泛的概念,有一些基本相同的系统级 TCP/IP 应用以及应用协议,也有许多的企业商业应用和互联网应用。 (Http、FTP等)
l 传输层 (Transport):传输层包括 UDP 和 TCP,UDP 几乎不对报文进行检查,而 TCP 提供传输保证。 (TCP)
l 网络层 (Network):网络层协议由一系列协议组成,包括 ICMP、IGMP、RIP、OSPF、IP(v4,v6) 等。 (IP)
l 链路层 (Link):又称为物理数据网络接口层,负责报文传输。 (网线高低电平等)
通讯过程如下图:
1.2.2 TCP/IP协议头
发送报文时,在各层对数据添加协议头,封装成新的报文后传入下一层,一直到链路层。
接收报文时,在各层对报文的协议头和数据进行解析,然后根据相应的协议,将数据提交到上一层,直到应用层。
TCP/UDP的报文格式如下:
16位TCP校验和时必填的,保证数据的正确性,16位UDP校验和是可不填的。
IP报文格式如下:
1.3 Socket编程
1.3.1 Socket概念
Socket的英文原义是“孔”或“插座”。作为进程通信机制,取后一种意思。通常也称作“套接字”,用于描述IP地址和端口,是一个通信链的句柄。(其实就是两个程序通信用的。)
Socket通讯是传输层的通讯,创建Socket连接时,可以指定使用的传输层协议,Socket可以支持不同的传输层协议(TCP或UDP)。当使用TCP协议进行连接时,该Socket连接就是一个TCP连接。
1.3.2 Socket通讯流程
建立连接的三次握手
1) 客户端向目标服务器发送建立连接的请求(有空没?)
2) 服务端响应客户端的请求,同意或拒绝(有空、没空)
3)客户端接收服务端响应,如果是同意(提供了一个新的socket进行通讯),客户端使用这个socket通讯与服务端建立连接。(知道了)
Socket通讯过程
1) 服务器创建一个监听用的socket,监听一个IP和端口
2) 客户端创建一个socket,去连接服务器的一个IP和端口(用TCP通过三次握手)
3)连接成功后(服务器创建了一个负责通讯用的Socket),客户端与服务端通过新的Socket进行收发数据。
Socket编程步骤
服务器端监听:
创建一个监听用的socket
绑定到一个IP地址和一个端口上
开启侦听,等待接收连接
客户端请求连接:
创建一个socket
连接服务器(指明IP地址和端口号)
客户端与服务端通讯:
服务器端接到连接请求后,产生一个新的socket(端口大于1024)与客户端建立连接并进行通讯,原监听socket继续监听。socket通讯流程图如下:
1.4 Socket聊天程序
1.4.1 聊天程序服务端
服务器接收连接后新开了一个socket,本身仍旧在监听,新开的socket才是真正与客户端进行通讯的操作对象
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.IO;
namespace Chat
{
public partial class FrmServer : Form
{
public Form1()
{
InitializeComponent();
Control.CheckForIllegalCrossThreadCalls = false;//不检查跨线程操作的合法性
}
//创建socket对象,开启监听,开启线程,执行AcceptConn方法接收连接
private void btnStart_Click(object sender, EventArgs e)
{
//1 准备IP地址 当前计算机IP
//IPAddress ip = IPAddress.Any;// 当前计算机任何可用的IP(127.0.0.1或localhost或192.168.1.100等方式)
IPAddress ip = IPAddress.Parse(txtServer.Text.Trim());
//2 准备端口号 (用到IP地址)
IPEndPoint point = new IPEndPoint(ip,int.Parse(txtPort.Text));
//3 创建socket对象(用来监听客户端的连接)
//初始化:InterNetwork 使用IP V4版本网络,Stream 类型为流式Socket类型,Tcp传输协议
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
//4 socket对象绑定IP地址和端口号
socket.Bind(point);
//5 开启监听 客户端的连接请求
socket.Listen(11);//11是挂起连接队列的最大长度
//禁用按钮每个套接字地址(协议/网络地址/端口)只允许使用一次
btnStart.Enabled=false;
ShowLog("开始监听!");
}
catch(Exception ex)
{
MessageBox.Show(ex.Message);
return;//出错了,不让往下执行
}
//执行AcceptConn,接受客户端连接。等待一个客户端连接的过程会阻塞线程,所以新开一个线程来执行,以免阻塞UI线程导致假死。
Thread th = null;
th = new Thread(AcceptConn);
th.IsBackground = true;//设为后台线程,这样关闭窗体的同时,关闭程序
//启动线程,开始接收
th.Start(socket);//传参数socket
}
Dictionary<string, Socket> dicSoc = new Dictionary<string, Socket>();
//服务器接收连接包含接收客户端消息的部分
//当一个客户端连接请求到来后,就创建一个新的socket负责与其通讯,这个通讯活动通过开启新的线程执行。而当前线程进入下一次循环体,等待下一个客户端连接的到来。
void AcceptConn(object socket)
{
int i = 0;//限制最大的客户端连接数
while (i<20)//接收多个客户端的连接,否则连接一次之后,后面的连接就没有"人"来接收了,因为代码是从上往下执行的,执行一次后,没法倒回来接收下一个,于是使用循环
{
i ;
Socket watchSocket = socket as Socket;
//使用Accept方法接收连接,返回一个新的socket对象,专门进行通讯
Socket connSocket = watchSocket.Accept();//每次会为个新的socket分配新的端口
ShowLog(connSocket.RemoteEndPoint.ToString() "连接成功!");
//将接收到的客户端的IP和负责该客户端的socket存入字典,IP加入到combobox集合中
btnStart.Enabled = false;
string ip = connSocket.RemoteEndPoint.ToString();
dicSoc.Add(ip, connSocket);
cboUsers.Items.Add(ip);
//正常情况下,接收消息的socket.Receive方法未接收到消息时,会处于等待状态,直到客户端发消息,Receive接收完毕。这样会阻断当前线程,导致循环卡死,影响服务器继续监听其他客户端连接。
//因此为本次循环得到的当前socket对象开启新的线程,在新的线程中是用socket.Receive方法接收客户端消息。
Thread threadConn = null;
threadConn = new Thread(RecMsg);
threadConn.IsBackground = true;
threadConn.Start(connSocket); //将通讯用的socket传过去
}
}
//接收客户端消息的方法
void RecMsg(object socket)
{
Socket connSocket = socket as Socket;
byte[] buffer = new byte[1024 * 1024 * 2];
//代码是向下执行的,用循环解决只能接收一次消息的问题
while (true)
{
string msg;
int length=0;
try
{
//socket.Receive 返回值显示接收的字节个数
length = connSocket.Receive(buffer);
}
catch (Exception ex)
{
ShowMsg(connSocket.RemoteEndPoint.ToString() ex.Message);
break;//如果出现异常,跳出循环
}
if (length != 0)
{
msg = Encoding.UTF8.GetString(buffer, 0, length);
ShowMsg(connSocket.RemoteEndPoint.ToString() msg);
}
else //如果发过来是是空,认为客户端走了,不用再接收了
{
connSocket.Shutdown(SocketShutdown.Receive);//关闭接收消息
connSocket.Close();//关闭connSocket
break;//跳出循环,避免Close了,还执行循环,访问关闭了的socket会报错
}
}
}
//将监听到客户端连接请求显示在文本框控件中
void ShowLog(string log)
{
txtLog.Text = log "\r\n";
}
//将接收到的消息显示在文本框控件中
void ShowMsg(string msg)
{
txtMsg.Text = msg "\r\n";
}
//向选定的客户端发送消息(标志位0)
private void btnSend_Click(object sender, EventArgs e)
{
if (cboUsers.SelectedIndex >= 0)
{
//提取指定的客户端索引
string key = cboUsers.Text.ToString();
//准备要发送的消息
byte[] buffer = Encoding.UTF8.GetBytes(txtMsg.Text);
List<byte> list = new List<byte>();
list.Add(0); //文本消息标志位
list.AddRange(buffer);
//调用对应socket的send方法,发送字节数组的消息
dicSoc[key].Send(list.ToArray());
}
}
//选定要发送的文件
private void btnSelect_Click(object sender, EventArgs e)
{
if (cboUsers.SelectedIndex<0)
{
MessageBox.Show("请选择要发送的用户!");
return;
}
OpenFileDialog ofd = new OpenFileDialog();
ofd.InitialDirectory = @"C:\";
if (ofd.ShowDialog()== System.Windows.Forms.DialogResult.OK)
{
txtPath.Text = ofd.FileName;
}
}
//发送文件(标志位1)
private void btnSendFile_Click(object sender, EventArgs e)
{
if (File.Exists(txtPath.Text))
{
using (FileStream fs = new FileStream(txtPath.Text, FileMode.Open))
{
byte[] buffer = new byte[fs.Length];
int length = fs.Read(buffer,0,buffer.Length);
byte[] arrFileSend = new byte[length 1];
arrFileSend[0] = 1; //标志位
//得到包含标志位的字节数组arrFileSend,
//将指定数目的字节从起始于特定偏移量的源数组复制到起始于特定偏移量的目标数组
Buffer.BlockCopy(buffer, 0, arrFileSend, 1, length);
dicSoc[cboUsers.Text].Send(arrFileSend);
}
}
}
//发送振动(标志位2)
private void btnZD_Click(object sender, EventArgs e)
{
if (cboUsers.SelectedIndex < 0)
{
MessageBox.Show("请选择要发送的用户!");
return;
}
byte[] buffer = new byte[1024];
buffer[0] = 2;
dicSoc[cboUsers.Text.ToString()].Send(buffer);
}
}
}
1.4.2 聊天程序客户端
namespace chatClient
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
Control.CheckForIllegalCrossThreadCalls = false;
}
// 创建 Socket对象连接接收服务端消息
Socket clientSocket = null;
private void btnStart_Click(object sender, EventArgs e)
{
IPAddress ip = IPAddress.Parse(txtServer.Text);
IPEndPoint point = new IPEndPoint(ip,int.Parse(txtPort.Text.Trim()));
clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
//连接服务器不是持续连接,不需要另外开启线程 clientSocket.Connect(point);
ShowMsg("连接成功!");
btnStart.Enabled = false;
//接收服务器消息持续检测状态要另外开启线程
Thread th = new Thread(RecMsg);
th.IsBackground = true;
th.Start(clientSocket);
}
catch (Exception ex)
{
ShowMsg(ex.Message);
btnStart.Enabled = true;
}
}
//接收消息的方法,同服务端的方法
void RecMsg(object socket)
{
Socket clientSocket = socket as Socket;
while (true)
{
byte[] buffer = new byte[1024 * 1024 * 2];
int length =0;
try
{
length = clientSocket.Receive(buffer);
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
return;
}
if(length==0)
{
return;
}
int flag = buffer[0];
//接收文字数据消息flag == 0
if (flag == 0)
{
string msg = Encoding.UTF8.GetString(buffer, 1, length - 1);
this.TopMost = true;
ShowMsg(clientSocket.RemoteEndPoint.ToString() msg);
}
//接收文件数据消息 flag == 1
else if (flag == 1)
{
SaveFileDialog sfd = new SaveFileDialog();
if (sfd.ShowDialog(this) == System.Windows.Forms.DialogResult.OK)
{
string path = sfd.FileName;
using (FileStream fs = new FileStream(path, FileMode.Append,FileAccess.Write))
{
fs.Write(buffer, 1, length - 1);
while(true)
{
length = clientSocket.Receive(buffer);
if(length==0)
{
break;
}
fs.Write(buffer, 0, length);
}
}
ShowMsg("文件保存成功!" path);
}
}
//接收震动消息
else if (flag == 2)
{
this.TopMost = true;//窗体前置
ZD();
}
}
}
void ZD()
{
int step = 5;
for (int i=0;i<15;i )
{
step = -step;
this.Location = new Point(this.Location.X step, this.Location.Y step);
System.Threading.Thread.Sleep(50);
}
}
//将接收到的消息显示在文本框控件中
void ShowMsg(string msg)
{
txtMsg.Text = msg "\r\n";
}
//向服务器发送消息,简单
private void btnSend_Click(object sender, EventArgs e)
{
byte[] buffer = Encoding.UTF8.GetBytes(txtMsg.Text);
clientSocket.Send(buffer);
}
}
}
1.4.3 协议封装与序列化
上述发送消息时,有文本消息、文件、振动等不同的消息类型。可以将消息分为消息类型和消息内容两部分,封装成包含两个属性的类。当协议比较复杂时,这样的处理非常有用。
发送时对类的实例进行序列化,得到二进制流,进行传输。
接收后,将二进制流反序列化,得到类的实例,然后进行相关的业务处理。
导入命名空间
using System.Runtime.Serialization.Formatters.Binary;
定义消息类
[Serializable]
public class MyData
{
private int flag;
public int Flag
{
get { return flag; }
set { flag = value; }
}
private byte[] buffer;
public byte[] Buffer
{
get { return buffer; }
set { buffer = value; }
}
}
发送消息的方式
MyData data = new Common.MyData();
data.Flag = 1;
data.Buffer = buffer;
string key = cboUsers.Text;
dic[key].Send(Serialize(data));
接收消息的方式
byte[] buffer = new byte[1024 * 1024];
//实际接收的字节个数
int count = socket.Receive(buffer);
MyData data = DeSerialize(buffer, count);
if (data.Flag == 1)
{
//文件
SaveFileDialog sfd = new SaveFileDialog();
//this 解决win7下。在线程中运行打不开对话框的问题
if (sfd.ShowDialog(this) == System.Windows.Forms.DialogResult.OK)
{
using (FileStream fs = new FileStream(sfd.FileName, FileMode.Create))
{
fs.Write(data.Buffer,0,data.Buffer.Length);
}
MessageBox.Show("保存成功");
}
}
1.5 服务端处理网络中断
我们知道,TCP有一个连接检测机制,就是假如在指定的时间内(一般为2个小时)没有数据传送,会给对端发送一个Keep-Alive数据报,使用的序列号是曾发出的最后一个报文的最后一个字节的序列号,对端假如收到这个数据,回送一个TCP的ACK,确认这个字节已收到,这样就知道此连接没有被断开。假如一段时间没有收到对方的响应,会进行重试,重试几次后,向对端发一个reset,然后将连接断掉。
在Windows中,第一次探测是在最后一次数据发送的两个小时,然后每隔1秒探测一次,一共探测5次,假如5次都没有收到回应的话,就会断开这个连接。
但两个小时对于我们的项目来说显然太长了。我们必须缩短这个时间。那么我们该如何做呢?我要利用Socket类的IOControl()函数。我们来看看这个函数能干些什么:
使用 IOControlCode 枚举指定控制代码,为 Socket 配置低级操作模式。
命名空间:System.Net.Sockets
程式集:System(在 system.dll 中)
语法
C#
public int IOControl ( IOControlCode ioControlCode, byte[] optionInValue, byte[] optionOutValue )
参数:
ioControlCode
一个 IOControlCode 值,他指定要执行的操作的控制代码。
optionInValue
Byte 类型的数组,包含操作需要的输入数据。
optionOutValue
Byte 类型的数组,包含由操作返回的输出数据。
返回值
optionOutValue 参数中的字节数。
在C#中,我们直接用一个Byte数组传递给函数:
uint dummy = 0;
byte[] inOptionValues = new byte[Marshal.SizeOf(dummy) * 3];
BitConverter.GetBytes((uint)1).CopyTo(inOptionValues, 0);//是否启用Keep-Alive
BitConverter.GetBytes((uint)5000).CopyTo(inOptionValues, Marshal.SizeOf(dummy));//多长时间开始第一次探测
BitConverter.GetBytes((uint)5000).CopyTo(inOptionValues, Marshal.SizeOf(dummy) * 2);//探测时间间隔
具体实现代码:
public static void AcceptThread()
{
Thread.CurrentThread.IsBackground = true;
while (true)
{
uint dummy = 0;
byte[] inOptionValues = new byte[Marshal.SizeOf(dummy) * 3];
BitConverter.GetBytes((uint)1).CopyTo(inOptionValues, 0);
BitConverter.GetBytes((uint)5000).CopyTo(inOptionValues, Marshal.SizeOf(dummy));
BitConverter.GetBytes((uint)5000).CopyTo(inOptionValues, Marshal.SizeOf(dummy) * 2);
try
{
Accept(inOptionValues);
}
catch
{
}
}
}
private static void Accept(byte[] inOptionValues)
{
Socket socket = Public.s_socketHandler.Accept();
socket.IOControl(IOControlCode.KeepAliveValues, inOptionValues, null);
UserInfo info = new UserInfo();
info.socket = socket;
int id = GetUserId();
info.Index = id;
Public.s_userList.Add(id, info);
socket.BeginReceive(info.Buffer, 0, info.Buffer.Length, SocketFlags.None, new AsyncCallback(ReceiveCallBack), info);
}