CSAPP 系统级I/O和网络编程

image

from pixiv

系统级I/O

文件

所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O

Linux 文件有主要有三种类型:

  • 普通文件
  • 目录
  • 网络socket(套接字)

当然还有命名通道(named pipe)、 符号链接(symbolic link),以及字符和块设备(character and block device)等类型先不予讨论。

改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置 k,初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行 seek 操作,显式地设置文件的当前位置为 k。

对于这种行为,对于普通文件是有效的,对于如socket、目录等类型文件无效。

当我们调用open函数两次打开同一个普通文件foo.txt,那么这两次打开均会从文件起始位置开始,然后记录file_offset, read和write的读写操作均会改变file_offset。

且两次open返回的是不同的文件描述符。有点像CSAPP书上下图:
image

关于文件描述符,文件表,v-node表在fork后,重定向后如何均有提及。

RIO

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t n);
// 返回:若成功则为读的字节数,若 EOF 则为0,若出错为 -1。

ssize_t write(int fd, const void *buf, size_t n);
// 返回:若成功则为写的字节数,若出错则为 -1。

在 x86-64 系统中,size_t 被定义为 unsigned long,而 ssize_t(有符号的大小)被定义为 long。
read 函数返回一个有符号的大小,而不是一个无符号大小,这是因为出错时它必须返回 -1。

RIO(Robust I/O,健壮的 I/O)包,其实现的思路和目的为:

  • 处理不足值:在某些情况下,read 和 write 传送的字节比应用程序要求的要少。这些不足值(short count)不表示有错误。出现这样情况的原因有:

    • 读时遇到 EOF。这个时候说明确实没有内容可以读了,直接返回。
    • 从终端读文本行。这个时候每个 read 函数将一次传送一个文本行。
    • 读和写网络套接字(socket)。那么内部缓冲约束和较长的网络延迟会引起 read 和 write 返回不足值。这个时候必须通过反复调用 read 和 write 处理不足值,直到所有需要的字节都传送完毕。
  • 实现无缓冲的输入输出函数:rio_readnrio_writen。这些函数直接在内存和文件之间传送数据,没有应用级缓冲。它们对将二进制数据读写到网络和从网络读写二进制数据尤其有用。

ssize_t rio_readn(int fd, void *usrbuf, size_t n);
ssize_t rio_writen(int fd, void *usrbuf, size_t n);
// 返回:若成功则为传送的字节数,若 EOF 则为 0(只对 rio_readn 而言),若出错则为 -1。
  • 实现带缓冲的输入函数。这些函数允许你高效地从文件中读取文本行和二进制数据。
#define RIO_BUFSIZE 8192
typedef struct {
    int rio_fd;                /* Descriptor for this internal buf */
    int rio_cnt;               /* Unread bytes in internal buf */
    char *rio_bufptr;          /* Next unread byte in internal buf */
    char rio_buf[RIO_BUFSIZE]; /* Internal buffer */
} rio_t;
//初始化rp指针
void rio_readinitb(rio_t *rp, int fd);
// 返回:无。

//对于文本数据,rio_readlineb,它从一个内部读缓冲区复制一个文本行,当缓冲区变空时,会自动地调用 read 重新填满缓冲区。
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);
//对于既包含文本行也包含二进制数据的文件,提供了一个 rio_readn 带缓冲区的版本,叫做 rio_readnb,它从和 rio_readlineb 一样的读缓冲区中传送原始字节。
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);
// 返回:若成功则为读的字节数,若 EOF 则为 0,若出错则为 -1。

手动实现一下

rio.c

#include "rio.h"


void unix_error(char *msg) /* Unix-style error */
{
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(0);
}

ssize_t rio_readn(int fd, void *userbuf, size_t n)
{
    size_t nleft = n;  // nleft为剩余需要读的字节数。
    ssize_t nread = 0; // nread为接受read返回的字节数。
    char *buf = userbuf; //char数据类型正好一个字节,这里的buf是userbuf的一个指针。
    while (nleft > 0) {
        if ((nread = read(fd, buf, nleft)) < 0) {
            if (errno == EINTR)  nread = 0; // 如果遇到中断,需要继续尝试读。
            else return -1; // 否则其他错误返回-1。
        } else if (nread == 0) break; // 说明读完了
        nleft -= nread; buf += nread; // 还需要继续读
    }
    return (n -  nleft); // 总共读到的字节数
}

