PHP로 HTTP 서버 구현하기 - 10 - 요청 메시지 파싱 3

연재글 전체 보기


지난 시간에 우리는..

이전 글에서 HTTP 메시지를 줄단위로 끊고 첫 요소에서 시작줄을 파싱하는 것까지 진행했다.

이제 슬슬 길어져 보기 힘드니까…여기서 확인

테스트 코드는 멀쩡한 코드인가?

여기까지는 최소한으로 받아들일 수 있는 HTTP 메시지의 형식을 제한했다.

그런데 이게 테스트 케이스로써 어떤 가치가 있는지 생각해보게 됐다.

스스로에게 이번 프로젝트에 TDD를 도입한 이유를 물어본다면

  • 자동화된 테스트는 내가 방금 수정한 코드에 문제가 있는지 매우 빠르게 피드백을 준다
  • 테스트로부터 테스트 대상을 어떻게 사용할 수 있는지 알 수 있기 때문에 그 자체로 살아있는 기능 정의서가 된다

크게 이정도 생각난다(사실 어디서 주워들었다). 더 있다면 누군가 댓글을 달아주겠지.

이외에도 단위테스트를 작성하면서 얻을 수 있는 효과는 많지만, 저 둘을 가장 근본적인 목적으로 꼽겠다, 나는.

자동화된 테스트가 즉각 피드백을 주는 것은 앞서 꾸준히 증명이 됐다고 본다.

그럼,

지금까지의 테스트 코드가 HttpMessageParser의 사용법을 올바르게 노출하고 있을까?

HttpMessageParser를 사용하는 곳은 아직까지 2곳으로 예상된다. 이들은 HttpMessageParser의 서비스를 이용하는 클라이언트가 된다.

일단 테스트 코드가 첫번째 손님이 됐고, 앞으로 테스트 코드가 충분히 cover하게 되면 Request도 곧 클라이언트가 된다. (기억이…나시는가?)

당신이 Request든 뭐든 HTTP 메시지를 파싱해서 지지고 볶는 기능을 만든다고 생각하고, 막 테스트 코드를 열었을 때 어떤 모양인지 살펴보자.

HttpMessageParserHttpMessageParserTest의 테스트 코드로 미루어 보건대, 어떤 기능을 담고 있는가?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php

use PHPUnit\Framework\TestCase;

class HttpMessageParserTest extends TestCase
{
public function testReturnValue()
{
$goodMsg = 'GET / HTTP/1.1';
$parser = new HttpMessageParser($goodMsg);
$result = $parser->getResult();
$this->assertEquals(true, is_array($result));

$requiredFields = [
'start_line',
'method',
'uri',
'version',
'headers',
'body',
];
foreach ($requiredFields as $field) {
$this->assertArrayHasKey($field, $result, 'key "' . $field . '" does not exist');
}
}

public function badMessages()
{
return [
[''],
['haha'],
['GET 둘 셋'],
['POST 둘 셋'],
['HEAD 둘 셋'],
['HEAD / 셋'],
['HEAD /index 셋'],
];
}

/**
* @dataProvider badMessages
* @expectedException Exception
*/
public function testInvalidMessageCausesException($badMsg)
{
new HttpMessageParser($badMsg);
}
}
  1. ‘GET / HTTP/1.1’라는 문자열을 전달한 후, getResult()를 호출하면
    • 그 결과물은 반드시 배열이다
    • ‘start_line’, ‘method’, ‘uri’, ‘version’, ‘headers’, ‘body’의 키가 반드시 존재한다
  2. 7개의 불량한 문자열을 넘기면 예외가 발생한다
    • ‘’, ‘haha’, ‘GET 둘 셋’, ‘POST 둘 셋’, ‘HEAD 둘 셋’, ‘HEAD / 셋’, ‘HEAD /index 셋’

1번 기능을 통해 우리가 원했던 대로 리턴이 되는 것을 확인할 수는 있지만, ‘GET / HTTP/1.1’이라는 문자열이 왜 통과하는 지는 알 수가 없다.

이게 통과하는 이유는 2번 테스트를 통해 유추가 가능한데, 7개의 패턴에 속하지 않기 때문이다.

테스트를 열심히 만들 때는 그 과정이 매우 자연스럽다고 생각했는데, 만들어 놓은 테스트를 보니 구멍이 많아 보인다.

누군가 OPTIONS라는 메소드를 허용하도록 HttpMessageParser를 변경했다고 치자.

