网络游戏开发基础(二):Socket编程与聊天室案例

一、Socket概念

1、Socket是什么

网络上的两个程序通过一个双向的通信连接实现数据交换,这个连接的一端称为一个Socket。一个Socket包含了进行网络通信必需的五种信息:连接使用的协议、本地主机的IP地址、本地的协议端口、远程主机的IP地址和远程协议端口。

2、Socket通信流程

在这里插入图片描述

3、Socket类常用API

常用方法
在这里插入图片描述
常用属性
在这里插入图片描述

二、最基础的Scoket案例

1、客户端

在Unity中创建两个按钮,用来连接服务端和发送数据,创建一个输入框和文本用来发送和显示接收的数据
在这里插入图片描述
编写客户端代码并挂载到场景任意物体上

/// <summary>
/// 客户端代码
/// </summary>
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();
    }
}

2、服务端

namespace Socket服务端
{
    /// <summary>
    /// 服务端代码
    /// </summary>
    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);
            }
        }
    }
}

3、运行测试

运行客户端和服务端,点击Connect,连接成功
在这里插入图片描述

三、异步和多路复用

上一个案例的程序全部使用阻塞API(Connect、Send、Receive等),可称为同步Socket程序,它简单且容易实现,但时不时卡住程序却成为致命的缺点。客户端一卡一顿、服务端只能一次处理一个客户端的消息,不具有实用性。可以用异步和多路复用两种技术解决阻塞问题。

1、实现异步方法的原理

IAsyncResult是.NET提供的一种异步操作,通过名为Begin×××和End×××的两个方法来实现原同步方法的异步调用。Begin×××方法包含同步方法中所需的参数,此外还包含另外两个参数:一个AsyncCallback委托和一个用户定义的状态对象。委托用来调用回调方法,状态对象用来向回调方法传递状态信息。且Begin×××方法返回一个实现IAsyncResult接口的对象,End×××方法用于结束异步操作并返回结果。End×××方法含有一个IAsyncResult参数,用于获取异步操作是否完成的信息,它的返回值与同步方法相同。

2、异步客户端

使用异步方法将Connect和Receive方法修改为异步执行

/// <summary>
/// 异步客户端代码
/// </summary>
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不会卡住程序,当数据成功写入输入缓冲区(或发生错误)时会调用回调函数。
在这里插入图片描述

3、异步服务端

同步服务端程序同一时间只能处理一个客户端的请求,因为它会一直阻塞,等待某一个客户端的数据,无暇接应其他客户端。使用异步方法,可以让服务端同时处理多个客户端的数据,及时响应。
实现原理:创建一个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);
            }
        }
    }
}

4、运行测试

build客户端程序,开启多个客户端并连接到服务端,测试成功
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

四、聊天室

1、服务端

在异步服务端基础上修改接收消息的回调函数,遍历并将消息发送给所有客户端即可

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);
            }
        }

2、客户端

在异步客户端的基础上修改接收消息的回调函数,显示历史消息即可

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);
        }
    }

3、运行测试

结果如图,测试成功
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

五、其他改进方案

1、状态检测Poll

1.1、什么是Poll

微软提供了一个Poll方法,能检测Socket的状态,我们只在可读的时候才Receive,就能避免客户端卡在Receive状态了
在这里插入图片描述

1.2、Poll客户端

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;
        }
    }

1.3、Poll服务端

服务端可以不断检测监听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);
        }
    }
}

1.4、运行测试

成功
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

1.5、Poll模式的弊端

若没有收到客户端数据,服务端也一直在循环,浪费了CPU。Poll客户端也是同理,没有数据的时候还总在Update中检测数据,同样是一种浪费。从性能角度考虑,还有不小的改进空间。

2、多路复用Select

2.1、什么是多路复用

同时检测多个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资源。
在这里插入图片描述

2.2、Select服务端

只需要在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);
                   }
               }
            }
        }

2.3、Select客户端

只需要声明一个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;
        }
    }

2.4、Select模式的弊端

修改代码量不多,这里就不测试了。和Poll模式一样,需要在Update里不断检测数据,Cpu消耗较大,商业上为了做到性能上的极致,大多使用异步(或使用多线程模拟异步程序)。

参考内容

Unity3D网络游戏实战(第2版)-罗培羽

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

拉达哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值