ソフトウエア基礎
ohmi@rsch.tuis.ac.jp

ネットワーク(4)

マルチスレッド

今までに説明したJavaによるサーバの作成では、 サーバは一度に1つのクライアントに対してしか通信が行なえない。 したがって、あるクライアントが既にサーバに接続して通信を行っている時に、 他のクライアントから接続しようとしても、現在接続しているクライアント の通信が終了(切断)されるまで、そのクライアントは待っていなければならない。

しかし、実用的なサーバは、 一度に複数のクライアントと通信が行なえるものが多い。 これを実現するには、マルチプロセスやマルチスレッドと呼ばれる機能を使う。 Javaの場合は、単一のプロセス上でしかプログラムを動作できないので、 スレッドを使うのが常套手段である。

ここで、典型的なスレッドを使ったサーバの動作を説明する。

NetServer
スレッドを使わないサーバの動作
NetServer2
スレッドを使ったサーバの動作

上図のように、スレッドを使う場合、クライアントからの接続要求を待ち、 要求があったら、1個のクライアントとの通信を行なうスレッドを生成し、 そのスレッド(図の右側)を実行する。スレッドの実行が始まったら、元のプログラム (図の左側)は、また接続要求を待つ。そして、要求があったら、また新たなスレッドを 生成する。これらのスレッドは独立して同時に動いているかのように動作する。 したがって、複数のクライアントの要求を同時に処理することができるのである。

Javaでのスレッドの使い方には、いくつかの種類があるが、ここでは、 Threadクラスから継承して新たなクラスを定義する方法を述べる。

前回のネットワーク(3)のEchoサーバ は、一度に1つのクライアント としか通信できない(課題net-29で確かめよ)。以下のEchoServer2.java は、一度に複数のクライアントと通信できる。

import java.net.*;
import java.io.*;

class EchoServer2Thread extends Thread {
    protected Socket sock;
    public EchoServer2Thread(Socket s) {
	sock = s;
    }
    public void run() {
	try {
	    System.out.println("Connected");
	    BufferedReader in = new BufferedReader(
	        new InputStreamReader(sock.getInputStream()));
	    PrintWriter out = new PrintWriter(sock.getOutputStream());
	    String s;
	    while((s = in.readLine()) != null) {
		out.print(s + "\r\n");
		out.flush();
		System.out.println(s);
	    }
	    sock.close();
	    System.out.println("Connection Closed");
	} catch (IOException e) {
	    System.err.println(e);
	}
    }
}	

public class EchoServer2 {
    static int port = 10007;
    
    public static void main(String[] args) {
	try {
	    ServerSocket server = new ServerSocket(port);
	    Socket conn = null;
	    System.out.println("Server Ready");
	    while(true) {
		try {
		    conn = server.accept();
		    EchoServer2Thread t = new EchoServer2Thread(conn);
		    t.start();
		} catch (IOException e) {
		    System.err.println(e);
		}
	    }
	} catch (IOException e) {
	    System.err.println(e);
	}
    }
}

このプログラムでは、EchoServer2クラスのmainメソッドが、上図の 左側の元のプログラムに相当し、EchoServer2Threadが、上図の右側の スレッドに相当する。

Echo2Server2ThreadクラスはThreadから継承されている。mainメソッドの server.accept()でクライアントからの接続要求 を待ち、その後 EchoServer2Threadオブジェクトを生成している。 そして、そのオブジェクトに対し、start() メソッドを呼べば、スレッドが実行される。

スレッドは実行されると、定義されている run() メソッドが自動的に呼ばれる。run()メソッドは、 mainメソッドのようなもので、main()メソッドが終わる(returnされる)と、 Javaプログラムが終了するように、run()メソッドが終わる(returnされる)と そのスレッドが終了する(つまりスレッドが消滅する)。

上記のプログラムの場合は、run()メソッドの中で、 Echoサーバが1つのクライアントと通信する処理を実行している。 つまり、クライアントから一行受信し、クライアントに一行送信するのを繰り返し、 クライアントからの接続が切れたらソケットを切断して終了する、 という処理をしている。

課題net-29