ssize_t rio_writen(int fd, const void *userbuf, size_t n)
{
    size_t nleft = n;
    ssize_t nwrite = 0;
    const char *buf = userbuf;
    while (nleft > 0) {
        if ((nwrite = write(fd, buf, n)) <= 0) { //需要注意的是这里和rio_readn不同,若 == 0说明是写失败了
            if (errno = EINTR) nwrite = 0; 
            else return -1;
        }
        nleft -= nwrite; buf += nwrite;
    }
    return (n - nleft);
}

void rio_readinitb(rio_t *rp, int fd)
{
    rp->rio_fd = fd;
    rp->rio_cnt = 0;
    rp->rio_bufptr = rp->rio_buf;
}

// rio_readnb 和 rio_readlineb都是从rp的内部缓冲区中读数据出来到指定数组中。
// 所以需要一个函数将数据从文件读到内部缓冲区来,并且再从内部缓冲区读数据到指定数组中。
// 且此函数还需要当内部缓冲区空了,则自动从文件中读数据填充。这个函数命名为rio_read
ssize_t rio_read(rio_t *rp, void *userbuf, size_t n)
{
    ssize_t nread = 0;
    if (rp->rio_cnt <= 0){ // 查看内部缓冲区是否还有数据,若没有则从文件中读取数据到内部缓冲区。
        if ((nread = rio_readn(rp->rio_fd, rp->rio_buf, sizeof(rp->rio_buf))) < 0) return -1;
        else if (nread == 0) return 0; // 说明读完了且rp内部缓冲区还没有数据了。
        rp->rio_cnt = nread;  rp->rio_bufptr = rp->rio_buf;
    }
    nread = n < rp->rio_cnt ? n : rp->rio_cnt;
    memcpy(userbuf, rp->rio_bufptr, nread);
    rp->rio_cnt -= nread; rp->rio_bufptr += nread; // rp->rio_bufptr的作用在于当内部缓冲区还未读完时,继续从未读的地方开始。
    return nread;
}


ssize_t rio_readnb(rio_t *rp, void *userbuf, size_t n)
{
    size_t nleft = n;
    ssize_t nread = 0;
    char *buf = userbuf;
    while (nleft > 0){
        if ((nread = rio_read(rp, buf, nleft)) < 0) return -1;
        else if (nread == 0) break;
        nleft -= nread; buf += nread;
    } 
    return (n - nleft);
}

ssize_t rio_readlineb(rio_t *rp, void *userbuf, size_t maxlen) //读出一行文本,这一行文本<=maxlen个字符,不包括换行符\n
{
    size_t nleft = maxlen;
    ssize_t nread = 0;
    char c, *buf = userbuf;
    while (nleft > 0) {
        if ((nread = rio_read(rp, &c, 1)) < 0) return -1;
        else if (nread == 0) break;
        
        assert(nread == 1);
        if (c == '\n') break;
        *buf = c; buf++; nleft--;
    }
    return (maxlen - nleft);
}

/**********************************
 * Wrappers for robust I/O routines
 **********************************/
ssize_t Rio_readn(int fd, void *ptr, size_t nbytes) 
{
    ssize_t n;
  
    if ((n = rio_readn(fd, ptr, nbytes)) < 0)
	unix_error("Rio_readn error");
    return n;
}

void Rio_writen(int fd, void *usrbuf, size_t n) 
{
    if ((size_t)rio_writen(fd, usrbuf, n) != n)
	unix_error("Rio_writen error");
}

void Rio_readinitb(rio_t *rp, int fd)
{
    rio_readinitb(rp, fd);
} 

ssize_t Rio_readnb(rio_t *rp, void *usrbuf, size_t n) 
{
    ssize_t rc;

    if ((rc = rio_readnb(rp, usrbuf, n)) < 0)
	unix_error("Rio_readnb error");
    return rc;
}

ssize_t Rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen) 
{
    ssize_t rc;

    if ((rc = rio_readlineb(rp, usrbuf, maxlen)) < 0)
	unix_error("Rio_readlineb error");
    return rc;
}

其中rio_read的实现思路为:

image



rio.h

