Web/Spring

[Tomcat 구현하기] 2. HttpRequest, HttpResponse, RequestMapper, Controller

 

이번에는 지난번에 구현한 톰캣을 이용해 요구사항을 구현해보겠습니다.

 

 

다음과 같이 기능 요구사항이 주어졌습니다.

 

기능요구사항

  1. GET /login 요청에 로그인 페이지를 보여준다.
  2. GET /register 요청에 회원가입 페이지를 보여준다.
  3. POST /register , body를 포함한 요청에 회원가입을 시키고 login 페이지로 redirect 시킨다.
  4. POST /login 요청에 로그인 처리를 한다.
    - 서버에서 세션을 생성해 로그인 정보를 저장한다.
    - 쿠키에 JSESSION 아이디를 담아서 로그인을 유지시킨다.
  5. 로그인 처리가 된 사용자에게는 index.html 페이지를 보여준다.

 

 

 

HttpRequest 구현

socket 에 쓰여진 inputStream을 사용하기 편하도록 HttpRequest로 변환해야 합니다.

HttpReqeust

 

HttpRequest가 가지는 요소들을 포함한 클래스를 만들어 줍니다.

  • HttpStartLine : Method, Path, Version
  • HttpHeaders

 

HttpReqeust

현재 socket에서 BufferReader로 읽어들이기 때문에 정적팩터리메서드로 BufferReader를 통해 HttpRequest를 생성합니다.

package org.apache.coyote.http11.http.request;

import static org.apache.catalina.webutils.IOUtils.readData;
import static org.apache.coyote.http11.header.HttpHeaderType.CONTENT_LENGTH;

import java.io.BufferedReader;
import java.io.IOException;
import org.apache.coyote.http11.header.HttpHeader;
import org.apache.coyote.http11.http.HttpHeaders;

public class HttpRequest {

    private final HttpRequestStartLine startLine;
    private final HttpHeaders headers;
    private final String body;

    private HttpRequest(final HttpRequestStartLine startLine, final HttpHeaders headers, final String body) {
        this.startLine = startLine;
        this.headers = headers;
        this.body = body;
    }

    public static HttpRequest from(final BufferedReader bufferedReader) throws IOException {
        final String startLine = bufferedReader.readLine();
        if (startLine == null) {
            throw new IllegalArgumentException("request가 비어있습니다.");
        }

        final HttpRequestStartLine httpRequestStartLine = HttpRequestStartLine.from(startLine);
        final HttpHeaders headers = HttpHeaders.from(bufferedReader);
        final String body = readBody(bufferedReader, headers);

        return new HttpRequest(httpRequestStartLine, headers, body);
    }

    private static String readBody(final BufferedReader bufferedReader,
                                   final HttpHeaders headers) throws IOException {

        if (!headers.contains(CONTENT_LENGTH.getValue())) {
            return "";
        }

        final int contentLength = convertIntFromContentLength(headers.get(CONTENT_LENGTH.getValue()));
        return readData(bufferedReader, contentLength);
    }

    private static int convertIntFromContentLength(final HttpHeader contentLength) {
        return Integer.parseInt(String.join("", contentLength.getValues()));
    }

    public HttpRequestStartLine getStartLine() {
        return startLine;
    }

    public HttpHeaders getHeaders() {
        return headers;
    }

    public String getBody() {
        return body;
    }

    public String getUrl() {
        return startLine.getPath();
    }

    public boolean isGetMethod() {
        return startLine.getHttpMethod().isGet();
    }
}

 

 

HttpStartLine

HttpMethod, path, HttpVersion을 포함하는 라인을 표현하는 클래스입니다.

package org.apache.coyote.http11.http.request;

import java.util.List;
import org.apache.coyote.http11.http.HttpVersion;

public class HttpRequestStartLine {

    private static final int START_LINE_MIN_LENGTH = 3;
    private static final String BLANK_LETTER = " ";

    private final HttpMethod httpMethod;
    private final String path;
    private final HttpVersion httpVersion;

    private HttpRequestStartLine(final HttpMethod httpMethod, final String path, final HttpVersion httpVersion) {
        this.httpMethod = httpMethod;
        this.path = path;
        this.httpVersion = httpVersion;
    }

    public static HttpRequestStartLine from(final String startLine) {
        return parseStartLine(startLine);
    }

