【五子棋与Socket - 01】服务端实现思路
需求
- 服务器需要同时与两个客户端通信,实现对战
- 由于两个客户端连接顺序不确定,所以需要动态分配连接端口
- 也可使使用多线程实现
- 服务器需要对两个客户端回传的游戏数据实现转发
与客户端通信
动态分配连接端口
原因
因为我们需要两个客户端同时与服务端通信,且我们的每个客户端并不固定自己是玩家1还是玩家2,自然不能固定地与服务端的某个端口通信
这样就需要一个机制,使两个相同的客户端可以自动地选择通信端口
实现
我们可以使客户端与服务端连接时,进行两次连接,由服务端分配端口;
第一次连接,客户端连接到服务端的一个固定的初始端口,这时服务端接受并分配给客户端一个端口,随后将端口号回传给客户端,然后释放该初始端口;
第二次连接,客户端使用回传的端口号与服务端通信并开始游戏
端口选择
这里我设计一个函数,实现接收客户端的连接并回传端口号,最后回收该初始端口使用的套接字
int init(int cnt) // 初始连接
{
// 创建监听套接字
int fdo = socket(AF_INET, SOCK_STREAM, 0);
if (fdo == -1)
{
perror("socket");
return -1;
}
// 绑定本地IP端口 - 60006
struct sockaddr_in saddro;
saddro.sin_family = AF_INET;
saddro.sin_port = htons(60006);
saddro.sin_addr.s_addr = INADDR_ANY;
int reto = bind(fdo, (struct sockaddr*)&saddro, sizeof saddro);
if (reto == -1)
{
perror("bind");
return -1;
}
// 设置监听
reto = listen(fdo, 128);
if (reto == -1)
{
perror("listen");
return -1;
}
// 阻塞并等待客户端连接
struct sockaddr_in caddro;
int addrleno = sizeof caddro;
int cfdo = accept(fdo, (struct sockaddr*)&caddro, &addrleno);
if (cfdo == -1)
{
perror("accept");
return -1;
}
// 回传连接用端口末位数
char lstpt[10];
sprintf(lstpt, "%d", cnt);
int lenlstpt = sizeof lstpt;
send(cfdo, lstpt, lenlstpt, 0);
// 回收套接字
close(fdo);
close(cfdo);
}
- 这个函数首先传入一个数字 - 为第几个连接的客户端
- 这个函数会回传正式连接使用的端口的末位数,在客户端,将60000加上回传的数字即为正式连接的端口号;这个只是我设计的机制,因为我将60000~60010分配给了该项目使用,当然也可以直接回传指定的端口号
- 我使用的初始端口是60006,你可以改为其他端口
- 最后不要忘记回收使用的套接字
端口复用
运行时,当第二个客户端连接时,服务端bind函数会报错:地址已被占用
这是因为TCP协议规定,主动关闭连接的一方处于TIME_WAIT状态,等待两个MSL的时间后才能回到CLOSED状态,这里我们的server端回传端口后就关闭了套接字,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server 端口。
这里我们可以使用setsockopt函数,设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符
具体实现:
在socket()函数和bind()函数间插入以下代码:
// 设置端口复用
int opt =1;
setsockopt(fdo, SOL_SOCKET, SO_REUSEADDR,& opt, sizeof(opt));
连接客户端
实现
流程:
- 客户端第一次连接并获得端口号
- 客户端使用端口号连接并保持
// 初始连接1
int retn = init(1);
if (retn == -1)
{
perror("init_1");
return -1;
}
// 连接玩家1
// 创建监听套接字
int fd1 = socket(AF_INET, SOCK_STREAM, 0);
if (fd1 == -1)
{
perror("socket");
return -1;
}
// 绑定本地IP端口 - 60001
struct sockaddr_in saddr1;
saddr1.sin_family = AF_INET;
saddr1.sin_port = htons(60001);
saddr1.sin_addr.s_addr = INADDR_ANY;
int ret1 = bind(fd1, (struct sockaddr*)&saddr1, sizeof saddr1);
if (ret1 == -1)
{
perror("bind");
return -1;
}
// 设置监听
ret1 = listen(fd1, 128);
if (ret1 == -1)
{
perror("listen");
return -1;
}
// 阻塞并等待客户端连接
struct sockaddr_in caddr1;
int addrlen1 = sizeof caddr1;
int cfd1 = accept(fd1, (struct sockaddr*)&caddr1, &addrlen1);
if (cfd1 == -1)
{
perror("accept");
return -1;
}
// 若连接成功,打印客户端的IP与端口信息
char ip1[32];
printf("1客户端IP: %s, 端口: %d\n", inet_ntop(AF_INET, &caddr1.sin_addr.s_addr, ip1, sizeof ip1), ntohs(caddr1.sin_port));
// 初始连接2
retn = init(2);
if (retn == -1)
{
perror("init_2");
return -1;
}
// 连接玩家2
// 创建监听套接字
int fd2 = socket(AF_INET, SOCK_STREAM, 0);
if (fd2 == -1)
{
perror("socket2");
return -1;
}
// 绑定本地IP端口 - 60002
struct sockaddr_in saddr2;
saddr2.sin_family = AF_INET;
saddr2.sin_port = htons(60002);
saddr2.sin_addr.s_addr = INADDR_ANY;
int ret2 = bind(fd2, (struct sockaddr*)&saddr2, sizeof saddr2);
if (ret2 == -1)
{
perror("bind2");
return -1;
}
// 设置监听
ret2 = listen(fd2, 128);
if (ret2 == -1)
{
perror("listen2");
return -1;
}
// 阻塞并等待客户端连接
struct sockaddr_in caddr2;
int addrlen2 = sizeof caddr2;
int cfd2 = accept(fd2, (struct sockaddr*)&caddr2, &addrlen2);
if (cfd2 == -1)
{
perror("accept2");
return -1;
}
// 若连接成功,打印客户端的IP与端口信息
char ip2[32];
printf("2客户端IP: %s, 端口: %d\n", inet_ntop(AF_INET, &caddr2.sin_addr.s_addr, ip2, sizeof ip2), ntohs(caddr2.sin_port));
绑定IP
由于我的服务端架设在云服务器VPS上,与部署在本地或者实体服务器不同;
如果在实体服务器上,那么bind时就应该填写服务器的公网IP;
但是云服务器实际上是多个虚拟机,直接绑定公网IP并不能访问到对应位置,因为服务器内存只有一个内网网卡,外部公网是通过NAT映射方式访问内网网卡业务;
需要绑定0.0.0.0 - 即宏 INADDR_ANY
,这个宏的实际值就是0,这时再在外部安全组开放对应端口,就可以访问了;
外部访问时依旧是使用服务器的公网IP访问 - 与实体服务器一样
通信
这里我们只需要与客户端1,2轮流通信即可,于是我们可以使用一个变量tar控制,tar为0时与客户端1通信,tar为1时与客户端2通信;
tar的变动可以反复异或1来实现
// 通信
int tar = 0; // 规定首先连接为1;后连接为0
while (1)
{
// 切换对象
tar ^= 1;
// 给玩家2发信息
if (tar)
{
int len = recv(cfd1, buff, sizeof buff, 0);
int lennum = sizeof num;
if (len > 0)
{ } // 接收到数据
else if (len == 0)
{ } // 客户端断开连接
else
{
perror("recv");
break;
} //调用失败
}
// 给玩家1发信息
else
{
int len = recv(cfd2, buff, sizeof buff, 0);
int lennum = sizeof num;
if (len > 0)
{ } // 接收到数据
else if (len == 0)
{ } // 客户端断开连接
else
{
perror("recv");
break;
} //调用失败
}
}
回收资源
在游戏结束后,不要忘记关闭文件描述符
// 关闭文件描述符
close(fd1);
close(cfd1);
close(fd2);
close(cfd2);
评论区