💡预备知识
?源IP地址与目的IP地址
在IP数据包头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址。这两个IP地址表述了这个数据包是从哪里来的,并且要到那里去,并且这两个地址表示的是数据包最初出发的地址,以及最终到达的地址;而实际上数据包在传输过程中并不止有这两个地址,还会有许多中间站,而用于表示数据包的上一站和下一站是源MAC地址和目的MAC地址。
?端口号与进程id
端口号(port)是传输层协议的内容:
- 端口号是一个2字节16位的整数;
- 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
- IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
- 一个端口号只能被一个进程占用; 但一个进程可以绑定多个端口号。
?源端口号与目的端口号
传输层协议(TCP和UDP)的数据段中有两个端口号, 分别叫做源端口号和目的端口号。与源IP地址和目的IP地址类似,标识“数据是谁发的, 要发给谁”。
?UDP与TCP协议
此处我们对udp和tcp协议有个直观的认识,后面详细讨论。
- UDP协议(User Datagram Protocol 用户数据报协议)
udp协议的特点是:面向数据报、无连接、不可靠的传输层协议。 - TCP协议(Transmission Control Protocol 传输控制协议)
tcp协议的特点是:面向字节流、有连接、可靠的传输层协议。
?网络字节序
我们知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢? 先回顾一下何为大小端,对于一个整型,若高位在高地址处,低位在低地址处则为小端字节序;反之,若高位在低地址处,而低位在高地址处,则为大端字节序。
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
- 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
- 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节. 为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换: 这些函数名比较好记,比如h表示主机(host),n表示网络(network),l表示32位长整型(long int),s表示16位短整型(short int)。htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
💡套接字(socket)介绍
?概念
所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。 从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议栈进行交互的接口。
?分类
socket主要有以下三种类型:
- 1.数据报套接字(
SOCK_DGRAM ) 数据报套接字提供一种无连接的服务。该服务并不能保证数据传输的可靠性,数据有可能在传输过程中丢失或出现数据重复,且无法保证顺序地接收到数据。数据报套接字使用UDP( User DatagramProtocol)协议进行数据的传输。由于数据报套接字不能保证数据传输的可靠性,对于有可能出现的数据丢失情况,需要在程序中做相应的处理。 - 2.流套接字(
SOCK_STREAM ) 流套接字用于提供面向连接、可靠的数据传输服务。该服务将保证数据能够实现无差错、无重复送,并按顺序接收。流套接字之所以能够实现可靠的数据服务,原因在于其使用了传输控制协议,即TCP(The Transmission Control Protocol)协议。 - 3.原始套接字(
SOCK_RAM ) 原始套接字与标准套接字(标准套接字指的是前面介绍的流套接字和数据报套接字)的区别在于:原始套接字可以读写内核没有处理的IP数据包,而流套接字只能读取TCP协议的数据,数据报套接字只能读取UDP协议的数据。因此,如果要访问其他协议发送的数据必须使用原始套接字。
?工作流程
要通过互联网进行通信,至少需要一对套接字,其中一个运行于客户端,我们称之为 Client Socket ,另一个运行于服务器端,我们称之为 Server Socket 。 对于面向连接的协议,套接字之间的连接过程可以分为三个步骤:
- 1.服务器监听
- 2.客户端请求
- 3.确认连接
💡socket编程
由于socket编程的模式比较套路化,基本可以根据模板写出,因此,先将socket套接字编程熟练,可以更好的理解后续的udp/tcp协议及网络传输原理。
?socket编程接口
int socket(int domain, int type, int protocol);
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
int listen(int socket, int backlog);
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
?sockaddr结构体
socket的API是一层抽象的网络编程接口,适用于各种底层网络协议,比如IPv4、IPv6,以及后续的Unix Domain Socket。然而各种网络协议的地址格式并不相同,因此我们需要用一个sockaddr结构体来描述对应的网络协议,区分地址类型,并且描述其端口号与IP地址。
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* ; 这样的好处是程序的通用性, 可以接收IPv4, IPv6 , 以及UNIX Domain Socket各种类型的sockaddr 结构体指针做为参数。 - 这里为什么不用void来强转的原因是最初网络提出socket套接字编程时还没有void类型,因此都是直接强制类型转换成sockaddr来使用接口的,而如果要替换为void,那么成本太大了。
🎄sockaddr结构体
前者为16位的地址类型,后14字节位地址路径。
🎄sockaddr_in结构体
虽然socket api的接口是sockaddr, 但是我们真正在基于IPv4编程时, 使用的数据结构是sockaddr_in; 这个结构体中出了16位的地址类型,还包含端口号和IP地址,以及8字节填充内容。
🎄in_addr结构体
in_addr 用来表示一个IPv4的IP地址. 其实就是一个32位的整数。
💡socket编程应用
?基于udp协议的socket编程
由于udp协议是无连接的,因此udp的server与client不需要构建连接,直接客户端发出请求,服务器收到请求并处理。
🎄服务端server_udp
#include <iostream>
#include <string>
#include <strings.h>
#include <unistd.h>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using std::cerr;
using std::cout;
using std::endl;
void Usage(std::string proc)
{
cout << "Usage:\n\t" << proc << "port" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
int sock = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(atoi(argv[1]));
local.sin_addr.s_addr = htons(INADDR_ANY);
if(bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
cerr << "bind error" << endl;
return 2;
}
char buf[1024];
while(true)
{
buf[0] = 0;
sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t s = recvfrom(sock, buf, sizeof(buf) - 1, 0, (struct sockaddr*)&peer, &len);
if(s > 0)
{
buf[s] = 0;
cout << "client#" << buf << endl;
std::string echo_message = buf;
echo_message += " server received";
sendto(sock, echo_message.c_str(), echo_message.size(), 0, (struct sockaddr*)&peer, len);
}
}
close(sock);
return 0;
}
这里需要注意,udp的服务端需要主动bind端口号与ip,但是这里bind的ip最好不要是具体的ip,因为一旦服务器bind的ip被占用,服务器就挂了,因此bind使用INADDR_ANY ,可以bind云服务器的所有ip。 其次,这里介绍一下recvfrom 接口:
🎄客户端client_udp
#include <iostream>
#include <cstdlib>
#include <cstdio>
#include <unistd.h>
#include <string>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using std::cout;
using std::cerr;
using std::endl;
void Usage(std::string proc)
{
cout << "Usage\n\t" << proc << "dest_ip" << "dest_port" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
return 1;
}
int sock = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in desc;
bzero(&desc, sizeof(desc));
desc.sin_family = AF_INET;
desc.sin_port = htons(atoi(argv[2]));
desc.sin_addr.s_addr = inet_addr(argv[1]);
char buf[1024];
while(true)
{
buf[0] = 0;
cout << "Please Enter#";
fflush(stdout);
ssize_t s = read(0, buf, sizeof(buf) - 1);
if(s > 0)
{
buf[s - 1] = 0;
sendto(sock, buf, sizeof(buf) - 1, 0, (struct sockaddr*)&desc, sizeof(desc));
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t size = recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr*)&peer, &len);
buf[size] = 0;
cout << buf << endl;
}
}
close(sock);
return 0;
}
客户端需要注意的点有:
- 由于IP是点分十进制的传参,因此需要调用inet_ntoa转换为32为长整型。
🎄地址转换函数
本文只介绍基于IPv4的socket网络编程,sockaddr_in 中的成员struct in_addr sin_addr 表示32位 的IP 地址 但是我们通常用点分十进制的字符串表示IP 地址,以下函数可以在字符串表示 和in_addr 表示之间转换;
- 客户端无需主动bind端口号与ip,在向服务端发起请求调用sendto函数时OS会自动随机的为client去bind端口号与ip。这是因为不会有其他主机端口号来连接客户端,因此客户端无需自己有具体的ip与port,相反如果我们主动bind的话,还可能会bind失败。
🎄结果展示
这里bind的ip为127.0.0.1,表示本地环回,即数据包绕本地一圈后到回来,用于测试udp协议的实现,可以看见,client向server发送的数据都被接收到并返回应答。
?基于tcp协议的socket编程
这里我们实现一个简易字典的服务器功能。
🎄服务端tcp_server
#pragma once
#include <iostream>
#include <cstdlib>
#include <cstdio>
#include <string>
#include <strings.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using std::cout;
using std::cerr;
using std::endl;
namespace ns_TcpServer
{
typedef void(*handler_t)(int);
const int backlog = 5;
class TcpServer
{
private:
uint16_t port;
int listen_sock;
public:
TcpServer(uint16_t _port)
:port(_port)
,listen_sock(-1)
{}
void InitTcpServer()
{
listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if(listen_sock < 0)
{
cerr << "socket error" << endl;
exit(2);
}
sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = htonl(INADDR_ANY);
if(bind(listen_sock, (sockaddr*)&local, sizeof(local)) < 0)
{
cerr << "bind error" << endl;
exit(3);
}
if(listen(listen_sock, backlog) < 0)
{
cerr << "listen error" << endl;
exit(4);
}
}
void Loop(handler_t handler)
{
while(true)
{
sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (sockaddr*)&peer, &len);
if(sock < 0)
{
cout << "warning:accept error" << endl;
continue;
}
handler(sock);
}
}
~TcpServer(){if(listen_sock >= 0) close(listen_sock);}
};
}
🌕tcp是面向连接的–listen_sock介绍
由于tcp协议是面向连接的,因此在双方进行通信之前,需要先建立连接,即server需要监听来自client的连接请求,因此在listen之前的套接字为监听套接字,而建立了连接之后用于获取对端消息的套接字才是和udp中作用一样的套接字。如何理解呢? 可以这么说,我们日常去饭店吃饭时,门口会站着揽客的服务员,而这些揽客的服务员拉到客人后,就交由饭店内的服务员来招待;而这里的listen_sock 就类比作门口揽客的服务员,连接建立好后的sock就是招待的服务员。
#include "tcp_server.hpp"
#include "handler.hpp"
void Usage(std::string proc)
{
cout << "Usage:\n\t" << proc << " port" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
}
uint16_t port = atoi(argv[1]);
ns_TcpServer::TcpServer* svr = new ns_TcpServer::TcpServer(port);
svr->InitTcpServer();
svr->Loop(ns_handler::Handler_V1);
return 0;
}
🎄客户端tcp_client
#pragma once
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <string>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using std::cout;
using std::cerr;
using std::endl;
namespace ns_TcpClient
{
class TcpClient
{
private:
std::string dest_ip;
uint16_t dest_port;
int sock;
public:
TcpClient(std::string _ip, uint16_t _port)
:dest_ip(_ip)
,dest_port(_port)
,sock(-1)
{}
void InitClient()
{
sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
cerr << "socket error" << endl;
exit(2);
}
}
void Start()
{
sockaddr_in peer;
bzero(&peer, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(dest_port);
peer.sin_addr.s_addr = inet_addr(dest_ip.c_str());
if(connect(sock, (sockaddr*)&peer, sizeof(peer)) == 0)
{
cout << "connect successfully..." << endl;
}
else
{
cout << "connect failed" << endl;
exit(3);
}
while(true)
{
char buf[1024];
cout << "Please Enter#";
fflush(stdout);
ssize_t s = read(0, buf, sizeof(buf) - 1);
if(s > 0)
{
buf[s - 1] = 0;
send(sock, buf, strlen(buf), 0);
ssize_t size = recv(sock, buf, sizeof(buf), 0);
if(size > 0)
{
buf[size] = 0;
cout << buf << endl;
}
else
{
cout << "server close..." << endl;
break;
}
}
}
}
~TcpClient() {if(sock >= 0) close(sock);}
};
}
🌕tcp是面向连接的–connect介绍
同样的,对于client,在与服务器进行通信之前,需要发起连接请求,即: 通过传入描述服务器的套接字信息,OS会自动为client bind相关的信息,同时向套接字描述的对象发起连接请求。
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
#include "tcp_client.hpp"
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " desc_ip desc_port" << std::endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
}
std::string dest_ip = argv[1];
uint16_t dest_port = atoi(argv[2]);
ns_TcpClient::TcpClient cli = ns_TcpClient::TcpClient(dest_ip, dest_port);
cli.InitClient();
cli.Start();
return 0;
}
🎄回调函数–处理sock带来的请求
这里我们实现3个版本的回调函数方案,但第一种方案不能处理多连接请求。
#pragma once
#include "tcp_server.hpp"
#include <errno.h>
#include <sys/wait.h>
#include <pthread.h>
#include <unordered_map>
std::unordered_map<std::string, std::string> dict = {
{"sort", "排序"},
{"left", "左边"},
{"map", "地图,映射"},
{"free", "自由,免费"},
{"comfortable", "舒服的"}
};
namespace ns_handler
{
void Handler(int sock)
{
while(true)
{
char buf[1024];
ssize_t s = recv(sock, buf, sizeof(buf), 0);
if(s > 0)
{
buf[s] = 0;
cout << "client#" << buf << endl;
std::string word = buf;
auto ret = dict.find(word);
std::string echo_message = "I don't know.";
if(ret != dict.end())
{
echo_message = ret->second;
}
send(sock, echo_message.c_str(), echo_message.size(), 0);
cout << "server#" << echo_message << endl;
}
}
}
void Handler_V1(int sock)
{
Handler(sock);
}
void Handler_V2(int sock)
{
if(fork() == 0)
{
if(fork() == 0)
{
Handler(sock);
}
else
{
exit(0);
}
}
waitpid(-1, nullptr, 0);
}
void* rountinue(void* args)
{
int sock = *(int*)args;
delete (int*)args;
pthread_detach(pthread_self());
Handler(sock);
close(sock);
return nullptr;
}
void Handler_V3(int sock)
{
pthread_t pid;
int* p = new int(sock);
pthread_create(&pid, nullptr, rountinue, p);
}
}
对于第一种单执行流的版本,我们通过多个客户端去连接发送消息: 对于第二种和第三种方案,则是通过多进程和多线程的方法来保证服务器可以同时处理多个请求的情况。 其次,在多进程版本下,如果仅仅是让服务器作为父进程创建子进程,那么父进程阻塞式等待子进程退出时也不能处理其他请求,因此这里采取一种取巧的方法,即让子进程创建孙子进程去执行请求,子进程立即退出,就可以避免父进程阻塞,同时孙子进程成为孤儿进程,无需担心其成为僵尸进程。 但实际上,多进程的资源消耗比较大,因此采用多线程是更加高效的,不过需要注意的是,多线程中可能存在传参的问题,即栈上的参数传入函数后,导致变量丢失,这里暂时处理为在堆上开辟空间。另外,线程执行回调函数结束后需要关闭文件描述符,防止文件描述符泄露(前面没有关闭文件描述符是因为进程结束后文件描述符自动释放了,而线程结束文件描述符不会释放,导致这些文件描述符无法再被利用,从而泄露)。 但其实多线程版本也存在缺陷,就是服务器永不结束,那么这样创建的线程是无上限的,一旦请求太多,可能出现严重的问题,因此这里最佳方案是线程池版本,如果有兴趣可以自己实现一下。
?tcp与udp对比
其实通过上面的内容就大致了解tcp与udp的区别了:
- tcp是面向连接的,而udp是无连接的。
- tcp是字节流套接字,而udp是数据报套接字。
- tcp是可靠的,而udp是不可靠的。
这里需要提一下的是,tcp的可靠和udp的不可靠属于中性词,并非说tcp就比udp要好;其次tcp的可靠性保证是有代价的,那么udp就会在性能上优于tcp,比如直播时不一定要保证画面的精确传输,那么使用udp可以是延迟没那么大。当然tcp的性能也并不是完全劣于udp的,这里我们后面介绍传输层时会具体谈的。 总的来说,网络套接字编程内容大概就是这样了,由于套接字编程相对套路化,因此多写才能更加熟练并深入理解。
|