使用c#的有关TCP的底层API进行服务器端的开发(直接经过socket进行通讯)c#
功能:
Third-Person Shooting Game
建立房间、加入房间的联机功能数组
Prerequisite:
TCP基础知识
MySQL基础知识
UI框架服务器
IP: 在网络环境中,将数据包发给最终的目标地址网络
路由器能够理解为数据的中转站
链接同一个路由器的多是多台设备,这部分构成了一个局域网
路由器会给每台设备分配一个不重复的局域网IP
(cmd: ipconfig -- WLAN的IPv4地址,通常是192.168.x.x)
而这个局域网内的设备是共享一个公网IP的
经过百度搜索IP便可查到当前设备的公网IP框架
IP地址是由网络供应商分配的异步
游戏的服务器有一个公网IP,用于与客户端之间的通讯
服务器购买:阿里云 -> 云服务器ECSsocket
Port: 端口号
数据通讯的实质是:在软件之间的传输
端口号代表了是在跟该电脑上的哪一个软件进行通讯
端口号是不会重复的,由操做系统进行分配函数
通常公认端口 (Well-known Ports)在0~1023之间
好比HTTP协议代理端口号经常使用80等
注册端口 (Registered Ports)在1024~49151之间,多被一些服务绑定
动态/私有端口 (Dynamic/ Private Ports)则通常在1024~65535之间
只要运行的程序向系统提出访问网络的申请,那么系统就能够从这些端口号中分配一个共该程序使用性能
当一个通讯创建链接时,须要进行TCP的三次握手
当一个通讯链接断开时,须要进行TCP的四次挥手大数据
TCP和UDP的优缺点:
TCP传输稳定,传输信息时会保证信息的完整性
-- 发出消息后会等待接收端的响应,若是等待时间到后没有响应,会再次发送
UDP不稳定,可能丢失数据,可是速度快
-- 发出消息后不会验证消息的接收状态
详见 https://blog.csdn.net/omnispace/article/details/52701752
TCP的三次握手 Three-Way Handshake:-- 链接的创建
1. 客户端发送SYN (syn=j -- 随机产生)包给服务器,并进入SYN_SENT状态,请求创建链接,等待服务器确认
2. 服务器收到SYN包后,针对SYN进行应答ACK (ack = j+1),同时本身也发送一个SYN包 (syn=k -- 随机产生),
即发送了SYN+ACK包给客户端,服务器进入SYN_RECV状态
3. 客户端收到SYN+ACK后,向服务器发送确认包ACK (ack=k+1)
此时客户端和服务器进入ESTABLISHED状态,完成三次握手
自此链接创建成功,能够开始发送数据
TCP的四次挥手 -- 链接终止协议
1. 客户端发送FIN包给服务器,用来表示须要关闭客户端到服务器的数据传输
客户端进入FIN_WAIT_1状态
2. 服务器收到FIN后,针对FIN进行确认应答ACK (确认序号为收到序号+1),并将ACK发送给客户端
服务器进入CLOSE_WAIT状态
3. 服务器发送FIN包给客户端,请求切断链接
服务器进入LAST_ACK状态
4. 客户端收到FIN后,进入TIME_WAIT状态,并针对FIN包进行确认应答ACK,并向服务器发送
服务器进入CLOSED状态
VS -> 文件 -> 新建 -> 项目 -> 控制台应用(.NET Framework) -> 命名Server
建立Socket并绑定IP和Port:
using System.Net.Sockets;
1. 建立socket -- Socket(AddressFamily, SocketType, ProtocolType);
Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
AddressFamily
.InterNetwork表示IPv4类型的地址
.InterNetworkV6表示IPv6
SocketType
.Dgram表示使用数据报文的形式,以投递的方式进行数据传输,可能丢失 -- UDP可使用该形式
.Stream表示使用数据流的形式,在二者之间创建管道,数据在管道中进行传输 -- 数据传输稳定
2. 绑定IP和port:
IP:
由于设备可能有多个网卡,每一个网卡可能链接不一样的网络,所以一个设备可能出现对应多个IP地址
可是,做为服务器端的部署,通常只会有一个外网IP
这里,绑定局域网IP便可
// 经过ipconfig获得局域网ip,或直接使用127.0.0.1 (本地localhost)
using System.Net;
IPAddress -- 表明ip -- xxx.xxx.xx.xx
IPEndPoint -- 表明ip: port -- xxx.xxx.xx.xx : xx
-- 由于过一段时间,路由器会给设备从新分配ip,基于路由器的ip管理策略
因此不该直接设置ip地址
建立ip地址
IPAddress ipAddress = new IPAddress(new byte[] {192,168, x, x});
不推荐这么写,改成 -->
IPAddress ipAddress = IPAddress.Parse("192.168.x.x");
Port:
建立port地址
IPEndPoint ipEndPoint = new IPEndPoint(ipAddress, "65535");
绑定ip和端口号
serverSocket.Bind(ipEndPoint); // 包括了向操做系统申请端口号
发送和接收数据:
3. 开始监听端口
serverSocket.Listen(50);
// 表示处理等待链接的队列最大为50,设置为0表示不设置最大值,无限制
// 服务器只有一个,而客户端有多个,等待队列满后将再也不接收客户端链接
4. 等待接收一个客户端来的链接
Socket clientSocket = serverSocket.Accept();
直到接收到链接后,才会继续执行下面的代码
发送数据
string msg = "Hello client! 你好 ....";
byte[] data = System.Text.Encoding.UTF8.GetBytes(msg); // 将string转换成byte[]
clientSocket.Send(data); // 须要传输的类型是byte[]
接收数据
byte[] dataBuffer = new byte[1024]; // 保证数组大小够用便可
int count = clientSocket.Receive(dataBuffer); // 返回值int表示接收到byte[]数据的长度
string megReceived = System.Text.Encoding.UTF8.GetString(dataBuffer,0 , count);
// 表示把有内容的那部分bytes进行转换, 从0开始,一直到第count字节
Console.WriteLine(msgReceive);
5. 关闭链接:
clientSocket.Close(); // 断开客户端的链接
serverSocket.Close();
新建 -> 项目 -> 控制台应用(.Net Framework) -> 命名Client
建立socket:
Socket clientSocket = new Socket(AddressFamily.InnerNetwork, SocketType.Stream, ProtocolType.Tcp);
与服务器端创建链接:
clientSocket.Connect(new IPEndPoint(IPAddress.Parse("192.168.x.x"), 65535));
与远程主机创建链接,服务器端的Accept()获得了来自客户端的链接,所以继续执行它如下的代码,向客户端Send()消息
进行有关消息的操做:
从服务器端接收消息:
byte[] data = new byte[1024];
int count = clientSocket.Receive(data);
string msg = System.Text.Encoding.UTF8.GetString(data, 0, count);
Console.Write(msg);
// 调用完Receive()后,程序会暂停并等待,直到接收到信息后才会继续执行下面的代码
发送消息给服务器端:
string input = Console.ReadLine();
clientSocket.Send(System.Text.Encoding.UTF8.GetBytes(input));
-- 此时server中的接收消息部分会接收这段发送过去的信息
关闭链接:
clientSocket.Close();
运行上面的服务器端和客户端
如何同时运行呢?
在VS中不能同时运行两个应用程序
1. 在VS中启动服务器端
2. 在文件资源管理器中,右键对应的项目 -> 生成 -- 就会生成.exe文件
直接双击.exe程序,启动客户端
左侧为server,右侧为client
server打开,暂停在serverSocket.Accept()处等待客户端链接
client打开,并进行clientSocket.Connect(),创建链接
链接创建成功,server代码继续执行,执行Send()后,在Receive()处暂停
而client创建链接后在Receive()处暂停,等待接收server消息,由于server执行了Send(),client接收到了消息
消息接收完,client代码继续执行,等待用户输入 Console.ReadLine();
输入后,进行Send()操做并执行关闭链接
server在接收到client发送的消息,继续执行代码
(由于server接收到信息并打印以后,程序就结束自动关闭了(client也同样)
为了方便看清server接收到的信息,在server最后加上了一行Console.ReadKey()阻止自动关闭)
以前的程序在会在Receive()处一直等待;若要想持续不断地发送或接收消息,有两种方法:
1. 另起一个线程,好比聊天室功能单独占有的线程
2. 异步方法
clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallBack, clientSocket);
开始监听数据的传递
BeginReceive(buffer, int offset, int size, SocketFlags, AsyncCallback, object state);
offset: 从哪开始;size: 最大数据长度;AsyncCallback: 接收到消息后的回调函数;
state: 给回调函数传递的参数,在回调函数中的ar.AsyncState强制转换成须要的类型便可
static byte[] s_Buffer = new byte[1024]; static void StartServerAsync() { Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); IPAddress ipAddress = IPAddress.Parse("192.168.1.5"); IPEndPoint ipEndPoint = new IPEndPoint(ipAddress, 65535); serverSocket.Bind(ipEndPoint); serverSocket.Listen(0); Socket clientSocket = serverSocket.Accept(); string msg = "Hello client"; clientSocket.Send(System.Text.Encoding.UTF8.GetBytes(msg)); // 这里开始进行异步接收消息 clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallBack, clientSocket); } static void ReceiveCallBack(IAsyncResult ar) { Socket clientSocket = ar.AsyncState as Socket; int count = clientSocket.EndReceive(ar); Console.WriteLine(Encoding.UTF8.GetString(s_Buffer, 0, count)); clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallBack, clientSocket); // 循环调用,继续等待接收数据 }
任务4~6中使用的socket.Accept()也会致使程序等待,当有客户端链接过来时才会继续下面的代码
如何异步地进行接受链接呢 -- 异步方式
BeginAccept(AsyncCallback callback, object state);
serverSocket.BeginAccept(AcceptCallback, serverSocket); // 开始异步等待链接 static void AcceptCallback(IAsyncResult ar) { // 异步接收的回调函数 Socket serverSocket = ar.AsyncState as Socket; Socket clientSocket = serverSocket.EndAccept(ar); byte[] data = Encoding.UTF8.GetBytes("....."); clientSocket.Send(data); clientSocket.BeginReceive(dataBuffer, 0, 1024, SocketFlags.None, ReceiveCallback, clientSocket); // 循环调用,不断接收 serverSocket.BeginAccept(AcceptCallback, serverSocket); }
此时,能启动多个客户端并与服务器端链接。
客户端链接的非正常关闭:
任务9中提到:
当客户端关闭时,会发现服务器端报错了: SocketException: 远程主机强迫关闭了一个现有的链接。
缘由是客户端窗口关闭时能够被视为非正常关闭,而服务器端执行clientSocket.BeginReceive()后调用EndReceive()接收消息时,客户端链接已不存在。
须要进行异常捕获处理
private static void ReceiveCallback(IAsyncResult ar) { Socket clientSocket = ar.AsyncState as Socket; try { int count = clientSocket.EndReceive(ar); string msg = Encoding.UTF8.GetString(buffer, 0, count); Console.WriteLine(msg); clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallback, clientSocket); } catch(Exception e) { Console.WriteLine(e); if(clientSocket != null) { clientSocket.Close(); } } finally { } }
抛出异常,则关闭链接
客户端链接的正常关闭:
假设在客户端中输入"c",则将socket关闭
string msg = Console.ReadLine(); if(msg == "c") { clientSocket.Close(); return; }
运行,会发现当客户端输入c执行socket.Close()后,服务器端不断接收到空数据,且没有报错
缘由:在服务器端的ReceiveCallback()中的EndReceive()会不断接收许多条空数据并继续BeginReceive()
即便客户端的链接已经断掉了
(对上面的缘由颇有疑惑)
解决方法:在服务器端判断EndReceive()返回值count的大小,若是count==0则关闭链接
if(count == 0) { clientSocket.Close(); return; }
粘包和分包是利用Socket在TCP协议下内部的优化机制
粘包和分包是因为内部的优化机制所致使的
包:每次调用Send()所传输的数据就能够算是一个包
粘包:发送数据很频繁,且每个数据包都很小时
频繁的发送是很耗费性能的,所以Tcp协议会在内部将多个数据包进行合并,产生一个粘包,在接收数据的终端用一条Receive()接收
一个Receive()接收到的数据极可能包含多个消息
分包:当发送的一个数据包的数据量特别大时,会拆分开来经过多个数据包进行发送。
由于若是这个数据量很大的包发送失败时,须要从新发送,浪费了性能;并且传输时占用的带宽也较大
一个Receive()接收到的数据极可能不是一个完整的消息
粘包和分包发送的数据
实例演示:
粘包:在客户端 利用for循环将i发送出去
在服务器端接收的次数远少于客户端发送的次数
粘包的大小不一样的缘由应该是客户端for循环运行的快慢致使的
在游戏开发中,粘包须要重点处理,由于游戏同步的数据(好比位置信息等)很符合被粘包数据的特征
分包:在客户端发送很大的数据包。
在服务器端的dataBuffer的长度会将该数据包进行分割。一个dataBuffer存放不下就会留给下一个buffer存放
解决方案思路:
给发送的数据添加一个前缀数据,用来表示该数据的长度。
在接收数据后解析数据时,经过读取表示数据长度的数据,获得实际数据。
若是实际获得的数据的长度大于数据长度,则解析出完整数据,并用相同方法解析下一个数据长度数据和实际数据
若是实际获得的数据的长度小于数据长度,则接收下一个数据包,直到接收够完整数据,再进行一次性解析
注意:表示数据长度的前缀数据,它自己的长度必须是固定的
插入题外话:如何将字符串或值类型(好比int)转换为byte[]字节数据
字符串是引用类型
1. 以前使用的方法是用UTF8编码格式将字符串转换为byte[]
byte[] data = System.Text.Encoding.UTF8.GetBytes("1a 中");
尝试输出该字节数组:49 97 32 228 184 173
其中49对应1,97为a的ascii码,32对应空格,以后三个字节对应的是一个汉字
那么,经过这种方法的转换为何不适用在表示数据长度的前缀数据上呢?
由于数字位数的不一样,会致使转换后的字节数不一样。
好比长度数据=4,转换后为一个字节;而长度数据=1000,则转换后为四个字节
2. 另外一种方法能够将值类型的数据转换为字节数据
int count = 1;
byte[] data = BitConverter.GetBytes(count);
输出data后,为四个字节 0 0 0 1, 由于int为Int32类型,占4个字节
即便count = 100000(只要不溢出Int32),都是4个字节
相对应的,BitConverter.ToInt32(data)能够将字节数据转换成int值
BitConverter中有不少方法,都是用来转换值类型的数据
解决方案实现:
客户端算出数据的长度,并将数据长度信息加到数据包前
public static byte[] GetDataBytesWithLengthInfo(string data) { // 获得data的字节数据 byte[] dataBytes = Encoding.UTF8.GetBytes(data); // 字节数据的长度 int dataLength = dataBytes.Length; // 长度信息的字节数据 byte[] lengthBytes = BitConverter.GetBytes(dataLength); // 合并数据 return lengthBytes.Concat(dataBytes).ToArray(); }
服务器端收到消息后,进行数据包的解析 -- 几条消息
用Message类实现相关功能
须要注意的地方:
1. 须要一个数组用来存储接收到的byte[]
Message.data
2. 须要一个flag来跟踪当前已经读取到的位置
Message.startIndex
3. 将存储的byte[]解析成消息
在Server中定义static Message msg = new Message();
接收数据的时候clientSocket.BeginReceive(msg.data, msg.startIndex, msg.RemainSize, SocketFlag.None, ReceiveCallback, clientSocket);
// data表示存储的byte[]; startIndex为接下来开始存储的位置,也表明已经存储了的字节数;
// RemainSize = data.Length-startIndex, 表示可存储的最大字节数,避免读取太多数据致使msg.data空间不足溢出
每读取一次完(EndReceive()),须要更新msg.startIndex += count;
读取完数据,开始解析:
1. 判断是否有足够数据以解析
if(startIndex <= 4) return; // 若是已经存储在data中的字节数据长度小于4,则没有存储数据(长度数据已经占了4个字节)
2. 数据长度 --
int length = BitConverter.ToInt32(data, 0); // 从0开始读取4个字节的数据,解析成长度数据
3. 判断是否有足够数据,没有的话等待下一次数据的读取,并须要再次调用本方法
if(startIndex - 4 >= length) {
4. 解析数据
Encoding.UTF8.ToString(data, 4, length); // 从4开始,读取出完整的一条数据,多余的不读取
5. 循环读取多条,直到读取完
startIndex -= (4 + length); // 更新startIndex
Array.Copy(data, 4 + count, 0, startIndex); // 删除已经解析完的数据 用while(true)进行循环,直到数据不足startIndex<=4或startIndex-4<length跳出循环