Linux网络学习:套接字、UDP的封装与应用
一、套接字数据由进程产生和接收因此网络通信的本质是进程和进程之间的通信而进程间具有独立性要实现通信必须要让两个进程看到同一份资源也就是通过网络协议栈看到网络上的资源。那么数据接收方在传输层怎么知道要把数据交付给上层的哪个应用这时引入端口号的概念。1.认识端口号端口号(port)是传输层协议的内容。端口号是一个2字节16位的整数。端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理。一个端口号只能被一个进程占用。2.端口号绑定原理内核通过端口号在协议控制块PCB中查找对应的进程从而实现数据交付那进程的端口号是谁来定义的呢app是厂商做的所以服务端口号已经被内置进app了从而可以实现一一对应。一个进程可以绑定多个端口号反之不行因为通过端口号必须能找到唯一的进程。3.端口号 vs pidpid也可以标识进程唯一性为什么还需要端口号PID 是动态的进程重启后会发生改变而端口号是固定的服务标识。通过端口号定位服务网络层就不需要关心具体进程的 PID实现了网络服务和操作系统进程管理的解耦。4.套接字是什么通过IP地址 端口号就能标识网络中某台主机的某个进程这样一套地址标识就被称为套接字Socket。那么通过两个套接字就可以实现两台主机一对一通信。5.sockaddr结构socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的UNIX Domain Socket. 然而, 各种网络协议的地址格式并不相同。IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型, 16 位端口号和32位IP地址.IPv4、IPv6地址类型分别定义为常数AF_INET、AF_INET6. 这样,只要取得某种sockaddr结构体的首地址, 不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容.socket API可以都用struct sockaddr *类型表示, 在使用的时候需要强制转化成sockaddr_in; 这样的好 处是程序的通用性, 可以接收IPv4, IPv6, 以及UNIX Domain Socket各种类型的sockaddr结构体指针做为 参数;二、网络字节序1.数据在网络中字节序规则我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出。接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存。因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据。如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可。2.字节序转换接口为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络 字节序和主机字节序的转换。h表示host,n表示network,l表示32位长整数,s表示16位短整数。例如htonl表示将32位的长整数从主机字节序转换为网络字节序, 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回 ; 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。三、UDP定义和特性UDPUser Datagram Protocol用户数据报协议是TCP/IP协议族中的核心传输层协议之一。UDP四大特性1. 无连接发送数据前UDP不需要像TCP那样进行“三次握手”建立连接而是直接把数据包扔到网络上。没有建立连接的时间延迟这是它更快快的根本原因。2. 不可靠传输UDP只管发送不确认对方是否收到。如果网络拥堵导致数据包丢失UDP不会主动重传。丢失的数据需要由上层应用自己决定如何处理。3. 面向报文TCP会把大块数据切分成小段再发送而UDP是“整体发送”。应用层给UDP一个完整的数据报UDP直接加上一个头部就发出去极大节省了带宽。4. 支持一对多TCP只能一对一通信但UDP支持广播和组播。四、UDP的封装及应用聊天室的实现结合前文有关知识实现UDP的封装并实现简易聊天室程序具体要实现的效果是客户端连接上服务端后在聊天室中上线可向聊天室发送消息同时可以接受到其它用户发送的消息。下面给出实现思路和代码。1.服务端设计服务端基于Udp_Server类封装采用回调注入的方式将网络层与业务逻辑解耦。类内部负责 socket 创建、端口绑定、数据收发等底层操作收到数据后通过handler回调函数把remote地址和消息内容交还给用户自定义逻辑处理也就是定义聊天室的实现逻辑。接入的用户通过mapstring, Server_Info管理以IP:端口号作为唯一标识记录每个在线用户的地址信息和用户名。广播功能通过遍历该容器、逐个调用sendto完成。主函数只需传入端口创建实例后调用Init初始化再调用Run并传入业务处理函数即可启动服务。整体采用 RAII 思想使用unique_ptr创建服务端并自动管理生命周期析构时自动关闭 socket。2.客户端设计客户端采用双线程模型_Read线程负责循环接收服务端的广播消息并打印_Write线程负责通过getline获取用户输入并发送。两个线程共享Client_Info结构体含 socket 和服务端地址实现收发分离避免输入和接收互相阻塞。启动时需指定服务端的 IP 和端口号。3.通信流程客户端发送消息后服务端通过handler解析发送方地址自动识别新用户并加入在线列表然后将消息组装成[用户名]: 消息内容的格式通过广播功能转发给所有在线用户。整个流程实现了简易聊天室的核心功能用户识别、消息广播、动态上下线管理。4.具体代码完整代码详见UDP封装与应用聊天室的实现(1)udpserver.hpp#pragma once #include log.hpp #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include sys/types.h #include functional #include vector #include map #include string enum { SOCKET_ERRNO 1, BIND_ERRNO, COMMAND_ERRNO, POPEN_ERRNO }; struct Server_Info { std::string ip; std::string port; std::string user_name; struct sockaddr_in sock; }; class Udp_Server; typedef std::functionvoid(const sockaddr_in remote, const std::string recstr, Udp_Server*instance) func; std::string ip_default 0.0.0.0; uint16_t port_default 8080; class Udp_Server { public: Udp_Server(const uint16_t port) : port_(port), ip_(ip_default), running_(false) {} void Init() { sockfd_ socket(AF_INET, SOCK_DGRAM, 0);//创建套接字并得到索引 if (sockfd_ -1) { LOG(FATAL, socket create fail!); exit(SOCKET_ERRNO); } else { LOG(INFO, socket create success!); } // 初始化socket结构体 struct sockaddr_in local; bzero(local, sizeof(local));//重置socket结构体 local.sin_family AF_INET; local.sin_port htons(port_);//主机转网络确保端口号的字节序 local.sin_addr.s_addr INADDR_ANY; socklen_t len sizeof(local); if (bind(sockfd_, (const struct sockaddr *)local, len) 0) { LOG(FATAL, bind fail,errno:%d,%s, BIND_ERRNO, strerror(errno)); exit(BIND_ERRNO); } else { LOG(INFO, bind success); } } void Adduser(const std::string key,const sockaddr_in input){//用里面的信息来填充Server_Info Server_Info newuser; char buf[INET_ADDRSTRLEN]; const char *ip_str inet_ntop(AF_INET, input.sin_addr, buf, sizeof(buf));//将ip转字符串顺便会转回主机字节序 newuser.ip ip_str; newuser.port std::to_string(ntohs(input.sin_port));//将网络字节序端口号转主机字节序端口号 std::string name; name [; name newuser.ip; name :; name newuser.port; name ]; newuser.user_name name; newuser.sock input; online_user_list[key] newuser;//利用map的方括号重载将新用户的Server_Info填入在线名单中 //用户第一次发送消息时聊天室广播欢迎语 std::cout name has been log in. std::endl; std::string welcome welcome name !!!; Broadcast(welcome); } bool Checklist(const std::string key){//根据ip端口号查找用户是否已经入网(绑定且发过消息) auto it online_user_list.find(key); if (it online_user_list.end()){ return false; }else{ return true; } } std::string Getusername(const std::string key){//从map存储的Server_Info中获取已经拼接好的用户名 auto itonline_user_list.find(key); if(itonline_user_list.end()){ LOG(ERROR,User does not exist); return User does not exist; } else return it-second.user_name; } void Broadcast(const std::string message){//一个用户发送的消息在聊天室广播给所有用户 for (auto i : online_user_list) { sockaddr_in target_socki.second.sock; socklen_t len sizeof(target_sock); sendto(sockfd_, message.c_str(),message.size(), 0, (sockaddr *)target_sock,len); } } void Run(func handler nullptr)//由用户指定业务逻辑main { if (!handler) { std::cout handler is NULL std::endl; return; } running_ true; char recbuf[1024]; while (running_) { memset(recbuf, 0, sizeof(recbuf)); struct sockaddr_in remote; bzero(remote, sizeof(remote)); socklen_t len sizeof(remote); size_t n recvfrom(sockfd_, recbuf, sizeof(recbuf), 0, (sockaddr *)remote, len);//接收消息并用remote获取用户信息 if (n 0) { LOG(ERROR, recvfrom fail,errno:%d %s, errno, strerror(errno)); continue; } recbuf[1023] \0; std::string recstr recbuf; handler(remote, recstr, this);//将获取到的 } } ~Udp_Server() { close(sockfd_);//关闭创建套接字时打开的文件 } private: std::mapstd::string, Server_Info online_user_list; //用于管理的上线用户名单以ip端口号为键以 Server_Info为值 int sockfd_; // 创建套接字得到的文件fd std::string ip_; // ip bool running_; uint16_t port_; // 端口号 };(2)main.cc#include udpserver.hpp #include memory void handler(const sockaddr_in remote, const std::string recstr, Udp_Server*instance)//这个函数被服务端调用的时候服务端要把this指针传进来从而调用对象的成员函数 { char buf[INET_ADDRSTRLEN];//16的大小 const char *ip_str inet_ntop(AF_INET, remote.sin_addr, buf, sizeof(buf)); std::string ipip_str; std::string port std::to_string(ntohs(remote.sin_port)); std::string key ip : port; // 收到数据后根据用户id判断用户是否已在容器中 // 如果不在根据ip和端口号得到用户名并写入容器中,在则直接得到用户名并转播消息 if(!instance-Checklist(key)){ instance-Adduser(key,remote); } std::string nameinstance-Getusername(key); std::string messagenamerecstr;//聊天室效果[用户名ip端口号]:用户发送的消息 instance-Broadcast(message);// } int main(int argc, char *argv[]) { // 启动客户端时需要指定一个端口 if (argc ! 2) { std::cout please enter server_port std::endl; exit(0); } uint16_t port static_castuint16_t(std::stoi(argv[1])); std::unique_ptrUdp_Server udp_server(new Udp_Server(port)); udp_server-Init(); udp_server-Run(handler);//将方法传给服务端 return 0; }(3)client.cc#include log.hpp #include string #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include sys/types.h #include functional #include pthread.h struct Client_Info { int sockfd_; struct sockaddr_in remote; }; void *_Read(void *data) { Client_Info *info static_castClient_Info *(data); char buffer[1024]; socklen_t len sizeof(info-remote); while (true) { memset(buffer, 0, sizeof(buffer)); //注意这里如果放定义字符串也不会自动清空原内容导致错乱所以选择用memset主动清空 size_t n recvfrom(info-sockfd_, buffer, sizeof(buffer), 0, (sockaddr *)info-remote, len); //从服务端接收广播消息 if (n 0) { LOG(ERROR, recvfrom fail,errno:%d %s, errno, strerror(errno)); continue; } buffer[1023] \0; std::cout buffer std::endl; } } void *_Write(void *data) { Client_Info *info static_castClient_Info *(data); std::string message; socklen_t len sizeof(info-remote); while (true) { getline(std::cin, message); // 这个getline不是C语言的getline获取用户输入的聊天文本 sendto(info-sockfd_, message.c_str(), message.size(), 0, (sockaddr *)info-remote, len); //向服务端传输需要发送到聊天室的聊天文本 } } int main(int argc, char *argv[]) { //客户端在启动时要指定服务端的ip和端口号所以命令应包含3个参数 if (argc ! 3) { std::cout please enter server_ip and server_port std::endl; exit(0); } int sockfd socket(AF_INET, SOCK_DGRAM, 0);//客户端也要创建套接字通信正是通过两个套接字完成的 if (sockfd -1) { LOG(FATAL, socket create fail!); } else { LOG(INFO, socket create success!); } // 初始化socket结构体和服务端差不多 struct sockaddr_in remote; bzero(remote, sizeof(remote)); socklen_t len sizeof(remote); std::string message; remote.sin_family AF_INET; remote.sin_port htons(static_castuint16_t(std::stoi(argv[2]))); remote.sin_addr.s_addr inet_addr(argv[1]); Client_Info info; info.sockfd_ sockfd; info.remote remote; char buffer[1024]; // 建立两个线程分别执行监听聊天室和向聊天室发送消息的功能否则用户的输入界面和接收消息界面会混在一起 pthread_t rpt; pthread_t wpt; pthread_create(rpt, nullptr, _Read, info); pthread_create(wpt, nullptr, _Write, info); pthread_join(rpt, nullptr); pthread_join(wpt, nullptr); close(sockfd); return 0; }5.补充(1)创建套接字接口socket作用创建一个网络通信端点返回一个文件描述符。参数1协议族如AF_INETIPv4参数2套接字类型如SOCK_DGRAMUDP参数3通常填0表示使用默认协议返回值操作系统进程级文件描述符表的一个索引。通过这个索引内核能找到其内部维护的一个struct socket内核对象该对象包含了套接字的缓冲区、状态、端口绑定信息等。创建失败返回-1。(2)绑定接口bind作用将套接字与本地 IP 和端口号绑定使该套接字固定监听指定地址。参数1套接字创建后得到的文件描述符参数2指向已定义的sockaddr_in结构体的指针包含要绑定的IP和端口参数3套接字结构体大小返回值成功返回0失败返回-1。(3)接收接口recvfrom作用从 UDP 套接字接收一个数据报同时获取发送方的地址信息。参数1发送方套接字创建后得到的文件描述符参数2用于接收数据的接收缓冲区参数3缓冲区大小参数4通常填0阻塞等待参数5输出参数内核将发送方的 IP 和端口填入此处参数6输入输出参数传入时填缓冲区大小返回时得到实际地址长度返回值成功返回收到多少个字节失败返回-1(4)发送接口sendto作用通过 UDP 套接字向指定目标发送一个数据报。参数1sockfd文件描述符参数2buf待发送的数据参数3len数据长度参数4flags通常填0参数5dest_addr目标地址IP 端口参数6addrlen目标地址结构体的大小返回值成功返回实际发送的字节数失败返回-1。(5)端口号设定成多少适合启动客户端时需要指定一个端口一般而言0–1023 为系统保留端口通常需要 root 权限才能绑定。建议使用 1024–65535 之间的端口常用的开发测试端口如 8080、8888等。(6)ip地址和字符串转化函数inet_ntoa函数把返回结果放到了静态存储区.不需要手动进行释放这样第二次调用该函数时的结果会覆盖掉上一次的结果所以inet_ntoa不是线程安全函数inet_pton 和 inet_ntop转换函数需要用户传入缓冲区存放转换结果不依赖静态存储区不存在覆盖问题天然线程安全。因此推荐使用。(7)通配地址INADDR_ANYlocal.sin_addr.s_addr INADDR_ANY; //等价于 local.sin_addr.s_addr 0; // 或者 inet_addr(0.0.0.0)一台机器可能有多个网卡意味着多个ip数据可能从其中任意一个网卡进来套接字ip设置成通配地址意味着只要是发往对应端口号的数据不管目标IP是什么统统交给该套接字处理。(8)如何得知服务端的ip举例来说浏览器访问的网址最终通过 DNS 解析成对应的 IP 地址从而定位到目标服务器所以用户通过搜索点击得到网址也就知道了ip地址。(9)为什么客户端在启动时要指定服务端的ip和端口号总是客户端先向服务器发起请求服务器收到数据后就能知道客户端的端口号而服务端的端口号必须是确定的ip是能被找到的客户端才能够连接上服务端。(10)udp全双工的特性udp是全双工Full-Duplex的意味着通信双方可以在同一时刻同时向对方发送数据互不干扰。因此服务器才能一边接收着新进来的消息写入接收缓冲一边同时从发送缓冲把消息转发给所有在线用户互不阻塞(11)网络状态查看工具netstat是 Linux 下经典的网络状态查看工具它的核心作用是打印出当前系统的网络连接状态、路由表和接口统计信息。本次使用netstat -naup即可查看所有需要的信息。