資料館/ネトゲ製作/004

Last-modified: 2008-11-24 (月) 22:50:08

P2Pチャットシステムを作るぞ

おさらい

前回サーバー・クライアント(SC)型のチャットシステムを作成した。今回はピア・ツー・ピア(P2P)型のチャットシステムを作ってみるが二つの形式にどのような違いがあるかもう一度見とく。

SC型システムの図

sc.jpg

SC型では、前回示したとおり、全員がサーバーへ接続する。サーバーがデータを管理し、全てのクライアントへ送信する。一見すると単純だが問題点もある。


あるシステムがある。クライアントは一秒に1回、5KBのデータを送信してくる。サーバーはそれにデータを付与して7KBのデータを、送信元を含めた全てのクライアントへ送信する。
さて、クライアントが10人接続している時、サーバーは全部でどのくらいのデータを扱わなければならないだろうか。
クライアント10人がそれぞれ5KBのデータを送ってくる。それを7KBにして7KB送信・・・と考えるのは甘い。サーバーは各ユーザーのデータ7KB*10を、全員に送信しなければならない。この例では、70KBのデータを10人に送信するので、サーバーから伸びた一本の回線に700KBものデータが流れることになる。サーバーの上り回線にかかる負担は、ユーザーが増えるたびに跳ね上がっていくということがイメージできたと思う。従ってサーバーが送信するデータは極力少なくしなければならない。サーバーの集中してしまう負荷をうまい具合に解消しなければ、多人数がデータをやり取りするサーバー、ぶっちゃけMMORPGのサーバーみたいなものは難しい。

P2Pシステムの図

p2p.jpg

それに対してP2P型では、各々のユーザーがそれぞれとつながる。サーバーがまったく存在しないこの形式はピュアP2Pと呼ばれているらしい。*1
特定のサーバーに処理を依存せず、お互いにデータを送信しあうことで負荷が分散していることがわかるだろうか。先のシステムを例に取る。ユーザーは、各ユーザーに7KBのデータを送信する。10人が相互に接続している場合、各ユーザーの上り回線には、自分を含めた全員に送信すると計算した場合70KBの負荷がかかる。サーバーと違い、自分のデータのみを送信するためサーバーと比べて回線負荷が少ない。


ところがピュアP2Pでも問題は残る。管理する明確なサーバーがない、ということはつまりネットワークを管理できないということになる。一度稼動したシステムをとめることは不可能だ。まあそんな大規模な話は、よく僕らに情報提供してくれるK察の共有厨に任せておいて、身近な話をしよう。


前回作成したチャットシステムは、名前を入力してサーバーに接続していた。サーバーのIPはいつも決まっているのでクライアントは迷うことなくサーバーに接続し、各ユーザーに接続できた。
では、ピュアP2Pの場合、ユーザーは一番最初にどこに接続すればいいのか。さらに、誰と接続すればいいのか、といって事を、ユーザー全員で管理していかなければならない。洒落等では、「初期ノード」という形で、最初に接続するユーザーを設定している。だがれらの管理は非常にめんどくさいので、今回は、前回最後にちょっとふれたが、ユーザーを管理する簡単なサーバーをおく。

今回作ろうとしているシステムの図

today.jpg

今回は、ピュアP2Pではなく、サーバーとも接続する。*2

システムの名前(最重要)を考える

P2Pチャットシステムなので、P2PCSという名前にしとこう。

サーバーの仕事

  • 有効なユーザーのIPを、リストにして保持
  • 新たに接続してきたユーザーに、ユーザー人数とIPのリストを送信

以上だ。前回行っていたチャットデータの送信はやらない。

クライアントの役割

  • サーバーに接続し、IPを登録する
  • 各ユーザーからの接続を受け付ける。また、各ユーザーに接続しに行く
  • 各ユーザーのデータを送受信する

前回サーバーが行っていた仕事を、クライアントが分散して行う。

ユーザーを管理するサーバーの作成

ユーザー接続時の変更点

