Webserve(2): HTTP解析

1、HTTP协议基本概念

超文本传输协议(HTTP,HyperText Transfer Protocol)是一种用于分布式、协作式和超媒体信息系统的应用层协议。它是万维网(WWW)的基础,用于在万维网上传输超文本文档,以及其他文件(如图片、视频、音频等)。

HTTP协议的主要特点包括:

  1. 简单易用:HTTP协议的语法非常简单,容易理解和使用。
  2. 灵活可扩展:HTTP协议支持多种类型的数据格式和编码方式,可以通过扩展头部字段等方式来支持新的功能。
  3. 无状态:HTTP协议是无状态的,即服务器不会在两个请求之间保留任何数据(状态),每个请求都是独立的。
  4. 请求/响应模型HTTP协议是一个客户端和服务器之间的请求/响应协议,客户端发起一个请求,服务器响应这个请求。

HTTP协议的应用非常广泛,它不仅是Web浏览器和Web服务器之间的通信协议,还是Web服务器和Web应用程序、RESTful API之间的通信协议。通过HTTP协议,客户端可以向服务器请求资源,并通过HTTP方法(GET、POST、PUT、DELETE等)对资源进行操作。

HTTP协议的发展是万维网协会和Internet工作小组合作的结果,它经历了多个版本的迭代,目前普遍使用的是HTTP 1.1版本。HTTP协议的设计初衷是为了提供一种发布和接收HTML页面的方法,但随着Web技术的不断发展,它已经扩展到支持各种类型的数据传输和应用程序通信。

HTTP 是一个客户端终端(用户)和服务器端(网站)请求和应答的标准(TCP)。通过使用网页浏览器、网络爬虫或者其它的工具,客户端发起一个HTTP请求到服务器上指定端口(默认端口为80)。我们称这个客户端为用户代理程序(user agent)。应答的服务器上存储着一些资源,比如 HTML 文件和图像。我们称这个应答服务器为源服务器(origin server)。在用户代理和源服务器中间可能存在多个“中间层”,比如代理服务器、网关或者隧道(tunnel)。


尽管 TCP/IP 协议是互联网上最流行的应用,HTTP 协议中,并没有规定必须使用它或它支持的层。事实上,HTTP可以在任何互联网协议上,或其他网络上实现。HTTP 假定其下层协议提供可靠的传输。因此,任何能够提供这种保证的协议都可以被其使用。因此也就是其在 TCP/IP 协议族使用 TCP 作为其传输层。


通常,由HTTP客户端发起一个请求,创建一个到服务器指定端口(默认是80端口)的 TCP 连接。HTTP服务器则在那个端口监听客户端的请求。一旦收到请求,服务器会向客户端返回一个状态,比如"HTTP/1.1 200 OK",以及返回的内容,如请求的文件、错误消息、或者其它信息。

2、HTTP协议工作原理与步骤

HTTP 协议定义 Web 客户端如何从 Web 服务器请求 Web 页面,以及服务器如何把 Web 页面传送给客户端。HTTP 协议采用了请求/响应模型。客户端向服务器发送一个请求报文,请求报文包含请求的方法、URL、协议版本、请求头部和请求数据。服务器以一个状态行作为响应,响应的内容包括协议的版本、成功或者错误代码、服务器信息、响应头部和响应数据

以下是 HTTP 请求/响应的步骤:
1. 客户端连接到 Web 服务器

客户端(通常是Web浏览器)与服务器(通常是Web服务器)之间建立TCP连接。这是通信的基础,确保数据可以在客户端和服务器之间稳定传输。一个HTTP客户端,通常是浏览器,与 Web 服务器的 HTTP 端口(默认为 80 )建立一个 TCP 连接。


2. 发送 HTTP 请求

客户端通过TCP连接向服务器发送HTTP请求。请求通常包括请求行(如GET或POST)、请求头部和请求体(对于POST请求,请求体包含要提交的数据)。请求行指定了请求的方法(GET、POST等)、请求的URL和HTTP协议版本。(一个请求报文由请求行、请求头部、空行和请求数据 4 部分组成。)


3. 服务器接受请求并返回 HTTP 响应

服务器接收到请求后,根据请求方法、URL和请求头部等信息,找到相应的资源或执行相应的操作。服务器可能会查询数据库、执行脚本或调用其他API来获取或处理数据。

Web 服务器解析请求,定位请求资源。服务器将资源复本写到 TCP 套接字,由客户端读取。一个响应由状态行、响应头部、空行和响应数据 4 部分组成

服务器将处理结果封装为HTTP响应,并通过TCP连接发送回客户端。响应通常包括状态码(如200表示成功)、响应头部和响应体(包含实际的数据,如HTML页面、图片等)。

4. 客户端浏览器解析 HTML 内容
客户端浏览器首先解析状态行,查看表明请求是否成功的状态代码然后解析每一个响应头,响应头告知以下为若干字节的 HTML 文档和文档的字符集。客户端浏览器读取响应数据 HTML,根据HTML 的语法对其进行格式化,并在浏览器窗口中显示。(客户端接收到响应后,根据状态码和响应头部等信息,判断请求是否成功。如果成功,客户端会解析响应体,并根据需要显示内容或执行其他操作。)


5. 释放连接 TCP 连接
若 connection 模式为 close,则服务器主动关闭 TCP连接,客户端被动关闭连接,释放 TCP 连接;若connection 模式为 keepalive,则该连接会保持一段时间,在该时间内可以继续接收请求;(在一次HTTP通信结束后,客户端和服务器会关闭TCP连接。但在HTTP/1.1版本中,引入了持久连接(也称为Keep-Alive连接),允许在同一个TCP连接上发送多个请求和响应,以提高通信效率。)

HTTP协议是无状态的,即服务器不会保留客户端的状态信息。每个请求都是独立的,服务器不会根据之前的请求来影响当前请求的处理。这种设计使得HTTP协议具有很好的可扩展性和灵活性,但也意味着客户端需要负责管理自己的状态信息。

