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단계 - HTTP 웹 서버 구현 미션 제출합니다. #129

Merged
merged 15 commits into from
Sep 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,48 @@
# 웹 애플리케이션 서버

이미 구현되어 사용하기 좋은 형태로 제공되는 WAS를 간단하게 재구현 해봄으로써, 아래와 같은 지식들을 학습하고자 합니다.
- 특정 개념/라이브러리/프레임워크에 대한 이해도를 높이기 위한 학습 목적 - Low Level 구현 학습
- 응용 애플리케이션 개발과는 다른 관점에서의 설계, 개발하는 역량 - Low Level API 설계
- 내가 구현한 API를 사용하는 개발자를 배려하면서 구현하는 역량 - API 설계

## 요구사항 1
> http://localhost:8080/index.html 로 접속했을 때 webapp 디렉토리의 index.html 파일을 읽어 클라이언트에 응답한다.

### TO-DO
- [x] 모든 Request Header 출력하기
- [x] Request Line에서 path 분리하기
- [x] path에 해당하는 파일 읽어 응답하기

## 요구사항 2
> “회원가입” 메뉴를 클릭하면 http://localhost:8080/user/form.html 으로 이동하면서 회원가입할 수 있다. 회원가입한다.
> 회원가입을 하면 다음과 같은 형태로 사용자가 입력한 값이 서버에 전달된다.

### TO-DO
- [x] Request Parameter 추출하기
- [x] 추출한 파라미터를 통해, 회원 생성

## 요구사항 3
> http://localhost:8080/user/form.html 파일의 form 태그 method를 get에서 post로 수정한 후 회원가입 기능이 정상적으로 동작하도록 구현한다.

### TO-DO
- [x] Request Body의 값 추출하기

## 요구사항 4
> “회원가입”을 완료하면 /index.html 페이지로 이동하고 싶다. 현재는 URL이 /user/create 로 유지되는 상태로 읽어서 전달할 파일이 없다. 따라서 redirect 방식처럼 회원가입을 완료한 후 “index.html”로 이동해야 한다. 즉, 브라우저의 URL이 /index.html로 변경해야 한다.

### TO-DO

- [x] redirect

## 요구사항 5
> 지금까지 구현한 소스 코드는 stylesheet 파일을 지원하지 못하고 있다. Stylesheet 파일을 지원하도록 구현하도록 한다.

### TO-DO

- [x] Content-Type 처리 힌트



## 진행 방법
* 웹 애플리케이션 서버 요구사항을 파악한다.
* 요구사항에 대한 구현을 완료한 후 자신의 github 아이디에 해당하는 브랜치에 Pull Request(이하 PR)를 통해 코드 리뷰 요청을 한다.
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/exception/InvalidRequestBodyException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package exception;

public class InvalidRequestBodyException extends RuntimeException {
public InvalidRequestBodyException() {
super("지원하지 않는 request body 형식입니다.");
}
Comment on lines +3 to +6
Copy link

Choose a reason for hiding this comment

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

예외에 대해서 꼼꼼하게 정의를 해주셨네요 👍
몇가지는 http 메시지에 대해서 정의된 예외로 보이는데 이를 같이 조금 더 추상화해봐도 좋을 것 같아요!

Copy link
Author

Choose a reason for hiding this comment

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

급하게 하느라 신경을 못썼네요 😭 수정했습니다!

}
7 changes: 7 additions & 0 deletions src/main/java/exception/InvalidRequestHeaderException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package exception;

public class InvalidRequestHeaderException extends RuntimeException {
public InvalidRequestHeaderException() {
super("지원하지 않는 request header 형식입니다.");
}
}
7 changes: 7 additions & 0 deletions src/main/java/exception/MethodNotAllowedException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package exception;

public class MethodNotAllowedException extends RuntimeException {
public MethodNotAllowedException() {
super("올바른 핸들러 메서드를 발견하지 못했습니다.");
}
}
7 changes: 7 additions & 0 deletions src/main/java/exception/MethodParameterBindException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package exception;

