Linux网络编程(上)

一、基础

cpp 复制代码
网络套接字:socket
    一个文件描述符指向一个套接字。
    套接字是成对出现的。(插头和插座)

网络字节序:
    小端法:(PC本地存储) 高位存高地址。低位存低地址。
    大端法:(网络存储)高位存低地址。低位存高地址。

// 二进制左边是高位
int a = 0x12345678

小端法:
地址0x00: 0x78
地址0x01: 0x56
地址0x02: 0x34
地址0x03: 0x12

大端法:
地址0x00: 0x12
地址0x01: 0x34
地址0x02: 0x56
地址0x03: 0x78

二、IP地址转换函数

cpp 复制代码
htonl: 本地 --> 网络(IP)
htons: 本地 --> 网络(port)
ntohl: 网络 --> 本地(IP)
ntohs: 网络 --> 本地(Port)

本地字节序(string IP)---> 网络字节序
int inet_pton(int af, const char *src, void *dst)  
    af:AF_FNET、AF_INET6
    src:传入,IP地址(点分十进制)
    dst:传出,转换后的网络字节序的IP地址。
返回值:
    成功:1
    异常:0,说明src指向的不是一个有效的ip地址。
    失败:-1


网络字节序 --> 本地字节序(string IP)
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
    af: AF_INET、AF_INET6
    src:网络字节序IP地址
    dst:本地字节序(string IP)
    size: dst 的大小。
返回值:
    成功:dst。
    失败:NULL

三、sockaddr结构

cpp 复制代码
struct sockaddr_in addr:
    addr.sin_family = AF_INET/AF_INET6
    addr.sin_port = htons(9527);
        int dst;
        inet_pton(AF_INET, "192.157.22.45", (void *)&dst);
        addr.sin_addr.s_addr = dst;
    常用:addr.sin_addr.s_addr = htonl(INADDR_ANY) 取出系统中有效的任意IP地址。二进制类型。
    
bind(fd, (struct sockaddr *)&addr, size):

四、socket和bind函数

cpp 复制代码
创建一个套接字:
int socket(int domain, int type,int protocol);
    domain: AF_INET、AF_INET6、AF_UNIX
    type: SOCK_STREAI、SOCK_DGRAM
    protocol:0
返回值:
    成功:新套接字所对应文件描述符
    失败:-1 errno


给socket绑定一个地址结构(IP+port):
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
    sockfd:socket函数返回值
        struct sockaddr_in addr:
        addr.sin_family = AF_INET;
        addr.sin_port = htons(8888);
        addr.sin_addr.s_addr = htonl(INADDR_ANY):
    addr: (struct sockaddr *)&addr
    addrlen:sizeof(addr)地址结构的大小。
返回值:
    成功:0
    失败:-1errno

五、listen和accept函数

cpp 复制代码
设置同时与服务器建立连接的上限数。(同时进行3次握手的客户端数量)
int listen(int sockfd, int backlog);
    sockfd:socket函数返回值
    backlog:上限数值。最大值128.
返回值:
    成功:0
    失败:-1 errno


阻塞等待客户端建立连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
    sockfd:socket函数返回值
    addr:传出参数。成功与服务器建立连接的那个客户端的地址结构(IP+port)
        socklen_t clit_addr_len = sizeof(addr):
    addrlen:传入传出。&clit_addr_len
    入:addr的大小。出:客户端addr实际大小。
返回值:
    成功:能与服务器进行数据通信的socket对应的文件描述。
    失败:-1,errno

六、connect函数

cpp 复制代码
用现有的socket与服务器建立连接
int connect (int sockfd, const struct sockaddr* addr, socklen_t addrlen);
    sockfd: socket函数返回值
    addr:传入参数。服务器的地址结构
    addrlen:服务器的地址结构的大小
返回值。
    成功:0
    失败:-1 errno
如果不使用bind绑定客户端地址结构,采用"隐式绑定".