3. HTTP请求与响应报文格式

请求报文

GET / HTTP/1.1     请求行

Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7  可接收的数据

Accept-Encoding: gzip, deflate, br, zstd   压缩方式

Accept-Language: zh-CN,zh;q=0.9    语言

Connection: keep-alive

Cookie: BD_UPN=12314753; BDUSS=jVINk......

响应报文

HTTP/1.1 200 OK

Connection: keep-alive

Content-Encoding: gzip

Content-Security-Policy: frame-ancestors 'self' https://chat.baidu.com http://mirror-chat.baidu.com https://fj-chat.baidu.com https://hba-chat.baidu.com https://hbe-chat.baidu.com https://njjs-chat.baidu.com https://nj-chat.baidu.com https://hna-chat.baidu.com https://hnb-chat.baidu.com http://debug.baidu-int.com;

Content-Type: text/html; charset=utf-8

Date: Sun, 03 Mar 2024 07:57:22 GMT

Isprivate: 1

Server: BWS/1.1

Set-Cookie: H_PS_PSSID=39662_40210_40207_40217_40272_40291_40284_40317_40080; path=/; expires=Mon, 03-Mar-25 07:57:22 GMT; domain=.baidu.com

Traceid: 1709452642282125185010911918741150816025

X-Ua-Compatible: IE=Edge,chrome=1

X-Xss-Protection: 1;mode=block

Transfer-Encoding: chunked

4. 可能用到知识:

这段代码是C++或C中的枚举(enum)定义,用于表示HTTP请求方法。让我们逐一解析它:

enum METHOD {GET = 0, POST, HEAD, PUT, DELETE, TRACE, OPTIONS, CONNECT};
  1. enum METHOD:这定义了一个名为METHOD枚举类型

  2. {GET = 0, POST, HEAD, PUT, DELETE, TRACE, OPTIONS, CONNECT}:这是枚举的成员列表。每个成员都代表一个HTTP请求方法。

    • GET = 0GET方法被明确地赋值为0
    • POSTHEADPUTDELETETRACEOPTIONS 和 CONNECT这些成员没有被明确赋值,因此它们会自动递增。所以,POST的值是1,HEAD的值是2,依此类推。

枚举是一种用户定义的类型,它允许你为整数值赋予有意义的名称。这样,代码就更易读、易维护了。例如,而不是在代码中直接使用数字0、1、2等来表示不同的HTTP方法,你可以使用GETPOST等更具描述性的名称。

使用这样的枚举,你可以轻松地引用和比较HTTP方法,例如:

METHOD reqMethod = GET;  
  
if (reqMethod == POST) {  
    // 处理POST请求  
} else if (reqMethod == GET) {  
    // 处理GET请求  
}  
// ... 其他方法

struct stat 是 Unix-like 系统(如 Linux)中的一个结构体,用于获取文件或文件系统的状态信息当你想要获取关于文件或文件系统的详细信息时(例如文件大小、文件类型、文件权限等),你可以使用 stat() 系列的函数(如 stat()fstat()lstat() 等)来填充这个结构体。

以下是 struct stat 在 C 语言中的常见定义(注意:不同的系统或不同的库版本可能会有细微的差别):

struct stat {  
    dev_t     st_dev;       /* ID of device containing file */  
    ino_t     st_ino;       /* inode number */  
    mode_t    st_mode;      /* protection */  
    nlink_t   st_nlink;     /* number of hard links */  
    uid_t     st_uid;       /* user ID of owner */  
    gid_t     st_gid;       /* group ID of owner */  
    dev_t     st_rdev;      /* device ID (if special file) */  
    off_t     st_size;      /* total size, in bytes */  
    blksize_t st_blksize;   /* blocksize for filesystem I/O */  
    blkcnt_t  st_blocks;    /* number of 512B blocks allocated */  
    time_t    st_atime;     /* time of last access */  
    time_t    st_mtime;     /* time of last modification */  
    time_t    st_ctime;     /* time of last status change */  
};

这个结构体的各个字段的含义如下:

  • st_dev: 设备ID,表示文件所在的设备。
  • st_ino: inode号,是文件或目录的唯一标识。
  • st_mode: 文件类型和权限。
  • st_nlink: 硬链接的数量。
  • st_uid: 文件所有者的用户ID。
  • st_gid: 文件所有者的组ID。
  • st_rdev: 如果文件是一个特殊文件(如设备文件),这个字段表示设备的ID。
  • st_size: 文件的大小,以字节为单位。
  • st_blksize: 文件系统I/O的块大小。
  • st_blocks: 分配给文件的512字节块的数量。
  • st_atime: 最后一次访问文件的时间。
  • st_mtime: 最后一次修改文件的时间。
  • st_ctime: 最后一次更改文件状态(例如权限或所有权)的时间。

你可以使用 stat() 系列的函数来获取这些信息,例如:

#include <sys/stat.h>  
#include <sys/types.h>  
  
struct stat fileStat;  
if (stat("path/to/file", &fileStat) == 0) {  
    // 使用 fileStat 中的数据  
}

这个示例中,stat() 函数被用来获取名为 "path/to/file" 的文件的状态信息,并将其存储在 fileStat 结构体中。如果函数成功执行,它会返回0,然后你可以使用 fileStat 中的数据。

struct iovec 是一个在Unix-like系统(如Linux)中常用的结构体,它主要用于描述I/O操作中涉及的多个非连续缓冲区。这个结构体通常与readv()writev()等系统调用一起使用,以在一次系统调用中读取或写入多个缓冲区,从而提高I/O操作的效率。

struct iovec {  
    void  *iov_base;    /* Starting address */  
    size_t iov_len;     /* Number of bytes to transfer */  
};
  • iov_base:指向缓冲区起始地址的指针。这个缓冲区可以是用来接收readv()读取的数据,或者是writev()将要写入的数据。
  • iov_len:表示要读取或写入的字节数。对于readv(),它表示接收的最大长度;对于writev(),它表示实际写入的长度。

