tinyWebServer代码详解

Reicher 2024-09-09 11:03:01 阅读 63

以tinyWebServer为例,按代码逻辑顺序对代码进行详解

目录

零、涉及的一些通用函数1.getcwd()2.字符串操作函数strlen()strcpy()和strncpy()strcat()strcmp()c_str()strrchr()snprintf()

3.内存初始化3.1 memset()3.2 bzero()

4.时间类4.1 time()4.2 localtime()

5.文件操作类5.1 fopen()5.2 fclose()5.3 fputs()5.4 fcntl()

6.MySQL接口7.线程类8.网络编程8.1 socket()8.2 setsockopt()8.3 sockaddr_in结构体8.4 bind()8.5 listen()8.6 socketpair()8.7 accept()

9.epoll9.1 epoll_create()9.2 epoll_wait()9.3 epoll_event结构体9.4 epoll_ctl()

一、主流程1.main2.config类3.WebServer类eventListeneventLoop

二、各部件1.日志系统Log类block_queue类

2.数据库连接池connection_pool类connectionRAII类

3.HTTP连接http_conn类4.线程池threadpool类5.定时器6.锁类Locker

三、压力测试3.1 基本原理3.2 主要流程

零、涉及的一些通用函数

1.getcwd()

将当前工作目录的绝对路径复制到参数buffer所指的内存空间中,参数size为buf的空间大小。

<code>#include<unistd.h>

char *getcwd(char *buf,size_t size);

2.字符串操作函数

strlen()

计算一个字符串的长度

str是一个指向以\0字符结尾的字符串的指针,函数返回值是一个无符号整型(size_t),表示字符串的长度。

size_t strlen(const char *str);

strcpy()和strncpy()

功能:将一个字符串复制到另一个字符串中。会覆盖原字符串内容

strncpy可以指定复制的字符数量

char *strcpy(char *dest, const char *src);

char *strncpy(char *dest, const char *src, size_t n);

dest:目标字符串的指针,即要将源字符串复制到的目标位置。src:源字符串的指针,即要被复制的字符串。n:要拷贝的源字符串的个数

strcat()

追加字符串,将一个字符串中的内容追加到另一个字符串后面(不会覆盖原字符串内容)

Destination:追加字符串的目的地Source :需要追加的字符串

结果为Destination+Source

char *strcat( char *Destination, const char *Source );

strcmp()

比较字符串,按顺序比较两个字符串内的字符的ASCII码。

string1大则返回1,等则返回0,小则返回-1

int strcmp( const char *string1, const char *string2 );

c_str()

将string转化为C的字符串数组,生成一个const char *指针,指向字符串的首地址。不能直接赋值给char*,所以就需要我们进行相应的操作转化。返回的是可读不可改的指向字符数组的指针,不需要手动释放或删除这个指针。这个数组的数据是临时的,当有一个改变这些数据的成员函数被调用后,其中的数据就会失效。要么现用现转换,要么把它的数据复制到用户自己可以管理的内存中

#include <cstring>

const char *c_str();

// 用法一:现用现转换

string s = "1234";

const char* c = s.c_str();

cout<<c<<endl; //1234,如果为*c,则输出结果为1

s = "abcde";

cout<<c<<endl;//abcde

// 用法二:使用strcpy等函数把需要的数据拷贝到另一个内存中

char* c=new char[20];

string s="1234";code>

//c = s.c_str();

strcpy(c,s.c_str());

cout<<c<<endl; //输出:1234

s="abcd";code>

cout<<c<<endl; //输出:1234

strrchr()

查找一个字符串在另一个字符串中 末次 出现的位置

返回 str 中最后一次出现字符 c 的位置如果未能找到指定字符,那么函数将返回一个空指针

#include <string.h>

char *strrchr(char *str, char c);

str – C 字符串。c – 要搜索的字符。以 int 形式传递,但是最终会转换回 char 形式。

snprintf()

格式化字符串,并将结果存储在指定的字符数组中

#include <stdio.h>

int snprintf(char *str, size_t size, const char *format[,argument...]);

str:指向一个字符数组,用于存储格式化后的字符串,该数组的大小至少为 size。size:指定写入 str 数组中字符的最大个数(包括最后的空字符 ‘\0’)。format:包含格式说明符的字符串,它定义了后续参数的输出格式。[,argument…]:可变参数列表,与格式字符串中的格式说明符相匹配。

3.内存初始化

3.1 memset()

memset是一个初始化函数,作用是将某一块内存中的全部设置为指定的值。

void *memset(void *s, int c, size_t n);

s指向要填充的内存块。c是要被设置的值。n是要被设置该值的字符数。返回类型是一个指向存储区s的指针。

3.2 bzero()

用于将一段内存区域清零

#include <string.h>

void bzero(void *s, int n);// 一般来说n通常取sizeof(s),将整块空间清零

bzero()函数已经被标记为废弃函数,不再建议使用。

常用于清零操作,特别是在对socket地址结构执行清0操作时。

4.时间类

4.1 time()

返回自1970年1月1日0点以来经过的秒数,每秒变化一次.

#include<time.h>

time_t time(time_t *arg);

arg不是空指针,那么函数返回time_t类型的calendar time,并且把结果保存在arg指向的对象;如果arg == NULL,那么函数只是返回一个值,值不能存储在空指针指向的对象。

4.2 localtime()

用于将时间戳(time_t 类型)转换为本地时间的结构体。

接受一个指向 time_t 类型的指针作为参数,并返回一个指向 tm 结构体的指针,该结构体包含了年、月、日、时、分、秒等时间信息。

#include <ctime>

int main(){

time_t currentTime = time(NULL);

struct tm* localTime = localtime(&currentTime);

// 获取本地时间信息

int year = localTime->tm_year + 1900; // 年份需要加上 1900

int month = localTime->tm_mon + 1; // 月份从 0 开始,需要加上 1

int day = localTime->tm_mday;

int hour = localTime->tm_hour;

int minute = localTime->tm_min;

int second = localTime->tm_sec;

}

5.文件操作类

5.1 fopen()

【C标准库】详解fopen函数 一篇让你搞懂fopen函数

创建并打开与文件相关联的文件流

FILE *fopen(const char *filename, const char *mode);

参数:

filename:要打开的文件名mode:打开模式

返回值类型是一个指向FILE类型的指针,FILE是个结构:

打开文件成功,则创建一个FILE类型结构的实例,并返回指向该结构实例的指针,程序使用该指针来操作文件;打开文件失败,则返回NULL;

5.2 fclose()

关闭文件流

int fclose ( FILE * stream )

5.3 fputs()

把字符串写入到指定的流 stream 中,但不包括空字符。

int fputs(const char *str, FILE *stream)

str – 这是一个数组,包含了要写入的以空字符终止的字符序列。

stream – 这是指向 FILE 对象的指针,该 FILE 对象标识了要被写入字符串的流。

5.4 fcntl()

掌握文件控制:深入解析 Linux fcntl 函数

认识 fcntl 接口函数(文件非阻塞设置)

操作一个文件的文件描述符。

#include <fcntl.h>

#include<unistd.h>

int fcntl(int fd, int cmd, ... /* arg */);

fd 是要操作的文件描述符。cmd 是控制操作的命令。arg 是与命令相关联的可选参数。

cmd的取值:

F_GETFL:获取文件描述符的状态标志。F_SETFL:设置文件描述符的状态标志。F_GETLK:获取文件锁。F_SETLK:设置或释放文件锁。F_SETLKW:阻塞地设置或释放文件锁。

文件状态标志:

O_RDONLY:只读打开。O_WRONLY:只写打开。O_RDWR:读写打开。O_APPEND:追加写入。O_CREAT:如果文件不存在则创建文件。O_EXCL:与O_CREAT一起使用,如果文件存在则报错。O_TRUNC:如果文件存在且为只写或读写,则将其长度截断为0。

文件描述符标志:

O_NONBLOCK:非阻塞模式,用于文件描述符,使得对文件的读写操作不会阻塞进程。O_SYNC:使得每次write都等到物理 I/O 操作完成后才返回。O_DIRECTORY:如果文件名是目录,则打开失败。O_DSYNC:等待物理 I/O 数据完成,不等待文件属性更新。O_NOATIME:不更新访问时间戳。O_NOCTTY:如果设备是终端,不将其分配为控制终端。

6.MySQL接口

Mysql接口API相关函数详细使用说明——mysql_init,mysql_real_connect,mysql_query,mysql_close等相关

7.线程类

在C++开发中,原生的线程库主要有两个,一个是C++11提供的< thread>(std::thread类),另一个是Linux下的<pthread.h>(pthread类)

pthread类:

C++ 多线程编程(二):pthread的基本使用

thread类

C++ 多线程编程(一):std::thread的使用

C++多线程:thread类

C++多线程编程——thread线程创建与使用(2W字保姆级介绍)

8.网络编程

浅谈 Linux 网络编程 - Server 端模型、sockaddr、sockaddr_in 结构体

【Socket网络编程】12. send()、recv()、sendto() 和 recvfrom() 函数解析

server 端的套路:

①创建 socket()

②绑定 ip + port,bind()

③设置连接上限,listen()

④阻塞,监听客户端的连接,accept()

⑤业务逻辑 ,read()/write()

⑥关闭 socket,close()

8.1 socket()

socket()函数介绍

建立一个协议族为domain、协议类型为type、协议编号为protocol的套接字文件描述符。

如果函数调用成功,会返回一个标识这个套接字的文件描述符,失败的时候返回-1。

#include<sys/types.h>

#include<sys/socket.h>

int socket(int domain, int type, int protocol);

8.2 setsockopt()

setsockopt()函数

setsockopt()函数功能介绍

获取或者设置与某个套接字关联的选项。

#include <sys/socket.h>

int setsockopt(int sockfd, int level, int optname,

const void *optval, socklen_t optlen);

参数说明:

int sockfd: 很简单,socket句柄int level: 选项定义的层次;目前仅支持SOL_SOCKET和IPPROTO_TCP层次int optname: 需设置的选项const void *optval: 指针,指向存放选项值的缓冲区;

对于getsockopt(),指向返回选项值的缓冲。对于setsockopt(),指向包含新选项值的缓冲。 socklen_t optlen: optval缓冲区的长度。

对于getsockopt(),作为入口参数时,选项值的最大长度。作为出口参数时,选项值的实际长度。对于setsockopt(),现选项的长度。

返回值:成功执行时,返回0。失败返回-1,errno被设为以下的某个值

EBADF:sock不是有效的文件描述词EFAULT:optval指向的内存并非有效的进程空间EINVAL:在调用setsockopt()时,optlen无效ENOPROTOOPT:指定的协议层不能识别选项ENOTSOCK:sock描述的不是套接字

8.3 sockaddr_in结构体

sockaddr_in详解

用于表示Internet地址和端口号。通常与套接字(socket)API一起使用。

#include<netinet/in.h>

struct sockaddr_in{

sa_family_tsin_family;//地址簇(Address Family)

uint16_tsin_port;//16位TCP/UDP端口号

struct in_addrsin_addr;//32位IP地址

charsin_zero[8];//不使用

}

struct in_addr{

In_addr_ts_addr;// 32为IPv4地址

}

参数说明:

sin_family:地址族,通常设置为AF_INET表示IPv4协议。sin_port:端口号,以网络字节序表示。sin_addr:IP地址,以网络字节序表示。sin_zero:填充字段,通常设置为0。

使用sockaddr_in结构体时,需要将其类型转换为sockaddr类型,因为套接字API中的大多数函数都需要传入sockaddr类型的指针作为参数。

可以使用强制类型转换将sockaddr_in类型转换为sockaddr类型

struct sockaddr_inaddr;// 设置addr的字段值

...

// 将addr转换为sockaddr类型

struct sockaddr* sa = (struct sockaddr*)&addr;

8.4 bind()

给 socket 绑定一个 地址结构 (IP+port)

#include <arpa/inet.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

返回值:

成功:0失败:-1 errno

8.5 listen()

设置 server 连接上限,设置同时与服务器建立连接的上限数。(同时进行3次握手的客户端数量)

int listen(int sockfd, int backlog);

参数说明:

sockfd: socket() 函数的返回值backlog:上限数值。最大值 128.

返回值:

成功:0失败:-1 errno

8.6 socketpair()

socketpair的用法和理解

用于创建一对无名的、相互连接的套接字。

#include <sys/types.h>

#include <sys/socket.h>

int socketpair(int d, int type, int protocol, int sv[2]);

如果函数成功,则返回0,创建好的套接字分别是sv[0]和sv[1];否则返回-1,错误码保存于errno中。

基本用法

这对套接字可以用于全双工通信,每一个套接字既可以读也可以写。例如,可以往sv[0]中写,从sv[1]中读;或者从sv[1]中写,从sv[0]中读;如果往一个套接字(如sv[0])中写入后,再从该套接字读时会阻塞,只能在另一个套接字中(sv[1])上读成功;读、写操作可以位于同一个进程,也可以分别位于不同的进程,如父子进程。如果是父子进程时,一般会功能分离,一个进程用来读,一个用来写。因为文件描述副sv[0]和sv[1]是进程共享的,所以读的进程要关闭写描述符, 反之,写的进程关闭读描述符。

8.7 accept()

阻塞等待客户端建立连接,成功的话,返回一个与客户端成功连接的 socket 文件描述符。

accept 返回的 socket 才是真正与 client 建立连接的 socket。

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

9.epoll

epoll详解(使用、原理、实验)

epoll_create():创建一个 eventpoll 对象epoll_ctl():添加或删除所要监听的socketepoll_wait():收集在epoll监控的事件中已经发生的事件

9.1 epoll_create()

epoll_create详解

创建一个epoll实例并返回该实例对应的文件描述符fd。

该文件描述符用于随后的所有对epoll的调用接口。每创建一个epoll句柄,会占用一个fd,因此当不再需要时,应使用close关闭epoll_create()返回的文件描述符,否则可能导致fd被耗尽。

#include <sys / epoll.h>

nfd = epoll_creat(max_size);

max_size:这个监听的数目最大有多大.

9.2 epoll_wait()

等待监听的所有fd相应事件的产生.

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数说明:

int epfd: epoll_create()函数返回的epoll实例的句柄。struct epoll_event * events: 接口的返回参数,epoll把发生的事件的集合从内核复制到 events数组中。events数组是一个用户分配好大小的数组,数组长度大于等于maxevents。(events不可以是空指针,内核只负责把数据复制到这个 events数组中,不会去帮助我们在用户态中分配内存)int maxevents: 表示本次可以返回的最大事件数目,通常maxevents参数与预分配的events数组的大小是相等的。int timeout: 表示在没有检测到事件发生时最多等待的时间,超时时间(>=0),单位是毫秒ms,-1表示阻塞,0表示不阻塞。

返回需要处理的事件数目。失败返回0,表示等待超时。

9.3 epoll_event结构体

#include<sys/epoll.h>

struct epoll_event {

uint32_t events; // epoll 事件类型,包括可读,可写等

epoll_data_t data; // 用户数据,可以是一个指针或文件描述符等

};

events字段表示要监听的事件类型,可以是以下值之一:

EPOLLIN:表示对应的文件描述符上有数据可读EPOLLOUT:表示对应的文件描述符上可以写入数据EPOLLRDHUP:表示对端已经关闭连接,或者关闭了写操作端的写入EPOLLPRI:表示有紧急数据可读EPOLLERR:表示发生错误EPOLLHUP:表示文件描述符被挂起EPOLLET:表示将epoll设置为边缘触发模式EPOLLONESHOT:表示将事件设置为一次性事件

data字段表示用户数据,它的类型是一个union,可以存放一个指针或文件描述符等数据。

typedef union epoll_data {

void *ptr;

int fd;

uint32_t u32;

uint64_t u64;

} epoll_data_t;

ptr可以指向任何类型的用户数据fd表示文件描述符u32和u64分别表示一个32位和64位的无符号整数。

使用时,用户可以将自己需要的数据存放到这个字段中,当事件触发时,epoll系统调用会返回这个数据,以便用户处理事件。

9.4 epoll_ctl()

添加或删除所要监听的socket

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

一、主流程

1.main

修改访问的数据库信息,包括登录名、密码、库名创建Config类的对象config对命令行进行解析,创建WebServer类的对象server使用Config类的对象config对server进行初始化初始化日志,包括设置写方式、创建文件初始化数据库连接池及数据库读取表,获取用户名与密码初始化线程池,创建一个http_conn的线程池,创建新线程并使其独立初始化触发模式,LT/ET + LT/ET共四种创建套接字,绑定地址,创建注册事件表运行服务器,开始监听socket接口,并处理不同的事件

