🍍

6.1 TCPとUDPによるネットワーク(ソケット、チャネル、ByteBuffer、ノンブロッキングI/O等)~Java Advanced編

2023/11/05に公開

はじめに

自己紹介

皆さん、こんにちは、斉藤賢哉と申します。私はこれまで、25年以上に渡って企業システムの開発に携わってきました。特にアーキテクトとして、ミッションクリティカルなシステムの技術設計や、Javaフレームワーク開発などの豊富な経験を有しています。
様々なセミナーでの登壇や雑誌への技術記事寄稿の実績があり、また以下のような書籍も執筆しています。

いずれもJava EEJakarta EE)を中心にした企業システム開発のための書籍です。中でも 「アプリケーションアーキテクチャ設計パターン」は、(Javaに限定されない)比較的普遍的なテーマを扱っており、内容的にはまだまだ陳腐化していないため、興味のある方は是非手に取っていただけると幸いです(中級者向け)。

Udemy講座のご紹介

この記事の内容は、私が講師を務めるUdemy講座『Java Advanced編』の一部の範囲をカバーしたものです。『Java Advanced編』はこちらのリンクから購入できます(セールス対象外のためいつも同じ価格)。また定価の約30%OFFで購入可能なクーポンをZenn内で定期的に発行していますので、興味のある方は、ぜひ私の他の記事をチェックしてみてください。

この講座は、以下のような皆様にお薦めします。

  • Javaの基本的なスキルを習得済みで、さらなるレベルアップを目指している方
  • 将来的なキャリアとして、希少性の高い上級エンジニアやアーキテクトを志向している方
  • フリーランスエンジニアとして付加価値の更なる向上を図っている方
  • 「Oracle認定Javaプログラマ」の資格取得を目指している方

この記事を含むシリーズ全体像

この記事はJava SEの一部の機能・仕様を取り上げたものですが、一連のシリーズになっており、シリーズ全体でJava SEを網羅しています。また認定資格である「Oracle認定Javaプログラマ」(Silver、Gold)の範囲もカバーしています。シリーズの全体像および「Oracle認定Javaプログラマ」の範囲との対応関係については、以下を参照ください。

https://zenn.dev/kenya_saitoh/articles/3fe26f51ab001b

6.1 TCPとUDPによるネットワーク

チャプターの概要

このチャプターでは、TCP/IPの概念や、Java NIOと呼ばれるAPIによってTCP/IPベースのネットワーク通信を行う方法について学びます。

6.1.1 ネットワークプログラミングの基本

ネットワークプログラミングの全体像

このチャプターおよび次のチャプターでは、JavaによるTCP/IPベースのネットワークプログラミングについて、説明します。
TCP/IPとは、TCPとIPをベースにした通信プロトコルの総称で、インターネットを含む世界中のネットワーク環境において標準的に利用されています。TCP/IPでは、上位から下位まで機能が4つの階層に分かれており、接続されたコンピューター同士は、双方の同じ階層のみを意識します。

【図6-1-1】TCP/IPの4つの階層
image.png

4つの階層の中で最上位にあたるのがアプリケーション層です。この層は、アプリケーションで利用するデータフォーマットや手順を定義した、抽象度の高い層です。
この層の中でも特に代表的なプロトコルが、HTTP(Hypertext Transfer Protocol)です。HTTPは元来はWWW(World Wide Web)のための通信プロトコルですが、WWWに限らず様々な用途に応用されています。特に昨今の企業システムでは、アプリケーションとアプリケーションがネットワークを経由して連携する場合、HTTPを使うのが一般的です。HTTPによってアプリケーション連携を行う方法については、次のチャプターで取り上げます。
さて、TCP/IPの階層に話を戻します。アプリケーション層のもう1つ下の層が、トランスポート層です。この層はコンピューター同士のデータ伝送を制御する層で、その代表がTCPとUDPです。TCPやUDPによるネットワーク通信はHTTPよりも通信量を抑えることができ、またHTTPサーバーをわざわざ立てる必要もないため、簡易的な通信には適しています。例えば企業システムであれば、ログの転送や、IoT機器からのセンサーデータの収集、といった用途が考えられます。HTTPにはメソッドやステータスといった仕様があり、通信プログラミングではそれらを設計する必要がありますが、簡易的な通信ではこれらは過剰とも言えます。またTCPやUDPはHTTPよりも下位のプロトコルのため、ネットワークの仕組みをより低レベルで理解する上でも役に立ちます。
JavaにおけるTCPおよびUDPにおけるネットワークプログラミングについては、このチャプターでこの後説明します。

サーバーとクライアント