例如,你可以使用struct iovec来组织多个缓冲区,并使用writev()系统调用一次性将它们写入文件或套接字:

#include <sys/uio.h>  
#include <unistd.h>  
  
struct iovec iov[2];  
char buf1[] = "hello";  
char buf2[] = "world\n";  
  
iov[0].iov_base = buf1;  
iov[0].iov_len = strlen(buf1);  
iov[1].iov_base = buf2;  
iov[1].iov_len = strlen(buf2);  
  
if (writev(fd, iov, 2) == -1) {  
    // 错误处理  
}

在这个例子中,我们定义了两个缓冲区buf1buf2,并将它们的指针和长度信息保存到iov数组中。然后,我们使用writev()系统调用一次性将它们写入到文件描述符fd所代表的文件或套接字中。

总的来说,struct iovec是一个非常实用的结构体,它允许你在一次系统调用中处理多个非连续缓冲区,从而提高了I/O操作的效率

端口复用技术:

端口复用是一种网络通信技术,允许在一个物理端口上同时连接多个逻辑端口。这些逻辑端口可以被不同的应用程序或服务使用,从而提高资源利用率和性能。

在端口复用技术中,每个逻辑端口都有自己的端口号,用于区分不同的应用程序或服务。当有数据需要发送时,发送方会根据目标端口号将数据发送到相应的逻辑端口。接收方则会根据接收到的数据的端口号来确定应该将其转发给哪个应用程序或服务。

端口复用技术的一个典型应用场景是在同一台主机上运行多个网络服务,如Web服务器、邮件服务器等。通过端口复用技术,这些服务可以在同一个物理端口上进行通信,从而节省了宝贵的网络带宽资源。

然而,端口复用技术也存在一定的安全隐患。例如,攻击者可能会尝试伪装成某个应用程序或服务,通过监听特定的端口号来获取敏感信息。为了防范这类攻击,许多操作系统和服务提供商都会对端口的使用进行严格的管理和控制。

总的来说,端口复用技术是一种在网络通信中提高资源利用率和性能的技术,但在实际应用中也需要关注其潜在的安全风险。在使用端口复用技术时,需要采取必要的安全措施来确保网络通信的安全性和可靠性。

在Web服务器中端口复用技术允许在同一物理端口上同时运行多个Web服务或应用程序,从而提高服务器资源的利用率和性能。

防止服务器重启时之前绑定的端口还未释放以及程序突然退出而系统没有释放端口。

服务器断开,处于TIME_WAIT阶段,会等待。这个时候再运行服务器端就会报错。端口还没有释放。

#include <sys/types.h>
#include <sys/socket.h>
// 设置套接字的属性(不仅仅能设置端口复用)
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t
optlen);

参数:
- sockfd : 要操作的文件描述符
- level : 级别 - SOL_SOCKET (端口复用的级别)
- optname : 选项的名称
- SO_REUSEADDR
- SO_REUSEPORT
- optval : 端口复用的值(整形)
- 1 : 可以复用
- 0 : 不可以复用
- optlen : optval参数的大小

端口复用,设置的时机是在服务器绑定端口之前。
setsockopt();
bind();
#include <stdio.h>  
#include <stdlib.h>  
#include <string.h>  
#include <unistd.h>  
#include <sys/types.h>  
#include <sys/socket.h>  
#include <netinet/in.h>  
  
void enablePortReuse(int sockfd) {  
    int reuse = 1;  
    if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1) {  
        perror("setsockopt");  
        exit(EXIT_FAILURE);  
    }  
}  
  
int main() {  
    int server_fd, new_socket;  
    struct sockaddr_in address;  
    int opt = 1;  
    int addrlen = sizeof(address);  
  
    // 创建套接字  
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {  
        perror("socket failed");  
        exit(EXIT_FAILURE);  
    }  
  
    // 设置端口复用  
    enablePortReuse(server_fd);  
  
    address.sin_family = AF_INET;  
    address.sin_addr.s_addr = INADDR_ANY;  
    address.sin_port = htons(8080);  
  
    // 绑定套接字到端口  
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {  
        perror("bind failed");  
        exit(EXIT_FAILURE);  
    }  
  
    // 监听端口  
    if (listen(server_fd, 3) < 0) {  
        perror("listen");  
        exit(EXIT_FAILURE);  
    }  
  
    // 接受客户端连接  
    if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {  
        perror("accept");  
        exit(EXIT_FAILURE);  
    }  
  
    // 在此处处理客户端请求...  
  
    close(new_socket);  
    close(server_fd);  
  
    return 0;  
}

在上面的例子中,我们首先定义了一个enablePortReuse()函数,用于设置端口复用选项。在该函数中,我们调用setsockopt()函数,并传递以下参数:

  • sockfd:要设置选项的套接字文件描述符。
  • SOL_SOCKET:选项所属的协议层,对于端口复用,通常使用SOL_SOCKET
  • SO_REUSEADDR:要设置的选项名称,表示启用端口复用。
  • &reuse:指向包含选项值的指针,通常设置为1表示启用端口复用。
  • sizeof(reuse):选项值的长度。

然后,在main()函数中,我们创建了一个TCP套接字,并调用enablePortReuse()函数来启用端口复用。之后,我们绑定套接字到指定的端口,并开始监听连接。当有客户端连接时,我们接受连接并处理客户端请求。

在客户端,浏览器作为HTTP客户端通过URL向HTTP服务端(即WEB服务器)发送请求。这个过程中,浏览器会创建一个socket实例,并主动发起连接到服务器。一旦连接建立成功,客户端就可以通过这个socket发送HTTP请求并接收服务器的响应。

在服务器端,WEB服务器会监听指定的端口,等待客户端的连接请求。当有一个客户端请求到来时,服务器会接受这个连接,并创建一个新的socket实例来处理这个请求。服务器通过这个socket接收客户端发送的HTTP请求,并发送HTTP响应回客户端。

epoll是Linux内核为处理大批量文件描述符而作的改进的poll,是Linux下多路复用IO接口select/poll的增强版本。它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。