    private static HttpRequestStartLine parseStartLine(final String startLine) {
        final List<String> startLineInfos = parseStartLineInfos(startLine);
        final HttpMethod method = HttpMethod.from(startLineInfos.get(0));
        final String path = startLineInfos.get(1);
        final HttpVersion version = HttpVersion.from(startLineInfos.get(2));

        return new HttpRequestStartLine(method, path, version);
    }

    private static List<String> parseStartLineInfos(final String startLine) {
        final List<String> startLineInfos = List.of(startLine.split(BLANK_LETTER));
        validateStartLineLength(startLineInfos);
        return startLineInfos;
    }

    private static void validateStartLineLength(final List<String> startLineInfos) {
        if (startLineInfos.size() < START_LINE_MIN_LENGTH) {
            throw new IllegalArgumentException("요청 정보가 잘못되었습니다.");
        }
    }

    public HttpVersion getHttpVersion() {
        return httpVersion;
    }

    public HttpMethod getHttpMethod() {
        return httpMethod;
    }

    public String getPath() {
        return path;
    }
}

 

 

HttpHeaders

Map (key: String, value: HtepHeader) 을 가지는 HttpHeaders.

package org.apache.coyote.http11.http;

import static org.apache.catalina.webutils.Parser.removeBlank;

import java.io.BufferedReader;
import java.io.IOException;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.coyote.http11.header.HttpHeader;
import org.apache.coyote.http11.header.HttpHeaderType;

public class HttpHeaders {
    private Map<String, HttpHeader> headers = new LinkedHashMap<>();

    private static final String COLON_LETTER = ":";

    public HttpHeaders() {
    }

    private HttpHeaders(final Map<String, HttpHeader> headers) {
        this.headers = headers;
    }

    public static HttpHeaders from(final BufferedReader bufferedReader) throws IOException {
        return new HttpHeaders(readAllHeaders(bufferedReader));
    }

    public static HttpHeaders of(final HttpHeader... httpHeaders) {
        final Map<String, HttpHeader> headers = Arrays.stream(httpHeaders)
                .collect(Collectors.toMap(HttpHeader::getHttpHeaderType,
                        httpHeader -> httpHeader,
                        (key, value) -> value,
                        LinkedHashMap::new));
        return new HttpHeaders(headers);
    }

    private static Map<String, HttpHeader> readAllHeaders(final BufferedReader bufferedReader) throws IOException {
        final Map<String, HttpHeader> headers = new LinkedHashMap<>();

        while (true) {
            final String line = bufferedReader.readLine();
            if (line.equals("")) {
                break;
            }
            final List<String> header = parseHeader(line);
            final String headerType = removeBlank(header.get(0));
            final String headerValue = removeBlank(header.get(1));
            final String httpHeaderType = HttpHeaderType.of(headerType);
            headers.put(httpHeaderType, HttpHeader.of(httpHeaderType, headerValue));
        }

        return headers;
    }

    private static List<String> parseHeader(final String line) {
        final List<String> header = List.of(line.split(COLON_LETTER));
        validateHeader(header);
        return header;
    }

    private static void validateHeader(final List<String> header) {
        if (header.size() < 2) {
            throw new IllegalArgumentException("요청 정보가 잘못되었습니다.");
        }
    }

    public boolean contains(final String httpHeaderType) {
        return headers.containsKey(httpHeaderType);
    }

    public HttpHeader get(final String httpHeaderType) {
        return headers.get(httpHeaderType);
    }

    public Set<String> keySet() {
        return headers.keySet();
    }

    public void put(final String httpHeaderType, final HttpHeader httpHeader) {
        headers.put(httpHeaderType, httpHeader);
    }

    public void add(final HttpHeader httpHeader) {
        headers.put(httpHeader.getHttpHeaderType(), httpHeader);
    }
}

 

HttpRequest 구현은 끝났습니다.

BufferReader를 통해 HttpRequst를 생성하면 됩니다.

    @Override
    public void process(final Socket connection) {
        try (final var inputStream = connection.getInputStream();
             final var outputStream = connection.getOutputStream()) {

            final InputStreamReader inputStreamReader = new InputStreamReader(inputStream, UTF_8);
            final BufferedReader bufferedReader = new BufferedReader(inputStreamReader);

            final HttpRequest httpRequest = HttpRequest.from(bufferedReader);
            ...

            outputStream.write(response.getBytes());
            outputStream.flush();
        } catch (IOException | UncheckedServletException e) {
            log.error(e.getMessage(), e);
        }
    }

 

 

HttpResponse

