这一节来编写一个简单的Web服务器, 一切都是从一个简单的原型开始的.
- 套接字读写的客户端与服务端
- 微型Web服务器 - 主程序
- doit 函数
- clienterror 函数
- read_requesthdrs 函数
- parse_uri 函数
- server_static 函数 函数
- serve_dynamic 函数
- get_filetype 函数
套接字读写的客户端与服务端
似乎网络编程都是从最简单的套接字读写开始的, CSAPP也不例外. 先来看看简单的客户端. 客户端的原理比较简单, 通过服务器的套接字地址去连接服务器, 然后从标准输入读取字符发送到服务端, 不断循环.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "csapp.h"
#define MAXLINE 8192
int main(int argc, char **argv) {
//声明客户端描述符
int clientfd;
//声明 主机名称, 端口, 缓冲区
char *host, *port, buf[MAXLINE];
// 声明与缓冲区和描述符联系起来的结构
rio_t rio;
//判断命令行是否正确
if (argc != 3) {
fprintf(stderr, "usage: %s <host> <port>\n", argv[0]);
}
host = argv[1];
port = argv[2];
//创建可以读写的描述符
clientfd = Open_clientfd(host, port);
//将描述符关联到缓冲区结构
Rio_readinitb(&rio, clientfd);
//读取输入直到结束
while (Fgets(buf, MAXLINE, stdin) != NULL) {
//将读取到的内容写入套接字描述符, 会一直写
Rio_writen(clientfd, buf, strlen(buf));
Rio_readlineb(&rio, buf, MAXLINE);
Fputs(buf, stdout);
}
//关闭描述符
Close(clientfd);
exit(0);
}
再来看看服务端, 服务端的原理是创建套接字用于等待连接, 连接成功之后打印连进来的客户端的连接信息, 然后显示接收到了多少字节的消息:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "csapp.h"
#define MAXLINE 8192
void echo(int connfd);
int main(int argc, char **argv) {
//声明监听套接字和已连接套接字
int listenfd, connfd;
//声明客户端长度
socklen_t clientlen;
//这个是特殊的结构, 用于存放客户端的套接字地址结构
struct sockaddr_storage clientaddr;
//这两个结构用于存放getnameinfo的结果
char client_hostname[MAXLINE], client_port[MAXLINE];
//判断命令行是否错误
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(0);
}
//使用编写的函数打开套接字
listenfd = Open_listenfd(argv[1]);
//开始无限循环, 每一次进来连接就将地址写入 clientaddr 和 clientlen 然后打印出来
while (1) {
//计算保存客户端套接字地址的长度
clientlen = sizeof(struct sockaddr_storage);
//调用accept函数, 将客户端套接字地址放入 clientaddr
connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
//调用 getnameinfo 将获取的客户端套接字地址转换成域名和端口
Getnameinfo((SA *) &clientaddr, clientlen, client_hostname, MAXLINE, client_port, MAXLINE, 0);
//打印客户端的连接信息
printf("Connected to (%s, %s)\n", client_hostname, client_port);
//调用函数处理客户端发来的信息
echo(connfd);
//关闭套接字
Close(connfd);
}
exit(0);
}
void echo(int connfd){
size_t n;
char buf[MAXLINE];
rio_t rio;
Rio_readinitb(&rio, connfd);
while ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
printf("server received %d bytes \n", (int) n);
Rio_writen(connfd, buf, n);
}
}
这个就是最简单的套接字连接了, 在此基础上还可以去扩展一些功能.
微型Web服务器
一些WEB概念:
- HTTP是一个基于文本的协议
- Web内容是一个指定了MIME类型的字节序列.常用的有 text/html text/plain image/gif image/png image/jpeg 等, 当然还有JSON之类, 可以通过浏览器来查看.
- Web服务提供的内容有两种, 一种仅仅是将磁盘上的文件发送给客户端, 这个叫做静态内容; 还有一种情况是Web服务器接到请求之后执行一些程序, 将程序执行的结果返回给客户端, 这个叫做动态内容.
- URL 是指包含 协议,域名和目录以及参数的完整地址, 比如
http://www.google.com:80/index.html?search=saner
. URI 是指后边的部分, 即/index.html?search=saner
- 如何解析一个URL并根据URL来提供内容, 是Web服务器的事情, 所以动态还是静态内容从URL上无法区分
服务动态内容需要符合CGI通用网关接口标准, 即URL传参的标准. 用 ?
来分隔文件名和参数, 然后用&字符来分割不同的参数. 参数中不允许有空格, 必须用字符串"%20"来替代. 很多特殊字符也都有对应的编码.
服务器在接收到URL的时候, 会先解析URL, 然后取出参数部分和文件, 之后会启动一个子进程, 在其中用 execve 来执行文件, 参数会被设置在子进程的QUERY_STRING环境变量中, 这样子进程就可以通过环境变量获取参数.
CGI定义了很多常用的环境变量:
环境变量 |
说明 |
QUERY_STRING |
程序参数 |
SERVER_PORT |
父进程侦听的端口 |
REQUIRED_METHOD |
请求类型 |
REMOTE_HOST |
客户端的域名 |
REMOTE_ADDR |
客户端的点分十进制IP地址 |
CONTENT_TYPE |
仅仅针对POST请求而言的MIME类型 |
CONTENT_LENGTH |
仅仅针对POST请求的请求体的字节大小 |
子进程在fork之后, 会把输出重定向到已连接描述符, 这样程序的输出都会写到响应中去.
主程序比较简单, 就是等待接受连接, 然后执行doit程序:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "csapp.h"
#define MAXLINE 8192
//执行事务的主函数
void doit(int fd);
//读取请求头的函数
void read_requesthdrs(rio_t *rp);
//解析URI的函数
int parse_uri(char *uri, char *filename, char *cgiargs);
//提供静态服务的函数
void server_static(int fd, char *filename, int filesize);
//获取文件类型的函数
void get_filetype(char *filename, char *filetype);
//提供动态服务的函数
void serve_dynamic(int fd, char *filename, char *cgiargs);
//专门返回错误响应的函数
void clienterror(int fd, char *cause, char *errnum, char *shortmsg, char *longmsg);
int main(int argc, char **argv){
//声明描述符
int listenfd, connfd;
//客户名称和端口
char hostname[MAXLINE], port[MAXLINE];
//长度
socklen_t clientlen;
//套接字地址
struct sockaddr_storage clientaddr;
//检查参数
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(1);
}
listenfd = Open_listenfd(argv[1]);
while (1) {
clientlen = sizeof(clientaddr);
connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
Getnameinfo((SA *) &clientaddr, clientlen, hostname, MAXLINE, port, MAXLINE, 0);
printf("Accepted connection from (%s, %s)\n", hostname, port);
doit(connfd);
Close(connfd);
}
}
doit 函数
从上边的程序可以看出, 其核心是doit函数, 即具体执行工作的函数. 其工作逻辑如下:
- 读HTTP请求行并且解析, 如果是GET以外的方法, 就返回错误信息.
- 如果是GET方法, 忽略请求头, 解析URI
- 将URI解析为一个文件名和一个可能是空串的结果, 设置一个标志表示是静态还是动态内容
- 如果请求的是静态内容, 就去找这个文件, 检查权限然后将文件返回给客户端.
- 如果是动态内容, 验证这个文件是不是可执行文件, 如果是则执行, 并将结果返回给客户端.
来看看看代码:
void doit(int fd){
//是否静态
int is_static;
//用于读取文件信息的结构
struct stat sbuf;
char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
char filename[MAXLINE], cgiargs[MAXLINE];
rio_t rio;
//读取请求行放入buf缓冲区
Rio_readinitb(&rio, fd);
Rio_readlineb(&rio, buf, MAXLINE);
printf("Resquest headers:\n");
printf("%s", buf);
//将请求行的方法, URI 和HTTP版本信息分别读取到三个变量里
sscanf(buf, "%s %s %s", method, uri, version);
//忽略大小写比较请求内容与GET,如果不相等, 就返回错误信息
if (strcasecmp(method, "GET")) {
//调用专门的函数去写错误信息到fd中
clienterror(fd, method, "501", "Not implemented", "Tiny dose not implement this method");
return;
}
//读取头部信息,这里实际上是忽略了, 可以自己来添加这些内容
read_requesthdrs(&rio);
//URI此时存放在uri变量中, 解析的结果会设置变量is_static, 然后在filename中存放文件名, 参数存放在cgiargs中
is_static = parse_uri(uri, filename, cgiargs);
//通过stat函数查找文件, 不存在则返回-1, 检测到-1就返回404错误.
if (stat(filename, &sbuf) < 0) {
clienterror(fd, filename, "404", "Not found", "Tiny could not find this file");
return;
}
//文件存在, 再根据是否是静态文件, 来决定返回文件本身还是返回执行的结果
//是静态文件,先检查权限
if(is_static){
//检查权限, 不具备权限就报403错误
if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) {
clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't read the file.");
return;
}
//具备权限, 返回静态文件
server_static(fd, filename, sbuf.st_size);
}
//是动态文件, 检查是否可执行
else{
//没有执行权限就报403错误
if (!(S_ISREG(sbuf.st_mode)) || !(S_IXUSR & sbuf.st_mode)) {
clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't run the CGI program");
}
//提供动态服务
serve_dynamic(fd, filename, cgiargs);
}
}
doit 函数通过parse_uri函数获取文件名和参数变量, 据此调用静态或者动态服务函数.
clienterror 函数
这其实是将返回错误信息的部分单独抽出来了做成函数, 没有什么好说的, 就是返回错误信息.
void clienterror(int fd, char *cause, char *errnum, char *shortmsg, char *longmsg){
char buf[MAXLINE], body[MAXBUF];
//创建响应体
sprintf(body, "<HTML><title>Tiny Error</title>");
sprintf(body, "%s<body bgcolor=""ffffff"">\r\n", body);
sprintf(body, "%s%s: %s\r\n", body, errnum, shortmsg);
sprintf(body, "%s<p>%s: %s\r\n", body, longmsg, cause);
sprintf(body, "%s<hr><em>The Tiny Web server<em>\r\n", body);
//创建响应头
sprintf(buf, "HTTP/1.0 %s %s\r\n", errnum, shortmsg);
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-type: text/html\r\n");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-length: %d\r\n", (int)strlen(body));
Rio_writen(fd, buf, strlen(buf));
//反复写完响应头, 然后写响应体
Rio_writen(fd, body, strlen(body));
}
read_requesthdrs 函数
这个函数就用于读取请求头并显示出来, 实际上什么也没干.
void read_requesthdrs(rio_t *rp){
char buf[MAXLINE];
//请求头每一行都以\r\n结尾, 最后一行是一个空的行, 只有\r\n, 所以每次进行比较.
Rio_readlineb(rp, buf, MAXLINE);
while (strcmp(buf, "\r\n")) {
Rio_readlineb(rp, buf, MAXLINE);
printf("%s", buf);
}
}
parse_uri 函数
这个函数是一个比较核心的函数, 用于解析URI, 解析出来的结果是一个文件名和一个参数字符串. 如果是静态内容, 就清除掉参数字符串.
URI则会被转换成相对地址, 以让程序去寻找路径. 如果是动态内容, 则会取出所有的参数, 然后也需要转换文件名.
这里如何判断是静态还是动态文件是根据是否能在uri里搜索到cgi-bin
字样来判断的.
int parse_uri(char *uri, char *filename, char *cgiargs){
char *ptr;
//如果搜索不到cgi-bin的字样, 就说明是静态文件
if (!strstr(uri, "cgi-bin")) {
//将参数设置为空串
strcpy(cgiargs, "");
//转换文件名为相对路径
strcpy(filename, ".");
//拼接成 ./xxxxx 开头的字符串
strcat(filename, uri);
//检测最后一个字符是不是"/", 如果是, 就拼上默认的文件, 然后返回1表示静态文件
if (uri[strlen(uri) - 1] == '/') {
strcat(filename, "home.html");
}
//返回1表示静态
return 1;
}
//搜索到cgi-bin就说明是动态文件, 此时要操作参数. 之后返回0表示动态
else {
//查找URI中问号的部分
ptr = index(uri, '?');
//如果找到, 把ptr位置之后的部分复制到cgiargs中, 然后把ptr置为'\0', 复制前一部分到buf中
if (ptr) {
*ptr = '\0';
strcpy(cgiargs, ptr + 1);
} else {
//没找到参数, 就设置为空字符串
strcpy(cgiargs, "");
}
//和上边一样, 拼接地址为 ./xxxxx
strcpy(filename, ".");
strcat(filename, uri);
return 0;
}
}
server_static 函数
提供静态服务的函数, 找到文件然后将文件复制到内存中, 之后从内存里写入到响应中.
void server_static(int fd, char *filename, int filesize){
//声明静态文件的描述符
int srcfd;
char *srcp, filetype[MAXLINE], buf[MAXLINE];
//获取文件类型
get_filetype(filename, filetype);
//准备响应头
sprintf(buf, "HTTP/1.0 200 OK\r\n");
sprintf(buf, "%sServer: Tiny Web Server\r\n", buf);
sprintf(buf, "%sConnection: close\r\n", buf);
sprintf(buf, "%sContent-length: %d\r\n", buf, filesize);
sprintf(buf, "%sContent-type: %s\r\n\r\n", buf, filetype);
Rio_writen(fd, buf, strlen(buf));
printf("Response headers:\n");
printf("%s", buf);
//准备响应体, 就是将文件读取之后返回给客户端
srcfd = Open(filename, O_RDONLY, 0);
//使用mmap函数将文件载入内存
srcp = Mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0);
//这里要注意, 将文件读入内存之后, 就可以关闭描述符了, 剩下就从内容复制到已连接描述符就可以了.
Close(srcfd);
//filesize是从 doit 函数中的文件信息中获得的
Rio_writen(fd, srcp, filesize);
//复制完之后释放这块指针
Munmap(srcp, filesize);
}
serve_dynamic 函数
提供动态服务的函数, 新启动一个子进程, 重定向好标准输出, 设置好环境变量, 然后将控制权交给要执行的函数.
void serve_dynamic(int fd, char *filename, char *cgiargs){
//声明一个缓冲区, 还有一个字符串数组
char buf[MAXLINE], *emptylist[] = {NULL};
//先拼出头部
sprintf(buf, "HTTP/1.0 200 OK\r\n");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Server: Tiny Web Server\r\n");
Rio_writen(fd, buf, strlen(buf));
//起一个子进程, 在子进程里设置好环境变量, 重定向好标准输出到已连接描述符, 之后换成adder.c来执行, 其实就是刚才编写的CGI程序.
if (Fork() == 0) {
setenv("QUERY_STRING", cgiargs, 1);
Dup2(fd, STDOUT_FILENO);
Execve(filename, emptylist, environ);
}
Wait(NULL);
}
get_filetype 函数
这是个辅助函数, 用于从文件名中获取文件的类型拼接在响应头中. 是一个辅助函数.
void get_filetype(char *filename, char *filetype){
//只支持五种文件, 文件名来自于parse_uri拼接后的filename
if (strstr(filename, ".html")) {
strcpy(filetype, "text/html");
} else if (strstr(filename, ".gif")) {
strcpy(filetype, "image/gif");
} else if (strstr(filename, ".png")) {
strcpy(filetype, "image/png");
} else if (strstr(filename, ".jpg")) {
strcpy(filetype, "image/jpeg");
} else {
strcpy(filetype, "text/plain");
}
}
通过底层的Web服务器, 可以清楚的知道操作字节和解析字符的方法. 通过解析的内容, 以及系统编程的技巧, 对于静态文件读入内存并返回, 对于动态文件, 启动新进程执行, 利用到了几乎所有系统编程的方方面面.有了一个最简单的原型, 之后就可以来扩充各种内容了. 继续前进, 是最后一章了.