#ifndef __RIO_H__
#define __RIO_H__
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <assert.h>

void unix_error(char *msg);


//不带缓冲的输入输出函数
ssize_t rio_readn(int fd, void *userbuf, size_t n); // 返回:若成功则为读的字节数,若 EOF 则为0,若出错为 -1。
ssize_t rio_writen(int fd, const void *userbuf, size_t n); // 返回:若成功则为写的字节数,若出错则为 -1。


//带缓冲的输入函数
#define RIO_BUFSIZE 8192
typedef struct {
    int rio_fd;
    size_t rio_cnt; //rio_buf中还未读数据的大小
    char *rio_bufptr; //rio_buf中下一个要读取的位置
    char rio_buf[RIO_BUFSIZE]; //rio内部缓冲区
} rio_t;

void rio_readinitb(rio_t *rp, int fd); 
ssize_t rio_readnb(rio_t *rp, void *userbuf, size_t n); // 返回:若成功则为读的字节数,若 EOF 则为 0,若出错则为 -1。 
ssize_t rio_readlineb(rio_t *rp, void *userbuf, size_t maxlen); // 返回:若成功则为读的字节数,若 EOF 则为 0,若出错则为 -1。

/* Wrappers for Rio package */
ssize_t Rio_readn(int fd, void *usrbuf, size_t n);
void Rio_writen(int fd, void *usrbuf, size_t n);
void Rio_readinitb(rio_t *rp, int fd); 
ssize_t Rio_readnb(rio_t *rp, void *usrbuf, size_t n);
ssize_t Rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);

#endif

errno是一个全局变量,用于存储最近一次系统调用或库函数调用所发生错误的错误码。

在 Unix、Linux 和其他类 Unix 操作系统中,许多系统调用(如open、read、write、fork等)和一些库函数在执行过程中如果遇到错误,会将一个对应的错误码存储到errno中。

使用errno需要导入#include <errno.h>

标准I/O

C 语言定义了一组高级输入输出函数,称为标准 I/O 库,为程序员提供了 Unix I/O 的较高级别的替代。

标准 I/O 库将一个打开的文件模型化为一个流。对于程序员而言,一个流就是一个指向 FILE 类型的结构的指针。

类型为 FILE 的流是对文件描述符和流缓冲区的抽象。流缓冲区的目的和 RIO 读缓冲区的一样:就是使开销较高的 Linux I/O 系统调用的数量尽可能得小。

#include <stdio.h>
extern FILE *stdin;    /* Standard input (descriptor 0) */
extern FILE *stdout;   /* Standard output (descriptor 1) */
extern FILE *stderr;   /* Standard error (descriptor 2) */

image

from csapp
  • G1:只要有可能就使用标准 I/O。

  • G2:不要使用 scanf 或 rio_readlineb 来读二进制文件。
    因为scanf和rio_readlineb中均有通过换行符或终止符来判断是否需要'截断'的操作,二进制文件可能散布着很多 Oxa 字节,而这些字节又与终止文本行无关。

  • G3:对网络套接字的 I/O 使用 RIO 函数,而不要使用标准I/O。
    具体理由和标准I/O内部实现有关,比如标准I/O需要使用 Unix I/O lseek 函数来重置当前的文件位置,但是socket没有文件位置这个概念。

网络编程

全球 IP 因特网是最著名和最成功的互联网络实现。
image

from csapp
从程序员的角度,我们可以把因特网看做一个世界范围的主机集合,满足以下特性:
  • 主机集合被映射为一组 32 位的 IP 地址。
  • 这组 IP 地址被映射为一组称为因特网域名(Internet domain name)的标识符。
  • 因特网主机上的进程能够通过连接(connection)和任何其他因特网主机上的进程通信。

套接字接口(socket interface)是一组函数,它们和 Unix I/O 函数结合起来,用以创建网络应用。大多数现代系统上都实现套接字接口,包括所有的 Unix 变种、Windows 和 Macintosh 系统。
image

from csapp

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

套接字接口

套接字接口和其余相关函数过于复杂,并不是我想要理解的重点,这里我只使用CSAPP上分装好的函数,并理解好几个概念也能使用好套接字接口。

open_clientfd 函数

int open_clientfd(char *hostname, char *port); // 返回:若成功则为描述符,若出错则为 -1。

