メッセージ
本来ならば、サーバーとクライアントとの間でやり取りする情報の交換方法を定めなければなりません。これをプロトコルと呼びます。ネットワーク間の通信の場合、相手コンピュータを固定的に定めることができないため、通信に利用する文字コードや形式を明確にする必要があるのです。
しかし、複雑なメッセージ交換方法を定めてしまうと、その部分に関するソースコードが膨大になってしまいます。とくに、独自のデータ形式の場合はパーサーを実装しなければなりません。そのため、今回は単純に一行文字列のコマンドでメッセージを交換したいと思います。メッセージの末尾は改行文字とします。
コマンド名 値
コマンドは、コマンド名から始まり半角空白で区切った後にコマンドに付属する値を指定するものとします。値はコマンドの内容によっては省略可能とします。例えば、ユーザーが通常の会話用メッセージを送ってきた場合は次のようなmsgコマンドを送るものと定めます。
msg 任意の文字列
このほかにも多くの操作用コマンドを用意します。今回は、入室コマンドにenterRoom、退室にはexitRoom、名前の設定にはsetNameというようにコマンドを定めています。詳細はソースコードを参照してください。
public void messageThrow(MessageEvent e) { String msgType = e.getName(); String msgValue = e.getValue(); //切断する if (msgType.equals("close")) { try { close(); } catch(IOException err) { err.printStackTrace(); } } //名前を更新する else if(msgType.equals("setName")) { String name = msgValue; //半角文字は使えない記号 if (name.indexOf(" ") == -1) { String before = getName(); setName(name); sendMessage("successful setRoom"); reachedMessage("msg", before + " から " + name + " に名前を変更しました"); } else { sendMessage( "error 名前に半角空白文字を使うことはできません"); } } //新しい部屋を追加する else if(msgType.equals("addRoom")) { String name = msgValue; //半角文字は使えない記号 if (name.indexOf(" ") == -1) { ChatRoom room = new ChatRoom(name , this); server.addChatRoom(room); sendMessage("successful addRoom"); } else sendMessage( "error 名前に半角空白文字を使うことはできません"); } //現在存在する部屋を返す else if(msgType.equals("getRooms")) { String result = ""; ChatRoom[] rooms = server.getChatRooms(); for(int i = 0 ; i < rooms.length ; i++) { result += rooms[i].getName() + " "; } sendMessage("rooms " + result); } //部屋に入る else if(msgType.equals("enterRoom")) { ChatRoom room = server.getChatRoom(msgValue); if (room != null) { room.addUser(this); sendMessage("successful enterRoom"); } else sendMessage( "error \"" + msgValue + "\" が見つかりません"); } //部屋から出る else if(msgType.equals("exitRoom")) { ChatRoom room = server.getChatRoom(msgValue); if (room != null) { room.removeUser(this); sendMessage("successful exitRoom"); } else sendMessage( "error \"" + msgValue + "\" が見つかりません"); } //指定した部屋のユーザーのリストを返す else if(msgType.equals("getUsers")) { ChatRoom room = server.getChatRoom(msgValue); if (room != null) { String result = ""; ChatClientUser[] users = room.getUsers(); for(int i = 0 ; i < users.length ; i++) { result += users[i].getName() + " "; } sendMessage("users " + result); } } }
もちろん、本格的なチャットサービスを構築しようと思った場合はこれでは不十分です。送られてきたメッセージに対して、誰が、いつ、送信したのかというメッセージに対するメタデータを付属させる必要があるためです。また、将来の拡張にも備えた方が良いでしょう。個人的には、XMLを使ってメッセージを交換する方法をお勧めします。
また、ネットワーク負荷や処理効率を優先する場合は、文字ベースのコマンドではなくバイナリデータで通信した方が効率的です。ただし、将来の拡張や変更には弱くなる可能性があります。
チャットルーム
チャットルームは、チャットルームに入室しているユーザーのリストを管理し、部屋に入室しているユーザーのいずれかがmsgコマンドを送信してきた場合に、そのメッセージを同じ部屋のすべてのユーザーに正しい形に変換して送信します。チャットルームはChatRoom
クラスとして実装しています。
ユーザーがaddRoomコマンドを送信してきた場合、ChatClientUser
は新しいChatRoom
インスタンスを生成してサーバーに追加します。ChatRoom
クラスはユーザーの追加や削除を行うメソッドを公開しています。
以下のコードは、ChatRoom
クラスのメソッドです。addUser()
メソッドは新しくこの部屋にユーザーを追加(入室)させ、removeUser()
メソッドは現在入室しているユーザーを解除(退室)させます。
//この部屋にユーザーを追加(入室)する public void addUser(ChatClientUser user) { user.addMessageListener(this); roomUsers.add(user); for(int i = 0 ; i < roomUsers.size() ; i++) { roomUsers.get(i).reachedMessage("getUsers", name); roomUsers.get(i).sendMessage("msg >" + user.getName() + " さんが入室しました"); } } //指定したユーザーをチャットルームから退室させる public void removeUser(ChatClientUser user) { user.removeMessageListener(this); roomUsers.remove(user); for(int i = 0 ; i < roomUsers.size() ; i++) { roomUsers.get(i).reachedMessage("getUsers", name); roomUsers.get(i).sendMessage("msg >" + user.getName() + " さんが退室しました"); } //ユーザーがいなくなったので部屋を削除する if (roomUsers.size() == 0) { ChatServer.getInstance().removeChatRoom(this); } } public void messageThrow(MessageEvent e) { ChatClientUser source = e.getUser(); //ユーザーが発言した if (e.getName().equals("msg")) { for(int i = 0 ; i < roomUsers.size() ; i++) { //msg 発言ユーザー名 メッセージ String message = e.getName() + " " + source.getName() + ">" + e.getValue(); roomUsers.get(i).sendMessage(message); } } //ユーザーが名前を変更した else if(e.getName().equals("setName")) { for(int i = 0 ; i < roomUsers.size() ; i++) { roomUsers.get(i).reachedMessage("getUsers", name); } } }
ChatRoom
クラスはMessageListener
クラスを実装しています。入室しているユーザーがメッセージを送ってきたかどうかは、addUser()
メソッドでユーザーを登録したときに、ChatClientUser
オブジェクトのaddMessageListener()
メソッドでChatRoom
オブジェクトをリスナとして追加することで判断できます。これで、ユーザーが送信したメッセージを常に監視することができ、興味のあるメッセージであればmessageThrow()
メソッド内で処理します。
チャットクライアントを作る
今回、クライアントアプリケーションはJava Swingコンポーネントを使って実装します。コードの大部分はコンポーネントの定義と操作に関する部分であり、流れや設計としては複雑なものではありません。クライアントはChatClient
クラスとして実装しています。ChatClient
コンストラクタはコンポーネントを初期化して組み合わせているだけなので、この部分のコードの説明は割愛します。
クライアントアプリケーションが接続するサーバーはHOST
フィールドに、ポート番号はPORT
フィールドに固定してあります。今回はローカル環境でのテストのみを想定しているため 「localhost」に固定してあります。別のコンピュータに接続する場合はHOST
フィールドの値を変更してください。
サーバーはServerSocket
クラスを使ってクライアントからの接続を待機しました。これに対してクライアントはSocket
クラスを使ってサーバーに接続しなければなりません。サーバーとは異なり、外部からの接続を待機する必要はありません。サーバーへの接続はconnectServer()
メソッドで行なっています。
public void connectServer() { try { socket = new Socket(HOST, PORT); msgTextArea.append(">サーバーに接続しました\n"); } catch(Exception err) { msgTextArea.append("ERROR>" + err + "\n"); } }
このSocket
クラスはサーバー側で接続してきたクライアントを表したSocket
クラスと同じです。入出力APIを用いてサーバーにデータを送信することができるので、コマンドをprintln()
メソッドなどでコマンド文字列を送信すればサーバーを操作することができます。
クライアントアプリケーションもサーバーアプリケーションと同様にマルチスレッドで構築する必要があります。特に、サーバーからのメッセージの受信は常に監視する必要があるため、新しいスレッドを作成してrun()
メソッド内で受信を待機させる必要があります。クライアントアプリケーションはGUI対応なので、イベントディスパッチスレッドを停止させてしまうと、ユーザーからの入力イベントを処理できなくなり、結果としてウィンドウがビジー状態になってしまいます。
public void run() { try { InputStream input = socket.getInputStream(); BufferedReader reader = new BufferedReader(new InputStreamReader(input)); while(!socket.isClosed()) { String line = reader.readLine(); String[] msg = line.split(" ", 2); String msgName = msg[0]; String msgValue = (msg.length < 2 ? "" : msg[1]); reachedMessage(msgName, msgValue); } } catch(Exception err) { } }
これが、ChatClient
クラスのメッセージ受信用スレッドとなります。ChatClient
はRunnable
インタフェースを実装し、コンストラクタから新しいスレッドを起動します。コードの内容は、サーバーのChatClientUser
クラスのrun()
メソッドとほとんど同じであることがわかると思います。
ChatClient
クラスは、sendMessage()
メソッドでサーバーに任意のコマンド文字列を送信し、reachedMessage()
メソッドで受信したメッセージを処理します。サーバーは、クライアントに対して他のユーザーのメッセージのほかに、現在作られている部屋のリストや、入室している部屋のユーザーリストなどを送信してきます。このようなメッセージを受け取り、適切にコンポーネントを更新しなければなりません。
//メッセージをサーバーに送信する public void sendMessage(String msg) { try { OutputStream output = socket.getOutputStream(); PrintWriter writer = new PrintWriter(output); writer.println(msg); writer.flush(); } catch(Exception err) { msgTextArea.append("ERROR>" + err + "\n"); } } //サーバーから送られてきたメッセージの処理 public void reachedMessage(String name, String value) { //チャットルームのリストに変更が加えられた if (name.equals("rooms")) { if (value.equals("")) { roomList.setModel(new DefaultListModel()); } else { String[] rooms = value.split(" "); roomList.setListData(rooms); } } //ユーザーが入退室した else if (name.equals("users")) { if (value.equals("")) { userList.setModel(new DefaultListModel()); } else { String[] users = value.split(" "); userList.setListData(users); } } //メッセージが送られてきた else if (name.equals("msg")) { msgTextArea.append(value + "\n"); } //処理に成功した else if (name.equals("successful")) { if (value.equals("setName")) msgTextArea.append(">名前を変更しました\n"); } //エラーが発生した else if (name.equals("error")) { msgTextArea.append("ERROR>" + value + "\n"); } }
最後に
今回作成したチャットサーバー・クライアントを、ルーム機能付きの公開用サーバーを開発する参考にしていただければ幸いです。しかし、このプログラムにはまだ多くの問題を残しています。例えば、このプログラムはネットワーク上での本格的な運用を想定していないため、セキュリティやエラーチェックが不十分です。
また、機能的にも追加要素がたくさん考えられます。ユーザーの検索、特定のユーザーにのみメッセージを送るウィスパー機能、チャットルームのホストユーザーに対する特別な権限(ユーザーの拒否、強制退出など)、ユーザーの入室・発言時にサウンドを鳴らすなど、必要な機能はたくさんあると思います。
しかし、こうした機能の追加もサーバーとクライアント間の通信部分を拡張すれば実現することができます。今回作成したソースコードでは、サーバー、クライアント、チャットルームなどが役割ごとにクラス化されているため、機能の変更や拡張は難しいものではありません。
参考資料
- JDK 5.0ドキュメント
- 『Javaネットワークプログラミング』 Elliotte Rusty Harold 著、戸松豊和 監訳、田和勝 訳、オライリー・ジャパン、2001年10月