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

Linux——基于socket编程实现简单的Tcp通信

前言1:想要实现一个简单的Tcp通信不难,对于初学者而言,难点在于使用了大量未曾接触过的函数调用,所以本篇重点在于详解每部分代码中相关函数的功能。

1. 简单认识一下TCP传输

TCP通信协议是面向字节流的、可靠的、有连接的传输,在实现TCP协议通信时:

①:必须先建立客户端和服务端间的连接这部分由系统函数实现

②:其次因为TCP是面向字节流的,所以它的很多操作和文件操作是一致的

2. 实现思路

 编写一个服务端:TcpServer;

①. 初始化服务端,设置 IP 、 端口号 and 执行方法

②. 运行服务端,接收客户端发来的数据,交由其他线程来处理

 编写一个客户端:TcpClient;

①. 确定目标 ip and 端口号

②. 客户端发送数据到服务端,同时接收服务端的消息

3. 各部分涉及的相关函数详解

3.1 初始化服务端

步骤1:网络也是文件,一种特殊的文件,因此一开始需要打开文件(创建套接字)

相关函数:

int socket(int domain, int type, int protocol);

功能:

        创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器):

参数:

domain:用于标识是网络通信还是本地通信

        AF_INET:网络通信  ☆☆☆

        AF_UNIX:本地通信

type:用于标识不同的套接字类型

        SOCK_DGRAM:基于UDP协议的通信

        SOCK_STREAM:基于TCP协议的通信

protocol:设置为0时;

        对于 AF_INET 和 SOCK_STREAM,操作系统会选择 TCP 协议

        对于 AF_INET 和 SOCK_DGRAM,操作系统会选择 UDP 协议​​​

返回值:int sockfd = socket(AF_INET, SOCK_STREAM, 0);

        sockfd(套接字网络文件描述符),用于表述对应描述符表的唯一性。

步骤2:套接字创建完毕时,sockfd需要和套接字描述符表进行绑定,就像打开文件时,文件描述符fd会和文件描述符表结构体相关联。

相关函数:

int bind(int sockfd, const struct sockaddr *address, socklen_t address_len);

功能

绑定端口号 (TCP/UDP, 服务器),将当前套接字与ip和端口号绑定

参数

sockfd

        套接字

address

        对于AF_INET通信,strcut sockaddr_in* 结构体指针内部保存的ip

len

        上述结构体对应的大小

注1☆☆☆

        对于服务端而言,将当前服务端所创建的套接字和服务端本地的ip以及指定端口绑定

        在次过程之前,需要将主机字节序转为网络字节序!

        这里能够通过面向对象的方式,来完成一步骤。相关代码如下

class InetAddr
{
public://主机转网络InetAddr(u_int16_t port):_port(port){memset(&_addr, 0, sizeof(_addr));_addr.sin_addr.s_addr = INADDR_ANY;_addr.sin_family = AF_INET;_addr.sin_port = htons(_port);}const struct sockaddr* NetAddrptr() { return CONV(_addr); } //获取sockaddr* 地址socklen_t NetAddrLen(){ return sizeof(_addr); }private:struct sockaddr_in _addr;std::string _ip;uint16_t _port;
};#define CONV(addr) ((struct sockaddr*)&addr)//类外可以通过相应函数进行构造
//  InetAddr local(_port);
//  int n = bind(sockfd, local.NetAddrptr(), local.NetAddrLen());

注2

        对于服务端,需要显式调用bind,bind的过程中,ip为当前服务器下所有可用ip

        对于客户端,不需显式调用bind,本地服务器在第一次进行网络通信时,操作系统会将本地IP and 随机端口号 分配给 sockfd。

        原因:因为用户不知道哪个端口号现在被占用了,所以需要等待系统去给你分配

        一个端口号只能被一个进程占用!

步骤3 :服务端显式调用bind后,需将当前sockfd设置为监听状态

相关函数:

int listen(int sockfd, int backlog);

sockfd

        当前套接字

backlog

        最大等待连接数

:之所以将当前套接字设置为listen的目的是:可能会有多个客户端向服务端进行通信。

