《Unity3D网络游戏实战》学习与实践

纸上得来终觉浅,绝知此事要躬行~

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

效果:

状态检测Poll

参考书籍:《Unity3D网络游戏实战(第2版)》 (豆瓣) (douban.com)

相关推荐
快快小毛毛1 小时前
CC攻击防御策略要怎么调整?使用游戏盾有效解决
运维·服务器·网络·tcp/ip·游戏·udp
lys_132 小时前
wireshark打开时空白|没有接口,卸载重装可以解决
网络·测试工具·wireshark
椰椰椰耶2 小时前
【网络】TCP/IP 五层网络模型:网络层
网络·tcp/ip·php
网安詹姆斯2 小时前
网络安全(黑客技术)2024年三个月自学手册
网络·python·sql·安全·web安全
小小小小关同学2 小时前
【Gateway】Gateway Filter Factories
网络·gateway
两仪式quq2 小时前
服务网关Gateway快速入门
服务器·网络·gateway
无敌岩雀2 小时前
【计算机网络】TCP 协议——详解三次握手与四次挥手
网络·网络协议·tcp/ip·计算机网络
洁洁!2 小时前
深入分析计算机网络性能指标
网络·计算机网络·性能优化
邂逅岁月3 小时前
【网络原理】Tcp 常用提升效率机制——滑动窗口,快速重传,流量控制, 拥塞控制, 建议收藏 !!!
网络·tcp/ip·php·滑动窗口·流量控制·拥塞控制·快速重传
~kiss~3 小时前
QUIC的丢包处理
服务器·网络