ネットワークプログラミングでは、機能を提供する側をサーバー、機能を要求する側をクライアントと呼びます。クライアントは、何らかの要求をデータとしてサーバーに送信します。サーバーは通常、常時起動された状態にあり、クライアントからの要求を待ち続けています。そしてクライアントからデータを受信すると、その内容に応じて業務処理を行い、その応答をクライアントに返送します。このとき、クライアントからサーバーに送信されるデータをリクエスト、サーバーからクライアントに返送されるデータをレスポンスと呼称します。本コースにおけるネットワークの説明では、これらの用語で統一します。

【図6-1-2】サーバーとクライアント
image.png

TCPとは

TCP(Transmission Control Protocol)とは、TCP/IPにおけるトランスポート層のプロトコルの一種です。TCPでは、ポート番号という識別番号によって、各IPパケットがどのアプリケーションに対応するものかを識別して振り分けます。
またTCPによる通信は「コネクション型」と呼ばれ、まずクライアントとサーバー間で接続を確立します。このとき、いわゆる「3ウェイ・ハンドシェイク」と呼ばれる手続きが行われます。その後、確立した接続によってデータの送受信を行い、送受信が終わると接続を切断するという手順を踏みます。
TCPでは、宛先が確実にデータを受け取ったかを確認したり、データの欠損を検知して再送したり、受信したデータを送信順に並べ替えたりすることが可能となるため、通信の信頼性が向上します。
TCPをベースにしたアプリケーション層のプロトコルとしては、WWWのためのHTTPや、電子メールに使われるPOPやSMTPなどがあります。

UDPとは

UDP(User Datagram Protocol)とは、TCP/IPにおけるトランスポート層のプロトコルの一種で、TCPと同様に、ポート番号によってIPパケットを振り分けます。
UDPにおける通信は「コネクションレス型」と呼ばれ、クライアントとサーバー間で接続を確立する、という手順を持ちません。
UDPでは、TCPのようなデータの送達管理や欠損の検知、並べ替えといった機能を持たないため、通信の信頼性はTCPよりも劣ります。ただしその分、通信コストが抑えられるという特徴を持ちます。
UDPをベースにしたアプリケーション層のプロトコルには、名前解決のためのDNS (Domain Name System)などがあり、また音声や動画のストリーミングなど、リアルタイム性が重要視される通信にも使われています。

ネットワーク通信用クラスライブラリの変遷

Javaでは、ネットワーク通信用のクラスライブラリが、Java SEによって標準的に提供されています。Java SEにおけるネットワーク通信用のクラスは、基本的にjava.netパッケージに所属しています。ただしJava 1.4で導入されたJava NIO(あるいはJava 7で導入されたJava NIO.2)により、TCPやUDPにおけるネットワークプログラミングで利用するクラス群が、刷新されました。
Java NIOにおける通信では、従来からの機能に加えて、後述するノンブロッキングI/Oを利用できる点が特徴です。Java NIOにおけるネットワーク通信用のクラスは、java.nioパッケージ、java.nio.channelsパッケージ、java.nio.charsetパッケージといったパッケージに所属しています。
現在でも、Java 1.3までのクラスによって作成されたアプリケーションを見かけることは、ゼロではありません。ただし、今後新たにTCPまたはUDPによるアプリケーションを開発する場合は、Java NIOとして提供されたクラス群を使う方が望ましいでしょう。本コースでも、Java NIOのクラスを前提に説明を進めます。
なお厳密には、Java 1.4で導入されたAPIはJava NIO、その後Java 7で導入されたAPIはJava NIO.2と呼ばれていますが、本コースでは便宜上その2つを合わせて「Java NIO」と呼称します。

ソケットとチャネル

前述したように、TCPやUDPによるネットワークプログラミング用のクラスは、Java 1.3までとJava 1.4以降のJava NIOとで大きく変わりました。具体的には以下の表を見てください。

【表6-1-1】TCPおよびUDPによるクラス

分類 用途 Java1.3までのクラス
(ソケットの抽象化)
Java NIO
(チャネルの抽象化)
TCP クライアントとの接続確立 java.net.ServerSocket java.nio.channels.ServerSocketChannel
データ読み書き java.net.Socket java.nio.channels.SocketChannel
UDP データ読み書き java.net.DatagramSocket java.nio.channels.DatagramChannel

Java1.3までは、ソケットを抽象化したクラス(〇〇Socket)を使用しますが、Java NIOではチャネルを抽象化したクラス(〇〇Channel)を使用します。
まずソケットとは、ネットワークにおける「送信元IPアドレス、送信元ポート番号、宛先IPアドレス、宛先ポート番号」の組合せのことを表す、一般的な用語です。またチャネルとは、ソケットの概念を包含し、対象をネットワークに限らず、ファイルなど入出力が可能なリソースに広げた概念です。両者はほとんど同義で使われますが、Java NIOでは、クラス名にあるとおりチャネルという用語で統一されています。
なおこの表にあるとおり、TCPではクライアントとの接続の確立とデータ読み書きとで、別々のクラスを使用します。一方UDPには接続を確立する処理がないため、データ読み書きのためのクラスしかありません。