package org.apache.coyote.response;

import static org.apache.coyote.header.HttpHeaderType.CONTENT_LENGTH;
import static org.apache.coyote.response.HttpStatus.OK;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.apache.coyote.HttpVersion;
import org.apache.coyote.header.HttpHeader;
import org.apache.coyote.header.HttpHeaders;

public class HttpResponse {

    private static final String NEW_LINE_LETTER = "\r\n";
    private static final String EMPTY_LETTER = "";
    private static final String BLANK_LETTER = " ";
    private static final String COLON_LETTER = ":";
    private static final String SEMI_COLON_LETTER = ";";
    private static final HttpVersion DEFAULT_HTTP_VERSION = HttpVersion.HTTP11;

    private HttpVersion httpVersion = DEFAULT_HTTP_VERSION;
    private HttpStatus httpStatus = OK;
    private HttpHeaders headers = new HttpHeaders();
    private String body = "";

    public String getBody() {
        return body;
    }

    public void setHttpStatus(final HttpStatus httpStatus) {
        this.httpStatus = httpStatus;
    }

    public void addHeader(final String name, final String value) {
        headers.add(HttpHeader.of(name, value));
    }

    public void setBody(final String body) {
        final int length = body.getBytes(StandardCharsets.UTF_8).length;
        addHeader(CONTENT_LENGTH.getValue(), String.valueOf(length));
        this.body = body;
    }

    public String generateResponse() {
        final String statusLine = generateStatusLine();
        final String headerLine = generateHeaderLine();
        return String.join(NEW_LINE_LETTER, statusLine, headerLine, EMPTY_LETTER, body);
    }

    private String generateStatusLine() {
        return String.join(BLANK_LETTER,
                httpVersion.getVersion(),
                String.valueOf(httpStatus.getCode()),
                httpStatus.getMessage(),
                EMPTY_LETTER);
    }

    private String generateHeaderLine() {
        final List<String> headers = new ArrayList<>();
        for (String httpHeaderType : this.headers.keySet()) {
            final String header = String.join(COLON_LETTER + BLANK_LETTER, httpHeaderType,
                    String.join(SEMI_COLON_LETTER, this.headers.get(httpHeaderType).getValues()));
            headers.add(header + BLANK_LETTER);
        }
        return String.join(NEW_LINE_LETTER, headers);
    }
}

 

 

 

 

 

각 method, url 요청에 컨트롤러 매핑을 해주겠습니다.

각 요청은 아래와 같이 처리됩니다.

  1. socket에 들어오는 inputStream을 사용하게 편하게 HttpRequest로 변환하기
  2. RequestMapper에 GET /login 요청을 매핑해 컨트롤러 반환하기
  3. Controller에 HttpReqeust, HttpResponse를 같이 넣어주며 service 실행하기
  4. HttpResponse를 socket의 outputStream에 담기

 

그리고 요청에 따라 다른 처리의 분기는 2, 3번만 코드를 추가해주면 됩니다.

 

Http11Processor

package org.apache.coyote.http11;

import static java.nio.charset.StandardCharsets.UTF_8;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import nextstep.jwp.exception.UncheckedServletException;
import org.apache.coyote.Processor;
import org.apache.coyote.http11.http.RequestMapper;
import org.apache.coyote.http11.http.request.HttpRequest;
import org.apache.coyote.http11.http.response.HttpResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Http11Processor implements Runnable, Processor {

    private static final Logger log = LoggerFactory.getLogger(Http11Processor.class);

    private final Socket connection;

    public Http11Processor(final Socket connection) {
        this.connection = connection;
    }

    @Override
    public void run() {
        try {
            log.info("connect host: {}, port: {}", connection.getInetAddress(), connection.getPort());
            process(connection);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            e.printStackTrace();
        }
    }

    @Override
    public void process(final Socket connection) {
        try (final var inputStream = connection.getInputStream();
             final var outputStream = connection.getOutputStream()) {

            final InputStreamReader inputStreamReader = new InputStreamReader(inputStream, UTF_8);
            final BufferedReader bufferedReader = new BufferedReader(inputStreamReader);

            final HttpRequest httpRequest = HttpRequest.from(bufferedReader);
            final Controller controller = RequestMapper.getControllerFrom(httpRequest);
            final HttpResponse httpResponse = new HttpResponse();
            controller.service(httpRequest, httpResponse);
            final String response = httpResponse.generateResponse();

            outputStream.write(response.getBytes());
            outputStream.flush();
        } catch (IOException | UncheckedServletException e) {
            log.error(e.getMessage(), e);
        }
    }
}

 

 