举个简单例子:这里的socket被称为监听套接字,他充当一个在外部揽客的角色。每当有一个客户端向服务端进行通信时,都会被 监听套接字纳入到连接队列。下面会介绍 accpet 函数,该函数会从连接队列中取出一个,和客户端进行通信,这部分由多线程完成。

补充1线程更安全的 字符串 and 网络字节序 转换

相关函数:

int inet_pton(int af, const char *src, void *dst);

功能: 点分字符串 转为 网络字节序  p → n   presentation → network

af:地址族,通常为(AF_INET、AF_INET6)

src: 字符串风格的ip地址

dst:转换后的 网络字节序保存的位置

相关函数:

uint16_t htons(uint16_t hostshort);

功能: 将主机字节序转为网络字节序

hostshort:主机字节序

3.2 运行服务端

步骤1 :多个客户端向服务端发起通信时,此时需从连接队列取出一个客户端(报文)建立通信

相关函数:

int accept(int listen_sockfd, struct sockaddr *addr, socklen_t *addrlen);

功能

        从连接队列取出一个 连接 进行通信

 参数

listen_sockfd:

        从监听套接字中取出,该套接字是唯一的

addr

         用于保存客户端地址信息!!!!

addrlen

        地址结构体大小的指针

:accpet取出的 连接,是客户端的地址信息,因此在accpet前,需要创建一个 struct sockaddr_in 结构体来保存该信息,该信息中包含客服端的ip and 端口号

步骤2:因为接收到的是网络字节序,所以需要将 网络字节序 转为 主机字节序

相关代码如下:

class InetAddr
{
public://网络转主机InetAddr(struct sockaddr_in &addr) : _addr(addr){_port = ntohs(_addr.sin_port);           // 从网络中拿到的!网络序列char ipbuffer[64];inet_ntop(AF_INET,&_addr.sin_addr, ipbuffer,sizeof(_addr));// 4字节网络风格的IP -> 点分十进制的字符串风格的IP_ip = ipbuffer;}
private:struct sockaddr_in _addr;std::string _ip;uint16_t _port;
};//InetAddr addr(peer);  // peer 为accept接收到的客户端的 struct sockaddr_in 结构体

补充: 线程更安全的 字符串 and 网络字节序 转换

相关函数:

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

功能: 网络字节序 转为 字符串    p → n   presentation → network

af:地址族,通常为(AF_INET、AF_INET6)

src: 二进制格式的ip地址

dst:输出的字符串缓冲区

size:缓冲区的大小

相关函数:

uint16_t ntohs(uint16_t Netshort);

功能: 网络字节序 转 主机字节序

Netshort:网络字节序

3.3 客户端初始化

步骤1:客户端需要提供服务端的 ip and 端口号

步骤2:客户端需要通过socket创建套接字,这部分和客户端是一样的

:客户端是否需要bind?

答:需要!但是不是显式的bind,客户端在和服务端通过TCP建立连接 or UDP直接发送报文时,操作系会自动给当前进程绑定一个端口号,将客户端的 ip and 端口号 发给 服务端,因此客户端不需要显式的bind

步骤3:客户端向服务端发送连接请求

相关函数:

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

• 功能:

        用于发起客户端到服务端的连接请求

• 参数

sockfd:通过socket创建的文件描述符

addr:目标客服端的结构体

addrlen:addr的大小

:需将主机字节序转为网络字节序才能发送

4.图解TCP传输流程

假设:主机A是客户端、主机B是服务端

        服务端B通过上述函数初始化后,并调用accept接口阻塞等待客户端A发送数据。

        客户端A初始化并通过send/write接口发送数据时,send所发送的数据并不是直接通过网络传输给客服端B,send/witre是将数据拷贝给了发送缓冲区,os会在合适的时候将数据通过网络拷贝给服务端B的接收缓冲区。

        当客户端A向服务端Bconnect建立通信连接时,服务端B会将客户端丢入到连接队列中,并返回一个新的网络文件描述符sockfd来进行业务处理,同样客户端recv/read不是直接从网络中获取数据,而是在服务端B的接收缓冲区中将数据拷贝到上层。

认知

        结论1:对于Tcp协议,通信的双方在传输层都有发送缓冲区和接收缓冲区,因此是全双工通信,二者相互独立

        结论2:以服务端为例,操作系统向缓冲区拷贝数据,上层向缓冲区读数据,这不就是生产消费者模型吗? Tcp协议本质就是四组生产消费者模型