public class MethodParameterBindException extends RuntimeException {
public MethodParameterBindException() {
super("파라미터 매핑 예외");
}
}
7 changes: 7 additions & 0 deletions src/main/java/exception/NoDefaultConstructorException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package exception;

public class NoDefaultConstructorException extends RuntimeException {
public <T> NoDefaultConstructorException(Class<T> clazz) {
super(clazz.getName() + "클래스에 기본생성자가 필요합니다.");
}
}
7 changes: 7 additions & 0 deletions src/main/java/exception/UnsupportedMethodTypeException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package exception;

public class UnsupportedMethodTypeException extends RuntimeException {
public UnsupportedMethodTypeException(String input) {
super(String.format("%s는 지원하지 않는 메소드 타입입니다.", input));
}
}
3 changes: 3 additions & 0 deletions src/main/java/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ public class User {
private String name;
private String email;

public User() {
}

public User(String userId, String password, String name, String email) {
this.userId = userId;
this.password = password;
Expand Down
34 changes: 32 additions & 2 deletions src/main/java/utils/FileIoUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,40 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;

public class FileIoUtils {
public static byte[] loadFileFromClasspath(String filePath) throws IOException, URISyntaxException {
Path path = Paths.get(FileIoUtils.class.getClassLoader().getResource(filePath).toURI());
private static final List<String> BASE_PATH = Arrays.asList("templates", "static");
public static final String NOT_FOUND = "404 NOT FOUND 잘 부탁드립니다.";
public static final String INDEX_PAGE = "/index.html";

public static byte[] loadFileFromClasspath(String filePath) throws IOException {
if (filePath.equals("/")) {
Copy link

Choose a reason for hiding this comment

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

이런 값들도 상수로 뺴는 걸 고민해봐도 좋겠어요.
static/notFound.html도 같이요 :)

return loadFileFromClasspath("/index.html");
Copy link

Choose a reason for hiding this comment

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

상수로 뺴셨는데 그대로 사용하셨네요. :)

}

if(!filePath.contains(".")) {
return "".getBytes();
}

Path path = BASE_PATH.stream()
.map(base -> getPath(base + filePath))
.filter(Objects::nonNull)
.findAny()
.orElseGet(() -> getPath("static/notFound.html"));

return Files.readAllBytes(path);
}

private static Path getPath(String path) {
try {
return Paths.get(FileIoUtils.class.getClassLoader().getResource(path).toURI());
} catch (NullPointerException e) {
return null;
} catch (URISyntaxException e) {
throw new IllegalArgumentException();
}
}
}
42 changes: 42 additions & 0 deletions src/main/java/webserver/DefaultHttpMessageConverter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package webserver;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import exception.MethodParameterBindException;
import exception.NoDefaultConstructorException;

public class DefaultHttpMessageConverter implements HttpMessageConverter {
@Override
public <T> T convert(Class<T> clazz, Map<String, String> attributes) {
try {
T instance = clazz.getConstructor().newInstance();
attributes.forEach((key, value) -> {
try {
Field field = clazz.getDeclaredField(key);
field.setAccessible(true);
field.set(instance, value);
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
Copy link

Choose a reason for hiding this comment

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

e.printStackTrace(); 는 안티패턴이라 별도 로거를 통해서 로그를 찍는게 좋을 것 같네요. :)

}
});
return instance;
} catch (InstantiationException | InvocationTargetException | IllegalAccessException e) {
throw new MethodParameterBindException();
} catch (NoSuchMethodException e) {
throw new NoDefaultConstructorException(clazz);
}
}

@Override
public boolean isSupport(Class<?> parameterType, Map<String, String> body) {
List<String> collect = Arrays.stream(parameterType.getDeclaredFields()).map(Field::getName)
.collect(Collectors.toList());
return collect.stream()
.anyMatch(body::containsKey);
}
}
70 changes: 70 additions & 0 deletions src/main/java/webserver/DispatcherServlet.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package webserver;