前回のサーバーからソースをコピって来る。幾つか削ってちょっと書き足したら出来上がりだ。

;---------ユーザーデータ領域の確保
	sdim username,200,MAXUSER;ユーザー名
	sdim userIP,20,MAXUSER;ユーザーのIP
	dim usersocketID,MAXUSER;ユーザーのソケットID
	dim uservalidflag,MAXUSER;有効かどうかのフラグ

ユーザーデータの宣言部分を持ってきたが、新しくユーザーのIPを保存する文字列型変数が追加されている。間違いなく20バイトも必要ないが、適当に取っておく。

	tcpinfo userIP.blankID,usersocketID.blankID;ユーザーの情報を取得
	getstr userIP.blankID,userIP.blankID,0,':';IPアドレスを取り出す

ユーザーがアクセスしてきた時の処理も殆ど変わっていないが、この2行が追加されている。tcpinfo命令を利用すると、そのソケットに関連付けられたIPとポートを取得できる。

IPアドレス:ポート

という形式の文字列となり、コロンで区切られているのでIPアドレスだけ抜き出しておこう。
ユーザーの登録での変更点はこのくらいで終わり。

長さの変わる配列変数をpack

さて、ユーザー登録が終わった段階で、サーバーはユーザーに、ユーザーリストを送信する。
pack関数によってIPアドレスをまとめればよさそうだが・・・

	pack senddata,"aaaaaaa...aaa",userIP.0,userIP.1,,,userIP.99

と書くのはめんどくさいし、何より、無効なユーザーのIPまで送信してしまい都合が悪い。


ここで、配列変数を送信する方法を思い出してもらいたい。配列変数はメモリ内で(たぶん)連続に確保されており、まとめて送信することが出来る。なので、送信用にナイスサイズな文字列型変数を改めて用意し、それに有効なアドレスのみを書き出して送信しよう。

	usercount=0;ユーザー数初期化
	repeat MAXUSER
		if uservalidflag.cnt=1:usercount++;ユーザー数増加
	loop

	sdim sendIP,20,usercount;文字列領域確保
	nowcount=0

	repeat MAXUSER
		if uservalidflag.cnt=0:continue
		sendIP.nowcount=userIP.cnt
		nowcount++
	loop

	size=20*nowcount;有効ユーザー数×文字列の長さ
	pack senddata,"d"+size+"",sendIP

予め有効なユーザー数をカウントしておいた方がいい。この例では、有効なユーザー数をカウントする処理もいれている。わざわざpackする必要もないのだが、学習のため、後々データを追加する場合、など適当に理由をでっち上げておく。この処理で、有効なユーザーのIPだけをナイスに送信できる。

クライアントの作成(多分長い)

クライアントは、サーバーと接続しつつも他のユーザーに接続しにいったり、他のユーザーからの接続を待ったりする。他のユーザーからの接続を待つ、というところに落とし穴がある。

テスト時の落とし穴

このシステムをテストする時、たいていの人間は1つのPCでテストするだろう。まずサーバーを起動し、クライアントをたくさん起動していく。
ところが今回は、クライアントも他のクライアントからの接続を待ち受ける必要がある。大前提知識のページにも書いたが、データを受信するアプリケーションはポート番号によって決定される。これはつまり、あるアプリケーションがポート番号Aで待ち受けているときは、他のアプリケーションはポートAで待ち受けることが出来ない、ということである。せっかく作ったプログラムをテストできないのでは非常にまずい。


解決策として次のようなものがあげられる。

  1. 複数のPCを用意し、LANを構築してテスティンぐー
  2. ルールに従って待ちうけるポート番号を変更する
  3. ポート番号を自分で決め、サーバーに申告して各ユーザーに通知してもらう

2、3の方法を用いれば、1PCでテストを行うことが出来る。ただし欠点として、ユーザーに割り当てを要求するポートが増えてしまう。悪戯に使用ポートを増やし、ポートをホホホイと開放していくのはセキュリティー上不安が残るといわざるを得ないため注意が必要だ。


