概要

前回までのプログラムではクライアントから要求されたファイルを返すだけの物でした。今回からはフォームなどから送信されるクエリパラメータの取得を行ってみます。そのためにクライアントから送信されるHTTPリクエストを解析するためのクラスを作ってみました。

HTTPリクエストを解析するためのクラス作成

まずは簡易的にHTTPヘッダとクエリパラメータを解析する機能を付けました。実際にHTTPリクエストでどんなデータが流れてくるかはこちらが参考になるかと思います。ではソースコードです。HttpRequest.javaという名前で保存しておきます。

import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import java.net.URLDecoder;

import java.util.Map;
import java.util.HashMap;
import java.util.Optional;
import java.util.Arrays;

import java.util.stream.Stream;

import java.io.IOException;

public class HttpRequest{
    private String method;
    private String path;
    private String version;
    private String body;
    
    private Map<String, String> header;
    private Map<String, String> params;
    
    private HttpRequest(String method, String path, String version){
        header = new HashMap<>();
        params = new HashMap<>();
        
        this.method = method;
        this.path = HttpRequest.parsePath(this, path);
        this.version = version;
    }
    
    public String getMethod(){
        return method;
    }
    
    public String getPath(){
        return path;
    }
    
    public String getVersion(){
        return version;
    }
    
    private void addHeader(String name, String value){
        if(header.containsKey(name)){
            header.put(name, header.get(name) + ", " + value);
        }else{
            header.put(name, value);
        }
    }
    
    public String getHeader(String name){
        return header.getOrDefault(name, "");
    }

    public boolean hasHeader(String name){
        return header.containsKey(name);
    }
    
    public Stream<Map.Entry<String, String>> getHeaderStream(){
        return header.entrySet().stream();
    }
    
    private void addParam(String name, String value){
        if(params.containsKey(name)){
            params.put(name, params.get(name) + ", " + value);
        }else{
            params.put(name, value);
        }
    }
    
    public String getParam(String name){
        return params.getOrDefault(name, "");
    }
    
    public boolean hasParam(String name){
        return params.containsKey(name);
    }
    
    public Stream<Map.Entry<String, String>> getParamStream(){
        return params.entrySet().stream();
    }
    
    public void setBody(String body){
        this.body = body;
    }
    
    public Optional<String> getBody(){
        return Optional.ofNullable(body);
    }
    
    //クライアントから送られてきたHTTPリクエストを解析する
    public static HttpRequest parse(BufferedReader reader) throws IOException{
        String line = reader.readLine();

        if (line == null){
            throw new IOException("Invalid HTTP Request");
        }
        
        var status = line.split(" ");
        
        if(status.length != 3){
            throw new IOException("Invalid HTTP Request: " + line);
        }
        
        var request = new HttpRequest(status[0], status[1], status[2]);
        
        HttpRequest.parseHeader(request, reader);
        HttpRequest.parseContent(request, reader);
        
        return request;
    }
    
    //クライアントから送られてきたHTTPリクエストのPathを解析する
    private static String parsePath(HttpRequest request, String path){
        int index = path.indexOf("?");
        
        if(index == -1){
            return path;
        }else{
            HttpRequest.parseQueryParams(request, path.substring(index + 1));
            return path.substring(0, index);
        }
    }
    
    //HTTPヘッダをパラメータの名前と値に分割してHttpRequestに保存する
    private static void parseHeader(HttpRequest request, BufferedReader reader) throws IOException{
        String line;
        
        while((line = reader.readLine()) != null && !line.isEmpty()){
            var parts = line.split(":", 2);
            
            if(parts.length == 2){
                String name = parts[0].trim();
                String value = parts[1].trim();
                
                if(request.hasHeader(name)){
                    request.addHeader(name, request.getHeader(name) + ", " + value);
                }else{
                    request.addHeader(name, value);
                }
            }
        }
    }
    
    //HTTPのボディ部分を取得し解析する
    private static void parseContent(HttpRequest request, BufferedReader reader) throws IOException{
        if(request.hasHeader("Content-Length")){
            int length = Integer.parseInt(request.getHeader("Content-Length"));
            var body = new char[length];
            reader.read(body);
            
            String content = new String(body);
            request.setBody(content);
            HttpRequest.parseQueryParams(request, content);
        }
    }
    
    //クエリをパラメータの名前と値に分割してHttpRequestに保存する
    private static void parseQueryParams(HttpRequest request, String query) {
        Arrays.stream(query.split("&")).map(p -> p.split("=", 2)).filter(v -> v.length == 2).forEach(v -> {
            String name = URLDecoder.decode(v[0], StandardCharsets.UTF_8);
            String value = URLDecoder.decode(v[1], StandardCharsets.UTF_8);
            
            if(request.hasParam(name)){
                request.addParam(name, request.getParam(name) + ", " + value);
            }else{
                request.addParam(name, value);
            }
        });
    }
}

getBodyはリクエストのボディ部分が無いときにnullが返ることを考慮しOptionalを返していますが最初からbodyに空文字列を入れておけば気にすることも無いかもしれません。クエリパラメータに関してはGETやPOSTで共通して使えるようになっています。GETのときはURLにパラメータが付与され、POSTのときはリクエストの本文に来ます。

サーバー本体のソースコード

ではこのHTTPリクエストクラスを利用したHTTPサーバー本体のソースコードです。HTTPリクエストにクエリパラメータが含まれていれば標準出力にパラメータが列挙されるようになっています。http://127.0.0.1:12435/index.html?parameter=value のようにアクセスしたり、後述のHTMLを準備してフォームからPOSTすることでパラメータが列挙されます。

import java.net.Socket;
import java.net.ServerSocket;

import java.io.BufferedReader;
import java.io.InputStreamReader;

import java.io.PrintStream;
import java.io.BufferedOutputStream;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.Files;
import java.nio.charset.StandardCharsets;

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

import java.io.IOException;

public class HttpServerSample{
    //ファイルの拡張子とMIMEタイプを関連付けるMap
    private static final Map<String, String> MIME_MAP;
    
    static{
        MIME_MAP = new HashMap<>();
        
        //HTMLやCSS、JavaScriptのMIMEタイプ
        MIME_MAP.put("htm", "text/html");
        MIME_MAP.put("html", "text/html");
        MIME_MAP.put("css", "text/css");
        MIME_MAP.put("js", "text/javascript");

        //画像のMIMEタイプ
        MIME_MAP.put("jpg", "image/jpeg");
        MIME_MAP.put("jpeg", "image/jpeg");
        MIME_MAP.put("png", "image/png");
        
        //音声のMIMEタイプ
        MIME_MAP.put("wav", "audio/wav");
        MIME_MAP.put("mp3", "audio/mpeg");
        MIME_MAP.put("aac", "audio/aac");
        
        //動画のMIMEタイプ
        MIME_MAP.put("mpeg", "video/mpeg");
        MIME_MAP.put("mp4", "video/mp4");
    }
    
    public static void main(String... args){
        //ポート12435でServerSocket作成
        try(var server = new ServerSocket(12435)){
            HttpServerSample.accept(server);
        }catch(IOException e){
            e.printStackTrace();
        }
    }

    private static void accept(ServerSocket server) throws IOException{
        while(true){
            //クライアントからの接続を待機する
            var socket = server.accept();
            
            //クライアントとの通信用のThreadを作る
            var thread = new Thread(() -> {
                try(socket){
                    //クライアントから接続されたときの処理
                    HttpServerSample.connectHttp(socket);
                }catch(IOException ex){
                    ex.printStackTrace();
                }
            });
                
            thread.start();
        }
    }

    private static void connectHttp(Socket socket) throws IOException{
        try(
            var in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
            var out = new PrintStream(new BufferedOutputStream(socket.getOutputStream()), true);
        ){
            //クライアントから受けっとったHTTPリクエストを解析する
            var request = HttpRequest.parse(in);

            String fileName = request.getPath();
            
            //HTTPクエリパラメータを列挙する
            request.getParamStream().forEach(e -> System.out.printf("%s: %s\n", e.getKey(), e.getValue()));
            
            if(fileName.equals("/")){
                fileName = "index.html";
            }
            
            HttpServerSample.printFile(out, fileName);
        }
    }

    private static void printFile(PrintStream out, String fileName) throws IOException{
        var path = Paths.get("www", fileName);
        System.out.println("Request File: " + fileName);
        
        if(Files.exists(path)){
            String extension = HttpServerSample.getFileExtension(path).toLowerCase();

            if(HttpServerSample.MIME_MAP.containsKey(extension)){
                System.out.println("MIME Type: " + HttpServerSample.MIME_MAP.get(extension));
                
                //拡張子から画像として判断し画像ファイルを読み込んでそのまま返す
                var content = Files.readAllBytes(path);

                out.println("HTTP/1.1 200 OK");
                out.println("Content-Type: " + HttpServerSample.MIME_MAP.get(extension));
                out.println("Content-Length: " + content.length);
                out.println("");

                out.write(content);
            }else{
                //拡張子で判断できなければとりあえずHTMLで返す
                out.println("HTTP/1.1 200 OK");
                out.println("Content-Type: text/html; charset=UTF-8");
                out.println();
                Files.lines(path).forEach(out::println);
            }
        }else{
            //ファイルが見つからなければ404を返す
            out.println("HTTP/1.1 404 Not Found");
            out.println("Content-Type: text/html; charset=UTF-8");
            out.println();
            out.println("<html><title><head>404 Not Found</head></title><body><h1>404 Not Found</h1></body></html>");

            System.err.printf("%s is Not Found\n", path.toAbsolutePath().toString());
        }
    }
    
    private static String getFileExtension(Path path) {
        String fileName = path.getFileName().toString();
        int index = fileName.lastIndexOf('.'); //ファイル名の最後のドットの位置を調べる

        //ファイル名にドットが無い場合は拡張子なしとして空文字列を返す
        if(index == -1){
            return ""; // 拡張子なし
        }

        //ファイル名にドットがあればそれ以降を返す
        return fileName.substring(index + 1);
    }
}

HTTP POSTを行うためのHTML

下記内容をindex.htmlというファイルに保存し、wwwディレクトリに入れておきます。フォームはあるもののデータを送信しても何も反映はされません。次回以降のプログラムで反映されるようにします。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Hello</title>
</head>
<body>
  <h1>サンプルページ</h1>
  <form action="./" method="post">
    <input type="text" name="input">
    <input type="submit" value="送信">
    <input type="hidden" name="type" value="other">
  </from>
</body>
</html>