Socket网络通信(3)
在上次的笔记中,其实是留了一个小的隐患,做过测试的人可能会发现,客户端接收数据只能接收一个,因为Accept方法是个阻塞方法,为了让客户端或服务器一直去接收消息,我们可以用个While循环,让socket一直处于接收状态。
while (true)
{
//等待客户端连入
Socket client = socket.Accept();
Console.WriteLine("客户端连入");
}
上次说了同步Socket程序,今天呢,说一下异步Socket程序。
同步程序中,服务器使用Accept接收请求,客户端使用Connect连接服务器。相对的,在异步模式下,服务器可以使用BeginAccept和EndAccept方法完成连接到客户端的任务。我会通过一个多人聊天的例子来说明异步Socket程序。
Socket里面的Begin开头的Api基本都是异步方法。涉及到的知识点大家自己去了解吧,比如BeginAccept,BeginReceive等。
我说一下多人聊天的思路:
首先我会用C#写一个后台的服务器,服务器的逻辑处理我会通过控制台展示出来。
然后用Unity去搭建UI,把客户端的逻辑放在它里面,让它去更新消息。
后台和前台共同持有的类我写在一个类库文件里面了,这样我可以把我的类库文件生成.dll文件,把它分别添加到前后端项目中。我说一下用类库文件的优点,如果随着项目的扩展,前后端共同持有的类也会越来越多,那么我们可以通过这种方式,很方便的实现前后端的数据类同步。如果在同一台电脑上的两个项目也都持有我这个类库文件。那么我如果更新了类库文件,重新生成之后。这两个项目中的类库文件也都会随着更新。这个多人聊天例子中呢,并没有非常多的数据,也只是需要发送和接受一些字符串数据,所以这里并没有写那么多的数据类,当然如果在我们的项目中,数据通信模块,与其他模块的交互肯定会有更多的数据类,比如与装备模块,与任务模块,与人物信息模块等的交互,会发送不同的数据,前后端肯定会有很多共同的数据类要持有。
闲话不多说了,直接上代码吧! 我们知道服务器需要处理多个客户端连接,它需要用一个数组来维护所有客户端的连接。每个客户端都有自己的Socket和缓冲区。所以我在服务端项目中定义了Conn类作为缓冲区,同时它也是服务端程序的重要数据结构。
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net.Sockets;
namespace Common
{
/// <summary>
/// 表示客户端连接的类
/// </summary>
public class Conn
{
/// <summary>
/// 常量
/// </summary>
public const int BUFFER_SIZE = 1024;
/// <summary>
/// Socket
/// </summary>
public Socket socket;
/// <summary>
/// 是否在使用
/// </summary>
public bool isUse =false;
/// <summary>
/// 缓冲区Buff
/// </summary>
public byte[] readBuffer =null;
public int bufferCount = 0;
/// <summary>
///构造函数
/// </summary>
/// <param name="socket"></param>
public Conn()
{
readBuffer = new byte[BUFFER_SIZE];
}
/// <summary>
/// 初始化, 在实例化的时候把对应的Socket传进来,表示该socket可用,bufferCount默认为0
/// </summary>
/// <param name="socket"></param>
public void Init(Socket socket)
{
this.socket = socket;
isUse = true;
bufferCount = 0;
}
/// <summary>
/// 缓冲区剩余的字节数
/// </summary>
/// <returns></returns>
public int BufferRemain()
{
return BUFFER_SIZE - bufferCount;
}
/// <summary>
/// 获取当前客户端socket的IP地址和端口
/// </summary>
/// <returns></returns>
public string GetAdress()
{
if (!isUse)
return "无法获取地址";
return socket.RemoteEndPoint.ToString();
}
/// <summary>
/// 关闭当前客户端连接
/// </summary>
public void Close()
{
if (!isUse)
return;
Console.WriteLine("[断开连接]" + GetAdress());
socket.Close();
isUse = false;
}
}
}
接下来就是服务端代码。都写了详细的注释。
using Common;
using System;
using System.Net;
using System.Net.Sockets;
namespace Server
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("hello,Game");
//开启服务器
Server server = new Server();
server.Start("127.0.0.1", 1234);
//如果输入quit,退出程序
while (true)
{
string str = Console.ReadLine();
switch (str)
{
case "quit":
return;
}
}
}
}
/// <summary>
///服务端程序类
/// 它包含一个Conn类型的对象池(数组),用于维护客户端的连接。
/// </summary>
public class Server
{
/// <summary>
/// 服务端监听套接字
/// </summary>
public Socket listenSocket;
/// <summary>
/// 客户端连接池
/// </summary>
public Conn[] conns;
/// <summary>
///最大连接数,监听队列最大个数
/// </summary>
public int maxConn = 50;
/// <summary>
///获取连接池的索引,如果返回负数,表示获取失败
/// 方便异步接受客户端的Socket的调用
/// </summary>
/// <returns>The index.</returns>
public int NewIndex()
{
//如果当前数组为空,则获取失败,否则遍历该数组找出空的位置,创建一个Conn连接类,并返回索引,或者返回一个isUse为false的conn的索引。
if (conns == null)
return -1;
for (int i = 0; i < conns.Length; i++)
{
if (conns[i] == null)
{
conns[i] = new Conn();
return i;
}
else if (conns[i].isUse ==false)
{
return i;
}
}
return -1;
}
/// <summary>
/// 开启服务器
/// </summary>
/// <param name="host">Host.</param>
/// <param name="port">Port.</param>
public void Start(string host,int port)
{
//连接池初始化,并将每一个Conn实例化,涉及知识:连接池的核心思想是连接复用,通过建立一个连接池及一套连接使用、分配、管理的策略,
//使得不必每次新建连接都要生成Conn实例。因为服务端对性能要求比较高,生成Conn实例将涉及内存的申请分配等操作,相对比价好费时间
//(尽管是很短很短的时间)。连接池因为使用了固定数组,会占用更多的内存,所以它是一个“空间换时间”的策略。
conns = new Conn[maxConn];
for (int i = 0; i < conns.Length; i++)
{
conns[i] = new Conn();
}
//Socket服务端监听套接字
listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//Bind
IPAddress ipAdr = IPAddress.Parse(host);
IPEndPoint ipEp = new IPEndPoint(ipAdr, port);
listenSocket.Bind(ipEp);
//Listen
listenSocket.Listen(maxConn);
//Accept
listenSocket.BeginAccept(AcceptCb, null);
Console.WriteLine("[服务器]启动成功");
}
/// <summary>
/// Accept回调,BeginAccept的回调函数。它里面处理了三件事情。
/// 1.给连接分配Conn
/// 2.异步接受客户端数据。
/// 3.再次调用AcceptCb实现循环接收。
/// </summary>
/// <param name="ar">Ar.</param>
public void AcceptCb(IAsyncResult ar)
{
//这里用了一个Try-Catch结构,它呢是专门处理异常的结构。它允许将任何可能发生异常情形的程序代码放置在try{}中进行监控
//如Accept方法,因为它是个阻塞方法,可能当我这边的客户端断开或关闭的时候,服务端Accept接收失败,它就会报出异常,
//若有异常发生,catch{}里面的代码将会被执行。catch语句中的Exception e附带了异常信息,可以将其打印出来。
try
{
Socket socket = listenSocket.EndAccept(ar);
int index = NewIndex();
//如果小于0,表示连接池已满,则拒绝连接,否则给新的连接分配Conn
if (index < 0)
{
socket.Close();
Console.WriteLine("[警告]连接已满");
}
else
{
//给新的连接分配Conn
Conn conn = conns[index];
conn.Init(socket);
string adr = conn.GetAdress();
Console.WriteLine("客户端连接[" + adr +"] conn池ID:" + index);
//异步接收客户端的数据
socket.BeginReceive(conn.readBuffer, conn.bufferCount, conn.BufferRemain(), SocketFlags.None, ReceiveCb, conn);
//再次调用BeginAccept实现循环
listenSocket.BeginAccept(AcceptCb, null);
}
}
catch (Exception e)
{
Console.WriteLine("AcceptCb失败:" + e.Message);
}
}
/// <summary>
/// Receive回调,BeginReceive的回调函数,它里面处理了三件事情。
/// 1.接收并处理消息,因为是多人聊天,服务端收到消息后,要把它转发给所有人。
/// 2.如果收到客户端关闭连接的信号(count==0),则断开连接。
/// 3.继续调用ReceiveCb接收下一个数据
/// </summary>
/// <param name="ar">Ar.</param>
public void ReceiveCb(IAsyncResult ar)
{
//获取BeginReeive传入的Conn对象
Conn conn = (Conn)ar.AsyncState;
try
{
//获取接收的字节数
int count = conn.socket.EndReceive(ar);
//关闭信号
if (count <= 0)
{
Console.WriteLine("收到[" + conn.GetAdress() +"]断开连接");
conn.Close();
return;
}
//数据处理
string str = System.Text.Encoding.UTF8.GetString(conn.readBuffer, 0, count);
Console.WriteLine("收到[" + conn.GetAdress() +"]数据:" + str);
str = conn.GetAdress() + ":" + str;
byte[] bytes = System.Text.Encoding.Default.GetBytes(str);
//广播,将消息发送给所有正在使用的连接
for (int i = 0; i < conns.Length; i++)
{
if (conns[i] == null)
continue;
if (!conns[i].isUse)
continue;
Console.WriteLine("将消息转播给" + conns[i].GetAdress());
conns[i].socket.Send(bytes);
}
//继续接收
conn.socket.BeginReceive(conn.readBuffer, conn.bufferCount, conn.BufferRemain(), SocketFlags.None, ReceiveCb, conn);
}
catch (Exception ex)
{
Console.WriteLine("收到[" + conn.GetAdress() +"]断开连接");
conn.Close();
}
}
}
}
后台写完了,该前台了。下面是用NGUI搭建的UI,有点丑勿喷。
相应的客户端代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net;
using System.Net.Sockets;
using System;
/// <summary>
///客户端程序
/// </summary>
public class Net : MonoBehaviour {
/// <summary>
/// 服务器Ip和端口号Input
/// </summary>
public UIInput hostInput;
public UIInput portInput;
/// <summary>
/// 聊天区域
/// </summary>
public UITextList uiTextList;
//收到的消息
public string recvStr;
/// <summary>
/// 聊天输入框
/// </summary>
public UIInput chatInput;
/// <summary>
///客户端Ip和端口Lable
/// </summary>
public UILabel clientIpLable;
/// <summary>
/// buttons
/// </summary>
public UIButton connectBtn;
public UIButton sendBtn;
/// <summary>
///Socket
/// </summary>
private Socket socket;
/// <summary>
///常量,buffer的最大个数
/// </summary>
public const int BUFFER_SIZE=1024;
/// <summary>
///接收缓冲区
/// </summary>
public byte[] readBuffer =null;
public Stack<string> messages=null;
private void Awake()
{
readBuffer = new byte[BUFFER_SIZE];
messages = new Stack<string>();
}
/// <summary>
///连接服务器
/// 当"连接服务器按钮"按下时,调用此方法。它依照socket-connect的流程连接到服务端,然后调用BeginReceive开启异步接收
/// </summary>
public void Connection()
{
recvStr = "";
//Socekt
socket=new Socket(AddressFamily.InterNetwork,SocketType.Stream,ProtocolType.Tcp);
//connect
string host=hostInput.value;
int port = int.Parse(portInput.value);
socket.Connect (host, port);
clientIpLable.text = socket.LocalEndPoint.ToString ();
//Receive
socket.BeginReceive(readBuffer,0,BUFFER_SIZE,SocketFlags.None,ReceiveCb,null);
}
/// <summary>
/// 接收回调
///当收到服务端发来的消息时,异步接收回调ReceiveCb将被调用。它先是解析服务端的消息,将聊天语句存入recvStr中,然后再调用BeginReceive开启下一次异步接收。
/// </summary>
/// <param name="ar">Ar.</param>
public void ReceiveCb(IAsyncResult ar)
{
try {
//count是接受数据的大小
int count= socket.EndReceive (ar);
//数据处理
string str=System.Text.Encoding.UTF8.GetString(readBuffer,0,count);
if (recvStr.Length > 300)
recvStr = "";
recvStr+=str;
//发消息提醒前台更新UI
SendMessage(recvStr);
//继续接收
socket.BeginReceive(readBuffer,0,BUFFER_SIZE,SocketFlags.None,ReceiveCb,null);
} catch (Exception ex) {
recvStr = "连接已断开";
SendMessage(recvStr);
socket.Close ();
}
}
/// <summary>
/// 发送数据
/// 当点击“发送”按钮时,执行该方法。它将输入框的内容发送给服务器
/// </summary>
public void Send()
{
string str = chatInput.value;
byte[] bytes = System.Text.Encoding.UTF8.GetBytes (str);
try {
socket.Send(bytes);
chatInput.text = "";
} catch (Exception ex) {
}
}
public void SendMessage(string message)
{
messages.Push(socket.LocalEndPoint.ToString() + ":" +message);
}
public void Start()
{
//给button绑定回调方法
connectBtn.onClick.Add(new EventDelegate(Connection));
sendBtn.onClick.Add(new EventDelegate(Send));
}
/// <summary>
///因为只有主线程才能修改UI组件的属性,因此把它放在Update里面更新
/// </summary>
public void Update()
{
if (messages.Count > 0) {
string message=messages.Pop ();
uiTextList.Add (message);
}
}
}
代码完工后,就开始测试了。
先打开后台服务器,运行起来。因为是控制台程序,运行结果如下:
然后打开客户端程序,我们把它生成.exe文件,然后就可以打开多个了,打开之后就可以测试了。输入服务器IP和端口号,连接服务器。测试结果如下:
我再打开一个客户端,我输入消息,点击发送,看看是否消息同步,测试结果如下: