TinyHTTPd--超轻量型Http Server源码分析

TinyHTTPd是一个超轻量型Http Server,使用C语言开发,全部代码不到600行,附带一个简单的Client,可以通过阅读这段代码理解一个Http Server的本质。源码下载链接http://sourceforge.net/projects/tinyhttpd/

分析这段源码前,需要对网络协议,Unix编程,以及HTTP有一定的了解,这里假设大家对http有一定的了解,如果有时间,会额外介绍下Http。

本文先全篇分析下该开源项目的源码,最后给出测试。

服务器端代码:httpd.c


建议源码阅读顺序:main —> startup —> accept_request —> excute_cgi

TinyHTTPd 项目流程图



先介绍几个中间辅助函数:(完整代码参见前面源码链接)

从客户端读取一行数据,以\r或\r\n为行结束符

/**********************************************************************/
/* Get a line from a socket, whether the line ends in a newline,
* carriage return, or a CRLF combination.  Terminates the string read
* with a null character.  If no newline indicator is found before the
* end of the buffer, the string is terminated with a null.  If any of
* the above three line terminators is read, the last character of the
* string will be a linefeed and the string will be terminated with a
* null character.
* Parameters: the socket descriptor
*             the buffer to save the data in
*             the size of the buffer
* Returns: the number of bytes stored (excluding null) */
/**********************************************************************/
/**********************************************************************/
/* 从socket读取一行数据。以\r或\r\n为行结束符
* Parameters: the socket descriptor
*             the buffer to save the data in
*             the size of the buffer
* Returns: the number of bytes stored (excluding null) */
/**********************************************************************/
int get_line(int sock, char *buf, int size)
{
	int i = 0;
	char c = '\0';
	int n;

	//至多读取size-1个字符,最后一个字符置'\0'
	while ((i < size - 1) && (c != '\n'))
	{
		n = recv(sock, &c, 1, 0);//单个字符接收

		if (n > 0)
		{
			if (c == '\r')//如果是回车符,继续读取
			{
				/*使用 MSG_PEEK 标志使下一次读取依然可以得到这次读取的内容,可认为接收窗口不滑动*/
				n = recv(sock, &c, 1, MSG_PEEK);

				if ((n > 0) && (c == '\n'))//如果是回车换行符
					recv(sock, &c, 1, 0);//继续接收单个字符,实际上和上面那个标志位MSG_PEEK读取同样的字符,读完后删除输入队列的数据,即滑动窗口,c=='\n'
				else
					c = '\n';//只是读取到回车符,则置为换行符,也终止了读取
			}
			buf[i] = c;//放入缓冲区
			i++;
		}
		else//没有读取到任何数据
			c = '\n';
	}
	buf[i] = '\0';

	return(i);//返回读到的字符个数(包括'\0')
}
请求出错情况处理
/**********************************************************************/
/* 告知客户端该请求有错误 400
* Parameters: client socket */
/**********************************************************************/
void bad_request(int client)
{
	char buf[1024];
	/*将字符串存入缓冲区,再通过send函数发送给客户端*/
	sprintf(buf, "HTTP/1.0 400 BAD REQUEST\r\n");
	send(client, buf, sizeof(buf), 0);
	sprintf(buf, "Content-type: text/html\r\n");
	send(client, buf, sizeof(buf), 0);
	sprintf(buf, "\r\n");
	send(client, buf, sizeof(buf), 0);
	sprintf(buf, "<P>Your browser sent a bad request, ");
	send(client, buf, sizeof(buf), 0);
	sprintf(buf, "such as a POST without a Content-Length.\r\n");
	send(client, buf, sizeof(buf), 0);
}

/**********************************************************************/
/* 通知客户端CGI脚本不能被执行 500
* Parameter: the client socket descriptor. */
/**********************************************************************/
void cannot_execute(int client)
{
	char buf[1024];
	/*回馈出错信息*/
	sprintf(buf, "HTTP/1.0 500 Internal Server Error\r\n");
	send(client, buf, strlen(buf), 0);
	sprintf(buf, "Content-type: text/html\r\n");
	send(client, buf, strlen(buf), 0);
	sprintf(buf, "\r\n");
	send(client, buf, strlen(buf), 0);
	sprintf(buf, "<P>Error prohibited CGI execution.\r\n");
	send(client, buf, strlen(buf), 0);
}

/**********************************************************************/
/* 打印出错信息,详见《Unix 环境高级编程》并终止*/
/**********************************************************************/
void error_die(const char *sc)
{
	perror(sc);
	exit(1);
}

/**********************************************************************/
/* 返回客户端404错误信息 404(万恶的404) */
/**********************************************************************/
void not_found(int client)
{
	char buf[1024];

	sprintf(buf, "HTTP/1.0 404 NOT FOUND\r\n");
	send(client, buf, strlen(buf), 0);
	sprintf(buf, SERVER_STRING);
	send(client, buf, strlen(buf), 0);
	sprintf(buf, "Content-Type: text/html\r\n");
	send(client, buf, strlen(buf), 0);
	sprintf(buf, "\r\n");
	send(client, buf, strlen(buf), 0);
	sprintf(buf, "<HTML><TITLE>Not Found</TITLE>\r\n");
	send(client, buf, strlen(buf), 0);
	sprintf(buf, "<BODY><P>The server could not fulfill\r\n");
	send(client, buf, strlen(buf), 0);
	sprintf(buf, "your request because the resource specified\r\n");
	send(client, buf, strlen(buf), 0);
	sprintf(buf, "is unavailable or nonexistent.\r\n");
	send(client, buf, strlen(buf), 0);
	sprintf(buf, "</BODY></HTML>\r\n");
	send(client, buf, strlen(buf), 0);
}

读取文件中的数据到client

/**********************************************************************/
/* Put the entire contents of a file out on a socket.  This function
 * is named after the UNIX "cat" command, because it might have been
 * easier just to do something like pipe, fork, and exec("cat").
 * Parameters: the client socket descriptor
 *             FILE pointer for the file to cat */
 /*Unix shell 命令cat file 即打印文件file中的数据*/
/**********************************************************************/
 /*将文件结构指针resource中的数据发送至client*/
void cat(int client, FILE *resource)
{
 char buf[1024];

 fgets(buf, sizeof(buf), resource);//从文件结构指针resource中读取数据,保存至buf中
 //处理文件流中剩下的字符
 while (!feof(resource))//检测流上的文件结束符,文件结束返回非0值,结束返回0
 {
  send(client, buf, strlen(buf), 0);//文件流中的字符全部发送给client
  fgets(buf, sizeof(buf), resource);/*从文件结构体指针resource中读取至多bufsize-1个数据
                                    (第bufsize个字符赋'\0')每次读取一行,如果不足bufsize,
                                     则读完该行结束。这里通过feof函数来判断fgets是否因出错而终止
                                     另外,这里有文件偏移位置,下一轮读取会从上一轮读取完的位置继续*/
 }
}

返回文件信息给client

/**********************************************************************/
/* Return the informational HTTP headers about a file. */
/* Parameters: the socket to print the headers on
*             the name of the file */
/*返回文件头部信息*/
/**********************************************************************/
void headers(int client, const char *filename)
{
	char buf[1024];
	(void)filename;  /* could use filename to determine file type */

	strcpy(buf, "HTTP/1.0 200 OK\r\n");
	send(client, buf, strlen(buf), 0);
	strcpy(buf, SERVER_STRING);
	send(client, buf, strlen(buf), 0);
	sprintf(buf, "Content-Type: text/html\r\n");
	send(client, buf, strlen(buf), 0);
	strcpy(buf, "\r\n");
	send(client, buf, strlen(buf), 0);
}

/**********************************************************************/
/* Send a regular file to the client.  Use headers, and report
* errors to client if they occur.
* Parameters: a pointer to a file structure produced from the socket
*              file descriptor
*             the name of the file to serve */
/*返回文件数据,用于静态页面返回*/
/**********************************************************************/
void serve_file(int client, const char *filename)
{
	FILE *resource = NULL;
	int numchars = 1;
	char buf[1024];

	buf[0] = 'A'; buf[1] = '\0';
	while ((numchars > 0) && strcmp("\n", buf))  /* read & discard headers */
		numchars = get_line(client, buf, sizeof(buf));

	resource = fopen(filename, "r");//只读方式打开文件
	if (resource == NULL)
		not_found(client);//如果文件不存在,返回404错误
	else
	{
		headers(client, filename);//先返回文件头部信息
		cat(client, resource);//将resource描述符指定文件中的数据发送给client
	}
	fclose(resource);//关闭
}
下面就是tynyhttpd服务器端的核心代码部分。

