本期知识点

  • Socket通信原理
  • 通信安全、网络安全
  • 制定通信协议
    • 登录、注册时的协议
    • 游戏进程中的通信协议
  • 通信内容的封装与解析
  • 连接状态的检测与服务器保护机制

0x03 网络编程,Socket从底层出发

在记录完基本基础开发过程之后,现在开始记录本次项目的核心内容:SOCKET。它在百度百科上是这样解释的:

所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议根进行交互的接口

Socket通信原理

套接字是通信的基础,例如,它是TCP/IP协议的基本操作单位。分为流套接字(SOCK_STREAM)数据报套接字(SOCK_DGRAM)、**原始套接字(SOCK_RAW)**三类。而前两种基本都用于已经制定好的协议TCP和UDP通信中,而我这里制作的小程序,显然用原始套接字更为简单,同时也是课程的要求。

在套接字通信中,有两个重要组成部分,客户端(Client Socket)和服务器端(Server Socket)。基本连接流程如下

graph LR
A[Server<br>>打开监听]-->B[Client<br>>通信请求]
B-->C[连接确认]

Socket表示方法:用点分十进制的IP地址加上端口号组成,中间用冒号,逗号,空格之类的符号隔开。例如:127.0.0.1 3366则是表示本地ip,端口号为3366,端口号随机设置就行,如果你不想和别人起冲突,那就不要设置最常见的就行 。

建立连接之后,就可以进行收发消息了,无论是客户端,还是服务器端,收发消息的函数是统一的:recvsend

int recv(SOCKET s, 		//指定接收端套接字描述符
         char FAR*buf, 	//指明一个缓冲区,存放recv函数接收到的数据
         int len, 		//指明buf的长度
         int flags);    //一般置0

int PASCAL FAR send( SOCKET s, //一个用于标识已连接套接口的描述字。
                    const char FAR* buf, //包含待发送数据的缓冲区。
                    int len, //缓冲区中数据的长度。
                    int flags);//调用执行方式。

套接字字描述符(SOCKET s),可在使用connect建立连接时指定SOCKET变量地址,连接成功后,即可获得描述符。

需要注意的是:recv函数是阻塞函数,具体会在下文中的 连接状态的检测与服务器保护机制 中提到。

通信安全、网络安全

原始套接字编程,在收发消息的时候,都是明文传输,在网络传输层,这是很危险的事情,只需要将网络传输拦截,即可查看到这些数据,而且是明文传输,那么直接就会被窃取到有用的信息。

相对于数据被窃取,更为严重的,则是数据被篡改,攻击者可以通过构造特殊攻击字符串到程序中,借此来攻击我们的数据库等等,安全问题不得不防。因为我们是游戏,所以还得保证游戏的公平性,避免不必要的bug。

而应对传输安全的问题,可以通过制定特殊的通信协议来完成,例如加入密码学原理,加密传输,再或者进行多端口同步分组传输等等。

事实上,在我们的项目中,还没有任何的保护措施,因为我觉得没有必要,我并不想在一次期末设计上花费太多精力,这个项目的代码前后就花了近十天的时间,能省点事就省点事吧,要是不过再来加上。

制定通信协议

没有专门针对网络安全问题,只是为了维持系统稳定的最低要求而制作的建议通信协议。所以该协议也不适用于其他地方,甚至你可能觉得它连协议都算不上,就是系统进程进行同步所需要发送的必要消息,完全没有额外的内容,没有效验,没有打包拆包等等。

登录、注册时的协议

程序登录,第一步要干嘛?传输账号?还是发送“我要登录”的信息?都不是。而是先连接服务器,在成功连接服务器的情况下,才能进行程序的基本操作,登录or注册。

sequenceDiagram
title: 登录协议流程
participant Client
participant Sever
participant Mysql
note over Mysql:打开服务
note over Sever:打开监听(默认端口号)
Client->>Sever:发送连接请求
Sever-->>Client:连接成功
Client->>Sever:登录请求
Sever->>Mysql:连接数据库,查询测试信息
Mysql-->>Sever:连接成功
note over Sever:准备完毕,发送允许登录
Sever-->>Client:允许登录,请发先账号
Client->>Sever:账号
Mysql-->>Sever:获取用户表表
note over Sever:在用户表中查找账号
Sever-->>Client:找到了,请发密码/未找到,无账号
note left of Client:有账号,发送密码<br>/无账号:登录失败
Client->>Sever:密码
note over Sever:查找到账号对应的密码<br>比对两个密码
Sever-->>Client:登录成功/密码错误
Sever->>Mysql:关闭连接
note left of Client:登录成功,进入游戏<br>/密码错误,重新登录

