diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 49f0c1e..9e6f579 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,7 +22,7 @@ jobs: steps: # 코드 체크 아웃 - name: Checkout - - uses: actions/checkout@v4 + uses: actions/checkout@v4 # JDK 17 설정 - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -54,12 +54,4 @@ jobs: key: ${{ secrets.GCP_SSH_PRIVATE_KEY }} port: 22 script: | - pkill -f 'elephant-app.*.jar' || true - - # 위에서 파일 복사한 디렉토리로 이동 - cd ~/elephant-project/ - - # nohup으로 새로운 JAR 파일을 백그라운드에서 실행 - # 실행 로그를 nohup.out 파일에 기록 - nohup sudo java -jar elephant-app-*.jar > nohup.out 2>&1 & - + ~/deploy.sh diff --git a/app/build.gradle b/app/build.gradle index 0358188..e4b195b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,4 +1,5 @@ plugins { + id 'com.github.johnrengelman.shadow' version '7.1.2' id 'application' } @@ -13,3 +14,8 @@ dependencies { implementation 'ch.qos.logback:logback-classic:1.5.13' } +shadowJar { + archiveBaseName = 'elephant-app' + archiveVersion = '0.0.1' + archiveClassifier = '' +} \ No newline at end of file diff --git a/app/src/main/java/com/example/elephant/WebServer.java b/app/src/main/java/com/example/elephant/WebServer.java index 229a0c0..331a09b 100644 --- a/app/src/main/java/com/example/elephant/WebServer.java +++ b/app/src/main/java/com/example/elephant/WebServer.java @@ -1,8 +1,22 @@ package com.example.elephant; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + public class WebServer { + private static final Logger log = LoggerFactory.getLogger(WebServer.class); + private static final int DEFAULT_PORT = 8080; + public static void main(String[] args) { + int port = DEFAULT_PORT; + if (args.length > 0) { + try { + port = Integer.parseInt(args[0]); + } catch (NumberFormatException e) { + log.error("Invalid port number specified: '{}'. Using default port {}.", args[0], DEFAULT_PORT); + } + } WebServerLauncher launcher = new WebServerLauncher(); - launcher.start(); + launcher.start(port); } } \ No newline at end of file diff --git a/app/src/main/java/com/example/elephant/WebServerLauncher.java b/app/src/main/java/com/example/elephant/WebServerLauncher.java index c67a3b8..87d1acf 100644 --- a/app/src/main/java/com/example/elephant/WebServerLauncher.java +++ b/app/src/main/java/com/example/elephant/WebServerLauncher.java @@ -8,9 +8,16 @@ import trunk.container.ContextConfig; import trunk.container.StandardContext; -import java.io.File; -import java.net.URISyntaxException; +import java.io.IOException; +import java.io.InputStream; +import java.net.JarURLConnection; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.jar.JarFile; +import java.util.stream.Stream; /** * Elephant 웹 서버의 실제 부팅 로직을 담당하는 클래스입니다. @@ -18,21 +25,22 @@ */ public class WebServerLauncher { private static final Logger log = LoggerFactory.getLogger(WebServerLauncher.class); - private static final int DEFAULT_PORT = 8080; + private static final String WEBAPP_RESOURCE_PATH = "/webapp"; private Connector connector; + private Path tempDocBase; - public void start() { + public void start(int port) { try { StandardContext context = configureContext(); - ProtocolHandler handler = new Http11NioProtocol(DEFAULT_PORT); + ProtocolHandler handler = new Http11NioProtocol(port); this.connector = new Connector(handler, context); this.connector.init(); this.connector.start(); logAsciiArt(); - log.info("Web Application Server started successfully on port {}.", DEFAULT_PORT); + log.info("Web Application Server started successfully on port {}.", port); setupShutdownHook(context); @@ -43,59 +51,123 @@ public void start() { } } - private StandardContext configureContext() throws URISyntaxException { - StandardContext context = new StandardContext(); - - // webapp 디렉토리의 실제 경로 찾아 Context의 dcosBase 설정 - URL webappUrl = this.getClass().getClassLoader().getResource("webapp"); - if (webappUrl == null) { - throw new IllegalStateException("Could not find webapp directory in classpath!"); + /** + * 웹 애플리케이션 컨텍스트를 설정하고, web.xml 파일을 파싱하여 서블릿 정보를 등록합니다. + * 실행 환경을 감지하고, 웹 애플리케이션의 문서 루트 경로를 설정합니다. + * @return 설정된 {@link StandardContext} 인스턴스 + * @throws Exception + */ + private StandardContext configureContext() throws Exception { + URL resourceUrl = getClass().getResource(WEBAPP_RESOURCE_PATH); + if (resourceUrl == null) { + throw new IllegalStateException("Cannot find webapp resource: " + WEBAPP_RESOURCE_PATH); } - String webappPath = new File(webappUrl.toURI()).getAbsolutePath(); - context.setDocBase(webappPath); - // web.xml 파일 경로 찾아 파싱 - File webXmlFile = new File(webappPath, "WEB-INF/web.xml"); - if (!webXmlFile.exists()) { - throw new IllegalStateException("Could not find 'web.xml' in 'webapp/WEB-INF/'."); + Path docBasePath; + String protocol = resourceUrl.getProtocol(); + + if ("jar".equals(protocol)) { + // JAR 환경: unpackWARs 전략 실행 + log.info("Execution environment: JAR. Unpacking webapp resources..."); + this.tempDocBase = unpackResourcesToTempDir(resourceUrl); + docBasePath = this.tempDocBase; + log.info("Webapp resources unpacked to temporary directory: {}", docBasePath); + } else if ("file".equals(protocol)) { + // 파일 시스템 환경: 직접 경로 사용 + log.info("Execution environment: File System. Using direct path."); + docBasePath = Path.of(resourceUrl.toURI()); + } else { + throw new IllegalStateException("Unsupported protocol for webapp resource: " + protocol); } + StandardContext context = new StandardContext(); + context.setDocBase(docBasePath.toAbsolutePath().toString()); + // web.xml 파일 경로 찾아 파싱 ContextConfig contextConfig = new ContextConfig(context); - contextConfig.parseWebXml(webXmlFile.getAbsolutePath()); + contextConfig.parseWebXml(); // 서블릿 즉시 로딩 context.loadOnStartup(); - return context; } + private Path unpackResourcesToTempDir(URL jarUrl) throws IOException { + Path tempDir = Files.createTempDirectory("elephant-webapp-"); + JarURLConnection connection = (JarURLConnection) jarUrl.openConnection(); + connection.setUseCaches(false); + try (JarFile jarFile = connection.getJarFile()) { + String prefix = connection.getEntryName() + "/"; + jarFile.stream() + .filter(entry -> entry.getName().startsWith(prefix) && !entry.isDirectory()) + .forEach(entry -> { + String relativePath = entry.getName().substring(prefix.length()); + Path targetPath = tempDir.resolve(relativePath); + try { + Files.createDirectories(targetPath.getParent()); + try (InputStream is = jarFile.getInputStream(entry)) { + Files.copy(is, targetPath, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + throw new RuntimeException("Failed to unpack resource: " + entry.getName(), e); + } + }); + } + + return tempDir; + } + + private void deleteDirectoryRecursively(Path path) throws IOException { + try (Stream walk = Files.walk(path)) { + walk.sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { + Files.delete(p); + } catch (IOException e) { + log.error("Unable to delete path: {}", p, e); + } + }); + } + } + /** * 애플리케이션 종료 시 안전하게 서버 자원을 해제하기 위한 종료 Hook을 등록합니다. */ private void setupShutdownHook(StandardContext context) { Runtime.getRuntime().addShutdownHook(new Thread(() -> { + log.info("Shutting down the web application server..."); try { - log.info("Shutting down the web application server..."); context.destroyAll(); connector.stop(); } catch (Exception e) { log.error("Error stopping Web Application Server", e); + } finally { + if (tempDocBase != null) { + try { + log.info("Deleting temporary docBase directory: {}", tempDocBase); + deleteDirectoryRecursively(tempDocBase); + log.info("Temporary directory deleted successfully."); + } catch (IOException e) { + log.error("Failed to delete temporary directory: {}", tempDocBase, e); + } + } } log.info("Server has been shut down gracefully."); })); } private void logAsciiArt() { - log.info(""" - - _____ _ _ _ \s - | ___| | | | | | | \s - | |__ | | ___ _ __ | |__ __ _ _ __ | |_\s - | __| | | / _ \\ | '_ \\ | '_ \\ / _` | | '_ \\ | __| - | |___ | | | __/ | |_) | | | | | | (_| | | | | | | |_\s - \\____/ |_| \\___| | .__/ |_| |_| \\__,_| |_| |_| \\__| - | | \s - |_| \s - """); + System.out.println( + """ + + _____ _ _ _ \s + | ___| | | | | | | \s + | |__ | | ___ _ __ | |__ __ _ _ __ | |_\s + | __| | | / _ \\ | '_ \\ | '_ \\ / _` | | '_ \\ | __| + | |___ | | | __/ | |_) | | | | | | (_| | | | | | | |_\s + \\____/ |_| \\___| | .__/ |_| |_| \\__,_| |_| |_| \\__| + | | \s + |_| \s + """ + ); } } diff --git a/app/src/main/resources/webapp/boa-docs.html b/app/src/main/resources/webapp/boa-docs.html new file mode 100644 index 0000000..2c417ff --- /dev/null +++ b/app/src/main/resources/webapp/boa-docs.html @@ -0,0 +1,137 @@ + + + + + + Boa Documentation | Elephant Project + + + + + + + +
+ + + + +
+
+