이때 $supportMethods에 ‘OPTIONS’라고 추가하면 검증 기능은 잘 동작할 것이다.

그리고 테스트 코드를 수정하지 않아도 당연히 테스트는 통과한다.

하지만 나중에 ‘OPTIONS’에 관한 기능을 수정할 땐 테스트의 가호를 받을 수 없다.

이에 문제를 인식한 그 누군가는 ‘OPTIONS’를 testReturnValue()쪽에 넣어 테스트를 돌려볼 수도 있고, badMessages()쪽에 몇가지 케이스를 추가할 수도 있다.

역시 테스트 코드는 잘 돌아갈 것이다.

그리고 여전히 테스트 코드만 봐선 OPTIONS를 쓸 수 있는 지 한 눈에 보이지 않는다. 지원 가능한 옵션이 늘어날 수록 테스트코드는 복잡해지고 유지보수하기 어려워진다.

열심히만 살아온 것 같은데, 왜 이런 시련이 왔을까?

Blacklist vs Whitelist

우리(내가 작성해놓고 우리라니…)의 테스트에 어떤 문제가 있었는지 돌아보자.

우선 시작줄의 세 영역을 한번에 테스트하는 것이 마음에 걸린다.

badMessages()라는 메소드엔 불량한 시작줄만 들어있으므로 적당한 메소드명도 아닌 것 같다.

그리고 테스트의 대부분이 blacklist에 기반한다.

즉, 어떤 패턴을 사용할 수 있는지보다는 어떤 걸 쓰면 문제가 생기는지 알 수 있다.

(아이를 키우다보면 하지말라는 말만 하게 되는데, 그 영향인가…)

이런 테스트로 지탱하는 시스템이라면 새로운 기능이 생겼을 때 하지 말아야 할 패턴을 만들어야 하는 난관에 봉착한다.

그래서 whitelist를 기반으로 HttpMessageParser에서 뭘 할 수 있는지 테스트를 바꿔보려 한다.

여전히 이해할 수 없는 구문이 들어오면 예외를 던지는 기능은 유지가 되어야 한다.

이제부터 해야할 일은,

  • badMessages()의 이름을 바꾼다
  • Whitelist 기반으로, HttpMessageParser를 어떻게 사용할 수 있는지 표현하자
    • 시작줄의 세영역은 따로따로 테스트하자

아직 시작줄에 관한 정책만 테스트하고 있으므로 dataProvider의 이름은 badStartLines라고 바꾸고, 이에 맞춰 테스트명도 변경했다.

1
2
3
4
5
6
7
8
9
/**
* @dataProvider badStartLines
* @expectedException Exception
* @param $badMsg
*/
public function testInvalidStartLineCausesException($badMsg)
{
new HttpMessageParser($badMsg);
}

이제 화이트리스트 기반의 테스트를 작성할텐데, 그 전에 또 하나의 고민이 생긴다.

HttpMessageParser가 서버에서 지원 가능한 메소드의 종류를 알아야 하는가?

RequestHttpMessageParser에 어떤 역할을 해주기 기대할까?

나는 아직까지 Request가 필요로 하는 기능 이상으로는 생각하고 싶지 않다.

RequestHttpMessageParser에게 문자열을 던져주고 특정 구조에 맞춰 결과를 돌려주길 바랄 뿐이다.

해당 메소드를 지원하는지 안 하는지 여부는 꼭 파서가 보유할 지식일까?

최소한 Request는 메소드에 따른 분기처리를 위해 지원하는 메소드의 종류를 알아야 한다.

두 클래스에서 보유할 필요는 없으니, 이 지식이 (나중에야 어찌되든) 현재는 Request 쪽에서 보유하는 게 좋겠다고 생각이 든다.

그럼 HttpMessageParser에서는 정상적인 메소드가 넘어왔는지 테스트 안해도 되나?

(안해도 된다고 생각은 하지만) HTTP 표준에 존재하는 모든 메소드 정도는 파서에서 알고 있는 것도 의미가 있을 것 같다.

그럼 HTTP 스펙에서 정의하는 메소드는 무엇이 있을까?

RFC 7231, section 4: Request methods에서 GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE를 정의하고 있고, RFC 5789, section 2: Patch method에서 PATCH를 추가로 정의하고 있다.

HttpMessageParser에서 허용 가능한 메소드를 수정해주자.

$HTTPMethods라는 변수도 PHPStorm의 Refactor > Rename을 이용해 변경해주자.

이 클래스만 독립적으로 보면 크게 잘못된 이름은 아니라고 보지만,