七、TCP实现客户端服务器连接

cpp 复制代码
// 服务器代码
#include<stdio.h>
#include<ctype.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<errno.h>
#include<pthread.h>

#define SERV_PORT 9527

void sys_err(const char* str) {
	perror(str);
	exit(1);
}

int main(int argc, char* argv[]) {
	int lfd = 0, cfd = 0;
	int ret, i;
	char buf[BUFSIZ], client_IP[1024];

	struct sockaddr_in serv_addr, clit_addr;
	socklen_t clit_addr_len;

	serv_addr.sin_family = AF_INET;
	serv_addr.sin_port = htons(SERV_PORT);
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

	lfd = socket(AF_INET, SOCK_STREAM, 0);
	if (lfd == -1) {
		sys_err("socket error");
	}

	bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

	listen(lfd, 128);

	clit_addr_len = sizeof(clit_addr);

	cfd = accept(lfd, (struct sockaddr*)&clit_addr, &clit_addr_len);
	if (cfd == -1) {
		sys_err("accept error");
	}

	printf("client ip:%s port:%d\n",
		inet_ntop(AF_INET, &clit_addr.sin_addr.s_addr, client_IP, sizeof(client_IP)),
		ntohs(clit_addr.sin_port));

	while (1) {
		ret = read(cfd, buf, sizeof(buf));
		write(STDOUT_FILENO, buf, ret);

		for (i = 0; i < ret; i++) {
			buf[i] = toupper(buf[i]);
		}

		write(cfd, buf, ret);
	}

	close(lfd);
	close(cfd);

	return 0;
}

// 测试如下图
cpp 复制代码
// 客户端代码
#include<stdio.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<errno.h>
#include<pthread.h>

#define SERV_PORT 9527

void sys_err(const char* str) {
	perror(str);
	exit(1);
}

int main(int argc, char* argv[]) {
	int cfd;
	int conter = 10;
	char buf[BUFSIZ];

	struct sockaddr_in serv_addr;	//服务器地址结构

	serv_addr.sin_family = AF_INET;
	serv_addr.sin_port = htons(SERV_PORT);
	inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr.s_addr);

	cfd = socket(AF_INET, SOCK_STREAM, 0);
	if (cfd == -1) {
		sys_err("socket error");
	}
		
	int ret = connect(cfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
	if (ret != 0) {
		sys_err("connect error");
	}

	while(--conter) {
		write(cfd, "hello\n", 6);
		ret = read(cfd, buf, sizeof(buf));
		write(STDOUT_FILENO, buf, ret);
		sleep(1);
	}

	close(cfd);

	return 0;
}

八、图解C-S模型


九、封装错误处理代码

cpp 复制代码
将一系列自己写的函数和错误处理代码封装到另一个程序中
cpp 复制代码
// 服务器代码
#include"wrap.h"

#define SERV_PORT 9527

int main(int argc, char* argv[]) {
	int lfd = 0, cfd = 0;
	int ret, i;
	char buf[BUFSIZ], client_IP[1024];

	struct sockaddr_in serv_addr, clit_addr;
	socklen_t clit_addr_len;

	serv_addr.sin_family = AF_INET;
	serv_addr.sin_port = htons(SERV_PORT);
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

	lfd = Socket(AF_INET, SOCK_STREAM, 0);    // 调用自己写的函数

	ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    if(ret != 0){
        sys_err("bind error");
    }

	Listen(lfd, 128);    // 调用自己写的函数

    // 此处省略...

	return 0;
}
cpp 复制代码
// wrap.h
#ifndef _WRAP_H_
#define _WRAP_H_

#include<stdio.h>
#include<ctype.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<errno.h>
#include<pthread.h>

void sys_err(const char* str);
int Socket(int domain, int type, int protocol);
int Listen(int sockfd, int backlog);

#endif
cpp 复制代码
// wrap.c
#include"wrap.h"

void sys_err(const char* str) {
	perror(str);
	exit(1);
}