6.1.2 TCP・UDPプログラミングの前提となるクラス

このレッスンでは、Java NIOによるTCPおよびUDPのプログラミングの説明をする前に、その前提となる、いくつかのクラスについて、紹介します。

InetSocketAddressクラス

まずjava.net.InetSocketAddressクラスです。このクラスは、ソケットアドレス、すなわちIPアドレス(またはホスト名)とポート番号の組み合わせを表すためのもので、Java NIOのAPIで引数として使われます。
例えばサーバーでは、サーバーを起動するときに、このクラスによって自身のソケットアドレスを指定します。サーバーでは自身のIPアドレスは自明なため、new InetSocketAddress(55555)といった具合にポート番号のみで初期化します。
クライアントでは、サーバーと通信するときに、このクラスによって宛先のソケットアドレスを指定します。すなわち宛先サーバーのIPアドレス(またはホスト名)とポート番号を指定して、new InetSocketAddress("localhost", 55555)といった具合に初期化します。

ByteBufferクラス

java.nio.ByteBufferクラスは、文字どおりバイト配列によってバッファを表すためのクラスです。このクラスは、Java NIOにおいて、送受信されるデータを格納するための一時領域として利用します。
Java NIOによるネットワークプログラミングをするためには、ByteBufferクラスの仕組みをある程度理解しておくことが前提になります。ByteBufferクラスにはバッファを操作するための様々なAPIがありますが、どのような挙動をするのか具体的に確認します。

snippet (pro.kensait.java.advanced.lsn_6_1_2.Main)
ByteBuffer buffer = ByteBuffer.allocate(100); //【1】
// 追加
buffer.putInt(10); //【2】
buffer.putLong(1000L); //【3】
// 取り出し
buffer.flip(); //【4】
int val1 = buffer.getInt(); //【5】
long val2 = buffer.getLong(); //【6】

最初にallocate()メソッドにサイズをバイト数で指定することで、新しいByteBufferを生成します【1】。
次にこのByteBufferに対して、データを追加していきます。まずputInt()メソッドで、int型の値として10を追加します【2】。次にputLong()メソッドで、long型の値として1000を追加します【3】。
これでいったん追加は終了です。この時点で、このByteBufferは以下のような状態になっています。ByteBufferには位置という概念があり、追加のたびに位置が移動します。

【図6-1-3】ByteBufferの状態(追加後)
image.png

なおByteBufferのサイズを超える追加があった場合は、java.nio.BufferOverflowExceptionが発生します。

続いてデータの取り出しです。データ追加後に取り出すためには、flip()メソッドによってByteBufferをフリップする必要があります【4】。フリップすると、ByteBufferの現在位置以降の内容が切り捨てられ、取り出し開始の位置が0に設定されます。この時点で、このByteBufferは以下のような状態になっています。

【図6-1-4】ByteBufferの状態(フリップ後)
image.png

この状態を作ってから、データを取り出していきます。まずgetInt()メソッドにより、最初に追加したint型の値を取り出します【5】。これで取り出し開始の位置が4に移動します。続いてgetLong()メソッドにより、2度目に書き込んだlong型の値を取り出します【6】。

ByteBufferへの文字列の追加と取り出し

前項ではByteBufferに対するプリミティブ型データの追加および取り出しを見てきましたが、ここでは文字列の追加および取り出しについて見ていきます。
文字列のByteBufferへの追加は「エンコード」と呼ばれますが、Charsetクラスのencode()メソッドを呼び出します。

snippet
ByteBuffer buffer = ByteBuffer.allocate(100);
buffer = StandardCharsets.UTF_8.encode("foo");

チャプター5.1で取り上げたように、Charsetにおける代表的な文字コードは、StandardCharsetsクラスの定数として定義されていますのでここでもそれを利用します。StandardCharsetsクラスの定数として取り出したCharsetクラスのencode()メソッドに文字列を渡すことで、このバッファに対して文字列(ここでは"foo")を追加します。

続いて文字列の取り出しは「デコード」と呼ばれますが、同じくCharsetクラスのdecode()メソッドを呼び出します。

snippet
String str = StandardCharsets.UTF_8.decode(buffer).toString();

このようにStandardCharsetsクラスの定数として取り出したCharsetクラスのdecode()メソッドに、バイトバッファを引数として渡すことで、このバッファから文字列を取り出します。
なおデコード前には必ずflip()メソッドを呼び出し、取り出し開始の位置を0に設定する必要があるのは、前述したとおりです。

6.1.3 TCPによるネットワークプログラミング

TCPサーバーの作成