为了更好地理解源码,这里提出http的请求报文格式

                                      

服务器端套接字初始化设置

/**********************************************************************/
/* This function starts the process of listening for web connections
* on a specified port.  If the port is 0, then dynamically allocate a
* port and modify the original port variable to reflect the actual
* port.
* Parameters: pointer to variable containing the port to connect on
* Returns: the socket */
/**********************************************************************/
/*服务器端套接字初始化设置*/
int startup(u_short *port)
{
	int httpd = 0;
	struct sockaddr_in name;

	httpd = socket(PF_INET, SOCK_STREAM, 0);//创建服务器端套接字
	if (httpd == -1)
		error_die("socket");
	memset(&name, 0, sizeof(name));
	name.sin_family = AF_INET;//地址簇
	name.sin_port = htons(*port);//指定端口
	name.sin_addr.s_addr = htonl(INADDR_ANY);//通配地址
	if (bind(httpd, (struct sockaddr *)&name, sizeof(name)) < 0)//绑定到指定地址和端口
		error_die("bind");
	if (*port == 0)  /* if dynamically allocating a port *///动态分配一个端口
	{
		int namelen = sizeof(name);
		/*在以端口号0调用bind后,getsockname用于返回由内核赋予的本地端口号*/
		if (getsockname(httpd, (struct sockaddr *)&name, &namelen) == -1)
			error_die("getsockname");
		*port = ntohs(name.sin_port);//网络字节顺序转换为主机字节顺序,返回主机字节顺序表达的数
	}
	if (listen(httpd, 5) < 0)//服务器监听客户端请求。套接字排队的最大连接个数5
		error_die("listen");
	return(httpd);
}
接收客户端的请求报文
/**********************************************************************/
/* A request has caused a call to accept() on the server port to
 * return.  Process the request appropriately.
 * Parameters: the socket connected to the client */
/**********************************************************************/
/**********************************************************************/
/* HTTP协议规定,请求从客户端发出,最后服务器端响应该请求并返回。
 * 这是目前HTTP协议的规定,服务器不支持主动响应,所以目前的HTTP
 * 协议版本都是基于客户端请求,然后响应的这种模型。 */
 /*accept_request函数解析客户端请求,判断是请求静态文件还是cgi代码
 (通过请求类型以及参数来判定),如果是静态文件则将文件输出给前端,
 如果是cgi则进入cgi处理函数*/
