文章目录
先讲一下socket编程
网络字节序
计算机在存储数据时是有大小端的概念的:
- 大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
- 小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。
如果编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。但如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的。
例如,现在两台主机之间在进行网络通信,其中发送端是小端机,而接收端是大端机。发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收端从网络中获取数据依次保存在接收缓冲区时,也是按内存地址从低到高的顺序保存的。
但由于发送端和接收端采用的分别是小端存储和大端存储,此时对于内存地址从低到高为44332211
的序列,发送端按小端的方式识别出来是0x11223344
,而接收端按大端的方式识别出来是0x44332211
,此时接收端识别到的数据与发送端原本想要发送的数据就不一样了,这就是由于大小端的偏差导致数据识别出现了错误。
由于我们不能保证通信双方存储数据的方式是一样的,因此网络当中传输的数据必须考虑大小端问题。因此TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节。无论是大端机还是小端机,都必须按照TCP/IP协议规定的网络字节序来发送和接收数据。
- 如果发送端是小端,需要先将数据转成大端,然后再发送到网络当中。
- 如果发送端是大端,则可以直接进行发送。
- 如果接收端是小端,需要先将接收到数据转成小端后再进行数据识别。
- 如果接收端是大端,则可以直接进行数据识别。
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
- 函数名当中的h表示host,n表示network,l表示32位长整数,s表示16位短整数。
- 例如htonl表示将32位长整数从主机字节序转换为网络字节序。
- 如果主机是小端字节序,则这些函数将参数做相应的大小端转换然后返回。
- 如果主机是大端字节序,则这些函数不做任何转换,将参数原封不动地返回。
socket编程接口
socket常见API
创建套接字:(TCP/UDP,客户端+服务器)
int socket(int domain, int type, int protocol);
绑定端口号:(TCP/UDP,服务器)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
监听套接字:(TCP,服务器)
int listen(int sockfd, int backlog);
接收请求:(TCP,服务器)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
建立连接:(TCP,客户端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockaddr结构
sockaddr结构的出现
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体,其中
sockaddr_in结构体是用于跨网络通信的,
sockaddr_un结构体是用于本地通信的。
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockeaddr结构体,该结构体与sockaddr_in和sockaddr_un的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。
此时当我们在传递在传参时,就不用传入sockeaddr_in或sockeaddr_un这样的结构体,而统一传入sockeaddr这样的结构体。在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API内部就可以提取sockeaddr结构头部的16位进行识别,进而得出我们是要进行网络通信还是本地通信,然后执行对应的操作。此时我们就通过通用sockaddr结构,将套接字网络通信和本地通信的参数类型进行了统一。
注意: 实际我们在进行网络通信时,定义的还是sockaddr_in这样的结构体,只不过在传参时需要将该结构体的地址类型进行强转为sockaddr*罢了。
TCP服务端编写步骤
1 . 创建套接字
2. 绑定
3. 监听
先把上面的步骤记下来 很重要!!!!
创建套接字
- 协议家族选择PF_INET(或者AF_INET ,都可以),因为我们要进行的是网络通信。
- 创建套接字时所需的服务类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。
- 协议类型默认设置为0即可。
// 创建套接字
Listen_ = socket(PF_INET, SOCK_STREAM, 0);
if (Listen_ < 0)
{
std::cerr << "socket 创建套接字失败" << std::endl;
exit(LISTEN_ERR);
}
服务端 绑定
套接字创建完毕后我们实际只是在系统层面上打开了一个文件,该文件还没有与网络关联起来,因此创建完套接字后我们还需要调用bind函数进行绑定操作。
绑定的步骤如下:
- 定义一个
struct sockaddr_in
结构体,将服务器网络相关的属性信息填充到该结构体当中,比如协议家族、IP地址、端口号等。 - 填充服务器网络相关的属性信息时,协议家族对应就是AF_INET,端口号就是当前TCP服务器程序的端口号。在设置端口号时,需要调用htons函数将端口号由主机序列转为网络序列。
- 在设置服务器的IP地址时,我们可以设置为本地环回127.0.0.1,表示本地通信。也可以设置为公网IP地址,表示网络通信。
- 如果使用的是云服务器,那么在设置服务器的IP地址时,不需要显示绑定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序的转换。
- 填充完服务器网络相关的属性信息后,需要调用bind函数进行绑定。绑定实际就是将文件与网络关联起来,如果绑定失败也没必要进行后续操作了,直接终止程序即可。
由于TCP服务器初始化时需要服务器的端口号,因此在服务器类当中需要引入端口号,当实例化服务器对象时就需要给传入一个端口号。而由于我当前使用的是云服务器,因此在绑定TCP服务器的IP地址时不需要绑定公网IP地址,直接绑定INADDR_ANY即可,因此我这里没有在服务器类当中引入IP地址。
当定义好struct sockaddr_in结构体后,最好先用memset函数对该结构体进行清空,也可以用bzero函数进行清空。bzero函数也可以对特定的一块内存区域进行清空,bzero函数的函数原型如下:
void bzero(void *s, size_t n);
// 绑定
sockaddr_in local;
memset(&local, 0, sizeof(local));
//当定义好struct sockaddr_in结构体后,
//最好先用memset函数对该结构体进行清空,也可以用bzero函数进行清空。
//bzero函数也可以对特定的一块内存区域进行清空,bzero函数的函数原型如下:
//void bzero(void *s, size_t n);
local.sin_family = PF_INET;
local.sin_port = htons(port_);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(Listen_, (const sockaddr *)&local, sizeof(local)) < 0)
{
std::cerr << "bind 失败" << strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "绑定成功" << std::endl;
说明一下:
TCP服务器绑定时的步骤与UDP服务器是完全一样的,没有任何区别。
服务端 监听
UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。
因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。
设置套接字为监听状态的函数叫做listen,该函数的函数原型如下:
int listen(int sockfd, int backlog);
参数说明:
- sockfd:需要设置为监听状态的套接字对应的文件描述符。
- backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。
返回值说明:
- 监听成功返回0,监听失败返回-1,同时错误码会被设置。
TCP服务器在创建完套接字和绑定后,需要再进一步将套接字设置为监听状态,监听是否有新的连接到来。如果监听失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可。
// 监听
if (listen(Listen_, 5) < 0)
{
std::cerr << "listen 失败" << strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "listen成功" << std::endl;
说明一下:
- 初始化TCP服务器时创建的套接字并不是普通的套接字,而应该叫做监听套接字。为了表明寓意,我们将代码中套接字的名字由sock改为Listen_。
- 在初始化TCP服务器时,只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成。
服务端获取连接
TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。
获取连接的函数叫做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获取到的连接提供服务。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字。
服务端获取连接
服务端在获取连接时需要注意:
- accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接。
- 如果要将获取到的连接对应客户端的IP地址和端口号信息进行输出,需要调用inet_ntoa函数将整数IP转换成字符串IP,调用ntohs函数将端口号由网络序列转换成主机序列。
- inet_ntoa函数在底层实际做了两个工作,一是将网络序列转换成主机序列,二是将主机序列的整数IP转换成字符串风格的点分十进制的IP。
sockaddr_in peer;
socklen_t len_s = sizeof(peer);
int ServerSock = accept(Listen_, (sockaddr *)&peer, &len_s);
// 第三个是输入输出型参数,所以先定义一个变量然后传进去
std::string clientIP = inet_ntoa(peer.sin_addr);
uint16_t clientPort = ntohs(peer.sin_port);
客户端创建套接字
int sock = socket(AF_INET, SOCK_STREAM, 0); // 创建套接字
if (sock < 0)
{
std::cerr << "创建套接字失败" << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
客户端不需要进行绑定和监听:
- 服务端要进行绑定是因为服务端的IP地址和端口号必须要众所周知,不能随意改变。而客户端虽然也需要IP地址和端口号,但是客户端并不需要我们进行绑定操作,客户端连接服务端时系统会自动指定一个端口号给客户端。
- 服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的。
此外,客户端必须要知道它要连接的服务端的IP地址和端口号,因此客户端除了要有自己的套接字之外,还需要知道服务端的IP地址和端口号,这样客户端才能够通过套接字向指定服务器进行通信。
客户端连接
// 填写远端服务器的信息
sockaddr_in server;
// 先置0
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverPort);
inet_aton(serverIP.c_str(), &server.sin_addr);
if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0)
{
std::cerr << "connect error" << strerror(errno) << std::endl;
exit(CONN_ERR);
}
参数说明:
- sockfd:特定的套接字,表示通过该套接字发起连接请求。
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度。
返回值说明:
连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。
客户端连接服务器
需要注意的是,客户端不是不需要进行绑定,而是不需要我们自己进行绑定操作,当客户端向服务端发起连接请求时,系统会给客户端随机指定一个端口号进行绑定。因为通信双方都必须要有IP地址和端口号,否则无法唯一标识通信双方。也就是说,如果connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器。
此外,调用connect函数向服务端发起连接请求时,需要传入服务端对应的网络信息,否则connect函数也不知道该客户端到底是要向哪一个服务端发起连接请求。
clientTcp.cc
#include "util.hpp"
volatile bool quit = false;
int main(int argc, char *argv[]) // 命令行参数传服务器的ip 和 端口
{
if (argc != 3)
{
exit(USAGE_ERR);
}
std::string serverIP = argv[1];
uint16_t serverPort = atoi(argv[2]);
int sock = socket(AF_INET, SOCK_STREAM, 0); // 创建套接字
if (sock < 0)
{
std::cerr << "创建套接字失败" << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
// 填写远端服务器的信息
sockaddr_in server;
// 先置0
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverPort);
inet_aton(serverIP.c_str(), &server.sin_addr);
if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0)
{
std::cerr << "connect error" << strerror(errno) << std::endl;
exit(CONN_ERR);
}
std::string message;
while (!quit)
{
message.clear();
std::cout << "请发送数据->" << std::endl;
std::getline(std::cin, message);
if (strcasecmp(message.c_str(), "quit") == 0)
{
quit = true;
// std::cout<<"要退出啦"<<std::endl;
// continue;
}
// 向套接字里面发送数据
size_t s_w = send(sock, message.c_str(), message.size(), 0);
// size_t s_w = write(sock, message.c_str(), message.size());
if (s_w > 0)
{
// 从sock里读取数据
message.resize(1024);
//size_t s_r = read(sock, (char *)message.c_str(), 1024);
size_t s_r = recv(sock, (char *)message.c_str(), 1024,0);
if (s_r > 0)
message[s_r] = '\0';
std::cout << "客户端收到消息啦::" << message << std::endl;
}
else if (s_w <= 0)
{
break;
}
}
close(sock);
return 0;
}
serverTcp.cc
#include "util.hpp"
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <pthread.h>
// 先不封装 先把基本功能写出来 2023/3/19 11:16
// 现在开始封装 2023/3/19 14:06
class serverTcp;//先声明一下不然会报错
class ThreadData
{
public:
int sock_;
std::string clientIP_;
uint16_t clientPort_;
serverTcp * this_;
ThreadData(int sock,
std::string IP,
uint16_t clientPort,
serverTcp *_this)
: sock_(sock),
clientIP_(IP),
clientPort_(clientPort),
this_(_this)
{
}
};
class serverTcp
{
public:
serverTcp(uint16_t port, const std::string &ip = "")
: Listen_(-1), port_(port), ip_(ip)
{
}
~serverTcp()
{
close(Listen_);
}
void init()
{
// 创建套接字
Listen_ = socket(PF_INET, SOCK_STREAM, 0);
if (Listen_ < 0)
{
std::cerr << "socket 创建套接字失败" << std::endl;
exit(LISTEN_ERR);
}
// 绑定
sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = PF_INET;
local.sin_port = htons(port_);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(Listen_, (const sockaddr *)&local, sizeof(local)) < 0)
{
std::cerr << "bind 失败" << strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "绑定成功" << std::endl;
// 监听
if (listen(Listen_, 5) < 0)
{
std::cerr << "listen 失败" << strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "listen成功" << std::endl;
}
// 开始服务
void TranceServer(int sock, const std::string &clientIP, uint16_t clientPort)
{
assert(sock >= 0);
assert(!clientIP.empty());
assert(clientPort > 1024);
char buffer[1024];
while (true)
{
// buffer.clear();
// size_t s = read(sock, buffer, 1024);
size_t s = recv(sock, buffer, 1024, 0);
buffer[s] = '\0';
if (s < 0)
{
std::cout << "读取错误" << std::endl;
break;
}
else if (s == 0)
{
std::cout << "客户端没有发消息" << std::endl;
break;
}
else
{
std::cout << "从客户端收到消息了: " << buffer << std::endl;
// C语言中判断字符串是否相等的函数,忽略大小写。s1和s2中的所有字母字符在比较之前都转换为小写。
// 该strcasecmp()函数对空终止字符串进行操作。函数的字符串参数应包含一个(’\0’)标记字符串结尾的空字符
if (strcasecmp(buffer, "quit") == 0)
{
std::cout << "客户端退出" << std::endl;
break;
}
std::string buffer_r;
// std::cout<<"测试buffer 范围for:: ";
for (int i = 0; i < s; i++)
{
if (isalpha(buffer[i]) && buffer[i])
buffer_r += toupper(buffer[i]);
else
buffer_r += buffer[i];
}
// write(sock, buffer_r.c_str(), buffer_r.size());
send(sock, buffer_r.c_str(), buffer_r.size(), 0);
}
}
close(sock);
}
static void *ThreadRoutine(void *args)
{
pthread_detach(pthread_self());
ThreadData *td = static_cast<ThreadData *>(args);
td->this_->TranceServer(td->sock_, td->clientIP_, td->clientPort_);
delete td;
return nullptr;
}
void loop()
{
while (true)
{
sockaddr_in peer;
socklen_t len_s = sizeof(peer);
int ServerSock = accept(Listen_, (sockaddr *)&peer, &len_s); // 第三个是输入输出型参数,所以先定义一个变量然后传进去
std::string clientIP = inet_ntoa(peer.sin_addr);
uint16_t clientPort = ntohs(peer.sin_port);
pid_t id = fork();
// if (id == 0)
// {
// // 子进程
// close(Listen_);
// TranceServer(ServerSock, clientIP, clientPort);
// exit(0);
// }
// signal(SIGCHLD, SIG_IGN);
// close(ServerSock); // 多进程一定要做这一步!!!
// 这里不需要进行关闭文件描述符吗??不需要啦
// 多线程是会共享文件描述符表的!
ThreadData *td = new ThreadData(ServerSock, clientIP, clientPort, this);
pthread_t t;
pthread_create(&t, nullptr, ThreadRoutine, td);
}
}
private:
int Listen_;
uint16_t port_;
std::string ip_;
};
int main()
{
serverTcp svr(8080);
svr.init();
svr.loop();
return 0;
}
util.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <cassert>
#include <cctype>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "log.hpp"
#define SOCKET_ERR 1
#define BIND_ERR 2
#define LISTEN_ERR 3
#define USAGE_ERR 4
#define CONN_ERR 5
#define BUFFER_SIZE 1024