Boa

+

The Application Framework

+
+ +
+

개요 (Overview)

+

Boa는 애플리케이션 개발의 복잡도를 낮추고, 객체지향적인 설계를 유도하기 위한 프레임워크 모듈입니다. Spring Framework의 핵심 철학인 IoC/DI, AOP와 MVC 모듈을 직접 구현하면서, '프레임워크는 왜 그렇게 설계되었는가'에 대한 근본적인 답을 찾고자 합니다. Boa는 Trunk 위에서 동작하며, 개발자가 비즈니스 로직에만 집중할 수 있는 환경을 제공합니다.

+
+ +
+

핵심 원리 (Core Principles)

+ +

IoC (Inversion of Control) & DI (Dependency Injection)

+

객체의 생성과 생명주기 관리를 개발자가 아닌 프레임워크(컨테이너)가 담당하는 '제어의 역전' 원리를 구현합니다. 이를 통해 컴포넌트 간의 결합도를 낮추고 테스트하기 쉬운 코드를 작성할 수 있습니다.

+
    +
  • BeanFactory: 빈(Bean)의 생성과 조립을 담당하는 IoC 컨테이너의 심장입니다.
  • +
  • BeanDefinition: 빈의 메타데이터(클래스 타입, 스코프, 의존성 등)를 담고 있는 설계도입니다.
  • +
  • Annotation-based Configuration: @Component, @Autowired 등의 어노테이션을 통해 자동으로 빈을 등록하고 의존성을 주입합니다.
  • +
