1. 理解HTTP代理服务器的核心概念
HTTP代理服务器就像网络世界的中转站,它位于客户端(比如你的浏览器)和目标服务器之间。当你想访问某个网站时,请求不会直接发送到目标网站,而是先经过代理服务器,由代理服务器帮你获取内容后再返回给你。
这种架构带来几个明显的好处:
- 隐私保护:目标网站看到的是代理服务器的IP,而不是你的真实IP
- 访问控制:企业或学校可以用它限制某些网站的访问
- 性能优化:通过缓存机制减少重复请求的响应时间
- 内容过滤:过滤掉不安全或不合规的内容
在Proxy Lab中,我们要实现的代理需要处理三种核心功能:
- 基础请求转发(Part 1)
- 多线程并发处理(Part 2)
- LRU缓存机制(Part 3)
2. 搭建基础代理服务器框架
我们先从最简单的顺序代理开始。这个阶段的代理只需要能接收HTTP请求并正确转发即可,不需要考虑性能优化。
关键代码结构如下:
#include "csapp.h" #define MAX_CACHE_SIZE 1049000 #define MAX_OBJECT_SIZE 102400 void doit(int clientfd) { char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE]; char hostname[MAXLINE], path[MAXLINE], port[MAXLINE]; int serverfd; rio_t rio; // 读取请求行 Rio_readinitb(&rio, clientfd); Rio_readlineb(&rio, buf, MAXLINE); sscanf(buf, "%s %s %s", method, uri, version); // 只处理GET请求 if (strcasecmp(method, "GET")) { printf("501 Not Implemented: %s method\n", method); return; } // 解析URI获取目标服务器信息 parse_uri(uri, hostname, path, port); // 连接目标服务器 serverfd = Open_clientfd(hostname, port); // 转发请求并返回响应 forward_request(serverfd, path, hostname); return_response(serverfd, clientfd); Close(serverfd); }URI解析是这里的重点,我们需要从类似http://www.example.com:8080/path/to/resource的URI中提取:
- 主机名(www.example.com)
- 端口(8080,默认80)
- 路径(/path/to/resource)
解析函数可以这样实现:
void parse_uri(char *uri, char *hostname, char *path, char *port) { char *ptr = strstr(uri, "//"); ptr = ptr ? ptr + 2 : uri; // 跳过http:// char *port_ptr = strchr(ptr, ':'); char *path_ptr = strchr(ptr, '/'); if (port_ptr && (!path_ptr || port_ptr < path_ptr)) { // 处理显式端口号情况 *port_ptr = '\0'; strcpy(hostname, ptr); *port_ptr = ':'; sscanf(port_ptr + 1, "%[^/]", port); } else { // 默认80端口 strcpy(port, "80"); if (path_ptr) { *path_ptr = '\0'; strcpy(hostname, ptr); *path_ptr = '/'; } else { strcpy(hostname, ptr); strcpy(path, "/"); } } if (path_ptr) strcpy(path, path_ptr); else strcpy(path, "/"); }3. 实现多线程并发处理
单线程代理性能太低,我们需要引入多线程。这里采用经典的"每个连接一个线程"模型,但要注意线程安全和资源管理。
关键修改点在main函数:
int main(int argc, char **argv) { int listenfd, *connfd; char hostname[MAXLINE], port[MAXLINE]; socklen_t clientlen; struct sockaddr_storage clientaddr; pthread_t tid; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(1); } listenfd = Open_listenfd(argv[1]); while (1) { clientlen = sizeof(clientaddr); connfd = Malloc(sizeof(int)); // 避免竞争 *connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); Getnameinfo((SA *)&clientaddr, clientlen, hostname, MAXLINE, port, MAXLINE, 0); printf("Accepted connection from (%s, %s)\n", hostname, port); Pthread_create(&tid, NULL, thread, connfd); } } void *thread(void *vargp) { int connfd = *((int *)vargp); Pthread_detach(pthread_self()); Free(vargp); doit(connfd); Close(connfd); return NULL; }这里有几个关键点需要注意:
- 使用
Malloc动态分配connfd,避免线程间共享变量 - 线程立即detach自己,这样不需要主线程join
- 记得释放动态分配的内存和关闭文件描述符
4. 实现LRU缓存机制
缓存是提升代理性能的关键。我们采用LRU(最近最少使用)策略,当缓存满时淘汰最久未使用的对象。
缓存数据结构设计:
typedef struct { char url[MAXLINE]; // 缓存键 char content[MAX_OBJECT_SIZE]; // 缓存内容 size_t size; // 内容大小 time_t last_used; // 最后使用时间 sem_t mutex; // 保护该缓存项的锁 } CacheItem; typedef struct { CacheItem items[MAX_CACHE_ITEM]; // 缓存项数组 int count; // 当前缓存项数 sem_t mutex; // 保护整个缓存的锁 } Cache;缓存查找和更新逻辑:
int find_cache(Cache *cache, const char *url, char *content, size_t *size) { P(&cache->mutex); int found = -1; for (int i = 0; i < cache->count; i++) { if (strcmp(cache->items[i].url, url) == 0) { P(&cache->items[i].mutex); memcpy(content, cache->items[i].content, cache->items[i].size); *size = cache->items[i].size; cache->items[i].last_used = time(NULL); V(&cache->items[i].mutex); found = i; break; } } V(&cache->mutex); return found; } void add_cache(Cache *cache, const char *url, const char *content, size_t size) { if (size > MAX_OBJECT_SIZE) return; P(&cache->mutex); // 查找空位或LRU项 int slot = -1, lru = 0; time_t oldest = time(NULL); for (int i = 0; i < MAX_CACHE_ITEM; i++) { if (i < cache->count) { if (cache->items[i].last_used < oldest) { oldest = cache->items[i].last_used; lru = i; } } else if (slot == -1) { slot = i; } } if (slot == -1) { // 缓存已满 slot = lru; cache->total_size -= cache->items[slot].size; } // 更新缓存 P(&cache->items[slot].mutex); strcpy(cache->items[slot].url, url); memcpy(cache->items[slot].content, content, size); cache->items[slot].size = size; cache->items[slot].last_used = time(NULL); cache->total_size += size; V(&cache->items[slot].mutex); if (slot >= cache->count) cache->count++; V(&cache->mutex); }5. 线程安全的读写锁实现
为了优化缓存性能,我们需要实现读写锁,允许多个线程同时读缓存,但写缓存时需要独占访问。
读写锁数据结构:
typedef struct { sem_t lock; // 保护readcnt的锁 sem_t writelock; // 写锁 int readcnt; // 当前读者数量 } rwlock_t;对应的操作:
void rwlock_init(rwlock_t *rw) { Sem_init(&rw->lock, 0, 1); Sem_init(&rw->writelock, 0, 1); rw->readcnt = 0; } void rwlock_acquire_read(rwlock_t *rw) { P(&rw->lock); rw->readcnt++; if (rw->readcnt == 1) // 第一个读者获取写锁 P(&rw->writelock); V(&rw->lock); } void rwlock_release_read(rwlock_t *rw) { P(&rw->lock); rw->readcnt--; if (rw->readcnt == 0) // 最后一个读者释放写锁 V(&rw->writelock); V(&rw->lock); } void rwlock_acquire_write(rwlock_t *rw) { P(&rw->writelock); } void rwlock_release_write(rwlock_t *rw) { V(&rw->writelock); }6. 完整代理工作流程
整合所有组件后,代理的工作流程如下:
- 监听指定端口,等待客户端连接
- 为每个连接创建新线程
- 线程中读取客户端请求:
- 检查是否为GET请求
- 解析URI获取目标服务器信息
- 检查缓存:
- 命中则直接返回缓存内容
- 未命中则继续
- 连接目标服务器并转发请求
- 接收目标服务器响应:
- 如果响应可缓存(如成功响应且大小合适),存入缓存
- 将响应返回给客户端
- 关闭连接
关键函数doit的完整实现:
void doit(int clientfd) { char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE]; char hostname[MAXLINE], path[MAXLINE], port[MAXLINE]; char cache_key[MAXLINE]; int serverfd; rio_t rio_client, rio_server; // 读取请求行 Rio_readinitb(&rio_client, clientfd); if (!Rio_readlineb(&rio_client, buf, MAXLINE)) return; sscanf(buf, "%s %s %s", method, uri, version); // 只处理GET请求 if (strcasecmp(method, "GET")) { clienterror(clientfd, method, "501", "Not Implemented"); return; } // 解析URI parse_uri(uri, hostname, path, port); // 生成缓存键 snprintf(cache_key, MAXLINE, "%s:%s%s", hostname, port, path); // 检查缓存 char cache_content[MAX_OBJECT_SIZE]; size_t cache_size; if (find_cache(&global_cache, cache_key, cache_content, &cache_size) >= 0) { Rio_writen(clientfd, cache_content, cache_size); return; } // 连接目标服务器 if ((serverfd = Open_clientfd(hostname, port)) < 0) { clienterror(clientfd, hostname, "404", "Not Found"); return; } // 转发请求 char request[MAXLINE]; snprintf(request, MAXLINE, "GET %s HTTP/1.0\r\n", path); Rio_writen(serverfd, request, strlen(request)); snprintf(request, MAXLINE, "Host: %s\r\n", hostname); Rio_writen(serverfd, request, strlen(request)); snprintf(request, MAXLINE, "%s", user_agent_hdr); Rio_writen(serverfd, request, strlen(request)); snprintf(request, MAXLINE, "Connection: close\r\n"); Rio_writen(serverfd, request, strlen(request)); snprintf(request, MAXLINE, "Proxy-Connection: close\r\n\r\n"); Rio_writen(serverfd, request, strlen(request)); // 读取响应并可能缓存 char response[MAX_OBJECT_SIZE]; size_t total = 0; Rio_readinitb(&rio_server, serverfd); while ((n = Rio_readlineb(&rio_server, buf, MAXLINE)) != 0) { if (total + n <= MAX_OBJECT_SIZE) { memcpy(response + total, buf, n); total += n; } Rio_writen(clientfd, buf, n); } // 如果响应可缓存则存入 if (total <= MAX_OBJECT_SIZE) { add_cache(&global_cache, cache_key, response, total); } Close(serverfd); }7. 常见问题与调试技巧
在实现过程中,我遇到了几个典型问题:
请求转发不完整:忘记转发某些必要的请求头,如Host头。解决方法是在转发请求时确保包含所有必要头信息。
缓存污染:多个线程同时修改缓存导致数据不一致。通过引入读写锁和细粒度的缓存项锁来解决。
内存泄漏:忘记释放动态分配的内存和关闭文件描述符。解决方法是严格检查每个malloc/free和open/close的配对。
性能瓶颈:最初的实现在高并发下性能不佳。通过以下优化提升性能:
- 使用更高效的哈希算法查找缓存
- 实现零拷贝的数据转发
- 优化锁的粒度
调试时可以使用的技巧:
- 使用
netcat手动发送HTTP请求测试代理 - 用
curl -v --proxy http://localhost:PORT URL测试 - 添加详细的日志输出请求/响应流程
- 使用Valgrind检查内存错误
8. 进阶优化方向
完成基础功能后,还可以考虑以下优化:
支持HTTPS:现代代理需要处理加密流量,可以通过CONNECT方法实现。
智能缓存策略:根据响应头中的Cache-Control决定是否缓存,而不仅基于大小。
连接池:复用后端连接减少TCP握手开销。
负载均衡:在多个后端服务器间分配请求。
压缩传输:对特定内容类型进行压缩减少带宽占用。
访问控制:实现基于IP或用户的访问限制。
这些优化可以显著提升代理服务器的实用性,但也会增加代码复杂度。建议在基础功能稳定后再逐步添加。