客户端调用 open_clientfd 建立与服务器的连接。

open_clientfd 函数建立与服务器的连接,该服务器运行在主机 hostname 上,并在端口号 port 上监听连接请求。它返回一个打开的套接字描述符,该描述符准备好了,可以用 Unix I/O 函数做输入和输出。

open_listenfd 函数

int open_listenfd(char *port); // 返回:若成功则为描述符,若出错则为 -1。

调用 open_listenfd 函数,服务器创建一个监听描述符,准备好接收连接请求。
open_listenfd 函数打开和返回一个监听描述符,这个描述符准备好在端口 port 接收连接请求。

Accept函数(非CSAPP的封装函数)

#include <sys/socket.h>

int accept(int listenfd, struct sockaddr *addr, int *addrlen);// 返回:若成功则为非负连接描述符,若出错则为 -1。

服务器通过调用 accept 函数来等待来自客户端的连接请求。

accept 函数等待来自客户端的连接请求到达侦听描述符 listenfd,然后在 addr 中填写客户端的套接字地址,并返回一个已连接描述符(connected descriptor)

image

from csapp

你可能很想知道为什么套接字接口要区别监听描述符和已连接描述符。乍一看,这像是不必要的复杂化。然而,区分这两者被证明是很有用的,因为它使得我们可以建立并发服务器,它能够同时处理许多客户端连接。例如,每次一个连接请求到达监听描述符时,我们可以派生(fork)—个新的进程,它通过已连接描述符与客户端通信。

其中套接字地址为如下结构:

struct sockaddr_in {
    uint16_t       sin_family;   /* Protocol family (always AF_INET) */ 协议族
    uint16_t       sin_port;     /* Port number in network byte order */ 端口
    struct in_addr sin_addr;     /* IP address in network byte order */ 主机地址
    unsigned char  sin_zero[8];  /* Pad to sizeof(struct sockaddr) */ 填充
};

sin_port 成员是一个 16 位的端口号,而 sin_addr 成员就是一个 32 位的 IP 地址。IP 地址和端口号总是以网络字节顺序(大端法)存放的。

但是实际使用的并不是上述的套接字地址,而是更加通用的sockaddr 结构

/* Generic socket address structure (for connect, bind, and accept) */
struct sockaddr {
    uint16_t  sa_family;    /* Protocol family */
    char      sa_data[14];  /* Address data */
};
typedef struct sockaddr SA;

然后要求应用程序将与协议特定的结构的指针强制转换成这个通用结构。

具体使用案例

EOF 的概念常常使人们感到迷惑,尤其是在因特网连接的上下文中。首先,我们需要理解其实并没有像 EOF 字符这样的一个东西。进一步来说,EOF 是由内核检测到的一种条件。

应用程序在它接收到一个由 read 函数返回的零返回码时,它就会发现出 EOF 条件。

对于磁盘文件,当前文件位置超出文件长度时,会发生 EOF。

对于因特网连接,当一个进程关闭连接它的那一端时,会发生 EOF。连接另一端的进程在试图读取流中最后一个字节之后的字节时,会检测到 EOF。

Web 服务器

Web 服务器以两种不同的方式向客户端提供内容:

  • 取一个磁盘文件,并将它的内容返回给客户端。磁盘文件称为静态内容(static content),而返回文件给客户端的过程称为服务静态内容(serving static content)。
  • 运行一个可执行文件,并将它的输出返回给客户端。运行时可执行文件产生的输出称为动态内容(dynamic content),而运行程序并返回它的输出到客户端的过程称为服务动态内容(serving dynamic content)。

客户端如何将程序参数传递给服务器?

在Http POST请求主体中。

服务器如何将参数传递给子进程

环境变量,使用setenvgetenv函数

子进程将它的输出发送到哪里

在子进程加载并运行程序之前,它使用 Linux dup2 函数将标准输出重定向到和客户端相关联的已连接描述符。

Proxy Lab

Basic Knowledge

物理上而言,网络是一个按照地理远近组成的层次系统。最低层是 LAN(Local Area Network,局域网),在一个建筑或者校园范围内。迄今为止,最流行的局域网技术是以太网(Ethernet)。

我们在计算机网络课程上学习的数据链路层协议(CSMA/CD协议, MAC协议)均是为了实现局域网。