具体来说,epoll相比传统的select/poll有以下几个优势

  1. 效率提升:在获取事件时,epoll无需遍历整个被侦听的描述符集,而只需遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合。这显著提高了处理大量并发连接时的效率。
  2. 支持更多触发模式:除了提供select/poll那种IO事件的水平触发(Level Triggered)外,epoll还提供了边缘触发(Edge Triggered)。这使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,进一步提高应用程序效率。
  3. 描述符限制较少:传统的select方法有一个缺点,即一个进程所打开的FD(文件描述符)数目是有一定限制的,由FD_SETSIZE设置,默认值是1024。而epoll则没有这个限制,因此可以处理更多的并发连接
  4. 高级内存机制:epoll使用了更高级的内存机制(如slab分配和内存共享),避免了内核和用户之间的数据拷贝,进一步提高了效率。
  5. 数据结构优化:epoll在数据结构上也进行了优化,如使用红黑树来提高查询效率,使用就绪链表来存储就绪时间,以及使用中间链表来存储epoll_wait返回后产生的就绪事件。

综上所述,epoll作为一种高效的IO多路复用技术,在Linux系统中得到了广泛应用,尤其在处理大量并发连接时表现出色。

epoll的工作原理和流程如下:

  1. 创建epoll对象:当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象。这个对象也是文件系统中的一员,和socket一样,它也会有等待队列。创建这个epoll对象是为了让内核维护“就绪列表”等数据,这个“就绪列表”可以作为eventpoll的成员。
  2. 维护监视列表:创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。这意味着,当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。
  3. 接收数据:当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。例如,如果sock2和sock3收到数据,中断程序会让rdlist引用这两个socket。
  4. 阻塞和唤醒进程:假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。此时,内核会将进程A放入eventpoll的等待队列中,使其处于阻塞状态。当某个socket有数据到达时,中断程序会唤醒这个进程,使其继续执行。

ET(Edge Triggered)模式设置为非阻塞的主要原因是为了避免在读写操作中发生阻塞,从而提高程序的性能和响应速度。

在ET模式下,只有当读写缓冲区内数据达到一定量时,才会触发事件并进行处理。如果不一次把socket内核缓冲区的数据读完,会导致socket内核缓冲区中即使还有一部分数据,该socket的可读事件也不会被触发。因此,在ET模式下,每次write或read需要循环write或read直到返回EAGAIN错误,或者直到缓冲区为空为止。这样可以确保所有的数据都被读取或写入,而不会因为阻塞而错过任何事件。

如果将ET模式设置为阻塞,那么在读写操作中,程序可能会因为等待数据而阻塞,导致无法及时响应其他事件。这会严重影响程序的性能和响应速度。因此,为了确保ET模式的高效运作,通常会将文件IO设置为非阻塞状态。

