Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[톰캣 구현하기 - 1,2단계] 엔델(김민준) 미션 제출합니다. #331

Merged
merged 11 commits into from
Sep 5, 2023
50 changes: 50 additions & 0 deletions tomcat/src/main/java/org/apache/cookie/Cookie.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package org.apache.cookie;

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public final class Cookie {

private final Map<String, String> cookies;

public static Cookie parse(final String cookieValue) {
final Map<String, String> cookies = new HashMap<>();
Arrays.stream(cookieValue.split("; "))
.forEach(cookie -> {
final String[] keyValue = cookie.split("=");
cookies.put(keyValue[0], keyValue[1]);
});
return new Cookie(cookies);
}

public static Cookie newInstance() {
return new Cookie(new HashMap<>());
}
Copy link
Member

@iamjooon2 iamjooon2 Sep 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

미리 생성해둔 객체를 사용하면 좀 더 효율적이지 않을까라는 생각이 들었습니다! (사실 저도 놓친 부분입니다😂)
엔델은 어떻게 생각하시나요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 미처 이해를 정확히 하지 못했는데요, 개별적인 빈 캐시를 만들기 위해 만든 메서드입니다.
일단 직관적인 네이밍이 아니어서 empty()로 변경해보았습니다.

Copy link
Member

@iamjooon2 iamjooon2 Sep 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 제가 잘못봤네요!
해당 메서드를 통해 빈 쿠키를 반환하는 데만 사용하는 것으로 보고 단 코멘트입니다
혼란을 드려서 죄송합니다🙇🏻‍♂️


private Cookie(Map<String, String> cookies) {
this.cookies = cookies;
}

public void addCookie(final String key, final String value) {
cookies.put(key, value);
}

public boolean containsKey(final String key) {
return cookies.containsKey(key);
}

public String generateCookieHeaderValue() {
final List<String> cookies = this.cookies.entrySet()
.stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(Collectors.toList());
return String.join("; ", cookies);
}

public boolean isEmpty() {
return cookies.isEmpty();
}
}
227 changes: 217 additions & 10 deletions tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java
Original file line number Diff line number Diff line change
@@ -1,18 +1,60 @@
package org.apache.coyote.http11;

import nextstep.jwp.db.InMemoryUserRepository;
import nextstep.jwp.exception.UncheckedServletException;
import nextstep.jwp.model.User;
import org.apache.cookie.Cookie;
import org.apache.coyote.Processor;
import org.apache.session.Session;
import org.apache.session.SessionManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;