int Socket(int domain, int type, int protocol) {
	int n = socket(domain, type, protocol);
	if (n = -1) {
		sys_err("socket error");
		return n;
	}

	return n;
}

int Listen(int sockfd, int backlog) {
	int n = listen(sockfd, backlog);
	if (n = -1) {
		sys_err("listen error");
		return n;
	}

	return n;
}

十、多进程服务器实现

cpp 复制代码
#include<stdio.h>
#include<ctype.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<string.h>
#include<strings.h>
#include<unistd.h>
#include<errno.h>
#include<signal.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<pthread.h>

// 封装的错误处理函数
#include"wrap.h"

#define SRV_PORT 9999

void catch_child(int signum) {
	while ((waitpid(0, NULL, WNOHANG)) > 0);
	return;
}

int main(int argc, char* argv[]) {
	int lfd, cfd;
	pid_t pid;
	struct sockaddr_in srv_addr, clt_addr;
	socklen_t clt_addr_len;
	char buf[BUFSIZ];
	int ret, i;

	bzero(&srv_addr, sizeof(srv_addr));		// 将地址结构清零

	srv_addr.sin_family = AF_INET;
	srv_addr.sin_port = htons(SRV_PORT);
	srv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

	lfd = Socket(AF_INET, SOCK_STREAM, 0);

	Bind(lfd, (struct sockaddr*)&srv_addr, sizeof(srv_addr));

	Listen(lfd, 128);

	clt_addr_len = sizeof(clt_addr);

	while (1) {
		cfd = Accept(lfd, (struct sockaddr*)&clt_addr, &clt_addr_len);

		pid = fork();
		if (pid < 0) {
			sys_err("fork error");
		}
		else if (pid == 0) {
			close(lfd);
			break;
		}
		else {
			struct sigaction act;

			act.sa_handler = catch_child;
			sigemptyset(&act.sa_mask);
			act.sa_flags = 0;

			ret = sigaction(SIGCHLD, &act, NULL);
			if (ret != 0) {
				perr_exit("sigaction error");
			}

			close(cfd);
			continue;
		}
	}

	if (pid = 0) {
		while (1) {
			ret = Read(cfd, buf,sizeof(buf));
			if (ret = 0) {
				close(cfd);
				exit(1);
			}

			for (i = 0; i < ret; i++) {
				buf[i] = toupper(buf[i]);
			}

			write(cfd, buf, ret);
			write(STDOUT_FILENO, buf, ret);
		}
	}

	return 0;
}

// 这样实现了多个客户端访问一个服务器,同时不会产生僵尸进程

十一、多线程服务器实现

cpp 复制代码
#include<stdio.h>
#include<string.h>
#include<arpa/inet.h>
#include<pthread.h>
#include<ctype.h>
#include<unistd.h>
#include<fcntl.h>

#include"wrap.h"

#define MAXLINE 8192
#define SERV_PORT 8000

struct s_info {		// 定义一个结构体,将地址结构跟cfd捆绑
	struct sockaddr_in cliaddr;
	int connfd;
};

void* do_work(void* arg) {
	int n, i;
	struct s_info* ts = (struct s_info*)arg;
	char buf[MAXLINE];
	char str[INET_ADDRSTRLEN];				// #define INET_ADDRSTRLEN 16

	while (1) {
		n = Read(ts->connfd, buf, MAXLINE);	// 读客户端
		if (n == 0) {
			printf("the client %d closed...\n", ts->connfd);
			break;							// 跳出循环,关闭cfd
		}

		printf("received from %s at PORT %d\n",
			inet_ntop(AF_INET, &(*ts).cliaddr.sin_addr, str, sizeof(str)),
			ntohs((*ts).cliaddr.sin_port));		// 打印客户端信息(IP/PORT)

		for (i = 0; i < n; i++) {
			buf[i] = toupper(buf[i]);			// 小写 --> 大写
		}

		Write(STDOUT_FILENO, buf, n);	// 写出至屏幕
		Write(ts->connfd, buf, n);		// 回写给客户端
	}

	Close(ts->connfd);

    /*Close函数如下:
    int n = close(fd);
    if(n == -1){
        sys_err("close error");
    }
    return n;
    */    

	return (void*)0;
}