即使可以使用 ET 模式一个socket 上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程在读取完某个 socket 上的数据后开始处理这些数据,而在数据的处理过程中该socket 上又有新数据可读(EPOLLIN 再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个 socket 的局面。一个socket连接在任一时刻都只被一个线程处理,可以使用 epoll 的 EPOLLONESHOT 事件实现。
对于注册了 EPOLLONESHOT 事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 epoll_ctl 函数重置该文件描述符上注册的 EPOLLONESHOT 事件。这样,当一个线程在处理某个 socket 时,其他线程是不可能有机会操作该 socket 的。但反过来思考,注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕, 该线程就应该立即重置这个socket 上的 EPOLLONESHOT 事件,以确保这个 socket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket。

EPOLLONESHOT 是 epoll 中的一个标志,当使用此标志时,一旦某个文件描述符上的事件被处理,epoll 将不会再次报告该事件,除非再次显式地注册该事件。这可以防止事件被重复处理。如果不使用 EPOLLONESHOT,那么一旦某个文件描述符上的事件就绪,每次调用 epoll_wait 时都会报告该事件,直到再次注册或重置。

下面是一个使用 EPOLLONESHOT 的示例代码:

#include <stdio.h>  
#include <stdlib.h>  
#include <string.h>  
#include <unistd.h>  
#include <sys/epoll.h>  
#include <sys/socket.h>  
#include <netinet/in.h>  
  
#define MAX_EVENTS 10  
#define PORT 8888  
  
int main() {  
    struct epoll_event ev, events[MAX_EVENTS];  
    int listen_sock, conn_sock, nfds, epollfd;  
  
    // 创建socket并绑定到指定端口  
    listen_sock = socket(AF_INET, SOCK_STREAM, 0);  
    if (listen_sock == -1) {  
        perror("socket creation failed");  
        exit(EXIT_FAILURE);  
    }  
  
    struct sockaddr_in server_addr;  
    memset(&server_addr, 0, sizeof(server_addr));  
    server_addr.sin_family = AF_INET;  
    server_addr.sin_addr.s_addr = INADDR_ANY;  
    server_addr.sin_port = htons(PORT);  
  
    if (bind(listen_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {  
        perror("bind failed");  
        exit(EXIT_FAILURE);  
    }  
  
    if (listen(listen_sock, 5) == -1) {  
        perror("listen failed");  
        exit(EXIT_FAILURE);  
    }  
  
    // 创建epoll实例  
    epollfd = epoll_create1(0);  
    if (epollfd == -1) {  
        perror("epoll_create1 failed");  
        exit(EXIT_FAILURE);  
    }  
  
    // 设置要监听的事件  
    ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT; // 设置EPOLLONESHOT标志  
    ev.data.fd = listen_sock;  
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {  
        perror("epoll_ctl failed");  
        exit(EXIT_FAILURE);  
    }  
  
    for (;;) {  
        // 等待事件发生  
        nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);  
        if (nfds == -1) {  
            perror("epoll_wait failed");  
            exit(EXIT_FAILURE);  
        }  
  
        // 处理事件  
        for (int n = 0; n < nfds; ++n) {  
            if (events[n].data.fd == listen_sock) {  
                // 处理新的连接  
                conn_sock = accept(listen_sock, NULL, NULL);  
                if (conn_sock == -1) {  
                    perror("accept failed");  
                    continue;  
                }  
  
                // 读取数据(由于设置了EPOLLONESHOT,需要重新注册读事件)  
                char buf[1024];  
                ssize_t bytes_read = read(conn_sock, buf, sizeof(buf));  
                if (bytes_read > 0) {  
                    write(conn_sock, buf, bytes_read); // 简单地回显数据  
                } else if (bytes_read == 0 || errno != EAGAIN && errno != EINTR) {  
                    // 连接关闭或错误  
                    close(conn_sock);  
                } else {  
                    // 重新注册读事件(EPOLLONESHOT要求)  
                    ev.events = EPOLLIN | EPOLLET;  
                    ev.data.fd = conn_sock;  
                    if (epoll_ctl(epollfd, EPOLL_CTL_MOD, conn_sock, &ev) == -1) {  
                        perror("epoll_ctl failed");  
                        close(conn_sock);  
                    }  
                }  
            }  
        }  
    }  
  
    close(listen_sock);  
    close(epollfd);

EPOLLET 是 epoll 中的一个标志,用于设置触发模式为边缘触发(Edge Triggered)。在这种模式下,epoll 会在文件描述符的状态发生变化时通知程序,即使这个状态变化只发生了一次。

具体来说,对于读操作,当文件描述符从不可读变为可读(例如,当网络套接字接收到新的数据),epoll 会通知程序。然后,如果程序没有一次性读取所有可用数据,epoll 不会再次通知,直到文件描述符再次变为不可读然后再次变为可读。(因此需要一次性读完所有数据,并设置为读为非阻塞)

对于写操作,当文件描述符从不可写变为可写(例如,当网络套接字的发送缓冲区有足够的空间来发送更多数据),epoll 同样会通知程序。如果程序只写入部分数据,epoll 也不会再次通知,直到文件描述符再次变为不可写然后再次变为可写。

这种边缘触发的行为与**水平触发(Level Triggered)**不同,后者只要文件描述符的状态是可读的或可写的,就会持续不断地通知程序。

在实际应用中,边缘触发模式通常用于处理大并发的服务器,因为它比水平触发模式更高效。然而,这也意味着程序员需要更加小心地处理文件描述符的读写操作,确保在一次循环中处理完所有可用数据,否则可能需要使用 epoll_ctl 函数来重新注册事件,以便在下一次循环时再次接收通知。

在 C/C++ 中,recv 函数是用于从一个套接字(socket)接收数据的函数,它是 socket 编程中的核心函数之一。这个函数通常用于面向连接的套接字类型(如 TCP)。下面是 recv 函数的基本用法和参数说明:

原型

<sys/socket.h>(POSIX 系统,如 Linux 和 macOS)或者 <winsock2.h>(Windows)头文件中定义:

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

参数

  • sockfd: 套接字描述符,它是通过 socket 函数创建的,代表一个套接字。
  • buf: 指向用于接收数据的缓冲区的指针
  • len: 缓冲区的长度,即可以接收的最大字节数
  • flags: 用于指定接收选项的标志位,通常设置为 0。其他选项可以是 MSG_PEEK(查看数据但不从系统缓冲区移除)、MSG_WAITALL(等待直到接收到指定数量的数据)等。

返回值

  • 成功时,返回接收到的字节数,如果连接已经正常关闭,则返回 0。
  • 出错时,返回 -1,并设置 errno 以指示错误类型。

示例代码

下面是一个使用 recv 函数接收数据的简单例子:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>

int main() {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};
    
    // 创建 socket
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }
    
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);
    
    // 绑定 socket
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    
    // 监听 socket
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }
    
    // 接受连接
    if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0) {
        perror("accept");
        exit(EXIT_FAILURE);
    }
    
    // 接收数据
    int valread = recv(new_socket, buffer, 1024, 0);
    printf("%d bytes received. Message: %s\n", valread, buffer);
    
    // 关闭 socket
    close(new_socket);
    close(server_fd);
    
    return 0;
}

在非阻塞模式的 socket 编程中,EAGAINEWOULDBLOCK 错误码的出现具有特殊含义。这两个错误码通常在非阻塞操作中表示资源暂时不可用,具体到 recv 函数,它们表示当前没有数据可供读取。在不同的系统或情况下,可能会遇到其中一个错误码,但它们的含义是相同的。为了保证兼容性,代码中经常会同时检查这两个错误码。

EAGAINEWOULDBLOCK 的含义

  • EAGAIN: 尝试的操作无法立即完成,并且如果重试操作,它可能会成功。在非阻塞模式下,如果没有数据可读,recv 会返回 -1 并且 errno 被设置为 EAGAIN,表示没有数据可供立即读取,应用程序可以在之后再尝试。
  • EWOULDBLOCK: 本质上与 EAGAIN 相同,在非阻塞模式下表示资源暂时不可用。在某些系统上,EWOULDBLOCK 是一个独立的错误码,而在其他系统中,它可能与 EAGAIN 的值相同。

代码解释

在你的代码段中:

byte_read = recv(m_sockfd, m_read_buf + m_read_index, READ_BUFFER_SIZE - m_read_index, 0);
if(byte_read == -1)
{
    if(errno == EAGAIN || errno == EWOULDBLOCK)
    {
        //没有数据
        break;
    }
    return false;
}
  • 首先尝试从套接字 m_sockfd 读取数据到缓冲区 m_read_bufm_read_index 索引处开始的位置,最多读取 READ_BUFFER_SIZE - m_read_index 字节的数据。
  • 如果 recv 返回 -1,则检查 errno 是否为 EAGAINEWOULDBLOCK
    • 如果是,表示当前没有数据可读,这并不是一个错误情况;程序可能会跳出当前的处理循环,可能稍后再试。
    • 如果不是,表示遇到了其他错误,函数返回 false 表示接收失败。

