CSAPP网络编程

客户端-服务器编程模型

每个网络应用都是基于客户端-服务器模型的,服务器管理某种资源,客户端向其请求,服务器处理请求,再发送响应,客户端再处理响应。这就是一个事务(模型中的基本操作)。

对一个主机而言,网络只是一种I/O设备。客户端和服务器通过在连接上发送和接受字节流来通信,这些字节流使用套接字接口函数和Unix I/O函数进行处理。

一个套接字是连接的一个端点,每个套接字都有相应的套接字地址,由一个因特网地址和一个16位的整数端口组成。客户端中的地址的端口是由内核自动分配的,称为临时端口。服务器中的通常是某个知名的端口。

一个连接是由它两端的套接字地址唯一确定的,这对套接字地址叫做套接字对(socket pair),表示为(cliaddr: cliport, servaddr: servport)

IP地址结构是一个32位无符号整数,放在所谓的IP地址结构中:

1
2
3
struct in_addr{
unsigned int s_addr;
}

IP地址总是以(大端法)网络字节顺序存放的,主机字节顺序是小端法,Unix提供了下面的转换函数:

1
2
3
4
5
6
#include<netinet/in.h>
unsigned long int htonl(unsigned long int hostlong); //将32位主机字节顺序转为网络字节顺序
unsigned short int htons(unsigned short int hostshort); //16位

unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);

下面的函数实现IP地址和点分十进制串之间的转换,inet_aton函数将点分十进制串(cp)转为一个网络字节顺序的IP地址,a代表application,n代表network:

1
2
3
4
#include<arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp); //成功返回1,出错返回0

char *inet_ntoa(struct in_addr in); //返回指向点分十进制字符串的指针

DNS通过分布世界范围内的数据库来维护的,DNS数据库有上百万的主机条目结构组成的,每条定义了一组域名和一组IP地址之间的映射:

1
2
3
4
5
6
7
struct hostent{
char *h_name; //域名
char **h_aliases; //以空值结尾的域名数组
int h_addrtype; //地址
int h_length; //地址的长度
char **h_addr_list; //以空值结尾的地址数组
}

关于网络协议还有网络架构模型各层次的细节等网络的东西就不在这里说了

套接字接口

是一组函数,和Unix I/O 函数结合起来创建网络应用

nIYkQS.png

从Unix内核角度看,一个套接字就是通信的一个端点,从Unix程序的角度看,套接字就是一个有相应描述符的打开文件

客户端和服务器都是用socket 函数来创建一个套接字描述符,但是区别是:

  • 客户端创建之后再使用connect 函数来建立和服务器的连接
  • 而服务器1. 通过bind 函数将服务器套接字地址和套接字描述符sockfd联系起来,2. 通过listen 函数将sockfd从一个主动套接字转换为一个监听套接字;3. 通过调用accept 函数等待来自客户端的连接请求,然后记录客户端的套接字地址,并返回一个已连接描述符
1
2
3
4
5
6
7
8
9
10
11
//sockfd成功指向套接字对,serv_addr是连接的服务器端地址,addrlen是套接字地址的长度
int connect(int sockfd, struct sockaddr *serv_addr, int addrlen); //成功返回0,出错返回-1

//my_addr都是连接的服务器端地址
int bind(int sockfd, struct scokaddr *my_addr, int addrlen);

//backlog表示内核在拒绝连接请求前,队列中等待的未完成连接请求的数量
int listen(int sockfd, int backlog);

//addr指向客户端的套接字
int accept(int listenfd, struct sockaddr *addr, int *addrlen); //成功返回非负连接描述符,出错返回-1

监听描述符和已连接描述符的区别:

  • 监听描述符是作为客户端连接请求的一个端点,它被创建一次,并存在于服务器的整个生命周期
  • 已连接描述符是客户端和服务器之间已经建立起来了的连接的一个端点,服务器每次接受请求时都会创建一次,它只存在于服务器为一个客户端服务的过程中

Web服务器

web基础

web 客户端与服务器间交互基于文本的应用级协议,叫HTTP,web 内容可以用HTML 语言来编写,内容分两种:

  • 静态:服务器取一个磁盘文件并将其内容返回给客户端,磁盘文件叫做静态文件,返回文件给客户端的过程叫做服务静态内容
  • 动态:运行一个可执行文件,并将它的输出返回给客户端,运行可执行文件产生的输出叫做动态内容,返回给客户端的过程叫做服务动态内容

