目录
前言
前两篇文章分别简要介绍了套接字及其相关基础知识,并实现了一个简单的 UDP 网络程序。本文将继续介绍如何实现一个简单的 TCP 网络程序。与 UDP 编程相比,TCP 在实现步骤上稍多两三个环节,但整体结构仍较为相似。
套接字的封装
创建套接字
再次,我们代码书写的方式与UDP改进一下,将对套接字的相关代码封装成一个公共类,然后对于TCP中服务端与客户端之间的代码在分别进行封装。
TCP服务器创建套接字的做法与UDP服务器的细节是一样的。只不过在设定服务的方式是不一样的,对于应用层编程,现在可以先掌握如何使用这些系统调用来实现功能,现在不需要了解太多,后面会统一在网路层学习。
同样我们效仿UDP创建套接字的设计:
- 协议家族选择
AF_INET
,因为我们要进行的是网络通信。 - 但创建套接字时所需的服务类型应该是
SOCK_STREAM
,因为我们编写的是TCP服务器,SOCK_STREAM
提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。 - 协议类型默认设置为0即可。
同样与UDP一样,创建套接字后获得的文件描述符是小于0的,说明套接字创建失败,此时后续的操作就没有必要了,接下来就选择直接终止程序,打印对应的错误信息即可。
class Sock
{
public:
Sock()
{
}
void Socket()
{
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
}
~Sock()
{
if (_sockfd >= 0)
{
close(_sockfd);
}
}
private:
int _sockfd;
};
在实际下,TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务。
绑定套接字
套接字创建完成后,在实际上我们知识在系统层面上打开了一个文件,但是该文件还没有与网络联系起来,因此我们要建立联系。
同样TCP的绑定套接字与UDP完全一致,我们首先要创建一个struct sockaddr_in结构体,然后将该结构体的相关属性进行填充后,将其调用bind即可。
其中该结构体我们需要填充的属性分别是:协议家族、IP地址、端口号。
对应的协议家族就是AF_INET,IP地址我们需要根据特定情况单独设置,比如说,使用的是云服务器,那么在设置服务器的IP地址时,不需要显示绑定IP地址,直接将IP地址设置为INADDR_ANY
即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY
本质就是0,因此在设置时不需要进行网络字节序的转换。
当然对于客户端的是进行动态绑定的,所以我们一般来说是不需要自己进行绑定的,所以我们这里代码的编写就只需要考虑服务器端即可,将ip设置为0。对于端口号,就是TCP服务端启动时需要绑定的端口号,需要注意调用htons函数将端口号由主机序列转为网络序列。
void Bind(uint16_t port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
{
std::cerr << "bind error" << std::endl;
exit(2);
}
}
设置监听状态
因为TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信,所以TCP服务端需要时刻注意客户端是否发来请求,那么此时针对于这种情况,就需要将TCP服务端设置为监听状态,其调用的函数即为listen。
listen函数的原型如下:
int listen(int sockfd, int backlog);
参数说明:
- sockfd:需要设置为监听状态的套接字对应的文件描述符。
- backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。
返回值说明:
- 监听成功返回0,监听失败返回-1,同时错误码会被设置。
补充:对于TCP网络编程,其只需要将服务端设置为监听状态即可,不需要将客户端设置为监听状态。
设置监听状态是保证服务端的套接字创建于绑定是正确且完成的,才可以进行设置监听状态。来监听是否有新的连接到来。同样与前面一致,如果监听设置失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可。
// 设置连接请求队列的最大长度为10
const int backlog = 10;
void Listen()
{
if(listen(_sockfd, backlog) < 0)
{
std::cerr << "listen error" << std::endl;
exit(3);
}
}
注意:其中的参数backlog现在简单理解即可。其中此时的套接字便不是简单的套接字,而是监听套接字。
获取连接
TCP 服务器完成地址绑定(bind)后,此时服务处于监听就绪状态 ,但尚未开始接受客户端连接。此时的服务器已经准备好接收连接请求,但需要进一步调用 listen
和 accept
函数,才能正式建立与客户端的通信链路,实现完整的连接处理功能。
对应调用的函数即为accept函数,其该函数的原型如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数说明:
- sockfd:特定的监听套接字,表示从该监听套接字中获取连接。
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。为输入输出参数。
- addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。
返回值说明:
- 获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置。
其中调用accept函数获取连接时,是从监听套接字当中获取的。如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。
监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。
accept函数返回的套接字:用于为本次accept获取到的连接提供服务。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字。
服务端获取连接
对于代码,服务端获取连接的函数accept其中有两个参数,分别是sockfd,还有sockaddr结构体,还有socklen_t,我们需要先创建一个sockaddr结构体。然后调用accept参数,然后获得连接的客户端的各个属性,然后此时我们也可以通过输入输出函数,然后获取该客户端的ip与端口号。
同样需要注意的是,要注意各个属性中不同格式的转换。
int Accept(std::string *clientip, uint16_t *clientport)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int newfd = accept(_sockfd, (struct sockaddr*)&peer, &len);
if(newfd < 0)
{
std::cerr << "accept error" << std::endl;
exit(4);
}
char ipstr[64];
// 字符串转 IPV4 格式
inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));
*clientip = ipstr;
*clientport = ntohs(peer.sin_port);
return newfd;
}
发送连接请求
由于客户端不需要绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求。当客户端想向服务端建立连接时,就需要调用函数connect函数,其函数如下:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
- sockfd:特定的套接字,表示通过该套接字发起连接请求。
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度。
返回值说明:
- 连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。
同样,因为connect中的第一个参数时sockaddr,我们需要知道服务端的各个属性,所以我们要自己传参数服务端的ip与端口号。同样需要记得调用htons函数将端口号转化为网络字节序,与调用inet_pton函数,将字符串ip形式转化为整数ip格式。
bool Connect(const std::string &ip, const uint16_t &port)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &(peer.sin_addr));
int n = connect(_sockfd, (struct sockaddr*)&peer, sizeof(peer));
if(n == -1)
{
std::cerr << "connect to " << ip << ":" << port << " error" << std::endl;
return false;
}
return true;
}
当我对套接字完成了封装后,我们就可以直接调用即可,以下是完整的封装。
同样添加两个函数,分别是关闭套接字与获取套接字函数。
void Close()
{
close(_sockfd);
}
int Fd()
{
return _sockfd;
}
#pragma once
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
// 设置连接请求队列的最大长度为10
const int backlog = 10;
class Sock
{
public:
Sock()
{
}
void Socket()
{
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
exit(1);
}
}
void Bind(uint16_t port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
{
std::cerr << "bind error" << std::endl;
exit(2);
}
}
void Listen()
{
if(listen(_sockfd, backlog) < 0)
{
std::cerr << "listen error" << std::endl;
exit(3);
}
}
int Accept(std::string *clientip, uint16_t *clientport)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int newfd = accept(_sockfd, (struct sockaddr*)&peer, &len);
if(newfd < 0)
{
std::cerr << "accept error" << std::endl;
exit(4);
}
char ipstr[64];
// 字符串转 IPV4 格式
inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));
*clientip = ipstr;
*clientport = ntohs(peer.sin_port);
return newfd;
}
bool Connect(const std::string &ip, const uint16_t &port)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &(peer.sin_addr));
int n = connect(_sockfd, (struct sockaddr*)&peer, sizeof(peer));
if(n == -1)
{
std::cerr << "connect to " << ip << ":" << port << " error" << std::endl;
return false;
}
return true;
}
void Close()
{
close(_sockfd);
}
int Fd()
{
return _sockfd;
}
~Sock()
{
if (_sockfd >= 0)
{
close(_sockfd);
}
}
private:
int _sockfd;
};
TCP网络程序服务端
同样,我们可以将 TCP 服务器功能封装为一个 TcpServer
类。在该类中,其成员变量之一就是我们之前封装的套接字类所定义的对象。为了区分,我们将该定义的对象命名为listen_sock。除此之外,我们还需要添加别的类成员来表示端口号。
有了前面实现UDP网络程序的经验,我们这里就直接将该类的构造函数与前UDP设计保持一致,这里不解释:
#include "Socket.hpp"
static const int defaultport = 8082;
class TcpServer
{
public:
TcpServer(uint16_t port = defaultport)
:_port(port)
{}
private:
Sock listen_sock;
uint16_t _port;
};
服务端创建套接字
第一步就是创建套接字,可以使用我们刚才封装的现成的函数。