这种处理方式是非阻塞 I/O 操作的典型模式,它允许程序在等待 I/O 操作时继续执行其他任务,而不是阻塞等待,从而提高了程序的整体效率和响应性。

http_conn::LINE_STATUS http_conn::parse_line()
{
    char temp;

    for (; m_checked_index < m_read_index; ++m_checked_index) //遍历一行数据
    {
        temp = m_read_buf[m_checked_index];
        //有问题的数据
        if (temp == '\r')
        {
            if((m_checked_index + 1) == m_read_index)
            {
                return LINE_OPEN;
            } else if(m_read_buf[m_checked_index + 1] == '\n')
            {
                m_read_buf[m_checked_index++] = '\0'; //字符串结束符,\r\n字符串结束了,把\r\n变成\0
                m_read_buf[m_checked_index++] = '\0';
                return LINE_OK;
            }
            return LINE_BAD;
        }else if (temp == '\n')
        {
            if((m_checked_index > 1) && (m_read_buf[m_checked_index - 1] == '\r'))
            {
                m_read_buf[m_checked_index-1] = '\0';
                m_read_buf[m_checked_index++] = '\0';
                return LINE_OK;
            }
            return LINE_BAD;
        }
        return LINE_OPEN;
    }

}

这段代码是一个 HTTP 请求解析函数的一部分,用于逐字节检查接收到的数据,以确定 HTTP 请求行的结束。HTTP 协议规定,一个请求行(以及头部行)的结束标志是连续的回车符('\r')和换行符('\n')。

函数 http_conn::parse_line() 的目的是遍历缓冲区 m_read_buf 中的字符,寻找行结束符("\r\n"),并根据找到的内容返回行的状态。这个函数是 HTTP 请求解析过程中的一部分,它帮助确定每一行的边界,这对于解析请求头部是必要的。这里是函数行为的概述:

返回值

该函数返回一个 LINE_STATUS 枚举类型的值,表明当前行的解析状态:

  • LINE_OK:成功找到一个完整的行。
  • LINE_BAD:行数据有问题,不符合预期的格式。
  • LINE_OPEN:当前行未完成,需要继续读取数据。

函数逻辑

这段代码是一个 HTTP 请求解析函数的一部分,用于逐字节检查接收到的数据,以确定 HTTP 请求行的结束。HTTP 协议规定,一个请求行(以及头部行)的结束标志是连续的回车符('\r')和换行符('\n')。

函数 http_conn::parse_line() 的目的是遍历缓冲区 m_read_buf 中的字符,寻找行结束符("\r\n"),并根据找到的内容返回行的状态。这个函数是 HTTP 请求解析过程中的一部分,它帮助确定每一行的边界,这对于解析请求头部是必要的。这里是函数行为的概述:

返回值

该函数返回一个 LINE_STATUS 枚举类型的值,表明当前行的解析状态:

  • LINE_OK:成功找到一个完整的行。
  • LINE_BAD:行数据有问题,不符合预期的格式。
  • LINE_OPEN:当前行未完成,需要继续读取数据。

函数逻辑

  1. 遍历 m_read_buf 缓冲区,从 m_checked_index 开始,直到 m_read_index 结束(m_checked_indexm_read_index 分别标记已检查和已读取的位置)。

  2. 如果当前字符是回车符('\r'):

    • 如果回车符是当前已接收数据的最后一个字符,表示行未结束,需要继续接收数据,返回 LINE_OPEN
    • 如果回车符后面紧跟换行符('\n'),则将回车换行符替换为字符串结束标志('\0'),表示找到了一行的结束,返回 LINE_OK
    • 如果回车符后面不是换行符,表示行格式有误,返回 LINE_BAD
  3. 如果当前字符是换行符('\n'):

    • 如果换行符前一个字符是回车符('\r')且不是第一个检查的字符(保证 '\r\n' 序列是合法的),也将它们替换为字符串结束标志,并返回 LINE_OK
    • 否则,表示行格式有误,返回 LINE_BAD

注意事项

  • 代码中对 m_checked_index 的操作非常关键,它确保连续的 \r\n 被正确处理,并在找到行尾时更新索引以跳过这两个字符。
  • 如果遍历结束都没有找到行尾标志,函数应当返回 LINE_OPEN,表示当前信息不完整,需要继续读取数据。

这段代码是处理 HTTP 请求的基础部分,正确解析请求行和头部对于理解和响应请求至关重要。

http_conn::HTTP_CODE http_conn::parse_request_line(char* text) {
    // GET /index.html HTTP/1.1
    m_url = strpbrk(text, " \t"); // 判断第二个参数中的字符哪个在text中最先出现
    if (! m_url) { 
        return BAD_REQUEST;
    }
    // GET\0/index.html HTTP/1.1
    *m_url++ = '\0';    // 置位空字符,字符串结束符
    char* method = text;
    if ( strcasecmp(method, "GET") == 0 ) { // 忽略大小写比较
        m_method = GET;
    } else {
        return BAD_REQUEST;
    }
    // /index.html HTTP/1.1
    // 检索字符串 str1 中第一个不在字符串 str2 中出现的字符下标。
    m_version = strpbrk( m_url, " \t" );
    if (!m_version) {
        return BAD_REQUEST;
    }
    *m_version++ = '\0';
    if (strcasecmp( m_version, "HTTP/1.1") != 0 ) {
        return BAD_REQUEST;
    }
    /**
     * http://192.168.110.129:10000/index.html
    */
    if (strncasecmp(m_url, "http://", 7) == 0 ) {   
        m_url += 7;
        // 在参数 str 所指向的字符串中搜索第一次出现字符 c(一个无符号字符)的位置。
        m_url = strchr( m_url, '/' );
    }
    if ( !m_url || m_url[0] != '/' ) {
        return BAD_REQUEST;
    }
    m_check_state = CHECK_STATE_HEADER; // 检查状态变成检查头
    return NO_REQUEST;
}

