概要

前回のプログラムではHTTPリクエストを解析するためのクラスを作り、クエリパラメータを列挙するところまで行いました。今回は簡易的なサーバーサイドプログラムを作り、フォームで送られてきたデータをHTMLの中に埋め込んでクライアントに変えところまでやりましょう。また、フォームで送られてきたデータには悪意のある物があることもあります。そのため、送られてきたデータをサニタイジングする処理も作りましょう。サニタイジングについてはこちらを参考にしてみてください。

HTTPレスポンス用のクラス作成

まずはHTTPレスポンス用のクラスHttpResponseを作ります。HTTPレスポンスヘッダやボディをメモリ(ByteArrayOutputStram)に出力するためのPrintStreamを持っています。HttpResponse.javaという名前のファイルで保存します。

import java.io.PrintStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;

import java.util.Map;
import java.util.LinkedHashMap;

import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
import java.text.SimpleDateFormat;

import java.io.IOException;

public class HttpResponse implements Closeable{
    private PrintStream out;
    private ByteArrayOutputStream body;
    private Map<String, String> header;
    
    private HttpResponse() throws IOException{
        body = new ByteArrayOutputStream();
        out = new PrintStream(new BufferedOutputStream(body));
        header = new LinkedHashMap<>();
        
        var formatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH);
        formatter.setTimeZone(TimeZone.getTimeZone("Asia/Tokyo"));
        
        header.put("Date", formatter.format(new Date()));
        header.put("Server", "d-kami's sample server");
        header.put("Content-Type", "text/html; charset=UTF-8");
    }
    
    public static HttpResponse create() throws IOException{
        return new HttpResponse();
    }
    
    public PrintStream getStream(){
        return out;
    }
    
    public byte[] getBody(){
        return body.toByteArray();
    }
    
    @Override
    public void close() throws IOException{
        out.close();
    }
}

HTTPリクエスト用クラスのソースコード

前回から特に変更ないけど一応ソースコードは載せておきます。

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);
            }
        });
    }
}

フォームのデータを受け取るためのクラス

HTMLのフォームからデータを受け取るためのクラスです。GETが来てもGETでアクセスされたことを示すメッセージを表示するのみで意味はないです。POSTがきたときにクエリパラメータにあったメッセージをHTMLに埋め込んで返す処理をしています。サニタイジングを忘れずに…。まぁ、作っているのはServletもどきみたいなもの。HttpFormSample.javaという名前で保存します。

public class HttpFormSample{
    public void doGet(HttpRequest request, HttpResponse response){
        var out = response.getStream();
        out.println("<html><head><meta charset=\"UTF-8\"></head><body>HTTP GETでアクセスされました。</body></html>");
    }
    
    public void doPost(HttpRequest request, HttpResponse response){
        String inputText = request.getParam("input");
        
        var out = response.getStream();
        out.printf("<html><head><meta charset=\"UTF-8\"></head><body>あなたが入力したのは%sです。</body></html>", HttpFormSample.sanitize(inputText));
    }
    
    private static String sanitize(String input){
        return input.replaceAll("&", "&amp;").replace("<", "&lt;").replaceAll(">", "&gt;");
    }
}

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

HTTPリクエストで要求されたパスが/program/で始まったときにexecuteServerProgramを呼び出すように変更しています。executeServerProgramは先ほどのGETやPOSTに応じてHttpFormSampleを呼び出しているだけです。

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();

            request.getParamStream().forEach(e -> System.out.printf("%s: %s\n", e.getKey(), e.getValue()));

            if(fileName.equals("/")){
                fileName = "index.html";
            }

            if(fileName.startsWith("/program/")){
                var response = HttpResponse.create();
                HttpServerSample.executeServerProgram(request, response);
                HttpServerSample.sendResponse(out, response);
            }else{
                HttpServerSample.printFile(out, fileName);
            } 
        }
    }
    
    private static void sendResponse(PrintStream out, HttpResponse response) throws IOException{
        var body = response.getBody();
        
        out.println("HTTP/1.1 200 OK");
        out.println("Content-Type: text/html; charset=UTF-8");
        out.println("Content-Length: " + body.length);
        out.println();
        out.write(body);
    }
    
    private static void executeServerProgram(HttpRequest request, HttpResponse response){
        try(response){
            var sample = new HttpFormSample();

            if(request.getMethod().equals("GET")){
                sample.doGet(request, response);
            }else if(request.getMethod().equals("POST")){
                sample.doPost(request, response);
            }
        }catch(IOException e){
            e.printStackTrace();
        }
    }

    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);
    }
}

サンプル用HTML

今回のサーバーサイドプログラムにフォームデータを送信するためのHTMLです。formタグのactionで/program/にいくように指定してあげましょう

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

最後に

今回はHttpFormRequestというクラスを事前に用意しさらに直接サーバーに組み込んでいる状態です。次回以降はサーバーサイドの処理を後から追加できるような仕組みを作っていきます。