まあそれを踏まえたうえで、2の方法を採用する。

ポート番号選定のルール

今回は最大接続数100人+サーバーで、101個のポートを利用する可能性がある。これをいかにして求めるか。
連続していた方がポート開放しやすい事が多いので以下のようにする。

サーバーのポート番号 + 1 + そのユーザーのナンバー = そのユーザーのポート番号

とする。サーバーは前回と同じく46497番を利用するとして、ユーザーナンバー5番のユーザーは4653番を利用すればよいことになる。

ユーザーリストの受信と各ユーザーへの接続

サーバーへの接続は前回と殆ど変わらないが、ユーザーリストを受信する処理が追加されている。
まず、サーバーのユーザー有効フラグを全部もらってこよう。こうすれば、ユーザー人数から、ユーザーリストを何バイト受信すればよいのかわかる。ついでに、自分のユーザーナンバーももらっておこうか。

大きなデータの送受信

まず有効フラグを受信する。有効フラグは0 or 1の1ビット値で管理されているため、本当は12バイトあれば100人分の有効フラグを受信することが出来るが、今回はめんどくさいので400バイトほど使用して受信する。後々解説すると思うのでとりあえずそういうことにしておけ。

サーバー側

	pack senddata,"d"+(4*MAXUSER)+"i",uservalidflag,blankID

こんな感じだ。面倒なので有効フラグをそのままごっそり送信する。そのユーザーのナンバーも送信するのでデータサイズは全部で404byteだ。So Big.
こいつを受信する側だが、そのままtcprecvと書いてしまってはいけない。データが受信し終わるまでの時間を考慮しなければならないのだ。今回は402byteのデータなので、昨今の情勢を見ればそんなに気にする必要もないが、せっかくなのでここで少し触れておく。


大きなデータを受信する方法は大きく分けて2通りだ。

  • データが到着し終わるのを待って、一気に受信する
  • データが来るそばから受け取り、くっつけていく

後者は、いわゆるようつべやニコニコでおなじみのストリーミング配信というやつだ。細切れのデータをがんがん受信していき、全体が受信し終わらないうちに再生を始めてしまう。前者はtcpcount、後者はtcprecvのオフセット値をうまく設定することで実装が可能だ。今回は前者の方法を採用する。


サーバーから送られてくる404byteのデータを、届くまで待ってから受信するスクリプトだが

	repeat
		tcpcount itmp1,socket
		if itmp1=404:break
		await 10
	loop
	tcprecv recivedata,0,404,socket

こんなとこだろう。tcpcountによって、受信バッファに溜まっているデータのサイズを取得する。受信バッファ云々については多分次回説明するので、鵜呑みにしておくように。


まあ兎に角、これで有効フラグと自分の番号を受け取った。ここから、送られてくるIPリストが何バイトになるか求められるので、今度は同様の手順でIPリストも受信しておく。受信したIPリストは、配列の頭から順に、有効なユーザーのIPが入っている。有効フラグと照らし合わせ、何番のユーザーにどのIPアドレスで接続したらいいのか関連付けておこう。




さて、これでユーザーは、チャットすべきユーザーのIPアドレスを手に入れた。サーバーとの通信はこれで終了だ。今すぐにでも切断してやりたいが、そうするとユーザーリストから自分が消えてしまい、他のユーザーが接続してこなくなるので、サーバーとの接続だけは維持しておく。
次はいよいよ、サーバーからもらったIPを用いて他のユーザーに接続していく。だがその前に、自身も他のユーザーからの接続を待つために、待ち受け処理を開始しておこう。

	tcpmake listensocket,SERVERPORT+1+mynumber

この処理を呼んでおこう。他のユーザーを受け付けるソケットを作成する。待ち受けるポートは、定義に従って求める。自分のユーザーナンバーはサーバーから有効フラグと一緒に貰っているのでそれをmynumberに入れておく。


