从上一章开始我们正式进入Linux网络编程的学习,上回中我们对网络有了大概的认识,宏观上了解了网络的传输过程,对局域网广域网以及Mac地址和IP地址有了初步的认识。
本章我们正式进入网络编程,用代码来实现网络间的通信,学习认识相关的接口……
为了完成通信,传输层有两个重要的协议。
可靠与不可靠传输,更多的标明的是一种通信特征。不能说tcp
和udp
哪个更好,只能说哪个更合适。
我们之前学过C语言都知道,有大端机和小端机,那么不同的计算机的字节序,要向网上发,其他计算结接收时,不知道发过来的数据是按照大端还是小端字节序来读,所有必须要有统一的规定。
规定网络字节序列是一种大端序列。
0x12345678
在大端字节序下的内存存储方式为0x12 0x34 0x56 0x78
。0x12345678
在小端字节序下的内存存储方式为0x78 0x56 0x34 0x12
。
如果主机就是大端机,这些函数什么都不会做。主机是小端机,则会将主机字节序转换成网络字节序(大端字节序),网络字节序转主机字节序也是同样的道理。
标准的流式或数据报
式方式发送和接收数据包,实现不同计算机之间的通信。一个 Socket 由 IP 地址和端口号两部分组成
。返回值:
Linux下的一切皆文件包括了socket接口。每个打开的文件(包括socket)都会被分配一个文件描述符(file descriptor),它是一个非负整数。
实际上,发送和接收数据就像对文件写入和读取数据一样操作。
API:
API 是
Application Programming Interface
的缩写,翻译为应用程序编程接口。是一组定义了不同软件组件之间,相互通信和交互的规范和工具集合。它允许不同的软件系统应用程序或服务之间进行数据传递、功能调用和交互操作。
那么socket是打开了一个文件吗:
- 在Linux中,socket并不是打开一个文件,而是提供了一种抽象的接口,用于进行网络通信。
- 尽管在编程上可以将socket看作是一个文件描述符,但实际上并没有打开一个物理文件。
- 当我们调用socket()函数创建一个socket时,操作系统会为该socket分配资源,并返回一个文件描述符(socket fd)。
- 这个文件描述符是一个整数值,用于标识该socket。
Socket 是一种抽象层,提供了一种通用的应用程序编程接口(API),允许应用程序通过网络或本地主机之间进行通信。它可以用于不同协议的网络通信,包括 TCP、UDP 等。
除了在网络通信中使用外,它还可以用于同一台计算机上的应用程序之间的通信,例如进程间通信、线程间通信等。
那么一个接口干两件事,如何区分呢?
sockaddr_in/scokaddr_un/sockadd_in6
之中的任意一个(需要强转指针)。sockaddr
是一个通用的地址结构体,它主要用于在网络编程中传递和表示套接字地址。在实际使用中,我们通常会使用sockaddr
的具体派生结构体,例如sockaddr_in(IPv4)或sockaddr_in6(IPv6)或scokaddr_un,它们在sockaddr的基础上添加了特定的字段,以方便使用不同类型的套接字地址。
sockaddr_in、sockaddr_un和sockaddr_in6
是sockaddr
结构体的几个具体实现,用于在网络编程中表示不同类型的套接字地址:
sockaddr_in
结构体用于表示IPv4地址,包括一个16位端口号和一个32位IP地址。该结构体 “继承” 自sockaddr
结构体,并且增加了专门存储端口号和IP地址的字段。sockaddr_un
结构体用于表示UNIX域套接字的地址,包括UNIX域套接字的路径名。该结构体同样 “继承” 自sockaddr
结构体,并且增加了存储路径名的字段。sockaddr_in6
结构体用于表示IPv6地址,该结构体也同样 “继承” 自sockaddr
结构体,而其增加了专门存储IPv6地址和端口号的字段。sockaddr
结构体使用,以便在函数调用中进行传递。补充:
- 相同起始成员: sockaddr结构体和这些特定结构体都有名为 “sa_family” 的成员变量,用于指示地址家族。这个成员在不同的特定结构体中具有相同的位置和作用。
- 强制类型转换: 因为这些特定结构体的首部成员与sockaddr结构体的首部成员相同,并且只有首部成员是重要的,所以可以通过将特定结构体的指针强制转换为sockaddr结构体的指针,并传递给需要sockaddr结构体参数的函数。
因为用的是ipv4的网络通信,所以这里需要初始化一个sockaddr_in
类型的结构体:
// 绑定网络信息,指明ip + port
// 先填充基本信息到 struct sockaddr_in
struct sockaddr_in local;
bzero(&local, sizeof(local));// 清空操作
首先是把协议家族设置为IPV4,端口配置为代码所在函数参数中传的端口号:
// 填充协议家族,域
local.sin_family = AF_INET;
// 填充服务器对应的端口号信息,一定是会发给对方的,port_一定会到网络中
local.sin_port = htons(port_);
这个
local.sin_family
就是前16位,确定是本地通信还是网络通信,也可以用PF_INET
是一样的。
然后配置IP:
local.sin_addr.s_addr = ip_.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
"xx.yy.zz.aaa"
,字符串风格点分十进制,4字节IP,uint32_t ip
。INADDR_ANY(0)
:程序员不关心会bind到哪一个ip, 任意地址bind,强烈推荐的做法,所有服务器一般的做法。inet_addr()
函数可以将一个点分十进制的IPv4地址转换为网络字节序,下的32位二进制整数,即4个字节的IP地址。
in_addr_t inet_addr(const char *cp);
因为对于网络来说并不认识字符串类型的ip,只认识网络字节流规定的ip。
IPV4地址是由四个十进制数组成(每个十进制数的数值是8位二进制数的数值),每个数组表示一个字节,范围从0~255
,用点分十进制表示。
123.123.0.1
是一个IPV4地址。ip第四个字节,用的是位段来存储的:
// 示例
struct ip
{
uint32_t part1:8;
uint32_t part2:8;
uint32_t part3:8;
uint32_t part4:8;
}
inet_ntoa()
函数将网络请求中的IP地址转换为字符串类型,接受一个struct in_addr
类型的参数,该类型表示一个IPv4地址。
从网络请求中获取到的IP地址转换为struct in_addr
类型,然后再使用inet_ntoa
函数将其转换为字符串类型。
char *inet_ntoa(struct in_addr in);
很多同学不知道struct in_addr
是什么类型,我们不妨在vscode中点开struct sockaddr_in
类型定义来看看:
所以我们在传参时,只需要将struct sockaddr_in
类的对象的成员传过去就好了。
返回值是个char*类型的,那么字符串在哪呢?
inet_ ntoa
这个函数返回了一个char*
,很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果。buffer
。inet_ntoa
函数,是把这个返回结果放到了静态存储区,这个时候不需要我们手动进行释放。#include
#include
#include
int main()
{
struct sockaddr_in addr1;
struct sockaddr_in addr2;
addr1.sin_addr.s_addr = 0;
addr2.sin_addr.s_addr = 0xffffffff;
char* ptr1 = inet_ntoa(addr1.sin_addr);
char* ptr2 = inet_ntoa(addr2.sin_addr);
std::cout << "ptr1: " << ptr1 << " " << "ptr2: " << ptr2 << std::endl;
return 0;
}
在APUE中, 明确提出inet_ntoa不是线程安全的函数。但是在Centos7上测试, 并没有出现问题, 可能内部的实现加了互斥锁。
该接口是指定socket
和sockaddr
进行绑定,第三个参数是addr参数的大小。
// bind 网络信息 -- 将数据填入到操作系统里
if (bind(sockfd_, (const struct sockaddr *)&local, sizeof(local)) == -1)
{
logMessage(FATAL, "bind: %s:%d", strerror(errno), sockfd_);
exit(2);
}
在之前的初始化struct sockaddr_in
时,我们提到过INADDR_ANY
:
INADDR_ANY(0)
:程序员不关心会bind到哪一个ip, 任意地址bind,强烈推荐的做法,所有服务器一般的做法。
INADDR_ANY
:转到定义上去看,我们发现它就是0。
关于端口号,不要绑定,0到1023以前的端口号,是服务器或者特定服务用的,一绑定可能就出错了:
小结一下:(个人理解)
sockaddr
存储着套接字的信息,bind
将sockaddr
和socket
绑定起来,然后socket
函数去处理套接字。具体代码如下:
// udp服务器,只需要,1. 创建套接字 2. 填充信息之后做绑定,绑定完成之后就算完成
void init()
{
// 1. 创建socket套接字
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0); // 就是打开了一个文件
if (sockfd_ < 0)
{
logMessage(FATAL, "socket:%s:%d", strerror(errno), sockfd_);
exit(1);
}
// 日志
logMessage(DEBUG, "socket create success: %d", sockfd_);
// 2. 绑定网络信息,指明ip + port
// 2.1 先填充基本信息到 struct sockaddr_in
struct sockaddr_in local; // local在哪里开辟的空间? 用户栈,就是临时变量,我们要将其写入内核中
bzero(&local, sizeof(local)); // 也可以用memset
// 填充协议家族,域
local.sin_family = AF_INET; // 这个family就是前16位,确定是本地通信还是网络通信,也可以用PF_INET是一样的
// 填充服务器对应的端口号信息,一定是会发给对方的,port_一定会到网络中
local.sin_port = htons(port_);
// 服务器都必须具有IP地址,"xx.yy.zz.aaa",字符串风格点分十进制 -> 4字节IP -> uint32_t ip
// INADDR_ANY(0): 程序员不关心会bind到哪一个ip, 任意地址bind,强烈推荐的做法,所有服务器一般的做法
// inet_addr: 指定填充确定的IP,特殊用途,或者测试时使用,除了做转化,还会自动给我们进行 h—>n (主机转网络)
local.sin_addr.s_addr = ip_.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
// 2.2 bind 网络信息 -- 将数据填入到操作系统里
if (bind(sockfd_, (const struct sockaddr *)&local, sizeof(local)) == -1)
{
logMessage(FATAL, "bind: %s:%d", strerror(errno), sockfd_);
exit(2);
}
logMessage(DEBUG, "socket bind success: %d", sockfd_);
}
recvfrom
函数用于接收UDP协议的数据报,它从指定的文件描述符处读取数据,并将数据保存在指定的缓冲区buf中,同时将发送方的地址信息存储在addr参数所指向的结构体中。
返回值:
start具体实现:
void start()
{
char inbuffer[1024]; // 将来读取到的数据,都放在这里
char outbuffer[1024]; // 将来发送的数据,都放在这里
// 服务器设计的时候,服务器都是死循环
while (true)
{
// 远端
struct sockaddr_in peer; // 输出型参数
socklen_t len = sizeof(peer); // 输入输出型参数
// demo2
// UDP是无连接的
// 对方给你发了消息,你想不想给对方回消息?
// 要的!后面的两个参数是输出型参数,发消息的一方会将属性写到对应的peer和len当中
// 不断地从网络当中进行数据读取:
ssize_t s = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0,
(struct sockaddr *)&peer, &len);
// 数据已经读到了吧
if (s > 0)
{
inbuffer[s] = 0; // 当做字符串
}
else if (s == -1)
{
logMessage(WARINING, "recvfrom: %s:%d", strerror(errno), sockfd_);
continue;
}
// 谁发的消息,将对方的信息提取出来:
// 读取成功的,除了读取到对方的数据,你还要读取到对方的网络地址[ip:port]
std::string peerIp = inet_ntoa(peer.sin_addr); // 拿到了对方的IP
uint32_t peerPort = ntohs(peer.sin_port); // 拿到了对方的port
// 打印出来客户端给服务器发送过来的消息
logMessage(NOTICE, "[%s:%d]# %s", peerIp.c_str(), peerPort, inbuffer);
for (int i = 0; i < strlen(inbuffer); i++)
{
if(isalpha(inbuffer[i]) && islower(inbuffer[i]))
outbuffer[i] = toupper(inbuffer[i]);
else
outbuffer[i] = toupper(inbuffer[i]);
}
// 谁给我发消息,立马转回去
sendto(sockfd_, outbuffer, strlen(outbuffer), 0, (struct sockaddr*)&peer, len);
}
}
sendto
函数用于通过UDP协议发送数据报。它可以将指定的缓冲区中的数据发送到目标地址。
在我们上述start函数中,我们还实现了一个功能就是将收到的信息处理之后,再发回出去。客户端可以再用recvfrom接到消息,再显示出来。
服务类的成员变量和main函数:
// 使用手册
static void Usage(const std::string porc)
{
std::cout << "Usage:\n\t" << porc << " port [ip]" << std::endl;
}
/// @brief 我们想写一个简单的udpSever
/// 云服务器有一些特殊情况:
/// 1. 禁止你bind云服务器上的任何确定IP, 只能使用INADDR_ANY,如果你是虚拟机,随意
class UdpServer
{
public:
UdpServer(int port, std::string ip = "") : port_((uint16_t)port), ip_(ip), sockfd_(-1)
{
}
~UdpServer()
{
}
// .........
private:
// 服务器必须得有端口号信息
uint16_t port_;
// 服务器必须得有ip地址
std::string ip_;
// 服务器的socket fd信息
int sockfd_;
// onlineuser
std::unordered_map<std::string, struct sockaddr_in> users;
};
// ./udpServer port [ip]
int main(int argc, char *argv[])
{
if (argc != 2 && argc != 3) // 反面:argc == 2 || argc == 3
{
Usage(argv[0]);
exit(3);
}
uint16_t port = atoi(argv[1]);
std::string ip;
if (argc == 3)
{
ip = argv[2];
}
UdpServer svr(port, ip);
svr.init();
svr.start();
return 0;
}
有了上述知识,客户端的实现就一马平川了。
struct sockaddr_in server;
static void Usage(std::string name)
{
std::cout << "Usage:\n\t" << name << " server_ip server_port" << std::endl;
}
void *recverAndPrint(void *args)
{
while (true)
{
int sockfd = *(int *)args;
char buffer[1024];
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t s = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&temp, &len);
if (s > 0)
{
buffer[s] = 0;
std::cout << "server echo# " << buffer << std::endl;
}
}
}
// ./udpClient server_ip server_port
// 如果一个客户端要连接server必须知道server对应的ip和port
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
// 1. 根据命令行,设置要访问的服务器IP
std::string server_ip = argv[1];
uint16_t server_port = atoi(argv[2]);
// 2. 创建客户端
// 2.1 创建socket,服务器是udp的已经跑起来了,客户端也要想办法去连接服务器
// 所以客户端也必须得有套接字信息
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
assert(sockfd > 0);
// 2.2 填写服务器对应的信息
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
// 创建一个线程就可以了
// pthread_t t;
// pthread_create(&t, nullptr, recverAndPrint, (void *)&sockfd);
// 3. 通讯过程
std::string buffer;
while (true)
{
std::cerr << "Please Enter# ";
std::getline(std::cin, buffer);
// 发送消息给server:
// 客户端首次调用sendto函数的时候,我们的client会自动bind自己的ip和port
sendto(sockfd, buffer.c_str(), buffer.size(), 0,\
(const struct sockaddr *)&server, sizeof(server));
// 发完消息之后再转发回去
char buffer[1024];
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t s = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr*)&temp, &len);
if (s > 0)
{
buffer[s] = 0;
std::cout << "server echo# " << buffer << std::endl;
}
}
close(sockfd);
return 0;
}
客户端需不需要bind???是需要bind的,但是不需要用户自己bind,而是OS自动bind的!!!
那么server凭什么要bind呢??
在填写好服务端主机的信息之后,客户端直接就可以向服务端发送消息,main函数:
// ./udpServer port [ip]
int main(int argc, char *argv[])
{
if (argc != 2 && argc != 3) // 反面:argc == 2 || argc == 3
{
Usage(argv[0]);
exit(3);
}
uint16_t port = atoi(argv[1]);
std::string ip;
if (argc == 3)
{
ip = argv[2];
}
UdpServer svr(port, ip);
svr.init();
svr.start();
return 0;
}
在测试之前,我们先把日志实现一下:
#pragma once
#include
#include
#include
#include
#include
#include
#include
#define DEBUG 0
#define NOTICE 1
#define WARINING 2
#define FATAL 3
const char *log_level[]={"DEBUG", "NOTICE", "WARINING", "FATAL"};
// logMessage(DEBUG, "%d", 10);
void logMessage(int level, const char *format, ...)
{
assert(level >= DEBUG);
assert(level <= FATAL);
char *name = getenv("USER");
char logInfo[1024];
va_list ap; // ap就是一个char*类型
va_start(ap, format);
vsnprintf(logInfo, sizeof(logInfo) - 1, format, ap);
va_end(ap); // ap = NULL
FILE *out = (level == FATAL) ? stderr:stdout;
fprintf(out, "%s | %u | %s | %s\n", \
log_level[level], \
(unsigned int)time(nullptr),\
name == nullptr ? "unknow":name,\
logInfo);
}
127.0.0.1
是IPv4地址中的本地回环地址。它通常被称为“localhost”,可用于测试计算机或网络设备的网络功能,以及运行并测试网络应用程序等。当计算机尝试连接到127.0.0.1
时,它实际上是在尝试与自己本身通信,因此这个地址非常有用。
客户端发的消息,经过网络协议栈,不往网络里发,到了网络协议栈的最底部,再由最底部向上交付。再交付给另一个进程对应的缓冲区里面,让那个进程读到消息。
我们可以创建一个多人聊天室,将所有人的信息(ip + port)都保存在unordered_map
中,只要有用户连到主机上,就将其添加到哈希表中。
void checkOnlineUser(std::string &ip, uint32_t port, struct sockaddr_in &peer)
{
std::string key = ip;
key += ":";
key += std::to_string(port);
auto iter = users.find(key);
if(iter == users.end())
{
users.insert({key, peer});
}
else
{
// iter->first, iter->second->
// do nothing
}
}
并且将收到的信息群发给所有的用户:
void messageRoute(std::string ip, uint32_t port, std::string info)
{
std::string message = "[";
message += ip;
message += ":";
message += std::to_string(port);
message += "]# ";
message += info;
// 给每个在线用户都发回去
for(auto &user : users)
{
sendto(sockfd_, message.c_str(), message.size(), 0, (struct sockaddr*)&(user.second), sizeof(user.second));
}
}
UDP的sendto和recvfrom是阻塞式的,sendto会一直阻塞直到数据成功发送或者发生错误,所以我们要加多线程:
void *recverAndPrint(void *args)
{
while (true)
{
int sockfd = *(int *)args;
char buffer[1024];
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t s = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&temp, &len);
if (s > 0)
{
buffer[s] = 0;
std::cout << "server echo# " << buffer << std::endl;
}
}
}
加了多线程之后,客户端有两个线程,主个线程在发消息, 获取用户输入,发消息;新线程在不断地收消息,并且打印到显示器上去。
为了让输出的内容更直观的显示出来,我们将客户端输出的内容写入到命名管道fifo中(注意:命名管道要现将读端打开,所以先要cat < fifo
然后再在服务端发送消息)
客户端的cout 本来是向显示器打印的,结果被重定向到了fifo命名管道中,重定向只是改变了输出流的目标,将输出内容发送到指定的管道文件中,但不会影响管道中已有的内容,所以才会出现上述情况(原有的信息依旧显示的情况)。
备注:
当将命令的输出重定向到一个 FIFO 命名管道时,它会将输出写入到管道中,并且不会影响管道中现有的任何数据。
#pragma warning(disable:4996)
#include
#include
#include
#include
#include
#pragma comment(lib, "Ws2_32.lib")
int server_port = 8080;
std::string server_ip = "xxx.yyy.zz.mm";
int main()
{
WSADATA data;
(void)WSAStartup(MAKEWORD(2, 2), &data);
(void)data;
SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);
assert(sockfd > 0);
// 2.2 填写服务器对应的信息
struct sockaddr_in server;
memset(&server, 0, sizeof server);
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
// 3. 通讯过程
std::string buffer;
while (true)
{
std::cerr << "Please Enter# ";
std::getline(std::cin, buffer);
// 发送消息给server:
// 客户端首次调用sendto函数的时候,我们的client会自动bind自己的ip和port
sendto(sockfd, buffer.c_str(), buffer.size(), 0,
(const struct sockaddr*)&server, sizeof(server));
char buffer[1024];
struct sockaddr_in temp;
int len = sizeof(temp);
int s = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr*)&temp, &len);
if (s > 0)
{
buffer[s] = 0;
std::cout << "server echo# " << buffer << std::endl;
}
}
closesocket(sockfd);
WSACleanup();
system("pause");
return 0;
}
除了开头一些Windows需要的东西外,其他的与Linux下的代码一模一样,这样我们就可以在Windows端来访问部署在Linux下的服务了。