🥁作者: 华丞臧.
📕专栏:【网络】
各位读者老爷如果觉得博主写的不错,请诸位多多支持(点赞+收藏+关注
)。如果有错误的地方,欢迎在评论区指出。
推荐一款刷题网站 👉 LeetCode刷题网站
文章目录
- 一、网络编程套接字
- 1.1 认识端口号
- 1.2 认识UDP和TCP协议
- 1.3 网络字节序
- 二、UDP套接字
- 2.1 sockaddr结构
- 2.2 简单的UDP网络程序
- 2.1 socket 常见API
- recvform
- bind
- inet_addr
- 2.1 封装 UdpSocket
- server
- init
- start
- 2.2 udpclient
- 2.4 日志显示
- 2.5 测试
一、网络编程套接字
在数据包的头部中,有两个IP地址,分别叫做源IP地址和目的IP地址;但是光有这两个IP地址不能实现网路通信;在计算机上进行通信的时候,实际上是用户和用户在进行网络通信,而用户通常是用程序体现的,也就是说用户是通过计算机上的某一个进程(软件)来进行数据的交互。
结论:网络通信的本质就是进程间的通信。
因此在实现网络通信时,不仅需要IP地址来确保主机的唯一性,还需要一个标记来确保主机上进程的唯一性。我们将这个表示主机上进程唯一性的标记称为端口号。
1.1 认识端口号
端口号(port)是传输层协议的内容。
- 端口号是一个2字节16位的整数。
- 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理。
- IP地址 + 端口号能够标识网络上的某一台主机的某一个进程。
- 一个端口号只能被一个进程占用。
- 端口号可以将操作系统的进程管理和网络解耦。
- 进程不一定都需要网络通信,端口号可以标识需要进行网络通信的进程。
- 0~1023的端口号是已经被使用了的,用户的端口号只能从1024 ~ 65536。
理解端口号和进程PID:
- PID表示唯一一个进程;
- 端口号也表示唯一一个进程;
- 一个进程可以绑定多个端口号;
- 一个端口号只能绑定唯一一个进程。
1.2 认识UDP和TCP协议
传输层协议(TCP和UDP)的数据段中有两个端口号,分别叫做源端口号和目的端口号,就是在描述 “数据是谁发的, 要发给谁”。
对TCP(Transmission Control Protocol 传输控制协议)有一个直观的认识,此处先了解TCP:
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
对UDP(User Datagram Protocol 用户数据报协议)有一个直观的认识,此处先了解UDP:
- 传输层协议
- 无连接
- 不可靠传输
- 面向数据报
UDP实现足够简单,TCP实现较为复杂;两个协议各有优缺点,不同场景选择合适的协议即可。
1.3 网络字节序
内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分, 那么如何定义网络数据流的地址呢?
两台主机进行网络通信,发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。
世界各地的计算机生产厂商非常多, 因此对于发送方和接收方的大小端字节序是无法确定的。无法控制通信双方主机,但是我们可以规定网络中的字节序;TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。不管这台主机是大端机还是小端机,都会按照这个TCP/IP规定的网络字节序来发送/接收数据;如果当前发送主机是小端,就需要先将数据转成大端;否则就忽略, 直接发送即可。
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络
字节序和主机字节序的转换。
- 这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
- 例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回。
- 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
二、UDP套接字
2.1 sockaddr结构
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的UNIX Domain Socket。然而,各种网络协议的地址格式并不相同。用于本地通信和用于网络通信的套接字接口需要的数据都是不同的,为了能够使用统一的接口来实现网络通信和本地通信,接口设计者给出了一个 抽象结构,如下图:
系统中存在三种结构体,struct sockaddr(抽象类型)、struct sockaddr_in(INET网路套接字)、struct sockaddr_un(Unix域套接字套接字)。struct sockaddr内部会处理判断数据的前16位进行类型识别,然后强转成对应的类型实现切片,可以理解为C++的中多态。
-
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_in;这样的好处是程序的通用性, 可以接收IPv4, IPv6,以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数。
sockaddr 结构
sockaddr_in 结构
虽然socket api的接口是sockaddr, 但是我们真正在基于IPv4编程时, 使用的数据结构是sockaddr_in;这个结构里主要有三部分信息: 地址类型, 端口号, IP地址。
in_addr结构
in_addr用来表示一个IPv4的IP地址. 其实就是一个32位的整数。
2.2 简单的UDP网络程序
2.1 socket 常见API
// 创建 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);
说明:
-
domain:域,进行本地通信还是网络通信。
- AF_UNIX:本地通信;
- AF_INET:IPV4;
-
type:套接字类型,决定通信对应的报文类型,常用的为流式和用户数据报。
-
protocol:协议类型,网络应用中直接设为0即可。
-
socket套接字返回的是文件描述符,可以理解所有网络操作最终都是文件描述符级的操作。
recvform
用于从套接字文件描述符中读取数据。
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
说明:
- sockfd:套接字
- buf:用于接收数据的指针,从sockfd文件中读取的数据放入指针指向的空间中;
- len:读取数据的字节数;
- flags:模式;
- src_addr:用于接收读取对方的port和ip地址;
- addrlen:src_addr数据的字节大小;
bind
用于绑定网络信息,指明ip和port。
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
- sockfd:套接字文件描述符;
- addr:需要绑定的sockaddr结构;
- addrlen:对应sockaddr结构的字节大小;
inet_addr
用于将字符串中的点分十进制转换成32位4字节的表示形式。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
in_addr_t inet_network(const char *cp);
char *inet_ntoa(struct in_addr in);
struct in_addr inet_makeaddr(int net, int host);
in_addr_t inet_lnaof(struct in_addr in);
in_addr_t inet_netof(struct in_addr in);
2.1 封装 UdpSocket
server
server是提供服务的一端,可以理解为服务器,用来接收用户传输的消息,同时也可以给用户发消息;可以将服务器封装成一个类,类当中包含套接字的初始化以及提供服务的接口,启动服务器时必须将端口号以参数的形式传递给main函数,ip地址可传可不传。
#include <iostream>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "Log.hpp"
static void Usage(const std::string porc)
{
std::cout << "Usage:\n\t" << porc << " port [ip]" << std::endl;
}
// 写一个简单的udpSever
// 云服务器有一些特殊情况:
// 1. 禁止你bind云服务器上的任何确定IP, 只能使用INADDR_ANY,如果你是虚拟机,随意
class UdpServer
{
public:
UdpServer(int port, std::string ip = "")
:sockfd_(-1)
,port_(port)
,ip_(ip)
{
}
~UdpServer()
{
}
public:
void init() //初识化server
{}
void start() //server开始服务
{}
private:
// 服务器的fd
int sockfd_;
// 服务器必须得有端口号信息
uint16_t port_;
// 服务器必须得有IP地址
std::string ip_;
};
// ./server port ip
int main(int argc, char *argv[])
{
if(argc != 2 && argc != 3)
{
Usage(argv[0]);
exit(PARA_ERR);
}
uint16_t port = atoi(argv[1]);
std::string ip;
if(argc == 3)
{
ip = argv[2];
}
UdpServer server(port,ip);
server.init(); // 创建并配置套接字
server.start(); // 提供服务
return 0;
}
init
初识化的任务主要是创建套接字,将套接字绑定网络信息指明ip和port。一般初识化分为以下几个步骤:
void init()
{
// 1. 创建套接字
// domain -- 域
// type -- 套接字类型,SOCK_DGRAM--数据报格式
// protocol -- 协议类型,网络应用中:0
// 返回值其实是文件描述符
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0); //就是打开一个文件
if(sockfd_ < 0)
{
logMessage(FATAL, "socket:%s:%d", strerror(errno), sockfd_);
exit(SOCK_ERR);
}
logMessage(DEBUG, "socket create success:%d", sockfd_);
// 2. 绑定网络信息,指明ip+port
// 2.1 先填充基本信息到 struct sockaddr_in
struct sockaddr_in local;
bzero(&local, sizeof(local)); //初始化为0
local.sin_family = AF_INET; //填充协议家族,域
local.sin_port = htons(port_); //填充服务对应的端口号信息,一定会发给对方,因此port_一定回到网络中
//local.sin_addr;
local.sin_addr.s_addr = ip_.empty()? htonl(INADDR_ANY) : inet_addr(ip_.c_str()); // htonl将INADDR_ANY转换成32位网络字节序, inet_addr将字符串中的点分十进制转换成32位比特位
if(bind(sockfd_, (const struct sockaddr*)&local, sizeof(local)) == -1)
{
logMessage(FATAL, "bind:%s", strerror(errno));
exit(BIND_ERR);
}
logMessage(DEBUG, "socket bind success:%d", sockfd_);
//完成
}
start
start()是server提供服务的接口,因此该函数必须是一个死循环(服务器都是在一个死循环当中)以给用户提供持续的服务;在start函数中,主要完成接收用户发送的消息并且将消息提取出来,其主要步骤如下图:
void start()
{
// 服务器都是在一个死循环当中
char inbuffer[1024]; //将来读取到的数据,都放在这里
char outbuffer[1024]; //将来发送的数据,都放在这里
while(true)
{
struct sockaddr_in peer; //输出型参数
socklen_t len = sizeof(peer); //输入输出型参数
// UDP是无连接的
// 对方发消息,你需要接收消息
ssize_t s = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, \
(struct sockaddr*)&peer, &len);
logMessage(DEBUG, "server 提供服务中.....");
if(s > 0)
{
//接收成功
inbuffer[s] = '\0';
}
else if(s == -1)
{
//
logMessage(WARINING, "recvfrom fialed:%s[%d]", strerror(errno), sockfd_);
continue;
}
// 读取成功的,除了读取到对方的数据,你还要读取到对方的网络地址[ip:port]
std::string peerIp = inet_ntoa(peer.sin_addr); //拿到对方的ip
uint32_t peerPort = ntohs(peer.sin_port); //拿到对方的port
// 打印出来客户端给服务器发送过来的消息
logMessage(NOTICE, "[%s:%d]# %s", peerIp.c_str(), peerPort, inbuffer);
logMessage(DEBUG, "server 提供服务中.....");
//sleep(1);
}
}
2.2 udpclient
客户端用来连接服务器,并且使用服务器提供的服务;用户想要客户端与服务器进行网络通信就必须将服务器使用的端口号和ip传给客户端,客户端通过端口号和ip可以实现与服务器地网络通信。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include "Log.hpp"
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
static void Usage(std::string name)
{
std::cout << "Usage:\n\t" << name << " server_ip server_port" << std::endl;
}
// ./udpClient IP port
int main(int argc, char* argv[])
{
if(argc != 3) //必须等于3,必须传ip和port
{
Usage(argv[0]);
exit(PARA_ERR);
}
std::string ip = argv[1];
uint16_t port = atoi(argv[2]);
// 2. 创建客户端
// 2.1 创建socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
assert(sockfd > 0);
struct sockaddr_in ser;
bzero(&ser, sizeof ser);
ser.sin_family = AF_INET; //填充与服务器相同的协议
ser.sin_port = htons(port); //服务器的端口号
ser.sin_addr.s_addr = inet_addr(ip.c_str()); //服务器ip
std::string buffer;
while(true)
{
std::cerr << "Please Enter# ";
std::getline(std::cin, buffer);
// 发送消息给server
sendto(sockfd, buffer.c_str(), buffer.size(), 0,
(const struct sockaddr *)&ser, sizeof(ser)); // 首次调用sendto函数的时候,我们的client会自动bind自己的ip和port
}
close(sockfd);
return 0;
}
2.4 日志显示
- vs_start:使用距离可变参数最近的一个参数初始化ap;
- va_arg:对可变参数列表提参,提参类型为type;
- va_end:将ap指针置为空;
//将可变参数列表全部转化为某个字符串当中
int vsnprintf(char *str, size_t size, const char *format, va_list ap);
说明:
str:用于保存数据的字符串数组地址;
size:最大写入的空间大小;
format:以什么格式化写入;
ap:可变参数列表部分;
#pragma once
#include <cstdio>
#include <ctime>
#include <cstdarg>
#include <cassert>
#include <cstring>
#include <cerrno>
#include <stdlib.h>
#define DEBUG 0
#define NOTICE 1
#define WARINING 2
#define FATAL 3
#define SOCK_ERR 4
#define BIND_ERR 5
#define PARA_ERR 6
const char *log_level[]={"DEBUG", "NOTICE", "WARINING", "FATAL"};
// logMessage(DEBUG, "%d", 10);
void logMessage(int level, const char *format, ...)
{
assert(level >= DEBUG);
assert(level <= FATAL);
char *name = getenv("USER"); //获取用户名
char logInfo[1024];
va_list ap; // ap -> char* //
va_start(ap, format); //
vsnprintf(logInfo, sizeof(logInfo)-1, format, ap); //写入logInfo
va_end(ap); // ap = NULL
FILE *out = (level == FATAL) ? stderr:stdout;
fprintf(out, "%s | %u | %s | %s\n", \
log_level[level], \
(unsigned int)time(nullptr),\
name == nullptr ? "unknow":name,\
logInfo);
}
2.5 测试
- 使用127.0.0.1可以进行本地通信。
- 使用云服务器的ip地址可以进行网络通信。