- [node] http 모듈 커스텀하기 3편2023년 12월 28일 01시 36분 04초에 업로드 된 글입니다.작성자: @kimyu0218
http 파서에 개선할 점이 아주 많아요. 추후에 개선해 나갈 예정이니 과정만 참고 부탁드립니다.
https://github.com/kimyu0218/custom-http이번 포스트에서는 2편에서 다루지 못한 `HttpRequest`를 작성할 예정이다.
요청은 클라이언트가 서버로 보내는 메시지이기 때문에 HTTP 프로토콜을 준수하고 있는지 검사해야 한다. 유효성 검사가 끝나면 메서드를 이용하여 요청에 들어있는 정보를 쉽게 사용할 수 있도록 `HttpRequest`로 파싱할 것이다.
HttpRequest를 위한 파서 만들기
요청은 크게 request-line, header, message-body로 구분된다.
request-line에는 HTTP 메서드, 요청 대상, HTTP 버전이 들어있다. 요청 대상, 즉 URI에는 query string이나 param이 붙을 수 있는데, 이를 위한 파서를 별도의 파일로 작성할 것이다.지금부터 만들 네 가지 파서
- query 파서 : URI의 query string
- param 파서 : URI의 param
- header 파서
- request 파서
query 파서
query string은 웹 요청에 추가적인 데이터를 전달하기 위한 일련의 문자열이다. 주로 URI 뒤에 `?`를 붙이고 그 뒤에 키와 값의 쌍으로 이루어진 매개변수들이 `&`로 구분된다.
export default function parseQuery(queryString: string): Map<string, string> { return queryString .split('&') .reduce<Map<string, string>>((acc: Map<string, string>, curr: string) => { const [key, value] = curr.split('='); acc.set(key, value); return acc; }, new Map()); }
위 함수의 queryString은 `?`을 제외한 문자열을 의미한다. `&`를 기준으로 split하여 `key=value`의 배열로 만든 후, 다시 등호를 기준으로 split 하여 맵에 저장한다.
param 파서
param은 리소스를 식별하기 위한 추가적인 정보를 제공하기 위해 사용한다. `/cats/123`이라는 URI가 있다고 가정해보자. 여기서 `123`은 `cats` 리소스에 대한 param으로, 123번 고양이를 가리킨다.
export function parseParam(uri: string, route: string): Map<string, string> { const uriSegments: string[] = uri.split('/').slice(1); const routeSegments: string[] = route.split('/').slice(1); const idxs: number[] = routeSegments .map((item: string, idx: number) => (item[0] === ':' ? idx : undefined)) .filter((item: number | undefined) => item !== undefined); return idxs.reduce<Map<string, string>>( (acc: Map<string, string>, curr: number) => { const key: string = routeSegments[curr].slice(1); const value: string = uriSegments[curr]; acc.set(key, value); return acc; }, new Map(), ); }
param은 query string과 달리 URI 경로 자체에 포함되어 있기 때문에 경로와 함께 파싱해야 한다. 그 전에 express의 동적 라우트를 알고 있어야 한다. (express가 node에서 널리 사용되는 프레임워크이기 때문에 express의 동적 라우트를 선택했다.) express에서 `/cats/:id`라는 라우트를 정의하면, `:id`는 동적으로 변하는 값을 의미한다.
param을 파싱하는 과정은 다음과 같다.ex. 123번째 고양이를 가리킨다
- uri : /cats/123
- route : /cats/:id
1. uri와 route를 `/`를 기준으로 split 한다.
2. routeSegments에서 동적 라우트가 없는지 확인하고, 있다면 인덱스를 기억한다.
3. 인덱스를 바탕으로 routeSegments에서는 key를, uriSegments에서는 param을 추출하여 key-value를 만든다.header 파서
이번에는 header를 위한 파서를 작성해볼 것이다. header는 `field-name: field-value` 형태를 가지며, request-line과 message-body 전의 CRLF 사이에 위치한다.
export function parseHeader(header: string): Map<string, string> { return header .split('\n') .reduce<Map<string, string>>((acc: Map<string, string>, curr: string) => { const match: RegExpExecArray = HEADER.exec(curr); if (match) { const { fieldName, fieldValue } = match.groups; acc.set(fieldName, fieldValue.trim()); } return acc; }, new Map()); }
header는 HTTP 요청의 header 전문이다. header를 줄바꿈 문자를 기준으로 split하여 한 줄씩 key-value로 추출할 것이다. header가 항상 올바른 형식으로 전송된다고 보장할 수 없기 때문에 HEADER라는 정규식을 활용했다.
// Message Headers: field-name: field-value export const HEADER: RegExp = new RegExpBuilder(`^(?<fieldName>[\\w-]+):`) // field-name .add(SP) .add('(?<fieldValue>[^\r\n]+)') // field-value .build();
field-name을 위한 정규식과 field-value를 위한 정규식을 작성하고, field-name 뒤에 콜론과 공백 문자를 추가했다. field-name의 경우 어떤 문자를 사용할 수 있는지 모호하여 \r이나 \n이 나타나기 전까지로 정의했다.
request 파서
이제 HTTP 요청을 파싱하는 request 파서에서 앞서 나온 파서들을 호출하여 `HttpRequest`를 위한 정보를 추출할 것이다. (param의 경우, 파싱 단계에서 route를 알 수 없기 때문에 호출하지 않는다.)
request-line, header, message-body 구분 방법
- request-line : HTTP의 첫번째 줄
- header : request-line을 제거하고 CRLF가 두 번* 나오기 전까지의 구간
- message-body : CRLF가 두 번* 나온 이후의 구간
export default function parseRequest(request: string): ParsedRequest { // parse request-line const requestLine: string = request.split(NEW_LINE).at(0) + CRLF; const { method, uri, version } = parseRequestLine(requestLine); if (!method || !uri || !version) { throw new InvalidRequestLineError(); } const [headerStr, bodyStr]: string[] = request .replace(requestLine, '') .split(EMPTY_LINE); // parse header const header: Map<string, string> = parseHeader(headerStr); // parse cookies const cookieValue: string = header.get('Cookies'); const cookies: Map<string, string> = cookieValue ? parseCookies(cookieValue) : new Map(); // GET request if (method === METHODS.GET) { const [path, queryString] = uri.split('?'); const query: Map<string, string> = queryString // parse query string ? parseQuery(queryString) : new Map(); return { method, path: path, version, cookies, query }; } // x-www-form-urlencoded const contentType: string = header.get('Content-Type'); if (contentType === X_WWW_FORM_URLENCODED) { const body: Map<string, string> = parseQuery( decodeURIComponent(bodyStr?.replace(/\+/g, ' ') ?? ''), ); return { method, path: uri, version, cookies, query: undefined, body }; } const body: any = bodyStr ? JSON.parse(bodyStr) : ''; return { method, path: uri, version, cookies, query: undefined, body }; }
HTTP 요청 파싱 과정이다.1. request-line을 떼어낸다.
1-1. request-line의 유효성을 검사하고, method / uri / version을 추출한다.
1-2. 셋 중에 하나라도 없는 경우, 에러를 throw 한다.
2. EMPTY_LINE*을 기준으로 header와 body를 구분한다.
3. header를 파싱한다.
3-1. header에 쿠키 정보가 있는 경우, 쿠키를 파싱한다.
4. GET 요청인 경우, 다음 과정 후에 파싱 결과를 반환한다.
4-1. query string이 있다면 파싱한다.
5. 다른 메서드에 대해 message-body를 검사한다.
5-1. header에서 콘텐츠 타입을 추출한다.
5-2. x-www-form-urlencoded의 경우, message-body가 query-string 형태로 전송되므로 별도의 파싱 과정을 거친다.
5-3. 나머지 형식에 대해 JSON 형태로 파싱한다.
*EMPTY_LINE : CRLF가 두 번 나오는 구간🚨 POST, PUT, PATCH 요청이 아닌 경우 message-body가 없기 때문에 곧바로 파싱 결과를 반환해야 하는데 이에 대한 처리가 누락되었다. 그리고 message-body가 항상 x-www-form-urlencoded나 json 형태로 오는 게 아니기 때문에 분기 처리가 조금 더 필요하다.
HttpRequest 작성하기
드디어 `HttpRequest`를 작성할 수 있다..! 요청도 메시지 구조 자체는 동일하기 때문에 2편에서 작성한 `HttpMessage`를 상속하여 작성해준다.
export class HttpRequest extends HttpMessage { private method: string; private path: string; private query: Map<string, string> = new Map(); private cookies: Map<string, string> = new Map(); private params: Map<string, string> = new Map(); constructor(data: string) { super(); const { method, path, version, cookies, query, body } = parseRequest(data); this.method = method; this.path = path; this.setVersion(version); this.cookies = cookies; this.query = query; if (body) { this.setMessageBody(body); } } getMethod(): string { return this.method; } getPath(): string { return this.path; } getQuery(): Map<string, string> { return this.query; } getCookie(key: string): string { return this.cookies.get(key) ?? null; } getParam(key: string): string { return this.params.get(key) ?? null; } setParam(key: string, value: string): HttpRequest { this.params.set(key, value); return this; } }
내가 목표로 한 핵심 기능은 모두 구현했다. 파서에서 부족한 점은 위에서 인용으로 적어놓았다. http 모듈 포스트는 여기서 마치겠지만 앞서 말한 부분은 추후에 개선할 예정이다.
예비 개발자의 얼렁뚱땅http 모듈 뜯어보기 끝'backend > 내가 만든 패키지' 카테고리의 다른 글
[node] http 모듈 커스텀하기 2편 (1) 2023.12.24 [node] http 모듈 커스텀하기 1편 (1) 2023.12.23 [node] Swagger 데코레이터 어디까지 커스텀 해봤니? (0) 2023.12.23 다음글이 없습니다.이전글이 없습니다.댓글