/**********************************************************************/ 
void accept_request(int client)
{
 char buf[1024];
 int numchars;
 char method[255];//请求方法GET or POST
 char url[255];//请求的文件路径
 char path[512];//文件相对路径
 size_t i, j;
 struct stat st;
 int cgi = 0;      /* becomes true if server decides this is a CGI
                    * program */
 char *query_string = NULL;

 numchars = get_line(client, buf, sizeof(buf));//从client中读取指定大小数据到buf
 i = 0; j = 0;
 //解析客户端的http请求报文
 /*接收字符处理:提取空格字符前的字符,至多254个*/
 while (!ISspace(buf[j]) && (i < sizeof(method) - 1))
 {
  method[i] = buf[j];//根据http请求报文格式,这里得到的是请求方法
  i++; j++;
 }
 method[i] = '\0';

 //忽略大小写比较字符串,用于判断是哪种类型
 if (strcasecmp(method, "GET") && strcasecmp(method, "POST"))
 {
  unimplemented(client);//两种method都不是,告知客户端所请求的方法未能实现
  return;
 }

 if (strcasecmp(method, "POST") == 0)//POST 类型
  cgi = 1;//设置标志位

 i = 0;
 while (ISspace(buf[j]) && (j < sizeof(buf)))//过滤空格字符,空格后面是URL
  j++;

/*将buf中的非空格字符转存进url缓冲区,遇空格字符或满退出*/
 while (!ISspace(buf[j]) && (i < sizeof(url) - 1) && (j < sizeof(buf)))
 {
  url[i] = buf[j];//获取的是URL(互联网标准资源的地址)
  i++; j++;
 }
 url[i] = '\0';

 if (strcasecmp(method, "GET") == 0)//GET method
 {
  query_string = url;//请求信息
  while ((*query_string != '?') && (*query_string != '\0'))//截取'?'前的字符
   query_string++;//问号前面是路径,后面是参数
  if (*query_string == '?')//有'?',表明动态请求
  {
   cgi = 1;
   *query_string = '\0';
   query_string++;
  }
 }
//下面是TinyHTTPd项目htdocs文件下的文件
 sprintf(path, "htdocs%s", url);//获取请求文件路径
 if (path[strlen(path) - 1] == '/')//如果文件类型是目录(/),则加上index.html
  strcat(path, "index.html");//

//根据路径找文件,并获取path文件信息保存到结构体st中
 if (stat(path, &st) == -1) {//执行失败,文件未找到
  /*丢弃所有 headers 的信息*/
  while ((numchars > 0) && strcmp("\n", buf))  /* read & discard headers */
   numchars = get_line(client, buf, sizeof(buf));//从客户端读取数据进buf
  not_found(client);//回应客户端找不到
 }
 else//获取文件信息,执行成功
 {
  /*如果是个目录,则默认使用该目录下 index.html 文件*/
  if ((st.st_mode & S_IFMT) == S_IFDIR)
   strcat(path, "/index.html");
  if ((st.st_mode & S_IXUSR) ||
      (st.st_mode & S_IXGRP) ||
      (st.st_mode & S_IXOTH)    )
   cgi = 1;
  if (!cgi)//静态页面请求
   serve_file(client, path);//直接返回文件信息给客户端,静态页面返回
  else//动态页面请求
   execute_cgi(client, path, method, query_string);//执行cgi脚本
 }

 close(client);//关闭客户端套接字
}
执行CGI脚本,动态页面申请
/**********************************************************************/
/* 执行CGI(公共网卡接口)脚本,需要设定合适的环境变量
* Parameters: client socket descriptor
*             path to the CGI script */
/*execute_cgi函数负责将请求传递给cgi程序处理,
服务器与cgi之间通过管道pipe通信,首先初始化两个管道,并创建子进程去执行cgi函数*/
/*子进程执行cgi程序,获取cgi的标准输出通过管道传给父进程,由父进程发送给客户端*/
/**********************************************************************/
void execute_cgi(int client, const char *path,
	const char *method, const char *query_string)
{
	char buf[1024];
	int cgi_output[2];
	int cgi_input[2];
	pid_t pid;
	int status;
	int i;
	char c;
	int numchars = 1;
	int content_length = -1;

	buf[0] = 'A'; buf[1] = '\0';
	if (strcasecmp(method, "GET") == 0)//GET方法:一般用于获取/查询资源信息
	while ((numchars > 0) && strcmp("\n", buf))  /* read & discard headers读取并丢弃 HTTP 请求 */
		numchars = get_line(client, buf, sizeof(buf));//从客户端读取
	else    /* POST 一般用于更新资源信息*/
	{
		numchars = get_line(client, buf, sizeof(buf));

		//获取HTTP消息实体的传输长度
		while ((numchars > 0) && strcmp("\n", buf))//不为空且不为换行符
		{
			buf[15] = '\0';
			if (strcasecmp(buf, "Content-Length:") == 0)//是否为Content-Length字段
				content_length = atoi(&(buf[16]));//Content-Length用于描述HTTP消息实体的传输长度
			numchars = get_line(client, buf, sizeof(buf));
		}
		if (content_length == -1) {
			bad_request(client);//请求的页面数据为空,没有数据,就是我们打开网页经常出现空白页面
			return;
		}
	}

	sprintf(buf, "HTTP/1.0 200 OK\r\n");//
	send(client, buf, strlen(buf), 0);

	//建立管道,两个通道cgi_output[0]:读取端,cgi_output[1]:写入端
	if (pipe(cgi_output) < 0) {
		cannot_execute(client);//管道建立失败,打印出错信息
		return;
	}//管道只能具有公共祖先的进程间进行,这里是父子进程之间
	if (pipe(cgi_input) < 0) {
		cannot_execute(client);
		return;
	}

	//fork子进程,这样就创建了父子进程间的IPC通道
	if ((pid = fork()) < 0) {
		cannot_execute(client);
		return;
	}
	//实现进程间的管道通信机制
	/*子进程继承了父进程的pipe,然后通过关闭子进程output管道的输出端,input管道的写入端;
	关闭父进程output管道的写入端,input管道的输出端*/
	//子进程,
	if (pid == 0)  /* child: CGI script */
	{
		char meth_env[255];
		char query_env[255];
		char length_env[255];

		//复制文件句柄,重定向进程的标准输入输出
		//dup2的第一个参数描述符关闭
		dup2(cgi_output[1], 1);//标准输出重定向到output管道的写入端
		dup2(cgi_input[0], 0);//标准输入重定向到input管道的读取端
		close(cgi_output[0]);//关闭output管道的写入端
		close(cgi_input[1]);//关闭输出端
		sprintf(meth_env, "REQUEST_METHOD=%s", method);
		putenv(meth_env);
		if (strcasecmp(method, "GET") == 0) {//GET
			/*设置 query_string 的环境变量*/
			sprintf(query_env, "QUERY_STRING=%s", query_string);
			putenv(query_env);
		}
		else {   /* POST */
			/*设置 content_length 的环境变量*/
			sprintf(length_env, "CONTENT_LENGTH=%d", content_length);
			putenv(length_env);
		}
		execl(path, path, NULL);//exec函数簇,执行CGI脚本,获取cgi的标准输出作为相应内容发送给客户端
		//通过dup2重定向,标准输出内容进入管道output的输入端
		
		exit(0);//子进程退出
	}
	else {    /* parent */
		close(cgi_output[1]);//关闭管道的一端,这样可以建立父子进程间的管道通信
		close(cgi_input[0]);
		/*通过关闭对应管道的通道,然后重定向子进程的管道某端,这样就在父子进程之间构建一条单双工通道
		如果不重定向,将是一条典型的全双工管道通信机制
		*/
		if (strcasecmp(method, "POST") == 0)//POST方式,将指定好的传输长度字符发送
			/*接收 POST 过来的数据*/
		for (i = 0; i < content_length; i++) {
			recv(client, &c, 1, 0);//从客户端接收单个字符
			write(cgi_input[1], &c, 1);//写入input,然后重定向到了标准输入
			//数据传送过程:input[1](父进程) ——> input[0](子进程)[执行cgi函数] ——> STDIN ——> STDOUT 
			// ——> output[1](子进程) ——> output[0](父进程)[将结果发送给客户端]

		}
		while (read(cgi_output[0], &c, 1) > 0)//读取output的管道输出到客户端,output输出端为cgi脚本执行后的内容
			send(client, &c, 1, 0);//即将cgi执行结果发送给客户端,即send到浏览器,如果不是POST则只有这一处理

		close(cgi_output[0]);//关闭剩下的管道端,子进程在执行dup2之后,就已经关闭了管道一端通道
		close(cgi_input[1]);
		waitpid(pid, &status, 0);//等待子进程终止
	}
}
上面父子进程间的管道通信可以用下图表示:父子进程各司其职,分工合作,通过管道建立通信通道。

                                   
上面最终完整状态是 POST 方式,如果不是 POST 方式,则只有 output[0] ——> 客户端。
上面即服务器端的程序:这里简单罗列一下:
#define ISspace(x) isspace((int)(x))//若x为空格字符,返回true