前回のEchoServer1.javaを動かし、2つのクライアントから接続を試みよ。 どのような挙動を示すか報告せよ。

課題net-30

EchoServer2.javaを動かし、2つのクライアントから接続を試みよ。 どのような挙動を示すかEchoServer1.java と比較して報告せよ。

応用:チャットシステムの作成

ここでは、やや高度のサーバ/クライアントシステムとして、 チャットシステムを作成する。

システム概要

一般にチャットシステムとは、複数のユーザが文字などのメッセージを やりとりできるもので、あるユーザが書き込めば、参加しているユーザ全員 にそのメッセージが伝わるものが一般的である。

実用的なチャットのシステムは、チャンネルや部屋と呼ばれる単位で、 区分けする機能があるが、ここでは簡単のため、 1つのサーバには1つのチャンネルしかないものとする。 つまり、そのサーバに接続しているクライアント全てにメッセージを送ることに する。

メッセージは一行単位の文字列とし、一行打ち終えたら一行分をまとめて 送信することにする。

スレッド

このチャットシステムを実現するには、スレッドの使用がかかせない。 なぜなら、複数のクライアントを同時に処理する必要があるからである。 チャットの場合、あるクライアントが、キーボードからのメッセージ入力を 待っている間にも、突然、他のクライアントからのメッセージが飛び込む。 スレッドを使用しなければ、一度に1つのクライアントからの受信を待ち続ける ことしかできない。どのクライアントからメッセージが送信されるかは、予測 できないので、これではチャットシステムは実現できない。

そこで、1つのクライアントに対して1つのスレッドを生成し、その スレッドは1つのクライアントに対して通信を行なうようにする必要がある。

プロトコル

このチャットシステムでのプロトコルは以下のように定義する。

クライアントは接続成功後、以下のいずれかをサーバに送信できるものとする。 いずれも一行単位でデータを送信する。 サーバからも一行単位でメッセージを受信する。

クライアント側から接続が切られた場合、サーバは接続されている クライアントのリストからそのクライアントを消去する。

サーバの作成

以下は、チャットシステムのサーバのプログラム例である。

import java.net.*;
import java.io.*;
import java.util.*;

// 1つのクライアントとの通信を行なうスレッド
class ChatServerThread extends Thread { 
    static int port = 7788;
    static Vector threads; // 現在動作中のスレッドの集合
    Socket conn; // このクライアントに対応するソケット
    String nickname=null; // このクライアントのニックネーム

    // コンストラクタ(使用するソケットを指定)
    public ChatServerThread(Socket s) {
	super(); // Threadクラスのコンストラクタを呼ぶ
	conn = s;
	if (threads == null) {
	    threads = new Vector(); // スレッドの集合の初期化
	}
	threads.add(this); // スレッドの集合に自分を追加
    }
    public String getNickname() { // 自分のニックネームを返す
	return nickname;
    }

    public void run() { // スレッドで実行される内容
	try {
	    System.err.println("*** Connected ***");
	    BufferedReader in =
		new BufferedReader(new InputStreamReader(conn.getInputStream()));
	    PrintWriter out = new PrintWriter(conn.getOutputStream());

	    while(true) {
		try {
		    String s = in.readLine(); // クライアントから一行入力
		    if (s == null) { // 接続が切れていたら
			conn.close(); // ソケット切断
			threads.remove(this); // スレッドの集合から自分を削除
			return; // スレッド消滅
		    }
		    if (s.startsWith("NICK:")) { // NICK:コマンドなら
			StringTokenizer st = new StringTokenizer(s, ":");
			st.nextToken();
			// NICK:の後のトークンをニックネームとする
			nickname = st.nextToken();
			out.print("Hello " + nickname + "! You entered.\r\n");
			out.flush();
		    } else {
			// ニックネームが登録されていたら
			if (nickname != null) {
			    talk(s); // メッセージ送信
			} else {
			    out.print("Error! You must specify your nickname.\r\n");
			    out.flush();
			}
		    }
		} catch (IOException e) { // 突然接続が切れた場合
		    System.err.println("*** Connection closed ***");
		    conn.close();
		    threads.remove(this); // スレッドの集合から自分を削除
		    return; // スレッド消滅
		}
	    }
	} catch (IOException e) {
	    System.err.println(e);
	}
    }

    public void talk(String message) {
	// スレッドの集合のそれぞれについて…
	for (int i = 0; i < threads.size(); i++) {
	    ChatServerThread t = (ChatServerThread)threads.get(i);
	    if (t.isAlive()) { // そのスレッドが動作していたら
		t.talkone(this, message); // そのスレッドにメッセージを送信
	    }
	}
	System.err.println(getNickname() + ":" + message);
    }

    // そのスレッドにメッセージを送信
    public void talkone(ChatServerThread talker, String message){
	try {
	    PrintWriter out = new PrintWriter(conn.getOutputStream());
	    String nick = talker.getNickname();
	    if (talker == this) { // 自分からのメッセージ
		out.print("<" + nick + ">" + message + "\r\n");
	    } else { // 他人からのメッセージ
		out.print("[" + nick + "]" + message + "\r\n");
	    }
	    out.flush();
	} catch (IOException e) {
	    System.err.println(e);
	}
    }
}

public class ChatServer {
    static int port = 7788;
    
    public static void main(String[] args) {
	try {
	    ServerSocket server = new ServerSocket(port);
	    Socket conn = null;
	    System.err.println("Ready");
	    while(true) {
		try {
		    // 接続要求を待つ
		    conn = server.accept();
		    // スレッド生成
		    ChatServerThread t = new ChatServerThread(conn);
		    // スレッド実行
		    t.start();
		} catch (IOException e) {
		}
	    }
	} catch (IOException e) {
	    System.err.println(e);
	}
    }
}

このプログラムでは、mainメソッドで、クライアントからの接続要求を 待ち、要求があれば、ChatServerThreadオブジェクトを生成し、 そのスレッドを実行する。ChatServerThreadオブジェクトでは、 1つのクライアントとの間の通信を行なう。

ChatServerThreadのrun()メソッドでは、 1つのクライアントとの間の通信を行なう。 ここでは、上記に示したプロトコルに基づき処理がなされる。 例えば、メッセージ送信の場合は、talk() メソッドを呼び、そのデータ(一行分)を全クライアントに送信する。

talk()メソッドでは、現在接続されている クライアントを扱うChatServerThreadの集合の1つ1つに対して、 talkone()メソッドを呼び、メッセージ送信をしている。

課題net-31

上記のChatServer.javaを実行し、telnet localhost 7788 として接続せよ。 ktermをいくつか開き、複数のクライアントから接続してみよ。 それぞれのクライアントについて、NICK:コマンドでニックネームを登録し、チ ャットが行なえることを確かめよ。

課題net-32

隣りの者など他人が起動したChatServerに接続し、そのサーバに複数人が 接続することで、他人とチャットが行なえることを確かめよ。

クライアントの作成

以下は、チャットシステムのクライアントのプログラム例である。 このプログラムは、Swingを使ったGUIを使用している。チャットの場合、 ユーザがメッセージを入力する場所と、他のユーザからのメッセージを 表示する場所の2つを設けるのが通例である。このようなことを、CUI (Character User Interface)で実現するには、 やや高度な技術が必要となるため、 それらを別のコンポーネントとできるGUIを使用した。

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import java.net.*;
import java.io.*;

