15
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

JavaとJavaScriptでwebブラウザとのソケット通信①

Last updated at Posted at 2020-05-17

背景

[更新]:APIを使用して簡単に実装した

ネット上で多人数同時通信のものを作成するため、ソケット通信について実践したことを記述。
チャットやオンラインゲームのような、複数でのリアルタイム通信を希望。

「webブラウザ」で実行できるようなプログラムを「Java」で組みたかったのだが、ググってもあまり出てこないので記述しておく。
(知識不足でネットワークプログラミングという言葉も知らなかったため、ググり方に問題があるかも…)

データの読み書き等の超基本的なところからスタート。

ソケット通信

通信方法の1種。
サーバーとクライアントに対して、HTTPのgetアクセスやpostアクセスのように単一方向への通信ではなく、リアルタイムでの双方向通信が可能。
⇒ サーバープログラムとクライアントプログラムの2つが必要。

詳しくはググってほしい。

実行環境

  • windows10
  • eclipse (Tomcat8.5)
  • Google Chrome

言語

サーバープログラム:Java
クライアントプログラム:JavaScript

成果物

  1. webブラウザで動くチャットアプリ

開発手順

  1. クライアント側のプログラムを組む
  2. サーバー側のプログラムを組む
  3. クライアントプログラムからサーバーへアクセス
  4. サーバーからの応答があればOK

実践項目

  1. クライアントエコーによるソケット通信
  2. 単一クライアントとサーバーのチャットアプリ
  3. 複数クライアントとサーバーのチャットアプリ

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(・・・);

実行結果

キャプチャ.JPG
キャプチャ2.JPG
キャプチャ3.JPG

送信した内容がそのまま架空サーバーから送り返されて表示される。

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」が送信されるとループを抜け受信終了

実行結果

  1. サーバープログラムを起動
  2. ポート番号を指定し、localhostへ接続

キャプチャ3.JPG

※クライアントプログラムのアドレスを変更しておく(今回は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.その他、メソッドの型や変数名等の細かい修正。

実行結果

  1. サーバープログラムを起動
  2. 複数のブラウザからlocalhostへ接続

キャプチャ5.JPG

1つのクライアントから送信すると、全てのブラウザで同じテキストが表示される。
どのクライアントからテキストを送信しても表示結果は同じ。

その他

改善点

・ブラウザを閉じたときのエラー処理
「bye」を送信すると監視のループを抜けてエラーなく終了できるが、監視中にブラウザを閉じるとエラーが発生しサーバーが停止してしまう。

・closeメソッドの追記
本来はソケット通信を終了する際はcloseメソッドを使用して切断するが、今回は使用していない。

・デコード処理の強化
デコードの処理を甘えた為、125文字以下の通信しかできない。
126文字以上になるとwebソケットのフレームが変化するため、場合分けが必要になってくる。

・セキュリティ関連の強化
今回は何も対策しておらずセキュリティがガバガバなので、送受信の方法を考える必要がある。

備考

・ポートの解放
プログラム内で指定したポートが既に使用されている場合、サーバー起動時にエラーが発生する。

  1. コマンドプロンプトで>netstat -anoを実行
  2. 指定したローカルアドレスからPIDを確認
  3. タスクマネージャーのプロセスからPDIのアプリケーションを確認
  4. 必要ない物ならプロセスを終了してポートを開放
    キャプチャ6.JPG

・ハンドシェイク
ソケット通信を行う前に、クライアントとサーバー間で合意を取る必要がある。
クライアントからサーバーへ接続要求があるなら、それに対してレスポンスを返す必要がある。
レスポンスの内容は参考ページに詳しく記述されている。

・受信データのデコード
webソケットのデータにはフレームフォーマットが存在し、そのフレームに沿ってバイナリデータが送信されている。
データをデコードするには、フレーム構造を理解して1バイト毎に変換していく必要がある。
フレームフォーマットの詳細やデコード方法は参考ページに詳しく記述されている。

感想

思った以上にややこしい。

ググってもなかなか出てこないし、マイナーな方法?
よく見かけるのは、サーバープログラムを「Node.js」で記述したもの。
ドットインストールの動画ではNode.jsで作成しており、レスポンスやデコード等の複雑な処理はしていなかった。
多分、Node.jsの勉強をした方が早い。

Javaでも、アノテーションを利用するとデコード処理が無くても通信できるような記事も見かけたが、今回はパスした。
パスしない方が良かったかも。

プログラミングの勉強をしていたはずが、いつの間にかプロトコルの勉強になっていた・・・
初学者が首を突っ込むところじゃない気がしたが、勉強になったので良し。

間違えているところがあったらスマン。
気が付けば修正する。
改善すべき点もかなり多いので、可能なら更新していく。

参考ページ

15
12
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?