纸上得来终觉浅,绝知此事要躬行~
Echo
网络上的两个程序通过一个双向的通信连接实现数据交换,这个连接的一端称为一个Socket
"端口"是英文port的意译,是设备与外界通信交流的出口。每台计算机可以分配0到65535共65536个端口
每一条Socket连接代表着本地Socket→本地端口→网络介质→远程端口→远程Socket的链路
Socket通信的基本流程
- 开启一个连接之前,需要创建一个Socket对象(使用API Socket),然后绑定本地使用的端口(使用API Bind)。对客户端而言,连接时(使用API Connect)会由系统分配端口,可以省去绑定步骤。
- 对客户端而言,连接时(使用API Connect)会由系统分配端口,可以省去绑定步骤。
- 客户端连接服务器(使用API Connect)
- 服务器接受连接(使用API Accept)
- 客户端和服务端通过Send和Receive等API收发数据,操作系统会自动完成数据的确认、重传等步骤,确保传输的数据准确无误。
- 某一方关闭连接(使用API Close),操作系统会执行"四次挥手"的步骤,关闭双方连接
cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net.Sockets;
using UnityEngine.UI;
public class Echo : MonoBehaviour {
//定义套接字
Socket socket;
//UGUI
public InputField InputFeld;
public Text text;
//点击连接按钮
//客户端通过socket.Connect(远程IP地址,远程端口)连接服务端。Connect是一个阻塞方法,程
//序会卡住直到服务端回应(接收、拒绝或超时)。
public void Connection()
{
//Socket
socket = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);
//这一行用于创建一个Socket对象,它的三个参数分别代表地址族、套接字类型和协议。
//Connect
socket.Connect("127.0.0.1", 8888);
}
//点击发送按钮
//客户端通过socket.Send发送数据,这也是一个阻塞方法。该方法接受一个byte[]类型的参数指明
//要发送的内容。Send的返回值指明发送数据的长度(例子中没有使用)。程序用
//System.Text.Encoding.Default.GetBytes(字符串)把字符串转换成byte[]数组,然后发送给服
//务端。
public void Send()
{
//Send
string sendStr = InputFeld.text;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
socket.Send(sendBytes);
//Recv
byte[] readBuff = new byte[1024];
//客户端通过socket.Receive接收服务端数据。Receive也是阻塞方法,没有收到服务端数据
//时,程序将卡在Receive不会往下执行。Receive带有一个byte[]类型的参数,它存储接收到
//的数据。Receive的返回值指明接收到数据的长度。之后使用System.Text.Encoding.
//Default.GetString(readBuff,0, count)将byte[]数组转换成字符串显示在屏幕上。
int count = socket.Receive(readBuff);
string recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
text.text = recvStr;
//Close
socket.Close();
}
}
此时运行游戏点击连接会出现
因为我们还没有启动服务器,所以属于正常现象
创建服务端程序
cs
using System.Net;
using System.Net.Sockets;
internal class Program
{
private static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
//Socket
Socket listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPAddress ipAdr = IPAddress.Parse("127.0.0.1");
IPEndPoint ipEp = new IPEndPoint(ipAdr, 8888);
listenfd.Bind(ipEp);
//Listen
listenfd.Listen(0);
Console.WriteLine("[服务器]启动成功");
while (true)
{
//Accept
Socket connfd = listenfd.Accept();
Console.WriteLine("[服务器]Accept");
//Receive
byte[] readBuff = new byte[1024];
int count = connfd.Receive(readBuff);
string readStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
Console.WriteLine("[服务器接受]" + readStr);
//Send
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(readStr);
connfd.Send(sendBytes);
}
}
}
绑定 Bind
listenfd.Bind(ipEp)将给listenfd套接字绑定IP和端口。程序中绑定本地地址"127.0.0.1"和8888号端口。127.0.0.1是回送地址,指本地机,一般用于测试。读者也可以设置成真实的IP地址,然后在两台计算机上分别运行客户端和服务端程序
监听 Listen
服务端通过listenfd.Listen(backlog)开启监听,等待客户端连接。参数backlog指定队列中最多可容纳等待接受的连接数,0表示不限制。
应答 Accept
开启监听后,服务器调用listenfd.Accept()接收客户端连接。本例使用的所有Socket方法都是阻塞方法,也就是说当没有客户端连接时,服务器程序卡在listenfd.Accept()不会往下执行,直到接收了客户端的连接。Accept返回一个新客户端的Socket对象,对于服务器来说,它有一个监听Socket(例子中的listenfd)用来监听(Listen)和应答(Accept)客户端的连接,对每个客户端还有一个专门的Socket(例子中的connfd)用来处理该客户端的数据。
IPAddress 和 IPEndPoint
使用IPAddress指定IP地址,使用IPEndPoint指定IP和端口。
System.Text.Encoding.Default.GetString
Receive方法将接收到的字节流保存到readBuff上,readBuff是byte型数组。GetString方法可以将byte型数组转换成字符串。同理,System.Text.Encoding.Default.GetBytes可以将字符串转换成byte型数组。
测试 :
Socket类的一些常用方法
公网和局域网
把宽带连接到家里的路由器,再由路由器分发到多台计算机(校园网、公司局域网同理),在这种情况下,路由器会有公网和局域网两个IP
比如:路由器的公网IP是123.207.111.220,局域网IP为192.168.0.1,连接路由器的计算机只有内网IP,它们分别是192.168.0.10和192.168.0.12。。如果将服务端放到连接路由器的某台计算机上,因为它只有局域网IP,所以只有局域网内的计算机可以连接上。如果拥有路由器的控制权,可以使用一种叫"端口映射 "的技术,即设置路由器,将路由器IP地址的一个端口映射到内网中的一台计算机,提供相应的服务。当用户访问该IP的这个端口时,路由器自动将请求映射到对应局域网内部的计算机上
异步和多路复用
上面的程序全部使用阻塞API(Connect、Send、Receive等),可称为同步Socket程序
一个简单的异步程序示例:
cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading;
public class Async : MonoBehaviour {
// Use this for initialization
void Start () {
//创建定时器
Timer timer = new Timer(TimeOut, null, 5000, 0);
//其他程序代码
//......
}
//回调函数
private void TimeOut(System.Object state){
Debug.Log("铃铃铃");
}
}
异步Connect
每一个同步API(如Connect)对应着两个异步API,分别是在原名称前面加上Begin和End(如BeginConnect和EndConnect)
BeginConnect的函数原型如下:
public IAsyncResult BeginConnect( string host, int port, AsyncCallback requestCallback, object state )
修改代码:
cs
using System;
//点击连接按钮
public void Connection()
{
//Socket
socket = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);
//Connect
socket.BeginConnect("127.0.0.1", 8888, ConnectCallback, socket);
}
//Connect回调
public void ConnectCallback(IAsyncResult ar){
try{
Socket socket = (Socket) ar.AsyncState;
socket.EndConnect(ar);
Debug.Log("Socket Connect Succ");
}
catch (SocketException ex){
Debug.Log("Socket Connect fail" + ex.ToString());
}
}
说明:
- 由BeginConnect最后一个参数传入的socket,可由ar.AsyncState获取到。
异步Receive
public IAsyncResult BeginReceive ( byte[] buffer, int offset, int size, SocketFlags socketFlags, AsyncCallback callback, object state )
public int EndReceive( IAsyncResult asyncResult ) 它的返回值代表接收到的字节数
修改客户端代码:
cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net.Sockets;
using UnityEngine;
using UnityEngine.UI;
public class Echo : MonoBehaviour
{
//UGUI
public InputField inputField;
public Text text;
//定义套接字
Socket socket;
//接收缓冲区
byte[] readBuff = new byte[1024];
string recvStr = "";
public void Connection()
{
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.BeginConnect("127.0.0.1", 8888,ConnectCallback,socket);
}
//Connect回调
public void ConnectCallback(IAsyncResult ar)
{
try
{
Socket socket = (Socket)ar.AsyncState;
socket.EndConnect(ar);
Debug.Log("Socket Connect Succ");
socket.BeginReceive(readBuff,0,1024,0,ReceiveCallback,socket);
}
catch (SocketException ex)
{
Debug.Log("Socket Connect fail" + ex.ToString());
}
}
public void ReceiveCallback(IAsyncResult ar) {
try
{
Socket socket = (Socket) ar.AsyncState;
int count = socket.EndReceive(ar);
recvStr = System.Text.Encoding.Default.GetString(readBuff,0,count);
socket.BeginReceive(readBuff,0,1024,0,ReceiveCallback,socket);
Debug.Log("ReceiveCallback" + recvStr);
}
catch (SocketException ex)
{
Debug.Log("Socket Receive fail" + ex.ToString());
}
}
public void Send()
{
string sendStr = inputField.text;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
socket.Send(sendBytes);
}
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
text.text = recvStr;
}
}
说明:
BeginReceive的参数
上述程序中,BeginReceive的参数为(readBuff, 0, 1024, 0, ReceiveCallback,socket)。第一个参数readBuff表示接收缓冲区;第二个参数0表示从readBuff第0位开始接收数据,这个参数和TCP粘包问题有关,第三个参数1024代表每次最多接收1024个字节的数据
BeginReceive的调用位置
程序在两个地方调用了BeginReceive:一个是ConnectCallback,在连接成功后,就开始接收数据,接收到数据后,回调函数ReceiveCallback被调用。另一个是BeginReceive内部,接收完一串数据后,等待下一串数据的到来
Update和recvStr
在Unity中,只有主线程可以操作UI组件。由于异步回调是在其他线程执行的,如果在BeginReceive给text.text赋值,Unity会弹出"get_isActiveAndEnabled can onlybe called from the main thread"的异常信息,所以程序只给变量recvStr赋值,在主线程执行的Update中再给text.text赋值
异步Send
Socket使用的协议、IP、端口属于用户层面 的属性,可以直接修改;操作系统层面拥有"发送"和"接收"两个缓冲区,当调用Send方法时,程序将要发送的字节流写入到发送缓冲区中,再由操作系统完成数据的发送和确认
如果缓冲区满,那么Send就会阻塞,直到缓冲区的数据被确认腾出空间
值得注意的是,Send过程只是把数据写入到发送缓冲区,然后由操作系统负责重传、确认等步骤。Send方法返回只代表成功将数据放到发送缓存区中,对方可能还没有收到数据。
异步Send不会卡住程序,当数据成功写入输入缓冲区(或发生错误)时会调用回调函数。异步Send方法BeginSend的原型如下。
public IAsyncResult BeginSend( byte[] buffer, int offset, int size, SocketFlags socketFlags, AsyncCallback callback, object state )
public int EndSend ( IAsyncResult asyncResult )
修改客户端代码,使用异步发送:
cs
public void Send()
{
string sendStr = inputField.text;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
for(int i = 0;i < 10000;i++) {
socket.BeginSend(sendBytes,0,sendBytes.Length,0,SendCallback,socket);
}
}
public void SendCallback(IAsyncResult ar) {
try
{
Socket socket = (Socket) ar.AsyncState;
int count = socket.EndSend(ar);
Debug.Log("Socket Send succ" + count);
}
catch (SocketException ex)
{
Debug.Log("Socket Send fail" + ex.ToString());
}
}
异步服务端
上面的同步服务端程序同一时间只能处理一个客户端的请求,因为它会一直阻塞,等待某一个客户端的数据,无暇接应其他客户端。使用异步方法,可以让服务端同时处理多个客户端的数据,及时响应
管理客户端
定义一个名为ClientState 的类,用于保存一个客户端信息。ClientState包含TCP连接所需Socket,以及用于填充BeginReceive参数的读缓冲区readBuff
cs
//数据结构
class ClientState {
public Socket socket;
public byte[] readBuff = new byte[1024];
}
static Dictionary<Socket, ClientState> clients =
new Dictionary<Socket, ClientState>();
异步Accept
public IAsyncResult BeginAccept( AsyncCallback callback, object state )
public Socket EndAccept( IAsyncResult asyncResult )
程序结构:
修改代码:
cs
using System.Net;
using System.Net.Sockets;
class ClientState {
public Socket socket;
public byte[] readBuff = new byte[1024];
}
internal class Program
{
//监听Socket
static Socket listenfd;
//客户端Socket及状态信息
static Dictionary<Socket,ClientState> clients = new Dictionary<Socket, ClientState>();
private static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
//Socket
listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPAddress ipAdr = IPAddress.Parse("127.0.0.1");
IPEndPoint ipEp = new IPEndPoint(ipAdr, 8888);
listenfd.Bind(ipEp);
//Listen
listenfd.Listen(0);
Console.WriteLine("[服务器]启动成功");
listenfd.BeginAccept(AcceptCallback,listenfd);
//等待
Console.ReadLine();
// while (true)
// {
// //Accept
// Socket connfd = listenfd.Accept();
// Console.WriteLine("[服务器]Accept");
// //Receive
// byte[] readBuff = new byte[1024];
// int count = connfd.Receive(readBuff);
// string readStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
// Console.WriteLine("[服务器接受]" + readStr);
// //Send
// string sendStr = System.DateTime.Today.ToString();
// byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
// connfd.Send(sendBytes);
// }
}
//Accept回调
public static void AcceptCallback(IAsyncResult ar) {
try
{
Console.WriteLine("[服务器]Accept");
Socket listenfd = (Socket) ar.AsyncState;
Socket clientfd = listenfd.EndAccept(ar);
//clients列表
ClientState state = new ClientState();
state.socket = clientfd;
clients.Add(clientfd,state);
//接收数据的BeginReceive
clientfd.BeginReceive(state.readBuff,0,1024,0,ReceiveCallback,state);
//继续Accept
listenfd.BeginAccept(AcceptCallback,listenfd);
}
catch (SocketException ex)
{
Console.WriteLine("Socket Accept fail" + ex.ToString());
}
}
//Receive回调
public static void ReceiveCallback(IAsyncResult ar) {
try
{
ClientState state = (ClientState) ar.AsyncState;
Socket clientfd = state.socket;
int count = clientfd.EndReceive(ar);
//客户端关闭
if(count == 0) {
clientfd.Close();
clients.Remove(clientfd);
Console.WriteLine("Socket Close");
return;
}
string recvStr = System.Text.Encoding.Default.GetString(state.readBuff,0,count);
byte[] sendBytes = System.Text.Encoding.Default.GetBytes("echo" + recvStr);
clientfd.Send(sendBytes); //减少代码量,不用异步
clientfd.BeginReceive(state.readBuff,0,1024,0,ReceiveCallback,state);
//注意BeginReceive的最后一个参数,这里以ClientState代替了原来的Socket。
}
catch (SocketException ex)
{
Console.WriteLine("Socket Receive fail" + ex.ToString());
throw;
}
}
}
AcceptCallback是BeginAccept的回调函数,它处理了三件事情:
- 给新的连接分配ClientState,并把它添加到clients列表中;
- 异步接收客户端数据;
- 再次调用BeginAccept实现循环。
ReceiveCallback是BeginReceive的回调函数,它也处理了三件事情:
- 服务端收到消息后,回应客户端;
- 如果收到客户端关闭连接的信号"if(count == 0)",断开连接;
- 继续调用BeginReceive接收下一个数据。
当Receive返回值小于等于0时,表示Socket连接断开,可以关闭Socket。
聊天室
在聊天室中,某个客户端发送聊天消息,所有在线的客户端都会收到这条消息。也就是会遍历在线的客户端,然后推送消息
修改服务端代码:
cs
//Receive回调
public static void ReceiveCallback(IAsyncResult ar){
try {
......
string recvStr = System.Text.Encoding.Default.GetString(state.readBuff,
0, count);
string sendStr = clientfd.RemoteEndPoint.ToString() + ":" + recvStr;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
foreach (ClientState s in clients.Values){
s.socket.Send(sendBytes);
}
clientfd.BeginReceive( state.readBuff, 0, 1024, 0,
ReceiveCallback, state);
}
catch (SocketException ex){
......
}
}
修改客户端代码,显示历史聊天:
cs
//Receive回调
public void ReceiveCallback(IAsyncResult ar){
try {
Socket socket = (Socket) ar.AsyncState;
int count = socket.EndReceive(ar);
string s = System.Text.Encoding.Default.GetString(readBuff, 0, count);
recvStr = s + "\n" + recvStr;
socket.BeginReceive( readBuff, 0, 1024, 0,
ReceiveCallback, socket);
}
catch (SocketException ex){
Debug.Log("Socket Receive fail" + ex.ToString());
}
}
效果: