HTTP Request
HTTP 응답을 처리하기 위해서는 HTTP Request 부터 완벽하게 받아내야한다.
(HTTP REQUEST - RFC2616)[https://datatracker.ietf.org/doc/html/rfc2616#section-5]
HTTP 를 정의해놓은 공식 문서를 참고해보자. HTTP Request 는 총 3가지로 나눌 수 있다.
- Request Line
- Request-header
- message-body
POST /cgi-bin/process.cgi HTTP/1.1
User-Agent: Mozilla/4.0 (compatible; MSIE5.01; Windows NT)
Host: www.tutorialspoint.com
Content-Type: application/x-www-form-urlencoded
Content-Length: length
Accept-Language: en-us
Accept-Encoding: gzip, deflate
Connection: Keep-Alive
licenseID=string&content=string&/paramsXML=string
위와 같은 실제 요청 예시에서 쉽게 각각을 매핑시킬 수 있을 것이다. 이를 바탕으로 우선 Request 객체를 생성해보자.
HttpRequest
public class MyHttpRequest {
/**
* Request-Line = Method SP Request-URI SP HTTP-Version CRLF
* <p>
* Request-URI = path [ "?" query ]
*/
private final String method;
private final String path;
private final Map<String, String> query;
private final String version;
/**
* Headers
* <p>
* message-header = field-name ":" [ field-value ]
*/
private final Map<String, String> headers;
/**
* message-body
* <p>
*/
private final byte[] body;
...
}
HttpRequest 라는 이름은 이미 존재하기에 MyHttpRequest 를 생성하였다. 해당 객체도 크게 3가지 부분으로 나누었으며 Request Line 은 더 세분화하였다.
- Request Line
- method : HTTP method 를 저장한다.
- path, query : 경로와 쿼리를 분리해 저장한다.
- version : HTTP version 을 저장한다.
- Request-header
- Message-body : 바이트 파일을 대비해 byte[] 형식을 가지게 한다.
HttpRequestParser
요청이 들어왔을 경우 해당 요청들을 읽어 MyHttpRequest 에 대응시켜야한다. 이 역시 3 단계로 나누었으며 각각을 살펴보자.
기본적인 구조는 아래와 같다. Byte 배열을 위한 Buffer 를 생성하고 한 바이트씩 읽으며 버퍼에 저장한다.
가득 차거나 끝나면 buffer 를 flush 한다.
public MyHttpRequest parseRequest(InputStream in) throws IOException {
ByteArrayOutputStream requestLineBuffer = new ByteArrayOutputStream();
int nextByte;
// 읽어들일 버퍼 크기
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
try {
// Read the request line
...
// Read the request headers
...
// Read the request body
...
return new MyHttpRequest(method, path, queries, version, requestHeaders, requestBody);
} catch (IOException e) {
throw new IllegalArgumentException("Invalid HTTP request : " + e.getMessage());
}
}
Request Line
우선 첫 줄을 읽고 이를 method, path, queries, version 으로 파싱해야한다.
// Read the request line
while ((nextByte = in.read()) != -1) {
requestLineBuffer.write(nextByte);
// 개행 문자('\r', '\n')가 나타나면 request line을 완료함
if (nextByte == '\r' || nextByte == '\n') {
break;
}
}
// request line을 UTF-8 문자열로 변환하여 처리
String requestLine = requestLineBuffer.toString("UTF-8").trim();
String[] requestLineParts = parseRequestFirstLine(requestLine);
String method = requestLineParts[0];
String[] url = requestLineParts[1].split("\\?");
String path = url[0];
Map<String, String> queries = new HashMap<>();
if (url.length > 1) {
queries = parseQuery(queries, url[1]);
}
String version = requestLineParts[2];
이때 Request Line 에서는 공백으로 구분짓는데, 공백의 개수는 옵셔널이기에 아래와 같이 파싱할 수 있다.
public String[] parseRequestFirstLine(String requestLine) {
// Split the request line by spaces
String[] firstLines = requestLine.split("\\s+");
// The request URI (path) is the second element in the tokens array
if (firstLines.length >= 2) {
return firstLines;
} else {
throw new IllegalArgumentException("Invalid HTTP request line: " + requestLine);
}
}
path 에서도 아래와 같이 파싱해 쿼리를 추출할 수 있다.
public Map<String, String> parseQuery(Map<String, String> queries, String query) {
String[] keyValues = query.split("&");
for (String keyValue : keyValues) {
String[] keyAndValue = keyValue.split("=");
String key = URLDecoder.decode(keyAndValue[0], StandardCharsets.UTF_8);
String value = URLDecoder.decode(keyAndValue[1], StandardCharsets.UTF_8);
queries.put(key, value);
}
return queries;
}
Request-header
헤더는 위에서 쿼리를 추출한 것과 비슷하게 key-value 를 추출한다. 이때도 역시 콜론 뒤에 공백이 옵션적으로 주어지기에 trim() 을 사용해서 파싱해주어야한다.
// Read the request headers
Map<String, String> requestHeaders = new HashMap<>();
boolean endOfHeaders = false;
while (!endOfHeaders && (nextByte = in.read()) != -1) {
requestLineBuffer.write(nextByte);
// 개행 문자('\r', '\n')가 나타나면 header 처리
if (nextByte == '\n') {
String headerLine = requestLineBuffer.toString("UTF-8").trim();
requestLineBuffer.reset();
if (headerLine.isEmpty()) {
endOfHeaders = true;
} else {
int index = headerLine.indexOf(':');
if (index > 0) {
String headerName = headerLine.substring(0, index).trim();
String headerValue = headerLine.substring(index + 1).trim();
requestHeaders.put(headerName, headerValue);
}
}
}
Message-body
BufferedReader 를 사용하지 않고 InputStream 을 그대로 사용한 이유가 Message-body 때문이다. BufferedReader 를 사용하게 되면 가장 작은 단위가 char 배열이기 때문이다. 바이트 메세지 그대로를 읽기 위해 InputStream 을 사용하였다.
그리고 여기서는 Content-Length 를 사용해서 해당 바이트까지만 읽어야한다.
// Read the request body
byte[] requestBody = null;
if (requestHeaders.containsKey("Content-Length")) {
int contentLength = Integer.parseInt(requestHeaders.get("Content-Length"));
requestBody = new byte[contentLength];
int bytesRead = 0;
while (bytesRead < contentLength) {
int read = in.read();
if (read == -1) {
break;
}
requestBody[bytesRead++] = (byte) read;
}
}
HTTP 메세지에서는 끝났다는 신호를 따로 주지 않는다.
- 때문에 일반 버퍼로 메세지들을 받아낼 경우에 어디서 끝나는 지 알 수 없다.
- 이 부분이 BufferedReader 를 사용해서 메세지를 읽어낼 경우 무한루프에 빠지게 되는 원인이다.
- 더 정확하게는 BufferedReader 가 여기까지 읽게할 수 있는 개행문자가 마지막에 존재하지 않기 때문에 버퍼에는 입력이 들어오지만 이를 가지고 있을 뿐 처리하지 못한다. (문장이 아직 더 남아있다고 생각하기에)
- 그렇기에 Header 에 Message-body 의 총 길이를 담아내고 정확히 이 길이만큼만 바이트를 읽어낸다면 손실없이 메세지를 읽어낼 수 있다.
HTTP Response
MyHttpRequest 객체에 담긴 정보를 처리하고 반환 시에는 필요한 정보를 MyHttpResponse 에 담아서 보낸다.
이렇게하는 이유는 각 요청마다 반환해주어야하는 정보들이 모두 다르지만 전체적인 형식은 동일하다.
HttpResponse
public class MyHttpResponse {
/**
* Status-Line = HTTP-Version SP Status-Code SP Reason-Phrase CRLF
*/
private final String version = "HTTP/1.1";
private int statusCode;
private String statusMessage;
/**
* Headers
* <p>
* message-header = field-name ":" [ field-value ]
*/
private Map<String, String> headers;
/**
* message-body
* <p>
*/
private byte[] body;
...
}
Http 응답 또한 3가지로 구분지을 수 있다.
- Request Line
- Version : 대부분이 HTTP/1.1 로 고정이다.
- statusCode : 200, 302 등 상태 코드가 필요하다.
- statusMessage : OK, FOUND 등 상태 코드에 맞는 메세지가 필요하다.
- Request-header
- Message-body : String 과 Byte 배열의 차이로 인해 byte[] 타입이어야한다.
HttpResponseParser
public void parseResponse(DataOutputStream dos, MyHttpRequest httpRequest) throws IOException {
MyHttpResponse httpResponse;
... // Request 처리
response(dos, httpResponse);
}
public void response(DataOutputStream dos, MyHttpResponse httpResponse) {
try {
dos.writeBytes(httpResponse.getVersion() + " " + httpResponse.getStatusCode() + " " + httpResponse.getStatusMessage() + " \r\n");
for (String key : httpResponse.getHeaders().keySet()) {
dos.writeBytes(key + ": " + httpResponse.getHeaders().get(key) + "\r\n");
}
dos.writeBytes("\r\n");
dos.write(httpResponse.getBody(), 0, httpResponse.getBody().length);
dos.writeBytes("\r\n");
dos.flush();
} catch (IOException e) {
logger.error(e.getMessage());
}
}
Request 를 처리하고 생성한 httpResponse 를 통해 위와 같이 응답을 보낼 수 있다.
처리는 다음에 다루기로하고 우선 어떤식으로 응답을 보내는 지 먼저 확인하자.
Http Response 형식에 맞게 다시 구성한다.
모든 정보들을 dos 에 담아서 flush 하게 되면 원하는 모습으로 응답을 보낼 수 있다.
'Programming > JAVA' 카테고리의 다른 글
[JAVA] JVM 구조 총정리 (0) | 2024.07.10 |
---|---|
[JAVA] Spring 없이 웹 서버 구축! (4) 라우팅을 위한 Mapper (0) | 2024.07.09 |
[Java] String 과 Byte[] 의 차이 by 인코딩! 바이트 파일을 String 으로 읽으면? (1) | 2024.07.05 |
[JAVA] Spring 없이 웹 서버 구축! (2) 다양한 컨텐츠 타입 지원 (0) | 2024.07.04 |
[JAVA] GC, Garbage Collection 알고리즘과 동작 방식 (0) | 2024.07.03 |