目录
前言:
网络编程(Network Programming)是指编写程序来实现计算机网络之间的通信。这通常涉及到使用套接字(sockets)来建立连接、发送和接收数据。网络编程可以应用于各种场景,如开发网站、构建分布式系统、实现网络服务等。
一、预备知识
(一)IP地址
我们知道 IP 是全球网络的基础,使用 IP
地址来标识公网环境下主机的唯一性,我们可以根据 目的IP地址 进行跨路由器的远端通信(将信息从主机 A
发送至主机 Z
)。
仅仅使用 IP 只能定位到目标主机,并且目标主机不是最终目的地,要想定位到最终目的地,需要依靠 端口号。
目标主机中存在很多进程,网络通信实际是不同主机中的进程在进行通信,并非主机与主机直接通信。
(二)端口号
端口号 是一个用于标识网络进程唯一性的标识符,是一个 2
字节(16位)的整数,取值范围为 [0, 65535]
,可以通过 端口号 定位主机中的目标进程。其中,0至1023之间的端口号是系统保留的,只能由系统进程使用,而剩下的端口号则可以用于用户程序和服务。
抛开网络其他知识,将信息从主机 A
中的进程 A
发送至主机 B
中的 进程 B
,这不就是 进程间通信 吗?之前学习的 进程间通信 是通过 匿名管道、命名管道、共享内存 等方式实现,而如今的 进程间通信 则是通过 网络传输 的方式实现。
需要进行网络通信的进程有很多,为了方便进行管理,就诞生了 端口号 这个概念,同进程的 PID
一样,端口号 也可以用于标识进程。
(三)端口号与进程PID
端口号 用于标识进程,进程
PID
也是用于标识进程,为什么在网络中,不直接使用进程PID
呢?
- 进程 PID 隶属于操作系统中的进程管理,如果在网络中使用 PID,会导致网络标准中被迫引入进程管理相关概念(进程管理与网络强耦合)。
- 进程管理 属于 OS 内部中的功能,OS 可以有很多标准,但网络标准只能有一套,在网络中直接使用 PID 无法确保网络标准的统一性。
- 并不是所有的进程都需要进行网络通信,如果端口号、PID 都使用同一个解决方案,无疑会影响网络管理的效。
所以综上所述,网络中的 端口号 需要通过一种全新的方式实现,也就是一个2
字节的整数 port
,进程 A
运行后,可以给它绑定 端口号 N
,在进行网络通信时,根据 端口号 N
来确定信息是交给主机Z的进程 A
的:
所以将之前的结论再具体一点:IP + Port 可以标识公网环境下,唯一的网络进程
网络传输中的必备信息组 [目的
IP
源IP
&& 目的Port
源Port
]
- 目的
IP
:需要把信息发送到哪一台主机- 源
IP
:信息从哪台主机中发出- 目的
Port
:将信息交给哪一个进程- 源
Port
:信息从哪一个进程中发出
注意: 端口号与进程 PID
并不是同一个概念
进程 PID
就好比你的身份证号,端口号 相当于学号,这两个信息都可以标识唯一的你,但对于学校来说,使用学号更方便进行管理。
一个进程可以绑定多个 端口号 吗?一个 端口号 可以被多个进程绑定吗?
端口号 的作用是配合 IP
地址标识网络世界中进程的唯一性,如果一个进程绑定多个 端口号,依然可以保证唯一性(因为无论使用哪个 端口号,信息始终只会交给一个进程);但如果一个 端口号 被多个进程绑定了,在信息递达时,是无法分辨该信息的最终目的进程的,存在二义性。
所以一个进程可以绑定多个端口号,一个 端口号 不允许被多个进程绑定,如果被绑定了,可以通过 端口号 顺藤摸瓜,找到占用该 端口号 的进程。
如果某个端口号被使用了,其他进程再继续绑定是会报错的,提示 该端口已被占用
主机(操作系统)是如何根据 端口号 定位具体进程的?
这个实现起来比较简单,创建一张哈希表,维护 <端口号, 进程 PID
> 之间的映射关系,当信息通过网络传输到目标主机时,操作系统可以根据其中的 [目的 Port
],直接定位到具体的进程 PID
,然后进行通信。
(四)传输层协议
主流的传输层协议有两个:TCP
和 UDP
两个协议各有优缺点,可以采用不同的协议,实现截然不同的网络程序,关于 TCP
和 UDP
的详细信息将会放到后面的文章中详谈,先来看看简单这两种协议的特点:
TCP
协议:传输控制协议
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
字节流就像水龙头,用户可以根据自己的需求获取水流量
UDP
协议:用户数据协议
- 传输层协议
- 无连接
- 不可靠传输
- 面向数据报
数据报则是相当于包裹,用户每次获取的都是一个或多个完整的包裹
关于 可靠性
TCP 的可靠传输并不意味着它可以将数据百分百递达,而是说它在数据传输过程中,如果发生了传输失败的情况,它会通过自己独特的机制,重新发送数据,确保对端百分百能收到数据;至于 UDP 就不一样,数据发出后,如果失败了,也不会进行重传,好在 UDP 面向数据报,并且没有很多复杂的机制,所以传输速度很快。
总结起来就是:TCP
用于对数据传输要求较高的领域,比如金融交易、网页请求、文件传输等,至于 UDP
可以用于短视频、直播、即时通讯等对传输速度要求较高的领域。
如果不知道该使用哪种协议,优先考虑
TCP
,如果对传输速度有要求,可以选择UDP
(五)网络字节序
预备知识
- 数据拥有高权值位和低权值位,比如在
32
位操作系统中,十六进制数0x11223344
,其中的11
称为 最高权值位,44
称为 最低权值位 - 内存有高地址和低地址之分
如果将数据的高权值存放在内存的低地址处,低权值存放在高地址处,此时就称为 大端字节序,反之则称为 小端字节序,这两种字节序没有好坏之分,只是系统设计者的使用习惯问题,比如我当前的电脑在存储数据时,采用的就是 小端字节序 方案:
通过内存单元可以看到,使用 小端字节序 时数据是倒着放的,大端字节序 就是正着存放了
大小端字节序就有点像吃香蕉时的方式,有的人是从头部开始剥皮,有的人是从尾部开始剥皮,两种方式都能吃到香蕉,纯属习惯问题。
在网络出现之前,使用大端或小端存储都没有问题,网络出现之后,就需要考虑使用同一种存储方案了,因为网络通信时,两台主机存储方案可能不同,会出现无法解读对方数据的问题。
如果你是网络标准的设计者,你会如何解决?
解决方案1:数据发送前,给报文中添加大小端的标记字段,待数据递达后,对端在根据标志位进行解读,再进行转换。 这个方案实现起来不太方便,并且给每一个报文都添加标记字段这个行为比较浪费。
解决方案2:书同文,车同轨,直接统一标准。 这种解决方案就很彻底了,直接从根源上解决问题,也更方便。
顶层设计者采用了解决方案2,TCP/IP
协议规定:网络中传输的数据,统一采用大端存储方案,也就是网络字节序, 现在大端/小端称为 主机字节序。
- 发送数据时,将 主机字节序 转化为 网络字节序,接收到数据后,再转回 主机字节序 就好了,完美解决不同机器中的大小端差异,可以用下面这批库函数进行转换,在发送/接收时,调用库函数进行转换即可
#include <arpa/inet.h>
// 主机字节序转网络字节序
uint32_t htonl(uint32_t hostlong); // l 表示32位长整数
uint32_t htons(uint32_t hostshort); // s 表示16位短整数
// 网络字节序转主机字节序
uint32_t ntohl(uint32_t netlong); // l 表示32位长整数
uint32_t ntohs(uint32_t netshort); // s 表示16位短整数
二、socket 套接字
(一)socket 常见API
Socket(套接字)提供了下面这一批常用接口,用于实现网络通信
#include <sys/types.h>
#include <sys/socket.h>
// 创建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);
可以看到在这一批 API
中,频繁出现了一个结构体类型 sockaddr
,该结构体支持网络通信,也支持本地通信。
socket
就是用于描述sockaddr
结构体的字段,复用了文件描述符的解决方案。
(二)sockaddr 结构体
socket
这套网络通信标准隶属于 POSIX
通信标准,该标准的设计初衷就是为了实现 可移植性,程序可以直接在使用该标准的不同机器中运行,但有的机器使用的是网络通信,有的则是使用本地通信,socket
套接字为了能同时兼顾这两种通信方式,提供了 sockaddr
结构体。
由 sockaddr
结构体衍生出了两个不同的结构体:sockaddr_in
网络套接字、sockaddr_un
域间套接字,前者用于网络通信,后者用于本地通信。
- 可以根据
16
位地址类型,判断是网络通信,还是本地通信 - 在进行网络通信时,需要提供
IP
地址、端口号 等网络通信必备项,本地通信只需要提供一个路径名,通过文件读写的方式进行通信(类似于命名管道)
socket
提供的接口参数为 sockaddr*
,我们既可以传入 &sockaddr_in
进行网络通信,也可以传入 &sockaddr_un
进行本地通信,传参时将参数进行强制类型转换即可,这是使用 C语言 实现 多态 的典型做法,确保该标准的通用性。
为什么不将参数设置为
void*
?
因为在该标准设计时,C语言还不支持void*
这种类型,为了确保向前兼容性,即便后续支持后也不能进行修改了。
三、基于UDP模拟网络通信
(一)核心功能
分别实现客户端与服务器,客户端向服务器发送消息,服务器收到消息后,回响给客户端,有点类似于 echo
指令
该程序的核心在于 使用 socket
套接字接口,以 UDP
协议的方式实现简单网络通信
(二)服务端客户端程序结构
程序由 server.hpp
、server.cc
、client.hpp
、client.cc
组成,大体框架如下:
创建
server.hpp
服务器头文件
#pragma once
#include <iostream>
namespace ns_server
{
class UdpServer
{
public:
// 构造
UdpServer()
{}
// 析构
~UdpServer()
{}
// 初始化
void Init()
{}
// 启动
void Start()
{}
private:
// ...
};
}
创建
server.cc
服务器源文件
#include <memory>
#include "server.hpp"
using namespace std;
using namespace ns_server;
int main()
{
unqiue_ptr<UdpServer> us(new UdpServer());
us->Init();
us->Start();
return 0;
}
创建 client.hpp
客户端头文件
#pragma once
#include <iostream>
namespace ns_client
{
class UdpClient
{
public:
// 构造
UdpClient()
{}
// 析构
~UdpClient()
{}
// 初始化
void Init()
{}
// 启动
void Start()
{}
private:
// ...
};
}
创建
client.cc
客户端源文件
#include <memory>
#include "client.hpp"
using namespace std;
using namespace ns_client;
int main()
{
unqiue_ptr<UdpClient> us(new UdpClient());
us->Init();
us->Start();
return 0;
}
makefile
文件
.PHONY:all
all:udp_client udp_server
udp_client:udp_client.cc
g++ -o $@ $^ -std=c++11
udp_server:udp_server.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f udp_client udp_server
准备工作完成后,接下来着手填充代码内容.
(三)服务端设计
1. 创建套接字
创建套接字使用 socket
函数
#include <sys/types.h>
#include <sys/socket.h>
// 创建套接字(TCP/UDP 服务器/客户端)
int socket(int domain, int type, int protocol);
- 参数1:domain 创建套接字用于哪种通信(网络/本地)
- 参数2:typ