int main(void) {
	struct sockaddr_in servaddr, cliaddr;
	socklen_t cliaddr_len;
	int listenfd, connfd;
	pthread_t tid;

	struct s_info ts[256];		// 创建结构体数组.
	int i = 0;

	listenfd = Socket(AF_INET, SOCK_STREAM, 0);	// 创建一个socket,得到lfd

	bzero(&servaddr, sizeof(servaddr));			// 地址结构清零

	servaddr.sin_family = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);		// 指定本地任意IP
	servaddr.sin_port = htons(SERV_PORT);				// 指定端口号

	Bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr));

	Listen(listenfd, 128);		// 设置同一时刻链接服务器上限数

	printf("Accepting client connect ...\n");

	while (1) {
		cliaddr_len = sizeof(cliaddr);
		connfd = Accept(listenfd, (struct sockaddr*)&cliaddr, &cliaddr_len);	//阻塞监听客户端链接请求
		
		ts[i].cliaddr = cliaddr;
		ts[i].connfd = connfd;

		pthread_create(&tid, NULL, do_work, (void*)&ts[i]);
		pthread_detach(tid);	//子线程分离,防止僵线程产生

		i++;
	}

	return 0;
}

十二、TCP连接图

cpp 复制代码
2MSL时长:保证最后一个ACK能成功被对端接收。
(等待期间,对端没收到我发的ACK,对端会再次发送FIN请求。)

十三、端口复用

cpp 复制代码
当服务器先关闭,再关闭客户端,再启动服务器就会出错
因为服务器主动关闭要等2MSL,期间端口还被占用

解决:在bind()之前插入以下代码

//listenfd = socket(...);
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (void*)&opt, sizeof(opt));

十四、半关闭状态

cpp 复制代码
半关闭。
通信双方中,只有一端关闭通信。---FIN_WAIT_2
close(cfd);

shutdown(int fd, int how);
how:
    SHUT_RD关读端
    SHUT_WR关写端
    SHUT_RDWR关读写

shutdown()在关闭多个文件描述符应用的文件时,采用全关闭方法。close()只关闭一个。

十五、多路IO


十六、select函数

cpp 复制代码
void FD_ZERO(fd_set *set);           ---清空一个文件描述符集合。
    fd_set rset;
    FD_ZERO(&rset);


void FD_SET(int fd, fd_set *set);    ---将待监听的文件描述符,添加到监听集合中。
    FD_SET(3, &rset);
    FD_SET(5, &rset);
    FD_SET(6, &rset);


void FD_CLR(int fd, fd_set *set);    ---将一个文件描述符从监听集合中移除。
    FD_CLR(4. &rset);


int FD_ISSET(int fd, fd set *set);   ---判断一个文件描述符是否在监听集合中。
    返回值:在1,不在0
    FD_ISSET(4, &rset);


int select(int nfds, fd_set *readfds, fd_set *writefds, 
    fd_set *exceptfds, struct timeval *timeout);

    nfds:监听的所有文件描述符中,最大文件描述符+1
    readfds:读文件描述符监听集合。     传入、传出参数    
    writefds:写文件描述符监听集合。    传入、传出参数    NULL
    exceptfds:异常文件描述符监听集合   传入、传出参数    NULL
    timeout:
        >0:设置监听超时时长。
        NULL:阻塞监听
        0:非阻塞监听,轮询
    返回值:
        >0:所有监听集合(3个)中,满足对应事件的总数。
        0:没有满足监听条件的文件描述符
        -1:errno

十七、select实现多路IO

cpp 复制代码
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<arpa/inet.h>
#include<ctype.h>

