第1章: 学习内容概述
学习内容概述
第2章: 网络开发必备理论
第1节: 网络基本概念
概括
第2节: IP、端口、Mac地址
概括
对公网和私网的形容
公网和私网是类似以家族为集体交互的形式存在的。
家庭和社会:在这个比喻中,家庭可以被看作是一个私网(例如您的家庭Wi-Fi网络),而社会则相当于公网(互联网)。家庭内的成员(设备)在家庭内部相互交流,就像设备在私网内部通信一样。
家庭的面目(公网IP):家庭成员(设备)对外交流时使用的是家庭的面目,这就像私网中的所有设备对外使用同一个公网IP地址。这个公网IP地址是外界识别整个私网的方式。
信息的配发:当外部信息(数据)返回家庭(私网)时,家庭(通过路由器)决定将信息分配给哪个成员(设备)。这对应于路由器使用网络地址转换(NAT)和内部私网IP地址来确保数据正确地传递给私网中的特定设备。
内部交流:家庭成员之间的内部交流(私网内的设备通信)不需要对外展示家庭的面目(公网IP),它们可以直接通过各自的身份(私网IP地址)相互识别和交流。
ip地址和mac地址
早期局域网与MAC地址的角色:
在早期的局域网技术中,MAC地址作为设备间通信的重要标识符,主要应用于物理层和数据链路层。
尽管在局域网内设备可以通过MAC地址进行直接通信,但更高层的协议(如IP协议)也在网络通信中扮演着重要角色。
MAC地址的类比和具体作用:
MAC地址可以类比为一个人的身份证号,全球唯一,主要在物理层和数据链路层中使用。
在数据报文的发送过程中,首先添加源和目的IP地址,然后添加源和目的MAC地址。
对端接收到数据报文后,首先处理MAC地址,然后检查目的IP地址是否匹配,以决定是否保留数据报文。
IP地址和MAC地址在现代网络中的并存与作用:
IP地址作为逻辑层上的标识符,用于整个互联网范围内的设备识别和路由。
MAC地址作为物理层上的标识符,用于局部网络环境中设备的唯一标识。
两者在网络通信中共同工作,确保数据包不仅能够在全球范围内找到正确的目的地,也能在局部网络段内准确地传递到特定的物理设备。
公网与私网中MAC地址的不同应用:
在私网中,MAC地址用于直接识别和通信到特定的物理设备。
在公网中,数据包的MAC地址在每个网络跳点(如路由器)会更新,以反映下一个物理目标地址。
公网中MAC地址的转换:
公网传输过程中,数据包通过多个路由器和网络设备,其中MAC地址在每个设备处更新。
这表明公网中的数据传输虽然不直接使用最终用户设备的MAC地址,但每个网络段内部的传输仍然依赖于MAC地址。
IP地址的跨网络作用:
IP地址在公网和私网中都用于逻辑层上的设备识别和数据传输的路由。
它确保数据包能够跨越多个网络和子网,最终到达正确的目标网络和设备
端口
端口的基本概念:
端口是计算机网络通信中用于区分同一IP地址上不同服务或应用程序的软件级标识符。
它们允许单个主机(如个人计算机)上的多个服务(例如Web服务、电子邮件服务等)同时运行,且相互独立。
只有联网的软件需要占用端口。
端口与软件的关系:
在客户端-服务器模型中,客户端软件(如网页浏览器)通过特定端口与服务器软件(如Web服务器)进行通信。
应用程序选择监听特定的端口以接收某种类型的网络通信,例如HTTP通信通常使用端口80。
端口在设备中的应用:
每个网络设备上可以使用多个端口。
端口由设备的操作系统管理,而不是由设备单独拥有。
多个应用程序可以在同一设备上,使用不同的端口同时进行网络通信。
路由器与端口转发:
路由器通过端口转发或网络地址转换(NAT)功能将互联网流量定向到内网中的适当设备和服务。
当从互联网来的数据包到达路由器时,路由器根据数据包的目的IP地址和端口号进行适当转发。
私网IP地址和端口映射:
路由器分配给设备的是私网中的IP地址。
端口号是由设备上的应用程序或服务指定的,不是由路由器分配的。
路由器在进行NAT时,会把外部的IP地址和端口映射到内部网络的相应IP地址和端口,以保证正确的数据传输。
路由器
需要强调的是,虽然看起来家用路由器可以参与数据的传输,但是现实中绝对不会让用户之间的路由器相连,这是涉及隐私的问题。
在典型的家庭互联网设置中,每个家庭的路由器通常直接连接到互联网服务提供商(ISP)的网络,而不是通过其他家庭的路由器。这个连接通常是“星形”拓扑。
家用路由器:
数据包路由:
家用路由器负责将数据包从内网路由到互联网,以及反向过程。它选择数据包到达ISP的最佳路径,但不直接与其他家庭路由器交换数据。
网络地址转换(NAT):
路由器通过执行NAT,使多个内网设备共享同一个公网IP地址。NAT同时增加了网络的安全性,因为它隐藏了内网设备的真实IP地址。
安全和防火墙功能:
现代路由器通常具备防火墙功能,能够保护网络免受未授权的访问和网络攻击。
无线接入点:
多数家用路由器提供无线网络连接功能,使得家庭内的各种设备能够方便地接入互联网。
动态主机配置协议(DHCP):
路由器通常具备DHCP功能,用于自动在内网中分配IP地址给设备。
ISP或基站中的路由器:
这些路由器在网络的核心层处理大量数据流量,专注于数据包的高效路由和网络流量的管理。
通常不提供Wi-Fi服务,主要功能包括高性能的数据处理、复杂的路由决策、流量管理和安全防护。
设计用于高效和安全地处理网络通信,确保数据快速、安全且高效地在互联网各处流动。
第3节: 客户端和服务端
概括
第4节: 数据通信模型
布局架构
分散式模型:
数据和处理逻辑完全分散在各个独立的节点上。
每个节点独立运作,没有中心化的控制或协调机制。
主要应用于简单的网络或系统,其中节点间的互动较少。
集中式模型:
所有的数据处理和管理集中在单一的中心节点或服务器上。
易于管理和维护,但存在单点故障的风险。
适用于对实时性和一致性要求不高的应用。
分布式模型:
数据和处理逻辑在网络中多个节点上分散。
节点之间通过网络协作,共同完成任务。
提高了系统的可靠性、扩展性和容错能力。
适用于大型、复杂的系统,如云计算、大数据处理等。
通信模型
C/S(Client/Server)模型:
客户端(Client)和服务器(Server)之间的模型。
客户端发起请求,服务器响应请求并提供服务。
常见于许多网络应用,如电子邮件、网页浏览、在线游戏等。
B/S(Browser/Server)模型:
特定于Web应用的模型,客户端通常是Web浏览器。
浏览器作为客户端向服务器发起请求,服务器提供Web页面和相关服务。
适用于Web应用和服务,如在线购物、信息查询、社交网络等。
P2P(Peer-to-Peer)模型:
网络中的每个节点既是客户端又是服务器。
节点之间直接交换数据,无需中心化的服务器。
增强了网络的可扩展性和健壮性。
常见于文件共享、流媒体传输、区块链等应用。
游戏常用数据通信模型示例图
服务器端布局架构采用分布式,通信模型采用C/S。
第3章: 网络协议
第1节: 网络协议概述
概述
协议的字面意思: 经过谈判、协商而制定的共同承认、共同遵守的文件
网络协议的基本概念: 网络协议是计算机网络中进行数据交换而建立的规则、标准或约定的集合 指的是计算机网络中互相通信的对等实体之间交换信息时所必须遵守的规则的集合
说人话: 如果你想要在网络环境中进行通信,那么网络协议就是你必须遵守的规则
重要协议
OSI模型是网络通信的基本规则
TCP/IP协议是基于OSI模型的工业实现
说人话: OSI模型是国际组织定义的一套理论基础,主要用于定义网络通信的规则
TCP/IP协议是基于这套理论基础真正实现的通信规则
我们之后学习的网络通信API底层都是基于TCP/IP协议的
第2节: OSI模型
概述
除了物理层直接二进制化,其他每层加工数据的时候会封装或者解析头文件,方便传输。
详解
应用层(Application Layer):
最接近用户的层,为网络应用提供服务。
实际操作:提供接口供应用程序访问网络服务,如Web浏览器请求网页。
协作:将用户的网络请求传递给表示层,接收表示层处理过的数据。
表示层(Presentation Layer):
负责数据的格式化、编码、转换,确保应用层数据能够在网络上正确传输。
实际操作:数据加密、压缩和转换,如将文本文件从ASCII码转换为EBCDIC码。
协作:接收应用层数据进行处理,然后传递给会话层;从会话层接收数据并进行解码后传递给应用层。
会话层(Session Layer):
管理网络中的会话,控制数据交换的开始和结束。通常不直接参与数据传输。
实际操作:建立、管理和终止网络连接(会话)。
协作:在表示层和传输层之间建立、维护和终止连接。
传输层(Transport Layer):
提供可靠的端到端通信,确保数据完整性。
实际操作:在数据中加入头部信息,如端口号,进行差错检查和恢复。
协作:将会话层的数据分割成小段,确保每段可靠传输;接收来自网络层的数据包,重新组装成完整的数据传递给会话层。
网络层(Network Layer):
负责数据包的路由和转发。
实际操作:在数据包中加入网络地址信息,如IP地址。
协作:接收传输层的数据段,加入路由信息后转发;从数据链路层接收数据,确定目标地址并传递给传输层。
数据链路层(Data Link Layer):
在物理地址间传输数据,确保无错传输。
实际操作:在数据帧中加入物理地址信息,如MAC地址。
协作:将网络层的数据包封装成帧,进行物理地址寻址和差错检测;从物理层接收信号,转换为数据帧后传递给网络层。
物理层(Physical Layer):
负责原始数据的传输,处理物理设备和传输介质。
实际操作:将数据转换为电信号、光信号或无线信号进行传输。
协作:接收来自数据链路层的数据帧,转换为适合传输介质的信号;接收物理信号,转换为数据帧后传递给数据链路层。
详细的说法:
应用层:为应用程序或用户请求提供各种请求服务。OSI参考模型最高层,也是最靠近用户的一层,为计算机用户、各种应用程序以及网络提供接口,也为用户直接提供各种网络服务。
表示层:数据编码、格式转换、数据加密。提供各种用于应用层数据的编码和转换功能,确保一个系统的应用层发送的数据能被另一个系统的应用层识别。如果必要,该层可提供一种标准表示形式,用于将计算机内部的多种数据格式转换成通信中采用的标准表示形式。数据压缩和加密也是表示层可提供的转换功能之一。
会话层:创建、管理和维护会话。接收来自传输层的数据,负责建立、管理和终止表示层实体之间的通信会话,支持它们之间的数据交换。该层的通信由不同设备中的应用程序之间的服务请求和响应组成。
传输层:数据通信。建立主机端到端的链接,为会话层和网络层提供端到端可靠的和透明的数据传输服务,确保数据能完整的传输到网络层。
网络层:IP选址及路由选择。通过路由选择算法,为报文或通信子网选择最适当的路径。控制数据链路层与传输层之间的信息转发,建立、维持和终止网络的连接。数据链路层的数据在这一层被转换为数据包,然后通过路径选择、分段组合、顺序、进/出路由等控制,将信息从一个网络设备传送到另一个网络设备。
数据链路层:提供介质访问和链路管理。接收来自物理层的位流形式的数据,封装成帧,传送到网络层;将网络层的数据帧,拆装为位流形式的数据转发到物理层;负责建立和管理节点间的链路,通过各种控制协议,将有差错的物理信道变为无差错的、能可靠传输数据帧的数据链路。
物理层:管理通信设备和网络媒体之间的互联互通。传输介质为数据链路层提供物理连接,实现比特流的透明传输。实现相邻计算机节点之间比特流的透明传送,屏蔽具体传输介质和物理设备的差异。
第3节: TCP/IP协议
TCP\IP协议
概述
职能与头文件封装
对应协议
详解
TCP/IP协议模型通常被描述为拥有四层结构,这与OSI七层模型有所不同。TCP/IP模型并不是直接省略了OSI模型中的某些层,而是将一些功能合并到了较少的层中。简单来说,TCP/IP模型是一种更简化且实用的网络协议架构。下面是TCP/IP模型与OSI模型层次的对应关系:
应用层:
TCP/IP模型的应用层相当于OSI模型的应用层、表示层和会话层的结合。
它处理所有高级协议、表示和会话管理问题。
传输层:
与OSI模型的传输层基本一致。
负责端到端的通信和数据流控制,主要使用TCP(传输控制协议)和UDP(用户数据报协议)。
网络层:
也与OSI模型的网络层相匹配。
负责数据包的路由和寻址,主要协议是IP(互联网协议)。
网络接口层或链路层:
结合了OSI模型的数据链路层和物理层的功能。
负责数据的物理传输,包括数据的封装、物理地址寻址以及与物理网络的接口。
TCP和UDP
TCP/IP协议中的重要协议
应用层协议: HTTP协议:超文本传输协议 HTTPS协议:加密的超文本传输协议 FTP协议:文件传输协议 DNS:域名系统
传输层协议: TCP协议:传输控制协议 UDP协议:用户数据报协议
网络层协议: IP协议
本节重点讲述传输层协议:TCP与UDP
传输层协议的区别
TCP协议
概述
TCP(Transmission Control Protocol,传输控制协议)
是面向连接的协议,也就是说,在收发数据前,必须和对方建立可靠的连接
并且在消息传送过程中是有顺序的,并且是不会丢包(丢弃消息)的
如果某一条消息在传送过程中失败了,会重新发送消息,直到成功
它的特点是:
面向连接 —— 两者之间必须建立可靠的连接
一对一 —— 只能是1对1的建立连接
可靠性高 —— 消息传送失败会重新发送,不允许丢包
有序的 —— 是按照顺序进行消息发送
连接形式——三次握手,四次挥手
TCP有了这三次握手,四次挥手的规则
可以提供可靠的服务,通过TCP连接传送的数据
可以做到无差错、不丢失、不重复、且按顺序到达
它让服务器和客户端之间的数据传递变得更加的可靠
UDP协议
UDP(User Datagram Protocol,用户数据报协议)
是一种无需建立连接就可以发送封装的IP数据包的方法
提供面向事务的简单不可靠信息传送服务
它的特点是:
无连接 —— 两者之间无需建立连接
可靠性低 —— 消息可能在传送过程中丢失,丢失后不会重发
传输效率高 —— 由于它的可靠性低并且也无需建立连接,所以传输效率上更高一些
n对n —— TCP只能1对1连接进行消息传递,而UDP由于无连接所以可以n对n
UDP协议不像TCP协议需要建立连接有三次握手和四次挥手
当使用UDP协议发送信息时
会直接把信息数据扔到网络上,所以也就造成了UDP的不可靠性
信息在这个传递过程中是有可能丢失的
虽然UDP是一个不靠谱的协议,但是由于它不需要建立连接
也不会像TCP协议那样携带更多的信息,所以它具有更好的传输效率
它具有资源消耗小,处理速度快的特点
第4章: 网络通信—Socket—TCP
第1节: 网络游戏通信方案概述
网络游戏通信方案概述
弱联网和强联网游戏
弱联网游戏:
这种游戏不会频繁的进行数据通信,客户端和服务端之间每次连接只处理一次请求,服务端处理完客户端的请求后返回数据后就断开连接了
强联网游戏:
这种游戏会频繁的和服务端进行通信,会一直和服务端保持连接状态,不停的和服务器之间交换数据
弱联网游戏代表:
一般的三消类休闲游戏、卡牌游戏等都会是弱联网游戏,这些游戏的核心玩法都由客户端完成,客户端处理完成后只是告诉服务端一个结果,服务端验证结果即可,不需要随时通信
比如:开心消消乐、刀塔传奇、我叫MT等等
强联网游戏代表:
一般的MMORPG(角色扮演)、MOBA(多人在线竞技游戏)、ACT(动作游戏)等等都会是强联网游戏,这些游戏的部分核心逻辑是由服务端进行处理,客户端和服务端之间不停的在同步信息
比如:王者荣耀、守望先锋、和平精英等等
长连接和短连接游戏
长连接和短连接游戏是按照网络游戏通信特点来划分的
我们甚至可以认为
弱联网游戏——>短连接游戏
强联网游戏——>长连接游戏
短连接游戏:
需要传输数据时,建立连接,传输数据,获得响应,断开连接
通信特点:需要通信时再连接,通信完毕断开连接
通信方式:HTTP超文本传输协议、HTTPS安全的超文本传输协议(他们本质上是TCP协议)
长连接游戏:
不管是否需要传输数据,客户端与服务器一直处于连接状态,除非一端主动断开,或
者出现意外情况(客户端关闭或服务端崩溃等)
通信特点:连接一直建立,可以实时的传输数据
通信方式:TCP传输控制协议 或 UDP用户数据报协议
Socket、HTTP、FTP
Socket
网络套接字,是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象,一个套接字就是网
络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制
我们之后主要要学习的就是Socket网络套接字当中的各种API来进行网络通信
主要用于制作长连接游戏(强联网游戏)
Http/Https
(安全的)超文本传输协议,是一个简单的请求-响应协议,它通常运行在TCP协议之上,它指定了
客户端可能发送给服务端什么样的信息以及得到什么样的响应。
主要用于制作短连接游戏(弱联网游戏),也可以用来进行资源下载
FTP:
文件传输协议,是用于在网络上进行文件传输的一套标准协议,可以利用它来进行网络上资源的下载和
上传。它也是基于TCP的传输,是面向连接的,为文件传输提供了可靠的保证
第2节: 通信前的必备知识—Ip地址和域名解析
IP地址和端口类
知识点一 IP类和端口类用来干什么?
通过之前的理论知识学习
我们知道想要进行网络通信,进行网络连接
首先我们需要找到对应设备,IP和端口号是定位网络中设备必不可少的关键元素
C#中提供了对应的IP和端口相关的类 来声明对应信息
对于之后的网络通信是必不可少的内容
知识点二 IPAddress类
命名空间:System.Net;
类名:IPAddress
初始化IP信息的方式
1.用byte数组进行初始化
byte[] ipAddress = new byte[] { 118, 102, 111, 11 };
IPAddress ip1 = new IPAddress(ipAddress);
2.用long长整型进行初始化
4字节对应的长整型 一般不建议大家使用,这里我们用十六位来简化书写的内容
IPAddress ip2 = new IPAddress(0x79666F0B);
3.推荐使用的方式 使用字符串转换
IPAddress ip3 = IPAddress.Parse("118.102.111.11");
特殊IP地址
127.0.0.1代表本机地址
获取可用的IPv6地址
IPAddress.IPv6Any
知识点三 IPEndPoint类
命名空间:System.Net;
类名:IPEndPoint
IPEndPoint类将网络端点表示为IP地址和端口号,表现为IP地址和端口号的组合
初始化方式
IPEndPoint ipPoint = new IPEndPoint(0x79666F0B, 8080);
IPEndPoint ipPoint2 = new IPEndPoint(IPAddress.Parse("118.102.111.11"), 8080);
总结
程序表示IP信息
IPAddress ip = IPAddress.Parse("IPv4地址");
·程序表示通信目标(IP结合端口)
IPEndPoint point = new IPEndPoint(ip, 8080);
域名解析
知识点一 什么是域名解析?
域名解析也叫域名指向、服务器设置、域名配置以及反向IP登记等等
说得简单点就是将好记的域名解析成IP
IP地址是网络上标识站点的数字地址,但是IP地址相对来说记忆困难
所以为了方便记忆,采用域名来代替IP地址标识站点地址。
比如 我们要登录一个网页 www.baidu.com 这个就是域名 我们可以通过记忆域名来记忆一个远端服务器的地址,而不是记录一个复杂的IP地址
域名解析就是域名到IP地址的转换过程。域名的解析工作由DNS服务器完成
我们在进行通信时有时会有需求通过域名获取IP
所以这节课我们就来学习C#提供的域名解析相关的类
域名系统(英文:Domain Name System,缩写:DNS)是互联网的一项服务
它作为将域名和IP地址相互映射的一个分布式数据库,能够使人更方便地访问互联网
是因特网上解决网上机器命名的一种系统,因为IP地址记忆不方便,就采用了域名系统来管理名字和IP的对应关系
知识点二 IPHostEntry类
命名空间:System.Net
类名:IPHostEntry
主要作用:域名解析后的返回值 可以通过该对象获取IP地址、主机名等等信息
该类不会自己声明,都是作为某些方法的返回值返回信息,我们主要通过该类对象获取返回的信息
获取关联IP 成员变量:AddressList
获取主机别名列表 成员变量:Aliases
获取DNS名称 成员变量:HostName
知识点三 Dns类
命名空间:System.Net
类名:Dns
主要作用:Dns是一个静态类,提供了很多静态方法,可以使用它来根据域名获取IP地址
常用方法
1.获取本地系统的主机名
print(Dns.GetHostName());
2.获取指定域名的IP信息
根据域名获取
同步获取
注意:由于获取远程主机信息是需要进行网路通信,所以可能会阻塞主线程
IPHostEntry entry = Dns.GetHostEntry("www.baidu.com");
for (int i = 0; i < entry.AddressList.Length; i++)
{
print("IP地址:" + entry.AddressList[i]);
}
for (int i = 0; i < entry.Aliases.Length; i++)
{
print("主机别名" + entry.Aliases[i]);
}
print("DNS服务器名称" + entry.HostName);
异步获取
GetHostEntry();
总结
如果你不知道对方的IP地址,想通过域名和对方进行通信
我们可以通过Dns类通过域名得到IP地址后再和对方建立连接并通信
第3节: 通信前的必备知识—序列化和反序列化2进制数据
2进制序列化反序列化概述
概述
我们进行网络通信的目的是要传输数据
所以我们在正式学习网络通信之前
要先有二进制数据序列化和反序列化的知识储备
这样才能在讲解网络通信时,轻松应对数据收发相关的需求
网络通信中传输的数据
在网络通信中
我们把想要传递的类对象信息序列化为2进制数据(一般为byte字节数组)
再将该2进制数据通过网络传输给远端设备
远端设备获取到该2进制数据后再将其反序列化为对应的类对象
序列化和反序列化
序列化:
将类对象信息转换为可保存或传输的格式的过程
反序列化:
与序列化相对,将保存或传输过来的格式转换为类对象的过程
我们之前学习的数据持久化四部曲就涉及了相关知识
数据持久化2进制知识点
和网络通信相关的重要知识点(会在网络通信中频繁使用):
BitConverter类:主要用于处理各类型和字节数组间的相互转换
Encoding类:主要用于处理字符串类型和字节数组间的相互转换
加密相关:了解2进制数据加密的常用手段和思路
本地持久化知识点:
File类:文件操作类,用于操作文件
FileStream类:文件流类,以流的形式进行文件存储读取操作
MemoryStrem:内存流对象
BinaryFormatter:2进制格式化对象
网络二进制转换的问题
在2进制知识点我们讲解了BinaryFormatter类
它可以快速的将C#类对象转换为字节数组数据
但是在网络开发时,我们不会使用BinaryFormatter类来进行数据的序列化和反序列化
因为客户端和服务端的开发语言大多数情况下是不同的
BinaryFormatter类序列化的数据无法兼容其它语言
总结
网络通信中传输数据的序列化和反序列化是非常重要的
在讲解网络通信之前
我们必须要掌握
将类对象 序列化为2进制数据
将2进制数据反序列化为 类对象
前置知识——字符编码(ASCII、Unicode、UTF-8)
Unity网络通讯的二进制
知识点一 非字符串类型转字节数组
关键类:BitConverter
所在命名空间:System
主要作用:除字符串的其它常用类型和字节数组相互转换
byte[] bytes = BitConverter.GetBytes(1);
知识点二 字符串类型转字节数组
关键类:Encoding
所在命名空间:System.Text
主要作用:将字符串类型和字节数组相互转换,并且决定转换时使用的字符编码类型,网络通信时建议大家使用UTF-8类型
byte[] byte2 = Encoding.UTF8.GetBytes("字符串内容");
知识点三 如何将一个类对象转换为二进制
注意:网络通信中我们不能直接使用数据持久化2进制知识点中的
BinaryFormatter 2进制格式化类
因为客户端和服务器使用的语言可能不一样,BinaryFormatter是C#的序列化规则,和其它语言之间的兼容性不好
如果使用它,那么其它语言开发的服务器无法对其进行反序列化
我们需要自己来处理将类对象数据序列化为字节数组
单纯的转换一个变量为字节数组非常的简单
但是我们如何将一个类对象携带的所有信息放入到一个字节数组中呢
我们需要做以下几步
1.明确字节数组的容量(注意:字符串和其他变量不同,传入的时候需要先传入字符串长度int的二进制数据,然后再传入字符串本身的二进制数据,以方便接收方识别字符串占用的二进制长度)
PlayerInfo info = new PlayerInfo();//这是我们创建的一个只有变量的类
info.lev = 10;
info.name = "唐老狮";
info.atk = 88;
info.sex = false;
得到的 这个Info数据 如果转换成 字节数组 那么字节数组容器需要的容量
int indexNum = sizeof(int) + //lev int类型 4
sizeof(int) + //代表 name字符串转换成字节数组后 数组的长度 4
Encoding.UTF8.GetBytes(info.name).Length + //字符串具体字节数组的长度
sizeof(short) + //atk short类型 2
sizeof(bool); //sex bool类型 1
2.申明一个装载信息的字节数组容器
byte[] playerBytes = new byte[indexNum];
3.将对象中的所有信息转为字节数组并放入该容器当中(可以利用数组中的CopeTo方法转存字节数组)
CopyTo方法的第二个参数代表 从容器的第几个位置开始存储
int index = 0;//从 playerBytes数组中的第几个位置去存储数据
//等级
BitConverter.GetBytes(info.lev).CopyTo(playerBytes, index);
index += sizeof(int);
//姓名
byte[] strBytes = Encoding.UTF8.GetBytes(info.name);
int num = strBytes.Length;
//存储的是姓名转换成字节数组后 字节数组的长度
BitConverter.GetBytes(num).CopyTo(playerBytes, index);
index += sizeof(int);
//存储字符串的字节数组
strBytes.CopyTo(playerBytes, index);
index += num;
//攻击力
BitConverter.GetBytes(info.atk).CopyTo(playerBytes, index);
index += sizeof(short);
//性别
BitConverter.GetBytes(info.sex).CopyTo(playerBytes, index);
index += sizeof(bool);
总结
我们对类对象的2进制序列化主要用到的知识点是
1.BitConverter转换非字符串的类型的变量为字节数组
2.Encoding.UTF8转换字符串类型的变量为字节数组(注意:为了考虑反序列化,我们在转存2进制,序列化字符串之前,先序列化字符串字节数组的长度)
转换流程是
1.明确字节数组的容量
2.申明一个装载信息的字节数组容器
3.将对象中的所有信息转为字节数组并放入该容器当中(利用数组中的CopeTo方法转存字节数组)
网络通信二进制的基类
概括
从上一节我们看得出来,网络通信的二进制写起来是比较繁琐的,但是却有大量重复的行为,为此,我们不如干脆写一个针对网络通信的二进制的一个基类供需要二进制的子类继承。
代码
BaseData:供需要二进制的子类继承,子类需要重写两个抽象方法以规定需要序列化的变量和字节数组长度。
可以用泛型或者反射进一步优化:
public abstract class BaseData
{
/// <summary>
/// 用于子类重写的 获取字节数组容器大小的方法
/// </summary>
/// <returns></returns>
public abstract int GetBytesNum();
/// <summary>
/// 把成员变量 序列化为 对应的字节数组
/// </summary>
/// <returns></returns>
public abstract byte[] Writing();
/// <summary>
/// 存储int类型变量到指定的字节数组当中
/// </summary>
/// <param name="bytes">指定字节数组</param>
/// <param name="value">具体的int值</param>
/// <param name="index">每次存储后用于记录当前索引位置的变量</param>
protected void WriteInt(byte[] bytes, int value, ref int index)
{
BitConverter.GetBytes(value).CopyTo(bytes, index);
index += sizeof(int);
}
protected void WriteShort(byte[] bytes, short value, ref int index)
{
BitConverter.GetBytes(value).CopyTo(bytes, index);
index += sizeof(short);
}
protected void WriteLong(byte[] bytes, long value, ref int index)
{
BitConverter.GetBytes(value).CopyTo(bytes, index);
index += sizeof(long);
}
protected void WriteFloat(byte[] bytes, float value, ref int index)
{
BitConverter.GetBytes(value).CopyTo(bytes, index);
index += sizeof(float);
}
protected void WriteByte(byte[] bytes, byte value, ref int index)
{
bytes[index] = value;
index += sizeof(byte);
}
protected void WriteBool(byte[] bytes, bool value, ref int index)
{
BitConverter.GetBytes(value).CopyTo(bytes, index);
index += sizeof(bool);
}
protected void WriteString(byte[] bytes, string value, ref int index)
{
//先存储string字节数组的长度
byte[] strBytes = Encoding.UTF8.GetBytes(value);
//BitConverter.GetBytes(strBytes.Length).CopyTo(bytes, index);
//index += sizeof(int);
WriteInt(bytes, strBytes.Length, ref index);
//再存 string字节数组
strBytes.CopyTo(bytes, index);
index += strBytes.Length;
}
protected void WriteData(byte[] bytes, BaseData data, ref int index)
{
data.Writing().CopyTo(bytes, index);
index += data.GetBytesNum();
}
}
需要被二进制的信息类TestInfo:
public class TestInfo : BaseData
{
public short lev;
public Player p;
public int hp;
public string name;
public bool sex;
public override int GetBytesNum()
{
return sizeof(short) +//2
p.GetBytesNum() + //4
sizeof(int) +//4
4 + Encoding.UTF8.GetBytes(name).Length + //4+n
sizeof(bool);//1
}
public override byte[] Writing()
{
int index = 0;
byte[] bytes = new byte[GetBytesNum()];
WriteShort(bytes, lev, ref index);
WriteData(bytes, p, ref index);
WriteInt(bytes, hp, ref index);
WriteString(bytes, name, ref index);
WriteBool(bytes, sex, ref index);
return bytes;
}
}
使用例:
public class Test : MonoBehaviour
{
void Start()
{
TestInfo info = new TestInfo();
info.lev = 87;
info.p = new Player();
info.p.atk = 77;
info.hp = 100;
info.name = "唐老狮";
info.sex = false;
byte[] bytes = info.Writing();
print(sizeof(bool));
}
}
网络通信二进制的反序列化
知识点一 字节数组转非字符串类型
关键类:BitConverter
所在命名空间:System
主要作用:除字符串的其它常用类型和字节数组相互转换
byte[] bytes = BitConverter.GetBytes(99);
int i = BitConverter.ToInt32(bytes, 0);
print(i);
知识点二 字节数组转字符串类型
关键类:Encoding
所在命名空间:System.Text
主要作用:将字符串类型和字节数组相互转换,并且决定转换时使用的字符编码类型,网络通信时建议大家使用UTF-8类型
byte[] bytes2 = Encoding.UTF8.GetBytes("转换的字符串");
string str = Encoding.UTF8.GetString(bytes2, 0, bytes2.Length);
print(str);
知识点三 如何将二进制数据转为一个类对象
1.获取到对应的字节数组
PlayerInfo info = new PlayerInfo();
info.lev = 10;
info.name = "唐老狮";
info.atk = 88;
info.sex = false;
byte[] playerBytes = info.GetBytes();
2.将字节数组按照序列化时的顺序进行反序列化(将对应字节分组转换为对应类型变量)
每次序列化的时候需要在第二参数填入开始反序列化的字节位置,String还需要填入长度参数,所以我们之前二进制字符串的时候需要先传入长度。
PlayerInfo info2 = new PlayerInfo();
//等级
int index = 0;
info2.lev = BitConverter.ToInt32(playerBytes, index);
index += 4;
print(info2.lev);
//姓名的长度
int length = BitConverter.ToInt32(playerBytes, index);
index += 4;
//姓名字符串
info2.name = Encoding.UTF8.GetString(playerBytes, index, length);
index += length;
print(info2.name);
//攻击力
info2.atk = BitConverter.ToInt16(playerBytes, index);
index += 2;
print(info2.atk);
//性别
info2.sex = BitConverter.ToBoolean(playerBytes, index);
index += 1;
print(info2.sex);
总结
我们对类对象的2进制反序列化主要用到的知识点是
BitConverter转换字节数组为非字符串的类型的变量
Encoding.UTF8转换字节数组为字符串类型的变量(注意:先读长度,再读字符串)
转换流程是
获取到对应的字节数组
将字节数组按照序列化时的顺序进行反序列化(将对应字节分组转换为对应类型变量)
网络通信二进制的基类完整版
概述
对上上节的基类加上了反序列化的能力,此基类将同时拥有序列化和反序列化的能力。
代码
BaseData:新增的Reading方法和Writing方法有所不同——当反序列化的时候,Reading是需要先获得读取字节位置的。
注意:下面的写法代码格式是有点不统一的,Reading的返回值int(ReadData<T>里的index),可以去掉,再写一个GetBytesNum同类的方法来专门处理反序列化的字节长度,然后index+=Value.GetBytesNum即可让两者的风格接近
public abstract class BaseData
{
/// <summary>
/// 用于子类重写的 获取字节数组容器大小的方法
/// </summary>
/// <returns></returns>
public abstract int GetBytesNum();
/// <summary>
/// 把成员变量 序列化为 对应的字节数组
/// </summary>
/// <returns></returns>
public abstract byte[] Writing();
/// <summary>
/// 把2进制字节数组 反序列化到 成员变量当中
/// </summary>
/// <param name="bytes">反序列化使用的字节数组</param>
/// <param name="beginIndex">从该字节数组的第几个位置开始解析 默认是0</param>
public abstract int Reading(byte[] bytes, int beginIndex = 0);
/// <summary>
/// 存储int类型变量到指定的字节数组当中
/// </summary>
/// <param name="bytes">指定字节数组</param>
/// <param name="value">具体的int值</param>
/// <param name="index">每次存储后用于记录当前索引位置的变量</param>
protected void WriteInt(byte[] bytes, int value, ref int index)
{
BitConverter.GetBytes(value).CopyTo(bytes, index);
index += sizeof(int);
}
protected void WriteShort(byte[] bytes, short value, ref int index)
{
BitConverter.GetBytes(value).CopyTo(bytes, index);
index += sizeof(short);
}
protected void WriteLong(byte[] bytes, long value, ref int index)
{
BitConverter.GetBytes(value).CopyTo(bytes, index);
index += sizeof(long);
}
protected void WriteFloat(byte[] bytes, float value, ref int index)
{
BitConverter.GetBytes(value).CopyTo(bytes, index);
index += sizeof(float);
}
protected void WriteByte(byte[] bytes, byte value, ref int index)
{
bytes[index] = value;
index += sizeof(byte);
}
protected void WriteBool(byte[] bytes, bool value, ref int index)
{
BitConverter.GetBytes(value).CopyTo(bytes, index);
index += sizeof(bool);
}
protected void WriteString(byte[] bytes, string value, ref int index)
{
//先存储string字节数组的长度
byte[] strBytes = Encoding.UTF8.GetBytes(value);
//BitConverter.GetBytes(strBytes.Length).CopyTo(bytes, index);
//index += sizeof(int);
WriteInt(bytes, strBytes.Length, ref index);
//再存 string字节数组
strBytes.CopyTo(bytes, index);
index += strBytes.Length;
}
protected void WriteData(byte[] bytes, BaseData data, ref int index)
{
data.Writing().CopyTo(bytes, index);
index += data.GetBytesNum();
}
/// <summary>
/// 根据字节数组 读取整形
/// </summary>
/// <param name="bytes">字节数组</param>
/// <param name="index">开始读取的索引数</param>
/// <returns></returns>
protected int ReadInt(byte[] bytes, ref int index)
{
int value = BitConverter.ToInt32(bytes, index);
index += sizeof(int);
return value;
}
protected short ReadShort(byte[] bytes, ref int index)
{
short value = BitConverter.ToInt16(bytes, index);
index += sizeof(short);
return value;
}
protected long ReadLong(byte[] bytes, ref int index)
{
long value = BitConverter.ToInt64(bytes, index);
index += sizeof(long);
return value;
}
protected float ReadFloat(byte[] bytes, ref int index)
{
float value = BitConverter.ToSingle(bytes, index);
index += sizeof(float);
return value;
}
protected byte ReadByte(byte[] bytes, ref int index)
{
byte value = bytes[index];
index += sizeof(byte);
return value;
}
protected bool ReadBool(byte[] bytes, ref int index)
{
bool value = BitConverter.ToBoolean(bytes, index);
index += sizeof(bool);
return value;
}
protected string ReadString(byte[] bytes, ref int index)
{
//首先读取长度
int length = ReadInt(bytes, ref index);
//再读取string
string value = Encoding.UTF8.GetString(bytes, index, length);
index += length;
return value;
}
protected T ReadData<T>(byte[] bytes, ref int index) where T:BaseData,new()
{
T value = new T();
index += value.Reading(bytes, index);
return value;
}
}
实战:
public class TestInfo : BaseData
{
public short lev;
public Player p;
public int hp;
public string name;
public bool sex;
public override int GetBytesNum()
{
return sizeof(short) +//2
p.GetBytesNum() + //4
sizeof(int) +//4
4 + Encoding.UTF8.GetBytes(name).Length + //4+n
sizeof(bool);//1
}
public override byte[] Writing()
{
int index = 0;
byte[] bytes = new byte[GetBytesNum()];
WriteShort(bytes, lev, ref index);
WriteData(bytes, p, ref index);
WriteInt(bytes, hp, ref index);
WriteString(bytes, name, ref index);
WriteBool(bytes, sex, ref index);
//序列化list的长度是多少
//在循环这个list保存对应的类型
return bytes;
}
public override int Reading(byte[] bytes, int beginIndex = 0)
{
int index = beginIndex;
lev = ReadShort(bytes, ref index);//0
p = ReadData<Player>(bytes, ref index);//2
hp = ReadInt(bytes, ref index);//6
name = ReadString(bytes, ref index);//10
sex = ReadBool(bytes, ref index);//17
//反序列化出list的长度
//循环反序列化对应的内容
return index - beginIndex;
}
}
public class Player : BaseData
{
public int atk;
public override int GetBytesNum()
{
return 4;
}
public override byte[] Writing()
{
int index = 0;
byte[] bytes = new byte[GetBytesNum()];
WriteInt(bytes, atk, ref index);
return bytes;
}
public override int Reading(byte[] bytes, int beginIndex = 0)
{
int index = beginIndex;
atk = ReadInt(bytes, ref index);
return index - beginIndex;
}
}
public class Test : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
TestInfo info = new TestInfo();
info.lev = 87;
info.p = new Player();
info.p.atk = 77;
info.hp = 100;
info.name = "唐老狮";
info.sex = false;
byte[] bytes = info.Writing();
TestInfo info2 = new TestInfo();
info2.Reading(bytes);
print(info2.lev);
print(info2.p.atk);
print(info2.hp);
print(info2.name);
print(info2.sex);
}
}
第4节: TCP通信—同步
Socket的重要API
知识点一 Socket套接字的作用
它是C#提供给我们用于网络通信的一个类(在其它语言当中也有对应的Socket类)
类名:Socket
命名空间:System.Net.Sockets
Socket套接字是支持TCP/IP网络通信的基本操作单位
一个套接字对象包含以下关键信息
本机的IP地址和端口
对方主机的IP地址和端口
双方通信的协议信息
一个Sccket对象表示一个本地或者远程套接字信息
它可以被视为一个数据通道
这个通道连接与客户端和服务端之间
数据的发送和接受均通过这个通道进行
一般在制作长连接游戏时,我们会使用Socket套接字作为我们的通信方案
我们通过它连接客户端和服务端,通过它来收发消息
你可以把它抽象的想象成一根管子,插在客户端和服务端应用程序上,通过这个管子来传递交换信息
知识点二 Socket的类型
Socket套接字有3种不同的类型
1.流套接字
主要用于实现TCP通信,提供了面向连接、可靠的、有序的、数据无差错且无重复的数据传输服务
2.数据报套接字
主要用于实现UDP通信,提供了无连接的通信服务,数据包的长度不能大于32KB,不提供正确性检查,不保证顺序,可能出现重发、丢失等情况
3.原始套接字(不常用,不深入讲解)
主要用于实现IP数据包通信,用于直接访问协议的较低层,常用于侦听和分析数据包
通过Socket的构造函数 我们可以申明不同类型的套接字
Socket s = new Socket()
参数一:AddressFamily 网络寻址 枚举类型,决定寻址方案
常用:
InterNetwork IPv4寻址
InterNetwork6 IPv6寻址
做了解:
UNIX UNIX本地到主机地址
ImpLink ARPANETIMP地址
Ipx IPX或SPX地址
Iso ISO协议的地址
Osi OSI协议的地址
NetBios NetBios地址
Atm 本机ATM服务地址
参数二:SocketType 套接字枚举类型,决定使用的套接字类型
常用:
Dgram 支持数据报,最大长度固定的无连接、不可靠的消息(主要用于UDP通信)
Stream 支持可靠、双向、基于连接的字节流(主要用于TCP通信)
做了解:
Raw 支持对基础传输协议的访问
Rdm 支持无连接、面向消息、以可靠方式发送的消息
Seqpacket 提供排序字节流的面向连接且可靠的双向传输
参数三:ProtocolType 协议类型枚举类型,决定套接字使用的通信协议
常用:
TCP TCP传输控制协议
UDP UDP用户数据报协议
做了解:
IP IP网际协议
Icmp Icmp网际消息控制协议
Igmp Igmp网际组管理协议
Ggp 网关到网关协议
IPv4 Internet协议版本4
Pup PARC通用数据包协议
Idp Internet数据报协议
Raw 原始IP数据包协议
Ipx Internet数据包交换协议
Spx 顺序包交换协议
IcmpV6 用于IPv6的Internet控制消息协议
2、3参数的常用搭配:
SocketType.Dgram + ProtocolType.Udp = UDP协议通信(常用,主要学习)
SocketType.Stream + ProtocolType.Tcp = TCP协议通信(常用,主要学习)
SocketType.Raw + ProtocolType.Icmp = Internet控制报文协议(了解)
SocketType.Raw + ProtocolType.Raw = 简单的IP包通信(了解)
我们必须掌握的
TCP流套接字
Socket socketTcp = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
UDP数据报套接字
Socket socketUdp = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
知识点三 Socket的常用属性
1.套接字的连接状态
if(socketTcp.Connected)
{
}
2.获取套接字的类型
print(socketTcp.SocketType);
3.获取套接字的协议类型
print(socketTcp.ProtocolType);
4.获取套接字的寻址方案
print(socketTcp.AddressFamily);
5.从网络中获取准备读取的数据数据量
print(socketTcp.Available);
6.获取本机EndPoint对象(注意 :IPEndPoint继承EndPoint)
socketTcp.LocalEndPoint as IPEndPoint
7.获取远程EndPoint对象
socketTcp.RemoteEndPoint as IPEndPoint
知识点四 Socket的常用方法
1.主要用于服务端
1-1:绑定IP和端口
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
socketTcp.Bind(ipPoint);
1-2:设置客户端连接的最大数量
socketTcp.Listen(10);
1-3:等待客户端连入
socketTcp.Accept();
2.主要用于客户端
1-1:连接远程服务端
socketTcp.Connect(IPAddress.Parse("118.12.123.11"), 8080);
3.客户端服务端都会用的
1-1:同步发送和接收数据
1-2:异步发送和接收数据
1-3:释放连接并关闭Socket,先与Close调用
socketTcp.Shutdown(SocketShutdown.Both);
1-4:关闭连接,释放所有Socket关联资源
socketTcp.Close();
总结
这节课我们只是对Socket有一个大体的认识
主要要建立的概念就是
TCP和UDP两种长连接通信方案都是基于Socket套接字的
我们之后只需要使用其中的各种方法,就可以进行网络连接和网络通信了
这节课必须掌握的内容就是如何声明TCP和UDP的Socket套接字
TCP通信概述
服务端和客户端需要做什么
TCP协议三次握手的体现
TCP协议四次挥手的体现
总结
TCP协议的三次握手四次握手已经被封装进了API。
同步TCP通信—同步实现服务端和客户端
概述
本节课将初步实现TCP通信,使用同步的方式,分为服务端和客户端,服务端位于Unity上运行,客户端直接用C#项目来运行。
服务端概述
服务端需要做的事情
创建套接字Socket
用Bind方法将套接字与本地地址绑定
用Listen方法监听
用Accept方法等待客户端连接
建立连接,Accept返回新套接字
用Send和Receive相关方法收发数据
用Shutdown方法释放连接
关闭套接字
总结
服务端开启的流程每次都是相同的
服务端的 Accept、Send、Receive是会阻塞主线程的,要等到执行完毕才会继续执行后面的内容
客户端
回顾客户端需要做的事情
创建套接字Socket
用Connect方法与服务端相连
用Send和Receive相关方法收发数据
用Shutdown方法释放连接
关闭套接字
总结
客户端连接的流程每次都是相同的
客户端的 Connect、Send、Receive是会阻塞主线程的,要等到执行完毕才会继续执行后面的内容
实现
服务端
namespace TeachTcpServer
{
class Program
{
static void Main(string[] args)
{
//1.创建套接字Socket(TCP)
Socket socketTcp = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//2.用Bind方法将套接字与本地地址绑定
try
{
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
socketTcp.Bind(ipPoint);
}
catch (Exception e)
{
Console.WriteLine("绑定报错" + e.Message);
return;
}
//3.用Listen方法监听
socketTcp.Listen(1024);
Console.WriteLine("服务端绑定监听结束,等待客户端连入");
//4.用Accept方法等待客户端连接
//5.建立连接,Accept返回新套接字
Socket socketClient = socketTcp.Accept();
Console.WriteLine("有客户端连入了");
//6.用Send和Receive相关方法收发数据
//发送
socketClient.Send(Encoding.UTF8.GetBytes("欢迎连入服务端"));
//接受
byte[] result = new byte[1024];
//返回值为接受到的字节数
int receiveNum = socketClient.Receive(result);
Console.WriteLine("接受到了{0}发来的消息:{1}",
socketClient.RemoteEndPoint.ToString(),
Encoding.UTF8.GetString(result, 0, receiveNum));
//7.用Shutdown方法释放连接
socketClient.Shutdown(SocketShutdown.Both);
//8.关闭套接字
socketClient.Close();
}
}
}
客户端
public class Lesson6 : MonoBehaviour
{
void Start()
{
//1.创建套接字Socket
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//2.用Connect方法与服务端相连
//确定服务端的IP和端口
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
try
{
socket.Connect(ipPoint);
}
catch (SocketException e)
{
if (e.ErrorCode == 10061)
print("服务器拒绝连接");
else
print("连接服务器失败" + e.ErrorCode);
return;
}
//3.用Send和Receive相关方法收发数据
//接收数据
byte[] receiveBytes = new byte[1024];
int receiveNum = socket.Receive(receiveBytes);
print("收到服务端发来的消息:" + Encoding.UTF8.GetString(receiveBytes, 0, receiveNum));
//发送数据
socket.Send(Encoding.UTF8.GetBytes("你好,我是唐老狮的客户端"));
//4.用Shutdown方法释放连接
socket.Shutdown(SocketShutdown.Both);
//5.关闭套接字
socket.Close();
}
void Update()
{
}
}
同步TCP通信——多线程并标准化的实现服务端和客户端
概述
在上一节我们已经基本实现了服务端和客户端的雏形,但是有两个比较麻烦的问题:
Send和Receive写在主线程的时候是比较消耗性能的,容易导致主线程卡顿,造成用户体验不佳。
上一节的雏形没有实现出TCP的优势——持续而且稳定的通信
而本节,我们将采用多线程来建立一个标准的TCP通信的客户端与服务端。
服务端
概述
请结合下面的实现对比查看
接收连接
将接收连接的循环函数AcceptClientConnect部署到一条线程中,用isClose布尔判断是否处于连接,只要连接进行中的时候,此线程池会不断的接收其他主机的连接,并把连接者的Socket 记录到数组clientSockets 中,方便服务端之后收发消息。
你需要注意的是 Socket clientSocket = socket.Accept();
这句代码是阻塞式的,会将该线程卡在这边,只有新连接来的时候才会执行下面的数组添加,然后在新的循环中再次阻塞。
接收消息
将接收消息的循环函数ReceiveMsg部署到一条线程中,用isClose布尔判断是否处于连接,只要连接进行中的时候,此线程池会不断的接收消息和发送消息。
当isClose为False的时候,函数执行结束,线程被销毁,直到我们下次再次激活连接。(本节未写连接方法,可以在输入判断里面扩充)
处理接收消息
你也许注意到ReceiveMsg里面获取接收的消息后不是立即处理的,而是用线程池另开了一个方法HandleMsg来处理。
这是因为处理线程是比较消耗性能的,可能会导致我们处理一条信息的时候,其他主机发来的信息全部被堵住,导致大规模信息的时候阻塞严重。
所以我们将不同的信息处理交给不同的线程,可以将这个处理的压力转移走,进而让接收消息的线程专心接收而不是处理。
可以说,服务端将多线程的强大展示的淋漓尽致。
实现
class Program
{
static Socket socket;
//用于存储 客户端连入的 Socket 之后可以获取他们来进行通信
static List<Socket> clientSockets = new List<Socket>();
static bool isClose = false;
static void Main(string[] args)
{
//1.建立Socket 绑定 监听
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
socket.Bind(ipPoint);
socket.Listen(1024);
//2.等待客户端连接(这节课需要特别处理的地方)
Thread acceptThread = new Thread(AcceptClientConnect);
acceptThread.Start();
//3.收发消息(这节课需要特别处理的地方)
Thread receiveThread = new Thread(ReceiveMsg);
receiveThread.Start();
//4.互动相关
while (true)
{
string input = Console.ReadLine();
//定义一个规则 关闭服务器 断开所有连接
if(input == "Quit")
{
isClose = true;
for (int i = 0; i < clientSockets.Count; i++)
{
clientSockets[i].Shutdown(SocketShutdown.Both);
clientSockets[i].Close();
}
clientSockets.Clear();
break;
}
//定义一个规则 广播消息 就是让所有客户端收到服务端发送的消息
else if(input.Substring(0, 2) == "B:")
{
for (int i = 0; i < clientSockets.Count; i++)
{
clientSockets[i].Send(Encoding.UTF8.GetBytes(input.Substring(2)));
}
}
}
}
static void AcceptClientConnect()
{
while (!isClose)
{
Socket clientSocket = socket.Accept();
clientSockets.Add(clientSocket);
clientSocket.Send(Encoding.UTF8.GetBytes("欢迎你连入服务端"));
}
}
static void ReceiveMsg()
{
Socket clientSocket;
byte[] result = new byte[1024 * 1024];
int receiveNum;
int i;
while (!isClose)
{
for (i = 0; i < clientSockets.Count; i++)
{
clientSocket = clientSockets[i];
//判断 该socket是否有可以接收的消息 返回值就是字节数
if(clientSocket.Available > 0)
{
//客户端即使没有发消息过来 这句代码也会执行
receiveNum = clientSocket.Receive(result);
//如果直接在这收到消息 就处理 可能造成问题
//不能够即使的处理别人的消息
//为了不影响别人消息的处理 我们把消息处理 交给新的线程,为了节约线程相关的开销 我们使用线程池
ThreadPool.QueueUserWorkItem(HandleMsg, (clientSocket, Encoding.UTF8.GetString(result, 0, receiveNum)));
}
}
}
}
static void HandleMsg(object obj)
{
(Socket s, string str) info = ((Socket s, string str))obj;
Console.WriteLine("收到客户端{0}发来的信息:{1}", info.s.RemoteEndPoint, info.str);
}
}
优化版概述
上一个版本的一个问题就是,将服务端的所有内容都写在一个类里面会导致过于臃肿以及我们后面加功能和维护的时候困难,所以我们不妨将其切割为多个功能类,优化下服务端。
优化版实现
运行类Program
负责初始化服务器,并且负责监听输入来实现操控服务器的功能,是类似控制台的存在。
class Program
{
static void Main(string[] args)
{
ServerSocket socket = new ServerSocket();
socket.Start("127.0.0.1", 8080, 1024);
Console.WriteLine("服务器开启成功");
while (true)
{
string input = Console.ReadLine();
if(input == "Quit")
{
socket.Close();
}
else if( input.Substring(0,2) == "B:" )
{
socket.Broadcast(input.Substring(2));
}
}
}
}
主要功能类ServerSocket
最核心的类,掌管服务端的绑定Socket和客户端连接的所有Socket,通过它可以实现对单个Socket的互动处理以及对整个服务端的开启和关闭。
class ServerSocket
{
//服务端Socket
public Socket socket;
//客户端连接的所有Socket
public Dictionary<int, ClientSocket> clientDic = new Dictionary<int, ClientSocket>();
private bool isClose;
//开启服务器端
public void Start(string ip, int port, int num)
{
isClose = false;
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse(ip), port);
socket.Bind(ipPoint);
socket.Listen(num);
ThreadPool.QueueUserWorkItem(Accept);
ThreadPool.QueueUserWorkItem(Receive);
}
//关闭服务器端
public void Close()
{
isClose = true;
foreach (ClientSocket client in clientDic.Values)
{
client.Close();
}
clientDic.Clear();
socket.Shutdown(SocketShutdown.Both);
socket.Close();
socket = null;
}
//接受客户端连入
private void Accept(object obj)
{
while (!isClose)
{
try
{
//连入一个客户端
Socket clientSocket = socket.Accept();
ClientSocket client = new ClientSocket(clientSocket);
client.Send("欢迎连入服务器");
clientDic.Add(client.clientID, client);
}
catch (Exception e)
{
Console.WriteLine("客户端连入报错" + e.Message);
}
}
}
//接收客户端消息
private void Receive(object obj)
{
while (!isClose)
{
if(clientDic.Count > 0)
{
foreach (ClientSocket client in clientDic.Values)
{
client.Receive();
}
}
}
}
public void Broadcast(string info)
{
foreach (ClientSocket client in clientDic.Values)
{
client.Send(info);
}
}
}
单个连接管理ClientSocket类
最基本的类,本身就代表了一个Socket,但是通过类的包装让它获取自己的clientID更加自然,可以更方便管理员的调用,本身负责自己所在通道的收发处理,并且可以单独关闭掉自身Socket。
class ClientSocket
{
private static int CLIENT_BEGIN_ID = 1;
public int clientID;
public Socket socket;
public ClientSocket(Socket socket)
{
this.clientID = CLIENT_BEGIN_ID;
this.socket = socket;
++CLIENT_BEGIN_ID;
}
/// <summary>
/// 是否是连接状态
/// </summary>
public bool Connected => this.socket.Connected;
//我们应该封装一些方法
//关闭
public void Close()
{
if(socket != null)
{
socket.Shutdown(SocketShutdown.Both);
socket.Close();
socket = null;
}
}
//发送
public void Send(string info)
{
if(socket != null)
{
try
{
socket.Send(Encoding.UTF8.GetBytes(info));
}
catch(Exception e)
{
Console.WriteLine("发消息出错" + e.Message);
Close();
}
}
}
//接收
public void Receive()
{
if (socket == null)
return;
try
{
if(socket.Available > 0)
{
byte[] result = new byte[1024 * 5];
int receiveNum = socket.Receive(result);
ThreadPool.QueueUserWorkItem(MsgHandle, Encoding.UTF8.GetString(result, 0, receiveNum));
}
}
catch (Exception e)
{
Console.WriteLine("收消息出错" + e.Message);
Close();
}
}
private void MsgHandle(object obj)
{
string str = obj as string;
Console.WriteLine("收到客户端{0}发来的消息:{1}", this.socket.RemoteEndPoint, str);
}
}
客户端
概述
在Unity里面的客户端,在Socket方面我们不需要下太多管理上的功夫——我们只需要处理自己这个用户拥有的Socket即可。
我们很自然的想到用单例来模块化管理网络通信这一部分,收发信息部分的逻辑都可照搬服务端,用多线程处理线程问题,但是我们面临一个Unity的特殊问题——Unity的多线程是不能直接影响主线程的,我们网络上获取数据当然是为了在游戏里面使用呀,怎么办呢?
也很简单,给一个中介即可,创建几个数组容器,多线程获取网络数据后塞进数组里,然后由主线程里面的update不断遍历,取用后在删除即可。
反过来发送线程也是同理,为了发送的时候不阻塞主线程,我们发送的时候只把线程放到队列里面,然后发送线程会按顺序取用发送。
实现
public class NetMgr : MonoBehaviour
{
private static NetMgr instance;
public static NetMgr Instance => instance;
//客户端Socket
private Socket socket;
//用于发送消息的队列 公共容器 主线程往里面放 发送线程从里面取
private Queue<string> sendMsgQueue = new Queue<string>();
//用于接收消息的对象 公共容器 子线程往里面放 主线程从里面取
private Queue<string> receiveQueue = new Queue<string>();
//用于收消息的水桶(容器)
private byte[] receiveBytes = new byte[1024 * 1024];
//返回收到的字节数
private int receiveNum;
//是否连接
private bool isConnected = false;
void Awake()
{
instance = this;
DontDestroyOnLoad(this.gameObject);
}
// Update is called once per frame
void Update()
{
if(receiveQueue.Count > 0)
{
print(receiveQueue.Dequeue());
}
}
//连接服务端
public void Connect(string ip, int port)
{
//如果是连接状态 直接返回
if (isConnected)
return;
if (socket == null)
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//连接服务端
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse(ip), port);
try
{
socket.Connect(ipPoint);
isConnected = true;
//开启发送线程
ThreadPool.QueueUserWorkItem(SendMsg);
//开启接收线程
ThreadPool.QueueUserWorkItem(ReceiveMsg);
}
catch (SocketException e)
{
if (e.ErrorCode == 10061)
print("服务器拒绝连接");
else
print("连接失败" + e.ErrorCode + e.Message);
}
}
//发送消息
public void Send(string info)
{
sendMsgQueue.Enqueue(info);
}
private void SendMsg(object obj)
{
while (isConnected)
{
if (sendMsgQueue.Count > 0)
{
socket.Send(Encoding.UTF8.GetBytes(sendMsgQueue.Dequeue()));
}
}
}
//不停的接受消息
private void ReceiveMsg(object obj)
{
while (isConnected)
{
if(socket.Available > 0)
{
receiveNum = socket.Receive(receiveBytes);
//收到消息 解析消息为字符串 并放入公共容器
receiveQueue.Enqueue(Encoding.UTF8.GetString(receiveBytes, 0, receiveNum));
}
}
}
public void Close()
{
if(socket != null)
{
socket.Shutdown(SocketShutdown.Both);
socket.Close();
isConnected = false;
}
}
private void OnDestroy()
{
Close();
}
}
第5节: 区分消息类型
区分消息类型
知识点一 如何发送之前的自定义类的2进制信息
1.继承BaseData类
2.实现其中的序列化、反序列化、获取字节数等相关方法
3.发送自定义类数据时 序列化
4.接受自定义类数据时 反序列化
抛出问题:
当将序列化的2进制数据发送给对象时,对方如何区分?
举例:
PlayerInfo:玩家信息
ChatInfo:聊天信息
LoginInfo:登录信息
等等
这些数据对象序列化后是长度不同的字节数组
将它们发送给对象后,对方如何区分出他们分别是什么消息
如何选择对应的数据类反序列化它们?
知识点二 如何区分消息
解决方案:
为发送的信息添加标识,比如添加消息ID
在所有发送的消息的头部加上消息ID(int、short、byte、long都可以,根据实际情况选择)
举例说明:
消息构成
如果选用int类型作为消息ID的类型
前4个字节为消息ID
后面的字节为数据类的内容
####***************************
这样每次收到消息时,先把前4个字节取出来解析为消息ID
再根据ID进行消息反序列化即可
知识点三 实践
实践步骤
1.创建消息基类BaseMsg,基类继承BaseData,基类添加获取消息ID的方法或者属性
可以理解用来网络通讯的BaseData为比单纯的数据BaseData变量多一层父类BaseMsg包装。
public abstract class BaseMsg : BaseData
{
public virtual int GetID()
{
return 0;
}
}
2.让想要被发送的消息继承该类,实现序列化反序列化方法
用GetID函数自定义这个类的头字节。
需要强调的是,服务端和客户端两端都要拥有相同的BaseData脚本,才能顺利进行解析。
public class PlayerMsg : BaseMsg
{
public int playerID;
public PlayerData playerData;
public override byte[] Writing()
{
int index = 0;
byte[] bytes = new byte[GetBytesNum()];
//先写消息ID
WriteInt(bytes, GetID(), ref index);
//写这个消息的成员变量
WriteInt(bytes, playerID, ref index);
WriteData(bytes, playerData, ref index);
return bytes;
}
public override int Reading(byte[] bytes, int beginIndex = 0)
{
//反序列化不需要去解析ID 因为在这一步之前 就应该把ID反序列化出来
//用来判断到底使用哪一个自定义类来反序化
int index = beginIndex;
playerID = ReadInt(bytes, ref index);
playerData = ReadData<PlayerData>(bytes, ref index);
return index - beginIndex;
}
public override int GetBytesNum()
{
return 4 + //消息ID的长度
4 + //playerID的字节数组长度
playerData.GetBytesNum();//playerData的字节数组长度
}
/// <summary>
/// 自定义的消息ID 主要用于区分是哪一个消息类
/// </summary>
/// <returns></returns>
public override int GetID()
{
return 1001;
}
}
3.修改客户端和服务端收发消息的逻辑
//接收数据
byte[] receiveBytes = new byte[1024];
int receiveNum = socket.Receive(receiveBytes);
//首先解析消息的ID
//使用字节数组中的前四个字节 得到ID
int msgID = BitConverter.ToInt32(receiveBytes, 0);
switch (msgID)
{
case 1001:
PlayerMsg msg = new PlayerMsg();
msg.Reading(receiveBytes, 4);
print(msg.playerID);
print(msg.playerData.name);
print(msg.playerData.atk);
print(msg.playerData.lev);
break;
}
print("收到服务端发来的消息:" + Encoding.UTF8.GetString(receiveBytes, 0, receiveNum));
注意
采用DataMsg后,我们需要对上一节两端的收发消息都进行调整,过程较为简单,这里不再写下去。
总结
区分消息的关键点,是在数据字节数组头部加上消息ID
只要前后端定义好统一的规则
那么我们可以通过ID来决定如何反序列化消息
并且可以决定我们应该如何处理该消息
第6节: 分包、黏包
分包、黏包—基本概念和逻辑实现
知识点一 什么是分包、黏包?
分包、黏包指在网络通信中由于各种因素(网络环境、API规则等)造成的消息与消息之间出现的两种状态
分包:一个消息分成了多个消息进行发送
黏包:一个消息和另一个消息黏在了一起
注意:分包和黏包可能同时发生
知识点二 如何解决分包、黏包的问题?
现在的处理:
我们收到的消息都是以字节数组的形式在程序中体现
目前我们的处理规则是默认传过来的消息就是正常情况
前4个字节是消息ID
后面的字节数组全部用来反序列化
如果出现分包、黏包会导致我们反序列化报错
思考:
那么通过接收到的字节数组我们应该如何判断收到的字节数组处于以下状态
正常
分包
黏包
突破点:
如何判断一个消息没有出现分包或者黏包呢?
答案——>消息长度
我们可以如同处理 区分消息类型 的逻辑一样
为消息添加头部,头部记录消息的长度
当我们接收到消息时,通过消息长度来判断是否分包、黏包
对消息进行拆分处理、合并处理
我们每次只处理完整的消息
知识点三 实践解决
1.为所有消息添加头部信息,用于存储其消息长度
2.根据分包、黏包的表现情况,修改接收消息处的逻辑
实现
1.为BaseMsg的子类的Writing加上长度信息(Reading不需要,会处理好头部再使用Reading)
public class PlayerMsg : BaseMsg
{
public int playerID;
public PlayerData playerData;
public override byte[] Writing()
{
int index = 0;
int bytesNum = GetBytesNum();
byte[] bytes = new byte[bytesNum];
//先写消息ID
WriteInt(bytes, GetID(), ref index);
//写如消息体的长度 我们-8的目的 是只存储 消息体的长度 前面8个字节 是我们自己定的规则 解析时按照这个规则处理就行了
WriteInt(bytes, bytesNum - 8, ref index);
//写这个消息的成员变量
WriteInt(bytes, playerID, ref index);
WriteData(bytes, playerData, ref index);
return bytes;
}
public override int Reading(byte[] bytes, int beginIndex = 0)
{
//反序列化不需要去解析ID 因为在这一步之前 就应该把ID反序列化出来
//用来判断到底使用哪一个自定义类来反序化
int index = beginIndex;
playerID = ReadInt(bytes, ref index);
playerData = ReadData<PlayerData>(bytes, ref index);
return index - beginIndex;
}
public override int GetBytesNum()
{
return 4 + //消息ID的长度
4 + //消息体的长度
4 + //playerID的字节数组长度
playerData.GetBytesNum();//playerData的字节数组长度
}
/// <summary>
/// 自定义的消息ID 主要用于区分是哪一个消息类
/// </summary>
/// <returns></returns>
public override int GetID()
{
return 1001;
}
}
2.在两端接收信息处加上新函数
//用于处理分包时 缓存的 字节数组 和 字节数组长度
private byte[] cacheBytes = new byte[1024 * 1024];
private int cacheNum = 0;
.....
//处理接受消息 分包、黏包问题的方法
private void HandleReceiveMsg(byte[] receiveBytes, int receiveNum)
{
int msgID = 0;
int msgLength = 0;
int nowIndex = 0;
//收到消息时 应该看看 之前有没有缓存的 如果有的话 我们直接拼接到后面
receiveBytes.CopyTo(cacheBytes, cacheNum);
cacheNum += receiveNum;
while (true)
{
//每次将长度设置为-1 是避免上一次解析的数据 影响这一次的判断
msgLength = -1;
//处理解析一条消息
if(cacheNum - nowIndex >= 8)
{
//解析ID
msgID = BitConverter.ToInt32(cacheBytes, nowIndex);
nowIndex += 4;
//解析长度
msgLength = BitConverter.ToInt32(cacheBytes, nowIndex);
nowIndex += 4;
}
if(cacheNum - nowIndex >= msgLength && msgLength != -1)
{
//解析消息体
BaseMsg baseMsg = null;
switch (msgID)
{
case 1001:
PlayerMsg msg = new PlayerMsg();
msg.Reading(cacheBytes, nowIndex);
baseMsg = msg;
break;
}
if (baseMsg != null)
receiveQueue.Enqueue(baseMsg);
nowIndex += msgLength;
if (nowIndex == cacheNum)
{
cacheNum = 0;
break;
}
}
else
{
//如果不满足 证明有分包
//那么我们需要把当前收到的内容 记录下来
//有待下次接受到消息后 再做处理
//receiveBytes.CopyTo(cacheBytes, 0);
//cacheNum = receiveNum;
//如果进行了 id和长度的解析 但是 没有成功解析消息体 那么我们需要减去nowIndex移动的位置
if (msgLength != -1)
nowIndex -= 8;
//就是把剩余没有解析的字节数组内容 移到前面来 用于缓存下次继续解析
Array.Copy(cacheBytes, nowIndex, cacheBytes, 0, cacheNum - nowIndex);
cacheNum = cacheNum - nowIndex;
break;
}
}
}
总结
处理分包、黏包问题首先要了解什么是分包和黏包
解决该问题的逻辑实现的写法可能有很多种
采用最节约性能的方式解决问题就行
第7节: 心跳消息
处理连接的断开
知识点一 目前的客户端主动断开连接
目前在客户端主动退出时
我们会调用socket的 ShutDown和Close方法
但是通过调用这两个方法后 服务器端无法得知客户端已经主动断开
知识点二 解决目前断开不及时的问题
服务端:
在断开的时候发送一条msg,这个msg会让服务端判断这个socket要断开连接,然后服务器端自己会移除此socket。
总结
客户端可以通过Disconnect方法主动和服务器端断开连接
服务器端可以通过Conected属性判断连接状态决定是否释放Socket
但是由于服务器端Conected变量表示的是上一次收发消息是否成功
所以服务器端无法准确判断客户端的连接状态
因此 我们需要自定义一条退出消息 用于准确断开和客户端之间的连接
隐患
在手机或者电脑里面通过强制杀进程等手段关闭客户端的时候,会来不及触发断开函数就直接断开,这导致服务器还是没有办法准确判断。
所以下一节会有最终解决方法:心跳消息
心跳消息
知识点一 什么是心跳消息?
所谓心跳消息,就是在长连接中,客户端和服务端之间定期发送的一种特殊的数据包
用于通知对方自己还在线,以确保长连接的有效性
由于其发送的时间间隔往往是固定的持续的,就像是心跳一样一直存在
所以我们称之为心跳消息
知识点二 为什么需要心跳消息?
1.避免非正常关闭客户端时,服务器无法正常收到关闭连接消息
通过心跳消息我们可以自定义超时判断,如果超时没有收到客户端消息,证明客户端已经断开连接
2.避免客户端长期不发送消息,防火墙或者路由器会断开连接,我们可以通过心跳消息一直保持活跃状态
知识点三 实现心跳消息
心跳信息
public class HeartMsg : BaseMsg
{
public override int GetBytesNum()
{
return 8;
}
public override byte[] Writing()
{
byte[] bytes = new byte[GetBytesNum()];
int index = 0;
WriteInt(bytes,GetID(),ref index);
WriteInt(bytes,0,ref index);
return bytes;
}
public override void Reading(byte[] bytes, int beginIndex = 0)
{
}
public override int GetID()
{
return 999;
}
}
客户端
主要功能:定时发送消息
//发送心跳消息的间隔时间
private int SEND_HEART_MSG_TIME = 2;
private HeartMsg hearMsg = new HeartMsg();
void Awake()
{
instance = this;
DontDestroyOnLoad(this.gameObject);
//客户端循环定时给服务端发送心跳消息
InvokeRepeating("SendHeartMsg", 0, SEND_HEART_MSG_TIME);
}
private void SendHeartMsg()
{
if (isConnected)
Send(hearMsg);
}
服务器
主要功能:不停检测上次收到某客户端消息的时间,如果超时则认为连接已经断开
/// <summary>
/// 间隔一段时间 检测一次超时 如果超时 就会主动断开该客户端的连接
/// </summary>
private void CheckTimeOut()
{
if (frontTime != -1 &&
DateTime.Now.Ticks / TimeSpan.TicksPerSecond - frontTime >= TIME_OUT_TIME)
{
Program.socket.AddDelSocket(this);
}
}
........
private void MsgHandle(object obj)
{
BaseMsg msg = obj as BaseMsg;
if(msg is PlayerMsg)
{
PlayerMsg playerMsg = msg as PlayerMsg;
Console.WriteLine(playerMsg.playerID);
Console.WriteLine(playerMsg.playerData.name);
Console.WriteLine(playerMsg.playerData.lev);
Console.WriteLine(playerMsg.playerData.atk);
}
else if(msg is QuitMsg)
{
//收到断开连接消息 把自己添加到待移除的列表当中
Program.socket.AddDelSocket(this);
}
else if(msg is HeartMsg)
{
//收到心跳消息 记录收到消息的时间
frontTime = DateTime.Now.Ticks / TimeSpan.TicksPerSecond;
Console.WriteLine("收到心跳消息");
}
}
总结
心跳消息是长连接项目中必备的一套逻辑规则
通过它可以帮助我们在服务器端及时的释放掉失效的socket
可以有效避免当客户端非正常关闭时,服务器端不能及时判断连接已断开
第8节: TCP通信—异步
异步通信常用方法
知识点一 异步方法和同步方法的区别
同步方法:
方法中逻辑执行完毕后,再继续执行后面的方法
异步方法:
方法中逻辑可能还没有执行完毕,就继续执行后面的内容
异步方法的本质
往往异步方法当中都会使用多线程执行某部分逻辑
因为我们不需要等待方法中逻辑执行完毕就可以继续执行下面的逻辑了
注意:Unity中的协同程序中的某些异步方法,有的使用的是多线程有的使用的是迭代器分步执行
关于协同程序可以回顾Unity基础当中讲解协同程序原理的知识点
知识点二 举例说明异步方法原理
我们以一个异步倒计时方法举例
1.线程回调
CountDownAsync(5, ()=> {
print("倒计时结束");
});
print("异步执行后的逻辑");
public void CountDownAsync(int second, UnityAction callBack)
{
Thread t = new Thread(() =>
{
while (true)
{
print(second);
Thread.Sleep(1000);
--second;
if (second == 0)
break;
}
callBack?.Invoke();
});
t.Start();
print("开始倒计时");
}
2.async和await 会等待线程执行完毕 继续执行后面的逻辑
相对第一种方式 可以让函数分步执行
CountDownAsync(5);
print("异步执行后的逻辑2");
public async void CountDownAsync(int second)
{
print("倒计时开始");
await Task.Run(() =>
{
while (true)
{
print(second);
Thread.Sleep(1000);
--second;
if (second == 0)
break;
}
});
print("倒计时结束");
}
上文是基于原版的语法实现的异步操作
但是需要强调的是,C#其实已经为我们准备好了专门用来网络通讯的异步API,我们不再需要自己写,直接使用即可。
下文便开始讲解专门用来网络通讯的异步方法
知识点三 Socket TCP通信中的异步方法(Begin开头方法)
回调函数参数IAsyncResult
参数IAsyncResult的 AsyncState 变量为调用异步方法时传入的参数 需要转换
AsyncWaitHandle 用于同步等待
Socket socketTcp = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
服务器相关
BeginAccept
EndAccept
下面的下方类似于不断的自己调用自己
和递归有本质区别,因为BeginAccept是个异步函数,不会影响调用它的主线程的结束
Socket socketTcp = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socketTcp.BeginAccept(AcceptCallBack, socketTcp);
private void AcceptCallBack(IAsyncResult result)
{
try
{
//获取传入的参数
Socket s = result.AsyncState as Socket;
//通过调用EndAccept就可以得到连入的客户端Socket
Socket clientSocket = s.EndAccept(result);
s.BeginAccept(AcceptCallBack, s);
}
catch (SocketException e)
{
print(e.SocketErrorCode);
}
}
客户端相关
BeginConnect
EndConnect
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
socketTcp.BeginConnect(ipPoint, (result) =>
{
Socket s = result.AsyncState as Socket;
try
{
s.EndConnect(result);
print("连接成功");
}
catch (SocketException e)
{
print("连接出错" + e.SocketErrorCode + e.Message);
}
}, socketTcp);
服务器客户端通用
接收消息
BeginReceive
EndReceive
socketTcp.BeginReceive(resultBytes, 0, resultBytes.Length, SocketFlags.None, ReceiveCallBack, socketTcp);
发送消息
BeginSend
EndSend
byte[] bytes = Encoding.UTF8.GetBytes("1231231231223123123");
socketTcp.BeginSend(bytes, 0, bytes.Length, SocketFlags.None, (result) =>
{
try
{
socketTcp.EndSend(result);
print("发送成功");
}
catch (SocketException e)
{
print("发送错误" + e.SocketErrorCode + e.Message);
}
}, socketTcp);
知识点四 Socket TCP通信中的异步方法2(Async结尾方法)
关键变量类型
SocketAsyncEventArgs
它会作为Async异步方法的传入值
我们需要通过它进行一些关键参数的赋值
服务器端
AcceptAsync
SocketAsyncEventArgs e = new SocketAsyncEventArgs();
e.Completed += (socket, args) =>
{
//首先判断是否成功
if (args.SocketError == SocketError.Success)
{
//获取连入的客户端socket
Socket clientSocket = args.AcceptSocket;
(socket as Socket).AcceptAsync(args);
}
else
{
print("连入客户端失败" + args.SocketError);
}
};
socketTcp.AcceptAsync(e);
客户端
ConnectAsync
SocketAsyncEventArgs e2 = new SocketAsyncEventArgs();
e2.Completed += (socket, args) =>
{
if (args.SocketError == SocketError.Success)
{
//连接成功
}
else
{
//连接失败
print(args.SocketError);
}
};
socketTcp.ConnectAsync(e2);
服务端和客户端
发送消息
SendAsync
SocketAsyncEventArgs e3 = new SocketAsyncEventArgs();
byte[] bytes2 = Encoding.UTF8.GetBytes("123123的就是拉法基萨克两地分居");
e3.SetBuffer(bytes2, 0, bytes2.Length);
e3.Completed += (socket, args) =>
{
if (args.SocketError == SocketError.Success)
{
print("发送成功");
}
else
{
}
};
socketTcp.SendAsync(e3);
接受消息
ReceiveAsync
SocketAsyncEventArgs e4 = new SocketAsyncEventArgs();
//设置接受数据的容器,偏移位置,容量
e4.SetBuffer(new byte[1024 * 1024], 0, 1024 * 1024);
e4.Completed += (socket, args) =>
{
if(args.SocketError == SocketError.Success)
{
//收取存储在容器当中的字节
//Buffer是容器
//BytesTransferred是收取了多少个字节
Encoding.UTF8.GetString(args.Buffer, 0, args.BytesTransferred);
args.SetBuffer(0, args.Buffer.Length);
//接收完消息 再接收下一条
(socket as Socket).ReceiveAsync(args);
}
else
{
}
};
socketTcp.ReceiveAsync(e4);
总结
C#中网络通信 异步方法中 主要提供了两种方案
1.Begin开头的API
内部开多线程,通过回调形式返回结果,需要和End相关方法 配合使用
2.Async结尾的API
内部开多线程,通过回调形式返回结果,依赖SocketAsyncEventArgs对象配合使用
可以让我们更加方便的进行操作
异步代码实现客户端和服务器
概述
为了演示,客户端使用Async方法,服务器使用begin方法
展示的只是基础版,没有心跳功能,分包粘包和BaseMgr系统
完整版内容过于繁多,请去泰课在线查看:
任务学习 - 泰课在线 -- 志存高远,稳如泰山 - 国内专业的在线学习平台|Unity3d培训|Unity教程|Unity教程 Unreal 虚幻 AR|移动开发|美术CG - Powered By EduSoho (taikr.com)
客户端
MainAsync
挂载在场景内
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MainAsync : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
if(NetAsyncMgr.Instance == null)
{
GameObject obj = new GameObject("NetAsync");
obj.AddComponent<NetAsyncMgr>();
}
NetAsyncMgr.Instance.Connect("127.0.0.1", 8080);
}
// Update is called once per frame
void Update()
{
}
}
NetAsynMgr
异步网络管理类
using System.Collections;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using UnityEngine;
public class NetAsyncMgr : MonoBehaviour
{
private static NetAsyncMgr instance;
public static NetAsyncMgr Instance => instance;
//和服务器进行连接的 Socket
private Socket socket;
//接受消息用的 缓存容器
private byte[] cacheBytes = new byte[1024 * 1024];
private int cacheNum = 0;
// Start is called before the first frame update
void Awake()
{
instance = this;
//过场景不移除
DontDestroyOnLoad(this.gameObject);
}
// Update is called once per frame
void Update()
{
}
//连接服务器的代码
public void Connect(string ip, int port)
{
if (socket != null && socket.Connected)
return;
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse(ip), port);
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.RemoteEndPoint = ipPoint;
args.Completed += (socket, args) =>
{
if(args.SocketError == SocketError.Success)
{
print("连接成功");
//收消息
SocketAsyncEventArgs receiveArgs = new SocketAsyncEventArgs();
receiveArgs.SetBuffer(cacheBytes, 0, cacheBytes.Length);
receiveArgs.Completed += ReceiveCallBack;
this.socket.ReceiveAsync(receiveArgs);
}
else
{
print("连接失败" + args.SocketError);
}
};
socket.ConnectAsync(args);
}
//收消息完成的回调函数
private void ReceiveCallBack(object obj, SocketAsyncEventArgs args)
{
if(args.SocketError == SocketError.Success)
{
//解析消息 目前用的字符串规则
print(Encoding.UTF8.GetString(args.Buffer, 0, args.BytesTransferred));
//继续去收消息
args.SetBuffer(0, args.Buffer.Length);
//继续异步收消息
if (this.socket != null && this.socket.Connected)
socket.ReceiveAsync(args);
else
Close();
}
else
{
print("接受消息出错" + args.SocketError);
//关闭客户端连接
Close();
}
}
public void Close()
{
if(socket != null)
{
socket.Shutdown(SocketShutdown.Both);
socket.Disconnect(false);
socket.Close();
socket = null;
}
}
public void Send(string str)
{
if(this.socket != null && this.socket.Connected)
{
byte[] bytes = Encoding.UTF8.GetBytes(str);
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.SetBuffer(bytes, 0, bytes.Length);
args.Completed += (socket, args) =>
{
if (args.SocketError != SocketError.Success)
{
print("发送消息失败" + args.SocketError);
Close();
}
};
this.socket.SendAsync(args);
}
else
{
Close();
}
}
}
服务器
Program
主运行类
using System;
namespace TeachTcpServerAsync
{
class Program
{
static void Main(string[] args)
{
ServerSocket serverSocket = new ServerSocket();
serverSocket.Start("127.0.0.1", 8080, 1024);
Console.WriteLine("开启服务器成功");
while (true)
{
string input = Console.ReadLine();
if(input.Substring(0, 2) == "B:")
{
serverSocket.Broadcast(input.Substring(2));
}
}
}
}
}
SeverSocket
总Socket类
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace TeachTcpServerAsync
{
class ServerSocket
{
private Socket socket;
private Dictionary<int, ClientSocket> clientDic = new Dictionary<int, ClientSocket>();
public void Start(string ip, int port, int num)
{
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse(ip), port);
try
{
socket.Bind(ipPoint);
socket.Listen(num);
//通过异步接受客户端连入
socket.BeginAccept(AcceptCallBack, null);
}
catch (Exception e)
{
Console.WriteLine("启动服务器失败" + e.Message);
}
}
private void AcceptCallBack(IAsyncResult result)
{
try
{
//获取连入的客户端
Socket clientSocket = socket.EndAccept(result);
ClientSocket client = new ClientSocket(clientSocket);
//记录客户端对象
clientDic.Add(client.clientID, client);
//继续去让别的客户端可以连入
socket.BeginAccept(AcceptCallBack, null);
}
catch (Exception e)
{
Console.WriteLine("客户端连入失败" + e.Message);
}
}
public void Broadcast(string str)
{
foreach (ClientSocket client in clientDic.Values)
{
client.Send(str);
}
}
}
}
CilentSocket
客户端socket类,服务端收集到SeverSocket的字典中统一管理
using System;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Text;
namespace TeachTcpServerAsync
{
class ClientSocket
{
public Socket socket;
public int clientID;
private static int CLIENT_BEGIN_ID = 1;
private byte[] cacheBytes = new byte[1024];
private int cacheNum = 0;
public ClientSocket(Socket socket)
{
this.clientID = CLIENT_BEGIN_ID++;
this.socket = socket;
//开始收消息
this.socket.BeginReceive(cacheBytes, cacheNum, cacheBytes.Length, SocketFlags.None, ReceiveCallBack, null);
}
private void ReceiveCallBack(IAsyncResult result)
{
try
{
cacheNum = this.socket.EndReceive(result);
//通过字符串去解析
Console.WriteLine(Encoding.UTF8.GetString(cacheBytes, 0, cacheNum));
//如果是连接状态再继续收消息
//因为目前我们是以字符串的形式解析的 所以 解析完 就直接 从0又开始收
cacheNum = 0;
if (this.socket.Connected)
this.socket.BeginReceive(cacheBytes, cacheNum, cacheBytes.Length, SocketFlags.None, ReceiveCallBack, this.socket);
else
{
Console.WriteLine("没有连接,不用再收消息了");
}
}
catch (SocketException e)
{
Console.WriteLine("接受消息错误" + e.SocketErrorCode + e.Message);
}
}
public void Send(string str)
{
if(this.socket.Connected)
{
byte[] bytes = Encoding.UTF8.GetBytes(str);
this.socket.BeginSend(bytes, 0, bytes.Length, SocketFlags.None, SendCallBack, null);
}
else
{
}
}
private void SendCallBack(IAsyncResult result)
{
try
{
this.socket.EndSend(result);
}
catch (SocketException e)
{
Console.WriteLine("发送失败" + e.SocketErrorCode + e.Message);
}
}
}
}
第5章: 网络通信—Socket—UDP
第1节: UDP通信概述
UDP通信概述
服务端和客户端需要做什么
于TCP不同,
UDP里,客户端和服务端要做的事情大体上是相同的:
创建套接字Socket
用Bind方法将套接字与本地地址进行绑定
用ReceiveFrom和SendTo方法在套接字上收发消息
用Shutdown方法释放连接
关闭套接字
UDP的分包、黏包问题
粘包
UDP本身作为无连接的不可靠的传输协议(适合频繁发送较小的数据包)
他不会对数据包进行合并发送
一端发送什么数据,直接就发出去了,他不会对数据合并
因此在UDP当中不会出现黏包问题(除非你手动进行黏包)
分包
由于UDP是不可靠的连接,消息传递过程中可能出现
无序、丢包等情况
所以如果允许UDP进行分包,那后果将会是灾难性的
比如分包的后半段丢包或者比上半段先发来,我们在处理消息时将会非常困难
因此为了避免其分包,我们建议在发送UDP消息时
控制消息的大小在MTU(最大传输单元)范围内
MTU(Maximum Transmission Unit)
最大传输单元,用来通知对方所能接受数据服务单元的最大尺寸
不同操作系统会提供用户一个默认值
以太网和802.3对数据帧的长度限制,其最大值分别是1500字节和1492字节
由于UDP包本身带有一些信息,因此建议:
1.局域网环境下:1472字节以内(1500减去UDP头部28为1472)
2.互联网环境下:548字节以内(老的ISP拨号网络的标准值为576减去UDP头部28为548)
只要遵守这个规则,就不会出现自动分包的情况
如果想要发送的消息确实比较大,要大于548字节或1472字节这个限制呢?
比如我们要发一个5000字节的数据,他是一条完整消息
我们可以进行手动分包,将5000拆分成多个消息,每个消息不超过限制
但是手动分包的前提是要解决UDP的丢包和无序问题
我们可以将不可靠的UDP通信实现为可靠的UDP通信
比如:在消息中加入序号、消息总包数、自己的包ID、长度等等信息
并且实现消息确认、消息重发等功能
总结
1.UDP不会黏包
2.因为UDP不可靠,我们要避免其自动分包,消息大小要控制在一定范围内
但是我们可以根据自己的需求自己在实现逻辑时
加入分包黏包功能
1.消息过小可以手动黏包
2.消息过大可以手动分包
但是对于手动分包,我们必须解决UDP无序和丢包的问题
第2节: UDP同步编程
UDP同步编程—服务端、客户端基本逻辑
实现
客户端
public class Cilent: MonoBehaviour
{
void Start()
{
//1.创建套接字
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
//2.绑定本机地址
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
socket.Bind(ipPoint);
//3.发送到指定目标
IPEndPoint remoteIpPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8081);
//指定要发送的字节数 和 远程计算机的 IP和端口
socket.SendTo(Encoding.UTF8.GetBytes("唐老狮来了"), remoteIpPoint);
//4.接受消息
byte[] bytes = new byte[512];
//这个变量主要是用来记录 谁发的信息给你 传入函数后 在内部 它会帮助我们进行赋值
EndPoint remoteIpPoint2 = new IPEndPoint(IPAddress.Any, 0);
int length = socket.ReceiveFrom(bytes, ref remoteIpPoint2);
print("IP:" + (remoteIpPoint2 as IPEndPoint).Address.ToString() +
"port:" + (remoteIpPoint2 as IPEndPoint).Port +
"发来了" +
Encoding.UTF8.GetString(bytes, 0, length));
//5.释放关闭
socket.Shutdown(SocketShutdown.Both);
socket.Close();
}
void Update()
{
}
}
服务端
class server
{
static void Main(string[] args)
{
//1.创建套接字
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
//2.绑定本机地址
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8081);
socket.Bind(ipPoint);
Console.WriteLine("服务器开启");
//3.接受消息
byte[] bytes = new byte[512];
//这个变量主要是用来记录 谁发的信息给你 传入函数后 在内部 它会帮助我们进行赋值
EndPoint remoteIpPoint2 = new IPEndPoint(IPAddress.Any, 0);
int length = socket.ReceiveFrom(bytes, ref remoteIpPoint2);
Console.WriteLine("IP:" + (remoteIpPoint2 as IPEndPoint).Address.ToString() +
"port:" + (remoteIpPoint2 as IPEndPoint).Port +
"发来了" +
Encoding.UTF8.GetString(bytes, 0, length));
//4.发送到指定目标
//由于我们先收 所以 我们已经知道谁发了消息给我 我直接发给它就行了
socket.SendTo(Encoding.UTF8.GetBytes("欢迎发送消息给服务器"), remoteIpPoint2);
//5.释放关闭
socket.Shutdown(SocketShutdown.Both);
socket.Close();
Console.ReadKey();
}
}
注意
可以看出,UDP下服务端和客户端都拥有自己IP和端口。
并且不需要连接,只需要知道ip和端口就可以互相发送。
UDP同步编程综合实现
第3节: UDP异步编程
UDP异步通信常用方法
知识点一 Socket UDP通信中的异步方法
通过之前的学习,UDP用到的通信相关方法主要就是
SendTo和ReceiveFrom
所以在讲解UDP异步通信时也主要是围绕着收发消息相关方法来讲解
知识点二 UDP通信中Begin相关异步方法
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
//BeginSendTo 异步发送
byte[] bytes = Encoding.UTF8.GetBytes("123123lkdsajlfjas");
EndPoint ipPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080);
socket.BeginSendTo(bytes, 0, bytes.Length, SocketFlags.None, ipPoint, SendToOver, socket);
//BeginReceiveFrom 异步接收
socket.BeginReceiveFrom(cacheBytes, 0, cacheBytes.Length, SocketFlags.None, ref ipPoint, ReceiveFromOver, (socket, ipPoint));
private void ReceiveFromOver(IAsyncResult result)
{
try
{
(Socket s, EndPoint ipPoint) info = ((Socket, EndPoint))result.AsyncState;
//返回值 就是接收了多少个 字节数
int num = info.s.EndReceiveFrom(result, ref info.ipPoint);
//处理消息
//处理完消息 又继续接受消息
info.s.BeginReceiveFrom(cacheBytes, 0, cacheBytes.Length, SocketFlags.None, ref info.ipPoint, ReceiveFromOver, info);
}
catch (SocketException s)
{
print("接受消息出问题" + s.SocketErrorCode + s.Message);
}
}
private void SendToOver(IAsyncResult result)
{
try
{
Socket s = result.AsyncState as Socket;
s.EndSendTo(result);
print("发送成功");
}
catch (SocketException s)
{
print("发送失败" + s.SocketErrorCode + s.Message);
}
}
知识点三 UDP通信中Async相关异步方法
//SendToAsync 异步发送
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
//设置要发送的数据
args.SetBuffer(bytes, 0, bytes.Length);
//设置完成事件
args.Completed += SendToAsync;
socket.SendToAsync(args);
//ReceiveFromAsync 异步接收
SocketAsyncEventArgs args2 = new SocketAsyncEventArgs();
//这是设置接受消息的容器
args2.SetBuffer(cacheBytes, 0, cacheBytes.Length);
args2.Completed += ReceiveFromAsync;
socket.ReceiveFromAsync(args2);
private void SendToAsync(object s, SocketAsyncEventArgs args)
{
if(args.SocketError == SocketError.Success)
{
print("发送成功");
}
else
{
print("发送失败");
}
}
private void ReceiveFromAsync(object s, SocketAsyncEventArgs args)
{
if (args.SocketError == SocketError.Success)
{
print("接收成功");
//具体收了多少个字节
//args.BytesTransferred
//可以通过以下两种方式获取到收到的字节数组内容
//args.Buffer
//cacheBytes
//解析消息
Socket socket = s as Socket;
//只需要设置 从第几个位置开始接 能接多少
args.SetBuffer(0, cacheBytes.Length);
socket.ReceiveFromAsync(args);
}
else
{
print("接收失败");
}
}
总结
由于学习了TCP相关的知识点
所以UDP的相关内容的学习就变得简单了
他们异步通信的唯一的区别就是API不同,使用规则都是一致的