우리의 서버가 제공하는 메소드가 아닌 HTTP에 정의된 메소드라는 의미로 바꾸어보자.

1
2
3
//HttpMessageParser.php

private $HTTPMethods = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE',];

(못난 테스트라도) 테스트는 잘 돌아간다.

이왕 고치는 김에 기존에 만들었던 testReturnValue() 메소드 명도 바꿔보자. 이름이 마음에 안든다.

testReturnValueHasRequiredFields()라고 좀 더 하는 일을 정확하게 표현해주자.

드디어 (이 글의 본론인…) whitelist 기반의 테스트를 작성한다!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function testMessageParsedAndPlacedIntoRequiredFields()
{
$goodMethod = 'GET';
$goodUri = '/';
$goodVersion = 'HTTP/1.1';

$goodMsg = implode(' ', [$goodMethod, $goodUri, $goodVersion]);
$parser = new HttpMessageParser($goodMsg);
$result = $parser->getResult();

//TODO $result의 형식은 올바른지 확인

$this->assertEquals($goodMethod, $result['method']);
}

여기선 어떤 메소드를 쓸 수 있는지 확인할텐데, 사용할 메소드마다 또 loop를 돌거나 dataProvider를 사용하게 될 것 같다.

시작줄의 요소를 하나씩 테스트하기 위해선 시작줄의 나머지 요소는 확실히 문제없는 문자열을 넣어줘야 한다.

그래서 완벽한 시작줄을 정의하고 각 요소를 하나씩 교체해가면서 문제가 없는지 확인하려고 한다.

반복되는 구문은 별도의 메소드로 빼놓는 것도 잊지 말고.

변경 후의 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
<?php

use PHPUnit\Framework\TestCase;

class HttpMessageParserTest extends TestCase
{
const PERFECT_START_LINE = 'GET / HTTP/1.1';

public function testReturnValueHasRequiredFields()
{
$parser = new HttpMessageParser(self::PERFECT_START_LINE);
$result = $parser->getResult();
$this->assertEquals(true, is_array($result));

$requiredFields = [
'start_line',
'method',
'uri',
'version',
'headers',
'body',
];
foreach ($requiredFields as $field) {
$this->assertArrayHasKey($field, $result, 'key "' . $field . '" does not exist');
}
}

public function testMessageParsedAndPlacedIntoRequiredFields()
{
$possibleHTTPMethod = [
'GET',
'HEAD',
'POST',
'PUT',
'DELETE',
'CONNECT',
'OPTIONS',
'TRACE',
];
foreach ($possibleHTTPMethod as $method) {
$this->assertValidStartLine($method, null, null);
}

$uriStartsWithSlash = [
'/',
'/index',
'/index.html',
];
foreach ($uriStartsWithSlash as $uri) {
$this->assertValidStartLine(null, $uri, null);
}

$possibleHTTPVersions = [
'HTTP/0.9',
'HTTP/1.0',
'HTTP/1.1',
'HTTP/1000.9999',
];
foreach ($possibleHTTPVersions as $version) {
$this->assertValidStartLine(null, null, $version);
}
}

private function assertValidStartLine($method = null, $uri = null, $version = null)
{
list($goodMethod, $goodUri, $goodVersion) = explode(' ', self::PERFECT_START_LINE);

$goodMsg = implode(' ', [
$method ?? $goodMethod,
$uri ?? $goodUri,
$version ?? $goodVersion,
]);

$parser = new HttpMessageParser($goodMsg);
$result = $parser->getResult();

if (isset($method)) $this->assertEquals($method, $result['method']);
if (isset($uri)) $this->assertEquals($uri, $result['uri']);
if (isset($version)) $this->assertEquals($version, $result['version']);
}

public function badStartLines()
{
return [
[''],
['haha'],
['GET 둘 셋'],
['POST 둘 셋'],
['HEAD 둘 셋'],
['HEAD / 셋'],
['HEAD /index 셋'],
];
}

/**
* @dataProvider badStartLines
* @expectedException Exception
* @param $badMsg
*/
public function testInvalidStartLineCausesException($badMsg)
{
new HttpMessageParser($badMsg);
}
}

반드시 통과하는 완벽한 시작줄의 포맷을 ‘GET / HTTP/1.1’으로 정했는데,

이는 위의 테스트에서도 사용하기 때문에 중복이라 PERFECT_START_LINE라는 상수로 뺐다.