#define SERVER_STRING "Server: jdbhttpd/0.1.0\r\n"

void accept_request(int);//客户端向服务器端发送请求
void bad_request(int);//告诉客户端请求出错,400
void cat(int, FILE *);//读取文件并发送给客户端
void cannot_execute(int);//通知客户端不能执行CGI脚本(perl)
void error_die(const char *);//打印出错信息
void execute_cgi(int, const char *, const char *, const char *);//执行CGI脚本,内部调用exec函数簇
int get_line(int, char *, int);//从套接字读取数据,返回读取到的字符个数
void headers(int, const char *);//返回HTTP头文件信息
void not_found(int);//通知客户端页面未找到,404
void serve_file(int, const char *);//发送消息给客户端,用于静态页面返回
int startup(u_short *);//服务器端套接字设置,创建,绑定,监听(TCP协议)
void unimplemented(int);//通知客户端所请求的网络方法没有实现(GET、POST)
下面这个就是服务器端的main.c
int main(void)
{
 int server_sock = -1;
 u_short port = 0;//传入的端口为0,
 int client_sock = -1;
 struct sockaddr_in client_name;
 int client_name_len = sizeof(client_name);
 pthread_t newthread;

 server_sock = startup(&port);//服务器端监听套接字设置
 printf("httpd running on port %d\n", port);

 /*多线程并发服务器模型*/
 while (1)
 {
  //主线程
  client_sock = accept(server_sock,
                       (struct sockaddr *)&client_name,
                       &client_name_len);//阻塞等待客户端连接请求
  if (client_sock == -1)
   error_die("accept");
 /* accept_request(client_sock); */
 if (pthread_create(&newthread , NULL, accept_request, client_sock) != 0)//创建工作线程,执行回调函数accept_request,参数client_sock
   perror("pthread_create");
 }

 close(server_sock);//关闭套接字,就协议栈而言,即关闭TCP连接

 return(0);
}

