チャットシステムの作成

下記のChatServer.javaとChatClient.javaをコピーし,動作を確認してみましょう.

ChatServer.java

package jp.ac.utsunomiya_u.is;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Scanner;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.logging.Level;
import java.util.logging.Logger;

public class ChatServer {

    // ChatTaskのリスト
    private final ArrayList<ChatTask> chatTasks = new ArrayList<>();

    public static void main(String[] args) {
        ChatServer chatServer = new ChatServer();
    }

    /**
     * コンストラクタ
     */
    public ChatServer() {
        // Scannerクラスのインスタンス(標準入力System.inからの入力)
        try (Scanner scanner = new Scanner(System.in)) {
            System.out.print("ChatServer (" + getMyIpAddress() + ") > Input server port > ");
            // ポート番号入力
            int port = scanner.nextInt();
            // スレッドプールの生成
            ExecutorService executorService = Executors.newCachedThreadPool();
            // ServerSocketクラスのインスタンスをポート番号を指定して生成
            try (ServerSocket serverSocket = new ServerSocket(port)) {
                System.out.println("ChatServer (" + getMyIpAddress() + ") > Started and Listening for connections on port " + serverSocket.getLocalPort());
                while (true) {
                    // ServerSocketに対する要求を待機し,それを受け取ったらSocketクラスのインスタンスからChatTaskを生成
                    ChatTask chatTask = new ChatTask(serverSocket.accept());
                    // ChatTaskのインスタンスをリストに保管
                    chatTasks.add(chatTask);
                    // タスクの実行
                    executorService.submit(chatTask);
                }
            } catch (IOException ex) {
                Logger.getLogger(ChatServer.class.getName()).log(Level.SEVERE, null, ex);
            } finally {
                // スレッドプールの停止
                executorService.shutdown();
            }
        }
    }

    /**
     * 自ホストのIPアドレス取得
     *
     * @return 自ホストのIPアドレス
     */
    private static String getMyIpAddress() {
        try {
            // 自ホストのIPアドレス取得
            return InetAddress.getLocalHost().getHostAddress();
        } catch (UnknownHostException ex) {
            Logger.getLogger(ChatServer.class.getName()).log(Level.SEVERE, null, ex);
        }
        return null;
    }

    /**
     * メッセージの同報通知(サーバ->クライアント)
     *
     * @param message メッセージ
     */
    private synchronized void broadcast(String message) {
        // ChatTashのArrayListの各要素に対して
        chatTasks.forEach((chatTask) -> {
            // PrintWriter経由でメッセージ送信
            chatTask.getPrintWriter().println(message);
        });
    }

    private final class ChatTask implements Callable<Void> {

        // ソケット
        private Socket socket;
        // データ送信用Writer(サーバ->クライアント用)
        private PrintWriter writer = null;
        // データ受信用Reader(クライアント->サーバ用)
        private BufferedReader reader = null;
        // ニックネーム
        private String nickname = null;

        /**
         * コンストラクタ
         *
         * @param socket Socketクラスのインスタンス
         */
        ChatTask(Socket socket) {
            this.socket = socket;
            try {
                // Socket経由での書込用PrintWriter生成(サーバ->クライアント用)
                writer = new PrintWriter(socket.getOutputStream(), true);
                // Soket経由での読込用BufferedReader生成(クライアント->サーバ用)
                reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                // クライアントから接続直後に送られてくるニックネームを取得
                nickname = reader.readLine();
                // 入室状況の文言作成
                String str = "";
                if (chatTasks.size() > 0) {
                    for (ChatTask chatTask : chatTasks) {
                        str += nickname + "[" + socket.getRemoteSocketAddress() + "]さん ";
                    }
                    str += "が入室しています";
                } else {
                    str = "誰も入室していません";
                }
                // 入室状況の文言送信
                writer.println(str);
                // 入室したことを他のユーザに同報通知
                broadcast(nickname + "[" + socket.getRemoteSocketAddress() + "]さんが入室しました");
                System.out.println("ChatServer (" + getMyIpAddress() + ") > Accepted connection from " + socket.getRemoteSocketAddress() + "[" + nickname + "]");
            } catch (IOException ex) {
                Logger.getLogger(ChatServer.class.getName()).log(Level.SEVERE, null, ex);
            }
        }

        /**
         * PrintWriterのゲッタ
         *
         * @return PrintWriter
         */
        public PrintWriter getPrintWriter() {
            return writer;
        }