这段代码是一个 HTTP 请求处理函数的一部分,具体来说,它负责解析 HTTP 请求行。HTTP 请求行是一个 HTTP 请求的第一行通常包含了请求方法(如 GET 或 POST)、请求的 URI(统一资源标识符)、以及 HTTP 协议的版本。函数的目标是从这个请求行中提取出这些信息,并做初步的格式合法性检查。

函数 http_conn::parse_request_line(char* text) 的步骤如下:

  1. 解析请求方法: 使用 strpbrk 函数查找 text 中第一次出现空格或制表符的位置,这通常是分隔请求方法和请求 URI 的地方。如果找不到,意味着请求行格式不正确,函数返回 BAD_REQUEST

  2. 标记请求方法的结束并保存: 通过在找到的位置写入 \0(字符串结束符),将请求方法从其余部分分隔开。随后,m_url 指针向前移动,跳过结束符,指向请求 URI 的起始位置。

  3. 比较请求方法: 使用 strcasecmp 函数忽略大小写地比较请求方法。如果方法是 "GET",则设置相应的内部状态。目前代码只处理 GET 请求,其他方法会导致返回 BAD_REQUEST

  4. 解析请求 URI 和 HTTP 版本: 同样使用 strpbrk 查找 URI 和版本之间的分隔符,然后用 \0 分隔 URI 和版本字符串。如果找不到版本信息,返回 BAD_REQUEST

  5. 验证 HTTP 版本: 使用 strcasecmp 检查 HTTP 版本是否为 "HTTP/1.1"。如果不是,返回 BAD_REQUEST

  6. 处理可能的绝对 URL: 如果 URI 以 "http://" 开头,代码将跳过这7个字符,并查找下一个斜杠 ('/') 的位置,这标志着主机部分的结束和路径部分的开始。如果处理后的 URI 不以斜杠 ('/') 开头,表明格式有误,返回 BAD_REQUEST

  7. 状态转移: 如果以上步骤都成功,那么请求行被成功解析,函数将内部状态设置为 CHECK_STATE_HEADER以准备解析接下来的头部信息,并返回 NO_REQUEST 表明目前请求还需要进一步的处理

这个函数是 HTTP 请求处理流程中的第一步,它只负责解析请求行。解析成功后,会进一步解析请求头和请求体(如果有的话)。通过这种方式,服务器能够理解和响应客户端的请求。

strpbrk 函数是 C/C++ 标准库中的一个字符串处理函数,其原型定义在 <cstring>(在 C 中为 <string.h>)头文件中。该函数用于搜索一个字符串中第一次出现的任何一个指定字符集中的字符。

函数原型

char *strpbrk(const char *str1, const char *str2);

参数

  • str1: 要被搜索的 C 字符串
  • str2: 包含了需要在 str1 中查找的字符集的 C 字符串。

返回值

  • 如果找到了 str2 中的任何字符在 str1 中的第一次出现,strpbrk 函数返回一个指向 str1 中第一个匹配字符的指针
  • 如果 str2 中的字符没有在 str1 中出现,则返回 NULL

用法示例

#include <iostream>
#include <cstring>

int main() {
    const char *str1 = "example.com";
    const char *str2 = "aeiou";
    char *result;

    result = strpbrk(str1, str2);
    if (result) {
        std::cout << "The first vowel in '" << str1 << "' is '" << *result << "'." << std::endl;
    } else {
        std::cout << "No vowels found in '" << str1 << "'." << std::endl;
    }

    return 0;
}

在这个例子中,strpbrk 用于查找 str1("example.com")中出现的第一个在 str2("aeiou",即所有元音字符)中的字符。在此例中,它将找到 'e' 作为第一个出现在 str1 中的元音字符,并打印相关信息。如果 str1 中没有元音字符,则 result 将是 NULL,相应的信息将被打印出来。

注意事项

  • strpbrk 是处理 C 风格字符串的函数。当在 C++ 中使用 C 风格字符串时,应当谨慎处理指针和内存,以避免安全问题。
  • 返回的指针直接指向 str1 中的字符,不应该通过返回的指针修改字符串内容,除非你确切知道你在做什么。

strchr 函数是 C/C++ 标准库中用于字符串处理的一个函数,它用于查找字符串中第一次出现的指定字符。这个函数的原型定义在 <cstring>(在 C 中为 <string.h>)头文件中。

char *strchr(const char *str, int character);

参数

  • str: 指向要被搜索的 C 风格字符串的指针。
  • character: 这是一个 int 类型的值,表示要查找的字符。尽管参数类型是 int,但实际上函数会将其转换为一个 unsigned char 类型的值,然后在 str 指向的字符串中查找这个字符。

返回值

  • 如果找到了指定的字符,则返回一个指向该字符在字符串中第一次出现位置的指针
  • 如果在给定的字符串中没有找到指定的字符,则返回 NULL

用法示例

#include <iostream>
#include <cstring>

int main() {
    const char *str = "Hello, world!";
    char ch = 'w';

    char *result = strchr(str, ch);
    if (result) {
        std::cout << "The character '" << ch << "' is found in \"" << str
                  << "\" at position: " << (result - str) << std::endl;
    } else {
        std::cout << "The character '" << ch << "' is not found in \"" << str << "\"." << std::endl;
    }

    return 0;
}

在这个示例中,strchr 用于查找字符 'w' 在字符串 "Hello, world!" 中的位置。如果找到了这个字符,它将打印该字符的位置;如果没有找到,则会打印相应的消息。

注意事项

  • 尽管 character 参数类型为 int,但是你应该传递一个能够表示为 unsigned char 的值,以避免潜在的字符编码问题。
  • 返回的指针指向原字符串中的字符。如果你试图通过这个指针修改字符串内容(假设字符串是可修改的),请确保你了解自己在做什么,以避免破坏字符串或造成未定义行为。
  • 在处理 C 风格字符串时,务必保证字符串是以空字符('\0')结尾的,这是 strchr 函数正常工作的前提。

http_conn::HTTP_CODE http_conn::parse_headers(char* text) {   
    // 遇到空行,表示头部字段解析完毕
    if( text[0] == '\0' ) {
        // 如果HTTP请求有消息体,则还需要读取m_content_length字节的消息体,
        // 状态机转移到CHECK_STATE_CONTENT状态
        if ( m_content_length != 0 ) {
            m_check_state = CHECK_STATE_CONTENT;
            return NO_REQUEST;
        }
        // 否则说明我们已经得到了一个完整的HTTP请求
        return GET_REQUEST;
    } else if ( strncasecmp( text, "Connection:", 11 ) == 0 ) {
        // 处理Connection 头部字段  Connection: keep-alive
        text += 11;
        text += strspn( text, " \t" );
        if ( strcasecmp( text, "keep-alive" ) == 0 ) {
            m_linger = true;
        }
    } else if ( strncasecmp( text, "Content-Length:", 15 ) == 0 ) {
        // 处理Content-Length头部字段
        text += 15;
        text += strspn( text, " \t" );
        m_content_length = atol(text);
    } else if ( strncasecmp( text, "Host:", 5 ) == 0 ) {
        // 处理Host头部字段
        text += 5;
        text += strspn( text, " \t" );
        m_host = text;
    } else {
        printf( "oop! unknow header %s\n", text );
    }
    return NO_REQUEST;
}

这段代码是一个 HTTP 请求解析函数的一部分,用于处理和解析 HTTP 请求头部。它是根据 HTTP 协议规范来识别和处理不同的 HTTP 头部字段。函数的主要目的是根据接收到的头部字段来设置相应的内部变量,以便后续处理 HTTP 请求。下面是对这段代码的逐行解释:

函数原型解释

http_conn::HTTP_CODE http_conn::parse_headers(char* text)

  • 这是一个成员函数,属于 http_conn 类。
  • 函数接收一个指向字符数组(C 风格字符串)的指针 text该字符串包含了一个 HTTP 头部字段。
  • 返回值是一个 HTTP_CODE 枚举值,表示解析状态或结果

代码逻辑

  1. 空行检测

    • 如果 text[0] == '\0',表示遇到了一个空行。根据 HTTP 协议,头部字段之后的空行表示头部结束,接下来可能是消息体
    • 如果存在 Content-Length(之前解析得到的 m_content_length 不为 0),表示请求中有消息体,需要读取相应长度的数据,状态机转移到 CHECK_STATE_CONTENT
    • 如果没有消息体(m_content_length == 0),表示已经接收到了一个完整的 HTTP 请求,返回 GET_REQUEST
  2. 解析 Connection 头部

    • 使用 strncasecmp 函数比较当前行是否以 "Connection:" 开头,忽略大小写。
    • 如果是,跳过 "Connection:" 及其后的空白字符。
    • 检查值是否为 "keep-alive",如果是,则设置 m_lingertrue,表示持久连接。
  3. 解析 Content-Length 头部

    • 类似地,检查是否以 "Content-Length:" 开头。
    • 跳过 "Content-Length:" 及其后的空白字符。
    • 使用 atol 函数将剩余部分转换为长整型数值,设置 m_content_length,表示消息体的长度。
  4. 解析 Host 头部

    • 检查是否以 "Host:" 开头。
    • 跳过 "Host:" 及其后的空白字符。
    • 剩余的字符串指向请求的主机名,保存到 m_host
  5. 其他头部字段

    • 如果头部字段不是上述任何一种,打印未知头部字段的消息。

函数返回值

  • 对于大部分头部字段的处理(除了遇到空行且没有内容长度的情况),函数返回 NO_REQUEST,表示当前请求尚未完全解析完成,需要继续读取数据。
  • 如果解析完所有头部后没有内容长度,返回 GET_REQUEST,表示已经接收并解析了一个完整的 HTTP 请求。

这个函数体现了对 HTTP 请求头部的基本解析流程和逻辑,处理了一些常见的头部字段,并且根据头部字段的不同设置了不同的内部状态,为后续处理请求体或完成请求处理做准备。

  • 17
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
以下是一个基于Vue的web项目程序,可以解析学号中的入学年份、所在专业、班级: 1. 创建项目 首先,我们需要使用Vue CLI创建一个Vue项目。在终端中输入以下命令: ``` vue create stu-parser ``` 然后按照提示进行配置,创建一个新的Vue项目。 2. 创建组件 在src目录下创建一个名为StuParser.vue的组件,并将下面的代码复制进去: ``` <template> <div> <h2>解析学号</h2> <label for="stuNum">请输入学号:</label> <input type="text" id="stuNum" v-model="stuNum" /> <button @click="parseStuNum">解析</button> <br /> <br /> <p v-if="parsed">入学年份:{{ year }},专业:{{ major }},班级:{{ className }}</p> </div> </template> <script> export default { data() { return { stuNum: "", parsed: false, year: "", major: "", className: "", }; }, methods: { parseStuNum() { const year = this.stuNum.slice(0, 4); const department = this.stuNum.slice(4, 6); const className = this.stuNum.slice(6, 8); const number = this.stuNum.slice(8, 11); let major = ""; if (department === "01") { major = "物联网"; } else if (department === "02") { major = "信息安全"; } let classNameStr = ""; if (className === "01") { classNameStr = "一班"; } else if (className === "02") { classNameStr = "二班"; } this.year = year; this.major = major; this.className = classNameStr; this.parsed = true; }, }, }; </script> ``` 3. 在App.vue中引入组件 在App.vue中引入StuParser组件,并将其添加到模板中。代码如下: ``` <template> <div id="app"> <StuParser /> </div> </template> <script> import StuParser from "./components/StuParser.vue"; export default { name: "App", components: { StuParser, }, }; </script> ``` 4. 运行项目 在终端中进入项目目录,然后输入以下命令运行项目: ``` npm run serve ``` 然后在浏览器中打开http://localhost:8080/,就可以看到一个解析学号的页面了。在输入框中输入学号,然后点击“解析”按钮,就可以在页面上看到解析出来的入学年份、所在专业和班级了。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值