Carnegie Mellon University의 Proxy Lab 과제
🌟 목적) HTTP 동작 및 소켓을 사용하여 네트워크 연결을 통신하는 프로그램을 작성하는 방법을 배우게 된다.
🌟 목적) 동시성 처리에 대한 이해를 높이게 되며, 시스템에서 중요한 개념 중 하나인 동시성을 다루는 방법을 배운다.
추가 요구사항
- 소켓 입력 및 출력을 위해 표준 I/O 함수를 사용하는 것은 문제가 될 수 있으므로 Robust I/O(RIO) 패키지를 사용한다. (csapp.c에 내장되어 있다.)
- 모듈화 등을 고려하여 모든 파일을 수정할 수 있다.
예를 들어 cache 기능을 구현할 때에는 모듈성을 고려하여 cache.c 및 cache.h 파일과 같은 라이브러리를 만들 수 있으며, 그럴 경우 Makefile도 수정해야 한다.- 프록시 서버는 SIGPIPE(닫힌 소켓에 데이터 보냈을때 발생) 시그널을 무시해야 한다. (928p 참고)
- 웹에서 전송되는 모든 콘텐츠가 ASCII 텍스트가 아니다.
네트워크 I/O와 바이너리 데이터(ex: 이미지, 동영상 등의 이진 데이터)를 다룰 때는 이진 데이터를 처리하기 위한 적절한 함수를 사용해야 한다.- 원래 요청이 HTTP/1.1인 경우에도 모든 요청은 HTTP/1.0으로 전달되어야 한다.
# 형식
curl --proxy {http://localhost:proxy포트/path} {http://localhost:tiny포트/path}
# 예시
curl --proxy http://localhost:7000/ http://localhost:8000/
# 테스트를 위한 툴 설치 (한 번만 하면 됨)
sudo apt install net-tools
# 테스트 수행 명령어 (🔥tiny 상위 폴더에서🔥 입력해야 함)
./dirver.sh
😃 HTTP/1.0 GET 요청을 처리하는 sequential proxy
를 구현한다.
💡 Sequential Proxy 개요
- 지정된 포트 번호에서 들어오는 연결을 수신한다.
- 연결이 수립되면, 클라이언트로부터 전송된 요청(request)을 읽고 파싱한다.
- 클라이언트가 유효한 HTTP 요청을 보냈는지 여부를 확인한 후, 웹 서버와의 연결을 수립하고 클라이언트가 지정한 object를 요청한다.
- 서버의 응답을 읽고 클라이언트로 전달한다.
◾️ Request 수신
GET http://www.cmu.edu/hub/index.html ****HTTP/1.1
◾️ URI Parsing
www.cmu.edu
/hub/index.html
◾️ Request 전송
GET /hub/index.html HTTP/1.0
◾️ Host header
◾️ User-Agent header
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:10.0.3) Gecko/20120305 Firefox/10.0.3
◾️ Connection/Proxy-Connection header
◾️ HTTP 요청 포트
◾️ 프록시 서버의 수신 포트
linux> ./proxy 15213
요청 라인에서 hostname과 port 및 path를 파악한다.
parse_uri
함수에서 http://www.cmu.edu:3012/hub/index.html
이렇게 생긴 uri를🖥 Code
void doit(int clientfd)
{
...
/* 1️⃣ -1) Request Line 읽기 [🙋♀️ Client -> 🚒 Proxy] */
Rio_readinitb(&request_rio, clientfd);
Rio_readlineb(&request_rio, request_buf, MAXLINE);
printf("Request headers:\n %s\n", request_buf);
// 요청 라인 parsing을 통해 `method, uri, hostname, port, path` 찾기
sscanf(request_buf, "%s %s", method, uri);
parse_uri(uri, hostname, port, path);
// Server에 전송하기 위해 요청 라인의 형식 변경: `method uri version` -> `method path HTTP/1.0`
sprintf(request_buf, "%s %s %s\r\n", method, path, "HTTP/1.0");
// 지원하지 않는 method인 경우 예외 처리
if (strcasecmp(method, "GET") && strcasecmp(method, "HEAD"))
{
clienterror(clientfd, method, "501", "Not implemented", "Tiny does not implement this method");
return;
}
...
}
// uri를 `hostname`, `port`, `path`로 파싱하는 함수
// uri 형태: `http://hostname:port/path` 혹은 `http://hostname/path` (port는 optional)
void parse_uri(char *uri, char *hostname, char *port, char *path)
{
// host_name의 시작 위치 포인터: '//'가 있으면 //뒤(ptr+2)부터, 없으면 uri 처음부터
char *hostname_ptr = strstr(uri, "//") ? strstr(uri, "//") + 2 : uri;
char *port_ptr = strchr(hostname_ptr, ':'); // port 시작 위치 (없으면 NULL)
char *path_ptr = strchr(hostname_ptr, '/'); // path 시작 위치 (없으면 NULL)
strcpy(path, path_ptr);
if (port_ptr) // port 있는 경우
{
strncpy(port, port_ptr + 1, path_ptr - port_ptr - 1);
strncpy(hostname, hostname_ptr, port_ptr - hostname_ptr);
}
else // port 없는 경우
{
if (is_local_test)
strcpy(port, "80"); // port의 기본 값인 80으로 설정
else
strcpy(port, "8000");
strncpy(hostname, hostname_ptr, path_ptr - hostname_ptr);
}
}
End Server 소켓 생성
요청 라인과 요청 헤더를 끝까지 읽으면서 end server에 전송한다.
전달 받은 헤더가 요구사항에 맞는지 확인한다.
🖥 Code
void doit(int clientfd)
{
...
/* 1️⃣ -2) Request Line 전송 [🚒 Proxy -> 💻 Server] */
// Server 소켓 생성
serverfd = Open_clientfd(hostname, port);
if (serverfd < 0)
{
clienterror(serverfd, method, "502", "Bad Gateway", "📍 Failed to establish connection with the end server");
return;
}
Rio_writen(serverfd, request_buf, strlen(request_buf));
/* 2️⃣ Request Header 읽기 & 전송 [🙋♀️ Client -> 🚒 Proxy -> 💻 Server] */
read_requesthdrs(&request_rio, request_buf, serverfd, hostname, port);
...
}
// Request Header를 읽고 Server에 전송하는 함수
// 필수 헤더가 없는 경우에는 필수 헤더를 추가로 전송
void read_requesthdrs(rio_t *request_rio, void *request_buf, int serverfd, char *hostname, char *port)
{
int is_host_exist;
int is_connection_exist;
int is_proxy_connection_exist;
int is_user_agent_exist;
Rio_readlineb(request_rio, request_buf, MAXLINE); // 첫번째 줄 읽기
while (strcmp(request_buf, "\r\n"))
{
if (strstr(request_buf, "Proxy-Connection") != NULL)
{
sprintf(request_buf, "Proxy-Connection: close\r\n");
is_proxy_connection_exist = 1;
}
else if (strstr(request_buf, "Connection") != NULL)
{
sprintf(request_buf, "Connection: close\r\n");
is_connection_exist = 1;
}
else if (strstr(request_buf, "User-Agent") != NULL)
{
sprintf(request_buf, user_agent_hdr);
is_user_agent_exist = 1;
}
else if (strstr(request_buf, "Host") != NULL)
{
is_host_exist = 1;
}
Rio_writen(serverfd, request_buf, strlen(request_buf)); // Server에 전송
Rio_readlineb(request_rio, request_buf, MAXLINE); // 다음 줄 읽기
}
// 필수 헤더 미포함 시 추가로 전송
if (!is_proxy_connection_exist)
{
sprintf(request_buf, "Proxy-Connection: close\r\n");
Rio_writen(serverfd, request_buf, strlen(request_buf));
}
if (!is_connection_exist)
{
sprintf(request_buf, "Connection: close\r\n");
Rio_writen(serverfd, request_buf, strlen(request_buf));
}
if (!is_host_exist)
{
sprintf(request_buf, "Host: %s:%s\r\n", hostname, port);
Rio_writen(serverfd, request_buf, strlen(request_buf));
}
if (!is_user_agent_exist)
{
sprintf(request_buf, user_agent_hdr);
Rio_writen(serverfd, request_buf, strlen(request_buf));
}
sprintf(request_buf, "\r\n"); // 종료문
Rio_writen(serverfd, request_buf, strlen(request_buf));
return;
}
readlineb
vs readnb
vs readn
// Rio_readlineb 사용
int n;
while ((n = Rio_readlineb(&response_rio, response_buf, MAXLINE)) > 0)
{
Rio_writen(clientfd, response_buf, n)
}
/*
Rio_readlineb의 실제 읽은 바이트 수인 `n`을 반환받고,
Rio_writen으로 `n`만큼 데이터를 전송한다.
*/
// Rio_readnb 사용
Rio_readnb(&response_rio, response_buf, content_length);
Rio_writen(clientfd, response_buf, content_length);
/*
Response Header에서 Content-length를 파싱해서 파악하고,
Content-length만큼 읽고 전송한다.
readline()이 아닌 read() 함수를 사용하면 바이트 단위로 데이터를 읽거나 쓸 수 있다.
read() 함수는 문자열의 끝을 나타내는 NULL 종료 문자열이 없는 이진 데이터를 처리할 수 있도록 설계되어 있다.
*/
// 이진 데이터 처리에 문제가 발생하는 코드
while ((Rio_readlineb(&response_rio, response_buf, MAXLINE)) > 0)
{
Rio_writen(clientfd, response_buf, strlen(response_buf));
}
/*
Rio_writen의 사이즈를 strlen(response_buf)로 구하고 있다.
strlen는 문자열의 길이를 구하는 함수로, NULL 종료 문자열이 나올 때까지 문자열의 길이를 계산한다.
이진 데이터는 NULL 종료 문자열을 사용하지 않아 response_buf의 길이를 정확히 계산하지 못한다.
*/
// Rio_readnb 대신 Rio_readn 함수를 사용해서 문제가 발생하는 코드
srcp = malloc(content_length);
Rio_readn(serverfd, srcp, content_length);
Rio_writen(clientfd, srcp, content_length);
free(srcp);
/*
Rio_readn() 함수는 버퍼를 사용하지 않는 함수이다.
(어쩐지,, 이 함수는 Rio_readnb()와 다르게 인자로 버퍼가 아닌 포인터를 받는다.)
이전에 serverfd에서 헤더의 내용을 읽은 시점부터 이어서 읽어야 바디의 내용을 잘 받아오는데,
Rio_readn() 함수는 버퍼를 사용하지 않기 때문에 읽은 지점을 파악하지 않고
다시 처음부터 읽어오기 때문에 바디의 내용이 아닌 다시 처음(헤더)부터 읽어와서 문제가 발생한다.
*/
void doit(int clientfd)
{
...
/* 3️⃣ Response Header 읽기 & 전송 [💻 Server -> 🚒 Proxy -> 🙋♀️ Client] */
Rio_readinitb(&response_rio, serverfd);
while (strcmp(response_buf, "\r\n"))
{
Rio_readlineb(&response_rio, response_buf, MAXLINE);
if (strstr(response_buf, "Content-length")) // Response Body 수신에 사용하기 위해 Content-length 저장
content_length = atoi(strchr(response_buf, ':') + 1);
Rio_writen(clientfd, response_buf, strlen(response_buf));
}
/* 4️⃣ Response Body 읽기 & 전송 [💻 Server -> 🚒 Proxy -> 🙋♀️ Client] */
response_ptr = malloc(content_length);
Rio_readnb(&response_rio, response_ptr, content_length);
Rio_writen(clientfd, response_ptr, content_length); // Client에 Response Body 전송
free(response_ptr); // 캐싱하지 않은 경우만 메모리 반환
Close(serverfd);
}
동시에 여러 요청
을 처리할 수 있도록 한다.
🖥 Code
int main(int argc, char **argv)
{
...
listenfd = Open_listenfd(argv[1]); // 전달받은 포트 번호를 사용해 수신 소켓 생성
while (1)
{
...
Pthread_create(&tid, NULL, thread, clientfd); // Concurrent 프록시
}
}
void *thread(void *vargp)
{
int clientfd = *((int *)vargp);
Pthread_detach(pthread_self());
Free(vargp);
doit(clientfd);
Close(clientfd);
return NULL;
}
😃 프록시에 최근에 사용된 웹 객체를 메모리에 저장
하는 캐시를 추가한다.
💡 Caching 개요
- 프록시가 서버로부터 웹 객체를 받을 때, 객체를 클라이언트로 전송하는 동안 캐시에 저장한다.
- 같은 객체를 요청하는 다른 클라이언트가 있다면, 서버에 다시 연결할 필요 없이 캐시된 객체를 재전송한다.
웹 객체, 이전 노드, 다음 노드
에 대한 포인터를 포함한다.캐시 연결 리스트에 동일한 요청의 캐싱된 객체를 찾는다. find_cache()
캐싱되어 있는 객체가 있다면, 캐싱된 객체를 Client에 전송한다. send_cache()
사용한 웹 객체의 순서를 캐시 연결 리스트의 맨 앞 순서로 갱신한다. read_cache()
Server로 요청을 보내지 않고 통신을 종료한다.
🖥 Code
/* 캐시 구현에 필요한 변수, 함수 등 선언 */
typedef struct web_object_t
{
char path[MAXLINE];
int content_length;
char *response_ptr;
struct web_object_t *prev, *next;
} web_object_t;
web_object_t *find_cache(char *path);
void send_cache(web_object_t *web_object, int clientfd);
void read_cache(web_object_t *web_object);
void write_cache(web_object_t *web_object);
extern web_object_t *rootp; // 캐시 연결리스트의 root 객체
extern web_object_t *lastp; // 캐시 연결리스트의 마지막 객체
extern int total_cache_size; // 캐싱된 객체 크기의 총합
#define MAX_CACHE_SIZE 1049000
#define MAX_OBJECT_SIZE 102400
void doit(int clientfd)
{
...
// 현재 요청이 캐싱된 요청(path)인지 확인
web_object_t *cached_object = find_cache(path);
if (cached_object) // 캐싱 되어있다면
{
send_cache(cached_object, clientfd); // 캐싱된 객체를 Client에 전송
read_cache(cached_object); // 사용한 웹 객체의 순서를 맨 앞으로 갱신
return; // Server로 요청을 보내지 않고 통신 종료
}
...
}
// 캐싱된 웹 객체 중에 해당 `path`를 가진 객체를 반환하는 함수
web_object_t *find_cache(char *path)
{
if (!rootp) // 캐시가 비었으면
return NULL;
web_object_t *current = rootp; // 검사를 시작할 노드
while (strcmp(current->path, path)) // 현재 검사 중인 노드의 path가 찾는 path와 다르면 반복
{
if (!current->next) // 현재 검사 중인 노드의 다음 노드가 없으면 NULL 반환
return NULL;
current = current->next; // 다음 노드로 이동
if (!strcmp(current->path, path)) // path가 같은 노드를 찾았다면 해당 객체 반환
return current;
}
return current;
}
// `web_object`에 저장된 response를 Client에 전송하는 함수
void send_cache(web_object_t *web_object, int clientfd)
{
// 1️⃣ Response Header 생성 및 전송
char buf[MAXLINE];
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\r\n", buf, web_object->content_length); // 컨텐츠 길이
Rio_writen(clientfd, buf, strlen(buf));
// 2️⃣ 캐싱된 Response Body 전송
Rio_writen(clientfd, web_object->response_ptr, web_object->content_length);
}
// 사용한 `web_object`를 캐시 연결리스트의 root로 갱신하는 함수
void read_cache(web_object_t *web_object)
{
if (web_object == rootp) // 현재 노드가 이미 root면 변경 없이 종료
return;
// 1️⃣ 현재 노드와 이전 & 다음 노드의 연결 끊기
if (web_object->next) // '이전 & 다음 노드'가 모두 있는 경우
{
// 이전 노드와 다음 노드를 이어줌
web_object_t *prev_objtect = web_object->prev;
web_object_t *next_objtect = web_object->next;
if (prev_objtect)
web_object->prev->next = next_objtect;
web_object->next->prev = prev_objtect;
}
else // '다음 노드'가 없는 경우 (현재 노드가 마지막 노드인 경우)
{
web_object->prev->next = NULL; // 이전 노드와 현재 노드의 연결을 끊어줌
}
// 2️⃣ 현재 노드를 root로 변경
web_object->next = rootp; // root였던 노드는 현재 노드의 다음 노드가 됨
rootp = web_object;
}
Request header에서 파싱해서 얻어낸 Content-length가 캐싱 가능한 최대 웹 객체의 크기보다 작거나 같다면, 웹 객체 구조체를 생성해 캐시 연결 리스트에 추가한다.
연결 리스트에 추가하는 과정에서 최대 캐시 크기를 초과하게 된다면, 사용한 지 가장 오래된 웹 객체부터 캐시 리스트의 용량이 충분할 때까지 제거한다.
🖥 Code
void doit(int clientfd)
{
...
/* 4️⃣ Response Body 읽기 & 전송 [💻 Server -> 🚒 Proxy -> 🙋♀️ Client] */
response_ptr = malloc(content_length);
Rio_readnb(&response_rio, response_ptr, content_length);
Rio_writen(clientfd, response_ptr, content_length); // Client에 Response Body 전송
if (content_length <= MAX_OBJECT_SIZE) // 캐싱 가능한 크기인 경우
{
// `web_object` 구조체 생성
web_object_t *web_object = (web_object_t *)calloc(1, sizeof(web_object_t));
web_object->response_ptr = response_ptr;
web_object->content_length = content_length;
strcpy(web_object->path, path);
write_cache(web_object); // 캐시 연결 리스트에 추가
}
else
free(response_ptr); // 캐싱하지 않은 경우만 메모리 반환
Close(serverfd);
}
// 인자로 전달된 `web_object`를 캐시 연결리스트에 추가하는 함수
void write_cache(web_object_t *web_object)
{
// total_cache_size에 현재 객체의 크기 추가
total_cache_size += web_object->content_length;
// 최대 총 캐시 크기를 초과한 경우 -> 사용한지 가장 오래된 객체부터 제거
while (total_cache_size > MAX_CACHE_SIZE)
{
total_cache_size -= lastp->content_length;
lastp = lastp->prev; // 마지막 노드를 마지막의 이전 노드로 변경
free(lastp->next); // 제거한 노드의 메모리 반환
lastp->next = NULL;
}
if (!rootp) // 캐시 연결리스트가 빈 경우 lastp를 현재 객체로 지정
lastp = web_object;
// 현재 객체를 루트로 지정
if (rootp)
{
web_object->next = rootp;
rootp->prev = web_object;
}
rootp = web_object;
}