在IP数据包头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址。这两个IP地址表述了这个数据包是从哪里来的,并且要到那里去,并且这两个地址表示的是数据包最初出发的地址,以及最终到达的地址;而实际上数据包在传输过程中并不止有这两个地址,还会有许多中间站,而用于表示数据包的上一站和下一站是源MAC地址和目的MAC地址。
端口号(port)是传输层协议的内容:
传输层协议(TCP和UDP)的数据段中有两个端口号, 分别叫做源端口号和目的端口号。与源IP地址和目的IP地址类似,标识“数据是谁发的, 要发给谁”。
此处我们对udp和tcp协议有个直观的认识,后面详细讨论。
我们知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
先回顾一下何为大小端,对于一个整型,若高位在高地址处,低位在低地址处则为小端字节序;反之,若高位在低地址处,而低位在高地址处,则为大端字节序。
所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。
从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议栈进行交互的接口。
socket主要有以下三种类型:
SOCK_DGRAM
)SOCK_STREAM
)SOCK_RAM
)要通过互联网进行通信,至少需要一对套接字,其中一个运行于客户端,我们称之为 Client Socket
,另一个运行于服务器端,我们称之为 Server Socket
。
对于面向连接的协议,套接字之间的连接过程可以分为三个步骤:
由于socket编程的模式比较套路化,基本可以根据模板写出,因此,先将socket套接字编程熟练,可以更好的理解后续的udp/tcp协议及网络传输原理。
// 创建 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);
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
结构体,就可以根据地址类型字段确定结构体中的内容。struct sockaddr *
类型表示, 在使用的时候需要强制转化成sockaddr*
; 这样的好处是程序的通用性, 可以接收IPv4, IPv6
, 以及UNIX Domain Socket各种类型的sockaddr
结构体指针做为参数。
前者为16位的地址类型,后14字节位地址路径。
虽然socket api的接口是sockaddr, 但是我们真正在基于IPv4编程时, 使用的数据结构是sockaddr_in; 这个结构体中出了16位的地址类型,还包含端口号和IP地址,以及8字节填充内容。
in_addr
用来表示一个IPv4的IP地址. 其实就是一个32位的整数。
由于udp协议是无连接的,因此udp的server与client不需要构建连接,直接客户端发出请求,服务器收到请求并处理。
//udp server.cc
#include
#include
#include
#include
#include
#include
#include
#include
#include
using std::cerr;
using std::cout;
using std::endl;
void Usage(std::string proc)
{
cout << "Usage:\n\t" << proc << "port" << endl;
}
// ./server 8080
// proc port
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
//1.创建套接字文件描述符
int sock = socket(AF_INET, SOCK_DGRAM, 0);//udp: IPv4, 数据报
//2.描述套接字端口号,IP地址信息
struct sockaddr_in local;
bzero(&local, sizeof(local));//初始化为0
local.sin_family = AF_INET;//16位地址类型
local.sin_port = htons(atoi(argv[1]));//16位端口号,主机转网络
local.sin_addr.s_addr = htons(INADDR_ANY);//IP地址使用INADDR_ANY,表示不绑定具体的IP地址,这样可以bind机器上的所有IP
//3.bind socket与sockaddr_in结构体
if(bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
cerr << "bind error" << endl;
return 2;
}
//4.启动服务,收发消息
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)
{
//success
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
接口:
//udp client.cc
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
using std::cout;
using std::cerr;
using std::endl;
void Usage(std::string proc)
{
cout << "Usage\n\t" << proc << "dest_ip" << "dest_port" << endl;
}
// ./client 127.0.0.1 8080
// proc IP port
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
return 1;
}
int sock = socket(AF_INET, SOCK_DGRAM, 0);//创建客户端套接字
//client不需要我们自己去bind,实际上在sendto的时候,操作系统会自动随机给client bind端口号
//绑定端口号和ip 确定具体某台主机上的某个进程,描述服务器端的ip和端口号
struct sockaddr_in desc;
bzero(&desc, sizeof(desc));
desc.sin_family = AF_INET;
desc.sin_port = htons(atoi(argv[2]));//绑定端口号
//用户端需要bind具体的ip地址,这样才能够连接到对应的服务器端
//ip本质上可以由4个字节保存 127.0.0.1 点分十进制,这是我们习惯的写法
//但对于计算机而言,更希望看到的是32位的整型ip地址
desc.sin_addr.s_addr = inet_addr(argv[1]);//inet_addr函数作用就是将点分十进制的ip地址转换为无符号的长整型
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;// 将\n吸收
//向对端发送数据,udp是无连接的,因此客户端直接向服务端发送请求
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);//peer与len暂且不用,仅作为接收使用
buf[size] = 0;
cout << buf << endl;
}
}
close(sock);
return 0;
}
客户端需要注意的点有:
本文只介绍基于IPv4的socket网络编程,sockaddr_in
中的成员struct in_addr sin_addr
表示32位 的IP 地址
但是我们通常用点分十进制的字符串表示IP 地址,以下函数可以在字符串表示 和in_addr
表示之间转换;
这里bind的ip为127.0.0.1,表示本地环回,即数据包绕本地一圈后到回来,用于测试udp协议的实现,可以看见,client向server发送的数据都被接收到并返回应答。
这里我们实现一个简易字典的服务器功能。
//tcp_server.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
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);//tcp: IPv4 流套接字
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);//ip地址,使用INADDR_ANY,可以bind 机器上任意一个ip
//bind 套接字 端口号与ip
if(bind(listen_sock, (sockaddr*)&local, sizeof(local)) < 0)
{
cerr << "bind error" << endl;
exit(3);
}
//监听,tcp是面向连接的,服务器监听等待客户端来发起连接
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协议是面向连接的,因此在双方进行通信之前,需要先建立连接,即server需要监听来自client的连接请求,因此在listen之前的套接字为监听套接字,而建立了连接之后用于获取对端消息的套接字才是和udp中作用一样的套接字。如何理解呢?
可以这么说,我们日常去饭店吃饭时,门口会站着揽客的服务员,而这些揽客的服务员拉到客人后,就交由饭店内的服务员来招待;而这里的listen_sock
就类比作门口揽客的服务员,连接建立好后的sock就是招待的服务员。
//server.cc
#include "tcp_server.hpp"//提供网络连接功能
#include "handler.hpp"//提供处理网络套接字的功能
void Usage(std::string proc)
{
cout << "Usage:\n\t" << proc << " port" << endl;
}
// ./tcp_server 8080
// proc port
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;
}
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
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);// IPv4 流套接字
if(sock < 0)
{
cerr << "socket error" << endl;
exit(2);
}
//client 无需主动bind, connect时OS会自动进行相关的bind
//但是client需要connect,发起与服务器的连接
}
void Start()
{
//发起连接,填充对端服务器的socket信息
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());//ip
//发起连接请求
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)
{
//read success
buf[s - 1] = 0;//吸收'\n'
//向对端发送消息
send(sock, buf, strlen(buf), 0);
ssize_t size = recv(sock, buf, sizeof(buf), 0);//从对端接收消息
if(size > 0)
{
//recv success
buf[size] = 0;
cout << buf << endl;
}
else
{
cout << "server close..." << endl;
break;
}
}
}
}
~TcpClient() {if(sock >= 0) close(sock);}
};
}
同样的,对于client,在与服务器进行通信之前,需要发起连接请求,即:
通过传入描述服务器的套接字信息,OS会自动为client bind相关的信息,同时向套接字描述的对象发起连接请求。
#include
#include
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//client.cc
#include "tcp_client.hpp"
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " desc_ip desc_port" << std::endl;
}
// ./client 127.0.0.1 8080
// proc desc_ip desc_port
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;
}
这里我们实现3个版本的回调函数方案,但第一种方案不能处理多连接请求。
#pragma once
#include "tcp_server.hpp"
#include
#include
#include
#include
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];
//从sock套接字文件描述符获取请求
ssize_t s = recv(sock, buf, sizeof(buf), 0);//这里后面会介绍,其实是应用层从传输层的缓冲区将客户端发来的消息拷贝到应用层
if(s > 0)
{
//recv success
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;
}
/*if(s > 0)
{
//recv success
buf[s] = 0;
cout << "client#" << buf << endl;
std::string echo_message = buf;
if(echo_message == "quit")
{
cout << "client quit..." << endl;
break;
}
echo_message += " server_received\n";
//向对端发送数据(回写)
send(sock, echo_message.c_str(), echo_message.size(), 0);
}
else if(s == 0)
{
//对端链接关闭
break;
}
else
{
cerr << "recv errno" << " errno: " << errno << endl;
//读取失败
break;
}*/
}
}
void Handler_V1(int sock)
{
//version1:单执行流
Handler(sock);
}
void Handler_V2(int sock)
{
//version2:多进程版本
if(fork() == 0)
{
//child
if(fork() == 0)
{
//grandchild
Handler(sock);//子进程退出,孙子进程成为孤儿进程被OS领养,因此无需wait该进程
}
else
{
exit(0);
}
}
//father 为了防止父进程阻塞式的等待子进程结束,这里让子进程创建孙子进程
//然后孙子进程回调函数,子进程退出,孙子进程成为孤儿进程,从而无需关心其退出状态
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/*this*/);
}
}
对于第一种单执行流的版本,我们通过多个客户端去连接发送消息:
对于第二种和第三种方案,则是通过多进程和多线程的方法来保证服务器可以同时处理多个请求的情况。
其次,在多进程版本下,如果仅仅是让服务器作为父进程创建子进程,那么父进程阻塞式等待子进程退出时也不能处理其他请求,因此这里采取一种取巧的方法,即让子进程创建孙子进程去执行请求,子进程立即退出,就可以避免父进程阻塞,同时孙子进程成为孤儿进程,无需担心其成为僵尸进程。
但实际上,多进程的资源消耗比较大,因此采用多线程是更加高效的,不过需要注意的是,多线程中可能存在传参的问题,即栈上的参数传入函数后,导致变量丢失,这里暂时处理为在堆上开辟空间。另外,线程执行回调函数结束后需要关闭文件描述符,防止文件描述符泄露(前面没有关闭文件描述符是因为进程结束后文件描述符自动释放了,而线程结束文件描述符不会释放,导致这些文件描述符无法再被利用,从而泄露)。
但其实多线程版本也存在缺陷,就是服务器永不结束,那么这样创建的线程是无上限的,一旦请求太多,可能出现严重的问题,因此这里最佳方案是线程池版本,如果有兴趣可以自己实现一下。
其实通过上面的内容就大致了解tcp与udp的区别了: