基于 Reactor 模式的 HTTP 协议扩展实现 - 详解

        为让Reactor模式下的TCP服务器具备网页服务能力,通过集成HTTP协议,对其进行网页端功能扩展,使其升级为可响应浏览器请求的Web服务器。

        TCP服务器实现部分Reactor 模式实现:从 epoll 到高并发调试-CSDN博客

1.功能实现

        将底层I/O与上层业务进行分离,提高I/O性能,更适配高并发,也便于维护和扩展

1.1全局变量部分

        conn结构体中加入状态机,共三个阶段

0(生成并发送响应头)   服务器向客户端发送数据的准备阶段,将响应头写入缓冲区,修改缓冲区大小,后进入1阶段

1(发送响应体)         向客户端发送数据的发送阶段,若数据长度过大,分段多次发送,发送完毕后进入2阶段

2(清空缓存)           发送数据的结束阶段,缓冲区置为空,长度置为0,进入0阶段

1.2上层业务部分

        webserver.c文件,接收新数据前的准备函数

int http_request(struct conn *c){
    //打印提示
    printf("request: %s\n", c->rbuffer);
    //清空数据,长度归零
    memset(c->wbuffer, 0, BUFFER_LENGTH);
    c->wlength = 0;
    //设置状态为0,进入发送数据的准备阶段
    c->status = 0;
}

        发送数据的具体实现