        @Override
        public Void call() {
            try {
                String inputLine;
                // readerから一行読み込み
                while ((inputLine = reader.readLine()) != null) {
                    // 整形文を同報通知
                    broadcast(nickname + "[" + socket.getRemoteSocketAddress() + "] > " + inputLine);
                    System.out.println("ChatClient (" + socket.getRemoteSocketAddress() + ") > " + inputLine);
                }
            } catch (IOException ex) {
                Logger.getLogger(ChatServer.class.getName()).log(Level.SEVERE, null, ex);
            } finally {
                // 退出した事を同報通知
                broadcast(nickname + "[" + socket.getRemoteSocketAddress() + "]さんが退出しました");
                System.out.println("ChatServer (" + getMyIpAddress() + ") > Terminated connection from " + socket.getRemoteSocketAddress() + "[" + nickname + "]");
                try {
                    // socket, reader, writerのclose
                    if (socket != null) {
                        socket.close();
                    }
                    if (reader != null) {
                        reader.close();
                    }
                    if (writer != null) {
                        writer.close();
                    }
                } catch (IOException ex) {
                    Logger.getLogger(ChatServer.class.getName()).log(Level.SEVERE, null, ex);
                }
            }
            // ChatTaskのリストから自身を削除         
            chatTasks.remove(this);
            return null;
        }
    }
}

ChatClient.java

