PHP로 HTTP 서버 구현하기 - 04 - 가장 간단한 서버

연재글 전체 보기


Updated (2017.10.18)

이번 글과 관련된 내용이라 조금 더 추가해봤습니다.

하단 Updated 참고.

가장 간단한 서버

이제부터 하나씩 기능을 붙여가며 commit & push를 할 예정이다.

PHP7.1/mac 환경에서 공부한 결과를 적는 거라서, 윈도 머신에선 안 통하는 설명도 있으리라.

소스 위치는 https://github.com/youngiggy/phttp

PHP로 HTTP 서버 구현하기 - 01에서 언급한 가장 간단한 서버부터 시작해볼까?

<?php
$server = stream_socket_server("tcp://127.0.0.1:1337", $errno, $errorMessage);

if ($server === false) {
throw new UnexpectedValueException("Could not bind to socket: $errorMessage");
}

while (true) {
$client = @stream_socket_accept($server);

if ($client) {
stream_copy_to_stream($client, $client);
fclose($client);
}
}

딱 봐도 어려울 것 없는 코드다.

127.0.0.1이란 IP에 1337 포트를 열었다.

왜 하필 1337이냐고? 그냥! 저 블로그 쓴 사람이 그렇게 써서 따라했다.

사용하는 프로그램에 따라 이미 포트가 선점되었을 수도 있다.

  • 기본으로 이 포트를 쓰는 프로그램이 있는지 이런 곳에서 찾아봐도 좋고
  • 쉘에서 lsof -PiTCP -sTCP:LISTEN 포트가 열려있는지 확인해봐도 좋다

그 다음 while 루프로 뱅글뱅글 돌면서 연결이 들어오는 지 확인한다.

stream_socket_accept 함수가 말을 못하게 @로 입을 막자.

  • &#9834; No alarms and no surprises
  • 하지만 안 막는다고 뭐 대단한 걸 볼 일은 없을 것이다

연결이 된 클라이언트가 있다면, stream_copy_to_stream를 통해 클라이언트가 보낸 스트림을 고스란히 돌려주자.

고스란히…

주고 받기

가장 간단한 클라이언트

가장 간단한 서버가 완성됐으니 터미널을 열어 php ./app/server.php로 일을 시키고 돌아오자.

새로운 터미널을 열어 echo "hello hello" | nc 127.0.0.1 1337를 입력하면 hello hello라고 반갑게 인사하는 것이 보일 것이다.

너무 시시하니까 가장 간단한 클라이언트를 만들어 잘 돌아가는 지 구경해보자.

아직 브라우저에서 HTTP 프로토콜로 붙을 상황은 아니므로 간단한 TCP 연결 클라이언트로 테스트 한다.

역시 PHP Socket Programming, done the Right Way에서 예시로 보여준 것을 기본으로 시작한다.

<?php
$client = stream_socket_client("tcp://127.0.0.1:1337", $errno, $errorMessage);
if ($client === false) {
throw new UnexpectedValueException("Failed to connect: $errno - $errorMessage");
}

/*
* generate a message
*/
$startLine = 'GET / HTTP/1.0';

$headers = [
'Host: localhost',
'Accept: */*',
];
$header = implode(PHP_EOL, $headers);

$emptyLine = PHP_EOL;

$body = '';

/*
* request
*/
$message = implode(PHP_EOL, [
$startLine,
$header,
$emptyLine,
$body,
]);
fwrite($client, $message);

/*
* response
*/
echo stream_get_contents($client);

/*
* bye bye
*/
fclose($client);

역시 별 거 없는 코드다.

stream_socket_client으로 연결을 시도하고,

HTTP 메시지를 만들어 준다.

  • HTTP 메시지는 시작줄, 헤더, 공백, 바디로 이뤄졌다. 참고

이 메시지를 fwrite를 통해 스트림에 써 준다.

02편에서 얘기한 것처럼, stream_socket_client이 리턴하는 stream resource에는 fopen, fread같은 filesystem 함수를 사용할 수 있다.

실행

터미널을 하나 열고 php ./app/client.php로 클라이언트를 실행해보자.

아무 반응이 없을 것이다.

하지만 조금만 더 기다려보면, 1분 후 timeout으로 client가 종료되고 자신이 서버에 전송한 값이 화면에 찍한다.

왜 그럴까?

다르게 재현해보자.

서버를 다시 실행하고 클라이언트를 다시 실행한 후, 이번엔 서버를 죽여보자.

이때도 클라이언트와 연결이 바로 끊어지고 서버에 전송한 값이 되돌아와 화면에 찍힌다.

우선 timeout 1분은 환경마다 다를 수 있는데, php.ini에 default_socket_timeout에 정의되어 있다.