注册操作的协议流程和登录大相径庭,只是有个别地方不同

sequenceDiagram
title: 注册协议流程
participant Client
participant Sever
participant Mysql
note over Mysql:打开服务
note over Sever:打开监听(默认端口号)
Client->>Sever:发送连接请求
Sever-->>Client:连接成功
Client->>Sever:发送注册请求
Sever->>Mysql:连接数据库,查询测试信息
Mysql-->>Sever:连接成功
note over Sever:准备完毕,发送允许注册
Sever-->>Client:允许注册,请发先账号
Client->>Sever:账号
Mysql-->>Sever:获取用户表表
note over Sever:在用户表中查找账号
Sever-->>Client:无重复,请发密码/用户名重复
note left of Client:无重复,发送密码<br>/用户名重复,注册失败
Client->>Sever:密码
Sever->>Mysql:添加账号和密码信息到表中
Mysql-->>Sever:添加成功/失败
Sever-->>Client:注册成功/密码错误,服务器问题
Sever->>Mysql:关闭连接
note left of Client:注册成功,返回登录<br>/注册失败,重新注册

游戏进程中的通信协议

登录成功之后就是游戏的主程序了,主程序的通信协议尤为重要,以为要涉及到一个东西那就是,进程同步。简言之就是服务器端要和客户端同步,服务器要知道客户端在哪一个进程(或者你理解成游戏界面)了,此时对应的客户端的操作是什么,服务器要怎么回复客户端。

主界面

在上面的流程图中,只有三个选项涉及到与服务器的通信,真正通信的,只有两个:开始游戏(创建房间)和加入游戏(加入房间)。当程序关闭时,服务器端会自动检测到程序关闭(其实是通信线程关闭)并关闭与之对应的线程。

游戏界面

在游戏界面,玩家可以一直和对手PK,或者玩累了选择退出,对于另一个玩家,则是可以检测到对方已退出的,但是因为recv是阻塞函数,这里的游戏体验可能略显卡顿,例如:

  • 当你选择了出拳了之后就不能再选择退出了,只能等待对手出拳或者关闭程序,这对先出拳的玩家而言,如果对方不退出,也不退出,那么选择出拳,那么简直就是浪费该玩家的时间。可以通过增加出拳倒计时来解决(暂未解决)。
  • 如果先出拳的玩家(这里称之为玩家1)选择了退出,那么对于玩家2而言,他不是马上就能看到“对方已退出”这条消息的,而是要在自己出拳之后才能看到。但是我觉这点影响不大,暂时不打算解决。
  • 在玩家选择了创建房间之后,就会进入等待加入的界面,有人加入就会进入游戏,没人加入就一直等待。但是如果一直没人加入,就会卡在这里,这是比较严重的bug,所以我马上就修复了。我在该界面增加了一个返回选项

事实上,这个返回选项并不是那么好加的

  1. 方法一:本来我是打算直接添加一个返回按钮的,但是由于我在该界面设置了另一个阻塞函数Sleep,因为等待界面是用代码实时绘制出来的,每隔一秒左右就变换一次颜色(重绘一遍)。所以此时还要实现完整的鼠标点击功能,程序是肯定不能阻塞的,至少在这个线程里是这样,所以又得开多线程专门用于绘图,然后作为客户端,还要等待服务器端发送有人加入房间的信息,但是如果一直等待接收消息,程序又是阻塞状态。所以又得开一个线程?那么问题又来了,怎么发送消息呢?接收消息的线程此时是阻塞态,不能用,所以只能在主线程,或者再创建一个专门发送消息的线程。 OK截止到这里没有什么问题,客户端只是多了两个线程而已,有没有工作压力,但是对于服务器,也得创建与之对应的接收和发送消息的线程,对于单个用户登录,问题不大,但是对于多个用户呢?服务器端的任务量显然加重了。 另一方面,这样设计下来逻辑也很复杂:首先是一个主线程,主要是显示绘图(绘图由副线程1完成),等待结果(接收消息由副线程2完成),检测返回键的点击(如果点击就发送返回消息然后关闭相关线程再返回主菜单)。线程管理这事,其实挺麻烦的,必须得设置线程标识,预防锁死等等。
  2. 方法二:添加一个返回按键,和方法一的按钮不同,这里是指物理按键,我选择了键盘上的ESC键,使用GetAsyncKeyState函数(该函数非阻塞,会根据按键状态返回不同的值)检测该按键是否按下,按下就等于选择返回。然后是阻塞问题的解决:设置轮询代替之前的一直等待。每隔300ms就给服务器发送一次询问消息,服务器应答有人加入或是没人加入,如果用户按下ESC键则用退出消息代替询问消息,服务器即可进行相关操作。在一个线程里,就完美解决了该问题,因为同样存在阻塞函数(主要是Sleep),在阻塞期间用户按键是无效的,所以我会提示用户“ 长按ESC退出 ”。:smirk:

