当前位置: 首页 > article >正文

【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)设置为监听状态,以接受客户端的连接请求。它通常在服务器端使用,与 socketbind 和 accept 函数配合使用。

函数原型(C/C++)

在 POSIX 系统(如 Linux)中,listen 函数的原型如下:

int listen(int sockfd, int backlog);

在 Windows 系统中,listen 函数的原型如下:

int listen(SOCKET sockfd, int backlog);

参数说明

  1. sockfd

    • 这是一个套接字描述符(socket file descriptor),通常由 socket 函数创建。

    • 在调用 listen 之前,必须先调用 bind 将套接字绑定到一个本地地址和端口。

  2. backlog

    • 这是一个整数,表示等待连接队列的最大长度。

    • 当多个客户端同时尝试连接服务器时,服务器可能无法立即处理所有连接请求。backlog 参数定义了等待连接队列的最大长度。

    • 如果队列已满,新的连接请求可能会被拒绝(客户端会收到 ECONNREFUSED 错误)返回值

返回值

  • 成功:返回 0

  • 失败:返回 -1(在 POSIX 系统中)或 SOCKET_ERROR(在 Windows 系统中),并设置 errno(POSIX)或调用 WSAGetLastError(Windows)来获取错误代码。

🐇 accept 

accept 函数是网络编程中的一个核心函数,用于服务器端接受客户端的连接请求。它通常在 socketbind 和 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);

参数说明

  1. sockfd

    • 这是一个监听套接字描述符(socket file descriptor),通常由 socket 创建并通过 bind 和 listen 设置为监听状态。

  2. addr

    • 这是一个指向 struct sockaddr 的指针,用于存储客户端的地址信息(如 IP 地址和端口号)。

    • 如果不需要客户端的地址信息,可以将其设置为 NULL

  3. 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 使用步骤

listenaccept 函数通常用于服务器端,典型的使用步骤如下:

  1. 调用 socket 创建一个套接字。

  2. 调用 bind 将套接字绑定到一个本地地址和端口。

  3. 调用 listen 将套接字设置为监听状态。

  4. 调用 accept 接受客户端的连接请求。

  5. 使用 accept 返回的新套接字与客户端通信。

  6. 通信完成后,关闭新套接字。

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 缓冲区的大小是多少。

    • 如果没有初始化 peerlenaccept() 函数将无法知道 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】的内容,请持续关注我 !!


http://www.kler.cn/a/541438.html

相关文章:

  • 使用Redis实现业务信息缓存(缓存详解,缓存更新策略,缓存三大问题)
  • qml RoundButton详解
  • 【Java基础】为什么不支持多重继承?方法重载和方法重写之间区别、Exception 和 Error 区别?
  • Linux中设置开机运行指令
  • Spring Boot接入Deep Seek的API
  • 爬虫技巧汇总
  • [学习笔记] Kotlin Compose-Multiplatform
  • 在离线的服务器上部署Python的安装库
  • 计算机网络结课设计:通过思科Cisco进行中小型校园网搭建
  • kbengine服务器和 数据库 系统路径配置
  • C语言基本概念————讨论sqrt()和pow()函数与整数的关系
  • 高效利用Java爬虫开发批量获取商品信息:电商数据挖掘的“利器”
  • 【鸿蒙HarmonyOS Next实战开发】多媒体视频播放-GSYVideoPlayer
  • Pyqt的QTabWidget组件
  • 【STM32H743】【RT-Thread Studio】RTC功能(基于BSP工程可一键开启)
  • 嵌入式linux系统中VIM编辑工具用法与GCC参数详解
  • 记录一次报错:spring security 403报错
  • HIVE如何注册UDF函数
  • 使用 Python/Boto/Django 实现 S3 直接上传
  • Django操作指令大集合说明
  • electron中调用C++
  • 视频编解码标准总结
  • 1.15 联邦学习
  • 《Wiki.js知识库部署实践 + CNB Git数据同步方案解析》
  • AI算力的摆脱有点像发动机汽车变电动车
  • 第22章 Balking设计模式(Java高并发编程详解:多线程与系统设计)