kimyu0218
  • [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가 두 번* 나온 이후의 구간
    *CRLF가 두 번 나오는 구간 : 마지막 header 필드의 CRLF와 header와 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 모듈 뜯어보기 끝

    댓글