+ +

AOP (Aspect-Oriented Programming)

+

로깅, 트랜잭션, 보안 등 여러 객체에 공통적으로 나타나는 부가 기능(횡단 관심사)을 핵심 비즈니스 로직으로부터 분리하여 모듈화합니다.

+
    +
  • Dynamic Proxy: JDK Dynamic Proxy와 CGLIB를 이용하여 런타임에 프록시 객체를 생성, 부가 기능을 주입합니다.
  • +
  • Advisor: 하나의 어드바이스(Advice, 부가 기능)와 하나의 포인트컷(Pointcut, 적용 대상)을 묶은 모듈입니다.
  • +
  • Auto-proxying: 빈 후처리기(BeanPostProcessor)를 통해 컨테이너의 빈들 중 포인트컷에 해당하는 빈을 자동으로 프록시 객체로 교체합니다.
  • +
+
+ +
+

핵심 코드 예시 (Code Snippets)

+

BeanFactory의 빈 생성 로직 일부

+

BeanFactory는 BeanDefinition을 기반으로 빈 인스턴스를 생성하고 의존성을 주입하는 복잡한 과정을 책임집니다.

+
+

+  // 구현 중
+
+
+
+ + +
+ +
+ + diff --git a/app/src/main/resources/webapp/favicon.ico b/app/src/main/resources/webapp/favicon.ico deleted file mode 100644 index 5cd1446..0000000 Binary files a/app/src/main/resources/webapp/favicon.ico and /dev/null differ diff --git a/app/src/main/resources/webapp/index.html b/app/src/main/resources/webapp/index.html index f3992bd..b7eab8f 100644 --- a/app/src/main/resources/webapp/index.html +++ b/app/src/main/resources/webapp/index.html @@ -1,225 +1,184 @@ - - - - - SLiPP Java Web Programming - - - - - - - - - + -
-
-
- -
-
-
- -
- -
+ +
+
+ + + + + + + +
-
-
+