このレッスンでは、TCPサーバーとTCPクライアントを作成し、ネットワーク経由でデータの送受信を行うための方法を説明します。
ここではまず、Java NIOのAPIによってTCPサーバーを作成します。TCPサーバーは、メインクラスとして作成します。このTCPサーバーの機能はいたってシンプルです。TCPクライアントからリクエストを受信したら、「Hello! 私は〇〇です。」というメッセージを生成します。そして生成したメッセージは、サーバーからの応答を待っているTCPクライアントに、レスポンスとして返送するものとします。

【図6-1-3】TCPサーバーとTCPクライアント
image.png

具体的にコードで見ていきましょう。まずはメインクラス(メインメソッド)です。

snippet_1 (pro.kensait.java.advanced.lsn_6_1_3.server.Main)
public static void main(String[] args) {
    try (
            //【1】ServerSocketChannelをオープンする
            ServerSocketChannel ssc = ServerSocketChannel.open()) {
        //【2】ソケットアドレス(ポート番号)をバインドする
        ssc.bind(new InetSocketAddress(55555));
        while (true) { //【3】
            SocketChannel socketChannel = ssc.accept(); //【4】接続を確立する(ブロッキング)
            readAndWrite(socketChannel); //【5】
        }
    } catch (IOException ioe) {
        throw new RuntimeException(ioe);
    }
}

最初にServerSocketChannelをオープンします【1】。ServerSocketChannelは、TCPクライアントからの接続を待ち受け、要求に応じて接続を確立するためのチャネルを表します。またこの処理は、try-with-resources文によってリソースを自動クローズするようにします。次にオープンしたServerSocketChannelに対して、ポート番号をバインドします【2】。このプログラムはサーバー側で動作するものなので、ここではwhile文にtrueを指定して、無限ループによって常駐プロセス化を図っています【3】。無限ループの中では、ServerSocketChannelのaccept()メソッドを呼び出し、クライアントからの接続要求を待機します【4】。ここが最初のブロッキングポイントです。TCPクライアントからの要求によって接続が確立されると、このメソッドはSocketChannelを返し、処理が再開されます。SocketChannelは、TCPクライアントとの間で、データを読み込んだり書き込んだりするためのチャネルを表します。再開後は、ここでは同一クラス内にあるreadAndWrite()という別メソッドを呼び出すようにしています【5】。
それでは、readAndWrite()メソッドの中を見ていきましょう。

snippet_2 (pro.kensait.java.advanced.lsn_6_1_3.server.Main)
private static void readAndWrite(SocketChannel socketChannel) {
    //【1】新しいByteBufferを割り当てる(リクエスト用、レスポンス用)
    ByteBuffer requestBuffer = ByteBuffer.allocate(1000);
    ByteBuffer responseBuffer = ByteBuffer.allocate(1000);
    try {
        //【2】SocketChannelからByteBufferにデータを読み込む(ブロッキング)
        socketChannel.read(requestBuffer);
        //【3】ByteBufferをフリップする
        requestBuffer.flip();
        //【4】リクエストをByteBufferから取り出し、何らかの業務処理を行う
        String request = StandardCharsets.UTF_8.decode(requestBuffer).toString();
        String response = "Hello! 私は" + request + "です。";
        //【5】レスポンスをByteBufferに追加する
        responseBuffer = StandardCharsets.UTF_8.encode(response);
        //【6】SocketChannelにByteBufferからデータを書き込む
        socketChannel.write(responseBuffer);
        // SocketChannelをクローズする
        socketChannel.close();
    } catch (IOException ioe) {
        throw new RuntimeException(ioe);
    }
}

前述したようにJava NIOのAPIでは、送受信されるデータを格納するための一時領域として、ByteBufferを利用します。クライアントから受信したリクエスト用のByteBufferと、クライアントに返送するレスポンス用のByteBufferを、それぞれ生成しています【1】。次にSocketChannelのread()メソッドを呼び出し、データの受信を待機します【2】。ここがもう1つのブロッキングポイントです。クライアントからデータが送信されサーバーでの受信が完了すると、データがByteBufferに読み込まれ、処理が再開されます。読み込みが終わったら、ByteBufferのflip()メソッドを呼び出し、取り出し開始位置を0に設定します【3】。次にCharsetのdecode()メソッドによって、リクエストを文字列として取り出し、何らかの業務処理を行うものとします【4】。ここでは前述したようにレスポンスとして「Hello! 私は〇〇です。」という文字列を生成します。業務処理が終わったところで、レスポンスをクライアントに返送します。TCPによるデータ送受信では、リクエストと同じSocketChannelを使って、レスポンスをクライアントに返送することができます。その前処理として、生成した文字列をByteBufferに追加します【5】。そして、SocketChannelのwrite()メソッドを呼び出します【6】。このようにすると、ByteBuffer上のデータがネットワーク経由でクライアントに返送されます。

TCPクライアントの作成