显而易见,方法二更为实用,虽然会一直收发消息,但是这点开销我认为是小于开多线程的。等待加入时,通信协议是这样的:

sequenceDiagram
title: 创建房间协议流程
participant Client:play1
participant Sever
participant Client:play2
Client:play1->>Sever:创建房间
note over Sever:查询是否可创建房间
Sever-->>Client:play1:可以创建
note over Client:play1:进入等待状态
Client:play1->>Sever:第1次询问:是否有人加入<br>/ESC按下,发送退出消息
Sever->>Client:play1:第1次应答:有/无 人加入
note right of Client:play1:....................
note left of Client:play1:如果按下ESC,<br>则直接退出等待
Client:play1->>Sever:第n次询问:是否有人加入<br>/ESC按下,发送退出消息
Sever->>Client:play1:第n次应答:有/无 人加入
note right of Client:play1:....................
Client:play2->>Sever:加入play1的房间
Sever->>Client:play1:第n+x次应答:有人加入
note over Client:play1:退出等待状态

因为轮询的间隔为300ms,所以当有人加入或者按ESC退出,程序反应速度都是很快的,500ms以内的延迟对这个小游戏来说,是完全没有任何影响和对游戏体验的损伤的。

当经历以上重重难关之后,终于来到了游戏对抗的环节,它的协议是这样的:

sequenceDiagram
title: 游戏对抗协议流程
participant Player1
participant Sever
participant Player2
Player1->>Sever:选择出拳
note over Player1:进入等待接收阻塞态
note over Sever:Choice_flag++
Player2->>Sever:选择出拳
note over Player2:进入等待接收阻塞态
note over Sever:Choice_flag++
note over Sever:Choice_flag%2 == 0?
Sever-->>Player1:Player2的选择
Sever-->>Player2:Player1的选择
note over Player1:本地判断结果
note over Player2:本地判断结果
Player2->>Sever:选择退出
note over Player2:退出游戏界面,返回主界面
note over Sever:Choice_flag++
Player1->>Sever:选择出拳
note over Player1:进入等待接收阻塞态
note over Sever:Choice_flag++
note over Sever:Choice_flag%2 == 0?
Sever-->>Player1:Player2的选择
Sever-->>Player2:Player1的选择
note over Player1:本地判断结果<br>检测到对方退出<br>退出游戏,回主界面

这里演示了玩家在第二回合就选择退出的场景,很好理解,在服务器设置有Choice_flag,每接收一个选择就自加,然后当它为2的倍数时,就判断为双方出拳完毕,然后返回另一个玩家的出拳,结果则在本地判断。如果对方选择退出,对方可直接退出,我方则需要等到出拳以后,如果我方挂机,这对对方而言,也是一种保护,保障了玩家的体验。

通信内容的封装与解析

关于游戏内通信时的内容,按理来说应该是具有封装与解析的,正确的封装应该包含封装头,信息内容,效验数据,封装尾等几大部分。

但是事实上基本都是明文或者特定值传输。甚至就连账号密码,都是明文传输的。唯一设计到封装的,则是房间列表的传输,将所有房间信息封装成一条信息,再由客户端解析出来。

//获取并解析房间列表
void GetRoomlist()
{
	char _buff[0x10],Buffer_grl[DEFAULT_BUFFER];
	_RecvMessage(Sock_Play, Buffer_grl);//接收到房间列表信息

	flag_L = 0;//房间数量计数器清零
	/*解析房间列表:BUFFER[i]与BUFFER[i+1]为一组数据,房间号和房间状态*/
	for (int i = 0; i < strlen(Buffer_grl) / 2; i++)
	{
		_itoa_s(Buffer_grl[2 * i], _buff, 10);//房间号为int,转为char
		ChartoTCHAR(_buff, roomlist[2 * i]);//char再转为可打印的字符类型(TCHAR)
		roomlist[2 * i + 1][0] = Buffer_grl[2 * i + 1];//高位为房间状态:已满/可加入
		flag_L++;
	}
	return;
}