// 封装的错误处理函数
#include"wrap.h"

#define SERV_PORT 6666

int main(int argc, char* argv[]) {
	int listenfd, connfd;
	char buf[BUFSIZ];
	
	struct sockaddr_in clie_addr, serv_addr;
	socklen_t clie_addr_len;

	listenfd = Socket(AF_INET, SOCK_STREAM, 0);

	int opt = 1;
	setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

	bzero(&serv_addr, sizeof(serv_addr));

	serv_addr.sin_family = AF_INET;
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	serv_addr.sin_port = htons(SERV_PORT);

	Bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

	Listen(listenfd, 128);

	fd_set rset, allset;		//定义读集合,备份集合allset
	int ret, maxfd = 0, n, i, j;
	maxfd = listenfd;			//最大文件描述符

	FD_ZERO(&allset);			//清空监听集合
	FD_SET(listenfd, &allset);	//将待监听fd添加到监听集合中

	while (1) {
		rset = allset;		//备份
		ret = select(maxfd + 1, &rset, NULL, NULL, NULL);	//使用select监听
		if (ret < 0) {
			perr_exit("select error");
		}

		//listenfd满足监听的读事件
		if (FD_ISSET(listenfd, &rset)) {	
			clie_addr_len = sizeof(clie_addr);

			//建立链接,不会阻塞
			connfd = Accept(listenfd, (struct sockaddr*)&clie_addr, &clie_addr_len);

			FD_SET(connfd, &allset);	//将新产生的fd,添加到监听集合中,监听数据读事件

			if (maxfd < connfd) {
				maxfd = connfd;			//修改maxfd
			}

			if (ret == 1) {
				continue;	//说明select只返回一个,并且是listenfd,后续执行无须执行
			}
		}

		//处理满足读事件的fd
		for (i = listenfd + 1; i <= maxfd; i++) {	
			//找到满足读事件的那个fd
			if (FD_ISSET(i, &rset)) {	
				n = Read(i, buf, sizeof(buf));

				//检测到客户端已经关闭链接
				if (n == 0) {			
					Close(i);
					FD_CLR(i, &allset);		//将关闭的fd,移除出监听集合
				}
				else if (n == -1) {
					perr_exit("read error");
				}

				for (j = 0; j < n; j++) {
					buf[j] = toupper(buf[j]);
				}

				write(i, buf, n);
				write(STDOUT_FILENO, buf, n);
			}
		}
	}

	Close(listenfd);

	return 0;
}

十八、select优缺点

cpp 复制代码
缺点:
    监听上限受文件描述符限制。最大1024
    轮询fd效率太慢,一个个问,有的可能用不到也被询

优点:
    跨平台。Win、Linux、MacOS、Unix

解决:通过自定义数组放入需要被轮询的fd,避免不必要的轮询
cpp 复制代码
// 用client数组解决
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<arpa/inet.h>
#include<ctype.h>

#include"wrap.h"

#define SERV_PORT 6666