続いて、前項で作成したTCPサーバーを呼び出すためのTCPクライアントを、Java NIOのAPIによって作成します。
TCPクライアントも、メインクラスとして作成します。具体的にコードで見ていきましょう。

snippet (pro.kensait.java.advanced.lsn_6_1_3.client.Main)
public static void main(String[] args) {
    //【1】新しいByteBufferを割り当てる(リクエスト用、レスポンス用)
    ByteBuffer requestBuffer = ByteBuffer.allocate(1000);
    ByteBuffer responseBuffer = ByteBuffer.allocate(1000);
    //【2】リクエストをByteBufferに追加する
    requestBuffer = StandardCharsets.UTF_8.encode("Alice");
    try (
            //【3】SocketChannelをオープンする
            SocketChannel socketChannel = SocketChannel.open(
                    new InetSocketAddress("localhost", 55555))) {
        //【4】SocketChannelにByteBufferを書き込む
        socketChannel.write(requestBuffer);
        //【5】SocketChannelからByteBufferに読み込む
        socketChannel.read(responseBuffer);
        //【6】ByteBufferをフリップする
        responseBuffer.flip();
        //【7】レスポンスをByteBufferから取り出す
        String response = StandardCharsets.UTF_8.decode(responseBuffer).toString();
        System.out.println("response => " + response);
    } catch (IOException ioe) {
        throw new RuntimeException(ioe);
    }
}

TCPクライアントの処理は、TCPサーバーの処理と表裏一体になります。まずサーバーに送信するリクエスト用のByteBufferと、サーバーから返送されたレスポンス用のByteBufferを、それぞれ生成します【1】。生成したByteBufferに対して、リクエストとして文字列"Alice"を追加します【2】。次にSocketChannelをオープンし、TCPサーバーとの間で接続を確立します【3】。このときTCPクライアントとサーバーの間では、パケットレベルでは、いわゆる「3ウェイ・ハンドシェイク」が行われます。接続が確立すると、TCPサーバー側ではServerSocketChannelのaccept()メソッドのブロッキングが解放されます。次にTCPクライアントは、SocketChannelによって、TCPサーバーとの間でデータの送受信を行います。まずはリクエストの送信です。SocketChannelのwrite()メソッドによって、ByteBufferからリクエストを書き込みます【4】。するとTCPサーバーに対してネットワーク経由でリクエスト(文字列"Alice")が送信されます。続いてTCPサーバーからのレスポンスの受信です。TCPによるデータ送受信では、リクエストと同じSocketChannelを使って、レスポンスを受け取ることができます。そのためには、SocketChannelのread()メソッドを呼び出します【5】。このメソッドは、TCPサーバーからネットワーク経由でレスポンスが返送されるまで待機し、レスポンスを受け取るとByteBufferに読み込みます。読み込みが終わったら、ByteBufferのflip()メソッドを呼び出すことで、取り出し開始位置を0に設定します【6】。次にCharsetのdecode()メソッドによって、レスポンスを文字列として取り出し【7】、それをコンソールに表示します。ここではTCPサーバーから「Hello! 私はAliceです。」という文字列が返送されるため、それがコンソールに表示されます。

6.1.4 ノンブロッキングI/OによるTCPサーバー

ノンブロッキングI/Oとは

前のレッスンに登場したTCPサーバーを、振り返ってみましょう。このTCPサーバーは、二か所のブロッキングポイントがありました。1つ目がTCPクライアントとの接続の確立で、もう1つがTCPクライアントから受信するリクエストの読み込みです。このTCPサーバーはシングルスレッドで動作しているため、この2つのポイントで処理を待機している間は、他の処理を行うことができません。例えば1つのTCPクライアントから受信するリクエストの読み込みを待機している間は、他のTCPクライアントからの接続を受け付けることができません。つまり、処理の並行性の観点で課題がある、というわけです。

【図6-1-4】TCPサーバーにおけるブロッキング
image.png

このような課題は、ノンブロッキングI/Oによって解決することができます。ノンブロッキングI/Oを利用すると、前述した2つのポイントでブロッキングが発生しないため、シングルスレッドであっても、複数の処理を効率的に切り替えながら実行することが可能になります。

セレクタの仕組み

TCPサーバーには2つのチャネル、ServerSocketChannelとSocketChannelが登場しました。それぞれデフォルトはブロッキングモードですが、いずれもconfigureBlocking()メソッドにfalseを渡すことで、ノンブロッキングモードに設定を変更することができます。
ノンブロッキングモードでは、ServerSocketChannelのaccpet()メソッドを呼び出すと、接続が確立していない場合は空振りし、直ちにnull値が返されます。またSocketChannelのread()メソッドも同様で、データを受信して読み込みの準備が完了していない場合は、読み込みは空振りします。
それではノンブロッキングモードにおいて、これらの処理が空振りすることなく、適切な結果を得るためには、どのようにしたら良いでしょうか。そのためには、Java NIOによって提供されるセレクタという仕組みを利用します。
セレクタには、様々なチャネル(ServerSocketChannelやSocketChannelなど)を「操作」と一緒に、「キー」として登録します。例えばServerSocketChannelであれば「クライアントとの接続確立を行う操作」としてセレクタに登録します。同じようにSocketChannelであれば「データの読み込みを行う操作」としてセレクタに登録します。そして登録したチャネルの準備が完了すると、セレクタからチャネルを「キー」として取り出すことができるようになります。