package jp.ac.utsunomiya_u.is;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javafx.application.Application;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Label;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class ChatClient extends Application {

    // ソケット
    private Socket socket = null;
    // データ送信用Writer
    private PrintWriter writer = null;
    // データ受信用Reader
    private BufferedReader reader = null;
    // 受信タスク
    private TaskReceiver taskReceiver = null;
    //  操作可能コンポーネント
    private TextField textFieldServerIpAddress = null;
    private TextField textFieldServerPortNumber = null;
    private TextField textFieldNickname = null;
    private Button buttonEnter = null;
    private Button buttonExit = null;
    private TextArea textAreaView = null;
    private TextField textFieldMessage = null;
    private Button buttonMessage = null;

    @Override
    public void start(Stage primaryStage) throws Exception {
        // Stageのタイトル
        primaryStage.setTitle(getClass().getName());
        // Stageのサイズ
        primaryStage.setWidth(400);
        primaryStage.setHeight(300);
        // ルートノード(VBox:単一の垂直列に子をレイアウト)
        VBox root = new VBox();
        //  Sceneを介してルートノードをStateに貼付け
        primaryStage.setScene(new Scene(root));
        // Stageの終了ボタンが押下された時の対応
        primaryStage.setOnCloseRequest((event) -> {
            exit();
        });

        // [第1段] IPアドレス,ポート番号,ハンドル名の設定
        // GridPaneレイアウト(行と列の柔軟なグリッド内に子をレイアウト)
        GridPane gridPaneConfig = new GridPane();
        gridPaneConfig.setAlignment(Pos.CENTER);
        // 各コンポーネントの生成
        Label labelIpAddress = new Label("サーバIPアドレス:");
        textFieldServerIpAddress = new TextField("");
        Label labelPortNumber = new Label("サーバポート番号:");
        textFieldServerPortNumber = new TextField("");
        Label labelNickname = new Label("ニックネーム:");
        textFieldNickname = new TextField("");
        buttonEnter = new Button("入室");
        buttonExit = new Button("退室");
        // 各コンポーネントの配置
        GridPane.setConstraints(labelIpAddress, 0, 0);
        GridPane.setConstraints(textFieldServerIpAddress, 1, 0);
        GridPane.setConstraints(labelPortNumber, 0, 1);
        GridPane.setConstraints(textFieldServerPortNumber, 1, 1);
        GridPane.setConstraints(labelNickname, 0, 2);
        GridPane.setConstraints(textFieldNickname, 1, 2);
        GridPane.setConstraints(buttonEnter, 2, 1);
        GridPane.setConstraints(buttonExit, 3, 1);
        // GridPaneに各コンポーネント追加
        gridPaneConfig.getChildren().addAll(labelIpAddress, textFieldServerIpAddress, labelPortNumber, textFieldServerPortNumber, labelNickname, textFieldNickname, buttonEnter, buttonExit);
        // buttonEnterボタンが押下された時の動作
        buttonEnter.setOnAction((ActionEvent event) -> {
            try {
                // textFiledServerIpAddressテキストフィールドに入力された文字列からサーバIPアドレスを指定
                InetAddress serverInetAddress = InetAddress.getByName(textFieldServerIpAddress.getText());
                // textFieldServerPortNumberテキストフィールドに入力された文字列からサーバポート番号を指定
                int serverPortNumber = Integer.valueOf(textFieldServerPortNumber.getText());
                // textFiledNicknameテキストフィールドに入力された文字列からニックネームを指定
                String nickname = textFieldNickname.getText();
                // ニックネームの長さが0なら
                if (nickname.length() == 0) {
                    new Alert(Alert.AlertType.ERROR, "ハンドル名が空です", ButtonType.OK).show();
                } else {
                    try {
                        // Socket生成
                        socket = new Socket(serverInetAddress, serverPortNumber);
                        // Socket経由での書込用PrintWriter生成(クライアント->サーバ用)
                        writer = new PrintWriter(socket.getOutputStream(), true);
                        // Soket経由での読込用BufferedReader生成(サーバ->クライアント用)
                        reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                        // 接続直後にサーバにニックネームを通知
                        writer.println(nickname);
                        // コンポーネットの表示切替
                        setComponents(true);
                        // スレッドプールをSingleThreadで生成
                        ExecutorService executorService = Executors.newSingleThreadExecutor();
                        // TaskReceiver生成
                        taskReceiver = new TaskReceiver();
                        // タスク実行
                        executorService.submit(taskReceiver);
                        // スレッドプールを停止
                        executorService.shutdown();
                    } catch (IOException ex) {
                        // ダイアログ(エラー用)生成
                        new Alert(Alert.AlertType.ERROR, "サーバに接続出来ません", ButtonType.OK).show();
                    }
                }
            } catch (UnknownHostException ex) {
                new Alert(Alert.AlertType.ERROR, "IPアドレスが不正です", ButtonType.OK).show();
            } catch (NumberFormatException ex) {
                new Alert(Alert.AlertType.ERROR, "ポート番号が不正です", ButtonType.OK).show();
            }
        });
     
        // [第2段] チャット内容の表示ビュー
        // TextArea生成
        textAreaView = new TextArea();
        // 表示用なので,書込不可
        textAreaView.setEditable(false);

        // [第3段] メッセージ入力
        // HBoxレイアウト(単一の水平行に子をレイアウト)
        HBox hBoxMessage = new HBox();
        hBoxMessage.setAlignment(Pos.CENTER);
        hBoxMessage.setSpacing(10);
        // 各コンポーネントの生成
        textFieldMessage = new TextField();
        buttonMessage = new Button("送信");
        // HBoxレイアウトに各コンポーネントを貼付け
        hBoxMessage.getChildren().addAll(textFieldMessage, buttonMessage);
        // buttonMessageボタンが押下された時の動作
        buttonMessage.setOnAction((ActionEvent event) -> {
            // textFieldMessageテキストフィールドに入力された文字列を取得
            String inputLine = textFieldMessage.getText();
            // 入力文字列長が0より大きい
            if (inputLine.length() > 0) {
                // PrintWriterに書込(クライアント->サーバ)
                writer.println(inputLine);
            }
            // textFiledMessageテキストフィールドをクリア
            textFieldMessage.clear();
        });

        // コンポーネントの表示切替(初期化)
        setComponents(false);

        // 3段のレイアウトをルートノードに貼付け
        root.getChildren().addAll(gridPaneConfig, textAreaView, hBoxMessage);

        // Stageの表示
        primaryStage.show();
    }

    /**
     * ”退室”ボタンが押下された時の処理
     */
    private void exit() {
        // タスクをキャンセル
        taskReceiver.cancel();
        try {
            // SocketとReaderとWriterをclose
            if (socket != null) {
                socket.close();
            }
            if (reader != null) {
                reader.close();
            }
            if (writer != null) {
                writer.close();
            }
        } catch (IOException ex) {
            new Alert(Alert.AlertType.ERROR, "ソケットを閉じることが出来ません", ButtonType.OK).show();
        }
    }

    /**
     * 各コンポーネントの変更可否
     *
     * @param connected 接続時を意味するフラグ
     */
    private void setComponents(boolean connected) {
        // サーバ接続時に無効
        textFieldServerIpAddress.setDisable(connected);
        textFieldServerPortNumber.setDisable(connected);
        textFieldNickname.setDisable(connected);
        buttonEnter.setDisable(connected);

        // サーバ接続時に有効
        buttonExit.setDisable(!connected);
        textAreaView.setDisable(!connected);
        textFieldMessage.setDisable(!connected);
        buttonMessage.setDisable(!connected);
    }

    /**
     * サーバからのメッセージを受信するためのタスク
     */
    private class TaskReceiver extends Task<Void> {

        @Override
        protected Void call() throws Exception {
            String inputLine;
            // Readerから読み込んだ一行をTextAreaに追記
            while ((inputLine = reader.readLine()) != null) {
                textAreaView.appendText(inputLine + "\n");
            }
            return null;
        }
    }

    public static void main(String[] args) {
        launch(args);
    }
}