int main(int argc, char* argv[]) {
	int i, j, n, maxi;

	/*自定义数组client,防止遍历1024个文件描述符 FD_SETSIZE默认为1024*/
	int nready, client[FD_SETSIZE];
	int maxfd, listenfd, connfd, sockfd;

	/* #define INET_ADDRSTRLEN 16 */
	char buf[BUFSIZ], str[INET_ADDRSTRLEN];

	struct sockaddr_in clie_addr, serv_addr;
	socklen_t clie_addr_len;

	fd_set rset, allset;	/*set读事件文件描述符集合allset用来暂存*/

	listenfd = Socket(AF_INET, SOCK_STREAM, 0);

	int opt = 1;
	setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

	bzero(&serv_addr, sizeof(serv_addr));

	serv_addr.sin_family = AF_INET;
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	serv_addr.sin_port = htons(SERV_PORT);

	Bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

	Listen(listenfd, 128);

	maxfd = listenfd;	/*起初listenfd即为最大文件描述符*/

	maxi = -1;			/*将来用作client[]的下标,初始值指向0个元素之前下标位置*/

	for (i = 0; i < FD_SETSIZE; i++) {
		client[i] = -1;				/*用-1初始化client[]*/
	}

	FD_ZERO(&allset);
	FD_SET(listenfd, &allset);		/*构造select监控文件描述符集*/

	while (1) {
		rset = allset;				/*每次循环时都重新设置select监控信号集*/

		nready = select(maxfd + 1, &rset, NULL, NULL, NULL);
		if (nready < 0) {
			perr_exit("select error");
		}

		/*说明有新的客户端连接请求*/
		if (FD_ISSET(listenfd, &rset)) {
			clie_addr_len = sizeof(clie_addr);

			//建立连接,不会阻塞
			connfd = Accept(listenfd, (struct sockaddr*)&clie_addr, &clie_addr_len);

			printf("received from %s at PORT %d\n",
				inet_ntop(AF_INET, &clie_addr.sin_addr, str, sizeof(str)),
				ntohs(clie_addr.sin_port));

			for (i = 0; i < FD_SETSIZE; i++) {
				/*找client[]中没有使用的位置*/
				if (client[i] < 0) {
					client[i] = connfd;		/*保存accept返回的文件描述符到client[]里*/
					break;
				}
			}

			/*达到select能监控的文件个数上限1024*/
			if (i == FD_SETSIZE) {
				fputs("too many clients\n", stderr);
				exit(1);
			}

			FD_SET(connfd, &allset);	/*向监控文件描述符集合allset添加新的文件描述符connfd*/

			if (connfd > maxfd) {
				maxfd = connfd;			/*select第一个参数需要*/
			}

			if (i > maxi) {
				maxi = i;				/*保证maxi存的总是client[]最后一个元素下标*/
			}

			if (--nready == 0) {
				continue;
			}
		}

		/*检测哪个clients有数据就绪*/
		for (i = 0; i <= maxi; i++) {
			if ((sockfd = client[i]) < 0) {
				continue;
			}

			if (FD_ISSET(sockfd, &rset)) {
				/*当client关闭连接时,服务器端也关闭对应连接*/
				if ((n = Read(sockfd, buf, sizeof(buf))) == 0) {
					Close(sockfd);
					FD_CLR(sockfd, &allset);	/*解除select对此文件描述符的监控*/
					client[i] = -1;
				}
				else if (n > 0) {
					for (j = 0; j < n; j++) {
						buf[j] = toupper(buf[j]);
					}

					Write(sockfd, buf, n);
					Write(STDOUT_FILENO, buf, n);

					if (--nready == 0) {
						break;
					}
				}
			}
		}
	}

	Close(listenfd);

	return 0;
}
相关推荐
ICscholar4 小时前
ExaDigiT/RAPS
linux·服务器·ubuntu·系统架构·运维开发
sim20204 小时前
systemctl isolate graphical.target命令不能随便敲
linux·mysql
米高梅狮子5 小时前
4. Linux 进程调度管理
linux·运维·服务器
再创世纪5 小时前
让USB打印机变网络打印机,秀才USB打印服务器
linux·运维·网络
fengyehongWorld6 小时前
Linux ssh端口转发
linux·ssh
知识分享小能手8 小时前
Ubuntu入门学习教程,从入门到精通, Ubuntu 22.04中的Shell编程详细知识点(含案例代码)(17)
linux·学习·ubuntu
yugi9878388 小时前
异构网络下信道环境建模方法及应用
开发语言·网络
Xの哲學8 小时前
深入解析 Linux systemd: 现代初始化系统的设计与实现
linux·服务器·网络·算法·边缘计算
龙月9 小时前
journalctl命令以及参数详解
linux·运维
C_心欲无痕9 小时前
网络相关 - 强缓存与协商缓存讲解
前端·网络·网络协议·缓存