目录
前言
在《套接字接口》一文中,我们介绍了TCP通信时的接口
这一期我们将基于接口的说明,自己手动编写一个基于TCP的echo服务
什么是TCP Echo Server?
TCP echo server 是一种网络服务,它接受来自客户端的 TCP 连接,并将收到的每一个数据包原封不动地返回给客户端。
简单来说就是我们需要实现服务端与客户端
客户端向服务器发送一个字符串数据,而服务器需要把客户端发送的数据重新发送回给客户端
对于Echo服务的功能,其实并没有什么太大的意义,重要的是我们要学习如何基于TCP协议进行跨主机通信!
并且这一篇文章为了简单易懂,尽量会减少对socket接口的封装
服务器实现
要写出一个echo服务,首先需要经历3个步骤
- 初始化TCP server
- 接收客户端的连接请求
- 提供echo服务
cpp
class TCPServer
{
TCPServer(uint16_t port)//通过端口号构造一个TCPServer对象
:_port(port) , _isrunning(false)
{}
void InitServer(); //初始化TCPServer
void Accept();//接收客户端的请求
void Loop();//提供echo服务
protected:
uint16_t _port; //端口号
int _listensock;
bool _isrunning; //控制Server是否运行
}
初始化TCPserver
需要初始化一个TCPServer对象,同样也要经历三步
- 创建套接字
- 绑定套接字
- 设置套接字为listen状态
cpp
void InitServer() //初始化TCPServer
{
//1、创建套接字
CreateSocket();
//2、绑定套接字
BindSocket();
//3、设置套接字为listen状态
SetListenSocket();
}
创建套接字
TCP创建套接字使用的接口是socket
调用socket()
系统调用创建一个套接字,指定通信协议(TCP/UDP)、地址族(IPv4/IPv6)和套接字类型。
cpp
int socket(int domain, int type, int protocol);
- domain:指定通信域,常见值有
AF_INET
(IPv4)和AF_INET6
(IPv6)。 - type:指定套接字类型,常见类型为
SOCK_STREAM
(TCP)和SOCK_DGRAM
(UDP)。 - protocol指定具体的协议,通常为0(系统自动选择合适的协议)。
- 返回值:若成功,一个文件描述符(套接字)被返回。若失败,-1被返回,错误码被设置。
- 头文件:sys/types.h 和 sys/socket.h
cpp
void CreateSocket()
{
_listensock = socket(AF_INET,SOCK_STREAM,0);
if(_listensock < 0)
{
std::cerr << "create socket error!\n" << std::endl;
exit(1);
}
}
绑定套接字
TCP绑定套接字使用的系统调用是bind
bind
系统调用用于将套接字(socket)绑定到一个特定的地址和端口号上。这个调用主要用于服务器端,目的是将一个特定的网络地址与一个套接字关联,使得操作系统能够将收到的数据包交给这个套接字处理。
cpp
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:套接字描述符,通常是通过
socket()
系统调用创建的套接字的文件描述符。 - addr:指向
sockaddr
结构体的指针。这个结构体包含了要绑定的地址信息。实际使用中,这通常是一个sockaddr_in
(IPv4)或sockaddr_in6
(IPv6)结构体的指针。 - addrlen:表示addr结构体的大小,以字节为单位。通常使用
sizeof(struct sockaddr_in)
或sizeof(struct sockaddr_in6)
。 - 返回值:若成功,返回0。若失败,返回-1,且错误码被设置
- 头文件:sys/types.h 和 sys/socket.h
补充:sockaddr和sockaddr_in
sockaddr:是一个通用的地址结构体,用于定义套接字的地址。它是一个通用的数据结构,适用于各种协议族(如 IPv4、IPv6)。但由于它的通用性,它没有具体的字段用于存储 IP 地址或端口号,需要通过特定的派生结构(如 sockaddr_in
)来实现这些功能。
sockaddr_in:sockaddr_in是sockaddr的专用版本,专门用于 IPv4 地址。这个结构体非常常用,因为它能够明确存储 IP 地址和端口号,适合用于 IPv4 网络通信。
总的来说,我们基于IPv4进行通信时,一般不会定义一个sockaddr类型,一般定义一个sockaddr_in,把定义出来的sockaddr_in对象填充完毕以后,强转为sockaddr类型后传参
sockaddr_in的头文件: netinet/in.h 和 arpa/inet.h
sockaddr_in要填充的字段:
- sin_addr.s_addr:这个填写的是IP地址的网络字节序,但服务器一般不会固定一个IP。所以这个参数填写INADDR_ANY即可。这意味着服务器将接收发往机器上任何网络接口的流量,而不仅仅是特定的 IP 地址。
- sin_family:IPv4下一般固定为AF_INET
- sin_port:表示该服务所绑定的端口号的网络字节序,主机序转为网络字节序的接口是htons
绑定套接字的实现
cpp
void BindSocket()
{
sockaddr_in addr;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_family = AF_INET;
addr.sin_port = htons(_port);
int n = bind(_listensock,reinterpret_cast<sockaddr*>(&addr),sizeof(addr));
if(n < 0)
{
std::cerr << "Server Bind Error!" << std::endl;
exit(2);
}
}
设置套接字为listen状态
对于TCP来说,要实现两台主机通信的前提是两台主机必须建立连接。而服务器要从绑定好的套接字中获取连接,那么需要设置该套接字为listen状态
设置套接字为listen状态的接口是listen
cpp
int listen(int sockfd, int backlog);
- sockfd:这是一个已通过socket系统调用创建的套接字描述符。这个套接字必须是一个 TCP 套接字(即,SOCK_STREAM类型),才能使用listen
- backlog:这个参数与TCP底层的全连接队列有关,之后会出一篇文章详细说明。
backlog的值该设置为多少?
-
一般来说,常见的
backlog
值范围在 5 到 1024 之间。具体的推荐值取决于你的应用场景: -
对于小型应用或测试环境,
backlog
值可以设置为 5 到 20。 -
对于中等负载的生产环境,可能需要设置为 50 到 100。
-
对于高负载、大规模的生产环境,可能需要设置为 500 或更高。
cpp
void SetListenSocket()
{
int n = listen(_listensock,8);
if(n < 0)
{
std::cerr << "Set Listen Status Error!" << std::endl;
exit(2);
}
}
接收客户端的连接请求
当我们完成初始化工作以后,这个TCP服务器已经可以接收客户端的连接请求了,但仅仅如此还不够。服务器在为客户端提供服务之前,需要先同意客户端的连接请求
接受客户端的连接
接受客户端的连接,我们使用的系统调用是accept
accept()系统调用的作用是从一个已绑定的、监听状态的套接字中接受一个新的连接。这个套接字通常是由socket()创建并通过bind()和listen进行设置的。调用后,系统会从等待队列中取出一个连接请求,并返回一个新的套接字,这个套接字用于与客户端进行通信。
cpp
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- sockfd: 监听套接字的文件描述符,即通过socket()创建并设置为监听状态的套接字。
- addr: 指向一个sockaddr结构体的指针,用于存放客户端的地址信息。如果不需要客户端的地址信息,可以传递NULL。
- addrlen: 指向一个socklen_t类型的变量的指针,用于传出客户端地址的长度。初始值应为addr所指向的结构体大小,调用成功后,这个变量将被设置为实际地址长度。如果不需要,可以传递NULL。
- 返回值:若成功,返回一个新的套接字文件描述符,专门用于与客户端进行通信(区别listen套接字,listen套接字是用于获取连接的)。若失败,返回-1,错误码被设置
- 头文件:sys/types.h 和 sys/socket.h
注意:若服务器未收到任何连接请求,那么调用accept的执行流会阻塞等待,直到有连接到来
cpp
void Accept(int& sockfd)//服务器提供服务之前
{
//1、接受客户端的连接请求
sockfd = accept(_listensock,nullptr,nullptr);
if(sockfd < 0)
{
std::cerr << "accept client link error!" << std::endl;
exit(4);
}
}
提供echo服务
提供服务之前,服务器得先获取客户端的输入,也就是要知道从哪个套接字中获取。对于这一点,在accept时返回的sockfd解决了这个问题!
并且,服务器的主执行流需要不断获取连接
提供echo服务,首先要完成n步:
- 主执行流获取连接(循环)
- 处理TCP通信时的执行流问题
- 提供echo服务
TCP服务的执行流问题
若服务器的主执行流去为客户端提供服务的话,由于主执行流只有一个,那么会导致一个服务器只能服务一个客户端。 而在实际当中不可能会出现一个服务器专门服务一个客户端的情况。所以一般来说TCP通信通常采用主执行流获取连接,当连接到来的时候,主执行流创建新执行流为客户端服务。
创建多执行流的一般方式有2个:
- 多线程
- 多进程
对于服务时间较短,一下子就好的服务,也可以采用线程池和进程池。
多进程等待问题
对于多进程来说,父进程需要完成自动等待所有的子进程。等待方式有两种
- Linux下忽略信号SIGCHLD,即可实现自动等待子进程
- 利用孤儿进程会被操作系统自动回收的性质,创建一个孤儿线程
多进程TCP通信代码实现
注意:在多进程提供服务之前,建议关闭对于各自进程无关的文件描述符,否则会造成文件描述符不足的问题!
- 对于父进程来说,父进程只完成获取连接的工作,所以可以关闭accept获取的那个文件描述符
- 对于子进程来说,子进程完成提供服务,不需要获取连接,关闭listensock文件描述符
注意:在提供服务时,recv和send都需要指定一个套接字,所以我们需要传入sockfd作为参数
cpp
void Loop() // 提供echo服务
{
// 服务器需要不断获取连接
_isrunning = true;
while (_isrunning)
{
int sockfd = -1;
Accept(sockfd);
// 走到这,说明客户端连接成功,通信之前需要解决执行流问题,我们先采用多进程
pid_t id = fork();
if (id == 0)
{
// 子线程
// 创建孤儿进程
if (fork() > 0)
exit(0);
// 关闭listensock
close(_listensock);
// 提供服务
Service(sockfd);
}
close(sockfd);
}
_isrunning = false;
}
多线程问题
多线程等待,直接由主线程或者新线程调用线程分离即可,我采用主线程调用线程分离的方式
多线程需要传递两个参数
- 由于线程方法固定参数为void*(void*),不能调用默认成员函数,因为它带有this指针。所以我们需要设置一个静态成员方法,并且把this指针和sockfd都传入进去,可以把this指针和sockfd封装成一个ThreadData对象
多线程TCP通信代码
传入ThreadData对象后,就可以直接调用Service提供服务
cpp
class TCPServer;
struct ThreadData
{
ThreadData(TCPServer* self, int sockfd) : _self(self) , _sockfd(sockfd)
{}
TCPServer* _self;
int _sockfd;
};
//...
static void* threadroutine(void* arg)
{
//线程分离
pthread_detach(pthread_self());
ThreadData* self = reinterpret_cast<ThreadData*>(arg);
//提供服务
self->_self-> Service(self->_sockfd);
delete self;
}
void Loop() // 提供echo服务
{
// 服务器需要不断获取连接
_isrunning = true;
while (_isrunning)
{
int sockfd = -1;
Accept(sockfd);
pthread_t tid;
ThreadData* data = new ThreadData(this,sockfd);
pthread_create(&tid , nullptr , threadroutine , data);
}
_isrunning = false;
}
服务接口Service
Serveice主要完成两步
- 获取客户端的输入
- 发送客户端的输入
cpp
void Service(int sockfd)
{
while (true)
{
//获取客户端的输入
char buffer[1024];
int n = recv(sockfd, buffer, sizeof(buffer), MSG_WAITALL);
if (n > 0)
{
// 接收成功
std::cout << "Client say# " << buffer << std::endl;
// 服务器把buffer中的数据重新发送给客户端,完成服务
//发送客户端的输入
send(sockfd, buffer, sizeof(buffer), 0);
}
else if (n == 0)
{
// 客户端套接字关闭
std::cout << "client socket close!" << std::endl;
close(sockfd);
exit(0);
}
else
{
// recv error
std::cerr << "recv error!" << std::endl;
close(sockfd);
exit(5);
}
}
}
服务器调用逻辑
cpp
#include "TCPServer.h"
// ./Tcpserver port
int main(int argc,char* argv[])
{
if(argc != 2)
{
std::cout << argv[0] << " port" << std::endl;
exit(0);
}
uint16_t port = std::stoi(argv[1]);
TCPServer t(port);
t.InitServer();
t.Loop();
return 0;
}
客户端实现
注意:对于客户端的实现不再使用任何封装
客户端需要获取两个信息
- 服务器的端口号
- 服务器的IP
这两个信息由用户输入
获取了端口号和IP后,客户端同样需要使用socket调用创建套接字,但无需绑定,因为在创建套接字时底层操作系统自动绑定了端口号和IP地址
创建好套接字后,客户端需要向服务器发送连接请求
发送连接请求使用的系统调用是connect
cpp
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:套接字文件描述符,是在前一步时socket的返回值
- addr:指向一个sockaddr结构体的指针,这个结构体包含了服务器的地址信息,如 IP 地址和端口号。根据协议不同,具体的sockaddr结构体可能有不同的类型,例如sockaddr_in(IPv4)。(要填充好这个参数中的字段)
- addrlen:addr类型的长度,IPv4下是sizeof(sockaddr_in)
- 返回值:若成功,返回0。若失败,返回-1,错误码被设置。
- 头文件:sys/socket.h 和 sys/types.h
客户端发起连接后,即可通过recv和send系统调用与服务器进行通信
代码实现
cpp
#include <iostream>
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void Usage(char *arg)
{
std::cout << "Usage:" << arg << " ip port" << std::endl;
}
// ./tcpclient ip port
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(0);
}
// 获取服务器端口号和IP地址
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
// 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
std::cerr << "client socket create error!" << std::endl;
exit(1);
}
// 填充connect的sockaddr参数
sockaddr_in peer;
inet_pton(AF_INET, ip.c_str(), &peer.sin_addr.s_addr);
peer.sin_family = AF_INET;
peer.sin_port = htons(port);
// 客户端发起连接
int n = connect(sockfd, reinterpret_cast<sockaddr *>(&peer), sizeof(peer));
if (n == 0)
{
// 客户端与服务器进行通信
while (true)
{
std::cout << "Please Enter# ";
char buffer[1024];
std::cin >> buffer;
send(sockfd, buffer, sizeof(buffer), 0);
char outbuffer[1024];
ssize_t n = recv(sockfd, outbuffer, sizeof(outbuffer), 0);
std::cout << outbuffer << std::endl;
}
}
return 0;
}
至此,最基本的一个基于TCP通信的代码已经完成,接下来看看结果展示