RequestMapper

저는 일단 RequestMapper를 enum으로 관리하고 있습니다.

클래스가 로딩되는 시점에 인스턴스가 생성되기 때문에 Controller를 싱글톤처럼 사용할 수 있습니다.

RequestMapper로부터 반환되는 Controller들은 Controller 인터페이스와 AbstractController 추상클래스를 상속하고 있기 때문에 각 Controller의 service 메서드를 호출하는 것으로 전략패턴을 구현할 수 있습니다.

package org.apache.coyote.http11.http;

import java.util.Arrays;
import java.util.NoSuchElementException;
import java.util.regex.Pattern;
import org.apache.coyote.http11.Controller;
import nextstep.jwp.controller.HomeController;
import nextstep.jwp.controller.LoginController;
import nextstep.jwp.controller.IndexController;
import nextstep.jwp.controller.RegisterController;
import nextstep.jwp.controller.ResourceController;
import org.apache.coyote.http11.http.request.HttpRequest;

public enum RequestMapper {

    HOME(Constants.HOME_URL_REGEX, new HomeController()),
    INDEX(Constants.INDEX_PAGE_REGEX, new IndexController()),
    LOGIN(Constants.LOGIN_REGEX, new LoginController()),
    REGISTER(Constants.REGISTER_REGEX, new RegisterController()),
    RESOURCE(Constants.RESOURCE_URL_REGEX, new ResourceController());

    private final Pattern urlRegex;
    private final Controller controller;

    RequestMapper(final Pattern urlRegex, final Controller controller) {
        this.urlRegex = urlRegex;
        this.controller = controller;
    }

    public static Controller getControllerFrom(final HttpRequest httpRequest) {
        final String url = httpRequest.getUrl();
        return Arrays.stream(RequestMapper.values())
                .filter(it -> matchUrl(url, it))
                .findFirst()
                .orElseThrow(() -> new NoSuchElementException("해당하는 handler가 없습니다. " + url))
                .controller;
    }

    private static class Constants {
        private static final Pattern HOME_URL_REGEX = Pattern.compile("^/$");
        private static final Pattern RESOURCE_URL_REGEX = Pattern.compile("^(/[a-z|A-Z|가-힣|ㄱ-ㅎ|_|0-9|\\-]*)+(\\.[a-z]*)$");
        private static final Pattern LOGIN_REGEX = Pattern.compile("^(/login)(\\?([^#\\s]*))?");
        private static final Pattern REGISTER_REGEX = Pattern.compile("^(/register)(\\?([^#\\s]*))?");
        private static final Pattern INDEX_PAGE_REGEX = Pattern.compile("^(/index\\.html)");
    }

    private static boolean matchUrl(final String url, final RequestMapper requestMapper) {
        return requestMapper.urlRegex.matcher(url).find();
    }
}

 

 

 

로그인기능

 

LoginController

 

SessionAuthorizeService 와 UserService를 싱글톤 객체로 받고있습니다.

 

GET 요청에는

  • 로그인됨 -> index.html redirect
  • 로그인안됨 -> login.html 반환

POST 요청에는

  • UserService를 이용해 로그인 처리
package nextstep.jwp.controller;

import static nextstep.jwp.controller.ResourceUrls.INDEX_HTML;
import static nextstep.jwp.controller.ResourceUrls.LOGIN_HTML;
import static nextstep.jwp.controller.ResourceUrls.UNAUTHORIZED_HTML;

import java.util.Map;
import java.util.NoSuchElementException;
import nextstep.jwp.application.SessionAuthorizeService;
import nextstep.jwp.application.UserService;
import nextstep.jwp.dto.UserLoginRequest;
import org.apache.catalina.session.SessionManager;
import org.apache.catalina.webutils.Parser;
import org.apache.coyote.header.HttpCookie;
import org.apache.coyote.request.HttpRequest;
import org.apache.coyote.response.HttpResponse;

public class LoginController extends ResourceController {

    private final SessionAuthorizeService sessionAuthorizeService = SessionAuthorizeService.getInstance();
    private final UserService userService = UserService.getInstance();

    @Override
    public void doGet(final HttpRequest httpRequest, final HttpResponse httpResponse) {
        if (sessionAuthorizeService.isAuthorized(httpRequest)) {
            setRedirectHeader(httpResponse, INDEX_HTML);
            return;
        }
        setResource(LOGIN_HTML.getValue(), httpResponse);
    }