【図6-1-5】セレクタの概念
image.png

例えば「クライアントとの接続確立」の準備が完了すると、セレクタからはキーとしてServerSocketChannelが取り出されます。この時点でaccpet()メソッドを呼び出すと「クライアントとの接続確立」の準備は完了しているため、accpet()メソッドは空振りせず、SocketChannelが返されます。
同じように「データの読み込み」の準備が完了すると、セレクタからはキーとしてSocketChannelが取り出されます。この時点でread()メソッドを呼び出すと、「データの読み込み」の準備は完了しているため、read()メソッドによってデータの読み込みが行われます。

ノンブロッキングI/OによるTCPサーバーの作成

それでは、既出のTCPサーバーと同じ処理を行うTCPサーバーを、ノンブロッキングI/Oを利用して作成してみましょう。
ここではすべての処理を、メインクラスに実装するものとします。このクラスには、main()メソッドの他に、accpet()、readAndWrite()という2つのスタティックメソッドがあるものとします。また3つのメソッド間でセレクタを共有するため、スタティックフィールドとして、セレクタを表すクラス(java.nio.channels.Selector)を宣言します。
このクラスの全体構成は以下のとおりです。

pro.kensait.java.advanced.lsn_6_1_4.server.Main
public class Main_NonBlocking {
    private static Selector selector; // メソッド間でセレクタを共有する
    public static void main(String[] args) {
        // 接続処理の準備が完了していた場合、accept()メソッドを呼び出す
        ........
        // 読み込み処理の準備が完了していた場合、readAndWrite()メソッドを呼び出す
        ........
    }
    // 接続処理
    private static void accept(ServerSocketChannel ssc) {
        ........
    }
    // 読み込みと書き込み処理
    private static void readAndWrite(SocketChannel socketChannel) {
        ........
    }
}

まずメインメソッドから見ていきましょう。

snippet (pro.kensait.java.advanced.lsn_6_1_4.server.Main)
public static void main(String[] args) {
    try (
            //【1】ServerSocketChannelをオープンする
            ServerSocketChannel ssc = ServerSocketChannel.open()) {
        //【2】ソケットアドレス(ポート番号)をバインドする
        ssc.bind(new InetSocketAddress(55555));
        //【3】ノンブロッキングモードに設定する
        ssc.configureBlocking(false);
        //【4】セレクタをオープンする
        selector = Selector.open();
        //【5】ServerSocketChannelをセレクタに登録する
        ssc.register(selector, SelectionKey.OP_ACCEPT);
        while (0 < selector.select()) { //【6】ブロッキング
            //【7】準備完了したキーの集合を取得し、while文でループ処理する
            Iterator<SelectionKey> keyIterator = selector.selectedKeys()
                    .iterator();
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                keyIterator.remove();
                if (key.isAcceptable()) {
                    //【8】接続処理の準備が完了していた場合
                    accept((ServerSocketChannel) key.channel());
                } else if (key.isReadable()) {
                    //【9】読み込み処理の準備が完了していた場合
                    readAndWrite((SocketChannel) key.channel());
                }
            }
        }
    } catch (IOException ioe) {
        throw new RuntimeException(ioe);
    }
}

まずServerSocketChannelをオープンし【1】、ポート番号をバインドします【2】。次にServerSocketChannelのconfigureBlocking()メソッドにfalseを渡し、ノンブロッキングモードに設定変更します【3】。続いてセレクタ(Selectorクラス)をオープンします【4】。そしてServerSocketChannelのregister()メソッドにセレクタを指定し、セレクタに対してこのチャネルを登録します【5】。合わせて、第2に引数にSelectionKey.OP_ACCEPTを指定することで、このチャネルが「クライアントとの接続確立を行う操作」であることを明示します。次にwhile文の条件式に、セレクタのselect()メソッドを指定します【6】。このメソッドは、登録されたチャネルの準備が完了するまで、ブロッキングされます。そして何らかのチャネルの準備が完了すると、そのチャネルの数を返却し、ブロッキングが解放されます。while文の中では、準備完了したキー(SelectionKeyクラス)を取得します。取得されるキーは1つかもしれませんが、複数かもしれませんので、それをさらにwhile文でループ処理します【7】。次にキーに紐づいた操作を確認します。isAcceptable()メソッドがtrueの場合は、「クライアントとの接続確立」の準備が完了していることを意味します。そこで、SelectionKeyクラスのchannel()メソッドを呼び出し、キーを取り出します。このとき取り出されたキーは必然的にServerSocketChannelになるので、ServerSocketChannelにキャストし、それを引数にaccept()メソッドを呼び出します【8】。またisReadable()メソッドがtrueの場合は、「データの読み込み」の準備は完了していることを意味します。その場合、同じようにSelectionKeyクラスのchannel()メソッドを呼び出してキーを取り出し、SocketChannelにキャストした上で、それを引数にreadAndWrite()メソッドを呼び出します【9】。while文によるループ処理が終わると、条件式の評価に戻り、次のチャネルの準備が完了するまで、セレクタのselect()メソッドによってブロッキングされます【6】。