每条web 服务器返回的内容都是和它管理的某个文件相关联的,每个文件都有唯一的名字,叫做URL,通用资源定位符,如 http://www.google.com:80/index.html(URI 统一资源标识符是相应的URL 的后缀,即 index/html

在事务过程中客户端和服务器使用的是URL 的不同部分:

  • 客户端使用前缀来决定与哪类服务器联系,服务器在哪里,它监听的端口号是多少
  • 服务器使用后缀来发现在它文件系统中的文件,并确定请求的静态内容还是动态内容

服务器如何解释一个URL 的后缀:

  • 后缀中最开始的 ‘/‘ 不表示Unix 的根目录,而是被请求内容类型的主目录
  • 如果后缀中只有一个 ‘/‘,所有服务器将其扩展为某个默认的主页,如 /index.html。并且当我们没有输入这个 ‘/‘ 时,游览器还会自动在URL后面添加缺失的 ‘/‘,然后服务器又把 ‘/‘ 扩展到默认的文件名,所以我们输入网址时经常只输入一个 www.google.com就行了

HTTP事务

HTTP请求

组成:一个请求行,后面跟随0个或多个请求报头,再跟随一个空的文本行来终止报头列表。请求行的形式:

<method> <uri> <version>

请求报头还为服务器提供了额外的信息,如浏览器的商标名,MIME 类型,其格式为:

<header name>: <header data>

HTTP响应

组成:一个响应行,后面跟随0个或多个响应报头,再跟随一个终止报头的空行,再跟随一个响应主体

一个响应行的格式为:<version> <status code> <status message>

响应报头提供了关于响应的附加信息

服务动态内容的问题

CGI 通用网关接口的标准的出现解决了这些问题

  1. 客户端如何将程序参数传给服务器?
  2. 服务器如何将参数传给子程序?
  3. 服务器如何将其他信息传给子程序?
  4. 子进程将它的输出发送到哪里?

工作流程