服务端绑定
同样因为我们刚才封装了,就可以直接调用。

服务端监听
一样,直接调用刚才封装的函数

服务端获取连接
无论是 TCP 还是 UDP 服务器,都需要持续运行以保持服务可用,始终处于等待客户端请求的状态。因此,与 UDP 服务器类似,TCP 服务器同样需要通过一个循环结构持续运行,实时监听并处理来自客户端的连接或数据请求。
所以代码整体是一个for死循环。
void start()
{
for(;;)
{
std::string clientip;
uint16_t clientport;
int sockfd = listen_sock.Accept(&clientip, &clientport);
if(sockfd < 0)
continue;
std::cout << "get a new connect, sockfd:" << sockfd << std::endl;
}
}
服务端处理请求
同样,我们对于接收到的请求,依然不做任何的处理,而是原封不动的直接打印,但是为了区分是谁发的,我们前面加上xxx :。
但是需要注意的是:此时,TCP 服务器已经能够接收客户端的连接请求,接下来需要对已建立的连接进行处理。需要注意的是,直接为客户端提供服务的并非监听套接字本身------监听套接字在成功接收一个连接后,会继续监听后续的连接请求。实际负责与客户端通信的是 accept
函数返回的套接字,下文将其称为"服务套接字"。
read函数
TCP服务器读取数据的函数叫做read,该函数的函数原型如下:
ssize_t read(int fd, void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示从该文件描述符中读取数据。
- buf:数据的存储位置,表示将读取到的数据存储到该位置。
- count:数据的个数,表示从该文件描述符中读取数据的字节数。
返回值说明:
- 如果返回值大于0,则表示本次实际读取到的字节个数。
- 如果返回值等于0,则表示对端已经把连接关闭了。
- 如果返回值小于0,则表示读取时遇到了错误。
write函数
TCP服务器写入数据的函数叫做write,该函数的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
- buf:需要写入的数据。
- count:需要写入数据的字节个数。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
当我们的客户端write向服务端发送数据,服务端调用read收到后,先打印client_ip:xxx。然后再调用write返回。客户端read收到后,打印:server :。
同样还是需要注意的是:我们调用read中的参数fd是服务套接字中读取的,而不是监听套接字中读取。而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。
在从服务套接字中读取客户端发来的数据时,如果调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。同样我们要养好良好的代码习惯,用完即关,保证在有限的资源下,避免浪费。
void start()
{
for(;;)
{
std::string clientip;
uint16_t clientport;
int sockfd = listen_sock.Accept(&clientip, &clientport);
if(sockfd < 0)
continue;
std::cout << "get a new connect, sockfd:" << sockfd << std::endl;
char buffer[1024];
while(true)
{
ssize_t size = read(sockfd, buffer, sizeof(buffer));
if (size > 0)
{
buffer[size] = '\0';
std::cout << clientip << " : " << buffer << std::endl;
write(sockfd, buffer, size);
}
else if(size == 0)
{
std::cout << clientip << "close" << std::endl;
break;
}
else
{
std::cerr << sockfd << " read error!" << std::endl;
break;
}
}
close(sockfd); //归还文件描述符
std::cout << clientip << ":" << clientport << " service done!" << std::endl;
}
}
TCP网络程序客户端
我们可以直接复用已封装的套接字创建方法。得益于前期的完整封装,客户端在初始化阶段无需额外操作,这就相对于我们前面UDP代码的封装思路相比,极大简化了代码实现。所以对于项目代码的书写,选择正确的封装,可以极大减少代码的书写。
这时,我们就不需要再将客户端进行封装成一个类了,我们可以直接调用我们刚才封装的函数即可。
此时我们就可以用一个简单的.cc文件代替客户端的代码,不需要再添加一个.hpp文件。
对于代码的大致思路就是先通过命令行参数,来获取客户端想要与服务端建立连接的ip地址与端口号。
然后根据此,实例化一个sockaddr_in结构体对象,然后对其属性进行填充。然后创建一个我们封装的socket类的对象,然后进行套接字的创建,又因为客户端的绑定是动态绑定,所以我们下一步就是向服务端发送连接请求,连接成功后,就向服务端发送数据请求。以此进行下去。
#include "Socket.hpp"
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << "server_ip server_port" << std::endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));
while(true)
{
Sock sock;
sock.Socket();
int sockfd = sock.Fd();
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return 1;
}
int n = sock.Connect(serverip, serverport);
if (n < 0)
{
std::cerr << "connect error..., reconnect: " << std::endl;
exit(1);
}
// 连接成功
while(true)
{
std::string message;
// 先发
std::cout << "Please Enter# ";
std::getline(std::cin, message);
int size = write(sockfd, message.c_str(), sizeof(message));
if (size < 0)
{
std::cerr << "write error..." << std::endl;
exit(2);
}
// 读取server处理后的数据
char inbuffer[4096];
size = read(sockfd, inbuffer, sizeof(inbuffer));
if (size > 0)
{
inbuffer[size] = 0;
std::cout << "server : " << inbuffer << std::endl;
}
else
{
close(sockfd);
}
}
}
return 0;
}
服务器测试
下面我们就完成对于服务端的代码测试代码,这里就直接给出。
#include "TcpServer.hpp"
#include <iostream>
#include <memory>
void Usage(std::string proc)
{
std::cout << "\n\rUsage: " << proc << " port[1024+]\n" << std::endl;
}
//./tcpserver 8080
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> tcp_svr(new TcpServer(port));
tcp_svr->Init();
tcp_svr->start();
return 0;
}
最后给出Makefile文件
.PHONY:all
all:tcpserver tcpclient
tcpserver:main.cc
g++ -o $@ $^ -std=c++11
tcpclient:TcpClient.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f tcpserver tcpclient
代码运行效果如下:
也是符合我们的预期,所以,目前来看,我们代码是没有错误的。

单执行流服务器的弊端
当我们仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务。
但在这个客户端正在享受服务端的服务时,我们让另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端。

只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。

单执行流的服务器
通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。
当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端。
但是客户端为什么会显示连接成功?
当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了。
实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。
对于该问题的解决方式有三种,分别是引进多进程,多线程的概念到服务端,或者引入进程池的概念。
对于该部分的引入,会放在下一篇文章。