続いて「クライアントとの接続確立」の準備が完了した場合に呼び出される、accept()メソッドを見ていきましょう。

snippet (pro.kensait.java.advanced.lsn_6_1_4.server.Main)
private static void accept(ServerSocketChannel ssc) {
    try {
        //【1】接続を受け付け、SocketChannelを取得する
        SocketChannel socketChannel = ssc.accept();
        //【2】ノンブロッキングモードに設定する
        socketChannel.configureBlocking(false);
        //【3】SocketChannelをセレクタに登録する
        socketChannel.register(selector, SelectionKey.OP_READ);
    } catch (IOException ioe) {
        throw new RuntimeException(ioe);
    }
}

このメソッドでは、まず引数として渡されたServerSocketChannelに対して、accept()メソッドを呼び出します【1】。この時点では「クライアントとの接続確立」の準備が完了しているため、直ちにSocketChannelを取得可能です。次に取得したSocketChannelのconfigureBlocking()メソッドにfalseを渡し、ノンブロッキングモードに設定変更します【2】。続いてSocketChannelのregister()メソッドにセレクタを指定し、セレクタにこのチャネルを登録します【3】。合わせて第2引数にSelectionKey.OP_READを指定することで、このチャネルが「データの読み込みを行う操作」であることを明示します。
最後に「データの読み込み」の準備が完了した場合に呼び出される、readAndWrite()メソッドです。このメソッドは、既出のTCPサーバーにおけるreadAndWrite()メソッドと完全に同じコードになるため、ここでは割愛します。

【図6-1-6】セレクタからのチャネル取り出しとループ処理
image.png

6.1.5 UDPによるネットワークプログラミング

UDPサーバーの作成

このレッスンでは、UDPサーバーとUDPクライアントを作成し、ネットワーク経由でデータの送受信を行うための方法を説明します。
ここではまず、Java NIOのAPIによってUDPサーバーを作成します。UDPサーバーは、メインクラスとして作成します。このUDPサーバーは、UDPクライアントからデータを受信したら"Hello! 私は〇〇です"という文字列を生成し、コンソールに表示する、という機能を持つものとします。具体的にコードで見ていきましょう。

snippet (pro.kensait.java.advanced.lsn_6_1_5.server.Main)
public static void main(String[] args) {
    //【1】新しいByteBufferを割り当てる(リクエスト用)
    ByteBuffer buffer = ByteBuffer.allocate(1000);
    try (
            //【2】DatagramChannelをオープンする
            DatagramChannel channel = DatagramChannel.open()) {
        //【3】ソケットアドレス(ポート番号)をバインドする
        channel.bind(new InetSocketAddress(55555));
        while (true) { //【4】
            //【5】受信を待機する
            channel.receive(buffer);
            //【6】ByteBufferをフリップする
            buffer.flip();
            //【7】リクエストをByteBufferから取り出す
            String request = StandardCharsets.UTF_8.decode(buffer).toString();
            System.out.println("Hello! 私は" + request + "です。");
        }
    } catch (IOException ioe) {
        throw new RuntimeException(ioe);
    }
}

まず、クライアントから受信したリクエスト用のByteBufferを生成します【1】。
次にDatagramChannelをオープンします【2】。
DatagramChannelは、UDPクライアントとの間で、データを読み込んだり書き込んだりするためのチャネルを表します。なおUDPには接続を確立する処理がないため、TCPにおけるServerSocketChannelに相当するチャネルはありません。
次にオープンしたDatagramChannelに対して、ポート番号をバインドします【3】。
このプログラムはサーバー側で動作するものなので、ここではwhile文にtrueを指定して、無限ループによって常駐プロセス化を図っています【4】。
無限ループの中では、DatagramChannelのreceive()メソッドを呼び出し、データの受信を待機します【5】。ここはブロッキングポイントになっており、受信が完了するとデータがByteBufferに読み込まれ、処理が再開されます。
読み込みが終わったら、ByteBufferのflip()メソッドを呼び出し、取り出し開始位置を0に設定します【6】。
次にCharsetのdecode()メソッドによってリクエストを文字列として取り出し、何らかの業務処理を行うものとします【7】。ここでは前述したように「Hello! 私は〇〇です。」という文字列を生成し、コンソールに表示します。