4.1 应用层再谈协议

• 先前结论:协议 → 结构体

        上述图解TCP传输流程是从底层来说的,对于处在应用层的用户,在将数据send/write 进发送缓冲区时,需要将结构体转成一个大的字符串,这一过程叫做序列化,目的是为了方便网络传输。

        对于服务端,接收缓冲区在收到网络中对应客户端的大字符串时,应用层调用recv/read 后,需要反序列化,方便上册的那个处理/阅读

:客户端和服务端都认识该结构体,该结构体就是被称为客户端和服务端约定好的协议。该模式被称为C(client)/S(server)模式

:为什么不直接发送结构体对象?而是将结构体对象序列化转成大字符串?再反序列化读取

:为了兼容性,因为不同语言结构体的对齐方式不同。

• 结论:所谓的协议定制,本质就是在定制双方都能认识的,符合通信和业务需要的结构化数据,所谓的结构化数据,其实就是struct 或者 class

5. 定制自定义协议来实现基于Tcp通信的网络计算器服务

• Tcp协议通信的双方会存在以下现象

1. 收发双方发送和接收次数不对等

2.收发双方是否选择发送和接收由操作系统决定

3.读方一定得读到一个完整的报文,才能进行反序列化的操作,如果在编程过程中没有读到一个完整的报文就处理,会出BUG

:因读取报文不完整导致的问题叫做“粘报“问题

• 因此定制一个协议需要满足

1.结构化的字段,提供 序列化 and 反序列化 的方案

2.解决因为字节流问题,导致读取报文不完整的问题(只用处理读取)

5.1 实现思路

对于服务端,主函数中通过分层结构实现

① 业务层

② 协议层

③ 通信连接层

对于客户端,主函数主要实现

① 初始化

② 请求建立通信连接

③ 发送数据 + 接收结果

5.1.1 业务层

主函数中,通过智能指针创建一个Cal对象

std::unique_ptr<Cal> cal = std::make_unique<Cal>();

Cal类如图如下所示