+ Elephant Project +

+

추상화의 이면의 본질을 탐험하는 여정, A Deep Dive into WAS & Framework Internals.

+ - - + +
+

Project Philosophy

+

+ 우리는 이미 잘 구축된 거대한 시스템 위에서 개발하고 있습니다. 그러나 그 시스템을 블랙박스로 취급한 채 사용하는 것은, 문제가 발생했을 때 제대로 대응할 수 없다는 의미이기도 합니다. + 이 프로젝트는 그런 무지에서 벗어나, 직접 HTTP를 파싱하고, I/O 처리 및 스레드 풀을 구성하며, 서블릿 컨테이너와 MVC 프레임워크의 동작 원리를 구현합니다. + 시스템의 핵심 아키텍처가 어떻게 구성되고 작동하는지를, 코드 수준에서 정확히 이해하는 것이 목표입니다. +

+
+ + +
- - + +
+

Trunk

+

The Web Application Server

+

코끼리의 코(trunk)는 물건을 들어 올리고, 냄새를 맡고, 소통하며, 생존에 필요한 거의 모든 상호작용을 처리합니다. + Trunk는 HTTP 요청을 수신하고 응답을 전달하는 전 과정을 처리하는 Java 기반 웹 애플리케이션 서버입니다.

+
    +
  • + + NIO-based Event Loop & Thread Pool +
  • +
  • + + HTTP/1.1 Request/Response Parser +
  • +
  • + + Servlet Container & Lifecycle +
  • +
  • + + Session Management & Cookies +
  • +
- -
- - - - ---> + - - - - - + + +
+

Boa

+

The Application Framework

+

보아뱀이 먹이를 감싸듯, Boa는 객체들을 느슨하게 연결하면서도 강력하게 제어합니다. Spring의 core와 MVC 구조를 구현하여 'Spring이 왜 그렇게 설계되었는가'에 대해 탐구합니다.

+
    +
  • + + IoC Container & DI +
  • +
  • + + Annotation-based Bean Configuration +
  • +
  • + + AOP with Dynamic Proxy +
  • +
  • + + DispatcherServlet based MVC Structure +
  • +
+
+
+ + + + + + \ No newline at end of file diff --git a/app/src/main/resources/webapp/trunk-docs.html b/app/src/main/resources/webapp/trunk-docs.html new file mode 100644 index 0000000..4f04b4c --- /dev/null +++ b/app/src/main/resources/webapp/trunk-docs.html @@ -0,0 +1,201 @@ + + + + + + Trunk Documentation | Elephant Project + + + + + + + +
+ + + + +
+
+

Trunk

+

The Web Application Server

+
+ +
+

개요 (Overview)

+

Trunk는 Elephant Project의 가장 근간이 되는 웹 애플리케이션 서버(WAS) 모듈입니다. + 외부의 HTTP 요청을 받아 내부의 서블릿 컨테이너까지 전달하는 모든 과정을 순수 Java로 구현합니다. + 이는 Tomcat의 핵심 아키텍처 구조를 모방하여, 저수준 I/O 처리부터 서블릿 생명주기 관리에 대한 깊은 이해를 목표로 합니다.