testMessageParsedAndPlacedIntoRequiredFields()에는,

  • 현재 8개의 HTTP 메소드이면
  • 슬래시(/)로 시작하는 문자열은
  • HTTP/정수.정수 형태의 포맷이면

통과한다.

이제 애초에 문제가 있다고 진단했던 testInvalidStartLineCausesException()을 손보자.

원하지 않는 포맷이 들어올 때 예외를 발생시키는 것도 엄연히 기능이므로 몇가지 주요 정책은 간단히 남겨두겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @expectedException Exception
*/
public function testInvalidMethodCausesException()
{
$this->assertValidStartLine('NotAMethod', null, null);
}

/**
* @expectedException Exception
*/
public function testInvalidURICausesException()
{
$this->assertValidStartLine(null, 'StartsWithChar', null);
}

/**
* @expectedException Exception
*/
public function testInvalidHTTPVersionFormatCausesException()
{
$this->assertValidStartLine(null, null, 'HTTP/2');
}

header 모으기

시작줄 다음에 등장하는 건 헤더 영역인데, 헤더의 끝이 어디냐 하면 공백이 나올 때다.

1
2
3
4
5
GET / HTTP/1.1
헤더1
헤더2

메시지 본문

잘린 메시지 본문에서 시작줄 이후 공백이 등장할 때까지 모두 헤더로 넣으면 된다.

HttpMessageParserparse() 메소드에서 전체 메시지를 줄단위로 나누는 일을 한다.

그 뒤로 시작줄을 검증하는 메소드가 있는데…어…이상하다.

시작줄을 자르는 행위가 보여야 할 것 같은데?

parse() 메소드의 흐름은

  1. 줄단위로 자른다
  2. 시작줄을 공백으로 나눈다
  3. 헤더를 모은다
  4. 메시지 바디를 넣는다

정도로 요약되어야 할 것 같은데 뜬금없이 시작줄을 검증하고 앉았다.

splitStartLineInto3Components()parse() 안으로 옮기고, 검증 기능은 그 내부로 옮기자.

HttpMessageParser를 재구성한 버전으로 새출발하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<?php

class HttpMessageParser
{
private $message = '';
private $messageLines = [];
private $result = [
'start_line' => '',
'method' => '',
'uri' => '',
'version' => '',
'headers' => '',
'body' => '',
];

private $HTTPMethods = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE',];

public function __construct(string $message)
{
$this->message = $message;
$this->parse();
}

private function parse(): void
{
$this->splitMessageIntoLines();

$this->splitStartLineInto3Components();
}

/**
* 메시지를 \r\n 혹은 \n으로 나누기
*/
private function splitMessageIntoLines()
{
$this->messageLines = preg_split('/(\r\n|\n)/', $this->message);
//각 줄의 양 끝 공백은 제거한다
array_walk($this->messageLines, function (&$item) {
$item = trim($item);
});
}

private function splitStartLineInto3Components(): void
{
//시작줄은 있어야 한다
if (empty($this->messageLines) || empty($this->messageLines[0])) {
$this->throwInvalidMessageFormat();
}

//시작줄을 whitespace로 나누기
$startLine = $this->messageLines[0];
$startLineSplit = preg_split('/\s/', $startLine);

if (empty($startLineSplit) || count($startLineSplit) !== 3) {
$this->throwInvalidMessageFormat();
}

$this->result['method'] = $startLineSplit[0];
$this->result['uri'] = $startLineSplit[1];
$this->result['version'] = $startLineSplit[2];

$this->verifyStartLine();
}

private function throwInvalidMessageFormat(): void
{
throw new Exception('Invalid message format.');
}

private function verifyStartLine(): void
{
$this->verifyStartLineMethod();
$this->verifyStartLineURI();
$this->verifyStartLineVersion();
}

private function verifyStartLineMethod(): void
{
if (empty($this->result['method']) || !in_array($this->result['method'], $this->HTTPMethods)) {
$this->throwInvalidMessageFormat();
}
}

private function verifyStartLineURI(): void
{
if (empty($this->result['uri']) || strpos($this->result['uri'], '/') !== 0) {
$this->throwInvalidMessageFormat();
}
}

private function verifyStartLineVersion(): void
{
if (empty($this->result['version']) || !preg_match('/HTTP\/\d+.\d+/', $this->result['version'])) {
$this->throwInvalidMessageFormat();
}
}

public function getResult(): array
{
return $this->result;
}
}

다시 실패-성공-리팩토링의 궤도 안으로

여차저차 정리됐으니 진짜 헤더를 모으는 작업을 시작하자. 테스트부터.

1
2
3
4
5
6
7
8
9
10
11
public function testHeadersMayExistAfterStartLineAndBeforeEmptyLine()
{
$msg = self::PERFECT_START_LINE . PHP_EOL;
$oneHeader = 'IAmAHeader';

$msg .= $oneHeader;
$parser = new HttpMessageParser($msg);
$result = $parser->getResult();

$this->assertEquals(1, count($result['headers']));
}

실패하려고 만든 테스트가 성공하고 말았다!

result에 headers가 빈 문자열로 들어가있었기 때문. result 내 headers의 기본값을 빈 배열로 넣어주자.

1
Failed asserting that 0 matches expected 1.

Yes! 싪패했으니 테스트가 통과할 정도만 메소드를 만들어본다.

parse()parseHeaders()라는 메소드 추가.

1
2
3
4
5
6
private function parseHeaders()
{
if (isset($this->messageLines[1])) {
$this->result['headers'][] = $this->messageLines[1];
}
}

모든 테스트가 성공하니 또 실패하는 테스트 작성. 이번엔 헤더 두개.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function testHeadersMayExistAfterStartLineAndBeforeEmptyLine()
{
$msg = self::PERFECT_START_LINE;

$msg .= PHP_EOL . 'firstHeader';
$parser = new HttpMessageParser($msg);
$result = $parser->getResult();
$this->assertEquals(1, count($result['headers']));

$msg .= PHP_EOL . 'secondHeader';
$parser = new HttpMessageParser($msg);
$result = $parser->getResult();
$this->assertEquals(2, count($result['headers']));
}

또 통과하도록 메소드 수정.

1
2
3
4
5
6
7
8
private function parseHeaders()
{
if (isset($this->messageLines[1])) {
for ($i = 1; $i < count($this->messageLines); $i++) {
$this->result['headers'][] = $this->messageLines[$i];
}
}
}

헤더가 전혀 없을 수도 있겠지?

헤더가 없을 때 실패하는 구문 추가:

1
2
3
4
$msg = self::PERFECT_START_LINE . PHP_EOL . PHP_EOL . 'endOfMessage';
$parser = new HttpMessageParser($msg);
$result = $parser->getResult();
$this->assertEquals(0, count($result['headers']));

이를 통과하는 코드:

1
2
3
4
5
6
7
8
9
10
11
private function parseHeaders()
{
if (isset($this->messageLines[1])) {
for ($i = 1; $i < count($this->messageLines); $i++) {
if (empty($this->messageLines[$i])) {
break;
}
$this->result['headers'][] = $this->messageLines[$i];
}
}
}

사실 여기서 각 헤더도 첫번째 콜론(:)을 기준으로 key, value를 나눠야 한다.

아직 그걸 어디다 쓸 지는 모르는 척을 하기 위해 이대로 두기로 한다.

  • 아마도 가장 먼저 쓰일 곳은 브라우저의 캐시를 지원할 때일 것이다. 이 속도라면 50회 정도는 되어야겠군…

Message Body

메시지 본문은 가져오기 쉽다. 헤더까지 가져온 다음 빈줄하나가 존재하면 나머지는 모두 메시지 본문이다.

다만 줄단위로 나눴던 나머지 메시지를 다시 붙여서 넣어야 한다.

이때 문제가 발생하겠지만, 걱정은 나중에 하고 메시지 본문을 result에 채워보자.

실패하는 테스트를 만들기 전 기초를 만들어 두자.

1
2
3
4
5
6
public function testBodyMayExistAfterHeaders()
{
$msgBeforeBody = self::PERFECT_START_LINE . PHP_EOL . 'firstHeader';
$result = (new HttpMessageParser($msgBeforeBody))->getResult();
$this->assertEmpty($result['body']);
}

메시지가 헤더 파싱까지만 존재하기 때문에 메시지 본문은 비어있는 게 당연하다. 테스트는 통과한다.

이제 본문이 존재할 때 실패하는 테스트 작성.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function testBodyMayExistAfterHeaders()
{
$msgBeforeBody = self::PERFECT_START_LINE . PHP_EOL . 'firstHeader';
$result = (new HttpMessageParser($msgBeforeBody))->getResult();
$this->assertEmpty($result['body']);

$msgBeforeBody .= PHP_EOL . 'secondHeader';
$msgBeforeBody .= PHP_EOL;//empty line

$bodyText = 'somebody to love';
$msg = $msgBeforeBody . PHP_EOL . $bodyText;
$result = (new HttpMessageParser($msg))->getResult();
$this->assertEquals($bodyText, $result['body']);
}