    @Override
    public void doPost(final HttpRequest httpRequest, final HttpResponse httpResponse) {
        final String body = httpRequest.getBody();
        login(body, httpResponse);
    }

    private void login(final String body, final HttpResponse httpResponse) {
        final Map<String, String> queryParams = Parser.parseQueryParams(body);
        try {
            final UserLoginRequest userLoginRequest = getUserLoginRequest(queryParams);
            userService.login(userLoginRequest);
            final HttpCookie cookie = SessionManager.createCookie();
            setRedirectHeader(httpResponse, INDEX_HTML);
            httpResponse.addHeader("Set-Cookie", cookie.toHeaderValue());
        } catch (IllegalArgumentException exception) {
            setRedirectHeader(httpResponse, UNAUTHORIZED_HTML);
        } catch (NoSuchElementException exception) {
            setRedirectHeader(httpResponse, LOGIN_HTML);
        }
    }

    private UserLoginRequest getUserLoginRequest(final Map<String, String> queryParams) {
        validateLoginParams(queryParams);
        return new UserLoginRequest(queryParams.get("account"),
                queryParams.get("password"));
    }

    private void validateLoginParams(final Map<String, String> queryParams) {
        if (!queryParams.containsKey("account") || !queryParams.containsKey("password")) {
            throw new IllegalArgumentException("account와 password 정보가 입력되지 않았습니다.");
        }
    }
}

 

 

 

 

 

Catalina vs Coyote

 

tomcat에는 catalina와 coyote라는 패키지가 있습니다.

각 패키지의 의미가 무엇이고 어떤 파일들을 가지고 있을까요?

 

Catalina

tomcat의 서블릿 컨테이너입니다.

tomcat은 실제로 tomcat JSP 엔진과 다양한 커넥터를 비롯한 여러 구성요소로 구성되어 있지만 핵심 구성 요소는 Catalina라고 합니다.

Catalina는 tomcat의 실제 서블릿 specification의 구현을 제공합니다. tomcat서버를 시작할 때 실제로는 Catalina를 시작하는 것입니다.

 

connector, manager, mapper, servlets, session 등이 있네요.

 

저도 비슷하게 패키지를 구성했습니다.

my apche catalina

 

 

 

 

 

 

Coyote

HTTP1.1, 2 프로토콜을 웹 서버로 지원하는 tomcat용 커넥터 구성요소 입니다.

이를 통해 Java 서블릿 또는 Catalina가 로컬 파일을 HTTP 문서로 제공하는 일반 웹 서버로도 작동할 수 있습니다.

Coyote는 특정 TCP 포트에서 서버로 들어오는 연결을 수신하고 Tomcat 엔진에 요청을 전달하여 요청을 처리하고 클라이언트에게 응답을 보냅니다.

 

http11, http2, Processor, Request, Response 등이 있습니다.

 

저도 비슷하게 패키지를 구성했습니다.

Header는 사실 RequestHeader, ResponseHeader로 나뉠 수 있는데 저는 공통으로 구성해서 밖으로 뺐습니다.

my apache coyote

 

 

 

 

이렇게 웹 어플리케이션이 어떻게 클라이언트로부터 데이터를 받고 가공하고 보내주는지를 한번 구현해보았습니다.

 

Tomcat은 Servlet Container의 역할을 하는데요.

Connector를 생성하고, Socket을 통해 클라이언트로부터 데이터를 받고 Controller를 매핑해 데이터를 가공해주고 있었습니다.

각 부분들을 직접 구현해보며 어떤 역할을 하는지도 더 이해할 수 있었습니다.

 

 

 

 

다음에는?

그런데 지금의 구조는 약간의 문제가 존재합니다.

Controller가 추가될 때마다 RequestMapper에도 url을 추가해주어야 합니다.

사실 우리는 Spring을 사용할때 Controller가 추가된다고 Spring의 클래스파일을 수정하지는 않고있죠.

 

다음에는 Spring MVC가 어떻게 이런 불편함들을 해결해주는지 직접 Spring MVC를 구현하며 알아보겠습니다.

 

 

 

 

 

 

 

참고자료

tecoble https://tecoble.techcourse.co.kr/post/2021-05-24-apache-tomcat/

 

 

코드 저장소

https://github.com/dongho108/jwp-dashboard-http/tree/step234