【Linux】:Socket编程应用层 TCP
📃个人主页:island1314
🔥个人专栏:Linux—登神长阶
⛺️ 欢迎关注:👍点赞 👂🏽留言 😍收藏 💞 💞 💞
1. 前言
在上篇文章里面已经讲了关于 Socket UDP 网络编程的内容,这篇文章我们主要是关于 Socket TCP 网络编程的内容
老样子,先写 Makefile 文件,如下:
.PHONY:all
all:server_tcp client_tcp
server_tcp:UdpServerMain.cc
g++ -o $@ $^ -std=c++17 -lpthread
client_tcp:UdpClientMain.cc
g++ -o $@ $^ -std=c++17 -lpthread
.PHONY:clean
clean:
rm -f server_tcp client_tcp
2. EchoSever -- 单进程
同样还需要把框架写好
2.1 基本框架
TCPClient.cc
#include <iostream>
int main()
{
return 0;
}
TCPServer.cc
#include "TcpServer.hpp"
#include <memory>
int main()
{
ENABLE_CONSOLE_LOG();
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>();
tsvr->InitServer();
tsvr->start();
return 0;
}
导入我们之前的 Log.hpp、Common.hpp、Mutex.hpp,然后对我们之前实现的 Common.hpp 也要做一下修改
2.2 listen & accept 函数
在写具体实现代码之前,我们先来了解一些相关知识
🐇 listen
listen 函数是网络编程中的一个重要函数,通常用于将套接字(socket)设置为监听状态,以接受客户端的连接请求。它通常在服务器端使用,与 socket
、bind
和 accept
函数配合使用。
函数原型(C/C++)
在 POSIX 系统(如 Linux)中,listen 函数的原型如下:
int listen(int sockfd, int backlog);
在 Windows 系统中,listen 函数的原型如下:
int listen(SOCKET sockfd, int backlog);
参数说明
sockfd
:
这是一个套接字描述符(socket file descriptor),通常由
socket
函数创建。在调用
listen
之前,必须先调用bind
将套接字绑定到一个本地地址和端口。
backlog
:
这是一个整数,表示等待连接队列的最大长度。
当多个客户端同时尝试连接服务器时,服务器可能无法立即处理所有连接请求。
backlog
参数定义了等待连接队列的最大长度。如果队列已满,新的连接请求可能会被拒绝(客户端会收到
ECONNREFUSED
错误)返回值返回值
成功:返回
0
。失败:返回
-1
(在 POSIX 系统中)或SOCKET_ERROR
(在 Windows 系统中),并设置errno
(POSIX)或调用WSAGetLastError
(Windows)来获取错误代码。
🐇 accept
accept 函数是网络编程中的一个核心函数,用于服务器端接受客户端的连接请求。它通常在 socket
、bind
和 listen 之后调用,用于从监听队列中取出一个客户端连接,并创建一个新的套接字用于与客户端通信。
函数原型(C/C++)
在 POSIX 系统(如 Linux)中,accept 函数的原型如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
在 Windows 系统中,accept 函数的原型如下:
SOCKET accept(SOCKET sockfd, struct sockaddr *addr, int *addrlen);
参数说明
sockfd
:
这是一个监听套接字描述符(socket file descriptor),通常由
socket
创建并通过bind
和listen
设置为监听状态。
addr
:
这是一个指向
struct sockaddr
的指针,用于存储客户端的地址信息(如 IP 地址和端口号)。如果不需要客户端的地址信息,可以将其设置为
NULL
。
addrlen
:
这是一个指向
socklen_t
(POSIX)或int
(Windows)的指针,表示addr
结构体的大小。在调用
accept
之前,需要将其初始化为addr
结构体的大小。调用完成后,
addrlen
会被设置为实际存储的地址信息的长度。返回值
成功:
返回一个新的套接字描述符(POSIX 中是
int
,Windows 中是SOCKET
),用于与客户端通信。这个新的套接字与监听套接字不同,专门用于与客户端进行数据交换。
失败:
返回
-1
(POSIX)或INVALID_SOCKET
(Windows),并设置errno
(POSIX)或调用WSAGetLastError
(Windows)来获取错误代码。
🐇 listen 和 accept 使用步骤
listen 和
accept 函数通常用于服务器端,典型的使用步骤如下:
-
调用 socket 创建一个套接字。
-
调用 bind 将套接字绑定到一个本地地址和端口。
-
调用 listen 将套接字设置为监听状态。
-
调用 accept 接受客户端的连接请求。
-
使用 accept 返回的新套接字与客户端通信。
-
通信完成后,关闭新套接字。
2.3 TcpServer.hpp
#include <iostream>
#include <cstring>
#include <string>
#include <cerrno>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "Log.hpp"
#include "Common.hpp"
#define BACKLOG 8
using namespace LogModule;
static const uint16_t gport = 8080;
class TcpServer
{
public:
TcpServer(int port = gport): _port(port), _isrunning(false)
{
}
void InitServer()
{
// 1. 创建 Tcp Socket
_listensockfd = ::socket(AF_INET, SOCK_STREAM, 0); // TCP SOCKET
if(_listensockfd < 0)
{
LOG(LogLevel::FATAL) << "socket";
Die(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "socket create success, socked is : " << _listensockfd;
struct sockaddr_in local;
memset(&local, 0, sizeof(local));;
local.sin_family = AF_INET;
local.sin_port = htons(gport);
local.sin_addr.s_addr = INADDR_ANY;
// 2. bind
int n = ::bind(_listensockfd, CONV(&local), sizeof(local));
if(n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
Die(BIND_ERR);
}
LOG(LogLevel::INFO) << "bind success, sockfd is : " << _listensockfd;
// 3.cs, tcp 是面向连接的,因此需要 tcp 随时随地等待被连接
// tcp 需要将 socket 设置为监听状态
n = ::listen(_listensockfd, BACKLOG);
if(n < 0)
{
LOG(LogLevel::FATAL) << "listen error";
Die(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "listen success, sockfd is : " << _listensockfd;
}
void start()
{
_isrunning = true;
while(_isrunning)
{
// 不能直接读取数据
// 1. 获取新连接
struct sockaddr_in peer;
socklen_t peerlen;
int sockfd = ::accept(_listensockfd, CONV(&peer), &peerlen);
if(sockfd < 0)
{
LOG(LogLevel::WARNING) << "accepet error: " << strerror(errno);
}
// 获取连接成功
LOG(LogLevel::INFO) << "accept success, sockfd is : " << sockfd;
}
}
void Stop()
{
_isrunning = true;
}
~TcpServer()
{
}
private:
int _listensockfd; // 监听 socket
uint16_t _port;
bool _isrunning;
};
验证
当我们打开浏览器的时候,其实它底层用的就是 TCP,比如我们访问网站输入其网站即可,我们的云服务器其实也是网站一个公开的服务,拿浏览器模拟访问云服务器,如下:
为啥会一次性弹出这么多呢?
原因:因为浏览器服务器它在访问的时候,是多线程的去访问我们的多种资源的,我们的 4、5、6、7 就是 它同时打开的多个资源,相当于多线程多次向服务端发生的连接
我们这里其实是有点问题的
🔥 在调用 accept() 函数时,需要让 socklen_t peerlen = sizeof(peer);
这一行代码的作用是为 peerlen 变量赋初值,表示 struct sockaddr_in peer
结构体的大小。这个初值是必要的
还记得我们上面说的 accept() 原型嘛
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
在调用 accept() 时,addrlen
既是输入参数,也是输出参数:
-
输入:调用者需要告诉accept() 函数,
addr
缓冲区的大小是多少(即 sizeof(peer))。 -
输出:accept() 函数会将实际写入
addr
的客户端地址信息的大小写回到addrlen
中。
为什么需要 peerlen = sizeof(peer)
?
-
初始化缓冲区大小:
-
peerlen 需要被初始化为 sizeof(peer),以告诉 accept() 函数,peer 缓冲区的大小是多少。
-
如果没有初始化 peerlen,accept() 函数将无法知道 peer 缓冲区的大小,可能导致缓冲区溢出或未定义行为。
-
-
输出实际地址信息大小:
-
accept() 函数会将实际写入 peer 的客户端地址信息的大小写回到 peerlen 中。
-
例如,如果客户端地址信息的大小是 16 字节,accept() 会将 peerlen 更新为 16。
-
2.4 HandlerRequest
这里网站一直在转,是因为我们还没有实现其对应的操作,在 TcpServer.cc 操作如下:
函数实现
void HandlerRequest(int sockfd) // TCP 同UDP 一样,也全双工通信
{
LOG(LogLevel::INFO) << "HandlerRequest, sockfd is : " << sockfd;
char inbuffer[4096];
while (true)
{
// 读取客户端数据
ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
if (n > 0)
{
inbuffer[n] = 0; // 确保字符串以 null 结尾
std::string echo_str = "server echo# "; // 回显数据给客户端
echo_str += inbuffer;
::write(sockfd, echo_str.c_str(), echo_str.size());
}
}
}
telnet
使用如下:
退出的话 CTRL + ],再输入 quit 即可
测试如下:
2.5 TcpClient.cc -- 客户端
上面我们已经把服务器的内容写了,下面我们开始对客户端 TcpClient.cc 进行编写
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// ./client_tcp server_ip server_port
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cout << "Usage:./client_tcp server_ip server_port" << std::endl;
return 1;
}
// 获取 server ip 和 port
std::string server_ip = argv[1]; // "192.168.1.1" 点分十进制的 ip 地址
int server_port = std::atoi(argv[2]);
// 创建 socket
int sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
if(sockfd < 0)
{
std::cout << "Error: Failed to create socket" << std::endl;
return 2;
}
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(server_port);
server_addr.sin_addr.s_addr = inet_addr(server_ip.c_str());
// client 不需要显示的进行 bind,tcp 是面向连接的协议,需要先建立连接
int n = ::connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if(n < 0)
{
std::cout << "Error: Failed to connect to server" << std::endl;
return 3;
}
// 发送数据
std::string message;
while(true)
{
char inbuffer[1024];
std::cout << "input message: " ;
std::getline(std::cin, message);
n = ::write(sockfd, message.c_str(), message.size());
if(n > 0)
{
int m = ::read(sockfd, inbuffer, sizeof(inbuffer));
if(m > 0)
{
inbuffer[m] = 0;
std::cout << inbuffer << std::endl;
}
else break;
}
else break;
}
::close(sockfd);
return 0;
}
演示如下:
2.6 fd 浪费
文件描述符(File Descriptor, FD)泄露是指程序在运行过程中打开了文件或其他资源(如套接字、管道等),但没有正确关闭它们,导致这些文件描述符一直占用系统资源的情况。文件描述符泄露会导致系统资源耗尽,进而引发程序崩溃或系统性能下降。
我们的上面代码其实就存在 fd 泄露问题,当我们直接退出服务器的时候,再连接就会出现 bind 问题,因此我们还需要做点修改
void HandlerRequest(int sockfd) // TCP 同UDP 一样,也全双工通信
{
LOG(LogLevel::INFO) << "HandlerRequest, sockfd is : " << sockfd;
char inbuffer[4096];
while (true)
{
memset(inbuffer, 0, sizeof(inbuffer)); // 清空缓冲区
// 读取客户端数据
ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
if (n > 0)
{
LOG(LogLevel::INFO) << inbuffer;
inbuffer[n] = 0; // 确保字符串以 null 结尾
std::string echo_str = "server echo# "; // 回显数据给客户端
echo_str += inbuffer;
::write(sockfd, echo_str.c_str(), echo_str.size());
}
else if (n == 0)
{
// read 如果读取返回值为 0,表示 client 退出
LOG(LogLevel::INFO) << "client quit: " << sockfd;
break;
}
else
{
// 处理 read 错误
if (errno == EINTR) {
continue; // 信号中断,重试
}
LOG(LogLevel::ERROR) << "read error: " << strerror(errno);
break;
}
}
// 关闭套接字
::close(sockfd); // fd 泄露问题
LOG(LogLevel::INFO) << "Connection closed, sockfd: " << sockfd;
}
退出时表现如下:
我们再引入我们在上一篇文章 Udp 的 EchoServer 封装的 InetAddr.hpp
演示如下:
- 我们此时就在一定程度上规避了文件描述符被浪费的问题
3. EchoServer -- 多进程
上面我们写的只是单进程方面的,接下来我们来创建多进程方面的
- 但是这里有个问题:当前创建出子进程的时候,父进程还需等待子进程,默认这里就阻塞了
- 但是我们这里是让子进程去做文本处理,如果子进程不退出/不返回,那么父进程不依然阻塞在这里嘛
- 阻塞之后还是无法accept,这不还是单进程嘛,但是我们还是必须得 wait,因为不 wait ,子进程一推出就会有僵尸问题
- 此时就需要用到 信号(signal)
上面那个是一种方法,但是这里换一种我们方法,利用到父子进程 fork 的返回值
演示结果如下:
4. EchoServer -- 多线程
ThreadData 结构体如下:
ThreadEntry 函数如下:
结果如下:
5. EchoServer -- 线程池
引入我们之前写的【Linux】:线程库 Thread.hpp 简单封装 Thread.hpp 以及 单例模式下的【Linux】:日志策略 + 线程池(单例模式 Threadpool.hpp
// version-3:线程池版本 比较时候处理短任务
// task_t f = std::bind(&TcpServer::HandlerRequest, this, sockfd); // 构建任务
// ThreadPool<task_t>::getInstance()->Equeue(f);
// 我们这里也可以 Lambda 表达式 --> 需要对Equeue那的T &in 的 & 删去
ThreadPool<task_t>::getInstance()->Equeue([this, sockfd]() {
this->HandlerRequest(sockfd);
});
噢其实这里还有个问题,就是我们写的 HandlerRequest 是长任务,但是线程池一般是用于处理短任务的,因此我们对于线程池数量应该调大点
结果如下:
6. 从文件描述符来进行读写 -- recv / send
上面我们使用的 read 和 write 都是文件中进行的读写,假如我们想从 文件描述符 fd 中来读取数据 呢?--> recv / send
7. 远程命令执行
工作:把远程发过来的当作命令字符串,合理的就执行
TcpServer.cc 修改如下:
CommandExec.cc 如下:
#pragma once
#include <iostream>
#include <string>
#include <set>
const int line_size = 1024;
class Command
{
public:
Command()
{
_white_list.insert("ls");
_white_list.insert("pwd");
_white_list.insert("ls -l");
_white_list.insert("ll");
_white_list.insert("touch");
_white_list.insert("who");
_white_list.insert("whoami");
}
bool SafeCheck(const std::string &cmdstr)
{
auto iter = _white_list.find(cmdstr);
return iter != _white_list.end();
}
// 给我们一个命令字符串 "ls -l",让你执行,执行完,把结果返回
std::string Execute(std::string cmdstr)
{
// 1. pipe
// 2. fork + dup2(pipe[1], 1) + exec*, 执行结果给父进程(进程间通信)
// 3. return
// popen 就可以完成上面效果
// FILE *popen(const char *command, const char *type);
// int pclose(FILE *stream);
if(!SafeCheck(cmdstr))
{
return std::string(cmdstr + " 不支持");
}
FILE* fp = ::popen(cmdstr.c_str(), "r");
if(nullptr == fp)
{
return std::string("Failed");
}
char buffer[line_size];
std::string result;
while(true)
{
char *ret = ::fgets(buffer, sizeof(buffer), fp);
if(!ret) break;
result += ret;
}
pclose(fp);
return result.empty() ? std::string("Done") : result;
}
private:
std::set<std::string> _white_list; // 白名单,只让执行一些命令
};
执行结果如下:
8. windows 作为 client 访问 Linux
tcp_client.cc
#include <winsock2.h>
#include <iostream>
#include <string>
#pragma warning(disable : 4996)
#pragma comment(lib, "ws2_32.lib")
std::string serverip = "1.12.51.69"; // 填写你的云服务器 ip
uint16_t serverport = 8080; // 填写你的云服务开放的端口号
int main()
{
WSADATA wsaData;
int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
if (result != 0)
{
std::cerr << "WSAStartup failed: " << result << std::endl;
return 1;
}
SOCKET clientSocket = socket(AF_INET, SOCK_STREAM,
IPPROTO_TCP);
if (clientSocket == INVALID_SOCKET)
{
std::cerr << "socket failed" << std::endl;
WSACleanup();
return 1;
}
sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(serverport);
serverAddr.sin_addr.s_addr = inet_addr(serverip.c_str());
result = connect(clientSocket, (SOCKADDR*)&serverAddr,
sizeof(serverAddr));
if (result == SOCKET_ERROR)
{
std::cerr << "connect failed" << std::endl;
closesocket(clientSocket);
WSACleanup();
return 1;
}
while (true)
{
std::string message;
std::cout << "Please Enter@ ";
std::getline(std::cin, message);
if (message.empty()) continue;
send(clientSocket, message.c_str(), message.size(), 0);
char buffer[1024] = { 0 };
int bytesReceived = recv(clientSocket, buffer,
sizeof(buffer) - 1, 0);
if (bytesReceived > 0)
{
buffer[bytesReceived] = '\0'; // 确保字符串以 null 结尾
std::cout << "Received from server: " << buffer <<
std::endl;
}
else
{
std::cerr << "recv failed" << std::endl;
}
}
closesocket(clientSocket);
WSACleanup();
return 0;
}
输出如下:
补充 -- 避免 bind Error
我相信大家都碰见过这个问题吧,就是当我们先把服务器关闭,然后再关闭客户端,然后再运行的时候,服务器就会 bind Error,然后需要过一会才可以好,或者需要重新更换端口号,这个原因后面我会有说【涉及到 传输层 TCP 的知识】,这里我就说下解决办法,代码如下:
// 地址复用
int opt = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
这个代码放在 创建套接字 及 bind 连接中间,如下:
9. 共勉
上面代码均可以在我的 gitee 里面看到的 island0920/112 - Gitee.com
【*★,°*:.☆( ̄▽ ̄)/$:*.°★* 】那么本篇到此就结束啦,如果有不懂 和 发现问题的小伙伴可以在评论区说出来哦,同时我还会继续更新关于【Linux】的内容,请持续关注我 !!