import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.lang.reflect.Method;
import java.net.Socket;
import java.util.Arrays;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import utils.FileIoUtils;
import webserver.controller.Handlers;
import webserver.controller.IndexController;
import webserver.controller.UserController;

public class DispatcherServlet implements Runnable {
private static final Logger logger = LoggerFactory.getLogger(DispatcherServlet.class);

private Socket connection;

public DispatcherServlet(Socket connectionSocket) {
this.connection = connectionSocket;
}

public void run() {
logger.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(),
connection.getPort());
try (InputStream in = connection.getInputStream(); OutputStream out = connection.getOutputStream();
InputStreamReader ir = new InputStreamReader(in); BufferedReader br = new BufferedReader(ir)) {
DataOutputStream dos = new DataOutputStream(out);

ServletRequest servletRequest = new ServletRequest(br);

String path = servletRequest.getPath();

if (path.contains(".") && !path.endsWith(".html")) {
byte[] bytes = FileIoUtils.loadFileFromClasspath(path);

try {
dos.writeBytes("HTTP/1.1 200 OK \r\n");
dos.writeBytes("Content-Length: " + bytes.length + "\r\n");
dos.writeBytes(
"Content-Type: " + servletRequest.getHeader("Accept").split(",")[0] + ";charset=utf-8\r\n");
dos.writeBytes("\r\n");
dos.write(bytes, 0, bytes.length);
dos.flush();
} catch (IOException e) {
logger.error(e.getMessage());
}
return;
}
Comment on lines +42 to +57
Copy link

Choose a reason for hiding this comment

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

정적 자원에 대한 부분도 별도 핸들러로 분리하고, 응답도 SerlvetResposne로 함께 추상화 할 수 있지 않을까요?

Copy link
Author

Choose a reason for hiding this comment

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

감사합니다! 나름대로 주신 피드백에 맞도록 반영했습니다!


List<Class<? extends Handlers>> controllers = Arrays.asList(UserController.class, IndexController.class);
HandlerMapping handlerMapping = new HandlerMapping(controllers);
Method handler = handlerMapping.mapping(servletRequest);
HandlerAdaptor handlerAdaptor = new HandlerAdaptor();
HttpMessageConverter converter = new DefaultHttpMessageConverter();
ServletResponse servletResponse = handlerAdaptor.invoke(handler, servletRequest, converter);
servletResponse.createResponse(dos, servletRequest);
} catch (IOException e) {
logger.error(e.getMessage());
Copy link

Choose a reason for hiding this comment

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

예외 발생 시 500이라는 응답이라도 클라이언트에 전달해줘야 하지 않을까요?

}
}
}
29 changes: 29 additions & 0 deletions src/main/java/webserver/HandlerAdaptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package webserver;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

import model.User;