int http_response(struct conn *c){
//简化版,无状态机
#if 1
    //生成响应头并写入缓冲区
    c->wlength = sprintf(c->wbuffer,
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/html\r\n"
        "Accept-Ranges: bytes\r\n"
        "Content-Length: 82\r\n"
        "Date: Tue, 30 Apr 2024 13:16:46 GMT\r\n\r\n"
        "Avogado6

Avogado6

\r\n\r\n"); //将html文件映射到网页中 #elif 0 //创建文件描述符只读模式获取文件内容 int filefd = open("index.html", O_RDONLY); //定义文件状态结构体,用于获取文件长度 struct stat stat_buf; //获取文件数据 fstat(filefd, &stat_buf); if(c->status == 0){//准备阶段 //生成响应头并写入缓冲区 //printf("c->status == 1"); c->wlength = sprintf(c->wbuffer, "HTTP/1.1 200 OK\r\n" "Content-Type: text/html\r\n" "Accept-Ranges: bytes\r\n" "Content-Length: %ld\r\n" "Date: Tue, 30 Apr 2024 13:16:46 GMT\r\n\r\n", stat_buf.st_size); //更新状态至发送阶段 c->status = 1; }else if(c->status == 1){//发送阶段 //调用sendfile函数,一次性映射完整文件(简化操作) int ret = sendfile(c->fd, filefd, NULL, stat_buf.st_size); //映射失败时的处理 if(ret == -1){ printf("send file error: %d\n", errno); } //发送完成,更新状态至清空缓存 c->status = 2; }else if(c->status == 2){//清空缓存 //缓冲区数据置空,长度置0 c->wlength = 0; memset(c->wbuffer, 0, BUFFER_LENGTH); //发送数据结束,更新状态至准备阶段,准备下一次发送数据 c->status = 0; } //发送完成,关闭文件描述符 close(filefd); //将图片文件映射到网页中 #elif 0 //创建文件描述符只读模式获取图片内容 int filefd = open("Avogado6.jpg", O_RDONLY); //定义文件状态结构体,获取图片大小 struct stat stat_buf; fstat(filefd, &stat_buf); if(c->status == 0){//准备阶段 //生成响应头并写入缓冲区 c->wlength = sprintf(c->wbuffer, "HTTP/1.1 200 OK\r\n" "Content-Type: image/jpg\r\n" //发送图片更改文件格式 "Accept-Ranges: bytes\r\n" "Content-Length: %ld\r\n" "Date: Tue, 30 Apr 2024 13:16:46 GMT\r\n\r\n", stat_buf.st_size); //更新状态至发送阶段 c->status = 1; }else if(c->status == 1){//发送阶段 //调用sendfile函数,一次性映射完整文件 int ret = sendfile(c->fd, filefd, NULL, stat_buf.st_size); //映射失败时的处理 if(ret == -1){ printf("send file error: %d\n", errno); } //发送完成,更新状态至清空缓存 c->status = 2; }else if(c->status == 2){//清空缓存 //缓冲区数据置空,长度置0 c->wlength = 0; memset(c->wbuffer, 0, BUFFER_LENGTH); //发送数据结束,更新状态至准备阶段,准备下一次发送数据 c->status = 0; } //发送完成,关闭文件描述符 close(filefd); #endif return c->wlength; }

1.3底层I/O处理部分

        recv_cb函数中调用http_request函数,对当前连接执行http请求的初始化操作

http_request(&conn_list[fd]);

        send_cb先调用http_response函数对当前连接执行http的生成响应内容操作

http_response(&conn_list[fd]);

        再通过状态机,对不同阶段进行不同处理

if(conn_list[fd].status == 1){//状态为发送数据阶段时
    //调用send函数将发送缓冲区中的数据发送至客户端fd
    count = send(fd, conn_list[fd].wbuffer, conn_list[fd].wlength, 0);
    //调用set_event函数将该fd连接信息中的关注事件修改为可写,以便继续发送剩余数据
    set_event(fd, EPOLLOUT, 0);
}else if(conn_list[fd].status == 2){//状态为清空缓存阶段时
    //调用set_event函数将该fd连接信息中的关注事件修改为可写,准备执行清空缓存操作
    set_event(fd, EPOLLOUT, 0);
}else if(conn_list[fd].status == 0){//状态为准备阶段时
    //处理不使用状态机的简单发送操作
    if (conn_list[fd].wlength != 0) {
        count = send(fd, conn_list[fd].wbuffer, conn_list[fd].wlength, 0);
    }
    //调用set_event函数将该fd连接信息中的关注事件修改为可读,准备接收数据
    set_event(fd, EPOLLIN, 0);
}

2.功能测试

2.1固定 HTML 响应生成

        运行该部分

//生成响应头并写入缓冲区
c->wlength = sprintf(c->wbuffer,
    "HTTP/1.1 200 OK\r\n"
    "Content-Type: text/html\r\n"
    "Accept-Ranges: bytes\r\n"
    "Content-Length: 82\r\n"
    "Date: Tue, 30 Apr 2024 13:16:46 GMT\r\n\r\n"
    "Avogado6

Avogado6

\r\n\r\n");

        运行结果,网页端访问该端口

        对该功能进行并发性能测试(简化输出)
         wrk -c50 -t10 -d10s http://192.168.147.130:2000
        50个并发连接,10个线程,持续10s的压力测试

        测试结果

Running 10s test @ http://192.168.147.130:2000
  10 threads and 50 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   542.43us  145.23us   8.75ms   80.37%
    Req/Sec     9.11k     0.89k   13.51k    72.42%
  914084 requests in 10.10s, 178.71MB read
Requests/sec:  90504.27
Transfer/sec:     17.69MB

        共处理90万次,平均延迟低于半毫秒,延迟波动小

2.2HTML 文件响应

运行该部分

//创建文件描述符只读模式获取文件内容
int filefd = open("index.html", O_RDONLY);
//定义文件状态结构体,用于获取文件长度
struct stat stat_buf;
//获取文件数据
fstat(filefd, &stat_buf);
if(c->status == 0){//准备阶段
    //生成响应头并写入缓冲区
    //printf("c->status == 1");
    c->wlength = sprintf(c->wbuffer,
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/html\r\n"
        "Accept-Ranges: bytes\r\n"
        "Content-Length: %ld\r\n"
        "Date: Tue, 30 Apr 2024 13:16:46 GMT\r\n\r\n",
        stat_buf.st_size);
    //更新状态至发送阶段
    c->status = 1;
}else if(c->status == 1){//发送阶段
    //调用sendfile函数,一次性映射完整文件(简化操作)
    int ret = sendfile(c->fd, filefd, NULL, stat_buf.st_size);
    //映射失败时的处理
    if(ret == -1){
        printf("send file error: %d\n", errno);
    }
    //发送完成,更新状态至清空缓存
    c->status = 2;
}else if(c->status == 2){//清空缓存
    //缓冲区数据置空,长度置0
    c->wlength = 0;
    memset(c->wbuffer, 0, BUFFER_LENGTH);
    //发送数据结束,更新状态至准备阶段,准备下一次发送数据
    c->status = 0;
}
//发送完成,关闭文件描述符
close(filefd);

        运行结果,网页端访问该端口

2.3图片文件响应

        运行该部分

//创建文件描述符只读模式获取图片内容
int filefd = open("Avogado6.jpg", O_RDONLY);
//定义文件状态结构体,获取图片大小
struct stat stat_buf;
fstat(filefd, &stat_buf);
if(c->status == 0){//准备阶段
    //生成响应头并写入缓冲区
    c->wlength = sprintf(c->wbuffer,
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: image/jpg\r\n"  //发送图片更改文件格式
        "Accept-Ranges: bytes\r\n"
        "Content-Length: %ld\r\n"
        "Date: Tue, 30 Apr 2024 13:16:46 GMT\r\n\r\n",
        stat_buf.st_size);
    //更新状态至发送阶段
    c->status = 1;
}else if(c->status == 1){//发送阶段
    //调用sendfile函数,一次性映射完整文件
    int ret = sendfile(c->fd, filefd, NULL, stat_buf.st_size);
    //映射失败时的处理
    if(ret == -1){
        printf("send file error: %d\n", errno);
    }
    //发送完成,更新状态至清空缓存
    c->status = 2;
}else if(c->status == 2){//清空缓存
    //缓冲区数据置空,长度置0
    c->wlength = 0;
    memset(c->wbuffer, 0, BUFFER_LENGTH);
    //发送数据结束,更新状态至准备阶段,准备下一次发送数据
    c->status = 0;
}
//发送完成,关闭文件描述符
close(filefd);

        运行结果,网页端访问该端口

3.方法总结

        整个web服务器遵循底层I/O与上层业务分离的原则,通过Reactor模式处理I/O事件+状态机管理HTTP响应流程的方法,实现高效、可扩展的web服务器的相应功能,若需扩展功能,只需修改http业务部分

        http_request函数进行http请求的初始化,作为业务逻辑的入口
        http_response函数通过条件编译实现三种业务功能
                固定HTML响应,生成响应头后直接写入缓冲区
                HTML文件/图片响应, 读取文件后,根据状态机阶段,分别进行生成响应头写入缓冲区,发送完整数据,清空缓存三个阶段
                调用send_file函数进行发送数据操作,提升文件传输效率
        send_cb函数根据状态机的不同阶段,实现向客户端发送数据的操作

4.完整代码

        server.h

#ifndef __SERVER_H__
#define __SERVER_H__
#define BUFFER_LENGTH       1024
//声明处理事件的回调函数类型,统一接口便于分发时间
typedef int (*RCALLBACK)(int fd);
//连接信息结构体
struct conn{
    //套接字,客户端fd或者监听fd
    int fd;
    //设置状态机,共三个阶段
    //0(生成并发送响应头)   服务器向客户端发送数据的准备阶段,将响应头写入缓冲区,修改缓冲区大小,后进入1阶段
    //1(发送响应体)         向客户端发送数据的发送阶段,若数据长度过大,分段多次发送,发送完毕后进入2阶段
    //2(清空缓存)           发送数据的结束阶段,缓冲区置为空,长度置为0,进入0阶段
    int status;
    //读写数据的缓冲区数组和大小
    char rbuffer[BUFFER_LENGTH];
    int rlength;
    char wbuffer[BUFFER_LENGTH];
    int wlength;
    //把回调函数的指针加入结构体
    RCALLBACK send_callback;
    //互斥状态的两个回调函数指针,共同体的形式加入结构体(客户端fd调用recv_callback,监听fd调用accept_callback)
    //共同体,所有成员公用同一块内存空间,节省内存
    union{
        RCALLBACK recv_callback;
        RCALLBACK accept_callback;
    } r_action;
};
int http_request(struct conn *c);
int http_response(struct conn *c);
#endif

        reactor.c

#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "server.h"
//宏定义设定缓冲区和连接列表的大小
#define CONNECTION_SIZE     1048576
#define MAX_PORTS            20
//计算耗时
#define TIME_SUB_MS(tv1, tv2)   ((tv1.tv_sec - tv2.tv_sec) * 1000 + (tv1.tv_usec - tv2.tv_usec) / 1000)
//epoll实例的全局变量,各函数中操作同一个epoll
int epfd = 0;
//具体回调函数的声明
int recv_cb(int fd);
int accept_cb(int fd);
int send_cb(int fd);
//创建开始时时间结构体变量
struct timeval begin;
//用数组存储所有连接
struct conn conn_list[CONNECTION_SIZE] = {0};
//添加/修改epoll事件
int set_event(int fd, int event, int flag){
    //flag非零时,添加epoll事件
    if(flag){//no-zero add
        //创建epoll_event类型变量
        struct epoll_event ev;
        //设定关注的事件类型
        ev.events = event;
        //将当前fd存入ev的data.fd对象中
        ev.data.fd = fd;
        //添加到epfd的epoll实例中
        epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
    }else{//zero mod
        struct epoll_event ev;
        ev.events = event;
        ev.data.fd = fd;
        //修改epfd中的epoll实例
        epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
    }
}
//为epoll实例中添加新的客户端连接
int event_register(int fd, int event){
    if(fd < 0) return -1;
    //初始化连接信息,绑定fd,设置回调函数
    conn_list[fd].fd = fd;
    //添加连接,共同体中选择recv_cb回调函数
    conn_list[fd].r_action.recv_callback = recv_cb;
    conn_list[fd].send_callback = send_cb;
    //初始化缓冲区
    conn_list[fd].rlength = 0;
    conn_list[fd].wlength = 0;
    //调用set_event函数添加事件,并监控可读事件
    set_event(fd, EPOLLIN, 1);
}
//listen(sockfd) --> EPOLLIN --> accept_cb
//接收客户端连接请求,创建客户端fd并完成初始化
int accept_cb(int fd){
    //定义客户端地址结构体,计算长度
    struct sockaddr_in clientaddr;
    socklen_t len = sizeof(clientaddr);
    //调用accept函数,从监听fd接收数据并创建对应地址的客户端fd
    int clientfd = accept(fd, (struct sockaddr*)&clientaddr, &len);
    //printf("accept finished: %d\n", clientfd);
    if(clientfd < 0){
        printf("accept error: %d\n", errno);
        return -1;
    }
    //调用event_register函数初始化连接信息,关注可读事件
    event_register(clientfd, EPOLLIN);
    if(clientfd % 1000 == 0){
        //获取每建立1000个连接时的时间
        struct timeval current;
        gettimeofday(¤t, NULL);
        //计算耗时
        int time_used = TIME_SUB_MS(current, begin);
        //更新每次时间开始值
        memcpy(&begin, ¤t, sizeof(struct timeval));
        printf("accept finished: %d, time_used: %d\n", clientfd, time_used);
    }
    return 0;
}
//客户端fd触发EPOLLIN事件的回调函数,接收客户端发送的数据
int recv_cb(int fd){
    memset(conn_list[fd].rbuffer, 0, BUFFER_LENGTH );
    //调用recv接收客户端数据,存入该连接的接收缓冲区,通过接收数据长度判断接收状态
    int count = recv(fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0);
    //状态为零时客户端主动断开连接
    if(count == 0){
        printf("clientfd disconnect: %d\n", fd);
        //关闭断开的客户端fd
        close(fd);
        //将该fd从epfd的epoll实例中删除,不再监控
        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);// 无需ev结构体
        return 0;
    }else if(count < 0){//处理异常连接
        printf("count: %d, errno: %d, %s\n", count, errno, strerror(errno));
        close(fd);
        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
        return 0;
    }
    //存储数据长度
    conn_list[fd].rlength  = count;
    //打印接收数据
    //printf("RRECV: %s\n", conn_list[fd].rbuffer);
#if 0 //echo  回声模式开关,1开启
    //将接收缓冲区的数据和数据长度存储到发送缓冲区中,用于send函数
    conn_list[fd].wlength = conn_list[fd].rlength;
    memcpy(conn_list[fd].wbuffer, conn_list[fd].rbuffer, conn_list[fd].wlength);
    printf("[%d]recv: %s\n", conn_list[fd].rlength, conn_list[fd].rbuffer);
#else
    //对当前连接执行http请求的初始化操作
    http_request(&conn_list[fd]);
#endif
    //调用set_event函数修改该fd的关注事件为EPOLLOUT可写
    //让epoll后续触发可写事件,调用send_cb发送缓冲区中数据
    set_event(fd, EPOLLOUT, 0);
    return count;
}
//客户端fd触发可写事件后,发送缓冲区中的数据
int send_cb(int fd){
#if 1
    //对当前连接执行http的生成响应内容操作
    http_response(&conn_list[fd]);
#endif
    int count = 0;
    if(conn_list[fd].status == 1){//状态为发送数据阶段时
        //调用send函数将发送缓冲区中的数据发送至客户端fd
        count = send(fd, conn_list[fd].wbuffer, conn_list[fd].wlength, 0);
        //调用set_event函数将该fd连接信息中的关注事件修改为可写,以便继续发送剩余数据
        set_event(fd, EPOLLOUT, 0);
    }else if(conn_list[fd].status == 2){//状态为清空缓存阶段时
        //调用set_event函数将该fd连接信息中的关注事件修改为可写,准备执行清空缓存操作
        set_event(fd, EPOLLOUT, 0);
    }else if(conn_list[fd].status == 0){//状态为准备阶段时
        //处理不使用状态机的简单发送操作
        if (conn_list[fd].wlength != 0) {
			count = send(fd, conn_list[fd].wbuffer, conn_list[fd].wlength, 0);
		}
        //调用set_event函数将该fd连接信息中的关注事件修改为可读,准备接收数据
        set_event(fd, EPOLLIN, 0);
    }
    return count;
}
//创建服务器,开启监听fd
int init_server(unsigned short port){
    //创建TCP流式套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    //设置服务器地址信息,IPv4,绑定所有本地网卡,绑定端口
    struct sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY); //0.0.0.0
    servaddr.sin_port = htons(port); //0-1023
    //绑定套接字到服务器地址和端口
    if(-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))){
        printf("bind failed: %s\n", strerror(errno));
    }
    //开启监听套接字,最大等待队列为10
    listen(sockfd, 4096);
    //printf("listen finished: %d\n", sockfd);
    return sockfd;
}
int main(){
    //设置端口
    unsigned short port = 2000;
    //调用epoll_create函数创建一个epoll实例
    epfd = epoll_create(1);
    for(int i = 0;i < MAX_PORTS;i ++){
        //初始化服务器,创建套接字,绑定地址,开始监听
        //返回监听套接字
        int sockfd = init_server(port + i);
        //将监听套接字存入连接列表中
        conn_list[sockfd].fd = sockfd;
        //设置accept_cb为共同体回调函数的处理,即监听到可读事件后调用accept_cb函数
        conn_list[sockfd].r_action.recv_callback = accept_cb;
        //调用set_event函数将监听套接字加入到epoll实例中,监控其可读事件
        set_event(sockfd, EPOLLIN, 1);
    }
    //获取开始时的时间
    gettimeofday(&begin,NULL);
    //主循环,处理新连接,收发客户端数据
    while(1){//mainloop
        //创建数组存储就绪事件,初始化为0
        struct epoll_event events[1024] = {0};
        //调用epoll_wait阻塞等待,直到监控的fd触发了就绪事件,并统计数量
        int nready = epoll_wait(epfd, events, 1024, -1);
        //循环遍历所有就绪事件(时间复杂度为O(k))
        int i = 0;
        for(i = 0;i < nready;i ++){
            //获取当前就绪事件对应的fd,存入connfd中,简化操作
            int connfd = events[i].data.fd;
            //当就绪事件触发可读事件时,执行连接列表中该fd的recv_callback回调函数,添加新连接或读取数据
            if(events[i].events & EPOLLIN){
                conn_list[connfd].r_action.recv_callback(connfd);
            }
            //当就绪事件触发可写事件时,执行连接列表中该fd的send_callback回调函数,将发送缓冲区的数据发送至客户端
            if(events[i].events & EPOLLOUT){
                conn_list[connfd].send_callback(connfd);
            }
        }
    }
}

        webserver.c