チャットシステムの動作例

番号説明サーバクライアント1クライアント2
1サーバ(ここではポート番号10000)を起動します
ChatServer (192.168.11.100) > Input server port > 10000
ChatServer (192.168.11.100) > Started and Listening for connections on port 10000
2クライアント(クライアント1と呼びます)を起動します.サーバのIPアドレスは"localhost",ポート番号は10000番です.ニックネームを適当に決めて,”入室”ボタンを押します
3サーバでは接続が確認出来ます.まだ誰も入室していないので,そのようなメッセージが表示されます.クライアント1のメッセージテキストフィールドに適当な文字列を入力して”送信”ボタンを押します
ChatServer (192.168.11.100) > Accepted connection from /127.0.0.1:61684[fujii]
4サーバで送信メッセージを確認出来ます.クライアントでは自身が送信したメッセージをサーバがそのまま返送してきたものを表示しています.ここまではエコーシステムと同じです
ChatClient (/127.0.0.1:61684) > Hello
5同じサーバIPアドレス,ポート番号で別のクライアント(クライアント2と呼びます)を起動します.ニックネームは先程のクライアントと変えておいて下さい
6サーバでクライアント2の接続が確認出来ます.クライアント1ではクライアント2の入室が,クライアント2では既に入室しているクライアント1の情報が確認できます
ChatServer (192.168.11.100) > Accepted connection from /127.0.0.1:61702[masahiro]
7クライアント2から同様にメッセージを送信してみましょう
8サーバでクライアント2からのメッセージを確認出来ます.2つのクライアントにメッセージが届いている事が確認できます.
ChatClient (/127.0.0.1:61702) > こんにちは
9クライアント1のウィンドウを右上の閉じるボタンで閉じると,サーバでクライアント1が切断された事が確認できます
ChatServer (192.168.11.100) > Terminated connection from /127.0.0.1:61684[fujii]
10クライアント2も同様に終了させると,サーバでそのことが確認できます
ChatServer (192.168.11.100) > Terminated connection from /127.0.0.1:61702[masahiro]

チャットシステムの動作原理

ここではチャットシステムの動作原理について説明します. 動作を下図に示します.

ChatServerは前回実装したMultiEchoServerとほとんど同じです. サーバの動作の主な流れは以下の通りです.
  1. ServerSocketを生成してクライアントの接続を待ち続けます.
  2. クライアントからの接続あったとき,acceptメソッドがSocketを生成します.
    サーバは複数のクライアントから接続出来る必要があるので,あるクライアントとの接続Socketの運用タスクは別のスレッドに任せて,メインのスレッドでは常に他のクライントからの接続を待ち続けなければなりません.
    1. サーバはクライアントからのメッセージがある限り待ち続けます.
    2. クライアントからのメッセージの受信があったら,このメッセージを接続している全てのクライアントに同報通知します.
      エコーシステムでは受信したメセージを,それを送信したクライアントにそのまま返せば良かったですが,チャットシステムでは受信したメッセージを,接続している全てのクライアントに同じメッセージを伝えなければならないので,少し工夫が必要です. 今回の実装では,クライアントがサーバに接続し,新しいタスクが発生したら,これをListに格納しておいて,メッセージの同報通知の際にはこのListを辿って各クライアントに同じメッセージを送信しています.
また,ChatClientも前回実装したEchoClientとほとんど同じです. クライアントの動作の主な流れは以下の通りです.
  1. "入室"ボタンが押されたら,Socketを生成し,サーバと接続します.
  2. メッセージが入力され続ける限り待ち続け,入力されたメッセージをサーバに送信します.
    エコーシステムと異なり,いつサーバからの同報通知が来るかわからないので,これを待ち続けなければなりません. しかし,これをメインのスレッドで行ってしまうと,他の処理ができなくなってしまうので,Taskクラスを継承した受信用のタスクを別スレッドで動かします. これで,送信処理と受信処理を適切なスレッド処理で実装することができます.