网络编程基础
网络知识基础
网络是如何构建的
我们知道,网络可以跨地域进行主机间通信,使多个主机之间完成数据共享。对于一小块局域网,想实现网络通信并不困难,但对于长距离网络通信,解决网络传输问题就成了关键
- 网络通信长距离传输,数据包丢失怎么办?
- 如何定位要发送的主机?
- 怎么进行数据转发,如何选择路径(路由)?
- 每个网络硬件生产厂商生产出来的硬件各不相同怎么办?
这些不同性质,不同模块的问题,需要我们在设计时采用分层的结构,根据不同性质的问题,策划不同的解决方案,让解决方案 “高内聚,低耦合!” ,于是就有了网络层状结构!
对于不同的层面,网络双方需要进行 “协商”,约定好数据如何转发,而操作系统有很多种,计算机的生产厂商有很多种,网络硬件设备也有很多种,如何让这些不同生产厂商生成的计算机进行通信,就需要制定一个协议标准,这就是网络协议!
网络协议层状结构
OSI七层模型
TCP/IP协议
TCP/IP通信协议采用了五层层级结构(不过物理层一般不考虑,所以也可以算作四层),自顶向下完成网络需求
- 物理层: 负责光/电信号的传递方式. 比如现在以太网通用的网线(双绞 线)、早期以太网采用的的同轴电缆(现在主要用于有线电视)、光纤, 现在的wifi无线网使用电磁波等都属于物理层的概念。物理层的能力决定了最大传输速率、传输距离、抗干扰性等. 集线器(Hub)工作在物理层
- 数据链路层: 负责设备之间的数据帧的传送和识别. 例如网卡设备的驱动、帧同步(就是说从网线上检测到什么信号算作新帧的开始)、冲突检测(如果检测到冲突就自动重发)、数据差错校验等工作. 有以太
网、令牌环网, 无线LAN等标准. 交换机(Switch)工作在数据链路层 - 网络层: 负责地址管理和路由选择. 例如在IP协议中, 通过IP地址来标识一台主机, 并通过路由表的方式规划出两台主机之间的数据传输的线路(路由). 路由器(Router)工作在网路层
- 传输层: 负责两台主机之间的数据传输. 如传输控制协议 (TCP), 能够确保数据可靠的从源主机发送到目标主机
- 应用层: 负责应用程序间沟通,如简单电子邮件传输(SMTP)、文件传输协议(FTP)、网络远程访问协议(Telnet)等. 我们的网络编程主要就是针对应用层.
传输层,网路层,数据链路层共同组成了网络协议栈!(一般不考虑物理层)
网络传输流程
- 数据的发送:自顶(应用层)向下发送
- 数据的接收:自底(硬件层)向上接收
数据包的封装和分用
- 不同的协议层都要将自己的数据包进行封装,而封装实际上就是将数据(有效载荷) 以及 该层协议(报头) 进行组合
- 将组合好的数据包转发给下一层,下一层继续封装
- 接收端每一个网络协议层读取数据包时,将报头和有效载荷分离,将有效载荷交付给上层,上层继续执行该过程,直到应用层成功接收数据,这就是数据包的分用
网络的地址管理
IP地址
IP协议有两个版本,IPv4和IPv6,默认情况下我们都是指IPv4
- IP地址是在IP协议中, 用来标识网络中不同主机的地址
- 对于IPv4来说,IP地址是一个4字节,32位的整数
- 通常也使用 “点分十进制” 的字符串表示IP地址,例如 192.168.0.1用点分割的每一个数字表示一个字节,范围是 0 - 255;
MAC地址
- MAC地址用来识别数据链路层中相连的节点
- 长度为48位,即6个字节, 一般用16进制数字加上冒号的形式来表示(例如: 08:00:27:03:fb:19)
- 在网卡出厂时就确定了,不能修改,mac地址通常是唯一的(虚拟机中的mac地址不是真实的mac地址,可能会冲突,也有些网卡支持用户配置mac地址)
网络编程
网络端口号
端口号(port)是传输层的协议内容
- 端口号是一个2字节16位的整数
- 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪个进程来处理
- IP地址 + 端口号能够标识网络上的某一台主机的某一个进程
- 一个端口号只能被一个进程占用
一个进程可以绑定多个端口号,但是一个端口号只能被一个进程绑定
源目IP地址和源目端口号
我们知道,网络通信其实本质就是进程通信,而在网络中,我们用IP地址 + 端口号标识某一台主机中的某一个进程,所以在网络通信中,发送端的进程需要有自己的源IP地址和源端口号,接收端进程需要有自己的目的IP地址和目的端口号
UDP协议和TCP协议
在网络传输层有两个常用协议,UDP协议和TCP协议,他们两个分别有自己的特点,这里先简单说明,之后也会详细介绍
- UDP
- 无连接
- 不可靠传输
- 面向数据报
- TCP
- 有连接
- 可靠传输
- 面向字节流
网络字节序
我们知道,在内存中,数据存储分为大端字节序和小端字节序,在网络中数据流也同样分大小端,在将本地主机的数据转发到网络中时,我们不清楚大小端是否匹配,因此我们要将主机字节序转换为网络字节序
通过以下函数进行转换
#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位长整数(long),s表示16位短整数(short)
- 如果我们要进行网络发送,就用通过htonl或htons函数将主机字节序转换为网络字节序
- 然后通过ntohl或ntohs进行网络数据接收
套接字(Socket)
在网络通信中,我们用IP地址 + 端口号标识某一台主机中的某一个进程,而IP地址和端口号就共同组成了套接字(Socket)!
socket接口
// 创建 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);
sockaddr结构
sockaddr和sockaddr_in用来存储网络通信中的地址
sockaddr
struct sockaddr
{
__SOCKADDR_COMMON (sa_); /* Common data: address family and length. */
char sa_data[14]; /* Address data. */
};
sockaddr_in
#define __SOCKADDR_COMMON(sa_prefix) \
sa_family_t sa_prefix##family
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
in_port_t sin_port; /* Port number. */
struct in_addr sin_addr; /* Internet address. */
/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr) -
__SOCKADDR_COMMON_SIZE -
sizeof (in_port_t) -
sizeof (struct in_addr)];
};
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
- sockaddr_in中sin_family字段(第一个宏字段)表示地址类型,IPv4为AF_INET,IPv6为AF_INET6
- sin_port和sin_addr都必须是网络字节序,因此必须将主机字节序进行转换
IP地址和端口号的转换方法
#include <arpa/inet.h>
// port转换方法(前面已经提过)
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
// ip转换方法
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// inet_aton是将ip地址字符串(点分十进制)
// 和sockaddr_in.sin_addr结构体作为参数传入
int inet_aton(const char *cp, struct in_addr *inp);// 常用
// inet_addr直接将ip地址字符串(点分十进制)传入,并返回ip地址的网络字节序
in_addr_t inet_addr(const char *cp);// 常用
int inet_pton(int family, const char* strptr, void *addrptr)
// 以上是字符串转in_addr的函数
char *inet_ntoa(struct in_addr in);
const char *inet_ntop(int af, const void *src,char *dst, socklen_t size);
// 以上是in_addr转字符串的函数
- sockaddr_in解决了sockaddr的缺陷,它将IP地址和端口号进行了分离
- sockaddr和sockaddr_in长度相同,而socket的相关接口中,参数类型都是sockaddr类型,因此我们通常使用sockaddr_in作为函数参数时,只需要进行强制类型转换就可以了(C实现多态)
简单实现UDP服务器
// 网络四件套
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <strings.h>
class UdpServer
{
public:
UdpServer()
{
}
void InitServer() // 初始化服务器
{
// 1.创建socket
// type(第二个参数) -- SOCK_DGRAM表示UDP
sock_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sock_ < 0)
{
exit(-1); // socket创建失败直接退出
}
// 给服务器指明IP和port
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 使用sockaddr_in之前最好先清空
local.sin_family = AF_INET;
// 服务器不需要指明一个确定的IP,因此将IP地址字段设置为INADDR_ANY
// 如果是服务端
// local.sin_addr_s.addr = inet_addr(serverip)
// or inet_aton(serverip, &server.sin_addr) .的优先级比&高
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(port_); // 要将主机字节序转为网络字节序!
// 2.绑定(bind)到内核中
int n = bind(sock_, (struct sockaddr *)&local, sizeof(local)); // 将sockaddr_in强转成sockaddr
if (n < 0)
{
exit(-1); // bind失败直接退出
}
}
void Start()
{
// 服务器运行 -> 接收数据
while (true) // 服务器是一直运行的
{
char buffer[1024];
// 3.接收数据
// 接收数据,就要知道对方的socket,因此需要创建一个临时的sockaddr_in来存储客户端信息
struct sockaddr_in peer;
socklen_t len = sizeof(peer); // 这里一定要写未来传入缓冲区的大小
// sizeof(buffer) - 1是因为在C/C++中,字符串是以\0结尾,但在内核中不是,所以需要减1
int n = recvfrom(sock_, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
// 4.业务处理
// TODO
// 这里以打印消息为例子
// 提取客户端信息并打印发送的消息
// 网络字节序转主机字节序
std::string clientip = inet_ntoa(peer.sin_addr);
uint16_t clientport = ntohs(peer.sin_port);
std::cout << clientip << "-" << clientport << "# " << buffer << std::endl;
// 5.发数据
// sendto();
}
}
~UdpServer()
{
}
private:
int sock_; // 套接字
uint16_t port_;
};
简单实现TCP服务器
TCP和UDP使用有点不同,对于服务端来说,TCP需要一个listensock来进行监听连接,并获取对方的socket;对于客户端来说,需要进行connect连接操作
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <strings.h>
class TcpServer
{
public:
TcpServer()
{
}
void InitServer() // 初始化服务器
{
// 1.创建socket
// type(第二个参数) -- SOCK_STREAM表示TCP
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
if (listensock_ < 0)
{
exit(-1); // socket创建失败直接退出
}
// 给服务器指明IP和port
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 使用sockaddr_in之前最好先清空
local.sin_family = AF_INET;
// 服务器不需要指明一个确定的IP,因此将IP地址字段设置为INADDR_ANY
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(port_); // 要将主机字节序转为网络字节序!
// 2.绑定(bind)到内核中
int n = bind(listensock_, (struct sockaddr *)&local, sizeof(local)); // 将sockaddr_in强转成sockaddr
if (n < 0)
{
exit(-1); // bind失败直接退出
}
// 3.监听listen(与UDP不同)
int backlog = 32; // 这个参数再讲TCP协议时会讲到
int n = listen(listensock_, backlog);
}
void Start()
{
// 服务器运行
while (true) // 服务器是一直运行的
{
char buffer[1024];
// 4.获取连接accept(与UDP不同)
// 对于客户端来说,多了一个connect的过程
struct sockaddr_in peer;
socklen_t len = sizeof(peer); // 这里一定要写未来传入缓冲区的大小
int socket = accept(listensock_, (struct sockaddr *)&peer, &len); // 同样要强转
if (socket < 0)
{
// 获取失败要重新继续获取,而不是退出结束
continue;
}
int n = recv(socket, buffer, sizeof(buffer) - 1, 0);
// 5.业务处理
// TODO
// 这里以打印消息为例子
// 提取客户端信息并打印发送的消息
// 网络字节序转主机字节序
std::string clientip = inet_ntoa(peer.sin_addr);
uint16_t clientport = ntohs(peer.sin_port);
std::cout << clientip << "-" << clientport << "# " << buffer << std::endl;
// 6.发数据
// send();
}
}
~TcpServer()
{
}
private:
// 与UDP不同,TCP需要一个listen套接字用来监听连接
int listensock_; // 监听套接字
uint16_t port_;
};
- 这里的UDP和TCP服务器目前只能处理一个连接,后续可以使用多进程、多线程或线程池进行完善修补