网络上的两个程序通过一个双向的通信连接实现数据交换,这个连接的一端称为一个Socket。一个Socket包含了进行网络通信必需的五种信息:连接使用的协议、本地主机的IP地址、本地的协议端口、远程主机的IP地址和远程协议端口。
在Unity中创建两个按钮,用来连接服务端和发送数据,创建一个输入框和文本用来发送和显示接收的数据
编写客户端代码并挂载到场景任意物体上
///
/// 客户端代码
///
public class ClientDemo : MonoBehaviour
{
private Socket socket;
public InputField inputField;
public Button btnConnect;
public Button btnSend;
public Text text;
private void Start()
{
btnSend.onClick.AddListener(Send);
btnConnect.onClick.AddListener(Connect);
}
public void Connect()
{
//创建socket对象,参数为Ip地址类型,套接字类型,协议类型
//这里使用Ipv4地址,游戏开发常用字节流类型,传输协议用tcp
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//阻塞方法,连接服务端,127.0.0.1是本机回环地址,表示本机ip
socket.Connect("127.0.0.1",8888);
}
public void Send()
{
//获取输入框内容
string sendStr = inputField.text;
//将输入框内容转换为字节数组
byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(sendStr);
//阻塞方法,发送消息到服务端
socket.Send(sendBytes);
//声明字节数组,用来存储收到的数据
byte[] readBuff = new byte[1024];
//Receive方法,阻塞,接收来自服务端的数据存入readBuff中,返回数据的字节数
int count = socket.Receive(readBuff);
//用C#自带的解码器将二进制数据转换为字符串
string recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
//将收到的内容显示到屏幕上
text.text = recvStr;
//关闭连接
socket.Close();
}
}
namespace Socket服务端
{
///
/// 服务端代码
///
internal class Program
{
public static void Main(string[] args)
{
Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//端口类,指定ip地址和端口
IPEndPoint ipEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8888);
//绑定ip和端口,
serverSocket.Bind(ipEndPoint);
//监听,参数backlog指队列中最多可容纳等待接受的连接数,0表示不限制
serverSocket.Listen(0);
Console.WriteLine("服务器启动成功,等待客户端连接");
//Accept接收客户端的连接,返回一个新的Socket,用来处理该客户端的数据
Socket connectedSocket = serverSocket.Accept();
Console.WriteLine("建立连接成功");
while (true)
{
//读取客户端的数据并发送
byte[] readBuff = new byte[1024];
int count = connectedSocket.Receive(readBuff);
string readStr = System.Text.Encoding.Default.GetString(readBuff,0,count);
Console.WriteLine("服务器接收"+readStr);
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(readStr);
connectedSocket.Send(sendBytes);
}
}
}
}
上一个案例的程序全部使用阻塞API(Connect、Send、Receive等),可称为同步Socket程序,它简单且容易实现,但时不时卡住程序却成为致命的缺点。客户端一卡一顿、服务端只能一次处理一个客户端的消息,不具有实用性。可以用异步和多路复用两种技术解决阻塞问题。
IAsyncResult是.NET提供的一种异步操作,通过名为Begin×××和End×××的两个方法来实现原同步方法的异步调用。Begin×××方法包含同步方法中所需的参数,此外还包含另外两个参数:一个AsyncCallback委托和一个用户定义的状态对象。委托用来调用回调方法,状态对象用来向回调方法传递状态信息。且Begin×××方法返回一个实现IAsyncResult接口的对象,End×××方法用于结束异步操作并返回结果。End×××方法含有一个IAsyncResult参数,用于获取异步操作是否完成的信息,它的返回值与同步方法相同。
使用异步方法将Connect和Receive方法修改为异步执行
///
/// 异步客户端代码
///
public class ClientAsync : MonoBehaviour
{
private Socket socket;
public InputField inputField;
public Button btnConnect;
public Button btnSend;
public Text text;
byte[] readBuff = new byte[1024];
private string recvStr = "";
private void Start()
{
btnSend.onClick.AddListener(Send);
btnConnect.onClick.AddListener(Connect);
}
public void Connect()
{
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.BeginConnect("127.0.0.1",8888,ConnectCallback,socket);
}
//Connect操作完成以后自动调用Receive
public void ConnectCallback(IAsyncResult asyncResult)
{
try
{
Socket socket = (Socket)asyncResult.AsyncState;
socket.EndConnect(asyncResult);
Debug.Log("连接成功");
socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
}
catch (SocketException e)
{
Debug.Log("连接失败");
}
}
//Receive执行完以后自动开启下一次Receive
public void ReceiveCallback(IAsyncResult asyncResult)
{
try
{
Socket socket = (Socket)asyncResult.AsyncState;
int count = socket.EndReceive(asyncResult);
recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
}
catch (SocketException e)
{
Debug.Log("接收失败"+e);
}
}
public void Send()
{
string sendStr = inputField.text;
byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(sendStr);
socket.BeginSend(sendBytes,0,sendBytes.Length,0,SendCallback,socket);
//不需要receive了
}
public void SendCallback(IAsyncResult asyncResult)
{
try
{
Socket socket = (Socket)asyncResult.AsyncState;
int count = socket.EndSend(asyncResult);
Debug.Log("Socket发送成功");
}
catch (SocketException e)
{
Debug.Log("Socket发送失败"+e);
}
}
private void Update()
{
text.text = recvStr;
}
}
1、在Unity中,只有主线程可以操作UI组件。由于异步回调是在其他线程执行的,如果在BeginReceive给text.text赋值,Unity会弹出“get_isActiveAndEnabled can only be called from the main thread”的异常信息,所以程序只给变量recvStr赋值,在主线程执行的Update中再给text.text赋值。
2、在操作系统内部,每个Socket都会有一个发送缓冲区,用于保存那些接收方还没有确认的数据。如图指示了一个Socket涉及的属性,它分为“用户层面”和“操作系统层面”两大部分。Socket使用的协议、IP、端口属于用户层面的属性,可以直接修改;操作系统层面拥有“发送”和“接收”两个缓冲区,当调用Send方法时,程序将要发送的字节流写入到发送缓冲区中,再由操作系统完成数据的发送和确认。
发送缓冲区的长度是有限的(默认值约为8KB),如果缓冲区满,那么Send就会阻塞,直到缓冲区的数据被确认腾出空间。
由于这些步骤是操作系统自动处理的,不对用户开放,因此称为“操作系统层面”上的属性。值得注意的是,Send过程只是把数据写入到发送缓冲区,然后由操作系统负责重传、确认等步骤。Send方法返回只代表成功将数据放到发送缓存区中,对方可能还没有收到数据。使用异步Send不会卡住程序,当数据成功写入输入缓冲区(或发生错误)时会调用回调函数。
同步服务端程序同一时间只能处理一个客户端的请求,因为它会一直阻塞,等待某一个客户端的数据,无暇接应其他客户端。使用异步方法,可以让服务端同时处理多个客户端的数据,及时响应。
实现原理:创建一个ClientState类,保存建立连接的Socket和对应的缓冲字节数组。用一个字典保存所有连接到的客户端,在异步客户端的基础上,Accept方法也修改为异步,这样就可以同时处理多个客户端的连接
namespace 异步Socket服务端
{
class ClientState
{
public Socket socket;
public byte[] readBuff = new byte[1024];
}
internal class Program
{
private static Socket serverSocket;
private static Dictionary<Socket, ClientState> clients = new();
public static void Main(string[] args)
{
serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ipEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8888);
serverSocket.Bind(ipEndPoint);
serverSocket.Listen(0);
Console.WriteLine("服务器启动成功");
serverSocket.BeginAccept(AcceptCallback, serverSocket);
Console.ReadLine();
}
private static void AcceptCallback(IAsyncResult ar)
{
try
{
Console.WriteLine("收到客户端请求");
Socket socket = (Socket)ar.AsyncState;
Socket clientSocket = socket.EndAccept(ar);
ClientState state = new ClientState();
state.socket = clientSocket;
clients.Add(clientSocket,state);
clientSocket.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state);
socket.BeginAccept(AcceptCallback, socket);
}
catch (SocketException e)
{
Console.WriteLine("Socket接收失败"+e);
}
}
private static void ReceiveCallback(IAsyncResult ar)
{
try
{
ClientState state = (ClientState)ar.AsyncState;
Socket clientSocket = state.socket;
//返回值代表接收到的字节数,为0时表示连接断开,也有特例,此处暂不介绍
int count = clientSocket.EndReceive(ar);
//客户端关闭
if (count == 0)
{
clientSocket.Close();
clients.Remove(clientSocket);
Console.WriteLine("Socket关闭");
return;
}
string recvStr = System.Text.Encoding.Default.GetString(state.readBuff, 0, count);
byte[] sendBytes = System.Text.Encoding.Default.GetBytes("Server Received:" + recvStr);
//减少代码量,不用异步
clientSocket.Send(sendBytes);
clientSocket.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state);
}
catch (SocketException e)
{
Console.WriteLine("Socket接收失败"+e);
}
}
}
}
build客户端程序,开启多个客户端并连接到服务端,测试成功
在异步服务端基础上修改接收消息的回调函数,遍历并将消息发送给所有客户端即可
private static void ReceiveCallback(IAsyncResult ar)
{
try
{
ClientState state = (ClientState)ar.AsyncState;
Socket clientSocket = state.socket;
//返回值代表接收到的字节数,为0时表示连接断开,也有特例,此处暂不介绍
int count = clientSocket.EndReceive(ar);
//客户端关闭
if (count == 0)
{
clientSocket.Close();
clients.Remove(clientSocket);
Console.WriteLine("Socket关闭");
return;
}
string recvStr = System.Text.Encoding.Default.GetString(state.readBuff, 0, count);
Console.WriteLine("服务端收到:"+recvStr);
//记录发送消息的客户端的ip和端口,并广播消息给所有玩家
string sendStr = clientSocket.RemoteEndPoint.ToString() + ":" + recvStr;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
foreach (ClientState s in clients.Values)
{
s.socket.Send(sendBytes);
}
clientSocket.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state);
}
catch (SocketException e)
{
Console.WriteLine("Socket接收失败"+e);
}
}
在异步客户端的基础上修改接收消息的回调函数,显示历史消息即可
public void ReceiveCallback(IAsyncResult asyncResult)
{
try
{
Socket socket = (Socket)asyncResult.AsyncState;
int count = socket.EndReceive(asyncResult);
string s=System.Text.Encoding.Default.GetString(readBuff, 0, count);
//每次在历史消息的基础上加上本次消息
recvStr = s + "\n" + recvStr;
socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
}
catch (SocketException e)
{
Debug.Log("接收失败"+e);
}
}
微软提供了一个Poll方法,能检测Socket的状态,我们只在可读的时候才Receive,就能避免客户端卡在Receive状态了
Update里面不停地判断有没有数据可读,如果有数据可读才调用Receive,将microSeconds设为0(不阻塞模式),此处暂时不处理阻塞Send。
///前面代码与普通客户端相同,此处略,仅列出修改部分代码
public void Send()
{
//获取输入框内容
string sendStr = inputField.text;
//将输入框内容转换为字节数组
byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(sendStr);
//阻塞方法,发送消息到服务端
socket.Send(sendBytes);
}
private void Update()
{
if (socket == null)
{
return;
}
if (socket.Poll(0, SelectMode.SelectRead))
{
//声明字节数组,用来存储收到的数据
byte[] readBuff = new byte[1024];
//Receive方法,阻塞,接收来自服务端的数据存入readBuff中,返回数据的字节数
int count = socket.Receive(readBuff);
//用C#自带的解码器将二进制数据转换为字符串
string recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
//将收到的内容显示到屏幕上
text.text = recvStr;
}
}
服务端可以不断检测监听Socket和各个客户端Socket的状态,如果收到消息,则分别处理。
注意:
1、这里将Poll的超时时间设置为0,程序不会有任何等待。如果设置较长的超时时间,服务端将无法及时处理多个客户端同时连接的情况。当然,这样设置也会导致程序的CPU占用率很高。
2、在主循环最后调用了System.Threading.Thread.Sleep(1),让程序挂起1毫秒,这样做的目的是避免死循环,让CPU有个短暂的喘息时间。
namespace ServerPoll
{
class ClientState
{
public Socket socket;
public byte[] readBuff = new byte[1024];
}
internal class Program
{
private static Socket serverSocket;
private static Dictionary<Socket, ClientState> clients = new();
private static string sendStr;
public static void Main(string[] args)
{
serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ipEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8888);
serverSocket.Bind(ipEndPoint);
serverSocket.Listen(0);
Console.WriteLine("服务器启动成功");
while (true)
{
if (serverSocket.Poll(0, SelectMode.SelectRead))
{
ReadServerSocket();
}
foreach (ClientState s in clients.Values)
{
Socket clientSocket = s.socket;
if (clientSocket.Poll(0, SelectMode.SelectRead))
{
//出错以后会将Socket从字典里删除,由于当前处在遍历中,遍历的长度发生改变会出错,故跳出遍历
if (!ReadClient(clientSocket))
{
break;
}
}
}
//防止cpu占用过高
System.Threading.Thread.Sleep(1);
}
}
private static bool ReadClient(Socket clientSocket)
{
ClientState state = clients[clientSocket];
int count = 0;
try
{
count = clientSocket.Receive(state.readBuff);
}
catch (SocketException e)
{
clientSocket.Close();
clients.Remove(clientSocket);
Console.WriteLine("接收Socket出错"+e);
return false;
}
//广播消息
string recvStr =System.Text.Encoding.Default.GetString(state.readBuff, 0, count);
Console.WriteLine("服务端收到:"+recvStr);
//记录发送消息的客户端的ip和端口,并广播消息给所有玩家
sendStr =sendStr+"\n"+ clientSocket.RemoteEndPoint.ToString() + ":"+ recvStr;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
foreach (ClientState s in clients.Values)
{
s.socket.Send(sendBytes);
}
return true;
}
//Accept客户端,并添加客户端的信息
private static void ReadServerSocket()
{
Console.WriteLine("服务器接收Socket");
Socket clientSocket = serverSocket.Accept();
ClientState state = new ClientState();
state.socket = clientSocket;
clients.Add(clientSocket,state);
}
}
}
若没有收到客户端数据,服务端也一直在循环,浪费了CPU。Poll客户端也是同理,没有数据的时候还总在Update中检测数据,同样是一种浪费。从性能角度考虑,还有不小的改进空间。
同时检测多个Socket的状态。在设置要监听的Socket列表后,如果有一个(或多个)Socket可读(或可写,或发生错误信息),那就返回这些可读的Socket,如果没有可读的,那就阻塞。
利用Select方法可以实现多路复用
public static void Select(
IList checkRead,
IList check Write,
IList checkError,
int microSeconds
)
Select可以确定一个或多个Socket对象的状态。使用它时,须先将一个或多个套接字放入IList中。通过调用Select(将IList作为checkRead参数),可检查Socket是否具有可读性。若要检查套接字是否具有可写性,可使用checkWrite参数。若要检测错误条件,可使用checkError。在调用Select之后,Select将修改IList列表,仅保留那些满足条件的套接字。把包含6个Socket的列表传给Select, Select方法将会阻塞,等到超时或某个(或多个)Socket可读时返回,并且修改checkRead列表,仅保存可读的socket A和socket C。当没有任何可读Socket时,程序将会阻塞,不占用CPU资源。
只需要在Poll服务端的基础上稍作修改即可,这里将阻塞时间设为1秒
public static void Main(string[] args)
{
serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ipEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8888);
serverSocket.Bind(ipEndPoint);
serverSocket.Listen(0);
Console.WriteLine("服务器启动成功");
List<Socket> checkRead = new List<Socket>();
while (true)
{
checkRead.Clear();
checkRead.Add(serverSocket);
foreach (ClientState s in clients.Values)
{
checkRead.Add(s.socket);
}
Socket.Select(checkRead,null,null,1000);
foreach (Socket s in checkRead)
{
if (s == serverSocket)
{
ReadServerSocket();
}
else
{
ReadClient(s);
}
}
}
}
只需要声明一个list存储Socket,然后在Update里检查即可,注意该List只有一个元素
private List<Socket> checkRead;
//中间代码略,与Poll客户端相同
private void Update()
{
if (socket == null)
{
return;
}
checkRead.Clear();
checkRead.Add(socket);
Socket.Select(checkRead,null,null,0);
foreach (Socket s in checkRead)
{
//声明字节数组,用来存储收到的数据
byte[] readBuff = new byte[1024];
//Receive方法,阻塞,接收来自服务端的数据存入readBuff中,返回数据的字节数
int count = socket.Receive(readBuff);
//用C#自带的解码器将二进制数据转换为字符串
string recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
//将收到的内容显示到屏幕上
text.text = recvStr;
}
}
修改代码量不多,这里就不测试了。和Poll模式一样,需要在Update里不断检测数据,Cpu消耗较大,商业上为了做到性能上的极致,大多使用异步(或使用多线程模拟异步程序)。
Unity3D网络游戏实战(第2版)-罗培羽