IP因特网(Internet):最著名和最成功的互联网络实现。

我们在计算机网络课程上学习的网络层(IP(Internet Protocol)协议),传输层(TCP/UDP协议)和应用层(HTTP/FTP/SMTP等协议)均是为了实现互联网络

IP 协议分为 IPv4 和 IPv6

传输层的TCP和UDP协议是传输层中主要的协议,其依靠于IP协议。

应用层的协议均基于传输层中的协议,如FTP协议依靠于TPC协议,HTTP协议依靠于TCP协议,DNS协议依靠于UDP协议。

套接字(Socket)计算机网络中用于进程间通信的一种机制,提供了在网络中发送和接收数据的接口。套接字是一种抽象层,它隐藏了具体的网络传输协议的细节,程序员只需通过套接字进行数据读写,而无需关心底层的传输过程。

套接字(Socket) 本身并不属于计算机网络层次中的某一特定层次,而是一个 应用编程接口(API),它位于 应用层传输层 之间的接口层。

套接字是通过 IP 地址端口号 绑定的,每个网络连接都需要由一个唯一的 (IP地址, 端口号) 对来标识。

套接字类型包括:

  1. 流套接字(SOCK_STREAM):用于TCP协议,提供可靠、面向连接的服务。
  2. 数据报套接字(SOCK_DGRAM):用于UDP协议,提供无连接、不可靠的服务。
  3. 原始套接字(SOCK_RAW):允许直接访问底层协议,如IP或ICMP。

套接字地址结构

IPV4的套接字地址结构:

struct sockaddr_in {
    sa_family_t    sin_family;   // 地址族,通常是 AF_INET(IPv4)
    in_port_t      sin_port;     // 16位端口号(网络字节序)
    struct in_addr sin_addr;     // 32位 IPv4 地址(网络字节序)
    char           sin_zero[8];  // 填充字段,通常设置为 0
};

IPV6的套接字地址结构:

struct sockaddr_in6 {
    sa_family_t     sin6_family;   // 地址族,通常是 AF_INET6(IPv6)
    in_port_t       sin6_port;     // 16位端口号(网络字节序)
    uint32_t        sin6_flowinfo; // 流标签和流量类别(通常未使用)
    struct in6_addr sin6_addr;     // 128位 IPv6 地址(网络字节序)
    uint32_t        sin6_scope_id; // 作用域 ID(用于链路本地地址)
};

套接字地址结构因协议的不同也会不同,从IPV4和IPV6的区别上就可看出。为编程需求(connect、bind 和 accept 函数要求一个指向与协议相关的套接字地址结构的指针。套接字接口的设计者面临的问题是,如何定义这些函数,使之能接受各种类型的套接字地址结构),我们需要一个更加通用的套接字地址结构, 类似:

/* Generic socket address structure (for connect, bind, and accept) */
struct sockaddr {
    uint16_t  sa_family;    /* Protocol family */
    char      sa_data[14];  /* Address data */
};
typedef struct sockaddr SA;

通用的 sockaddr 结构体确实无法直接容纳 IPv6 的套接字地址(sockaddr_in6),因为 sockaddrsa_data 字段只有 14 字节,而 IPv6 的地址结构(sockaddr_in6)需要更大的空间(通常是 28 字节)。

为了解决这个问题,操作系统引入了一个更大的通用结构体:sockaddr_storage。它可以容纳所有支持的地址族的套接字地址。

struct sockaddr_storage {
    sa_family_t ss_family; // 地址族(如 AF_INET 或 AF_INET6)
    char        __ss_pad1[_SS_PAD1SIZE]; // 填充字段
    int64_t     __ss_align; // 对齐字段
    char        __ss_pad2[_SS_PAD2SIZE]; // 填充字段
};

socketaddr_storage结构体一般用在Accept函数上,因为Accept函数并不确定会接受到什么样的套接字地址结构

struct sockaddr_storage clientaddr;
clientlen = sizeof(struct sockaddr_storage);
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);

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

从程序员的角度,我们可以把因特网看做一个世界范围的主机集合,满足以下特性:

  • 主机集合被映射为一组 32 位的 IP 地址
  • 这组 IP 地址被映射为一组称为因特网域名(Internet domain name)的标识符。
  • 因特网主机上的进程能够通过连接(connection)和任何其他因特网主机上的进程通信。