这里就是对房间列表信息的解析过程,也是很简单,两位一组进行封装,低位为房间号,高位为房间状态。所以字符串长度的二分之一就是房间数量。这里我们只设置了8个房间的上限,在我测试的过程中,全开都没有什么问题。

至于其他的消息封装,就没有了,因为进程同步机制,所以不用封装也能很好的实现功能,都具有单独的消息处理函数。

当然,就算是明文,也是得说一下是怎样的明文,一般也就是设置一些特定值,因为0不方便传输,所以很多都是从1开始的,在客户端和服务器端使用同一套宏定义,对于完成代码来说,是很快捷的。例如:

//房间状态
#define _close 0
#define _open 1
#define _over 2

//加入游戏模式选择
#define _create 1
#define _enter 2

/*缓冲区大小*/
#define DEFAULT_BUFFER 4096 

//登录注册返回结果
#define _victory 1
#define _fail 2
#define _none 3

int playGame(int C)//这里的C是菜单界面的传参
{
	char BUFFER[DEFAULT_BUFFER];
	if (C == _create)
	{
		BUFFER[0] = _create;
		BUFFER[1] = 0;
		_SendMessage(Sock_Play, BUFFER);//发送创房请求
		_RecvMessage(Sock_Play, BUFFER);//接收房间号(1-100),创建失败接收到101
		if (BUFFER[0] == 101)
			return 1;
		if(_Loading(BUFFER[0]))
			CreateRoom();
	}
	else if (C == _enter)
	{
		BUFFER[0] = _enter;
		BUFFER[1] = 0;
		_SendMessage(Sock_Play, BUFFER);//发送加入房间请求请求
		Sleep(100);
		GetRoomlist();
		ChoiceRoom();
	}
	return 0;
}

上述代码中还有我常用到101,因为已经记住了,所以就没有宏定义了。

连接状态的检测与服务器保护机制

这里其实我在前面已经说过了,这里主要是针对服务器端的。因为客户端嘛,是人为控制的,见情况不对还能直接一键关闭,甚至 有时候来一些骚操作,不按照用户手册和正规操作方法来使用软件,客户端重启就能解决了问题,但是服务器端却不能随随便便重启,所以要优化代码以及对服务器端进行保护,在正常的操作情况下退出(菜单界面鼠标点击关闭),服务器端是可以接收到关闭信息的,然后服务器会关闭该线程以释放空间。

但是如果,有的用户玩着玩着突然就点 × 把软件关闭,如果服务器不能检测,那么线程就会一直存在并且越来越多,最后导致服务器崩溃。这里的解决办法也很简单,关键在于 recv 函数。

recv函数和send函数有一个最大的区别,一个是阻塞函数,一个是非阻塞函数。在我的服务器端和客户端,我都将这两个函数进行了打包,根据其返回信息做了处理,主要在于服务器端的recv函数,它是这样的

int _RecvMessage(SOCKET sock, char* Buffer)
{
	int ret;
	ret = recv(sock, Buffer, DEFAULT_BUFFER, 0);
	if (ret == 0)
		return 0;
	else if (ret == SOCKET_ERROR) {
		return 0;
	}
	Buffer[ret] = '\0';
	return 1;
}

而我通常是这么用它的:

if (!_RecvMessage(Sock, buffer))
{
	/* …… */
    return;
}

原理如下:recv为阻塞函数,当执行到该函数时,程序会进入等待状态,此时整个程序是阻塞态,不会继续运行,你可以理解为scanf,同时该函数还可以用于检测通信线程是否正常连接,如果断开了,则会返回一个 SOCKET_ERROR,所以根据这个值就可以判断客服端是否在线了,此时,就省略了“保活”这一操作。

我在每次接收时都是这样使用(服务器端),直接是断开连接了,就会得到SOCKET_ERROR,然后就不管进程到哪一步了直接一路return到最开始的线程函数

while (_RecvMessage(sock, Buffer))//主菜单界面接收客户端请求
{
    CorI = Buffer[0];
    CreateOrInter(sock,CorI,user.name);//加入房间还是创建房间
}
printf("玩家%s已断开连接\n", user.name);
return 0;

最后return 0,线程结束后关闭。

该方法经过我各种暴力测试运行结果良好基本没有问题。