1 🍑简单理解TCP/UDP协议 🍑
TCP协议:
- 1️⃣传输层协议
- 2️⃣有连接
- 3️⃣可靠传输
- 4️⃣面向字节流
UDP协议:
- 1️⃣传输层协议
- 2️⃣无连接
- 3️⃣不可靠传输
- 4️⃣面向数据报
2 🍑网络字节序 🍑
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可.
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换:
#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);
3 🍑socket编程接口 🍑
3.1 🍎socket 常见API 🍎
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器) int socket(int domain, int type, int protocol); // 绑定端口号 (TCP/UDP, 服务器) int bind(int socket, const struct sockaddr *address, socklen_t address_len); // 开始监听socket (TCP, 服务器) int listen(int socket, int backlog); // 接收请求 (TCP, 服务器) int accept(int socket, struct sockaddr* address, socklen_t* address_len); // 建立连接 (TCP, 客户端) int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
3.2 🍎sockaddr结构🍎
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的UNIX DomainSocket. 然而, 各种网络协议的地址格式并不相同。
所以当我们使用的时候可以将地址强转成 sockaddr* 类型。
IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型, 16位端口号和32位IP地址.
IPv4、IPv6地址类型分别定义为常数AF_INET、AF_INET6. 这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容.
socket API可以都用sockaddr *类型表示, 在使用的时候需要强制转化成sockaddr *; 这样的好处是程序的通用性, 可以接收IPv4, IPv6, 以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数。
struct sockaddr的定义:
struct sockaddr_in的定义:
4 🍑简单的UDP网络程序 🍑
4.1 🍎基本分析🍎
在写之前,我们先来简单的分析分析下我们应该怎样写?首先我们封装一个udpServer的类来帮助我们创建套接字以及套接字的初始换工作,当然客户端也可以使用这种方式来完,不过由于客户端的代码很简单,我就不在封装一个udpClient的类了。
其次我们思考下udpServer类中成员应该有哪些?
首先肯定要一个套接字(其本质就是一个文件描述符),其次我们需要一个端口号,大家猜一下,我们需要一个IP地址吗?这个其实是不需要的,因为一款服务器/云服务器一般是不要指定某一个具体的IP地址的.
那我们bind的时候应该怎样传入参数呢?这个大家先不急,等会儿将代码写好了大家在回过来看就会清晰很多。为了方便使用我们还可以用一个包装器来包装我们将来要执行回调的函数。
4.2 🍎udpServer.hpp(重点)🍎
#pragma once #include<iostream> #include <sys/types.h> /* See NOTES */ #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include<cstring> #include<string> #include<functional> using namespace std; using fun_t =function<string(string)>; class udpServer { public: const static uint16_t defaultPort=8848; udpServer(fun_t service=nullptr, uint16_t port =defaultPort) :_service(service) ,_port(port) {} void init() { //1 创建套接字,打开网络文件 _socket=socket(AF_INET,SOCK_DGRAM,0); if(_socket<0) { cerr<<"create socket fail"<<endl; exit(-1); } //2 bind sockaddr_in local; memset(&local,0,sizeof(local)); local.sin_family=AF_INET; local.sin_port=htons(_port); local.sin_addr.s_addr=INADDR_ANY; if(bind(_socket,(sockaddr*)&local,sizeof(local))<0) { cerr<<"bind fail"<<endl; exit(-2); } cout<<"bind success"<<endl; } void start() { char buffer[1024];//自定义缓冲区 while(true) { //1 从客户端收消息 sockaddr_in client;//用作输出型参数,用来接受是哪个具体的客户端发送数据给服务端的 socklen_t len=sizeof(client); int n=recvfrom(_socket,buffer,sizeof(buffer)-1,0,(sockaddr*)&client,&len); if(n>0) buffer[n]=0; else continue; // cout<<"receive message success"<<endl; string clientIp=inet_ntoa(client.sin_addr); uint16_t clientPort=ntohs(client.sin_port); cout<<clientIp<<"-"<<clientPort<<":"<<buffer<<endl; //2 处理消息 string message=_service(buffer); //3 发送消息给客户端 if(sendto(_socket,message.c_str(),message.size(),0,(sockaddr*)&client,sizeof(client))<0) { cerr<<"send message fail"<<endl; exit(-3); } //cout<<"send message success"<<endl; } } private: int _socket; uint32_t _port; fun_t _service; };
4.2.1 🍋注意事项🍋
- 1️⃣ 创建套接字所要的头文件是:
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h>
但是sockaddr_in是定义在下面的头文件中的:
#include <netinet/in.h> #include <arpa/inet.h>
所以我们写套接字编程的时候,这四个头文件都要带上。
- 2️⃣由于我们使用的是udp协议,所以我们使用的是
SOCK_DGRAM
,如果是tcp协议,我们使用的是SOCK_STREAM
。
- 至于第三个参数默认给0即可。
- 3️⃣在bind的时候我们由于类中成员并没有加上IP地址,所以我们使用下面这种写法:
4.3 🍎udpClient.cc🍎
#include"udpServer.hpp" //./udpClient serverIp serverPort void usage() { cout<<"Usage error\n\t"<<"serverIp serverPort"<<endl; exit(-1); } int main(int argc,char*args []) { if(argc!=3) { usage(); } string serverIp=args[1]; uint16_t serverPort=stoi(args[2]); //1 创建套接字 int sock=socket(AF_INET,SOCK_DGRAM,0); if(sock<0) { cout<<"create socket fail"<<endl; exit(-1); } //2 client要不要bind呢?要不要自己bind呢? //要bind 但是不要自己bind 操作系统会帮助我们做这件事情 // 2 明确server sockaddr_in server; memset(&server,0,sizeof(server)); server.sin_family=AF_INET; server.sin_port=htons(serverPort); server.sin_addr.s_addr=inet_addr(serverIp.c_str()); while(true) { //1 用户输入 string message; cout<<"[grm]:"; getline(cin,message); sendto(sock,message.c_str(),message.size(),0,(sockaddr*)&server,sizeof(server)); //2 接受服务端信息 char buffer[1024]; sockaddr_in tmp; socklen_t len=sizeof(tmp); int n=recvfrom(sock,buffer,sizeof(buffer)-1,0,(sockaddr*)&tmp,&len); if(n>0) { buffer[n]=0; cout<<buffer<<endl; } } return 0; }
4.3.1 🍋注意事项🍋
1️⃣在客户端这里,我们不难发现我们是没有自己手动bind的,为什么呢?
在这之前我们先要明确一点,就是客户端也是必须要bind的,这件事只不过是操作系统帮助我们做了。但是大家肯定又有一个疑问:为什么服务端我们要自己手动bind呀?
server的端口号要我们自己bind是因为服务器的端口号是众所周知的,且不能够随意改变;客户端不需要我们手动bind是因为害怕我们自己bind端口号时会发生冲突,所以这件事就交给了操作系统来帮助我们做。
2️⃣在明确服务端的时候我们使用了下面的接口函数:
这个函数有两个作用:
- 将字符串类型转化成四字节的uint32_t类型的四字节整数;
- 将主机序列转化成网络序列。
与这个函数具有同种功能的函数还有inet_aton
而上面的inet_ntoa
则是与inet_aton具有相反的功能。
除此之外,还有inet_pton
和inet_ntop
:
在这个系列的转换函数中不仅可以转换IPV4的地址,也可以转换IPV6的地址。
inet_ntoa这个函数返回了一个char*, 很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果. 那么是否需要调用者手动释放呢?
man手册上说, inet_ntoa函数, 是把这个返回结果放到了静态存储区. 这个时候不需要我们手动进行释放.
那么问题来了, 如果我们调用多次这个函数, 会有什么样的效果呢? 参见如下代码:
因为inet_ntoa把结果放到自己内部的一个静态存储区, 这样第二次调用时的结果会覆盖掉上一次的结果。
如果有多个线程调用 inet_ntoa, 是否会出现异常情况呢?在APUE中, 明确提出inet_ntoa不是线程安全的函数;但是在centos7上测试, 并没有出现问题, 可能内部的实现加了互斥锁;在多线程环境下, 推荐使用inet_ntop, 这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题。