完成本次Proxy Lab Part I, 我认为需要理解如下几点:

  • 如何打开一个套接字文件?
  • 如何建立连接?
  • 如何向套接字文件中读写内容?
  • 如何发送HTTP请求,如何接收响应?

image
image
如上两张图已经给出答案,我们从API参数和返回值的角度来理解下。

getaddrinfo

int getaddrinfo(const char *host, const char *service,
                const struct addrinfo *hints,
                struct addrinfo **result);
// 返回:如果成功则为 0,如果错误则为非零的错误代码。

getaddrinfo的作用是我们给出主机名(host)和端口(service)以及一些配置参数(在hints中),getaddrinfo给出一系列套接字信息,包括:套接字协议族ai_family,套接字类型ai_socktype,套接字协议ai_protocol,套接字地址struct sockaddr *ai_addr等(在result中),这些都是socket,connect,listen函数参数

对于客户端host和service表示要请求的服务器的主机地址和端口
对于服务器host为NULL,表示接收来自任何主机的请求;service为要监听的端口。
客户端和服务器端的service是一样的


socket,connect, bind, listen

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

// 返回:若成功则为非负描述符,若出错则为 -1。

socket函数的作用是根据参数返回一个套接字文件描述符,这个套接字文件描述符需要传入connect/(bind, listen)函数才能起作用

int connect(int clientfd, const struct sockaddr *addr,
            socklen_t addrlen);

// 返回:若成功则为 0,若出错则为 -1。

对于客户端使用connect函数,其作用是将套接字文件描述符clientfd试图与套接字地址为 addr 的服务器建立一个因特网连接,这样套接字文件描述符clientfd就可以像普通文件描述符一样用于读写。

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

// 返回:若成功则为 0,若出错则为 -1。

int listen(int listenfd, int backlog);

// 返回:若成功则为 0,若出错则为 -1。

int accept(int listenfd, struct sockaddr *addr, int *addrlen);

// 返回:若成功则为非负连接描述符,若出错则为 -1。

对于服务器端使用bind和listen函数。

bind其作用是将套接字文件描述符listenfd和服务器套接字地址addr联系起来。

listen其作用是将 将套接字文件描述符listenfd从一个主动套接字转化为一个监听套接字(listening socket),该套接字可以接受来自客户端的连接请求。

对于服务器来说真正与客户端进行信息传递的套接字文件描述符不是一开始用socket得到的listenfd,而是通过accept函数返回得到的connfd套接字文件描述符进行与客户端信息传递。


accept

int accept(int listenfd, struct sockaddr *addr, int *addrlen);

accept函数会将请求连接并成功连接的主机套接字地址和套接字地址大小写入addr变量和addrlen函数,并返回connfd套接字文件描述符


HTTP请求和响应
如果我作为一个客户端,我想要发送一个HTTP请求,其实也就是要向服务器端写一些内容(协议)过去,让服务器端理解我的意思。协议的格式为:

# 请求行,通用格式:
method URI version
# 请求头, 通用格式:
header-name: header-data
...

# 具体案例:
GET http://www.baidu.com/home.html HTTP/1.1
# 或
GET /home.html HTTP/1.1
Host: www.baidu.com
# 或
GET http://www.baidu.com:80/home.html HTTP/1.1 // HTTP默认端口就是80

注意每一行均以\r\n进行结尾,请求头的结束以一行\r\n作为标志


作为服务器端需要响应,也需要向客户端返回一些内容,协议格式为:

# 响应行
version status-code status-message
# 响应头
Content-type: xxx
....

# 响应体
<html>
...
</html>
or other


# 具体案例:
HTTP/1.0 200 OK                         # Server: response line
MIME-Version: 1.0                       # Server: followed by five response headers
Date: Mon, 8 Jan 2010 4:59:42 GMT
Server: Apache-Coyote/1.1
Content-Type: text/html                 # Server: expect HTML in the response body
Content-Length: 42092                   # Server: expect 42,092 bytes in the response body
                                        # Server: empty line terminates response headers
<html>                                  # Server: first HTML line in response body
...                                     # Server: 766 lines of HTML not shown
</html>                                 # Server: last HTML line in response body