public class Http11Processor implements Runnable, Processor {

private static final Logger log = LoggerFactory.getLogger(Http11Processor.class);
private static final String HTTP_METHOD = "HTTP_METHOD";
private static final String URL = "URL";
private static final String HTTP_VERSION = "HTTP_VERSION";
private static final Map<String, String> CONTENT_TYPE;
private static final Map<String, String> HTTP_STATUS;

private final Socket connection;
private final SessionManager sessionManager = new SessionManager();

static {
final Map<String, String> contentType = new HashMap<>();
contentType.put("html", "text/html;charset=utf-8");
contentType.put("css", "text/css");
contentType.put("js", "application/javascript");
contentType.put("ico", "image/x-icon");
CONTENT_TYPE = Collections.unmodifiableMap(contentType);
final Map<String, String> httpStatus = new HashMap<>();
httpStatus.put("OK", "200 OK");
httpStatus.put("CREATED", "201 CREATED");
httpStatus.put("FOUND", "302 FOUND");
httpStatus.put("NOT_FOUND", "404 NOT FOUND");
httpStatus.put("UNAUTHORIZED", "401 UNAUTHORIZED");
HTTP_STATUS = Collections.unmodifiableMap(httpStatus);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enum이라는 선택지도 있었을 텐데, Map을 사용하신 이유를 여쭙고 싶습니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일단 직관적인 구현을 위해 이렇게한 것 같습니다.
현재는 두 ContentType, HttpStatus를 enum화 했습니다.

}

public Http11Processor(final Socket connection) {
this.connection = connection;
Expand All @@ -28,20 +70,185 @@ public void run() {
public void process(final Socket connection) {
try (final var inputStream = connection.getInputStream();
final var outputStream = connection.getOutputStream()) {
final BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BufferedReader도 try-with-resource 구문 안으로 넣어서 사용 후 자원종료를 해줄 수 있을 것 같아요!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생각하고 있었는데 미처 적용하지 못했네요 감사합니다~

final Map<String, String> requestLine = extractRequestLine(reader);
final Map<String, String> httpRequestHeaders = extractRequestHeaders(reader);
final Map<String, String> requestBody = extractRequestBody(httpRequestHeaders, reader);
final String method = requestLine.get(HTTP_METHOD);
final String path = parsingPath(requestLine.get(URL));
final Map<String, String> parsingQueryString = parsingQueryString(requestLine.get(URL));

final var responseBody = "Hello world!";

final var response = String.join("\r\n",
"HTTP/1.1 200 OK ",
"Content-Type: text/html;charset=utf-8 ",
"Content-Length: " + responseBody.getBytes().length + " ",
"",
responseBody);

final String response = controller(httpRequestHeaders, method, path, parsingQueryString, requestBody);
outputStream.write(response.getBytes());
outputStream.flush();
} catch (IOException | UncheckedServletException e) {
log.error(e.getMessage(), e);
}
}

private String controller(final Map<String, String> httpRequestHeader, final String method, final String path, final Map<String, String> queryString, Map<String, String> requestBody) throws IOException {
Copy link
Member

@iamjooon2 iamjooon2 Sep 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

마지막 requestBody만 final이 빠져있네요! ( 달게 없을 때 적는 비겁한 리뷰입니다 )

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

붙였습니다!

if ("GET".equals(method) && "/".equals(path)) {
return generateResponseBody("OK", "html", "Hello world!");
}
if ("GET".equals(method) && "/index.html".equals(path)) {
return generateResult(path, "OK");
}
if ("GET".equals(method) && "/login".equals(path) && queryString.size() == 2) {
final Optional<User> user = InMemoryUserRepository.findByAccount(queryString.get("account"));
if (user.isEmpty()) {
return generateResult("/401.html", "UNAUTHORIZED");
}
if (user.get().checkPassword(queryString.get("password"))) {
log.info("user : {}", user);
Cookie cookie = Cookie.newInstance();
final String sessionId = UUID.randomUUID().toString();
final Session session = new Session(sessionId);
session.setAttribute("user", user);
sessionManager.add(session);
cookie.addCookie("JSESSIONID", sessionId);
return generateRedirect("/index.html", "FOUND", cookie);
}
return generateResult("/401.html", "UNAUTHORIZED");
}
if ("GET".equals(method) && "/login".equals(path)) {
Cookie cookie = Cookie.newInstance();
if (httpRequestHeader.containsKey("Cookie")) {
cookie = Cookie.parse(httpRequestHeader.get("Cookie"));
}
if (cookie.containsKey("JSESSIONID")) {
return generateRedirect("/index.html", "FOUND");
}
return generateResult(path, "OK");
}
if ("POST".equals(method) && "/register".equals(path)) {
InMemoryUserRepository.save(new User(requestBody.get("account"), requestBody.get("password"), requestBody.get("email")));
return generateRedirect("/index.html", "FOUND");
}
if ("GET".equals(method) && "/register".equals(path)) {
return generateResult(path, "OK");
}

return generateResult(path, "OK");
}

private String generateRedirect(final String location, final String statusCode) {
return String.join("\r\n",
"HTTP/1.1 " + HTTP_STATUS.get(statusCode) + " ",
"Location: " + location + " ");
}

private String generateRedirect(final String location, final String statusCode, final Cookie cookie) {
return String.join("\r\n",
"HTTP/1.1 " + HTTP_STATUS.get(statusCode) + " ",
"Set-Cookie: " + cookie.generateCookieHeaderValue() + " ",
"Location: " + location + " ");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공백, 개행문자, 헤더 key가 다양한 곳에서 쓰일 수 있을 것 같아서 상수화 할 수 있을 것 같습니다

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일단은 헤더의 Key 부분은 HttpHeaders 클래스를 만들어 상수화했습니다.
추가로 캡슐화를 하고 싶은데 이 부분은 ResponseBody를 리팩토링 하면서 같이 하면 좋을 것 같아서 남겨두었습니다.
지금 당장 이부분만 하려니 엄두가 안나네요

}

private String generateResult(final String path, final String statusCode) throws IOException {
return generateResult(path, statusCode, Cookie.newInstance());
}

private String generateResult(final String path, final String statusCode, final Cookie cookie) throws IOException {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

쿠키를 담아 보낼 경우 generateResult가 아닌 generateRedirect를 사용중이군요!
두 메서드를 합칠 수 있을 것 같습니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 구현에 어려움이 있어서
쿠키를 보내면서 보내는 경우 / 쿠키를 보내지 않으면서 보내는 경우 / 쿠키를 보내면서 리다이렉트하는 경우 / 쿠키를 보내지 않으면서 리다이렉트하는 경우
총 네가지의 메서드로 response 결과를 만듭니다.
이 과정이 복잡해서 역시 리펙토링 대상인데요, 현재는 Header를 최종적으로 만들어서 response에 전달하는 방식으로 해볼까 생각중입니다. 다만, 3단계때 한꺼번에 하는 것은 어떤지 여쭤봐도 될까요?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 좋습니다!!

final String resourcePath = viewResolve(path);
final URL resource = getClass().getClassLoader().getResource("static" + resourcePath);
if (Objects.isNull(resource)) {
return "";
}
final Path staticFilePath = new File(resource.getPath()).toPath();
final int lastDotIndex = staticFilePath.toString().lastIndexOf(".");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastIndexOf()...! 배워갑니다👀

final String fileExtension = staticFilePath.toString().substring(lastDotIndex + 1);
final String responseBody = Files.readString(staticFilePath);

if (cookie.isEmpty()) {
return generateResponseBody(statusCode, fileExtension, responseBody);
}
return generateResponseBody(statusCode, fileExtension, responseBody, cookie);
}

private String generateResponseBody(final String statusCode, final String fileExtension, final String responseBody) {
return String.join("\r\n",
"HTTP/1.1 " + HTTP_STATUS.get(statusCode) + " ",
"Content-Type: " + CONTENT_TYPE.get(fileExtension) + " ",
"Content-Length: " + responseBody.getBytes(StandardCharsets.UTF_8).length + " ",
"",
responseBody);
}

private String generateResponseBody(final String statusCode, final String fileExtension, final String responseBody, final Cookie cookie) {
return String.join("\r\n",
"HTTP/1.1 " + HTTP_STATUS.get(statusCode) + " ",
"Set-Cookie: " + cookie.generateCookieHeaderValue() + " ",
"Content-Type: " + CONTENT_TYPE.get(fileExtension) + " ",
"Content-Length: " + responseBody.getBytes(StandardCharsets.UTF_8).length + " ",
"",
responseBody);
}

private String viewResolve(final String resourcePath) {
if (!resourcePath.contains(".") && !resourcePath.endsWith(".html")) {
return resourcePath + ".html";
}

return resourcePath;
}

private Map<String, String> parsingQueryString(final String path) {
final Map<String, String> queryStrings = new HashMap<>();
if (path.contains("?")) {
final String queryString = path.substring(path.indexOf("?") + 1);
Arrays.stream(queryString.split("&")).forEach(query -> {
String[] keyValue = query.split("=");
queryStrings.put(keyValue[0], keyValue[1]);
});
}

return queryStrings;
}

private String parsingPath(final String path) {
if (path.contains("?")) {
return path.substring(0, path.indexOf("?"));
}

return path;
}

private Map<String, String> extractRequestLine(final BufferedReader reader) throws IOException {
final String requestLine = reader.readLine();
final String[] parts = requestLine.split(" ");

final Map<String, String> request = new HashMap<>();
request.put(HTTP_METHOD, parts[0]);
request.put(URL, parts[1]);
request.put(HTTP_VERSION, parts[2]);

return request;
}

private Map<String, String> extractRequestHeaders(final BufferedReader reader) throws IOException {
final Map<String, String> requests = new HashMap<>();
String line;
while ((line = reader.readLine()) != null && !line.isEmpty()) {
final int colonIndex = line.indexOf(": ");
final String key = line.substring(0, colonIndex);
final String value = line.substring(colonIndex + 2);
requests.put(key, value);
}
return requests;
}

private Map<String, String> extractRequestBody(final Map<String, String> httpRequestHeaders, final BufferedReader reader) throws IOException {
final Map<String, String> requestBodies = new HashMap<>();
if (httpRequestHeaders.containsKey("Content-Length")) {
final int contentLength = Integer.parseInt(httpRequestHeaders.get("Content-Length"));
final char[] buffer = new char[contentLength];
reader.read(buffer, 0, contentLength);
final String requestBody = new String(buffer);
Arrays.stream(requestBody.split("&")).forEach(query -> {
final String[] keyValue = query.split("=");
final String decodedValue = URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인코딩까지 꼼꼼하게 고려해주셨군요👍

requestBodies.put(keyValue[0], decodedValue);
});
}
return requestBodies;
}
}
30 changes: 30 additions & 0 deletions tomcat/src/main/java/org/apache/session/Session.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.apache.session;

import java.util.HashMap;
import java.util.Map;

public class Session {

private final String id;
private final Map<String, Object> values = new HashMap<>();

public Session(final String id) {
this.id = id;
}

public String getId() {
return this.id;
}

public Object getAttribute(final String name) {
return values.get(name);
}

public void setAttribute(final String name, final Object value) {
values.put(name, value);
}

public void removeAttribute(final String name) {
values.remove(name);
}
}
21 changes: 21 additions & 0 deletions tomcat/src/main/java/org/apache/session/SessionManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.apache.session;

import java.util.HashMap;
import java.util.Map;

public class SessionManager {

private static final Map<String, Session> SESSIONS = new HashMap<>();

public void add(final Session session) {
SESSIONS.put(session.getId(), session);
}

public Session findSession(final String id) {
return SESSIONS.get(id);
}

public void remove(final String id) {
SESSIONS.remove(id);
}
}
2 changes: 1 addition & 1 deletion tomcat/src/main/resources/static/register.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<div class="card shadow-lg border-0 rounded-lg mt-5">
<div class="card-header"><h3 class="text-center font-weight-light my-4">회원 가입</h3></div>
<div class="card-body">
<form method="post" action="register">
<form method="post" action="/register">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

남길 곳이 마땅치 않아서 이곳에 남깁니다

2단계 요구사항 중 로그인 페이지도 버튼을 눌렀을 때 GET 방식에서 POST 방식으로 전송하도록 변경하자. 가 있었는데요
요 부분을 깜빡 하신 것 같습니다. 한번 확인부탁드립니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

어쩐지 뭔가 이상하다고 생각했었는데 미처 적용하지 못했네요
페이지를 불러오는 경우에는 GET, 정보를 입력하고 버튼을 누르면 POST를 사용해 요청하도록 구현했습니다.

<div class="form-floating mb-3">
<input class="form-control" id="inputLoginId" name="account" type="Text" placeholder="아이디를 입력하세요" />
<label for="inputLoginId">아이디</label>
Expand Down