souki-paranoiastのブログ

地方都市でプログラマーをやっている人のブログ。技術ネタ以外も少し書く。メインの言語はJava。https://paranoiastudio-japan.jimdo.com/ に所属

JavaでSocketライブラリを使ってHTTP通信を行う

 元々はネットワークスペシャリスト試験にちょっと興味を持って、それに何も知らん人が望むには必読みたいな扱いを受けている『ネットワークはなぜつながるのか』という書籍を読み、その序盤で出てくるSocketライブラリを用いてHTTP通信する例をJavaで実行しながら理解したいという考えから触り始めた。 思ったよりも時間かかったので、せっかくなので記録として残しておく。

 HTTPS通信はSocketでやるのは結構大変らしいので純粋なHTTP通信で試す。上述の書籍もHTTPS通信に対しての言及も無いし、暗号化とかがメインで基本的な考え方は変わらないはずなので今回は無視する。

 サーバ側は適当にSpringで立てる。QueryStringとBodyをコンソールに吐きつつ返すだけのアプリを用意。 ちなみに手元の環境はspring-boot-starter.2.3.1.RELEASEだったが、大きな違いはないはず。…Spring4Shellの影響受けるやつみたいだけど稼働していないし大丈夫。

@SpringBootApplication
public class SpringTestApplication {
    public static void main(String[] args) throws Exception {
        new SpringApplicationBuilder(SpringTestApplication.class)
                .properties(Collections.singletonMap("server.port", 8000))
                .run(args);
    }
}
@RestController()
public class MyController {

    @ResponseBody
    @RequestMapping(value = "/tcpIpTest", method = {RequestMethod.GET, RequestMethod.POST})
    public String tcpIpTest(HttpServletRequest request, HttpServletResponse response) throws IOException {
        StringBuilder output = new StringBuilder();
        output.append("str *******").append(System.lineSeparator());
        output.append("QueryString").append(System.lineSeparator());
        output.append(request.getQueryString()).append(System.lineSeparator());
        output.append("Body").append(System.lineSeparator());
        request.getReader().lines().forEach(e -> output.append(e).append(System.lineSeparator()));
        output.append("end *******").append(System.lineSeparator());
        String s = output.toString();
        System.out.println(s);
        return "Return \n" + s;
    }
}

サーバが出来上がったらcurlで疎通確認。