从上面我们可以的出Tinyhttp的工作流程:

  1. 服务器启动,指定端口或随机选取端口绑定httpd服务,监听客户端的连接请求。(startup 函数)
  2. 收到客户端的 HTTP 请求,派生一个线程去相应客户端请求(多线程服务器模型),即执行 accept_request 函数
  3. 服务器端解析客户端 HTTP 请求报文。判断是何种 method (GET or POST)以及获取 url。对于 GET 方法,如果携带参数,则 query_string 指针指向 url 中 ? 后面的 GET 参数(http 协议)
  4. 拷贝 url 数据到 path数组,表示浏览器请求的服务器文件路径,在 tinyhttpd 中服务器文件是在 htdocs 文件夹下,若 url 以 /  结尾,或 url 本身是个目录(stat 文件信息),则默认在 path 中加上 index.html,表示访问主页。
  5. 在文件路径合法的前提下,如果是静态页面访问 ,直接输出服务器文件到浏览器,即用 HTTP 格式写到客户端套接字上,然后跳到。如果是动态页面申请(带?的GET方式,POST方式,utl 为可执行文件),则转调用 excute_cgi 函数执行cgi脚本。
  6. 读取整个 HTTP 请求并丢弃,如果是 POST 则找出Content-Length。把"HTTP/1.0 200 OK\r\n" 状态码写到套接字。
  7. 建立两个管道,cgi_input 和 cgi_output ,并 fork 一个进程(必须 fork 子进程,pipe 管道才有意义)。建立父子进程间的通信机制。
  8. 在子进程中,对其进程下的管道进行重定向,并设置对应的环境变量(method、query_string、content_length),这些环境变量都是为了给 cgi 脚本调用,接着用 execl 运行 cgi 脚本,可以看出 cgi 脚本的执行在子进程中进行,然后结果通过管道以及重定向返回给父进程。
  9. 父进程中,关闭管道一端,如果是 POST 方式,则把 POST 数据写入 cgi_intput,已被重定向到 STDIN,读取 cgi_output 管道输出到客户端(浏览器输出),具体流程图参见上面的管道最终状态图。接着关闭所有管道,等待子进程结束。
  10. 关闭连接,完成一次 HTTP 请求与回应。
HTTP 是无连接的,在进行 Web 应用前无须建立专门的 HTTP 应用层会话连接,仅需要直接利用传输层已为它建立好的 TCP 传输连接即可。即虽然是不可靠的无连接协议,但使用可可靠的 TCP 传输层协议,所以从数据传输角度来讲,HTTP 的报文传输仍是可靠的。

值得说明的是,这个项目是不能直接在Linux环境下编译运行的,它本来是在Solaris上实现的,需要修改几处地方,由于篇幅问题,下一篇TinyHTTPd 在Linux 下编译 给出修改地方以及最后运行测试结果。

如果错误,欢迎指出,交流进步,谢谢。



  • 15
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值