基于上图整个的工作流程有 5 步:

  1. 开启服务器(open_listenfd函数,做好接收请求的准备)
    • getaddrinfo: 设置服务器的相关信息
    • socket: 创建 socket descriptor,也就是之后用来读写的 file descriptor
      • int socket(int domain, int type, int protocol)
      • 例如 int clientfd = socket(AF_INET, SOCK_STREAM, 0);
      • AF_INET 表示在使用 32 位 IPv4 地址
      • SOCK_STREAM 表示这个 socket 将是 connection 的 endpoint
      • 前面这种写法是协议相关的,建议使用 getaddrinfo 生成的参数来进行配置,这样就是协议无关的了
    • bind: 请求 kernel 把 socket address 和 socket descriptor 绑定
      • int bind(int sockfd, SA *addr, socklen_t addrlen);
      • The process can read bytes that arrive on the connection whose endpoint is addr by reading from descriptor sockfd
      • Similarly, writes to sockfd are transferred along connection whose endpoint is addr
      • 最好是用 getaddrinfo 生成的参数作为 addraddrlen
    • listen: 默认来说,我们从socket函数中得到的 descriptor 默认是 active socket(也就是客户端的连接),调用listen函数告诉 kernel 这个 socket 是被服务器使用的
      • int listen(int sockfd, int backlog);
      • sockfd 从 active socket 转换成 listening socket,用来接收客户端的请求
      • backlog 的数值表示 kernel 在接收多少个请求之后(队列缓存起来)开始拒绝请求
    • [*]accept: 调用accept函数,开始等待客户端请求
      • int accept(int listenfd, SA *addr, int *addrlen);
      • 等待绑定到 listenfd 的连接接收到请求,然后把客户端的 socket address 写入到 addr,大小写入到 addrlen
      • 返回一个 connected descriptor 用来进行信息传输(类似 Unix I/O)
      • 具体的过程可以参考 图3
  2. 开启客户端(open_clientfd函数,设定访问地址,尝试连接)
    • getaddrinfo: 设置客户端的相关信息,具体可以参见 图1&2
    • socket: 创建 socket descriptor,也就是之后用来读写的 file descriptor
    • connect: 客户端调用connect来建立和服务器的连接
      • int connect(int clientfd, SA *addr, socklen_t addrlen);
      • 尝试与在 socker address addr 的服务器建立连接
      • 如果成功 clientfd 可以进行读写
      • connection 由 socket 对描述 (x:y, addr.sin_addr:addr.sin_port)
      • x 是客户端地址,y 是客户端临时端口,后面的两个是服务器的地址和端口
      • 最好是用 getaddrinfo 生成的参数作为 addraddrlen
  3. 交换数据(主要是一个流程循环,客户端向服务器写入,就是发送请求;服务器向客户端写入,就是发送响应)
    • [Client]rio_writen: 写入数据,相当于向服务器发送请求
    • [Client]rio_readlineb: 读取数据,相当于从服务器接收响应
    • [Server]rio_readlineb: 读取数据,相当于从客户端接收请求
    • [Server]rio_writen: 写入数据,相当于向客户端发送响应
  4. 关闭客户端(主要是close
    • [Client]close: 关闭连接
  5. 断开客户端(服务接收到客户端发来的 EOF 消息之后,断开已有的和客户端的连接)
    • [Server]rio_readlineb: 收到客户端发来的关闭连接请求
    • [Server]close: 关闭与客户端的连接

Tiny Web Server

用c语言实现一个简单的服务器,很刺激

main函数

是主程序,通过调用open_listenfd函数打开一个监听套接字后就无限服务器循环,不断接受连接请求,再执行事务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int main(int argc, char **argv)
{
int listenfd, connfd, port, clientlen;
struct sockaddr_in clientaddr;

if (argc != 2)
{
//fprintf()函数根据指定的format(格式)发送信息(参数)到由stream(流)指定的文件
fprintf(stderr, "usage:%s <port>\n", argv[0]);
exit(1);
}
port = atoi(argv[1]);

//open_listenfd 函数打开一个监听套接字
listenfd = Open_listenfd(port);
while (1)
{
clientlen = sizeof(clientaddr);
//不断接收连接请求,返回连接描述符
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
doit(connfd); //执行事务
close(connfd);
}

}

doit 函数

处理一个HTTP事务,首先读和解析请求行,目前只支持get方法,如果客户端请求其他方法就发送一个错误信息给它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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;

//读取请求行和headers
Rio_readinitb(&rio, fd);
Rio_readlineb(&rio, buf, MAXLINE);
sscanf(buf, "%s %s %s", method, uri, version); //从字符串读取格式化输入
if (strcasecmp(method, "GET"))
{
clienterror(fd, method, "501", "Not Implemented", "Tiny does not implement this method");
return;
}

//读并且忽略任何请求报头
read_requesthdrs(&rio);

//从GET请求中解析URI
is_static = parse_uri(uri, filename, cgiargs);
//stat()用来将参数file_name 所指的文件状态, 复制到参数buf 所指的结构中
if (stat(filename, &sbuf) < 0)
{
clienterror(fd, filename, "404", "Not found", "Tiny couldn't find this file");
return;
}

if (is_static) //处理静态内容
{
//通过属性判断给定的文件,S_ISREG是否是一个常规文件,S_IRUSR判断文件所有者具可读取权限
if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & *sbuf.st_mode))
{
clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't read this file");
return;
}
serve_static(fd, filename, sbuf.st_size); //服务静态内容
}
else //动态内容
{
//S_IXUSR判断文件所有者具可执行权限
if (!(S_ISREG(sbuf.st_mode)) || !(S_IXUSR & *sbuf.st_mode))
{
clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't run this CGI program");
return;
}
serve_dynamic(fd, filename, cgiargs); //服务动态内容
}
}

其中用到的一些函数就不再贴出其代码啦

小结

主要是了解互联网络(internet)和因特网(Internet)的基本概念以及 web 服务器的一些基础知识,比如使用的协议,通信的事务流程,如何提供静态内容与动态内容

客户端-服务器模型:利用套接字接口进行通信,一个套接字时连接的一个端点,连接通过文件描述符的形式提供给应用程序,而套接字接口提供了打开和关闭套接字描述符的函数,客户端和服务器通过读写这些描述符来实现通信

用 c 实现一个小的web 服务器,可提供静态内容,也可提供动态内容