背景
[更新]:APIを使用して簡単に実装した
ネット上で多人数同時通信のものを作成するため、ソケット通信について実践したことを記述。
チャットやオンラインゲームのような、複数でのリアルタイム通信を希望。
「webブラウザ」で実行できるようなプログラムを「Java」で組みたかったのだが、ググってもあまり出てこないので記述しておく。
(知識不足でネットワークプログラミングという言葉も知らなかったため、ググり方に問題があるかも…)
データの読み書き等の超基本的なところからスタート。
ソケット通信
通信方法の1種。
サーバーとクライアントに対して、HTTPのgetアクセスやpostアクセスのように単一方向への通信ではなく、リアルタイムでの双方向通信が可能。
⇒ サーバープログラムとクライアントプログラムの2つが必要。
詳しくはググってほしい。
実行環境
- windows10
- eclipse (Tomcat8.5)
- Google Chrome
言語
サーバープログラム:Java
クライアントプログラム:JavaScript
成果物
- webブラウザで動くチャットアプリ
開発手順
- クライアント側のプログラムを組む
- サーバー側のプログラムを組む
- クライアントプログラムからサーバーへアクセス
- サーバーからの応答があればOK
実践項目
- クライアントエコーによるソケット通信
- 単一クライアントとサーバーのチャットアプリ
- 複数クライアントとサーバーのチャットアプリ
1. クライアントエコーによるソケット通信
クライアント側のプログラムはJavaScriptで記述する。
ここはほぼパクり。
サーバー側のプログラムは必要なし。
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>WebSocket通信</title>
<script type="text/javascript">
var wSck= new WebSocket("echo.websocket.org");// WebSocketオブジェクト生成
wSck.onopen = function() {//ソケット接続時のアクション
document.getElementById('show').innerHTML += "接続しました。" + "<br/>";
};
wSck.onmessage = function(e) {//メッセージを受け取ったときのアクション
document.getElementById('show').innerHTML += e.data + "<br/>";
};
var sendMsg = function(val) {//メッセージを送信するときのアクション
var line = document.getElementById('msg');//入力内容を取得
wSck.send(line.value);//ソケットに送信
line.value = "";//内容をクリア
};
</script>
</head>
<body>
<div
style="width: 500px; height: 200px; overflow-y: auto; border: 1px solid #333;"
id="show"></div>
<input type="text" size="80" id="msg" name="msg" />
<input type="button" value="送信" onclick="sendMsg();" />
</body>
</html>
コードの解説
1.Webソケットオブジェクトを生成し、引数にアドレスを代入。
var 変数 = new WebSocket("ws://IPアドレス:ポート番号");
今回はアドレスに「echo.websocket.org」を使用。
⇒クライアントから送信したものをそのまま返してくれる。
サーバープログラムを作成しなくてもソケット通信が可能なため便利。
2.ソケット接続時のアクションを記述
ソケット.onopen = function() {・・・};
ソケット接続に成功したら、まずこの関数が実行される。
3.メッセージを受け取ったときのアクションを記述
ソケット.onmessage = function() {・・・};
サーバーからデータが送られて来たらこの関数が実行される。
4.メッセージを送る内容を記述
ソケット.send(・・・);
実行結果
送信した内容がそのまま架空サーバーから送り返されて表示される。
2. 単一クライアントとサーバーのチャットアプリ
架空サーバーじゃなく、実際にサーバーを作成してクライアントからの送信内容をそのまま返信する。
サーバー側のプログラムをJavaで記述する。
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Base64.Encoder;
class SocketFree {
private static ServerSocket sSck;// サーバー用のソケット
private static Socket sck;// 受付用のソケット
private static InputStream is;// 入力ストリーム
private static InputStreamReader isr;// 入力ストリームを読み込む
private static BufferedReader in;// バッファリングによるテキスト読み込み
private static OutputStream os;// 出力ストリーム
public static void main(String[] args) {
try {
sSck=new ServerSocket(60000);//サーバーソケットのインスタンスを作成(ポートは60000)
System.out.println("サーバーに接続したよ!");
sck=sSck.accept();//接続待ち。来たらソケットに代入。
System.out.println( "参加者が接続しました!");
// 必要な入出力ストリームを作成する
is=sck.getInputStream();//ソケットからの入力をバイト列として読み取る
isr=new InputStreamReader(is);//読み取ったバイト列を変換して文字列を読み込む
in=new BufferedReader(isr);//文字列をバッファリングして(まとめて)読み込む
os=sck.getOutputStream();//ソケットにバイト列を書き込む
//クライアントへ接続許可を返す(ハンドシェイク)
handShake(in , os);
//ソケットへの入力をそのまま返す ※125文字まで(126文字以上はヘッダーのフレームが変化する)
echo(is , os);
} catch (Exception e) {
System.err.println("エラーが発生しました: " + e);
}
}
//クライアントへ接続許可を返すメソッド(ハンドシェイク)
public static void handShake(BufferedReader in , OutputStream os){
String header = "";//ヘッダーの変数宣言
String key = "";//ウェブソケットキーの変数宣言
try {
while (!(header = in.readLine()).equals("")) {//入力ストリームから得たヘッダーを文字列に代入し、全行ループ。
System.out.println(header);//1行ごとにコンソールにヘッダーの内容を表示
String[] spLine = header.split(":");//1行を「:」で分割して配列に入れ込む
if (spLine[0].equals("Sec-WebSocket-Key")) {//Sec-WebSocket-Keyの行
key = spLine[1].trim();//空白をトリムし、ウェブソケットキーを入手
}
}
key +="258EAFA5-E914-47DA-95CA-C5AB0DC85B11";//キーに謎の文字列を追加する
byte[] keyUtf8=key.getBytes("UTF-8");//キーを「UTF-8」のバイト配列に変換する
MessageDigest md = MessageDigest.getInstance("SHA-1");//指定されたダイジェスト・アルゴリズムを実装するオブジェクトを返す
byte[] keySha1=md.digest(keyUtf8);//キー(UTF-8)を使用してダイジェスト計算を行う
Encoder encoder = Base64.getEncoder();//Base64のエンコーダーを用意
byte[] keyBase64 = encoder.encode(keySha1);//キー(SHA-1)をBase64でエンコード
String keyNext = new String(keyBase64);//キー(Base64)をStringへ変換
byte[] response = ("HTTP/1.1 101 Switching Protocols\r\n"
+ "Connection: Upgrade\r\n"
+ "Upgrade: websocket\r\n"
+ "Sec-WebSocket-Accept: "
+ keyNext
+ "\r\n\r\n")
.getBytes("UTF-8");//HTTP レスポンスを作成
os.write(response);//HTTP レスポンスを送信
} catch (IOException e) {
System.err.println("エラーが発生しました: " + e);
} catch (NoSuchAlgorithmException e) {
System.err.println("エラーが発生しました: " + e);
}
}
//ソケットへの入力を無限ループで監視するメソッド
public static void echo(InputStream is , OutputStream os) {
try{
while(true) {
byte[] buff = new byte[1024];//クライアントから送られたバイナリデータを入れる配列
int lineData =is.read(buff);//データを読み込む
for (int i = 0; i < lineData - 6; i++) {
buff[i + 6] = (byte) (buff[i % 4 + 2] ^ buff[i + 6]);//7バイト目以降を3-6バイト目のキーを用いてデコード
}
String line = new String(buff, 6, lineData - 6, "UTF-8");//デコードしたデータを文字列に変換
byte[] sendHead = new byte[2];//送り返すヘッダーを用意
sendHead[0] = buff[0];//1バイト目は同じもの
sendHead[1] = (byte) line.getBytes("UTF-8").length;//2バイト目は文字列の長さ
os.write(sendHead);//ヘッダー出力
os.write(line.getBytes("UTF-8"));//3バイト目以降に文字列をバイナリデータに変換して出力
if (line.equals("bye")) break;//「bye」が送られたなら受信終了
}
} catch (Exception e) {
System.err.println("エラーが発生しました: " + e);
}
}
}
コードの解説
1.ソケットやストリーム等の必要な変数を宣言。
2.サーバーソケットのインスタンスを作成。
ServerSocket 変数 = new ServerSocket(ポート番号)
3.acceptメソッドでクライアントからの接続を待機。
4.接続されたクライアントの入出力ストリームを作成する。
5.クライアントからの接続要求に対してレスポンスを返す。(handShakeメソッド)
リクエストのヘッダーに必要な情報が記述されているため、編集して送り返す。
編集内容の詳細はコードのコメント及び参照サイトを参考。
要は、リクエストのヘッダーにある『WebSocketキー』を送り返す必要がある。
⇒ 『ハンドシェイク』
6.ソケット接続後、無限ループでクライアントからの入力をそのまま返す。(echoメソッド)
クライアントから送信されたデータはバイナリデータとしてエンコードされている。
サーバー側でデコードし、再度エンコードしてクライアントへ送信する。
デコード内容の詳細はコードのコメント及び参照サイトを参考。
※送信された文字列の長さによってヘッダーのフレームが変化するので、ここでは簡単のため125文字以下に限定する。
7.クライアントから「bye」が送信されるとループを抜け受信終了
実行結果
- サーバープログラムを起動
- ポート番号を指定し、localhostへ接続
※クライアントプログラムのアドレスを変更しておく(今回は60000番)
var 変数 = new WebSocket("ws://127.0.0.1:60000");
架空サーバー(echo.websocket.org)を使用したときと同様の結果が得られた。
今回はエコーするだけだが、サーバー側でデータをいろいろ弄って返信することも可能。
複数クライアントとサーバーのチャットアプリ
サーバープログラムを編集し、複数のクライアントからの接続を許可する。
また、各クライアント間でリアルタイム通信となるようにする。
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Base64.Encoder;
class SocketFree {
private static ServerSocket sSck;// サーバー用のソケット
private static Socket[] sck;// 受付用のソケット
private static InputStream[] is;// 入力ストリーム
private static InputStreamReader[] isr;// 入力ストリームを読み込む
private static BufferedReader[] in;// バッファリングによるテキスト読み込み
private static OutputStream[] os;// 出力ストリーム
private static ClientThread user[];//各クライアントのインスタンス
private static int member;// 接続しているメンバーの数
public static void main(String[] args) {
int n=0;
int maxUser=100;
//各フィールドの配列を用意
sck=new Socket[maxUser];
is=new InputStream[maxUser];
isr=new InputStreamReader[maxUser];
in=new BufferedReader[maxUser];
os=new OutputStream[maxUser];
user=new ClientThread[maxUser];
try {
sSck=new ServerSocket(60000);//サーバーソケットのインスタンスを作成(ポートは60000)
System.out.println("サーバーに接続したよ!");
while(true) {
sck[n]=sSck.accept();//接続待ち。来たらソケットに代入。
System.out.println( (n+1)+"番目の参加者が接続しました!");
// 必要な入出力ストリームを作成する
is[n]=sck[n].getInputStream();//ソケットからの入力をバイト列として読み取る
isr[n]=new InputStreamReader(is[n]);//読み取ったバイト列を変換して文字列を読み込む
in[n]=new BufferedReader(isr[n]);//文字列をバッファリングして(まとめて)読み込む
os[n]=sck[n].getOutputStream();//ソケットにバイト列を書き込む
//クライアントへ接続許可を返す(ハンドシェイク)
handShake(in[n] , os[n]);
//各クライアントのスレッドを作成
user[n] = new ClientThread(n , sck[n] , is[n] , isr[n] , in[n] , os[n]);
user[n].start();
member=n+1;//接続数の更新
n++;//次の接続者へ
}
} catch (Exception e) {
System.err.println("エラーが発生しました: " + e);
}
}
//クライアントへ接続許可を返すメソッド(ハンドシェイク)
public static void handShake(BufferedReader in , OutputStream os){
String header = "";//ヘッダーの変数宣言
String key = "";//ウェブソケットキーの変数宣言
try {
while (!(header = in.readLine()).equals("")) {//入力ストリームから得たヘッダーを文字列に代入し、全行ループ。
System.out.println(header);//1行ごとにコンソールにヘッダーの内容を表示
String[] spLine = header.split(":");//1行を「:」で分割して配列に入れ込む
if (spLine[0].equals("Sec-WebSocket-Key")) {//Sec-WebSocket-Keyの行
key = spLine[1].trim();//空白をトリムし、ウェブソケットキーを入手
}
}
key +="258EAFA5-E914-47DA-95CA-C5AB0DC85B11";//キーに謎の文字列を追加する
byte[] keyUtf8=key.getBytes("UTF-8");//キーを「UTF-8」のバイト配列に変換する
MessageDigest md = MessageDigest.getInstance("SHA-1");//指定されたダイジェスト・アルゴリズムを実装するオブジェクトを返す
byte[] keySha1=md.digest(keyUtf8);//キー(UTF-8)を使用してダイジェスト計算を行う
Encoder encoder = Base64.getEncoder();//Base64のエンコーダーを用意
byte[] keyBase64 = encoder.encode(keySha1);//キー(SHA-1)をBase64でエンコード
String keyNext = new String(keyBase64);//キー(Base64)をStringへ変換
byte[] response = ("HTTP/1.1 101 Switching Protocols\r\n"
+ "Connection: Upgrade\r\n"
+ "Upgrade: websocket\r\n"
+ "Sec-WebSocket-Accept: "
+ keyNext
+ "\r\n\r\n")
.getBytes("UTF-8");//HTTP レスポンスを作成
os.write(response);//HTTP レスポンスを送信
} catch (IOException e) {
System.err.println("エラーが発生しました: " + e);
} catch (NoSuchAlgorithmException e) {
System.err.println("エラーが発生しました: " + e);
}
}
//各クライアントからのデータを全クライアントに送信するメソッド
public static void sendAll(int number , byte[] sendHead , String line){
try {
for (int i = 0; i <member ; i++) {
os[i].write(sendHead);//ヘッダー出力
os[i].write(line.getBytes("UTF-8"));//3バイト目以降に文字列をバイナリデータに変換して出力
System.out.println((i+1)+"番目に"+(number+1)+"番目のメッセージを送りました!" );
}
} catch (IOException e) {
System.err.println("エラーが発生しました: " + e);
}
}
}
class ClientThread extends Thread{
//各クライアントのフィールド
private int myNumber;
private Socket mySck;
private InputStream myIs;
private InputStreamReader myIsr;
private BufferedReader myIn;
private OutputStream myOs;
//コンストラクタでインスタンスのフィールドに各値を代入
public ClientThread(int n , Socket sck , InputStream is , InputStreamReader isr , BufferedReader in , OutputStream os) {
myNumber=n;
mySck=sck;
myIs=is;
myIsr=isr;
myIn=in;
myOs=os;
}
//Threadクラスのメイン
public void run() {
try {
echo(myIs , myOs , myNumber);
} catch (Exception e) {
System.err.println("エラーが発生しました: " + e);
}
}
//ソケットへの入力を無限ループで監視する ※125文字まで(126文字以上はヘッダーのフレームが変化する)
public void echo(InputStream is , OutputStream os , int myNumber) {
try{
while(true) {
byte[] buff = new byte[1024];//クライアントから送られたバイナリデータを入れる配列
int lineData =is.read(buff);//データを読み込む
for (int i = 0; i < lineData - 6; i++) {
buff[i + 6] = (byte) (buff[i % 4 + 2] ^ buff[i + 6]);//7バイト目以降を3-6バイト目のキーを用いてデコード
}
String line = new String(buff, 6, lineData - 6, "UTF-8");//デコードしたデータを文字列に変換
byte[] sendHead = new byte[2];//送り返すヘッダーを用意
sendHead[0] = buff[0];//1バイト目は同じもの
sendHead[1] = (byte) line.getBytes("UTF-8").length;//2バイト目は文字列の長さ
SocketFree.sendAll(myNumber , sendHead , line);//各クライアントへの送信は元クラスのsedAllメソッドで実行
if (line.equals("bye")) break;//「bye」が送られたなら受信終了
}
} catch (Exception e) {
System.err.println("エラーが発生しました: " + e);
}
}
}
コードの解説
1.クライアントのナンバリングや接続数を把握するためのフィールド変数を追加。
2.複数のクライアントに対応するため、各フィールドを配列形式にする。
main関数で配列の初期化を行い、これまでの変数を編集。
3.複数クライアントを同時並行で処理するため、Threadクラスを継承したClientThreadクラスを作成する。
ハンドシェイクまではmain関数内で処理。
それ以降は各クライアント毎にClientThreadインスタンスを作成して並行処理。
Threadクラス:main関数から分岐して同時に処理することができる。
.start();でrun()が実行される謎仕様。
4.ClientThreadクラスにecho関数を移動。
データ受信 ⇒ デコード ⇒ データ送信
この内データ送信はmain関数が所属するクラスで実行するため、新しくsendAllメソッドを作成。
※データ送信は全クライアントに対して実行。
全クライアントを扱っているのはmain関数のクラス。
5.その他、メソッドの型や変数名等の細かい修正。
実行結果
- サーバープログラムを起動
- 複数のブラウザからlocalhostへ接続
1つのクライアントから送信すると、全てのブラウザで同じテキストが表示される。
どのクライアントからテキストを送信しても表示結果は同じ。
その他
改善点
・ブラウザを閉じたときのエラー処理
「bye」を送信すると監視のループを抜けてエラーなく終了できるが、監視中にブラウザを閉じるとエラーが発生しサーバーが停止してしまう。
・closeメソッドの追記
本来はソケット通信を終了する際はcloseメソッドを使用して切断するが、今回は使用していない。
・デコード処理の強化
デコードの処理を甘えた為、125文字以下の通信しかできない。
126文字以上になるとwebソケットのフレームが変化するため、場合分けが必要になってくる。
・セキュリティ関連の強化
今回は何も対策しておらずセキュリティがガバガバなので、送受信の方法を考える必要がある。
備考
・ポートの解放
プログラム内で指定したポートが既に使用されている場合、サーバー起動時にエラーが発生する。
- コマンドプロンプトで
>netstat -ano
を実行 - 指定したローカルアドレスからPIDを確認
- タスクマネージャーのプロセスからPDIのアプリケーションを確認
- 必要ない物ならプロセスを終了してポートを開放
・ハンドシェイク
ソケット通信を行う前に、クライアントとサーバー間で合意を取る必要がある。
クライアントからサーバーへ接続要求があるなら、それに対してレスポンスを返す必要がある。
レスポンスの内容は参考ページに詳しく記述されている。
・受信データのデコード
webソケットのデータにはフレームフォーマットが存在し、そのフレームに沿ってバイナリデータが送信されている。
データをデコードするには、フレーム構造を理解して1バイト毎に変換していく必要がある。
フレームフォーマットの詳細やデコード方法は参考ページに詳しく記述されている。
感想
思った以上にややこしい。
ググってもなかなか出てこないし、マイナーな方法?
よく見かけるのは、サーバープログラムを「Node.js」で記述したもの。
ドットインストールの動画ではNode.jsで作成しており、レスポンスやデコード等の複雑な処理はしていなかった。
多分、Node.jsの勉強をした方が早い。
Javaでも、アノテーションを利用するとデコード処理が無くても通信できるような記事も見かけたが、今回はパスした。
パスしない方が良かったかも。
プログラミングの勉強をしていたはずが、いつの間にかプロトコルの勉強になっていた・・・
初学者が首を突っ込むところじゃない気がしたが、勉強になったので良し。
間違えているところがあったらスマン。
気が付けば修正する。
改善すべき点もかなり多いので、可能なら更新していく。