+
+ +
+

아키텍처 (Architecture)

+

Trunk는 크게 요청을 받아들이고 연결을 관리하는 Connector와, 전달된 요청을 실제 비즈니스 로직(서블릿)으로 연결하는 Container 두 부분으로 나뉩니다.

+ +

Connector: The I/O Handler

+

Connector는 클라이언트와의 통신을 담당합니다. Java NIO(Non-blocking I/O)를 기반으로 하여 적은 수의 스레드로 다수의 동시 연결을 효율적으로 처리합니다.

+
+
+

Endpoint & Acceptor

+

+ 네트워크 연결을 수락하고 관리하는 최상위 컴포넌트입니다. Acceptor 스레드가 클라이언트의 새로운 연결 요청(accept)을 받아 소켓을 생성합니다. +

+
+
+

Poller

+

+ 다수의 소켓 채널을 Selector에 등록하고, I/O 이벤트를 감지하는 이벤트 루프를 실행합니다. +

+
+
+

SocketProcessor

+

+ 실제 I/O 작업(read/write)과 HTTP 파싱을 수행하며, 워커 스레드 풀에서 비동기적으로 실행됩니다. +

+
+
+ +

Container: The Servlet Engine

+

+ Container는 Connector로부터 전달받은 요청을 처리하는 서블릿 엔진입니다. + 서블릿 표준 명세를 구현하고 서블릿의 생명주기를 관리하며, URL 요청을 가장 적절한 서블릿으로 매핑하는 역할을 수행합니다. +

+
+
+

ContextConfig

+

+ 웹 애플리케이션의 배포 서술자인 webapp/WEB-INF/web.xml 파일을 파싱하여, 서블릿 정보를 읽어들이고 Context를 동적으로 설정하는 역할을 담당합니다. +

+
+
+

StandardContext

+

+ 하나의 웹 애플리케이션(webapp)에 해당되는 최상위 컨테이너입니다. web.xml에 명시된 load-on-startup값에 따라 서버 시작 시점에 서블릿을 미리 로딩(Eager Loading)하거나, 첫 요청 시 로딩(Lazy Loading)하는 전략을 관리합니다. +

+
+
+

StandardWrapper

+

+ 개별 서블릿 인스턴스를 감싸는 래퍼 객체입니다. 서블릿의 init(), service(), destroy() 메서드를 호출하며 생명주기를 직접 제어합니다. 또한, 서블릿이 ServletContextAware 인터페이스를 구현한 경우, Context 객체를 서블릿에 주입(DI)하여 컨테이너의 기능을 사용할 수 있도록 지원합니다. +

+
+
+

Mapper

+

+ 요청 URI와 서블릿을 매핑하는 컴포넌트입니다. 서블릿 표준 명세와 동일하게, 아래와 같은 우선순위 규칙에 따라 가장 적합한 서블릿을 찾아냅니다. +

    +
  • 정확한 경로 매칭 (Exact Match)
  • +
  • 가장 긴 접두사 매칭 (Prefix Match)
  • +
  • 확장자 매칭 (Extension Match)
  • +
  • 기본 매칭 (Default Match)
  • +
+
+
+
+ +
+

핵심 코드 예시 (Code Snippets)

+

Poller의 이벤트 루프

+

Poller는 `Selector`를 이용해 I/O 이벤트를 감지하는 핵심적인 역할을 수행합니다. 아래는 이벤트 루프의 일부입니다.

+
+