#pragma once
#include "Protocol.hpp"
#include <iostream>class Cal
{
public:Response Excute(Request &req){Response resp(0, 0);switch (req.Oper()){case '+':resp.SetResult(req.X() + req.Y());break;case '-':resp.SetResult(req.X() - req.Y());break;case '*':resp.SetResult(req.X() * req.Y());break;case '/':{if (req.Y() == 0)resp.SetCode(1); // 除零错误elseresp.SetResult(req.X() / req.Y());break;}case '%':{if (req.Y() == 0)resp.SetCode(2); // 余零错误elseresp.SetResult(req.X() % req.Y());break;}default:resp.SetCode(3); // 传参错误break;}return resp;}
};

5.1.2 协议层

 主函数中,同样通过创建一个对象Protocol对象来调用接口

std::unique_ptr<Protocol> p = std::make_unique<Protocol>([&cal](Request& req)->Response{return cal->Excute(req);});

客户端发送的数据经协议处理后,需要将数据交由上层业务处理,因此需通过lamda表达式来实现回调。

规定1:用户端发送给服务端的数据称为请求 , 服务端发送给用户端的数据称为回复

规定2:大字符串以  "len + \r\n + 数据 + \r\n"  的方式通过网络传递,其中len为数据的长度

\r\n为分隔符,通过上述来解决"粘报"问题。

Protocol类中主要需要实现的函数

①. 用户端将请求序列化 or 服务端将回复序列化, 将结构化的数据转为字符串形式,通过json库来实现,这是一个三方库

②. 用户端 加密序列化后的请求 or 客户端 加密序列化后的回复,将数据转成  "len + \r\n + 数据 + \r\n" 的形式

③. 服务端 解密用户端的请求 or 用户端 解密服务端的回复   从网络中收到的 "len + \r\n + 数据 + \r\n" 的形式的大字符串

客户端和服务端间通信流程

👉. 服务端创建服务器,等待客户端通信,(建立套接字、bind、设置listen、accept)

👉. 客户端发送通信请求,双方建立通信连接 (建立套接字、connect)

👉. 客户端将结构化数据转为字符串形式

👉. 客户端将数据加密为 "len + \r\n + 数据 + \r\n"形式的数据

👉. 客户端发送数据  (send/write)

👉. 服务端接收数据  (recv/read)

👉. 服务器解密收到的数据

👉. 服务端将有效数据反序列化 

👉. 服务器调用回调进行业务处理

👉. 服务端将处理的结果序列化

👉. 服务端将序列化后的结果加密

👉. 服务端发送数据

👉. 客户端接收数据、解密数据、反序列化数据得到结果。

:请求的序列化和反序列化 与 回复的序列化和反序列化是分开的。两者的参数和结果均不同,当然需要分开写。

代码如下

#pragma once
#include <functional>
#include "common.hpp"
#include "Socket.hpp"using namespace SocketModule;// client -> server  客户的请求:客户需要将请求序列化到服务端,服务端需要将客户的请求反序列化
class Request
{
public:Request(){}Request(int x, int y, char oper): _x(x),_y(y),_oper(oper){}std::string Serialize(){Json::Value root;root["x"] = _x;root["y"] = _y;root["oper"] = _oper;Json::FastWriter Writer;std::string s = Writer.write(root);return s;}bool Deserialize(std::string& Package){Json::Value root;Json::Reader reader;bool ok = reader.parse(Package, root); // 将Json串读到root中if (ok){_x = root["x"].asInt();_y = root["y"].asInt();_oper = root["oper"].asInt();}return ok;}int X() { return _x; }int Y() { return _y; }char Oper() { return _oper; }~Request(){}private:int _x;int _y;char _oper;
};// server -> client  服务端需要将业务处理的结果序列化到客户端,客户端需要反序列业务处理的结果
class Response
{
public:Response() {}Response(int result, int code) : _result(result), _code(code){}// 服务端向用户端序列化std::string Serialize(){Json::Value root;root["result"] = _result;root["code"] = _code;Json::FastWriter writer;return writer.write(root);}// 用户端反序列从服务端接收到的数据bool Deserialize(std::string &in){Json::Value root;Json::Reader reader;bool ok = reader.parse(in, root);if (ok){_result = root["result"].asInt();_code = root["code"].asInt();}return ok;}void SetResult(int result){_result = result;}void SetCode(int code){_code = code;}void ShowResult(){std::cout << "计算结果是:" << _result << " [" << _code << "]" << std::endl;}~Response() {}private:int _result;int _code;
};const std::string seq = "\r\n";
using func_t = std::function<Response(Request &req)>;class Protocol
{
public:Protocol(func_t func): _func(func){}Protocol(){}// 编码 Encode      len/r/n json码 /r/n  约定通信的双方以这种方式(协议)进行通信,json码为有效数据std::string Encode(std::string &jsonstr){std::string len = std::to_string(jsonstr.size());       //return (len + seq + jsonstr + seq);}// 解码 Decode 通信的双方以该协议进行通信bool Decode(std::string &buffer, std::string *Json_Package){// 通信的双方通过 数据长度 + /r/n + json串 + /r/n 进行加密// 1.先找第一个 /r/n,没找到说明数据不完整ssize_t pos = buffer.find(seq);if (pos == std::string::npos){   return false;}// 到这说明该报文有数据长度// 2.获得数据长度std::string package_len_str = buffer.substr(0, pos);int len = std::stoi(package_len_str);// 3.根据加密协议 以及数据长度,得到有效报文的总长度int size = package_len_str.size() + 2 * seq.size() + len;// 4.如果当前服务端收到的数据长度小于 size  说明收到的消息不完整if (buffer.size() < size){   return false;}// 5.到这说明buffer内至少包含一条完整的加密后数据,对加密数据进行解密*Json_Package = buffer.substr(pos + 2, len);// 6.将解密后的数据从buffer中删除buffer.erase(0, size);return true;}void GetRequest(std::shared_ptr<Socket> &sock, InetAddr &client){std::string buffer_queue;while (true){int n = sock->Recv(&buffer_queue);if (n > 0){// 读取不完整,避免粘包问题// 服务端从用户端读取用户请求  → 经客户端序列化的数据且加密的数据// 1. 服务端解密std::string Json_Package;while (Decode(buffer_queue, &Json_Package)){// 2.服务端对客户请求的反序列化Request req;bool ret = req.Deserialize(Json_Package);if (!ret)continue;// 3. 解密反序列化完毕后 调用上层业务Response resp = _func(req); // 要将业务处理的结果返回给客户端,所以需要返回一个Response对象// 将得到的结果序列化传递给 用户端// 4.先序列化  服务端 → 用户端的序列化std::string send_str = resp.Serialize();// 5.序加密std::string package = Encode(send_str);// 6.发送sock->Send(package);}}else if (n == 0){LOG(LogLevel::INFO) << "用户退出了";break;}else{LOG(LogLevel::WARNING) << "client" << client.StringAddr() << ": recv error";break;}}}bool GetResponse(std::shared_ptr<Socket> client, std::string &resp_buffer, Response *resp){while (true){int n = client->Recv(&resp_buffer);if (n > 0){std::string json_package;while (Decode(resp_buffer, &json_package)){resp->Deserialize(json_package);}return true;}else if (n == 0){return false;}else{return false;}}}std::string BuildRequestString(int x, int y, char oper){Request req(x, y, oper);// 将客户端的请求序列化std::string json_str = req.Serialize();// 将序列化的json串加密return Encode(json_str);}~Protocol(){}private:// 因为我们用的是多进程// Request _req;// Response _resp;func_t _func;
};

5.1.3 通信连接层

主函数中,通过智能指针创建一个TcpServer对象,当服务端收到客户端的通信请求时,创建子进程的子进程来执行回调方法实现通信双方数据的交互

std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(std::stoi(argv[1]),[&p](std::shared_ptr<Socket> &sock, InetAddr &client){p->GetRequest(sock, client);});tsvr->Start();

通信连接层通过模板方法模式进行初始化

模板方法模式:将基类中的虚函数方法在派生类中重写,同时基类中有一套固定方法,创建派生类对象时,调用该方法来初始化派生类对象的过程

补充:套接字的创建、绑定、监听、数据的发送、接收和连接,都和网络文件描述符相关,因此我们可以创建一个专门用来调用这些接口的自定义类,该类由书上的模板方法模式实现。具体代码如下所示:
 

#pragma once
#include "common.hpp"
#include "InetAddr.hpp"
#include <memory>namespace SocketModule
{using namespace LogModule;class Socket{public:virtual void SocketOrDie() = 0;virtual void BindOrDie(uint16_t port) = 0;virtual void ListenOrDie(int backlog) = 0;virtual std::shared_ptr<Socket> Accept(InetAddr* Client) = 0;virtual bool Connect(std::string& ip, uint16_t port) = 0;virtual void Close() = 0;virtual int Recv(std::string* out) = 0;virtual int Send(const std::string& message) = 0;public:void BuildTcpSocketMethod(int port, int backlog = 16){SocketOrDie();BindOrDie(port);ListenOrDie(backlog);}void BulidClientSocketMethod(){SocketOrDie();}};const int DefaultNum = -1;class TcpSocket : public Socket{public:TcpSocket(): _sockfd(DefaultNum){}TcpSocket(int fd): _sockfd(fd){}void SocketOrDie() override{_sockfd = ::socket(AF_INET, SOCK_STREAM, 0);if (_sockfd < 0){LOG(LogLevel::FATAL) << "socket error";exit(SOCKET_ERROR);}LOG(LogLevel::INFO) << "socket sunccess";}void BindOrDie(uint16_t port) override{InetAddr localAddr(port);int n = ::bind(_sockfd, localAddr.NetAddrptr(), localAddr.NetAddrLen());if (n < 0){LOG(LogLevel::FATAL) << "bind error";exit(BIND_ERROR);}LOG(LogLevel::INFO) << "bind sunccess";}void ListenOrDie(int backlog) override{int n = ::listen(_sockfd, backlog);if (n < 0){LOG(LogLevel::FATAL) << "listen error";exit(LISTEN_ERROR);}LOG(LogLevel::INFO) << "listen sunccess";}std::shared_ptr<Socket> Accept(InetAddr* Client) override{struct sockaddr_in peer;socklen_t len = sizeof(len);int fd = ::accept(_sockfd, CONV(peer), &len);if (fd < 0){LOG(LogLevel::WARNING) << "accept warning ...";return nullptr; // TODO}Client->SetAddr(peer);// 解耦,让另一个对象来管理客户端的ip and 端口号信息return std::make_shared<TcpSocket>(fd);}bool Connect(std::string& ip, uint16_t port) override{InetAddr client(ip,port);//初始化客户端int n = ::connect(_sockfd,client.NetAddrptr(),client.NetAddrLen());if(n < 0){LOG(LogLevel::FATAL) << "connnect error";return false;}return true;}int Recv(std::string* out) override{char buffer[1024];ssize_t n = ::recv(_sockfd, buffer, sizeof(buffer) - 1, 0);if(n > 0){buffer[n] = 0;*out += buffer;}return n;}virtual int Send(const std::string& message) override{return send(_sockfd, message.c_str(), message.size(), 0);}void Close() override{if(_sockfd >= 0)::close(_sockfd);}~TcpSocket(){}private:int _sockfd;};
}

主函数在实例化时,默认的构造函数如图所示

TcpServer(u_int16_t port, ioservice_t func):_ListenSockfd(std::make_unique<TcpSocket>()),_port(port),_isrunning(false),_service(func){_ListenSockfd->BuildTcpSocketMethod(port);}

在创建过程中,会自动将服务端初始化

5.1.4 客户端

#include "Protocol.hpp"
#include "Socket.hpp"
#include "InetAddr.hpp"
#include "common.hpp"void GetDataFromStdin(int* x,int* y,char* opre)
{std::cout << "please enter x:";std::cin >> *x;std::cout << "please enter y:";std::cin >> *y;std::cout << "please enter oper:";std::cin >> *opre;
}int main(int argc, char* argv[])
{if(argc != 3){exit(1);}std::string ip = argv[1];uint16_t port = std::stoi(argv[2]);std::shared_ptr<Socket> client = std::make_shared<TcpSocket>();client->BulidClientSocketMethod();if(!client->Connect(ip,port)){exit(2);}std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>();std::string resp_buffer;while(true){int x, y;char oper;GetDataFromStdin(&x,&y,&oper);std::string req_str = protocol->BuildRequestString(x,y,oper);//发送client->Send(req_str);//获取应答Response resp;bool res = protocol->GetResponse(client,resp_buffer,&resp);resp.ShowResult();}client->Close();return 0;
}

相关文章:

  • 如何在 Java 中从 PDF 文件中删除页面(教程)
  • 删除不了jar包-maven clean package失败
  • 10.建造者模式:思考与解读
  • C++学习之游戏服务器开发十二nginx和http
  • Linux:简单自定义shell
  • 界面控件DevExpress WPF v25.1预览 - 支持Windows 11系统强调色
  • 【图像识别改名】如何批量识别多个图片的区域内容给图片改名,批量图片区域文字识别改名,基于WPF和腾讯OCR的实现方案和步骤
  • PLC互连全攻略:Profinet和EthernetIP实操演示
  • 极狐GitLab 项目功能和权限解读
  • GMS认证之 CTS Verifier认证新变化
  • 【前端】【业务逻辑】【面试】JSONP处理跨域原理与封装
  • Python 设计模式:回调模式
  • WebGis与WebGL是什么,两者之间的关系?
  • 【MCP Node.js SDK 全栈进阶指南】初级篇(6):MCP传输层配置与使用
  • 基于LightGBM-TPE算法对交通事故严重程度的分析与可视化
  • java 设计模式 原型模式
  • 【安装neo4j-5.26.5社区版 完整过程】
  • Linux系统用户迁移到其它盘方法
  • “融合Python与机器学习的多光谱遥感技术:数据处理、智能分类及跨领域应用”​
  • 在Windows上安装Git
  • 对话地铁读书人|超市营业员朱先生:通勤时间自学心理学
  • 解密帛书两千年文化传承,《帛书传奇》央视今晚开播
  • 言短意长|大学校门到底应不应该开放?
  • 神舟二十号航天员乘组计划于10月下旬返回
  • 大连万达商业管理集团提前兑付“22大连万达MTN001” ,本息2.64亿元
  • 五角大楼正在“全面崩溃”?白宫被指已在物色新国防部长