2.config类

声明及定义位于文件config.h和config.cpp内。

用于初始化webserver类

类成员变量

主要有:端口号,日志写入方式、触发模式、线程池线程数量、数据库连接池数量等,用于配置服务器属性。

类成员函数

构造函数:给成员变量赋默认值参数解析parse_arg:根据程序传入的参数修改成员变量的值,即修改服务器属性

3.WebServer类

声明及定义位于文件webserver.h和webserver.cpp内。

利用config类的成员变量进行初始化。

类成员变量:

基础成员变量:端口、日志写入方式、触发模式等,还有通信管段、epollfd、用户数据库相关:线程池相关:epoll事件相关定时器相关:

类成员函数:

构造函数:给http连接对象分配空间,初始化root文件夹路径,给定时器分配空间init:将服务器属性参数配置赋值好log_write:初始化日志sql_pool:初始化数据库连接池,初始化数据库读取表thread_pool:创建线程池,创建新线程并加入trig_mode:设定监听和连接的触发模式eventListen:创建套接字,绑定地址,注册内核事件

eventListen

创建一个socket句柄设定socket句柄的选项处理网络通信的地址,将socket和地址(IP+port)绑定epoll创建内核事件表,注册事件

初始化定时器创建epoll实例,注册事件创建管道设置信号函数

eventLoop

循环运行获取要处理的事件数获取要处理的事件的文件描述符fd,对不同事件采取不同的处理:

新到客户连接:判断是否来自监听的socket,是的话建立新连接,创建定时器进行管理文件描述符被关闭、被挂起或发生错误:服务器段关闭连接,移除对应的定时器管道上来了可读的数据:处理管道信号的数据客户连接上文件描述符上有数据可读:处理客户连接上文件描述符上可以写入数据

二、各部件

1.日志系统

Log类

使用了懒汉模式,确保只有一个实例。

类成员函数:

get_instance():获取一个实例,返回实例的指针init:对像初始化

若是异步,则设置异步标志位,创建阻塞队列,创建线程记录时间并设置设置日志文件名创建日志文件并打开 write_log:对日志文件进行写入flush:刷新写入流缓冲区

block_queue类

阻塞队列类,主要是解决异步写入日志做准备。

为了线程安全,每个操作前都要先加互斥锁,操作完后,再解锁。也就是每个函数在进入时先上锁,退出函数时解锁。

使用模板,根据传入的类型创建对应类型的队列。

类成员函数:

构造与析构clear():将成员变量都初始化full():判断队列是否满了empty():判断队列是否为空front(T &value):返回队首元素back(T &value):返回队尾元素size():获取队列长度max_size():获取队列容量

2.数据库连接池

connection_pool类

使用单例模式,使用list来实现连接池,静态大小

类成员函数:

GetInstance():获取实例

connection_pool:构造函数,初始化url、端口、用户等信息

init:初始化连接池,按最大连接数初始化连接

实现连接,连接上MySQL数据库把连接成功的指针加入连接池初始化信号量

GetConnection:从数据库连接池中返回一个可用连接,更新使用和空闲连接数

申请信号量上锁获取连接更新使用和空闲连接数解锁

ReleaseConnection:释放当前使用的连接

上锁释放更新使用和空闲连接数解锁释放信号量

DestroyPool:销毁数据库连接池

上锁关闭全部MySQL连接使用和空闲连接数置零,连接池清空解锁

GetFreeConn:获取当前空闲的连接数

返回当前空闲的连接数

connectionRAII类

用来获取连接

输入连接句柄的地址和的连接池,将该句柄与连接池的一个可用连接 连接起来

3.HTTP连接http_conn类

根据状态转移,通过主从状态机封装了http连接类。其中,主状态机在内部调用从状态机,从状态机将处理状态和数据传给主状态机。

客户端发出http连接请求从状态机读取数据,更新自身状态和接收数据,传给主状态机主状态机根据从状态机状态,更新自身状态,决定响应请求还是继续读取

类成员变量:

使用四个enum枚举类型列举请求方法、检查状态、http代码、监听状态定义http响应的一些状态信息

-如400、403、404、500等