$ curl -X POST "http://localhost:8000/tcpIpTest?xxx=a&c=123" --trace-ascii /dev/stdout -s -d '{"hoge":"fuga"}'
== Info: Expire in 0 ms for 6 (transfer 0xe948d0)
== Info: Expire in 1 ms for 1 (transfer 0xe948d0)
== Info: Expire in 3 ms for 1 (transfer 0xe948d0)
== Info: Expire in 6 ms for 1 (transfer 0xe948d0)
== Info:   Trying ::1...
== Info: TCP_NODELAY set
== Info: Expire in 149990 ms for 3 (transfer 0xe948d0)
== Info: Expire in 200 ms for 4 (transfer 0xe948d0)
== Info: Connected to localhost (::1) port 8000 (#0)
=> Send header, 169 bytes (0xa9)
0000: POST /tcpIpTest?xxx=a&c=123 HTTP/1.1
0026: Host: localhost:8000
003c: User-Agent: curl/7.64.0
0055: Accept: */*
0062: Content-Length: 15
0076: Content-Type: application/x-www-form-urlencoded
00a7:
=> Send data, 15 bytes (0xf)
0000: {"hoge":"fuga"}
== Info: upload completely sent off: 15 out of 15 bytes
<= Recv header, 15 bytes (0xf)
0000: HTTP/1.1 200
<= Recv header, 40 bytes (0x28)
0000: Content-Type: text/plain;charset=UTF-8
<= Recv header, 20 bytes (0x14)
0000: Content-Length: 83
<= Recv header, 37 bytes (0x25)
0000: Date: Sat, 09 Apr 2022 15:54:39 GMT
<= Recv header, 2 bytes (0x2)
0000:
<= Recv data, 83 bytes (0x53)
0000: Return .str *******
0015: QueryString
0022: xxx=a&c=123
002f: Body
0035: {"hoge":"fuga"}
0046: end *******
Return
str *******
QueryString
xxx=a&c=123
Body
{"hoge":"fuga"}
end *******
== Info: Connection #0 to host localhost left intact

問題ないことが確認できたので、クライアント側にあたるアプリの作成。

まず先に、Javaの標準APIで叩けるようにする。

package socket;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

public class SocketTest {
    public static void main(String[] args) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(new URI("http://localhost:8000/tcpIpTest?hogehoge=xxx"))
                .POST(HttpRequest.BodyPublishers.ofString("a=1&b=ge", StandardCharsets.UTF_8))
                .build();
        HttpResponse<byte[]> response = HttpClient.newHttpClient()
                .send(request, responseInfo -> HttpResponse.BodySubscribers.ofByteArray());

        System.out.println(new String(response.body(), StandardCharsets.UTF_8));
    }
}

レスポンスはこのようになる。

Return 
str *******
QueryString
hogehoge=xxx
Body
a=1&b=ge
end *******

…とまあ、ライブラリは簡単に実装できる。普段ならこれで何も問題はないのだが、今回は勉強なのでここからが本番。

package socket;

import java.net.InetAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;

public class SocketTest {
    public static void main(String[] args) throws Exception {
        String hostName = "localhost";
        int port = 8000;

        // HTTPの通信で改行が必要になるが、CRLFである必要がある。LFだけとかだと正常に動かない。
        // リンクを残すのを忘れたが結構色々なページで言及されていたが、サーバ側の対応状況によってはLFでも良いみたい。でも今回は動かないのでCRLFで。
        // https://detail.chiebukuro.yahoo.co.jp/qa/question_detail/q1368862218
        String lineSeparator = "\r\n";

        InetAddress inetAddress = InetAddress.getByName(hostName); // ドメインからIPアドレスへの解決
        try (Socket socket = new Socket(inetAddress, port)) { // connectはコンストラクタ内部で実施。closeはtry-with-resourcesで実施。
            String messageBody = "{\"hoge\":\"fuga\"}";

            String requestLine = "POST /tcpIpTest?query=aaa HTTP/1.1";
            // ヘッダ情報は最低限の指定。Content-Typeとか諸々実際はあった方が良い
            List<String> requestHeaders = Arrays.asList(
                    "Host: %s:%s".formatted(inetAddress.getHostAddress(), port),
                    "Content-Length: " + messageBody.length()
            );


            // 完成形 ↓
            // POST /tcpIpTest?query=aaa HTTP/1.1
            // Host: 127.0.0.1:8000
            // Content-Length: 15
            //
            // {"hoge":"fuga"}

            StringBuilder requestBuilder = new StringBuilder();
            requestBuilder.append(requestLine).append(lineSeparator);
            for (String header : requestHeaders) {
                requestBuilder.append(header).append(lineSeparator);
            }
            requestBuilder.append(lineSeparator); // BODYの前に改行1つ
            requestBuilder.append(messageBody);

            socket.getOutputStream().write(requestBuilder.toString().getBytes(StandardCharsets.UTF_8));
            socket.getOutputStream().flush(); // 送信
            socket.shutdownOutput(); // 送信終了通知。HTTP1.1だとリクエストヘッダのConnection: Keep-Aliveが存在するのでそれ用のはず。
            String response = new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8); // レスポンス全行
            System.out.println(response);
            socket.shutdownInput(); // 受信終了通知。これはクライアント側からやるものなのか??まだ理解不足
        }
    }
}

これでひとまず動くようになる。リクエストヘッダのHostとConent-Lengthは必須らしい。無いと動かなかった。 定義まで調べた方が良いだろうけど…。 RFC 7231 — HTTP/1.1: Semantics and Content (日本語訳)

まとめていたら更に時間がかかったので今日はこの辺で。 Content-Lengthと対に(?)なるTransfer-Encoding: chunkedの実装も試していて、このあたりを利用してクライアント側からダミーデータを一定間隔でリクエストをかければ、リクエスト送信後の画面クローズとかも拾えそうだなーとかそんなのことも調べたのでまた書きたい。