UDPクライアントの作成

続いて、前項で作成したUDPサーバーを呼び出すためのUDPクライアントを、Java NIOのAPIによって作成します。
UDPクライアントもメインクラスとして作成します。具体的にコードで見ていきましょう。

snippet (pro.kensait.java.advanced.lsn_6_1_5.client.Main)
public static void main(String[] args) {
    //【1】新しいByteBufferを割り当てる(リクエスト用)
    ByteBuffer buffer = ByteBuffer.allocate(1000);
    //【2】リクエストをByteBufferに追加する
    buffer = StandardCharsets.UTF_8.encode("Alice");
    try (
            //【3】DatagramChannelをオープンする
            DatagramChannel channel = DatagramChannel.open()) {
        //【4】DatagramChannelにByteBufferを書き込む
        channel.send(buffer, new InetSocketAddress("localhost", 55555));
    } catch (IOException ioe) {
        throw new RuntimeException(ioe);
    }
}

まずサーバーに送信するリクエスト用のByteBufferを生成します【1】。
生成したByteBufferに対して、リクエストとして文字列"Alice"を追加しておきます【2】。
次にDatagramChannelをオープンし、接続を確立します【3】。UDPクライアントでは、ここでオープンしたDatagramChannelによって、UDPサーバーとの間でデータの送受信を行います。
次にDatagramChannelのwrite()メソッドによって、ByteBufferからリクエストを書き込みます【4】。すると、UDPサーバーに対してネットワーク経由でリクエストが送信されます。

ノンブロッキングI/OによるUDPサーバー

TCPと同じように、UDPサーバーでもノンブロッキングI/Oを利用することができます。セレクタを利用する点も、TCPサーバーと同様です。TCPとは異なり「クライアントとの接続確立」のためのチャネルはありませんが、それ以外の処理はほとんど同じです。
以下にそのコードを示します。処理の流れはコード上のコメントを参照していただくこととし、詳細はここでは割愛します。

pro.kensait.java.advanced.lsn_6_1_5.nonblockingserver.Main
public class Main {
    private static Selector selector;
    public static void main(String[] args) {
        try (
                // DatagramChannelをオープンする
                DatagramChannel channel = DatagramChannel.open()) {
            // ソケットアドレス(ポート番号)をバインドする
            channel.bind(new InetSocketAddress(55555));
            // ノンブロッキングモードに設定する
            channel.configureBlocking(false);
            // セレクタをオープンする
            selector = Selector.open();
            // セレクタに登録する
            channel.register(selector, SelectionKey.OP_READ);
            while (0 < selector.select()) { // ブロッキング
                // 準備完了したキーの集合を取得し、while文でループ処理する
                Iterator<SelectionKey> keyIterator = selector.selectedKeys()
                        .iterator();
                while (keyIterator.hasNext()) {
                    SelectionKey key = keyIterator.next();
                    keyIterator.remove();
                    if (key.isReadable()) {
                        // 読み込み処理の準備が完了していた場合
                        read((DatagramChannel) key.channel());
                    }
                }
            }
        } catch (IOException ioe) {
            throw new RuntimeException(ioe);
        }
    }
    // 読み込み処理
    private static void read(DatagramChannel channel) {
        // 新しいByteBufferを割り当てる(リクエスト用)
        ByteBuffer buffer = ByteBuffer.allocate(1000);
        try {
            // DatagramChannelからByteBufferにデータを読み込む
            channel.receive(buffer);
        } catch (IOException ioe) {
            throw new RuntimeException(ioe);
        }
        // ByteBufferをフリップする
        buffer.flip();
        // リクエストをByteBufferから取り出す
        String request = StandardCharsets.UTF_8.decode(buffer).toString();
        System.out.println("Hello! 私は" + request + "です。");
    }
}

このチャプターで学んだこと

このチャプターでは、以下のことを学びました。

  1. TCP/IPの概念や4つの階層について。
  2. TCPがコネクション型の通信であり、信頼性の高い通信が可能であること。
  3. UDPはコネクションレス型の通信であり、信頼性は必ずしも高くはないがコストを抑えた通信が可能であること。
  4. Javaにおけるネットワーク通信用クラスライブラリの全体像や、現在はJava NIOおよびJava NIO.2が主流であること。
  5. TCPプログラミングで送受信データとして使われるByteBufferクラスの特徴やAPIについて。
  6. TCPサーバーの作成方法について。
  7. TCPクライアントの作成方法について。
  8. ノンブロッキングI/Oの概念や、セレクタによるノンブロッキングI/Oの実現方法について。
  9. UDPによるネットワークプログラミングの方法について。

Discussion