各ユーザーに接続しに行くわけだが、このとき他のユーザーは何をしているだろうか。チャットに勤しんでいるはずだ。なので、チャットを行っているメインループにtcpwaitを入れておけば、チャット中のユーザーは他のユーザーの待ち受けを感知できそうだ。入れておこう。
サーバーから有効フラグとIPリストは貰ったものの、ユーザーの名前はわからない。なのでユーザーとの接続処理に、お互いの名前その他必要な情報を教えあう処理も入れておこう。以上の事を行えば、接続処理は完了だ。以下に例を挙げておく。

新規接続側

	repeat MAXUSER
		if uservalidflag.cnt=0:continue
		if cnt=mynumber:hisname.cnt=username:continue
		tcpopen hissocketID.cnt,hisip.cnt,SERVERPORT+1+cnt
		cn2=cnt
		repeat
			tcpiscon hissocketID.cn2;接続されたかチェック
			if stat=1:break
		loop
		tcpsend mynumber,0,4,hissocketID.cnt;自分のナンバーを送信
		repeat
			tcpget hisname.cn2,64,hissocketID.cn2;相手の名前を受信
			if stat!0:break
		loop
		tcpput username,hissocketID.cnt;自分の名前を送信
	loop

受信した有効フラグをもとに、各ユーザーのIPアドレスに接続しにいく。ポート番号は定義に従って求める。接続したらまず自分が何番のユーザーか申告し、名前の送受信を行う。

接続を受ける側

	tcpwait listensocket
	if stat=1:{;ユーザーが来た
		tcpaccept tmpsocket,listensocket;接続を承認
		repeat
			tcprecv hisid,0,4,tmpsocket;とりあえずソケットを作成
			if stat!0:break
		loop
		hissocketID.hisid=tmpsocket;ソケットIDを格納しなおす
		tcpput username,hissocketID.hisid;自分の名前を送信
		repeat
			tcpget hisname.hisid,64,hissocketID.hisid
			if stat!0:break
		loop
		uservalidflag.hisid=1;有効フラグを立てる
	}

5行目に注目してもらいたい。接続を承認する時点では、どの要素にソケットIDを格納したらいいかわからない。なので適当な変数にソケットIDを格納しておき、ナンバーを受信してから格納しなおしている。あとは送信側受信側で手順をあわせ、うまく動作すればOK。

チャットデータの送信

各ユーザーへのテキストの配信は、クライアントが直接行う。前回のサーバー部分をもってきてちょこっと変えるだけで良い。
ヒントは、有効なユーザーにのみ送信する、名前は相手先に既に伝わっている、という感じかな。回答例はサンプルデータとしてうpしておくので各自確認しておくように。
エラーチェックなどよしなに終わらせたら、P2Pチャットシステムの完成である。

おまけ、テスト時の風景

自作自演の宝石箱や~
cliss.jpg
サーバーのユーザーリストを出力させてみる
serss.jpg

おまとめ

前回とあわせて、二つもチャットシステムを作れば、各々クライアントがどんな仕事をしているか大体頭に入ったと思う。このクライアントとサーバーの仕事の量が、P2PとSCの概念の違いである。お互いに何か特別なものというわけではなく、どのようにネットワークを組むかという指針のようなものだという理解で構わないはずだ。従って、複数人で接続可能なライブラリならば、何だってP2Pも組めりゃSCも組めるという事になる。
ゲームを作るうえで避けて通れないとはいえ、ただ単にチャットするだけのシステムを2回も作ったのでちょっと飽きてきたかもしれない。だが次回はもう少し踏み込んで、データの送信の仕方を考えたり、その仕組みについてフィーリングで理解して行こうと思う。まだ暫く基礎レベルの学習が続くが、これが終わったらいよいよ、ネット対戦型パズルゲームを作ったろうじゃないか。




なんか良いネタ思いついたらだけどね。

サンプルのソース

一応今回はサブPCを用いてローカルエリアでテストしたので大丈夫なはずだ。
例によって改変は自由、批判は断わる。


*1 Peer To Peer
*2 うぃきぺでぃあ様によると、大抵のP2P型通信も、SC的な要素を持っているとある。