目录
一、理解IP地址和端口号
端口号范围划分
理解"端口号"和"进程ID"
理解源端口号和目的端口号
理解socket
二、网络字节序
三、socket编程接口
1.常见API
创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器):
绑定端口号 (TCP/UDP, 服务器):
开始监听socket (TCP, 服务器):
接收请求 (TCP, 服务器):
建立连接 (TCP, 客户端):
2.sockaddr结构
四、UDP套接字
利用UDP套接字实现英译汉字典
recvfrom:
sendto:
dict.txt 单词表
服务端
Dict.hpp
UdpServer.hpp
客户端
Main.cc
五、TCP套接字
1.简单认识TCP协议
2.listen函数
3.accept函数
4.connect函数
5.通用TCP服务器
一、理解IP地址和端口号
要理解Socket编程,我们首先来看看IP地址和端口号。
IP协议目前有两个版本,分别是IPV4和IPV6,IP地址是在IP协议中用来标识网络中不同主机的地址。在TCP/IP五层模型中网络IP层的IP数据包中,有两个IP地址,一个是源IP地址,一个是目的IP地址。源IP地址指的是发送信息一方的IP地址,目的IP地址指的是接收信息一方的IP地址
但是这里要思考一个问题:数据传输到主机是目的吗?不是的。因为数据是给人用的。比如:聊天是人在聊天,下载是人在下载,浏览网页是人在浏览?但是人是怎么看到聊天信息的呢?怎么执行下载任务呢?怎么浏览网页信息呢?通过启动的qq,迅雷,浏览器。
而启动的qq,迅雷,浏览器都是进程。换句话说,进程是人在系统中的代表,只要把数据给进程,人就相当于就拿到了数据。
所以:数据传输到主机不是目的,而是手段。到达主机内部,在交给主机内的进程,才是目的。但是系统中,同时会存在非常多的进程,当数据到达目标主机之后,怎么转发给目标进程?这就要在网络的背景下,在系统中,标识主机的唯一性。这就是IP地址的作用。
而端口号(port)是传输层协议的内容,它用来标识一台主机上的一个进程的唯一性。端口号是2字节的16位整数,一个端口号只能被一个进程占用,端口号存在的意义是当对方主机将数据发送过来的时候,操作系统拿到这个数据之后通过端口号来确定要将数据交给哪一个进程来处理。
传输层协议(TCP和UDP)的数据段中也有两个端口号,一个是源端口号,一个是目的端口号,它们分别用来描述数据是谁发的以及数据要发给谁。
IP地址标定互联网中一台主机的唯一性,端口号标定一台主机中进程的唯一性,所以IP地址和端口号共同标定互联网中进程的唯一性。网络通信的本质也是进程间通信!
端口号范围划分
- 0-1023:知名端口号,HTTP,FTP,SSH等这些广为使用的应用层协议,他们的端口号都是固定的.
- 1024 -65535:操作系统动态分配的端口号.客户端程序的端口号,就是由操作系统从这个范围分配的.
理解"端口号"和"进程ID"
我们知道pid表示唯一一个进程,而端口号也表示的唯一一个进程,他们两者有什么区别?
举个很贴切的例子来说:
- PID相当于10086 客服人员的工号
- 端口号相当于10086 的对外服务电话号码
另外,一个进程可以绑定多个端口号;但是一个端口号不能被多个进程绑定
理解源端口号和目的端口号
传输层协议(TCP和 UDP)的数据段中有两个端口号,分别叫做源端口号和目的端口号.就是在描述"数据是谁发的,要发给谁"
理解socket
所以现在我们就清晰了,综上,IP地址用来标识互联网中唯一的一台主机,port用来标识该主机上唯一的一个网络进程
所以IP+Port就能表示互联网中唯一的一个进程。
所以,通信的时候,本质是两个互联网进程代表人来进行通信,
{srcIp,srcPort,dstIp,dstPort}这样的4元组就能标识互联网中唯二的两个进程
所以,网络通信的本质,也是进程间通信。我们把ip+port叫做套接字socket
二、网络字节序
内存中的多字节数据相对于内存地址有大端和小端之分,有些机器是大端序列(即低地址高字节),而有些机器是小端序列(即高地址低字节),由于不同机器的字节序不同,所以就会存在大端序列机器通过网络给小端序列机器发消息时出现数据错乱的问题。
为了解决这个问题,TCP/IP协议规定,网络数据流应采用大端字节序。
不管你的主机是大端序列机器还是小端序列机器,都要按照这个TCP/IP协议规定的网络字节序来发送或者接收数据。
如果你的主机是小端序列的机器,就需要先将数据转换成大端序列再进行网络发送,大端序列的机器可以忽略转换直接发送。
在接收数据的时候,如果你的主机是小端序列的机器,就需要先将网络接收下来的数据转成小端序列,这样你的机器才能正确读取数据。如果是大端序列的机器同样可以忽略转换直接读取。
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,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);这些函数名非常好理解,h表示host,n表示network,l表示32位长整数,s表示16位短整数。如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
三、socket编程接口
1.常见API
socket的API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6。socket网络
编程接口分为两类,一类是用来支持网络通信的,这种叫作网络套接字;还有一类是用来支持本地通信的,这种叫做域间套接字,也被称作双向管道。
创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器):
int socket(int domain, int type, int protocol);形参列表中
- domain表示的是socket的域,它用来标识是要进行网络通信还是本地通信;
- type表示的是套接字的类型,它用来标识我们通信的时候对应的报文类型,报文类型分为流式类型和用户数据报类型;
- protocol表示的是协议类型,网络通信时该参数设置为0即可。
- socket函数的返回值是int类型,如果创建socket成功则返回文件描述符 否则返回-1。
绑定端口号 (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);2.sockaddr结构
由于网络套接字需要一套接口多种用途,既要支持网络通信,也要支持本地通信,但是网络通信的接口和本地通信的接口所需要的参数数据肯定是不一样的,所以socket这一套接口的设计者就设计了一套抽象结构,也就是sockaddr结构:
如图他们一共设计了三个结构体,分别是sockaddr、sockaddr_in和sockaddr_un,其中sockaddr_in对应的是网络通信的结构,sockaddr_un对应的是本地通信的结构,它们的前16位用来标定地址类型,网络通信结构的地址类型是AF_INET,本地通信结构的地址类型是AF_UNIX。所以IPv4、IPv6地址类型分别定义为常数AF_INET、AF_INET6.
而sockaddr结构是设计出来的第三个结构,它用来统一sockaddr_in结构和sockaddr_un结构,在使用的时候我们先定义sockaddr_in结构或者sockaddr_un结构,然后强转成sockaddr结构,它会通过前16位的地址类型来确定这是要进行网络通信还是本地通信。如图:
网络通信必须有的两个数据分别是端口号和IP地址。在sockaddr_in中描述端口号的字段是sin_port,描述IP地址的字段是sin_addr。
IP地址在sockaddr_in的sin_addr字段中,而sin_addr其实也是一个结构体,它里面只封装了一个in_addr_t类型的变量,一般在设置IP地址的时候,我们将其设置成INADDR_ANY,这其实是一个宏,就是一个全0的数字,但这个宏有特殊的含义,它代表我们不关心会连接到哪一个IP地址,直接让操作系统帮我们连接到任意的IP地址,一般在写服务器的时候非常推荐将IP地址字段设置成INADDR_ANY。
云服务器禁止我们连接云服务器上的所有确定的IP地址,所以云服务器上只能使用INADDR_ANY填充IP字段。
四、UDP套接字
UDP协议的全称是User Datagram Protocol,即用户数据报协议,它是传输层协议,它的特点是无连接的,不可靠传输,以及面向数据报的传输。
在socket函数的形参type中,SOCK_DGRAM代表的就是UDP套接字。
利用UDP套接字实现英译汉字典
UDP套接字的网络通信采用的读取接口是recvfrom函数,发送接口是sendto函数:
recvfrom:
- int sockfd:传入创建好的socket文件描述符即可。
- void * buf:需要将读取上来的数据保存到这个buf中。
- size_t len:填写一次需要读取的大小。
- int flags:设置为0,阻塞式读取数据。
- struct sockaddr * src_addr:这是一个输入输出型参数,用来接收远端的sockaddr结构信息。
- socklen_t * addrlen:这也是一个输入输出型参数,用来接收远端sockaddr结构体的大小。
#include <sys/socket.h> ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);sendto:
- int sockfd:传入创建好的socket文件描述符即可。
- const void * buf:将buf中的内容发送到对端。
- size_t len:填写一次需要发送的大小。
- int flags:设置为0,阻塞式发送数据。
- struct sockaddr * src_addr:这是一个输入型参数,用来填写远端的sockaddr结构信息。
- socklen_t * addrlen:这也是一个输入型参数,用来填写远端sockaddr结构体的大小。
dict.txt 单词表
apple: 苹果banana: ⾹蕉 cat: 猫 dog: 狗 book: 书 pen: 笔 happy: 快乐的 sad: 悲伤的 run: 跑 jump: 跳 teacher: ⽼师 student: 学⽣ car: 汽⻋ bus: 公交⻋ love: 爱 hate: 恨 hello: 你好 goodbye: 再⻅ summer: 夏天 winter: 冬天服务端
UDP套接字服务端首先要做的是解决网络的问题,然后再来扩展业务。UDP套接字建立网络通信的步骤比较简单,首先是创建套接字,然后填充端口号、IP地址等网络信息,最后bind网络信息即可完成网络通信渠道的建立。
所以我们先将UdpServer的网络通信搭建起来,首先是构造函数我们需要传递进端口号和IP地址来初始化服务器的端口号与IP地址,由于云服务器是禁止我们bind任何IP地址的,所以我们给IP地址默认为空串,使用INADDR_ANY来填充IP地址字段。
然后是创建套接字和bind网络信息,在bind网络信息之前先要填充sockaddr结构,由于我们实现的是网络通信,所以应该使用sockaddr_in,填充sockaddr_in里的端口号、协议家族和IP地址之后,再将sockaddr_in类型强转成sockaddr类型,因为bind函数接口的参数只支持sockaddr类型。
Dict.hpp
#pragma once #include <iostream> #include <string> #include <fstream> #include <unordered_map> const std::string sep = ": "; class Dict { private: void LoadDict() { std::ifstream in(_confpath); if(!in.is_open()) { std::cerr << "open file error" << std::endl; // 后⾯可以⽤⽇志替代打 印 return; } std::string line; while(std::getline(in, line)) { if(line.empty()) continue; auto pos = line.find(sep); if(pos == std::string::npos) continue; std::string key = line.substr(0, pos); std::string value = line.substr(pos + sep.size()); _dict.insert(std::make_pair(key, value)); } in.close(); } public: Dict(const std::string &confpath):_confpath(confpath) { LoadDict(); } std::string Translate(const std::string &key) { auto iter = _dict.find(key); if(iter == _dict.end()) return std::string("Unknown"); else return iter->second; } ~Dict() {} private: std::string _confpath; std::unordered_map<std::string, std::string> _dict; };UdpServer.hpp
#pragma once // 系统头文件 #include <iostream> #include <string> #include <cerrno> #include <cstring> #include <unistd.h> #include <strings.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <functional> // 自定义头文件 #include "nocopy.hpp" #include "Log.hpp" #include "Comm.hpp" #include "InetAddr.hpp" // 默认配置常量 const static uint16_t default_port = 8888; // 默认监听端口 const static int default_fd = -1; // 无效文件描述符 const static int recv_buf_size = 1024; // 接收缓冲区大小 // 业务回调函数类型:输入请求,输出响应 using HandleFunc = std::function<void(const std::string& req, std::string* resp)>; // UDP服务器类(禁止拷贝) class UdpServer : public nocopy { public: // 构造:传入业务处理函数 + 自定义端口(默认8888) UdpServer(HandleFunc handle_func, uint16_t port = default_port) : _handle_func(handle_func), _port(port), _sock_fd(default_fd) {} // 初始化:创建socket + 绑定端口 void Init() { // 1. 创建UDP套接字(SOCK_DGRAM=UDP协议) _sock_fd = socket(AF_INET, SOCK_DGRAM, 0); if (_sock_fd < 0) { lg.LogMessage(Fatal, "socket create failed: %d - %s", errno, strerror(errno)); exit(Socket_Err); } lg.LogMessage(Info, "socket create success, fd: %d", _sock_fd); // 2. 初始化本地地址结构 struct sockaddr_in local_addr; bzero(&local_addr, sizeof(local_addr)); local_addr.sin_family = AF_INET; // IPv4协议 local_addr.sin_port = htons(_port); // 端口转网络字节序 local_addr.sin_addr.s_addr = INADDR_ANY; // 绑定所有本地网卡 // 3. 绑定套接字与地址 if (::bind(_sock_fd, (struct sockaddr*)&local_addr, sizeof(local_addr)) != 0) { lg.LogMessage(Fatal, "bind failed: %d - %s", errno, strerror(errno)); exit(Bind_Err); } } // 启动服务器:循环处理客户端请求 void Start() { lg.LogMessage(Info, "UDP server start, listen port: %d", _port); char recv_buf[recv_buf_size]; // 无限循环(服务器常驻) while (true) { struct sockaddr_in client_addr; // 客户端地址 socklen_t client_addr_len = sizeof(client_addr); // 接收客户端数据(获取数据 + 客户端地址) ssize_t recv_len = recvfrom( _sock_fd, recv_buf, sizeof(recv_buf) - 1, 0, (struct sockaddr*)&client_addr, &client_addr_len ); if (recv_len > 0) { recv_buf[recv_len] = '\0'; // 字符串结尾符 InetAddr client_addr_wrap(client_addr); // 封装地址为易读格式 std::cout << "[" << client_addr_wrap.PrintDebug() << "]# " << recv_buf << std::endl; std::string resp; _handle_func(recv_buf, &resp); // 调用业务逻辑处理请求 // 向客户端发送响应 sendto( _sock_fd, resp.c_str(), resp.size(), 0, (struct sockaddr*)&client_addr, client_addr_len ); } } } // 析构:关闭套接字(释放资源) ~UdpServer() { if (_sock_fd != default_fd) { close(_sock_fd); lg.LogMessage(Info, "socket fd: %d closed", _sock_fd); } } private: uint16_t _port; // 监听端口 int _sock_fd; // 套接字文件描述符 HandleFunc _handle_func;// 业务处理回调函数 };客户端
Main.cc
客户端与服务端不同的是,创建了套接字之后,客户端不需要bind网络信息,准确来说应该是不需要自己手动bind网络信息,让操作系统帮我们自动bind网络信息。非常不推荐自己手动bind网络信息,原因是如果我们的客户端在代码实现的地方就手动指定端口号,而其它客户端都采用操作系统指定的端口号,就有可能出现其它客户端先启动,并且操作系统给它指定的端口号跟我们客户端的端口号相同,那么我们的客户端就启动不了了。所以只有服务端要手动bind端口号,客户端只需要让操作系统帮我们自动bind端口号即可。
#include "UdpServer.hpp" #include "Comm.hpp" #include "Dict.hpp" #include <memory> // 命令行参数错误时打印用法 void Usage(std::string proc) { std::cout << "Usage : \n\t" << proc << " local_port\n" << std::endl; } // 全局字典对象:启动时加载字典文件 Dict gdict("./dict.txt"); // 业务回调:调用字典翻译请求 void Execute(const std::string &req, std::string *resp) { *resp = gdict.Translate(req); } // 入口:./udp_server 8888 int main(int argc, char *argv[]) { // 校验参数(需传入1个端口) if(argc != 2) { Usage(argv[0]); return Usage_Err; } // 端口字符串转数字 uint16_t port = std::stoi(argv[1]); // 智能指针创建UDP服务器(绑定业务回调+端口) std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(Execute, port); usvr->Init(); // 初始化:创建socket+绑定端口 usvr->Start(); // 启动服务:循环处理请求 return 0; }五、TCP套接字
1.简单认识TCP协议
TCP协议的全称是Transmission Control Protocol,即传输控制协议,它和UDP协议一样也是传输层协议,它的特点是有连接的,可靠传输,以及面向字节流传输。
在socket函数的形参type中,SOCK_STREAM代表的就是TCP套接字。
2.listen函数
TCP服务器需要通过listen函数将服务器设置成为监听状态,因为TCP协议是需要连接的,服务器设置成为监听状态是在等待客户端连接它。
int listen(int sockfd, int backlog);- int sockfd:socket文件描述符。
- int backlog:该参数表示底层的全连接队列的长度,当服务器没有accept的时候,操作系统最多能维护backlog+1个连接。
3.accept函数
在TCP服务器被设置成监听状态等待其它人来连接的时候,TCP服务器需要使用accept函数来获取连接。
- int sockfd:这个形参是一个socket套接字,它的核心工作是用来获取新的连接,所以它叫作监听套接字。
- struct sockaddr * addr:用来获取连接上的客户端的信息。
- socklen_t * addrlen:addr的大小。
- 返回值:如果连接成功,accept函数也会返回一个sockfd,与形参的sockfd不同的是,这是另一类socket套接字,它的核心工作是为用户提供网络服务,主要是进行IO。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);4.connect函数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);- int sockfd:填入客户端创建好的套接字。
- const struct sockaddr * addr:输入型参数,用来标定需要连接的服务器的网络信息。
- socklen_t addrlen:addr的大小。
5.通用TCP服务器
我们利用TCP套接字的编程接口写一个通用版本的TCP服务器,即只提供监听和获取网络连接,不提供其它任何服务,让浏览器暂时充当客户端,访问我们的服务器,测试是否能够连接成功:
UDP协议不是面向连接的,所以UDP服务器只需要创建套接字以后bind网络信息即可。TCP服务器在创建套接字和bind网络信息以后,还需要将TCP服务器设置成listen监听状态,只有设置监听状态才能等待客户端来连接。
当TCP服务器初始化完毕以后,就可以运行服务器了,服务器运行起来需要用accept函数来获取连接,如果此时没有客户端来连接服务器,它会继续循环重新获取连接,直到有人来连接为止。
#include <iostream> #include <string> #include <cstring> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> using namespace std; class TcpServer { public: TcpServer(int port, const string &ip = "") : _port(port), _ip(ip), _listenSock(-1) { } ~TcpServer() {} public: void init() { // 1.创建套接字 _listenSock = socket(AF_INET, SOCK_STREAM, 0); if(_listenSock < 0) { cerr << "socket error" << endl; exit(1); } cout << "socket success" << endl; // 2.bind // 2.1填充网络信息 sockaddr_in local; memset(&local, 0, sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(_port); local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); // 2.2bind网络信息 if(bind(_listenSock, (const sockaddr*)&local, sizeof(local)) < 0) { cerr << "bind error" << endl; exit(2); } cout << "bind success" << endl; // 3.listen if(listen(_listenSock, 5) < 0) { cerr << "listen error" << endl; exit(3); } cout << "listen success" << endl; } void start() { while(true) { // accept sockaddr_in peer; memset(&peer, 0, sizeof(peer)); socklen_t len = sizeof(peer); int serviceSock = accept(_listenSock, (sockaddr*)&peer, &len); // 如果没有获取连接成功,则继续重新获取 if(serviceSock < 0) { continue; } cout << "accept success" << endl; } } private: uint16_t _port; // 端口号 string _ip; // IP地址 int _listenSock; // 监听套接字 }; int main() { TcpServer svr(8080); svr.init(); svr.start(); return 0; }我们接下来就来示范一下:
云服务器默认会防火墙拦截端口,需要在云服务商控制台开放你用的端口:
- 登录云服务器控制台;
- 找到 “安全组” 配置,添加一条入站规则:
- 端口:
8080- 协议:
TCP- 授权对象:
0.0.0.0/0(允许所有 IP 访问)
我们再运行云服务器的代码看看
我们会发现浏览器在空转加载不出来,因为我们并未添加什么服务。
但我们在命令行后台可以看到,浏览器作为客户端已经成功连接我们的TCP服务器了,并且浏览器一般是多线程执行的,所以我们会看到连接了三次。