注意每一行均以\r\n进行结尾,响应头的结束以一行\r\n作为标志

API

unix_error

void unix_error(char *msg) /* Unix-style error */
{
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(0);
}

strerror(errno)errno 是一个全局变量,当一个系统调用或库函数出错时,它会被设置为一个表示错误类型的值。strerror(errno) 会将 errno 的值转换为一个人类可读的错误信息字符串。这个字符串对应的是最近一次错误的描述。例如,errno 的值可能是 ENOENT(表示文件不存在),strerror(errno) 会返回类似 "No such file or directory" 的字符串。

exit(0):传递 0exit() 表示程序正常退出。在错误处理函数中,通常使用非零值来表示异常退出状态。使用 exit(0) 可能表示程序在错误发生时进行了自定义处理,依然选择以“正常”的方式退出。

Accept

#include <sys/socket.h>

int accept(int listenfd, struct sockaddr *addr, int *addrlen);

// 返回:若成功则为非负连接描述符,若出错则为 -1。

accept 函数等待来自客户端的连接请求到达侦听描述符 listenfd,然后在 addr 中填写客户端的套接字地址,并返回一个已连接描述符(connected descriptor),这个描述符可被用来利用 Unix I/O 函数与客户端通信。

需要注意的是这个int *addrlen这个参数通常我们选择初始化为sizeof(addr)

Pthread_create

#include <pthread.h>
typedef void *(func)(void *);

int pthread_create(pthread_t *tid, pthread_attr_t *attr,
                   func *f, void *arg);

// 若成功则返回 0,若出错则为非零。

typedef void *(func)(void *);将一个接受一个 void * 类型的参数,并且返回一个 void * 类型的函数指针类型别名为func

func *f中f是一个函数指针

函数指针是指向函数的指针变量。和普通指针指向变量或数组的元素不同,函数指针是用来指向函数的地址。它可以存储一个函数的地址,之后可以通过这个指针来调用该函数。

#include <stdio.h>

int add(int a, int b) {
    return a + b;
}

int main() {
    // 声明一个函数指针并指向 add 函数
    int (*func_ptr)(int, int) = add;

    // 使用函数指针调用 add 函数
    int result = func_ptr(3, 4);
    printf("Result: %d\n", result);

    return 0;
}

在这段代码中,func_ptr 是一个函数指针,指向 add 函数。

int (*func_ptrs[])(int, int) = {add, add, add}; 声明一个函数指针数组

实验要求

sequential web proxy

HTTP/1.0 GET requests

  • the proxy’s request line ends with HTTP/1.0. Modern web browsers will generate HTTP/1.1 requests, but your proxy should handle them and forward them as HTTP/1.0 requests.

  • RFC 1945 for the complete HTTP/1.0 specification

  • while the specification allows for multiline request fields, your proxy is not required to properly handle them. Of course, your proxy should never prematurely abort due to a malformed request.


Request headers

  • Always send a Host header

    • The Host header describes the hostname of the end server.
    • It is possible that web browsers will attach their own Host headers to their HTTP requests, your proxy should use the same Host header as the browser
  • You may choose to always send the following User-Agent header:

    • User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:10.0.3) Gecko/20120305 Firefox/10.0.3
  • Always send the following Connection header

    • Connection: close
  • Always send the following Proxy-Connection header:

    • Proxy-Connection: close
    • The Connection and Proxy-Connection headers are used to specify whether a connection will be kept alive after the first request/response exchange is completed.
  • Finally, if a browser sends any additional request headers as part of an HTTP request, your proxy should forward them unchanged.


Port numbers

There are two significant classes of port numbers for this lab:

  • HTTP request ports

    • http://www.cmu.edu:8080/hub/index.html, in which case your proxy should connect to the host www.cmu.edu on port 8080 instead of the default HTTP port, which is port 80
    • Your proxy must properly function whether or not the port number is included in the URL
  • your proxy’s listening port

    • The listening port is the port on which your proxy should listen for incoming connections. Your proxy should accept a command line argument specifying the listening port number for your proxy.
    • ./proxy 15213, your proxy should listen for connections on port 15213
    • ? 我可以设定proxy监听的端口,但是Web browser 和end Server(tiny)如何知道proxy监听的端口?
      • Web brower在测试程序driver.sh中是通过curl命令实现的,可以直接写上proxy的端口号
      • proxy会得到要连接的Web服务器信息,proxy是直接连接Web服务器的,Web服务器并不需要知道proxy的监听端口信息