public class HandlerAdaptor {

public ServletResponse invoke(Method handler, ServletRequest servletRequest,
HttpMessageConverter converter) {

Class<?>[] parameterTypes = handler.getParameterTypes();
User user = null;
Copy link

Choose a reason for hiding this comment

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

HandlerAdaptor가 도메인 모델에 있는 User에 대한 의존을 가지고 있는게 맞을까요?

Copy link
Author

Choose a reason for hiding this comment

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

이부분이 많이 헷갈렸는데, Cast를 통해 해결했습니다!


try {
Object handlers = handler.getDeclaringClass().newInstance();
if (parameterTypes.length == 0) {
return (ServletResponse)handler.invoke(handlers);
}
if (converter.isSupport(parameterTypes[0], servletRequest.getBody())) {
user = (User)converter.convert(parameterTypes[0], servletRequest.getBody());
}
return (ServletResponse)handler.invoke(handlers, user);
Comment on lines +21 to +24
Copy link

Choose a reason for hiding this comment

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

파라미터 메소드가 다른 컨트롤러가 추가될 때마다 상당히 복잡해질 우려가 있네요.
웹 서버가 도메인 자체에 너무 강결합이 되어있어서요. ;)
일단 Serlvet 패턴을 따라서 간단하게라도 추상화해서 구현해보면 어떨까요?

httpRequest, httpResposne 위주로 의존하고 도메인과는 최대한 격리되도록이요.

Copy link

Choose a reason for hiding this comment

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

Map<String, String> 형태로 들어온 Body나 Params를 컨트롤러의 파라미터로 바인딩 하는 과정에 Object로 만든 걸 해당 타입으로 바꾸는 과정에서 여러개의 파라미터 바인딩을 하는게 어렵더라구요. 그런 역할은 Converter가 아니라 Argument Resolver에서 해주는 게 맞나요?

이 질문에 대한 내용이 이 부분때문 인 것 같은데요.
일단 서블릿 리퀘스트에 대해서 컨트롤러에 전달하고, 컨트롤러에서 용도에 맞게 데이터를 꺼내 가공하도록 하는게 맞는 것 같아요. :)

} catch (IllegalAccessException | InvocationTargetException | InstantiationException e) {
throw new IllegalArgumentException();
}
}
}
41 changes: 41 additions & 0 deletions src/main/java/webserver/HandlerMapping.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package webserver;

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import exception.MethodNotAllowedException;
import webserver.controller.Handlers;
import webserver.controller.annotation.Controller;
import webserver.controller.annotation.RequestMapping;

public class HandlerMapping {
private final List<Class<? extends Handlers>> handlers;

public HandlerMapping(List<Class<? extends Handlers>> handlers) {
this.handlers = handlers.stream()
.filter(handler -> Objects.nonNull(handler.getAnnotation(Controller.class)))
.collect(Collectors.toList());
}

public Method mapping(ServletRequest request) {
RequestHeader.MethodType methodType = request.getMethod();
String origin = request.getPath();
String findPath = origin.endsWith(".html")
Copy link

Choose a reason for hiding this comment

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

html이 붙어있다면 제외하고 찾기 보다는 정적 파일로 취급하는게 맞지 않을까요? :)

? origin.substring(0, origin.lastIndexOf("."))
: origin;

return handlers.stream()
.flatMap(controller -> Stream.of(controller.getMethods()))
.filter(method -> {
RequestMapping annotation = method.getAnnotation(RequestMapping.class);
return Objects.nonNull(annotation) && Arrays.asList(annotation.value()).contains(findPath)
&& annotation.type().equals(methodType);
})
.findAny()
.orElseThrow(MethodNotAllowedException::new);
}
}
9 changes: 9 additions & 0 deletions src/main/java/webserver/HttpMessageConverter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package webserver;

import java.util.Map;

public interface HttpMessageConverter {
<T> T convert(Class<T> clazz, Map<String, String> attributes);

boolean isSupport(Class<?> parameterType, Map<String, String> body);
}
43 changes: 43 additions & 0 deletions src/main/java/webserver/RequestBody.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package webserver;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;

import exception.InvalidRequestBodyException;

public class RequestBody {
private final Map<String, String> attribute;

private RequestBody(Map<String, String> attribute) {
this.attribute = attribute;
}

public static RequestBody of(String line) {
Map<String, String> attributes = parseBodyAttributes(line);

return new RequestBody(attributes);
}

private static Map<String, String> parseBodyAttributes(String line) {
Map<String, String> attributes = new LinkedHashMap<>();
if (Objects.isNull(line) || line.isEmpty()) {
return attributes;
}
try {
String[] params = line.split("&");
for (String param : params) {
String[] attribute = param.split("=");
attributes.put(attribute[0], attribute[1]);
}
return attributes;
} catch (IndexOutOfBoundsException e) {
throw new InvalidRequestBodyException();
}
}

public Map<String, String> getAttribute() {
return Collections.unmodifiableMap(attribute);
}
}
Loading