class ChatClientFrame extends JFrame
    implements ActionListener, ScrollPaneConstants {

    String server = "localhost"; // 接続サーバ名
    String nickname = "noname";  // ユーザのニックネーム

    JButton b_talk,b_disconnect; // Talkボタン,Disconnectボタン
    JTextArea display; // チャット表示用
    JTextField input; // チャットメッセージ入力欄
    JScrollPane scroll; // displayスクロール用
    Socket sock = null;
    PrintWriter sout;
    BufferedReader sin;
    public ChatClientFrame() {
	Container contentPane = getContentPane();
	setSize(300,300);
        addWindowListener(new MyWindowAdapter());
	contentPane.setLayout(new FlowLayout());
	display = new JTextArea("", 10, 25);
	scroll = new JScrollPane(display,
				 VERTICAL_SCROLLBAR_ALWAYS,
				 HORIZONTAL_SCROLLBAR_NEVER);
	contentPane.add(scroll);
	display.setEditable(false);
	input = new JTextField("", 25);
	contentPane.add(input);
	input.addActionListener(this);
	b_talk = new JButton("Talk");
	contentPane.add(b_talk);
	b_talk.addActionListener(this);
	b_disconnect = new JButton("Disconnect");
	contentPane.add(b_disconnect);
	b_disconnect.addActionListener(this);
    }

    public void actionPerformed(ActionEvent ae) {
	if (sock != null && sock.isConnected()) {
	    if (ae.getSource() == b_disconnect) { // 切断ボタンが押されたら
		System.exit(0);
	    } 
	    // それ以外(入力欄で改行 or Talkボタン)
	    System.out.println(input.getText());
	    sout.println(input.getText());
	    input.setText(""); // 入力欄を空にする
	    sout.flush();
	} else { // サーバに接続されていない
	    append_display("NOT CONNETED NOW.\n");
	}
    }
    public void connect() {
	try {
	    InetAddress addr = InetAddress.getByName(server);
	    sock = new Socket(addr, 7788);
	    System.out.println("Connected");
	    sout = new PrintWriter(new OutputStreamWriter(sock.getOutputStream()));
	    sin = new BufferedReader(new InputStreamReader(sock.getInputStream()));
	    char[] t = new char[256];
	    sout.println("NICK:" + nickname); // ニックネーム送信
	    sout.flush();
	    while(sock != null && sock.isConnected()) {
		String s = sin.readLine(); // サーバからのメッセージを受信
		append_display(s + "\n"); // 表示に追加する
		System.out.println(s);
	    }
	}
	catch (UnknownHostException e) {
	    System.err.println(e);
	}
	catch (IOException e) {
	    System.err.println(e);
	    sock = null;
	}
    }
    public void append_display(String mess) {
	// メッセージを表示内容に追加
	display.append(mess);
	// 表示位置を内容の一番下(最新)にもってくる
	display.setCaretPosition(display.getText().length());
    }
}

class MyWindowAdapter extends WindowAdapter
{
    public void windowClosing(WindowEvent e){
       System.exit(0);
    }
}

public class ChatClient {
    public static void main(String[] args) {
	ChatClientFrame f = new ChatClientFrame();
	f.setVisible(true);
	f.connect();
    }
}

キーボードからのメッセージを受け付ける入力欄として JTextField(一行)を使用している。 また、サーバからのメッセージを表示するのに、JTextAreaを使用している。 なお、JTextAreaの内容は勝手に編集ができないようにしてある (setEditable(false))。 また、表示内容が増えてきたら自動的にスクロールするようにJScrollPaneを 使用している。

サーバへの接続は、connect()メソッドで 行なっている。接続に成功したら、NICK:コマンドでニックネームを送信してい る。その後は、接続が切れるまで、ひたすらサーバからのメッセージを受信し、 その内容を表示部に追加している。

入力欄(JTextField)の部分で、Enterキーを打つか、Talkボタンを押すと、 actionPerformed()が呼ばれる。 そうするとサーバに、その内容を送信する。 他に、Disconnectボタンが押されても、呼ばれるため getSource()で判別してい る。

課題net-33

上記、ChatClient.javaは接続するサーバ名がlocalhostに、ニックネームが nonameに固定されている。これらを起動時オプションとして自由に指定できるよ うにせよ。そして他人とチャットが行なえることを確認せよ。

課題net-34

接続するサーバ名と自分のニックネームを、TextFieldとして、 GUI上で自由に指定できるようにせよ。接続を開始するための、 Connectボタンも必要になる。

課題net-35

ConnectボタンとDisconnectボタンを兼用するようにせよ。 つまり、1つのボタンで、未接続時は Connectと表示し、 接続時は Disconnectと表示するようにせよ。

課題net-36

プロトコルとして、以下のWHOIS:コマンドを追加し、 それをサーバとして実装せよ。


ソフトウエア基礎

ohmi@rsch.tuis.ac.jp

Valid HTML 4.01!