multiple concurrent requests

  • Note that your threads should run in detached mode to avoid memory leaks.
  • The open clientfd and open listenfd functions described in the CS:APP3e textbook are based on the modern and protocol-independent getaddrinfo function, and thus are thread safe.

Caching web objects

  • When your proxy receives a web object from a server, it should cache it in memory as it transmits the object to the client. If another client requests the same object from the same server, your proxy need not reconnect to the server; it can simply resend the cached object.

  • your proxy should have both a maximum cache size and a maximum cache object size

    • The entirety of your proxy’s cache MAX_CACHE_SIZE = 1 MiB

    • your proxy must only count bytes used to store the actual web objects; any extraneous bytes, including metadata, should be ignored. ?

      什么是metadata?

    • Your proxy should only cache web objects that do not exceed the following maximum size MAX_OBJECT_SIZE = 100 KiB

    • allocate a buffer for each active connection and accumulate data as it is received from the server.

      Using this scheme, the maximum amount of data your proxy will ever use for web objects is the following, where T is the maximum number of active connections: MAX_CACHE_SIZE + T * MAX_OBJECT_SIZE

  • Your proxy’s cache should employ an eviction policy that approximates a least-recently-used (LRU) eviction policy. It doesn’t have to be strictly LRU, but it should be something reasonably close. Note that both reading an object and writing it count as using the object. (cache 替换策略)

  • Synchronization

    • As a matter of fact, there is a special requirement that multiple threads must be able to simultaneously read from the cache. Of course, only one thread should be permitted to write to the cache at a time, but that restriction must not exist for readers. (读写者锁)
    • You may want to explore options such as partitioning the cache, using Pthreads readers-writers locks, or using semaphores to implement your own readers-writers solution.
    • In either case, the fact that you don’t have to implement a strictly LRU eviction policy will give you some flexibility in supporting multiple readers.

Autograding

./driver.sh: Your handout materials include an autograder, called driver.sh,

you must deliver a program that is robust to errors and even malformed or malicious input

Robustness implies other requirements as well, including invulnerability to error cases like segmentation faults and a lack of memory leaks and file descriptor leaks.

Be sure to exercise all code paths and test a representative set of inputs, including base cases, typical cases, and edge cases

测试

文件中drive.sh是用于自动测试的文件,其中:

TINY启动:tiny_port=$(free_port); ./tiny ${tiny_port} &> /dev/null &

Proxy启动:proxy_port=$(free_port); ./proxy ${proxy_port} &> /dev/null &

核心测试方法是通过curl以proxy为代理发送HTTP请求给TINY:curl --max-time ${TIMEOUT} --silent --proxy http://localhost:${proxy_port} --output ${file} http://localhost:${free_port}/{file}

我们也可以用文档上介绍的其他命令来测试,比如telnet,该命令可于指定主机端口建立HTTP连接,并向这个主机发送HTTP事务

# proxy监听4501端口,tiny监听4500端口
cilinmengye@cilinmengye:~/GitHub_repo/CSAPP/Proxy_Lab/proxylab-handout$ telnet localhost 4501
Trying 127.0.0.1...                                               
Connected to localhost.                                           
Escape character is '^]'.                                         
GET http://localhost:4500/home.html HTTP/1.1 # 我手动输入,向proxy发送HTTP请求
Host: localhost                  
                                 
HTTP/1.0 200 OK # proxy转发的响应                 
Server: Tiny Web Server          
Content-length: 120              
Content-type: text/html                                           
                                                                  
<html>                                                            
<head><title>test</title></head>                                  
<body>                                                            
<img align="middle" src="godzilla.gif">                                                                                              
Dave O'Hallaron                                                                                                                      
</body>                                                                                                                              
</html>                                                                                                                              
Connection closed by foreign host.

Code

cilinmengye/CSAPP/Proxy_Lab/proxylab-handout

Reference

CSAPP电子书

posted @ 2024-11-20 16:20  次林梦叶  阅读(61)  评论(0)    收藏  举报