echo서버부터 차근차근 만들어가기로 했다. 목표는 proxy를 구현하는 것인데,
아직 network에 대한 개념이 많이 잡혀 있지 않아서 속도는 느리지만, 일단 겁 먹지말고 코드 하나하나 뜯어보기로 했다...
처음 코드들이 엄청 많은데, malloc_lab 5배 인 것 같다.
하나씩 꼭꼭 씹어보자...
echo서버 레츠고...
echo서버가 뭘까...
echo라는 단어의 사전적 정의는 이렇다.
Echo
그리스 신화에 나오는 숲의 요정. 미소년 나르키소스를 사랑했으나 거절당하고 슬픔 때문에 몸은 없어지고 메아리가 되었다 함.
메아리를 의미하는 단어같다.
이처럼 echo서버는 직역하면, 메아리 서버이다.
에코 서버(Echo Server)
클라이언트가 보낸 메시지를 그대로 다시 되돌려주는 서버.
클라이언트가 서버로 "hello" 를 보내면,
서버는 다시 클라이언트에게 "hello"를 그대로 돌려줌
➡️ 즉, "echo"처럼 반복한다.
받은 걸 그대로 되돌려주는 정직한 친구이다.
해당 echo서버는 TCP, UDP 에 기본이 되고, webserver에 기본이 되는 거라서,
한번 구현해 보려고한다.
일단 클라이언트부터 구현해 보려고 한다. 아직 감이 하나도 안잡혀서 메인 루틴의 흐름을 찾아보았다.
대강 흐름은 이런식으로 흘러간다고 한다.
┌──────────────┐
│ 키보드 입력 │ ←───── 사용자
└─────┬────────┘
↓
Fgets() [유저 영역, 라이브러리 함수]
↓
Rio_writen() → 내부적으로 write(fd) → [Trap] → 커널로 진입
↓
[네트워크로 서버에 전송]
↓
Rio_readlineb() → 내부적으로 read(fd) → [Trap] → 커널에서 응답 대기
↓
Fputs() → stdout으로 출력
계속해서 커널을 호출하는 시스템 콜이 발생한다.
시스템 콜과 예외 처리가 web server 의 기본이라고 하는데,
이제 왜 그런 말이 적혀있는지 이해가 가기 시작했다.
CS:APP 책에서 제공된 함수들이 많은데, 여기서 제공해준 함수들은 알고쓰라고 만든 것이니,
해당 함수들의 동작 과정 부터 정리하고 가려고 한다.
신뢰성 있는 입출력 라이브러리 라고 한다.
정리해서 얘기하면,
rio함수는 시스템 콜read,write의 단점을 보완하기 위해 만든 버퍼링 기반의 안정적인 I/O 함수 세트이다.
시스템 콜 read(), write()는 다음과 같은 문제점이 있다
read()는 요청한 만큼 읽힌다는 보장이 없다. (→ partial read)write()도 마찬가지로 일부만 쓸 수도 있음
→ 그래서 만든 게 robust I/O(rio) 라이브러리이다.
#define RIO_BUFSIZE 8192
// *내부 버퍼를 통해 read한 것들을 저장하고, 프로그램에 조금씩 제공한다.
typedef struct { // rio 구조체
int rio_fd; // 대상 파일 디스크립터
int rio_cnt; // 읽을 수 있는 남은 바이트 수
char *rio_bufptr; // 현재 버퍼 포인터
char rio_buf[RIO_BUFSIZE]; // 내부 버퍼
} rio_t;
rio_buf)를 가지고 있어서 한번 read()를 통해 많이 읽어와서 내부 버퍼에 저장한 뒤fgets() 처럼 작동 !)| 함수 | 설명 |
|---|---|
Rio_readinitb(rio_t *rp, int fd) | rio_t를 특정 fd로 초기화 |
Rio_readlineb(rio_t *rp, char *usrbuf, int maxlen) | 한 줄씩 안정적으로 읽음 (개행 문자 포함) |
Rio_readn(int fd, void *usrbuf, size_t n) | n바이트를 다 읽을 때까지 시도 |
Rio_writen(int fd, void *usrbuf, size_t n) | n바이트를 다 쓸 때까지 시도 |
해당 함수들을 직접 들어가서 살펴보면, 유닉스의 함수들을 사용하기 편하도록 개선한 느낌이다.
직접 구현되어 있는 함수를 살펴보는 건, 겁나게 재밌는 거 같다.
한번 하나하나 살펴보자.
시그널 인터럽트 복구 루프 (signal-interrupted retry loop)
while (rp->rio_cnt <= 0) {
// 내부 버퍼에 남은 데이터가 없다면, 파일 디스크럽터에서 최대 버퍼 크기만큼 읽어온다.
rp->rio_cnt = read(rp->rio_fd, rp->rio_buf,
sizeof(rp->rio_buf));
if (rp->rio_cnt < 0) {
if (errno != EINTR) /* Interrupted by sig handler return */
return -1; // 오류 예외처리 (시그널로 인한 경우는 무시하고 재실행)
}
else if (rp->rio_cnt == 0) /* EOF 처리 */
return 0;
else
rp->rio_bufptr = rp->rio_buf; /* 버퍼 포인터 리셋 */
}
해당 루프는 시그널에 의한 인터럽트가 발생한다면, 예외적으로 무시 후에 다시 루프를 구성하도록 유닉스의 read함수를 더 안정적이게 쓸 수 있도록 해준다.
루프의 동작 흐름은 이렇게 된다.
- 만약 내부 버퍼에 이전 값이나, 남은 데이터가 있다면 버퍼 포인터를 리셋하고 다시 루프를 구성한다.
- 버퍼가 비어 있다면
read함수를 사용하여,rio버퍼에 읽어 온 값을 저장한다.- 읽어온 값이 없다면,
EOF처리 후에 다시 루프를 구성한다.
사용자에게 버퍼 리턴
/* Copy min(n, rp->rio_cnt) bytes from internal buf to user buf */
// cnt바이트 만큼 사용자 버퍼로 데이터 복사, 요청이 많다면 가능한 만큼만
cnt = n;
if (rp->rio_cnt < n)
cnt = rp->rio_cnt;
memcpy(usrbuf, rp->rio_bufptr, cnt);
// rio버퍼 상태를 갱신 후에 남은 데이터 수를 감소시킨다.
rp->rio_bufptr += cnt;
rp->rio_cnt -= cnt;
return cnt;
만약 위 루프가 끝났다면, 정상적으로 rio버퍼에 값이 저장된 것이다. 루프에서 빠져나왔다면, 사용자버퍼로 값을 리턴한다.
이때 버퍼는 실제로 복사된 바이트 수만 반환한다. 이는 요청이 많다면 가능한 만큼만 복사하기 때문.
그래서 위에서 표로 설명했던, rio_readlineb, rio_readn 등의 함수들은 일반적으로 rio_read를 사용하여, 각각의 쓰임새에 맞게 구성되어있다.
ssize_t rio_writen(int fd, void *usrbuf, size_t n)
{
size_t nleft = n;
ssize_t nwritten;
char *bufp = usrbuf;
while (nleft > 0) {
if ((nwritten = write(fd, bufp, nleft)) <= 0) {
if (errno == EINTR) /* Interrupted by sig handler return */
nwritten = 0; /* and call write() again */
else
return -1; /* errno set by write() */
}
nleft -= nwritten;
bufp += nwritten;
}
return n;
}
해당 쓰기 함수 또한 write함수를 사용처에 맞게 재구성 한 것이다.
인터럽트같은 예외 상황에서도 전체 데이터가 다 써질 때 까지 보장하는 함수이다.
동작 흐름은 다음과 같다.
nleft은 아직 써야할 남은 바이트 수를 의미한다.- 따라서,
write함수를 사용하여 최대nleft바이트 쓰기를 시도한다.EINTR(인터럽트 시스템 콜) 이 들어오면 그냥 다시시도- 일부만 썻다면
bufp를 앞으로 당기고nleft를 감소시킨다.- 이를 전부 다 쓸 때까지 반복시킨다.
해당 함수를 사용해서 사용처에 맞게 write를 사용할 수 있다.
int main(int argc, char **argv)
{
int clientfd; // 클라이언트 파일 디스크립터
char *host, *port, buf[MAXLINE];
rio_t rio;
if (argc != 3){
fprintf(stderr, "usage : %s <host> <port> \n", argv[0]);
exit(0);
}
host = argv[1];
port = argv[2];
clientfd = Open_clientfd(host, port);
Rio_readinitb(&rio, clientfd);
while(Fgets(buf, MAXLINE, stdin) != NULL) {
Rio_writen(clientfd, buf, strlen(buf));
Rio_readlineb(&rio, buf, MAXLINE);
Fputs(buf, stdout);
}
Close(clientfd);
exit(0);
}
일단 하나씩 씹어보자...
명령 인자
int main(int argc, char **argv)
argc, argv는 C 언어에서 main() 함수의 명령줄 인자(command-line arguments) 를 처리할 때 사용하는 표준 매개변수 라고 한다.
int argc는 인자 개수를 받고 항상 최소는 1 이라고 한다.
char **argv or char *argv[]는 인자 배열이다. (argv[0]은 실행파일 이름)
예시
argc == 3
argv[0] == "./client"
argv[1] == "localhost"
argv[2] == "8080"
이런식으로 서버 주소/ 포트 등 외부 입력을 받을 수 있도록 해준다.
변수 선언
int clientfd; // 클라이언트 파일 디스크립터
char *host, *port, buf[MAXLINE];
rio_t rio;
명령 인자에서 받은 호스트명과 포트번호를 변수에 담아준다.
clientfd : 서버와 연결된 소켓 파일 디스크립터
host, port : 명령행 인자에서 받은 호스트명/포트
buf : 데이터를 담을 버퍼
rio : robust I/O를 위한 구조체
예외 종료
if (argc != 3){
fprintf(stderr, "usage : %s <host> <port> \n", argv[0]);
exit(0);
}
해당 함수는 인자가 필요한 만큼 들어오지 않았다면, 사용법을 알려주고 종료한다.
TCP 연결
host = argv[1];
port = argv[2];
clientfd = Open_clientfd(host, port);
Rio_readinitb(&rio, clientfd);
host, port 변수에 넣어준 후open_clientfd 함수를 사용하여 파일 디스크립터에 등록 후 인덱스 할당리눅스/유닉스에서는 모든 것을 파일로 취급하기 때문에 파일 디스크립터가 중요하다.
c
복사편집
int fd = open("test.txt", O_RDONLY);
read(fd, buf, sizeof(buf));
fd는 "test.txt" 파일에 대한 디스크립터이다.fd를 통해 읽거나 쓴다.| 디스크립터 번호 | 의미 |
|---|---|
| 0 | 표준 입력 (stdin) |
| 1 | 표준 출력 (stdout) |
| 2 | 표준 에러 (stderr) |
[유저 공간]
int fd = open("a.txt", O_WRONLY);
[커널 공간: 프로세스의 파일 디스크립터 테이블]
fd = 3 → 인덱스 3번 자리에 ↓ 포인터 저장
[struct file (커널 구조체)]
┌──────────────────────────┐
│ file→f_mode = O_WRONLY │ ← 바로 이 부분!
│ file→f_pos │
│ file→f_inode │
│ file→f_op │
└──────────────────────────┘
요약하면, 디스크립터는 OS가 관리하는 자원에 접근하기 위한 추상적인 인덱스 또는 핸들이라고 한다.
다시 본론으로 돌아와서 TCP연결을 해주는 함수인 Open_clientfd함수를 다시 봐보자...
clientfd = Open_clientfd(host, port);
Rio_readinitb(&rio, clientfd);
int open_clientfd(char *hostname, char *port) {
int clientfd, rc;
struct addrinfo hints, *listp, *p;
/* Get a list of potential server addresses */
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_socktype = SOCK_STREAM; /* Open a connection */
hints.ai_flags = AI_NUMERICSERV; /* ... using a numeric port arg. */
hints.ai_flags |= AI_ADDRCONFIG; /* Recommended for connections */
if ((rc = getaddrinfo(hostname, port, &hints, &listp)) != 0) {
fprintf(stderr, "getaddrinfo failed (%s:%s): %s\n", hostname, port, gai_strerror(rc));
return -2;
}
위 함수를 직접 뜯어보면 저렇게 생겼는데 정확히 얘기하면,
서버와 연결을 맺는 TCP 클라이언트 소켓을 여는 함수 이다.
동작 흐름
memset을 사용하여addrinfo hints구조체를 모두0으로 초기화 시킨다.
(여기서addrinfo는 반환 결과를 담을 구조체이다)hints.ai_socktype = SOCK_STREAM->TCP연결을 하겠다는 의미이다.
(만약UDP를 쓰고 싶면SOCK_DGRAM사용)hints.ai_flags = AI_NUMERICSERV-> 문자열 포트가 아닌 숫자로 해석하겠다.
http(X)80(O)hints.ai_flags |= AI_ADDRCONFIG->IPv4사용 할지IPv6사용할지 결정한다.getaddrinfo를 통해DNS에 해당 서버의host ip를 알아온다.
(성공시rc에 해당 정보를 담은 구조체의 주소를 리턴한다.)
localhost에 TCP 연결을 시도하고,PORT 를 사용해서 연결한다.여기서 getaddrinfo()함수가 소켓을 생성하고 TCP연결 또한 진행시킨다고 한다.
그래서 이제 socket도 만들었고, TCP 연결도 되었으니 rio_readinitb함수를 사용해서 해당 디스크립터에 대한 요청 버퍼를 초기화 시킨다 !
이제 다 끝났다... 요청한걸 쓰고, 받은걸 읽으면 된다....
while(Fgets(buf, MAXLINE, stdin) != NULL) {
Rio_writen(clientfd, buf, strlen(buf));
Rio_readlineb(&rio, buf, MAXLINE);
Fputs(buf, stdout);
}
Close(clientfd);
exit(0);
Fgets함수와 fputs 함수는 버퍼에 저장된 값을 다루는 함수인데, 에러에 대한 것을 잡아주는 함수이다. 서버가 닫은 상황 EOF = 0을 감지하면 루프를 종료한다.
(Fgets = 출력 fputs = 입력)
rio함수를 사용하여, writen으로 버퍼에 저장되어 있던 값을 해당 소켓 버퍼에 밀어넣고, 커널 버퍼에 저장 되있는 값을 읽는다. 그리고 또 버퍼에 값을 밀어넣는 방식으로 동작
동작 과정
[클라이언트] [서버]
| |
| write(fd, "hello") |
| ------------------------------> | ← 커널이 TCP 패킷으로 전송
| |
| 커널 수신 버퍼에 도착
| |
| read(fd, buf) → "hello"
| |
| write(fd, "hello back") |
| <------------------------------ |
| read(fd, buf) → "hello back" |
이런 식으로 동작된다.
이제 받는 요청은 만들었으니 서버 구현을 할 차례이다...
내가 웹을 알고있다 생각했는데, 진짜 하나도 모르고 사용하던 것이였다.
구현하며 느낀 점은 진짜 겁나게 재밌네...