parse() 메소드에

1
$this->parseBody();

한줄 추가하고 테스트를 통과시키는 코드 작성.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private function parseBody()
{
//시작줄 + 헤더
$emptyLineIndex = 1 + count($this->result['headers']);

if (!isset($this->messageLines[$emptyLineIndex]) ||
$this->messageLines[$emptyLineIndex] !== ''
) {
return;
}

$bodyIndex = $emptyLineIndex + 1;
if (isset($this->messageLines[$bodyIndex])) {
for ($i = $bodyIndex; $i < count($this->messageLines); $i++) {
$this->result['body'] = $this->messageLines[$i];
}
}
}

가운데 쯤 빈줄을 확인하는 코드는 사실 필요 없다. 빈줄이 없으면 다 헤더로 들어갔을테니까.

하지만 메시지 본문 앞에 공백이 있고 그래서 시작줄, 헤더 다음 1을 추가하는 코드($bodyIndex = $emptyLineIndex + 1;)를 이해하기 쉽도록 남겨두기로 했다.

이제 다 끝인가 싶겠지만 몇가지 문제가 있다.

첫번째로, 본문에 개행이 들어가 있을 경우 제대로 동작하지 않는다.

테스트로 확인해보면,

1
2
3
4
5
$bodyText .= PHP_EOL . 'Help! I need somebody';
$bodyText .= PHP_EOL . 'Help! Not just anybody';
$msg = $msgBeforeBody . PHP_EOL . $bodyText;
$result = (new HttpMessageParser($msg))->getResult();
$this->assertEquals($bodyText, $result['body']);

통과하기.

1
2
3
4
5
6
7
8
$bodyIndex = $emptyLineIndex + 1;
if (isset($this->messageLines[$bodyIndex])) {
$partsOfBody = [];
for ($i = $bodyIndex; $i < count($this->messageLines); $i++) {
$partsOfBody[] = $this->messageLines[$i];
}
$this->result['body'] = implode(PHP_EOL, $partsOfBody);
}

또 하나의 문제는 애초에 메시지 본문을 \r\n 혹은 \n으로 줄단위로 나눴기 때문에

1
$this->messageLines = preg_split('/(\r\n|\n)/', $this->message);

이 놈을 다시 붙여줄 때는 \r\n으로 붙여야 할지 \n으로 붙여야 할지 모른다는 점이다.

이를 해결하려면 뭐로 나눴는지 기억하는 것도 방법이지만 메시지 본문 안에 \r\n 혹은 \n가 섞여 있다면 이 또한 문제다.

결국 전체 메시지에서 공백줄이 나타난 이후를 모두 잘라내는 쪽으로 코드를 수정해야 한다.

가독성 때문에 정규식을 쓰기 싫었지만, 쉽게 해결된다.

1
2
3
4
private function parseBody()
{
$this->result['body'] = preg_match('/(?:.+?)(?:\r\n|\n){2}(.*)/s', $this->message, $matches) ? $matches[1] : '';
}
1
/(?:.+?)(?:\r\n|\n){2}(.*)/s

이 정규식을 간단히

1
/(아무 문자열)(개행){2개}(나머지문자열)/개행을포함

정도로 표현할 수 있겠다.

괄호 안의 ?:는 캡쳐를 하지 않는다는 의미로, 만약 정규식을

1
/(.+?)(\r\n|\n){2}(.*)/s

처럼 쓴다면 $matches[1]가 아니라 $matches[3]으로 변경해야 할 것이다.

결론

어떻게든 돌아가는 코드를 만들어 놓기는 했지만 썩 마음에 들진 않는다.

무조건 전체 HTTP 메시지를 받아 한번에 파싱한 다음 결과를 리턴하는 구조인데,

나중에 스트리밍을 고려한다면 서버에 전송되는 메시지를 그때 그때 파서에 전달하고 파서에서는 받은 문자열을 버퍼에 쌓는 동시에 파싱을 진행해야할 것이다.

아직 해당 요구사항이 발견되지 않았다 치더라도,

분리하는 로직에서 개행문자로 한번에 나눠놓지 말고 개행이 나타나는 곳까지 읽은 후 적당한 위치에 넣어주기만 해도 훨씬 빠른 처리가 될 것이다.

하지만 난 그대로 둘 것이다.

다시 개발하기 싫으니까!


Season 1, 끝.