#include
#include
#include
#include
#include
#include
#include
#include "server.h"
//接收新数据前的准备
int http_request(struct conn *c){
    //printf("request: %s\n", c->rbuffer);
    //清空数据,长度归零
    memset(c->wbuffer, 0, BUFFER_LENGTH);
    c->wlength = 0;
    //设置状态为0,进入发送数据的准备阶段
    c->status = 0;
}
//发送数据的具体实现
int http_response(struct conn *c){
//简化版,无状态机
#if 1
    //生成响应头并写入缓冲区
    c->wlength = sprintf(c->wbuffer,
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/html\r\n"
        "Accept-Ranges: bytes\r\n"
        "Content-Length: 82\r\n"
        "Date: Tue, 30 Apr 2024 13:16:46 GMT\r\n\r\n"
        "Avogado6

Avogado6

\r\n\r\n"); //将html文件映射到网页中 #elif 0 //创建文件描述符只读模式获取文件内容 int filefd = open("index.html", O_RDONLY); //定义文件状态结构体,用于获取文件长度 struct stat stat_buf; //获取文件数据 fstat(filefd, &stat_buf); if(c->status == 0){//准备阶段 //生成响应头并写入缓冲区 //printf("c->status == 1"); c->wlength = sprintf(c->wbuffer, "HTTP/1.1 200 OK\r\n" "Content-Type: text/html\r\n" "Accept-Ranges: bytes\r\n" "Content-Length: %ld\r\n" "Date: Tue, 30 Apr 2024 13:16:46 GMT\r\n\r\n", stat_buf.st_size); //更新状态至发送阶段 c->status = 1; }else if(c->status == 1){//发送阶段 //调用sendfile函数,一次性映射完整文件(简化操作) int ret = sendfile(c->fd, filefd, NULL, stat_buf.st_size); //映射失败时的处理 if(ret == -1){ printf("send file error: %d\n", errno); } //发送完成,更新状态至清空缓存 c->status = 2; }else if(c->status == 2){//清空缓存 //缓冲区数据置空,长度置0 c->wlength = 0; memset(c->wbuffer, 0, BUFFER_LENGTH); //发送数据结束,更新状态至准备阶段,准备下一次发送数据 c->status = 0; } //发送完成,关闭文件描述符 close(filefd); //将图片文件映射到网页中 #elif 0 //创建文件描述符只读模式获取图片内容 int filefd = open("Avogado6.jpg", O_RDONLY); //定义文件状态结构体,获取图片大小 struct stat stat_buf; fstat(filefd, &stat_buf); if(c->status == 0){//准备阶段 //生成响应头并写入缓冲区 c->wlength = sprintf(c->wbuffer, "HTTP/1.1 200 OK\r\n" "Content-Type: image/jpg\r\n" //发送图片更改文件格式 "Accept-Ranges: bytes\r\n" "Content-Length: %ld\r\n" "Date: Tue, 30 Apr 2024 13:16:46 GMT\r\n\r\n", stat_buf.st_size); //更新状态至发送阶段 c->status = 1; }else if(c->status == 1){//发送阶段 //调用sendfile函数,一次性映射完整文件 int ret = sendfile(c->fd, filefd, NULL, stat_buf.st_size); //映射失败时的处理 if(ret == -1){ printf("send file error: %d\n", errno); } //发送完成,更新状态至清空缓存 c->status = 2; }else if(c->status == 2){//清空缓存 //缓冲区数据置空,长度置0 c->wlength = 0; memset(c->wbuffer, 0, BUFFER_LENGTH); //发送数据结束,更新状态至准备阶段,准备下一次发送数据 c->status = 0; } //发送完成,关闭文件描述符 close(filefd); #endif return c->wlength; }

posted on 2025-11-11 08:19  slgkaifa  阅读(0)  评论(0)    收藏  举报

导航