client 소스의 response 부분을 아래와 같이 바꿔보자.

/*
* response
*/
while (true) {
if (!($response = stream_get_contents($client, 4))) {
break;
}
echo $response . '|';
}

그럼 아래와 같이 나오던 것이

GET / HTTP/1.0
Host: localhost
Accept: *

아래와 같이 바로 찍힌다.

GET |/ HT|TP/1|.0
H|ost:| loc|alho|st
A|ccep|t: *|/*

stream_get_contents의 두번째 인자는 maxlength라서 지정한 만큼만 스트림에서 읽어온다.

그래서 읽어온 만큼 바로바로 화면에 echo 하는 것이다.

  • 보기 좋으라고(?)뒤에 |를 붙여봤다.

하지만 이렇게 해도 클라이언트는 종료되지 않는다.

stream_get_contents가 계속 스트림에 뭔가 들어오는지 지켜보고 있어서 while loop가 끝나지 않는다.

이 악순환의 고리를 끊으려면 서버에서 연결을 끊어버리면 간단한데, 우리 코드에선 클라이언트로 들어오는 스트림을 바로 돌려주기 때문에 쉽지 않다.

클라이언트에서 loop를 종료하려면 어디가 끝인지를 확인해야만 한다.

서버가 Content-Length를 돌려주게끔 해주면 좋겠지만 우리 서버는 아직 바보니까 클라이언트가 좀 더 고생을 하자.

$messageLenth = strlen($message);
$totalLenth = 0;
while (true) {
$response = stream_get_contents($client, 1);
echo $response;
$totalLenth += strlen($response);
if ($messageLenth <= $totalLenth) {
break;
}
}

Updated

이 글 이후로 좀 더 진행해보다가 meta 정보를 이용하는 방법까지는 여기에 묻어두는 것이 좋을 것 같아서 내용을 추가한다.

stream_get_contents으로 데이터를 가져온 다음 현재 스트림의 상태를 보려면 stream_get_meta_data를 통해 확인할 수 있다.

여기에는 다음과 같은 정보가 리턴된다.

Array
(
[timed_out] =>
[blocked] => 1
[eof] =>
[stream_type] => tcp_socket/ssl
[mode] => r+
[unread_bytes] => 1
[seekable] =>
)

어쩐지 eof나 unread_bytes를 쓰면 될 것 같다.

/*
* response
*/
while (true) {
$response = stream_get_contents($client, 1);
echo $response;
$info = stream_get_meta_data($client);
if ($info['eof'] || $info['unread_bytes'] === 0) {
break;
}
}

이렇게 하니 소중한 고객님의 연결이 끊겼습니다.

그러나,

문서를 보면 unread_bytes가 의미하는 것은,

unread_bytes (int) - the number of bytes currently contained in the PHP's own internal buffer.

즉, PHP 자체의 내부 버퍼의 내용을 보여주는 것이라서 스크립트에서 활용하지 않을 것을 추천하고 있다.

Note: You shouldn’t use this value in a script.

그럼 포기하고 다른 방법을 찾아보자.

우선 client 쪽 소스에서 unread_bytes를 검사하는 부분만 제거한 다음,

/*
* response
*/
while (true) {
$response = stream_get_contents($client, 1);
echo $response;
$info = stream_get_meta_data($client);
if ($info['eof'] || $info['unread_bytes'] === 0) {
break;
}
}

연결이 안 끊어지는 것을 반드시 확인하자.

그 다음,

서버쪽 소스에서 stream_set_blocking으로 non-blocking 모드로 바꿔보자.

stream_set_blocking($client, false);

즉, 서버의 전체 소스는 아래와 같이 변할 것이다.

<?php
$server = stream_socket_server("tcp://127.0.0.1:1337", $errno, $errorMessage);

if ($server === false) {
throw new UnexpectedValueException("Could not bind to socket: $errorMessage");
}

while (true) {
$client = @stream_socket_accept($server);

if ($client) {
stream_set_blocking($client, false);// <-- 여기요 여기
stream_copy_to_stream($client, $client);
fclose($client);
}
}

클라이언트를 실행하면 바로 연결이 끊긴다.

stream_set_blocking 함수의 두번째 인자 mode가 false로 들어가면 non-blocking 모드로 진입하며,

클라이언트 쪽에서는 스트림으로 더이상의 데이터가 들어오는 것을 기다리지 않는다.

이렇게 하면 굳이 스트림을 쓸 이유가 없기 때문에 이 역시도 좋은 방법은 아닐 것이다.

결론

아주 간단한 서버와 아주 간단한 클라이언트를 아주 간단히 살펴봤다.

도저히 못 써먹겠으니 다음 글에서는 이를 좀 더 개선해보자.
(뭘 어떻게 바꿀 지는 아직 계획없음)