💓博主CSDN主页:麻辣韭菜💓
⏩专栏分类:Linux初窥门径⏪
🚚代码仓库:Linux代码练习🚚
💻操作环境: CentOS 7.6 华为云远程服务器
🌹关注我🫵带你学习更多Linux知识
🔝
目录
🌃前言
在当今数字化时代,互联网已成为我们生活中不可或缺的一部分。从简单的网页浏览到复杂的云计算服务,网络技术支撑着现代社会的通信和数据交换。网络编程,作为这一基础设施的核心,不仅是一门科学,更是一门艺术,它要求开发者不仅要有深厚的技术功底,还要有创新和解决问题的能力。
本篇我们将探索网络协议的原理,学习如何使用套接字(sockets)进行网络通信,了解网络安全的重要性,能够实现一个简单的udp客户端/服务器; 能够实现一个简单的tcp客户端/服务器(单连接版本, 多进程版本, 多线程版本); 理解tcp服务器建立连接, 发送数据, 断开连接的流程;
🎇正文
1. 预备知识
1.1 端口号
在进行网络通信的时候,网络基础 【发展、协议、传输、地址】我们讲的是两台主机之间通信。但是如果我们微信不启动,对方发来的消息我们是接受不了的,两台主机通信其的是功能性上的通信,真正通信的是应用层(APP)
网络基础 【发展、协议、传输、地址】在这篇中我们主要讲解的是网络协议栈的下三层。对于应用层我们是没有讲的,下三层主要解决的是,数据安全可靠的送到远端机器。数据送到远端机器并不重要,重要的是远端机器如何处理数据,才是关键。
也就是说用户要进行通信得先把APP启动起来才能通信。——>是不是就一个进程?
可是进程是具有独立性的,要通信的前提是得有一份公共资源,而网络协议栈不就是公共资源吗?
数据链路层、网络层、传输层、是确保通信的手段,而应用层才是目的。我们日常网络通信的本质:进程间通信。(每一层都认为是双方之间是在直接通信)
这里就一个问题了?我们在微信上经过读写操作,在网络协议栈中然通过下三层是能精准的发送给远端机器,但是远端机器,可不只有微信还有抖音,快手、QQ等?那么我们在微信上发的消息,如何让远端机器应用层的微信准确的拿到,并做处理,也就是传输层怎么知道要发给谁?所以传输层和应用层必须要有某种"协议",而这个"协议"就是端口号
那么我们就可以得出几个结论:
网络基础 【发展、协议、传输、地址】在这篇讲解了在公网上 IP地址标识唯一的一台主机,而本篇所讲的端口号,是用来标识该主机上唯一进程。
那么 IP + 端口号(port)就等于全网标识唯一的一个进程。
那么如果客户端:IP + Port ,服务端:IP + Port 那么就能标识全网唯二的两个进程。我们就称为socket (套接字)
1.1.1 认识端口号
- 端口号(port)是传输层协议的内容.
- 端口号是一个2字节16位的整数;
- 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
- IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
- 一个端口号只能被一个进程占用
1.1.2 理解 "端口号" 和 "进程PID"
进程PID也是标识进程得唯一性,怎么不用PID,又搞出来个端口号?
不用进程PID原因有以下几点:
- 不是所有的进程都要进行网络通信,但是所有进程都要有PID。
- 从技术角度来说,是可用PID的,但是PID是属于系统层面的,如果系统随着迭代更新而发生了改变,那么网络相应的也要受影响,这种牵一发而动全身,还不如单独给网络设计一套,完成解耦。
1.1.3 理解源端口号和目的端口号
用户通过启动抖音获取视频请求,然后传输层添加报头 绑定用户端口号:4321,服务端口号:8080,传输给下一层,到了网络层之后,那就是添加报头用户IP地址,服务器IP地址,给数据链路层,数数据链路层添加报头,MAC地址。这里我们就不细说了,通过路由器,最后到了抖音服务端。层层解析报头,然后到传输层通过哈希运算绑定端口号,8080所映射的进程就是抖音。这时抖音服务器将视频以同样的方式传回给用户端。
这里传输层还有很多细节,比如传输层肯定也是要有缓冲区的,这样网络传输的数据,就以网络文件形式在缓冲区中,对于OS来说,就如同文件一样读取。
一个进程可以绑定多个端口号吗?
答案是:可以的
一个端口号可以被多个进程绑定吗?
答案是:不可以。
1. 2 认识UDP协议
UDP(用户数据报协议,User Datagram Protocol)是一种无连接的、简单的、面向数据报的传输层协议。它主要用于处理那些可以容忍一定数据丢失的应用程序,如视频会议、在线游戏和VoIP
-
无连接:
UDP不建立连接,发送方和接收方之间不需要建立通信会话。发送方可以直接发送数据包到接收方。 -
简单性:
UDP协议的头部结构简单,只包含8字节,包括源端口、目的端口、长度和校验和。这种简单性使得UDP在开销较小的情况下快速传输数据。 -
不保证交付:
UDP不保证数据包的可靠交付。如果数据包在传输过程中丢失,UDP不会重新发送。 -
不保证顺序:
UDP不保证数据包的顺序。接收方可能会无序地接收到数据包
UDP协议的这些特性使其在需要快速传输和低开销的场景中非常有用,但也意味着它不适合那些需要高可靠性和数据完整性的应用。
1.3 认识TCP协议
TCP(传输控制协议,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。它是互联网协议(IP)套件的核心组成部分之一,主要用于在网络中的两个主机之间提供可靠的数据传输服务。适用于需要可靠数据传输的应用程序,如Web浏览(HTTP)、文件传输(FTP)、电子邮件(SMTP)和远程登录(SSH)。
-
面向连接:
TCP提供面向连接的服务,即在数据传输开始之前,必须在两端建立一个连接。这通过一个称为三次握手的过程实现。 -
可靠性:
TCP确保数据的可靠传输。如果数据在传输过程中丢失或损坏,TCP会重新发送数据,直到接收方正确接收到所有数据。 -
流量控制:
TCP通过滑动窗口机制实现流量控制,允许接收方根据其处理能力调整接收数据的速率。 -
拥塞控制:
为了防止网络拥塞,TCP使用拥塞控制算法(如慢启动、拥塞避免和快速恢复)来动态调整数据的发送速率。 -
有序传输:
TCP保证数据的有序传输。接收方会按照发送方发送的顺序重新组装数据。
TCP协议的这些特性使其成为许多需要可靠数据传输的网络应用程序的首选协议。
这里我们简单讲了TCP 、UDP。后面篇章会更详细讲。
1.4 网络字节序
在语言中,数据在内存中如何存储,分为两种,大端和小端。 同样在磁盘中也是一样,那么网络中也是一样吗?
- 数据拥有高权值位和低权值位,比如在
32
位操作系统中,十六进制数0x11223344
,其中的11
称为 最高权值位,44
称为 最低权值位
如果将数据的高权值存放在内存的低地址处,低权值存放在高地址处,此时就称为 大端字节序,反之则称为 小端字节序,这两种字节序没有好坏之分,只是系统设计者的使用习惯问题,比如我当前的电脑在存储数据时,采用的就是 小端字节序 方案
通过内存单元可以看到,使用 小端字节序 时数据是倒着放的,大端字节序 就是正着存放了
在网络出现之前,使用大端或小端存储都没有问题,网络出现之后,就需要考虑使用同一种存储方案了,因为网络通信时,两台主机存储方案可能不同,会出现无法解读对方数据的问题
所以为了杜绝上述问题,TCP/IP直接就定死了规定,,网络数据流应采用大端字节序,即低地址高字节
如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可
有小伙伴就会问?如果我的机器是小端机,我们是不是还得手搓一个转化为大端得函数?前人已经帮我写好了直接拿来用就行。
-
htonl() 和 ntohl():将长整型数据从主机字节顺序转换为网络字节顺序,或从网络字节顺序转换为主机字节顺序。
uint32_t htonl(uint32_t hostlong); uint32_t ntohl(uint32_t netlong);
-
htons() 和 ntohs():将短整型数据从主机字节顺序转换为网络字节顺序,或从网络字节顺序转换为主机字节顺序。
uint16_t htons(uint16_t hostshort); uint16_t ntohs(uint16_t netshort);
2. socket 套接字
"socket"(套接字)是一种通信端点,它为进程之间的网络通信提供了一个抽象的接口。
2.1 socket 常见API
以下是一些常见的套接字API:
#include <sys/types.h>
#include <sys/socket.h>
// 创建socket文件描述符(TCP/UDP 服务器/客户端)
int socket(int domain, int type, int protocol);
// 绑定端口号(TCP/UDP 服务器)
int bind(int socket, const struct sockaddr* address, socklen_t address_len);
// 开始监听socket (TCP 服务器)
int listen(int socket, int backlog);
// 接收连接请求 (TCP 服务器)
int accept(int socket, struct sockaddr* address, socklen_t* address_len);
// 建立连接 (TCP 客户端)
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
socket返回值 就是 文件系统的 fd ,后面我们讲UDP时,大家就明白了。
从上面接口函数来看,频繁的出现了sockaddr* 这个结构体,那它又是什么?
2. 2 sockaddr 结构体
为什么要出现这个结构体,我们先说说套接字编程的种类
1.域间套接字编程
2.原始套接字编程
3.网络套接字编程
域间套接字是用于本地通信,原始套接字主要用于网络工具,而网络套接字用于用户间通信。3种不同的套接字一定对应着3种不同API接口,套接字的设计者们觉得这样太麻烦了,能不能把三种套接字都用一种APL?,所以就搞出来了sockaddr这个结构体。
AF_INET 对应的是网络,而AF_UNIX对应的是本地。
这种方式就是C++的多态,通过sockaddr* 接口,我们传入谁的指针就调用谁。
那为什么不用void*?
套接字的概念最早出现在1970年代的UNIX系统中,void*还没有出来。void*最早在贝尔实验室开发出来是1972年。真正进标准库是1978年。
sockaddr还有其他细节,我们在后面网络编程中,在继续讲解,没有代码讲解非常抽象。
3. UDP网络编程
在编写之前我们先了解socket接口如何使用。
3.1 socket函数接口说明
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数
domain
:指定通信域,这选择了将用于通信的协议族。这些协议族在<sys/socket.h>
中定义。type
:指定套接字类型,这决定了通信的语义。protocol
:指定特定的协议,通常设置为 0 以使用默认协议。
通信域(domain)
AF_UNIX
或AF_LOCAL
:本地通信。AF_INET
:IPv4 互联网协议。AF_INET6
:IPv6 互联网协议。- 以及其他多种协议族,如
AF_IPX
、AF_NETLINK
、AF_X25
等。
套接字类型(type)
SOCK_STREAM
:提供有序、可靠的双向、基于连接的字节流。可能支持带外数据传输机制。SOCK_DGRAM
:支持数据报(无连接、不可靠的固定最大长度消息)。SOCK_SEQPACKET
:提供有序、可靠的双向、基于连接的数据传输路径,用于固定最大长度的数据报;每次输入系统调用都需要读取整个数据报。SOCK_RAW
:提供原始网络协议访问。SOCK_RDM
:提供可靠的数据报层,不保证顺序。
注意
- 某些套接字类型可能不是所有协议族都实现的,例如
SOCK_SEQPACKET
在AF_INET
中就没有实现。
函数描述
socket()
函数创建一个通信端点,并返回一个描述符。这个描述符可以用于后续的通信操作,如绑定(bind()
)、监听(listen()
)、接受连接(accept()
)、连接(connect()
)、发送(send()
/write()
)和接收(recv()
/read()
)数据。
错误处理
如果 socket()
调用失败,它会返回 -1
并设置全局变量 errno
以指示错误类型。
3.2 编写UDP服务器
第一步:创建mian.cc 主函数文件,创建Udpsever.hpp头文件,创建makefile自动化工具。
之前写的log日志类我们直接拷贝过来用。
第二步:编写udp套接字
#pragma once
#include <iostream>
#include "log.hpp"
#include <sys/types.h>
#include <sys/socket.h>
extern Log lg;
enum
{
SOCKET_ERR=1
};
class Udpserver
{
public:
Udpserver(){} //构造函数
~Udpserver(){} //析构函数
void Init()
{ //1.创建套接字
_socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_socketfd<0)
{
lg(Fatal,"创建套接字失败: %d",_socketfd);
exit(SOCKET_ERR);
}
lg(Info,"创建套接字成功:%d",_socketfd);
}
private:
int _socketfd;
};
套接字创建出来了,想一想前面的预备知识,我们是不是还要绑定端口号和IP地址?
在编写之前我们还需要认识一个函数bind
通常,在使用
bind()
之前,需要根据所使用的地址家族初始化sockaddr
结构,并设置sa_family
和相应的sa_data
字段。然后调用bind()
并传入套接字描述符、地址结构的指针以及结构的大小。
#include <netinet/in.h>
我们前面讲得这里我们定义对象报错,很正常因为我们没有包头文件不认识。
bind函数(绑定端口号和IP地址)
// 2. 绑定套接字
struct sockaddr_in local;
memset(&local, 0, sizeof(local)); // 清空local
local.sin_family = AF_INET;
local.sin_addr = _ip; //??
local.sin_port = _port; //??
上面这样写有问题吗?当然有问题啊,我们得_ip是string类型,而 sin_addr是个整型,类型不匹配
那我们直接把类成员_ip定义整型得不行?可以是可以,但是用户不习惯,用户习惯得是192.168.0.1 这种点分十进制,字符串风格的。 有字符串转整型的函数吗? 当然有的
inet_addr
函数
- 函数原型:
#include <arpa/inet.h> in_addr_t inet_addr(const char *cp);
- 功能:将IPv4地址的字符串形式转换为一个32位的网络字节顺序的整数。
- 返回值:返回转换后的IPv4地址,如果输入的字符串不是有效的IPv4地址,则返回
INADDR_NONE
(通常是-1)。
local.sin_addr.s_addr = inet_addr(_ip.c_str());
inet_addr
函数将返回一个 in_addr_t
类型的值,这个值已经是网络字节序。所以我们调用这个函数不用考虑网络字节序问题,函数会自己转。
端口号同样也是要保证是网络字节序的,因为端口号是要在网络中发送给对方的。
local.sin_port = htons(_port);
初始化好sockaddr结构体,这里就有个问题?我们忙活半天,请问能够在网络上进行通信吗?当然不能,我们现在定义的local这个结构体对象,是在栈上,也就是用户态中,我们的套接字,是没有设置进内核中的。所以说我们需要将套接字设置进内核中。
那我们就要使用bind函数进行绑定,而刚好bind是系统调用接口。
这里语法提示错误,因为我们的local是sockaddr_in,而bind参数要求是socket,我们直接强制类型转换。
if (bind(_socketfd, (const struct sockaddr *)&local, sizeof(local)) < 0)
{
lg(Fatal, "绑定套接字失败,错误码:%d, 错误信息:%s", errno, strerror(errno));
}
lg(Info, "绑定套接字成功:%d ,%s", errno, strerror(errno));
这里我们绑定时直接判断,失败和成功都打印日志。
最后是完整的udp套接字初始化函数
void Init()
{ // 1.创建套接字
_socketfd = socket(SOCK_DGRAM, AF_INET, 0);
if (_socketfd < 0)
{
lg(Fatal, "创建套接字失败: %d", _socketfd);
exit(SOCKET_ERR);
}
lg(Info, "创建套接字成功:%d", _socketfd);
// 2. 绑定套接字
struct sockaddr_in local;
memset(&local, 0, sizeof(local)); // 清空local
local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(_ip.c_str()); //??
local.sin_port = htons(_port); //??
if (bind(_socketfd, (const struct sockaddr *)&local, sizeof(local)) < 0)
{
lg(Fatal, "绑定套接字失败,错误码:%d, 错误信息:%s", errno, strerror(errno));
}
lg(Info, "绑定套接字成功:%d ,%s", errno, strerror(errno));
}
我们先运行一下
#include "Udpsever.hpp"
#include "log.hpp"
#include <memory>
Log lg;
int main()
{
std::unique_ptr<Udpserver> svr(new Udpserver());
svr->Init();
svr->run();
return 0;
}
udpserver:main.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f udpserver
在命令行中make一下
运行成功,套接字是3,这就说明了套接字就是文件描述符,标准输入、输出、错误自动打开,占用,那么就只从3开始了。
服务器初始化后,是不是就该运行了?
第三步:编写运行函数
运行函数肯定是一个死循环函数,你早上9点刷抖音,下午5点刷抖音,凌晨3点也在刷抖音,所以服务器要一直运行。
客户端请求的获取视频的信息,我们作为服务器是不是要接收到来自客户端的请求消息?然后根据请求消息,再相应的将视频发送回给客户端?所以接下来我们要认识两个新函数recvform 和 sendto
recvfrom、sendto 函数接口说明
还是一样用man指令打开文档手册
这里简单介绍一下recvfrom的参数,消息是从网络当中来的,所以第一个参数是网络文件描述符,没有什么好说的,void* buf 这个是一个缓冲区,也就是客户端消息放在缓冲区中,方便服务端读取, size_t len 缓冲区的大小, int flags 我们默认设为0(0标识阻塞等待),前面都不重要,关键是后面两个参数struct sockaddr *src_addr, socklen_t *addrlen,因为我们作为服务器要知道谁发给来的,然后将消息再发给谁 。成功时返回接收到的字节数,失败返回-1,错误码和错误信息被设置。
sendto和recvfrom差不多 ,一个返回,一个接收,这里就不细讲了,直接用。
void run()
{
while (true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
char buf[BUFSZIE];
ssize_t n = recvfrom(_socketfd, buf, sizeof(buf) - 1, 0, (struct sockaddr *)&client, &len);
if (n < 0)
{
lg(Warning, "接收消息失败,错误码:%d,错误信息:%s", errno, strerror(errno));
continue;
}
buf[n] = 0; // 这里我们直接当字符串使用。
// 这里我们简单处理一下数据,客户发什么,我们回响什么。
std::string info = buf;
std::string ehco_string = "server ehco#" + info;
sendto(_socketfd, ehco_string.c_str(), ehco_string.size(), 0, (sockaddr *)&client, len);
}
}
我们先用默认的IP地址和端口号试试
服务器启动了,我们要看服务器的状态如何查看?
指令:netstat -naup
Proto: 表示使用的协议类型,例如
TCP
、UDP
或UNIX
等。Recv-Q: 接收队列(Receive Queue),显示了套接字接收缓冲区中等待被应用程序读取的数据量(以字节为单位)。如果这个值持续增长,可能意味着应用程序没有及时读取数据。
Send-Q: 发送队列(Send Queue),显示了已经由应用程序发送但尚未被远程主机确认的数据量(以字节为单位)。这个值的大小可以反映网络的拥堵情况。
Local Address: 本地地址,包括本地套接字绑定的 IP 地址和端口号,格式通常是
IP地址:端口
。Foreign Address: 远程地址,包括远程套接字的 IP 地址和端口号,格式同样是
IP地址:端口
。如果是一个监听套接字(如服务器等待连接的套接字),这可能会显示为*:端口
或者0.0.0.0:端口
,表示接受任何远程地址的连接。State: 状态,表示套接字当前的状态。对于 TCP 连接,可能的状态包括
LISTEN
(监听)、ESTABLISHED
(已建立连接)、FIN_WAIT1
、FIN_WAIT2
、CLOSE_WAIT
、CLOSING
、LAST_ACK
和TIME_WAIT
等。UDP 套接字通常不显示状态,因为它们是无连接的。PID/Program name: 进程 ID 或者程序名称,显示了哪个本地进程拥有这个套接字。在某些情况下,可能只显示 PID,或者由于权限问题,可能不显示程序名称。
注意: 这里还有二个坑在里面,我们现在把默认地址0.0.0.0,改成我的云服务器的公网IP地址试试?
我换成公网IP直接绑定失败了,原因就是云服务器禁止直接绑定公网IP,有些服务器可不只有 一张网卡,甚至是3张以上,这也就意味着,我们只绑定一个公网IP,那么这台服务器就只能收到来自这张IP的信息,如果是129的或者是130的那么就收到不了。所以我们绑定IP地址一般默认为全0也就是0.0.0.0,全零表示可以接收任何远程地址。
如果我们把默认端口号改成80结果会是怎样?
我们试试1024这个端口号
为什么80端口号绑定失败?1024端口号就可以?这是因为【0,1023】是系统内定的端口号,一般都有固定应用层协议使用,比如 http:80 https:443 mysql: 3306(这个是个例外)。就好比120就是急救电话,119就是消防,110不用我多说了吧。
至此UDP服务器就这么多代码,这是因为UDP套接字就是这样的固定模式。接下来我们编写客户端。
3.3 编写UDP客户端
第一步:创建UdpClient.cc源文件,创建套接字
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
int main()
{
// 1.创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cout << "创建套接字失败" << std::endl;
return 1;
}
return 0;
}
按照前面的编写的服务器的时候,我们是要再bind之前,初始化sockadrr_in这个结构体。
这里有一个问题:要不要bind?答案是要的,只不过不需要我们显示的绑定,OS随机绑定。
为什么要随机绑定?如果抖音想要的端口号是1234,而淘宝也要1234,前面我们讲过端口号只能被一个进程绑定,一个进程可以绑定多个端口号,这也就意味着你先打开了淘宝,就不能快乐的刷小哥哥、小姐姐视频了。所以对于OS来说,不管端口号是多少,我只要通过哈希表映射确定这个端口号只被一个进程绑定就行。
那为什么服务器要绑定端口号?
也很简单,如果服务器的端口号也是随机的,这就意味你今天能刷抖音,明天后天就找不到服务器了,以后刷抖音全靠运气。就好比你买了一个快递,7天无理由退货,过了7天你要退货,那么寄回时,卖家的地址变了,你能寄回卖家最开始的地址吗?
那什么时候OS进行随机绑定?
在客户端第一次给服务端发送消息时进行绑定。
第二步:初始化sockaddr_in结构体
可是我们要初始化填充字段,问题来了,我们作为客户怎么知道服务器的IP地址和端口号?那些知名的互联网公司都会做推广,推出自己的域名,比如百度、腾讯等。
下面我们以window为例 将 39.156.66.14 在浏览器输入后回车访问百度界面
www.baidu.com会转变成 39.156.66.14 这个IP地址。
那我们只有写个简单dome示例,提示用户在命令行怎么输入了。
void Usage(std::string proc)
{
std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
<< std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
}
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(argv[1]); //?
server.sin_port = std::stoi(argv[2]); //?
socklen_t len = sizeof(server);
第三步:发送消息、接收消息
std::string message;
char buffer[1024];
while (true)
{
std::cout << "请输入@:";
std::getline(std::cin, message);
sendto(sockfd, message.c_str(), message.size(), 0, (sockaddr *)&server, len);
struct sockaddr_in temp;
socklen_t Len = sizeof(temp);
ssize_t n = recvfrom(sockfd, buffer, 1023, 0, (sockaddr *)&temp, &Len);
if (n > 0)
{
buffer[n] = 0;
std::cout << buffer << std::endl;
}
}
这里我们简单发送和接受消息,服务器sendto给客户端回响消息,这里需要recvfrom接受服务器的消息,这里recvfrom参数我们需要定义一个temp对象来占位,不写代码编译不过。
接下里就是见证奇迹的时候了,我们人生中的第一个服务器到底能不能通信?
完成了通信,如果你的通信不了,可能是因为你的防火墙开着了。我们需要关闭防火墙
查看当前防火墙开启的端口:
sudo firewall-cmd --list-ports
这些就是开启的,其他就是没有开启的,需要用下面的指令关闭指定端口的防火墙 如果我们是1234,那么是通信不了的
关闭指定端口的防火墙规则
sudo firewall-cmd --zone=public --remove-port=22/tcp --permanent
增加开放端口(需要替换端口号)
sudo firewall-cmd --zone=public --add-port=端口号/tcp --permanent
如果要增加UDP协议我们只需要将tcp变为udp
这里有小伙伴就会问了,一次就能开一个端口和关闭一个端口吗?
可以一次开放和关闭多个端口,比如上面的我们要开放10000——20000的端口号
sudo firewall-cmd --zone=public --add-port=10000-20000/tcp --permanent
重启防火墙 sudo systemctl restart firewalld.service
重启之后就生效了
这里服务器的处理数据的方法我们之前写的很简单,谁说就只能传入字符串?万一我们传入的是个指令呢?根据前面我们模拟的xshell,利用fork创建子进程,然后进行程序替换,不就可以处理指令吗?
在改造之前我们需要认识一个函数popen
这个函数 封装了fork,创建子进程和管道,然后子进程对字符串进行解析,程序替换,执行指令,最后的结果通过管道返回给父进程。
我们在mian.cc这里编写这个函数执行函数。
std::string ExcuteCommand(const std::string & cmd)
{
FILE* fp = popen(cmd.c_str(),"r");
if(nullptr == fp)
{
perror("popen");
return "error";
}
std:: string result;
char buffer[4096];
while(true)
{
char * str = fgets(buffer,sizeof(buffer),fp);
if(str == nullptr) break;
result += str;
}
return result;
}
那服务器如何调用这个函数?我们可以利用C++11包装器,在run函数中传入包装器。
typedef std::function<std::string(const std::string &)> func_t;
void Run(func_t func)
{
char buf[size];
while (true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
ssize_t n = recvfrom(_socketfd, buf, sizeof(buf) - 1, 0, (struct sockaddr *)&client, &len);
if (n < 0)
{
lg(Warning, "接收消息失败,错误码:%d,错误信息:%s", errno, strerror(errno));
continue;
}
buf[n] = 0; // 这里我们直接当字符串使用。
// 这里我们简单处理一下数据,客户发什么,我们回响什么。
std::string info = buf;
std::string ehco_string = func(info);
sendto(_socketfd, ehco_string.c_str(), ehco_string.size(), 0, (const struct sockaddr *)&client, len);
}
}
当然这里我们初步写执行函数并不安全,如果人家来一个rm -rf *这样指令,那不就一夜回到解放前?所以我们需要一个检查指令的函数,不安全的直接就终止了。
这样做的好处就是对代码进行分层
3.4 windows 和 Linux 通联
这里我们利用windows充当客户端,Linux充当服务端。所以我们只需要编写Windows客户端。
那windows套接字和Linux一样吗?网络协议栈必须是一样,不一样都要搞成一样。初始化在Windows系统中,使用Winsock API之前需要调用WSAStartup
函数来初始化Winsock库,并在结束时调用WSACleanup
来清理,其他基本也就是宏定义,windows喜欢用大写。下面直接手搓
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <cstdlib>
#include <string>
#include <WinSock2.h>
#include <Windows.h>
#pragma comment(lib,"ws2_32.lib") //导入库
int main()
{
WSADATA wsd;
WSAStartup(MAKEWORD(2, 2), &wsd);
WSACleanup();
return 0;
}
windows中提前要准备好的结构就是这写,然后我们直接把前面Linux的客户端cv一下就行了。
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <cstdlib>
#include <string>
#include <WinSock2.h>
#include <Windows.h>
#pragma warning (disable:4996) //不加这句 编译不过,因为这个过时了。编译让用inet_pton
#pragma comment(lib,"ws2_32.lib")
std::string serverip = "121.37.237.128";
uint16_t serverport = 12345;
int main()
{
WSADATA wsd;
WSAStartup(MAKEWORD(2, 2), &wsd);
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport); //?
server.sin_addr.s_addr = inet_addr(serverip.c_str()); //?
int len = sizeof(server);
// 1.创建套接字
SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cout << "创建套接字失败" << std::endl;
return 1;
}
std::string message;
char buffer[1024];
while (true)
{
std::cout << "请输入@:";
std::getline(std::cin, message);
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, len);
struct sockaddr_in temp;
int len = sizeof(temp);
int n = recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr*)&temp, &len);
if (n > 0)
{
buffer[n] = 0;
std::cout << buffer << std::endl;
}
}
closesocket(sockfd);
WSACleanup();
return 0;
}
这里由于Linux下 socklen_t 还有上面ssize_t 这些类型不认识,我图方便直接改为int。
关闭网络文件描述符,windows是用的closesocket。linux用的close。其他的就一样了。
3.5 聊天室
基于前面的代码,我们要写聊天室还缺少一些聊天室的功能,比如要知道谁发的消息。在线列表,加入群时要有通告等等。
登陆注册,搞验证码那一套有点繁琐了,我们直接用IP地址来代替用户名。
我们先用哈希表数据结构来定义用户在线列表。
private:
int _socketfd;
std::string _ip;
uint16_t _port;
std::unordered_map<std::string,struct sockaddr_in> online_user;
那我们有了这个表之后,用户发来消息,我们是不是要检查是不是没有在用户列表里面,没有就插入到哈希表中。
void CheckUser(const struct sockaddr_in &client, const std::string &clientip, uint16_t clientport)
{
auto iter = _online_user.find(clientip);
if (iter == _online_user.end()) // 迭代器走到end,说明是新用户。插入哈希表
{
_online_user.insert({clientip, client});
std::cout << "[" << clientip << ":" << clientport << "] add to online user." << std::endl;
}
}
有了用户列表,谁发消息大家都能收到,我们直接遍历在线用户列表,字符串做拼接,然后用sendto发送给每个在线用户。写一个播送函数。
void BroadCast(const std::string &info, const std::string &clientip, uint16_t clientport)
{
for(auto & user:_online_user)
{
socklen_t len = sizeof(user.second);
sendto(_socketfd,info.c_str(),info.size(),0,(struct sockaddr*)(&user.second),len);
}
}
然后我们在mian.cc中添加字符串拼接函数。
std::string Handler(const std::string &info, const std::string &clientip, uint16_t clientport)
{
std::string message = "[";
message += clientip;
message += ":";
message += std::to_string(clientport);
message += "]# ";
message += info;
return message;
}
这个聊天室有个问题,那就是我们自己发的消息能看到,其他人的消息我们是看不到的,为什么?因为客户端代码逻辑是先输入,再发送。也就是说我们不发消息,那么会一直阻塞再getline这个函数这里, sendto 我们设为0,阻塞。
现实中的群里,就是有人要潜水啊,从建群到解散群可能一句话也不会发,那不是挺尴尬啊,对于这种人来说,看不到群消息。如何解决?我们用多线程来解决。一个线程发消息,一个线程收消息。
最后解释一下,我用的客户端是windows,IP地址不一样,连入的是局域网,也就是运行商(电信、移动、联通)它们的公网IP地址。如果是云服务器作为客户端IP地址是云服务器的公网IP。
3.6 udp多线程版本
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <cstdlib>
#include <pthread.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
struct ThreadData
{
struct sockaddr_in server;
int sockfd;
};
void Usage(std::string proc)
{
std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
<< std::endl;
}
void *recv_message(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
char buffer[1024];
while (true)
{
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t n = recvfrom(td->sockfd, buffer, 1023, 0, (struct sockaddr *)&temp, &len);
if (n > 0)
{
buffer[n] = 0;
std::cout << buffer << std::endl;
}
}
}
void *send_message(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
std::string message;
char buffer[1024];
socklen_t len = sizeof(td->server);
while (true)
{
std::cout << "请输入@:";
std::getline(std::cin, message);
sendto(td->sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)(&td->server), len);
}
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
struct ThreadData td;
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
bzero(&(td.server), sizeof(td.server));
td.server.sin_family = AF_INET;
td.server.sin_port = htons(serverport); //?
td.server.sin_addr.s_addr = inet_addr(serverip.c_str()); //?
// 1.创建套接字
td.sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (td.sockfd < 0)
{
std::cout << "创建套接字失败" << std::endl;
return 1;
}
pthread_t recv, send;
pthread_create(&recv, nullptr, recv_message, &td);
pthread_create(&send, nullptr, send_message, &td);
pthread_join(recv, nullptr);
pthread_join(send, nullptr);
close(td.sockfd);
return 0;
}
这里我们先创建一个线程要包含的属性的结构体,把socket 和 sockaddr_in作为成员。这样我们线程传参时,两个线程就能拿到线程的属性。然后把之前的收发消息函数做拆分,一个给收线程,一个发线程,简单明了。如果你对线程不明白。请看Linux初窥门径 专栏里面的线程篇章,这里不在过多讲解线程相关方面的知识。
这里有个问题socket没有线程安全的问题吗?
答案:socket是全双功的,也就是能够同时发消息和收消息,因为它是线程安全的,收发消息都有各自的缓冲区。
这里我们读写消息混在一起了,看着不舒服,我们没有图形化界面,不能像微信那样,但是我们可以利用云服务器的终端,进行重定向。一个终端发消息,一个终端收消息。
下面我们把发的消息打到终端1中。
同理我们聊天室也是这样的。
由于线程send是标准输入,线程之间有冲突,我们直接将recv这个线程的原本从标准输出改为标准错误。所以2重定向到/dev/pts/1的终端。
3.7 几个接口函数的说明
前面我们用到的 inet_addr 这个接口。其实在现在来说已经淘汰了。对于inet_addr来说更推荐inet_pton 它提供了更全面的错误检查。
3.7.1 关于 inet_ntoa
用于将一个 in_addr
结构的网络字节序的32位IPv4地址转换为可读的点分十进制格式的字符串。
4. TCP网络编程
在前面的UDP的基础上,我们现在再来写TCP就容易很多了。
第一步:不用多说直接就是创建文件
第二步:编写TCP服务器
#pragma once
#include <iostream>
#include <cstdlib>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "log.hpp"
Log lg;
const int defaultsockfd = -1;
const uint16_t defaultport = 8080;
const std::string defaultip = "0.0.0.0";
const int backlog = 5;
enum
{
SocketError = 1,
BindError,
ListenError
};
class TcpServer
{
public:
TcpServer( uint16_t serverport = defaultport, std::string serverip = defaultip)
: _listensockfd(defaultsockfd), _serverport(serverport), _serverip(serverip)
{
}
void Init()
{
_listensockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_listensockfd < 0)
{
lg(Fatal, "create listensockfd error: %d,errstring: %s", errno, strerror(errno));
exit(SocketError);
}
lg(Info, "create listensockfd success, listensockfd: %d", _listensockfd);
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_serverport);
inet_aton(_serverip.c_str(), &(local.sin_addr));
socklen_t len = sizeof(local);
if (bind(_listensockfd, (struct sockaddr *)(&local), len) < 0)
{
lg(Fatal, "bind listensockfd error: %d,errstring: %s", errno, strerror(errno));
exit(BindError);
}
lg(Info, "bind listensockfd success, listensockfd: %d", _listensockfd);
if (listen(_listensockfd, backlog) < 0)
{
lg(Fatal, "listen listensockfd error: %d,errstring: %s", errno, strerror(errno));
exit(ListenError);
}
lg(Info, "listen listensockfd success, listensockfd: %d", _listensockfd);
}
private:
int _listensockfd;
uint16_t _serverport;
std::string _serverip;
};
创建和绑定套接字和前面UDP模式一样,这里不再过多讲,UDP是无连接的,而TCP是面向连接的。这意味着服务端和客户端是要连接的,也就是说 客服端和服务端先要连接成功之后才能通信,而不像之前的UDP,服务器启动了之后,就可以通信了。
但是服务器也不知道客户端什么时候要来连接,所以服务器就处于一种被动的方式等待客服端来连接。下面我们就需要认识一个接口 listen
参数:
sockfd
:已绑定到特定地址和端口的套接字的文件描述符。backlog
:这是一个指定在队列中等待连接的连接请求的最大数量的参数。如果达到这个数量,额外的连接请求将被拒绝。
返回值:
- 成功时,
listen
函数返回 0。 - 失败时,返回 -1 并设置全局变量
errno
以指示错误。
backlog
参数的值应该根据服务器的负载能力来设置,避免过载。所以这里参数不要设置过大。
通过这个监听函数我们的服务器就知道了客户端有请求连接。
if (listen(_listensockfd, backlog) < 0)
{
lg(Fatal, "listen listensockfd error: %d,errstring: %s", errno, strerror(errno));
exit(ListenError);
}
lg(Info, "listen listensockfd success, listensockfd: %d", _listensockfd);
至此TCP初始化函数就写完了。
这里就小伙伴有疑问了,为什么初始化函数不能写在构造函数里面?能写是能写,这里有风险,我们创建套接字和绑定套接字不是百分之一百成功。如果是在构造函数里面的,那么对象被初始化后,我们创建和绑定套接字失败了 对象可是存在堆上的。所以外部处理会更好。
下面我们写启动函数。
服务器收到了客户端的请求连接,但是光有listen还不行?就好比现实中一样,张三找李四借钱先是先有借钱的请求,但是借不借还得看李四同不同意。所以下面我们再认识一个接口accept
返回值:
参数和recvfrom 差不多,参数不是重点。
这里 返回值才是重点,为什么这么说,因为接受请求连接成功之后,会返回一个文件描述符,这里就要一个问题了,我们已经有了一个网络文件描述符了,怎么还要一个文件描述符?
这里我们讲一个小故事了,大家都知道饭店前面都一个迎宾的接待人员,这里我们就叫他张三,此时你们刚好路过饭店,张三吆喝着你们进去吃饭,进去之后里面又有服务人员招待你们吃饭,但是张三不会跟着你们进去。
这里张三就是我们的_listensockfd,而返回文件描述符就是服务员,_listensockfd作用是用于底层建立客户与服务器的连接通信,而返回的文件描述符用于IO的数据接受和发送。
void Start()
{
for (;;)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &len);
if (sockfd < 0)
{
lg(Warning, "accept listensockfd error: %d,errstring: %s", errno, strerror(errno));
continue;
}
// 知道是谁请求连接,我们以IP地址和端口号来鉴别。
uint16_t clientport = ntohs(client.sin_port);
char clientip[16];
// 这里我们用这个接口函数来把网络字节序转化为主机序列点分十的字符串
inet_ntop(AF_INET, (struct sockaddr *)&client, clientip, sizeof(clientip));
lg(Info, "get a new link..., sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport);
// 写一个模拟服务器对数据做加工的服务函数
server(sockfd, clientip, clientport);
}
}
accept 成功以后,意味着客户端和服务器真正的可以收发数据了。 然后我们对上面的代码针对数据做处理的代码做解耦,单独写一个server函数。
void server(int sockfd, const std::string &clientip, const uint16_t &clientport)
{
char buffer[4096];
while (true)
{
// TCP是字节流读取的。所以我们可以直接用系统调用接口read和write来进行读写
ssize_t n = read(sockfd, buffer, sizeof(buffer));
if (n < 0)
{
lg(Fatal, "read error,sockfd: %d,clientip: %s,clientport:%d", sockfd, clientip.c_str(), clientport);
break;
}
else if (n == 0)
{
lg(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
break;
}
else
{
buffer[n] = 0;
std::cout << "client say#" << buffer << std::endl;
std::string echo_string = "tcpserver echo# ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
}
}
做差错处理,如果n的返回值小于等于0,说明读取都失败了,还玩啥?直接就是break。
第三步:编写Main.cc调用服务器逻辑
#include "TcpServer.hpp"
#include <string>
#include <iostream>
#include <memory>
void Usage(const std::string &porc)
{
std::cout<<"\n\rUsage:"<<porc <<"port[1024+]\n"<<std::endl;
}
int main(int argc, char* argv[])
{
if(argc!=2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<TcpServer> svr(new TcpServer(port));
svr->Init();
svr->Start();
return 0;
}
这里我们没有编写客户端,如何也能进行通信?
4.1 telnet
telnet
是一个网络工具,它允许用户通过 TCP/IP 协议连接到远程服务器,并与该服务器进行交互。它通常用于测试网络连接、诊断网络问题或访问远程服务器上的服务,如HTTP、FTP等。
在终端命令行输入:
- 使用快捷键
Ctrl+]
回车键
退出 telnet
:
- 使用快捷键
Ctrl+]
。 - 输入
quit
或exit
命令。
这里我们编写服务器也能进行本地通信了。下面我们进行编写客户端
第四步:编写客户端
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
//这里直接写死
std::string defaultserverip = "121.37.237.128";
uint16_t defaultserverport = 8080;
int main()
{
std:: string serverip = defaultserverip;
uint16_t serverport = defaultserverport;
struct sockaddr_in server;
//初始化server
memset(&server,0,sizeof(server));
server.sin_family = AF_INET;
inet_aton(serverip.c_str(),&(server.sin_addr));
server.sin_port = ntohs(serverport);
return 0;
}
初始化server后,老套路创建套接字,然后系统帮我们绑定。这里和UDP不同的是,TCP是需要连接的,也就是说我们需要连接服务端。正好对应服务器的监听功能。监听到连接的请求,双方之间建立连接。所以我们需要认识一个系统调用 connect
connect
系统调用用于将文件描述符 sockfd
引用的套接字连接到由 addr
指定的地址。addrlen
参数指定了 addr
的大小。addr
中地址的格式由套接字 sockfd
的地址空间决定
返回值
如果连接或绑定成功,返回零。出错时返回 -1
,并设置 errno
以反映错误的类型。
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
// 这里直接写死
std::string defaultserverip = "121.37.237.128";
uint16_t defaultserverport = 8080;
int main()
{
std::string serverip = defaultserverip;
uint16_t serverport = defaultserverport;
struct sockaddr_in server;
// 初始化server
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
inet_aton(serverip.c_str(), &(server.sin_addr));
server.sin_port = ntohs(serverport);
socklen_t len = sizeof(server);
// 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
std::cerr << " socket error" << std::endl;
return 1;
}
int x = connect(sockfd, (struct sockaddr *)&server, len);
if (x < 0)
{
std::cerr << " connect error" << std::endl;
return 2;
}
while (true)
{
std::string message;
std::cout << "Please Enter#" << std::endl;
std::getline(std::cin, message);
ssize_t n = write(sockfd, message.c_str(), message.size());
if (n < 0)
{
std::cerr << " write error" << std::endl;
break;
}
char buffer[4096];
n = read(sockfd, buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = 0;
std::cout << buffer << std::endl;
}
}
close(sockfd);
return 0;
}
我们简单写一个客户端,这里客户端还要很多问题 ,比如断线重连?读写失败的差错处理。当然我们前面的服务器也有问题。我们先改进服务器。
4.2 服务器多进程、多线程、线程池版本
前面我们演示的没有任何问题,因为只有我一个人访问服务器,现实中也不可能只有一个人访问服务器,我们前面的写的单进程版本明显不行。
我们先启动上面的客服端,可以正常收发消息,但是下面的客户端发出的消息服务器接收不到。单进程明显不能满足多用户场景。
4.2.1多进程版本
直接利用fork创建子进程,处理任务的交给子进程。
// 多进程版本
pid_t id = fork();
// child
if (id == 0)
{
close(_listensockfd);
if (fork() > 0) //孙子进程
exit(0);
server(sockfd, clientip, clientport);
close(sockfd);
exit(0);
}
// father
close(sockfd);
//子进程我们不关心默认设为0
pid_t rid = waitpid(id,nullptr,0);
(void)rid;
这里解释一下为什么还要创建孙子进程,父进程必须等待子进程。不然子进程就会成为僵尸进程,所以父进程会阻塞等待子进程,那么就意味着父进程不能再建立连接了,后面的其他客户再来进行连接时,无法进行连接。 所以我们创建孙子进程,子进程立马退出,立即就被回收,子进程退出,那么孙子进程成为孤儿进程,被OS领养。这样就做到了爷爷进程和孙子进程并发访问。
至于关闭文件描述符,父子进程具有血缘关系,如果我们不关闭文件描述符,那么创建越多的子进程,最后那几个子进程就会继承更多文件描述符,可能存在不够用的情况。
但是多进程还是不够完美,前面线程篇章讲过,创建一个进程的成本太高了。如果用线程相比于进程来说,可以大大减轻服务器的压力。
4.2.2多线程版本
先把线程属性这个类创建好,我们等哈创建线程时,就可以拿到客户端的套接字、IP、端口号。
class TcpServer;
class ThreadData
{
public:
ThreadData(const int sockfd, const std::string ip, const uint16_t port, TcpServer *t)
: _sockfd(sockfd), _ip(ip), _port(port), tsrvr(t)
{
}
public:
int _sockfd;
std::string _ip;
uint16_t _port;
TcpServer *tsrvr;
};
回调函数
static void *Routine(void *args)
{
pthread_detach(pthread_self());
ThreadData *td = static_cast<ThreadData *>(args);
td->tsrvr->server(td->_sockfd, td->_ip, td->_port);
delete td;
return nullptr;
}
创建线程
ThreadData *td = new ThreadData(sockfd, clientip, clientport, this);
pthread_t tid;
pthread_create(&tid, nullptr, Routine, td);
这里为什么不join?很简单,线程也是需要等待,但是我们如果线程等待,那不是和单进程一样?
主线程一直阻塞等待,其他客户请求连接,又被阻塞了,所以我们在回调函数中直接线程分离。
但是这样还是不够完美,我们可以提前创建一批线程,等客户来的时候,直接就可以调用线程池里面的线程,处理相应的任务。
4.2.3线程池版本
前面篇章我已经写了线程池,我直接拿过来用
任务也拷贝一下,之前的任务,明显不适用我们的网络这一套,所以我们对它改造。一直字符串做拼接再发送,有点锉了,我们来一个用户输入英文,服务器翻译中文。
既然是线程池了,我们上来就是创建线程池
线程池头文件和任务类的头文件先包含到TcpServer当中
在服务器启动函数中我们先把线程池创建出来
ThreadPool<Task>::GetInstance()->start();
Task t(sockfd, clientip,clientport);
ThreadPool<Task>::GetInstance()->push(t);
服务器这里代码就结束了。
重写任务
我们先创建一个Init这个类,哈希表KV模型。
我们在当前目录创建 dict.txt文件,用来放中英文,然后利用C++文件操作,打开这个文件,按行查找,查找用户对应输入的英文,然后分割字符串。将分割字符串插入到哈希表中。
利用哈希表KV模型返回second,first对应英文,second对应中文,根据用户输入的英文,利用哈希表查找算法,找到返回second,没有找到返回Unknow。
废话不多说,下面手撕一个这个类
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <unordered_map>
#include "log.hpp"
const std::string dictname = "./dict.txt";
const std::string sep = ":";
static bool Split(const std::string &s, std::string *part1, std::string *part2)
{
auto pos = s.find(sep);
if (pos == std::string::npos)
return false;
*part1 = s.substr(0, pos);
*part2 = s.substr(pos + 1);
return true;
}
class Init
{
public:
Init()
{
std::ifstream in(dictname);
if (!in.is_open())
{
lg(Fatal, "isfstream open error %s", dictname.c_str());
exit(1);
}
std::string line;
while (std::getline(in, line))
{
std::string part1, part2;
Split(line, &part1, &part2);
dict.insert(part1, part2);
}
in.close();
}
std::string translation(const std::string &key)
{
auto iter = dict.find(key);
if (iter == dict.end())
return "Unknow";
else
return iter->second;
}
private:
std::unordered_map<std::string, std::string> dict;
};
有了这个类之后,我们在Task这个类编写调用逻辑
我们之前server函数和服务器是可以独立的,没有用到服务器这个类私有成员,直接就是CV一下到Task类成员函数run中 需要改就一句代码
echo_string += init.translation(buffer);
#pragma once
#include <iostream>
#include <string>
#include "log.hpp"
#include "Init.hpp"
extern Log lg;
Init init;
class Task
{
public:
Task(int fd, std::string ip, uint16_t port)
: sockfd(fd), clientip(ip), clientport(port)
{
}
Task() {}
void run()
{
char buffer[4096];
while (true)
{
// TCP是字节流读取的。所以我们可以直接用系统调用接口read和write来进行读写
ssize_t n = read(sockfd, buffer, sizeof(buffer));
if (n < 0)
{
lg(Fatal, "read error,sockfd: %d,clientip: %s,clientport:%d", sockfd, clientip.c_str(), clientport);
break;
}
else if (n == 0)
{
lg(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
break;
}
else
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
std::string echo_string = "tcpserver echo# ";
echo_string += init.translation(buffer);
write(sockfd, echo_string.c_str(), echo_string.size());
}
}
close(sockfd);
}
void operator()()
{
run();
}
~Task()
{
}
private:
int sockfd;
std::string clientip;
uint16_t clientport;
};
这样我们的服务器就有像样了,但是还有一个小问题。那就是我们在命令行上终止了服务器这个进程,我们再启动时会有绑定套接字失败,原因后面讲协议时,再说。
我们先用这个代码
int opt = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt)); // 防止偶发性的服务器无法进行立即重启(tcp协议的时候再说)
把它加入到TcpServer.hpp头文件的Init函数中。如下图
服务器OK了,那我们现在改进客户端
4.3 客户端升级版
升级版 我们加入了客户断线重连, 读写错误时的差错处理,比较简单粗暴。
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
// 这里直接写死
std::string defaultserverip = "121.37.237.128";
uint16_t defaultserverport = 8080;
int main()
{
std::string serverip = defaultserverip;
uint16_t serverport = defaultserverport;
struct sockaddr_in server;
// 初始化server
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
inet_aton(serverip.c_str(), &(server.sin_addr));
server.sin_port = ntohs(serverport);
socklen_t len = sizeof(server);
// 创建套接字
while (true)
{
int cnt = 5;
int isreconnect = false;
int sockfd = 0;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return 1;
}
do
{
// tcp客户端要不要bind?1 要不要显示的bind?0 系统进行bind,随机端口
// 客户端发起connect的时候,进行自动随机bind
int n = connect(sockfd, (struct sockaddr *)&server, sizeof(server));
if (n < 0)
{
isreconnect = true;
cnt--;
std::cerr << "connect error..., reconnect: " << cnt << std::endl;
sleep(2);
}
else
{
break;
}
} while (cnt && isreconnect);
if (cnt == 0)
{
std::cerr << "user offline..." << std::endl;
break;
}
std::string message;
std::cout << "Please Enter# ";
std::getline(std::cin, message);
int n = write(sockfd, message.c_str(), message.size());
if (n < 0)
{
std::cerr << "write error..." << std::endl;
break;
}
char inbuffer[4096];
n = read(sockfd, inbuffer, sizeof(inbuffer));
if (n > 0)
{
inbuffer[n] = 0;
std::cout << inbuffer << std::endl;
}
else
{
break;
}
close(sockfd);
}
return 0;
}
5. 守护进程
前面我们演示时,在任务终端直接按ctrl+c那么进程直接就退了。如果用户正在访问服务器,这样对用户体验非常不友好。基于这样的背景,就有了我们守护进程,说人话就如同手机上APP挂入到后台,也能一直运行,如果手机一直有电,那么微信就会一直后台运行。
我用的xshell每次登陆只能有一个前台进程,这个其实也好理解,我们在手机上如果不用分屏,其实我们就只能用一个APP,其他的APP都在后台运行。
那什么是前台进程,什么又是后台进程?
谁拥有键盘谁就是前台,因为键盘只有一份。如果后台进程也能接受到键盘的输入,那不乱套吗?就好比,你在刷抖音,微信在后台挂起,你向上滑动视频时,你的微信聊天列表也跟着同时滑动过吗?没有,只有将微信变成前台进程时,屏幕操作时,才对微信有效。
那Linux如何将前后台进程状态进行转化?
- 你可以使用
&
符号将命令放入后台运行,例如:command &
。
在命令行界面(CLI)或终端中,你可以使用以下命令来控制进程的前台和后台状态:
Ctrl+Z
:将当前前台进程放到后台,并暂停它。bg
:将被暂停的后台进程恢复运行。fg
:将后台进程带到前台继续运行。jobs
:列出当前终端会话中的后台作业。kill
:向进程发送信号以终止它。
现在test这个进程已经变成了后台进程。我们用fg +任务号 提到前台终止。
5.1 Linux 进程间的关系
包括它们的父进程ID(PPID)、进程ID(PID)、进程组ID(PGID)、会话ID(SID)、终端类型(TTY)、进程状态(STAT)、用户ID(UID)、CPU时间(TIME)和命令(COMMAND)。
我们刚才启动了2个后台进程,它们的进程组 ID 会话ID、父进程ID 都是同一个 我们通过指令查找就是bash。
而我们每次登陆xshell都只有一个bash,而bash对应我们会话 SID 。所以多个任务(进程组),在同一个session内启动SID都是一样的。
进程组ID一般都是多个进程中第一个。
那进程组和任务有什么关系?
- 进程组可以跨越多个任务,因为一个进程组内的进程可以被启动在不同的shell会话中,但仍然属于同一个进程组。
- 任务通常指的是当前shell会话中的进程,而进程组是操作系统级别的概念,可以包含不同shell会话中的进程。
- 在shell中,可以使用
&
将命令放入后台运行,形成后台任务。这些后台任务的进程可以属于不同的进程组,也可以属于相同的进程组,这取决于它们是如何启动的。 - 当在shell中使用
kill
命令时,可以指定要发送信号的进程组ID或任务ID,来控制一组进程或单个进程。
前面 process 其实都是受当前会话的影响。我们退出了当前会话,也就是意味着所有在当前会话中的进程都要跟着退出。
所以我们的进程要不受影响,自己成为一个会话,不受bash的影响。
基于前面讲的理论我们可以让我们服务器自成一个会话。
#pragma once
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
const std::string nullfile = "/dev/null";
void Daemon(const std::string &cwd = "")
{
// 1. 忽略其他异常信号
signal(SIGCLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
signal(SIGSTOP, SIG_IGN);
// 2. 将自己变成独立的会话
if (fork() > 0)
exit(0);
setsid();
// 3. 更改当前调用进程的工作目录
if (!cwd.empty())
chdir(cwd.c_str());
// 4. 标准输入,标准输出,标准错误重定向至/dev/null
int fd = open(nullfile.c_str(), O_RDWR);
if(fd > 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
这里我们重新退出,再登陆也直接访问服务器。而且进程组会话组都是同一个30379 说明它自成一个会话。
总结
本篇总共3.2W多个字,涵盖内容非常多,以前系统编程知识全部用上了,从进程到线程。本篇新学知识也是非常重要,许多的接口函数和系统调用,希望读者下去反复阅读。期间分别对UDP TCP写了简单的服务器与客户端,利用网络的特性将windows与Linux进行了通信。实现了一个简单聊天室,一个简单英中翻译。最后对后台与前台原理进行讲解,然后我们自己实现了一个守护进程。