类成员函数:

initmysql_result:初始化数据库读取表

从连接池中取出一个MySQL连接向数据库中执行user检索记录返回的结果集,将结果用map记录 setnonblocking:设置文件描述符为非阻塞addfd:将内核事件表注册读事件removefd:从内核事件表删除某个文件描述符modfd:将事件重置为EPOLLONESHOTinit:初始化新接受的连接,check_state默认为分析请求行状态

4.线程池threadpool类

半同步/半反应堆线程池,使用一个工作队列完全解除了主线程和工作线程的耦合关系:主线程往工作队列中插入任务,工作线程通过竞争来取得任务并执行它。

采用模板化设计

类成员函数:

threadpool:构造函数

初始化线程池数组,数组里的每一个值都是一个线程标识符创建新线程,并使其与主线程分离 ~threadpool:析构函数

删除线程池 append:增加一个线程,并设定好状态append_p:增加一个线程,但不设定状态worker:启动线程池,运行run()函数run():循环运行

获取信号量,上锁获取工作队列的头部队列

5.定时器

通过实现一个服务器定时器,处理非活跃连接,释放连接资源。

利用alarm函数周期性地触发SIGALRM信号,该信号的信号处理函数利用管道通知主循环执行定时器链表上的定时任务基于升序链表的定时器

client_data类:记录每个客户端的地址、socket接口、定时器

util_timer类:定时器类,作为链表的结点

*prev:指向上一个定时器*next:指向下一个定时器*user_data:指向定时器对应的用户expire:时间

sort_timer_lst类:基于升序链表的定时器

*head:指向头定时器*tail:指向尾定时器add_timer():添加定时器del_timer():删除定时器adjust_timer():tick():

Utils类:定时器链表的方法类,包含一个sort_timer_lst类的对象

init:设置最小超时单位setnonblocking:对文件描述符设置非阻塞addfd:将内核事件表注册读事件sig_handler:信号处理函数addsig:设置信号函数timer_handler:不断触发SIGALRM信号

6.锁类Locker

信号量sem:

构造函数:inti初始化析构函数:destorywait:申请信号量post:释放信号量

锁locker:

lock:上锁unlock:解锁geit:获取锁的状态

条件变量cond

三、压力测试

压测利器Webbench(附源码)

3.1 基本原理

父进程fork若干个子进程,每个子进程在用户要求时间或默认的时间内对目标web循环发出实际访问请求父子进程通过管道进行通信,子进程通过管道写端向父进程传递在若干次请求访问完毕后记录到的总信息,父进程通过管道读端读取子进程发来的相关信息子进程在时间到后结束,父进程在所有子进程退出后统计并给用户显示最后的测试结果,然后退出。

3.2 主要流程

解析参数:用户通过命令行参数指定目标网址、并发连接数、测试时长等参数。webbench 首先会解析这些参数,确定压测的目标和条件。创建并发连接:webbench 根据用户指定的并发连接数,在测试开始时创建相应数量的并发连接。这些连接将模拟多个客户端同时访问目标网站。建立连接:对于每个并发连接,webbench 发起一个 HTTP 请求并尝试与目标服务器建立连接。这些连接可能是非阻塞的,允许同时处理多个连接。发送请求:一旦连接建立成功,webbench 就会向目标服务器发送 HTTP 请求。这些请求可以是简单的 GET 请求,也可以包含其他 HTTP 方法和自定义的请求头信息。接收响应:webbench 在发送请求后会等待目标服务器的响应。它可以根据用户指定的超时时间来确定是否需要等待响应,或者在超时后放弃等待并关闭连接。记录结果:在测试过程中,webbench 会记录每个请求的响应时间、状态码等信息。这些信息将用于后续的性能分析和结果报告。重复测试:根据用户指定的测试时长,webbench 会在一定时间内不断重复发送请求和接收响应,以模拟持续的并发访问情况。汇总结果:在测试结束后,webbench 会汇总所有请求的结果,并计算出一些统计信息,如平均响应时间、成功率等。这些信息将作为压测结果向用户展示。生成报告:最后,webbench 会根据测试结果生成一份报告,包括压测的详细信息、性能指标和可能的改进建议。用户可以根据这份报告来评估服务器的性能表现和优化方向。



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。