Part1:
题目要求实现一个客户端到服务器之间的中转,接收客户端的请求后,再向服务器发出请求即可。
思考向服务器发出请求需要哪些信息:
- method(即GET、CONNECT等)
- uri(即ip、port、path等)
- version(HTTP的版本,1.0/1.1)
回忆客户端向服务器发出请求的过程为请求头,第一行为<method><uri><version>,我们需要读取这一部分,这样我们才能向服务器发出请求。
因为执行的方式为 ./proxy port 的形式,因此通过:Open_listenfd(csapp封装好的函数,内核为之前讲的getaddrinfo等)使得proxy监听该端口,并读取客户端输入的数据,然后对服务器的目标产生请求:
int listenfd = Open_listenfd(input_port); //监听端口
connfd = Accept(listenfd, NULL, NULL) // 获得connfd
csapp提供的健壮的RIO的IO函数,因此能让我们更好的继续,通过Rio_read来读取输入,让我们简单的看下Rio_readlineb的代码:
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen)
{
int n, rc;
char c, *bufp = usrbuf;
for (n = 1; n < maxlen; n++)
{
if ((rc = rio_read(rp, &c, 1)) == 1)
{
*bufp++ = c;
if (c == '\n')
{
n++;
break;
}
}
else if (rc == 0)
{
if (n == 1)
return 0; /* EOF, no data read */
else
break; /* EOF, some data was read */
}
else
return -1; /* Error */
}
*bufp = 0;
return n - 1;
}
当读取到\n时就是一行的结束了。
接下来让我们通过Rio_readlineb来获得用户的输入,我们需要先Rio_readinitb来初始化Rio的缓冲区,再通过Rio_readlineb来获取信息:
rio_t p;
Rio_readinitb(&p, connfd);
Rio_readlineb(&p, buf, MAXBUF);
sscanf(buf, "%s %s %s", method, uri, version);
while (Rio_readlineb(&p, buf, MAXBUF) > 0)
{
if (strcmp(buf, "\r\n") == 0)
{
break;
}
}
其实有用的就Rio_readlineb(&p, buf, MAXBUF)这一行,下面的Rio_readlineb主要是为了将数据读完,Rio_readlineb在读取到”\n”防止数据依旧在那里,读取完防止出问题,strcmp(buf, “\r\n”)是因为请求头的最后是以”\r\n\r\n”的形式截止的,因此在读取到前一个\r\n时是一行的结束,最后的\r\n就只有\r\n,因此能通过strcmp来进行判断是否跳出。
我们得到了用户输入的<method><uri><version>,让我们通过sscanf来获取对应的部分,在这里我们默认为ip+端口的形式,因此就特定的弄下即可:
sscanf(buf, "%s %s %s", method, uri, version); // 在上面的就已经出现了,单独说明一下而已
接下来就是对uri再通过sscanf进行拆分:
char protocol[10];
char hostname[100];
char port[10];
char path[200];
sscanf(uri, "%[^:]://%[^:]:%[^/]%s", protocol, hostname, port, path);
%[^:],读取到:为止(不包括:),以此类推。
这样我们就获得了hostname、port、path,必要的信息都有了,就可以向服务器请求了,通过Open_clientfd去connect服务器,然后将请求汇总写入该clientfd即可:
int clientfd = Open_clientfd(hostname, port);
char buf1[MAXLINE];
sprintf(buf1, "%s %s %s\r\n", method, path, "HTTP/1.0");
char temp[MAXBUF];
sprintf(temp, "Host: %s:%s\r\n", hostname, port);
strcat(temp, user_agent_hdr); //已经提供,固定的
strcat(temp, "Connection: close\r\n"); // 题目要求,固定的
strcat(temp, "Proxy-Connection: close\r\n\r\n"); // 题目要求,固定的
strcat(buf1, temp);
Rio_writen(clientfd, buf1, strlen(buf1));
通过sprintf、strcat来拼接,其他三行都是提供的固定的要求,因此我们要做的其实就第一行而已,也就是我之前说只有那一行有用的原因。
接下来就是从服务器获得信息:
rio_t p;
char buf[MAXBUF];
Rio_readinitb(&p, clientfd);
int rc;
while ((rc = Rio_readnb(&p, buf, MAXBUF)) > 0)
{
Rio_writen(connfd, buf, rc); // 将服务器获取的信息写到客户端
}
依旧是read和write的配合即可,这里用readnb(如果不足MAXBUF就会堵塞的等待,直到足够MAXBUF或连接关闭,因为请求头中是Connection: close,即服务器传输完后会自动关闭,因此并没有出现问题)是为了防止传输图片之类的出现问题,因为readlineb是根据\n来进行停止并跳过\n,因此图片的ASCII码就会被错误的识别而出现问题。
好了,一个简单的part1就完成了。
Part2:
在part2中,需要实现并行,既然前面讲了那么多的线程,那就用线程来做,当接到客户端发来的连接时,分裂出一个线程去负责该连接,主线程等待下一个连接,因此产生线程后不能使用pthread_join来回收线程,使用会堵塞的等待线程结束然后回收,那应该使用什么既能使主线程能继续接收下一个连接,子线程还能自动回收?pthread_detach(),让子线程独立,等结束就自动回收。
while ((connfd = Accept(listenfd, NULL, NULL)))
{
pthread_t tid;
pthread_create(&tid, NULL, get_start, (void *)(long)connfd);
}
void *get_start(void *arg)
{
pthread_detach(pthread_self());
...
}
我没有通过malloc然后free来传递connfd,而是通过类型转换来进行获取,看个人喜好吧。
接下来思考这样操作会不会导致线程竞争,每一个connfd是独立的,因此线程往里写时不会出现问题,clientfd也是每个客户端有个独立的服务器进程或线程等负责,线程读取时也是独立的,因此在该阶段不涉及锁,可以简单的加上线程来并行即可。
Part3:
在该阶段,我们要实现cache缓存,即当客户端请求的内容若已经缓存了,那就不必再连接服务器获取然后再返回,可以从缓存中直接获取然后返回。
题目限制了缓存的总大小(MAX_CACHE_SIZE 1049000)和单个缓存最大的大小(MAX_OBJECT_SIZE 102400),因此我们需要一个全局变量来存储当前缓存的总大小。
题目还要求若新加缓存后会大于总缓存大小,需要依据LRU(最不常用的先淘汰)来进行,在cs50中也出现过,在这里采用双向链表的形式,方便加入缓存,总缓存过大需要删除时,可以更加的方便。
主要这里的删除是删除到总缓存大小小于限制的大小。
思考需不需要加锁:假设当一个线程正在读取cache表时,另一个线程正在删除cache,还有一个在加cache,很明显会出问题,因此需要加锁,是读取一个锁、删除cache一个锁、加cache一个锁这样弄三个锁还是全局共用一个锁?全局共用一个锁,也只能这样。
首先定义缓存的链表:
typedef struct ListNode
{
char path[200];
char *buf;
size_t sz;
struct ListNode *next;
struct ListNode *pre;
} ListNode;
为了后续初始化链表的方便起见,让我们建立个函数来初始化链表,这个函数应当将path写入、buf填入数据、sz设置,next和pre设置为NULL,即建立个储存好数据的单个链表节点:
void node_init(ListNode *new, char *path, char *buf, size_t sz)
{
if (new == NULL)
{
return;
}
memset(new->path, 0, 200);
strcpy(new->path, path);
new->buf = malloc(sz);
memcpy(new->buf, buf, sz);
new->sz = sz;
new->next = NULL;
new->pre = NULL;
}
buf用memcpy是为了防止数据为图等,里面的ASCII码使strcpy错误判断。
path来判断该缓存储存的是否是需要的,buf即存储的数据,sz为该数据的大小,next、pre为下一个、上一个的链表。
接下来定义一个全局锁、全局变量sum、链表头:
sem_t mutex;
size_t sum;
ListNode *dummy;
在main中初始化锁、虚拟链表头dummy:
Sem_init(&mutex, 0, 1);
dummy = malloc(sizeof(ListNode));
node_init(dummy, "\0", "\0", 0);
这里我们采用一个虚拟头节点,这样每次新加cache、移动cache加到dummy后即可。
接下来看需要在哪里使用cache:
- 当客户端发出请求时,知晓path时,就可以进行查找,若找到即返回该cache,并将其放到dummy的下一个。
- 当cache中没有,得到服务器返回的信息后,若该信息可以缓存就开始缓存,放到dummy,若是sum>MAX_CACHE_SIZE,就要依据LRU删除然后再将节点放到dummy后。
char *ans;
size_t ans_len;
if (get_cache(path, &ans, &ans_len))
{
Rio_writen(connfd, ans, ans_len);
Free(ans);
return NULL;
}
bool get_cache(char *path, char **ans, size_t *ans_len)
{
P(&mutex);
ListNode *t = dummy->next;
while (t != NULL)
{
if (strcmp(t->path, path) == 0)
{
*ans = malloc(t->sz);
*ans_len = t->sz;
memcpy(*ans, t->buf, t->sz);
if (t != dummy->next) // 当t就在dummy后时,就直接返回就可以
{
t->pre->next = t->next;
if (t->next != NULL)
{
t->next->pre = t->pre;
}
t->next = dummy->next;
if (dummy->next != NULL)
{
dummy->next->pre = t;
}
dummy->next = t;
t->pre = dummy;
}
V(&mutex);
return true;
}
t = t->next;
}
V(&mutex);
return false;
}
注意这里我往里面传的时ans的指针,也就是char **ans,这样malloc时才能正确malloc,不然传入char *然后malloc,并不会得到我们想要的结果,因此需要二重指针。
这时候就要加锁了,这样能防止其他线程的干扰。
若是cache表中并没有我们想要的数据,我们就往后,去获取服务器的返回:
void get_serveinput(int clientfd, int connfd, char *path)
{
rio_t p;
char buf[MAXBUF];
Rio_readinitb(&p, clientfd);
int rc;
size_t cur_sz = 0;
char *new_buf = malloc(MAX_OBJECT_SIZE);
while ((rc = Rio_readnb(&p, buf, MAXBUF)) > 0)
{
Rio_writen(connfd, buf, rc);
cur_sz += rc;
if (cur_sz <= MAX_OBJECT_SIZE)
{
memcpy(new_buf + cur_sz - rc, buf, rc);
}
}
if (cur_sz <= MAX_OBJECT_SIZE)
{
P(&mutex);
insert_info(path, new_buf, cur_sz);
V(&mutex);
}
}
用cur_sz记录new_buf中储存了多少数据,然后while中时时更新,因为Rio_readnb每次的读取都是在覆盖缓冲区,因此要时时copy。
然后通过insert_info来插入cache:
void insert_info(char *path, char *buf, size_t sz)
{
ListNode *t = malloc(sizeof(ListNode));
node_init(t, path, buf, sz);
Free(buf);
sum += sz;
ListNode *k = dummy;
while (k->next != NULL)
{
k = k->next;
}
while (sum > MAX_CACHE_SIZE)
{
sum -= k->sz;
ListNode *tem = k;
k = k->pre;
k->next = NULL;
Free(tem->buf);
Free(tem);
}
t->next = dummy->next;
if (dummy->next != NULL)
{
dummy->next->pre = t;
}
dummy->next = t;
t->pre = dummy;
}
当sum > MAX_CACHE_SIZE时,就要从后往前删除,注意free内存,避免内存泄漏,将cache节点删除到一定大小后,再将节点放到dummy后即可。
Part4:
让我们实现https,也就是加密,要求很简单,当服务器写的时候,将写的传给connfd,当客户端写的时候,传给服务器。
我们先判断method是否是CONNECT,若是CONNECT,重新获取hostname、port,因为https的hostname可能不是ip,可能是个网址,然后采用https来进行传输:
if (strcmp(method, "CONNECT") == 0)
{
sscanf(uri, "%[^:]:%s", hostname, port);
connect_head(connfd, hostname, port);
}
题目要求当与服务器建立连接后,像客户端发送“HTTP/1.1 200 Connection Established\r\n\r\n”,因此:
char *res = "HTTP/1.1 200 Connection Established\r\n\r\n";
int clientfd = open_clientfd(hostname, port);
if (clientfd < 0)
{
printf("Connect to %s:%s failed\n", hostname, port);
return;
}
Rio_writen(connfd, res, strlen(res));
线程只能接收一个参数,因此我们需要构建个struct,该struct里面储存着connfd和clientfd,然后传给线程:
typedef struct
{
int connfd;
int clientfd;
} my_fds;
接下就很简单,创建两个线程,两个线程,一个读取客户端然后输入服务器,一个读取服务器输入客户端:
void connect_head(int connfd, char *hostname, char *port)
{
...
my_fds *fds1 = malloc(sizeof(my_fds));
fds1->connfd = connfd;
fds1->clientfd = clientfd;
my_fds *fds2 = malloc(sizeof(my_fds));
fds2->connfd = connfd;
fds2->clientfd = clientfd;
pthread_t tid1, tid2;
pthread_create(&tid1, NULL, client_help, (void *)fds1);
pthread_create(&tid2, NULL, server_help, (void *)fds2);
}
void *client_help(void *arg)
{
pthread_detach(pthread_self());
my_fds *fds = (my_fds *)(arg);
int connfd = fds->connfd;
int clientfd = fds->clientfd;
Free(fds);
int rc;
char buf[MAXBUF];
while ((rc = read(connfd, buf, MAXBUF)) > 0)
{
Rio_writen(clientfd, buf, rc);
}
close(connfd);
return NULL;
}
void *server_help(void *arg)
{
pthread_detach(pthread_self());
my_fds *fds = (my_fds *)(arg);
int connfd = fds->connfd;
int clientfd = fds->clientfd;
Free(fds);
int rc;
char buf[MAXBUF];
while ((rc = read(clientfd, buf, MAXBUF)) > 0)
{
Rio_writen(connfd, buf, rc);
}
close(clientfd);
return NULL;
}
我这里使用read,是因为Rio_read那些是堵塞的读满MAXBUF,而read是最多读MAXBUF,能读多少读多少,这就避免了死锁问题。
这样part4就完成了。
在这里我们可以看到在https下,中转只负责传输数据,并不负责数据处理,这样就保证了数据的安全性,你可能会想我在中间加个new_buf将数据存起来不就好了,在https下,数据时加密的,这样做得到的数据是乱的、加密的。