+public void run() {
+    while (true) {
+        try {
+            if (selector.select(1000) > 0) {
+                Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
+                while (keys.hasNext()) {
+                    SelectionKey key = keys.next();
+                    // ... 이벤트 타입에 따라 처리 로직 분기 ...
+                    if (key.isReadable()) {
+                        // 워커 스레드에 작업 위임
+                    }
+                    keys.remove();
+                }
+            }
+        } catch (IOException e) {
+            // ... 예외 처리 ...
+        }
+    }
+}
+
+
+
+ + +
+
+ + diff --git a/trunk/src/main/java/trunk/container/ContextConfig.java b/trunk/src/main/java/trunk/container/ContextConfig.java index 02797f1..1f8fe68 100644 --- a/trunk/src/main/java/trunk/container/ContextConfig.java +++ b/trunk/src/main/java/trunk/container/ContextConfig.java @@ -1,5 +1,7 @@ package trunk.container; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; @@ -10,8 +12,12 @@ import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; -import java.io.File; +import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -26,6 +32,8 @@ * @see StandardContext */ public class ContextConfig { + private static final Logger log = LoggerFactory.getLogger(ContextConfig.class); + private static final String SERVLET = "servlet"; private static final String SERVLET_NAME = "servlet-name"; private static final String SERVLET_CLASS = "servlet-class"; @@ -33,69 +41,95 @@ public class ContextConfig { private static final String URL_PATTERN = "url-pattern"; private static final String SERVLET_MAPPING = "servlet-mapping"; - private final StandardContext standardContext; + private final StandardContext context; /** * 지정된 {@link StandardContext}를 설정하는 생성자. * 이 {@code ContextConfig}는 파싱 결과를 이 컨텍스트에 등록합니다. * - * @param standardContext 서블릿 정보를 등록할 컨텍스트 + * @param context 서블릿 정보를 등록할 컨텍스트 */ - public ContextConfig(StandardContext standardContext) { - this.standardContext = standardContext; + public ContextConfig(StandardContext context) { + this.context = context; } /** * 지정된 경로의 web.xml 파일을 파싱하고, 그 내용을 {@link StandardContext}에 적용합니다. * - * @param path web.xml 파일의 전체 경로 * @throws RuntimeException 파일 파싱 또는 서블릿 클래스 로딩 중 심각한 오류가 발생했을 경우 */ - public void parseWebXml(String path) { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + public void parseWebXml() { + Path webXmlPath = Paths.get(context.getDocBase(), "WEB-INF", "web.xml"); - try { + if (!Files.exists(webXmlPath)) { + log.info("web.xml not found at path: {}. Skipping servlet configuration.", webXmlPath); + return; + } + try (InputStream is = new FileInputStream(webXmlPath.toFile())) { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); - Document document = builder.parse(new File(path)); + Document document = builder.parse(is); document.getDocumentElement().normalize(); - Map > servletNameToClass = new HashMap<>(); - NodeList servletNodes = document.getElementsByTagName(SERVLET); + // 태그들을 먼저 파싱하여 서블릿 이름과 클래스 정보를 매핑 + Map > servletInfoMap = parseServletDefinitions(document); + + // 태그들을 파싱하여 URL 패턴과 서블릿 클래스를 매핑 + mapServletUrls(document, servletInfoMap); + + } catch (ParserConfigurationException | SAXException | IOException | ClassNotFoundException e) { + throw new RuntimeException("Failed to parse web.xml", e); + } - for (int i = 0; i < servletNodes.getLength(); i++) { - Element servletElement = (Element) servletNodes.item(i); - String servletName = servletElement.getElementsByTagName(SERVLET_NAME).item(0).getTextContent(); - String servletClass = servletElement.getElementsByTagName(SERVLET_CLASS).item(0).getTextContent(); + } - // Default value lazy loading - String loadOnStartUp = "-1"; - NodeList loadOnStartupNode = servletElement.getElementsByTagName(LOAD_ON_STARTUP); - if (loadOnStartupNode.getLength() > 0) { - loadOnStartUp = loadOnStartupNode.item(0).getTextContent(); - } + private void mapServletUrls(Document document, Map> servletInfoMap) throws ClassNotFoundException { + NodeList mappingNodes = document.getElementsByTagName(SERVLET_MAPPING); - servletNameToClass.put(servletName, List.of(servletClass, loadOnStartUp)); - } + for (int i = 0; i < mappingNodes.getLength(); i++) { + Element mappingElement = (Element) mappingNodes.item(i); + String servletName = getElementTextContent(mappingElement, SERVLET_NAME); + String urlPattern = getElementTextContent(mappingElement, URL_PATTERN); - NodeList mappingNodes = document.getElementsByTagName(SERVLET_MAPPING); - for (int i = 0; i < mappingNodes.getLength(); i++) { - Element mappingElement = (Element) mappingNodes.item(i); - String servletName = mappingElement.getElementsByTagName(SERVLET_NAME).item(0).getTextContent(); - String urlPattern = mappingElement.getElementsByTagName(URL_PATTERN).item(0).getTextContent(); - String servletClass = servletNameToClass.get(servletName).get(0); - int loadOnStartUp = Integer.parseInt(servletNameToClass.get(servletName).get(1)); + List servletInfo = servletInfoMap.get(servletName); - Class clazz = Class.forName(servletClass); + String servletClass = servletInfo.get(0); + int loadOnStartup = Integer.parseInt(servletInfo.get(1)); - if (HttpServlet.class.isAssignableFrom(clazz)) { - Class servletClazz = clazz.asSubclass(Servlet.class); - standardContext.addChild(urlPattern, servletClazz, loadOnStartUp); - } + Class clazz = Class.forName(servletClass); + if (HttpServlet.class.isAssignableFrom(clazz)) { + Class servletClazz = clazz.asSubclass(Servlet.class); + context.addChild(urlPattern, servletClazz, loadOnStartup); } + } + } - } catch (ParserConfigurationException | SAXException | IOException | ClassNotFoundException e) { - throw new RuntimeException("Failed to parse web.xml", e); + private Map> parseServletDefinitions(Document document) { + Map> servletInfoMap = new HashMap<>(); + NodeList servletNodes = document.getElementsByTagName(SERVLET); + + for (int i = 0; i < servletNodes.getLength(); i++) { + Element servletElement = (Element) servletNodes.item(i); + String servletName = getElementTextContent(servletElement, SERVLET_NAME); + String servletClass = getElementTextContent(servletElement, SERVLET_CLASS); + String loadOnStartUp = getElementTextContent(servletElement, LOAD_ON_STARTUP, "-1"); + + servletInfoMap.put(servletName, List.of(servletClass, loadOnStartUp)); } + return servletInfoMap; + } + + private String getElementTextContent(Element element, String tagName) { + NodeList nodeList = element.getElementsByTagName(tagName); + if (nodeList.getLength() > 0) { + return nodeList.item(0).getTextContent().trim(); + } + return null; + } + + private String getElementTextContent(Element element, String tagName, String defaultValue) { + String content = getElementTextContent(element, tagName); + return (content == null || content.isEmpty()) ? defaultValue : content; } } diff --git a/trunk/src/main/java/trunk/container/StandardContext.java b/trunk/src/main/java/trunk/container/StandardContext.java index 83c7b69..d5f0b45 100644 --- a/trunk/src/main/java/trunk/container/StandardContext.java +++ b/trunk/src/main/java/trunk/container/StandardContext.java @@ -4,7 +4,8 @@ import org.slf4j.LoggerFactory; import trunk.servlet.Servlet; -import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.*; /** @@ -100,19 +101,29 @@ public void setDocBase(String docBase) { this.docBase = docBase; } + + /** + * 이 컨텍스트의 문서 루트 디렉토리(docBase)를 반환합니다. + * @return 문서 루트 디렉토리의 경로, 또는 설정되지 않은 경우 null + */ + public String getDocBase() { + return docBase; + } + /** * 주어진 경로에 대한 실제 파일 시스템 경로를 반환합니다. *

* 이 메서드는 보안을 위해 상위 디렉토리로 이동하는 것을 방지할 수 있습니다. * * @param path 요청된 리소스의 경로 - * @return 해당 경로의 절대 파일 시스템 경로, 또는 docBase가 설정되지 않았거나 path가 null인 경우 null + * @return 해당 경로의 실제 파일 시스템 경로, 또는 유효하지 않은 경우 null */ public String getRealPath(String path) { - if (docBase == null || path == null) { + if (path.contains("..")) { return null; } - File file = new File(docBase, path); - return file.getAbsolutePath(); + Path resolvedPath = Paths.get(this.docBase, path).normalize(); + return resolvedPath.toString(); } + } diff --git a/trunk/src/main/java/trunk/servlet/DefaultServlet.java b/trunk/src/main/java/trunk/servlet/DefaultServlet.java index 6c1eb6b..a10cc0e 100644 --- a/trunk/src/main/java/trunk/servlet/DefaultServlet.java +++ b/trunk/src/main/java/trunk/servlet/DefaultServlet.java @@ -1,5 +1,7 @@ package trunk.servlet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import trunk.container.ServletContextAware; import trunk.container.StandardContext; import trunk.http11.request.HttpRequest; @@ -19,6 +21,7 @@ * @author jungbin97 */ public class DefaultServlet extends HttpServlet implements ServletContextAware { + private static final Logger log = LoggerFactory.getLogger(DefaultServlet.class); private StandardContext context; @Override @@ -29,8 +32,11 @@ public void setServletContext(StandardContext context) { @Override public void service(HttpRequest request, HttpResponse response) throws IOException { String requestPath = request.getStartLine().getRequestUri(); - String realPathStr = context.getRealPath(requestPath); + if ("/".equals(requestPath)) { + requestPath = "/index.html"; + } + String realPathStr = context.getRealPath(requestPath); if (realPathStr == null) { sendNotFound(response); return; diff --git a/trunk/src/test/java/trunk/container/ContextConfigTest.java b/trunk/src/test/java/trunk/container/ContextConfigTest.java index bd0a53c..2f8f9d6 100644 --- a/trunk/src/test/java/trunk/container/ContextConfigTest.java +++ b/trunk/src/test/java/trunk/container/ContextConfigTest.java @@ -1,5 +1,6 @@ package trunk.container; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -9,23 +10,36 @@ import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; class ContextConfigTest { @TempDir Path tempDir; + private StandardContext mockContext; + private Path webInfDir; + + @BeforeEach + void setUp() throws IOException { + mockContext = mock(StandardContext.class); + + when(mockContext.getDocBase()).thenReturn(tempDir.toString()); + + webInfDir = tempDir.resolve("WEB-INF"); + Files.createDirectories(webInfDir); + } + @Test @DisplayName("web.xml을 정상 파싱하여 서블릿을 등록한다.") void parseXmlSuccess() throws IOException { // given - File xmlFile = tempDir.resolve("web.xml").toFile(); + File xmlFile = webInfDir.resolve("web.xml").toFile(); try (FileWriter fileWriter = new FileWriter(xmlFile)) { fileWriter.write( """ @@ -43,11 +57,10 @@ void parseXmlSuccess() throws IOException { """); } - StandardContext mockContext = mock(StandardContext.class); ContextConfig contextConfig = new ContextConfig(mockContext); // when - contextConfig.parseWebXml(xmlFile.getPath()); + contextConfig.parseWebXml(); // then ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class); @@ -65,7 +78,7 @@ void parseXmlSuccess() throws IOException { @DisplayName("web.xml에 정상적인 서블릿 클래스가 없을 경우 예외를 발생시킨다.") void parseXmlinavlidClass() throws Exception { // given - File xmlFile = tempDir.resolve("web.xml").toFile(); + File xmlFile = webInfDir.resolve("web.xml").toFile(); try (FileWriter fileWriter = new FileWriter(xmlFile)) { fileWriter.write( """ @@ -83,12 +96,24 @@ void parseXmlinavlidClass() throws Exception { """); } - StandardContext mockContext = mock(StandardContext.class); ContextConfig config = new ContextConfig(mockContext); // then - assertThatThrownBy(() -> config.parseWebXml(xmlFile.getPath())) + assertThatThrownBy(() -> config.parseWebXml()) .isInstanceOf(RuntimeException.class) .hasMessageContaining("Failed to parse web.xml"); } + + @Test + @DisplayName("web.xml 파일이 존재하지않아도 오류 없이 정상적으로 종료된다.") + void webXmlNotFound() { + // given + ContextConfig config = new ContextConfig(mockContext); + + // when + config.parseWebXml(); + + // then + verify(mockContext, never()).addChild(anyString(), any(), anyInt()); + } }