Tutorial:Networking with UDP (日本語)

これは Luasocket を使用したネットワーキング入門です。おっと、逃げないでください! Luasocket は LÖVE へ組み込まれており、いったん慣れてしまえば実際はあまり悪くないものです。

このチュートリアルでは読者がコールバック、および Lua 全般を熟知していると仮定します。ネットワークは適度に高度な話題であると見なすべきです。

基本的な二種類のソケットがあり、このチュートリアルでは UDP を網羅しています。 UDP ネットワーキングはメッセージ指向 (その反対はストリーム指向の TCP) であり、それはデータグラムと呼ばれている個別 (かつ、そうでなければ独立した) のメッセージ指向を意味します。 どの様にネットワークが動作するかに関して中身のある理解として割に合うことですので、いまから解説を始めます。  :3 さて LÖVE のクライアントから開始して、次に Lua により記述されたスタンドアロン型のサーバーが後に続きます。

クライアント

最初に、 "socket" ライブラリを require する必要があります。 socket は低級ネットワーキング機能を提供します。

local socket = require "socket"
 
-- サーバーのアドレスおよびポート
local address, port = "localhost", 12345
local entity -- entity は制御用です
local updaterate = 0.1 -- 更新の要求前の、秒単位による待機時間の長さ
local world = {} -- 世界の状態は空です
local t

love.load

最初に、 UDP ソケットが必要であり、その後に全てのネットワーキングを行います。

function love.load()
	udp = socket.udp()

通常はデータを保有するまで読み取りは阻止されます (ゲームの停止および待機の原因となるため)。それは目的に適合していませんので、 timeout (タイムアウト) へ 0 を設定して読み取り阻止を解除します。

	udp:settimeout(0)

サーバーとは異なり、一台の機器のみと対話を行うため、 udp:setpeername を使用してサーバーのアドレスおよびポートへ当ソケットを"接続"します。

O.png UDP において実際にはコネクションレスであり、これは純粋に socket ライブラリにより提供される便宜です。それは実際には'回線上のビットを変更しない'ので、事実上はいつでも変更/削除ができます。  

	udp:setpeername(address, port)

疑似乱数生成器 (PRNG) の種を使うため、従って同値を毎回取得することはありません。このチュートリアルの目的のために entity は制御を行うためのものです。それは単なる数値ですが、有用です。小さな努力に対する合理的な固有識別子を付与するために math.random を使用します。

O.png これを行うために識別子に乱数を用いるのは実際のところ非常に悪い方法ですが、"正しい"方法は本記事の範囲外です。  

	math.randomseed(os.time()) 
	entity = tostring(math.random(99999))

ここで、ちょっとした初めての実際のネットワーキングを行います: 送信を行いたい (string.format を使用して) データを内包している文字列を設定してから udp.send を使用して送信を行います。以前に udp:setpeername を使用したので、それをどこへ送信すべきかどうかを再度明示する必要はありません。

本当に…、それだけです。その他のものは、このコンテキストおよび実用的に使用するために記入します。

	local dg = string.format("%s %s %d %d", entity, 'at', 320, 240)
	udp:send(dg) -- 論点の魔法行。
 
	-- t は love.update での更新比率を支援するために使用する単なる変数です。
	t = 0 -- t へ 0 を(再)設定
end

love.update

以前に宣言した t を含む意味のないことから開始します。送信 (または要求) するパケットに注意を払わなければ、ネットワーク接続を完全に満杯にするのは非常に容易であるため、従ってどのくらい頻繁に更新を送信 (および要求)するかどうかの制限をすることにより対策を見込みます。

(記録に関しては、ほとんどの普通のゲームにおいて一秒当たり十回が良いと考えられ (多くの MMO も該当します)、高速に歩測されたゲームであっても、実際には常時三十回以上の更新を必要としないようにしてください)

全ての小さな転送に対して更新を送信することはできますが、ここで単一パケットへ統合することで最終の更新は価値のあるものとなり、使用する帯域幅は徹底的に減少します。

function love.update(deltatime)
	t = t + deltatime -- デルタ時間により t を増加します
 
	if t > updaterate then
		local x, y = 0, 0
		if love.keyboard.isDown('up') then 	y=y-(20*t) end
		if love.keyboard.isDown('down') then 	y=y+(20*t) end
		if love.keyboard.isDown('left') then 	x=x-(20*t) end
		if love.keyboard.isDown('right') then 	x=x+(20*t) end

再び、 string.format を使用してパケットのペイロードを準備してから、 udp:send による方法で送信します。これは上記にて言及された転送の更新です。

		local dg = string.format("%s %s %f %f", entity, 'move', x, y)
		udp:send(dg)

さらにもう一度! これはサーバーが世界の状態に対して更新を送信するための要求です。

O.png ほとんどの設計において世界の状態に対しての更新を要求しないため、それらを周期的に送信を受けて取得します。

その他のところで、これには様々な理由がありますが、厳粛に注意を必要しなければならない重大な事項が一つあります: "破壊対策" です。世界の更新は最も大きな問題の一つで一般的な基本原理として恐らく平均的なゲームサーバーが定期的に送り出すため、偽造された更新の要求による破壊は単純で効果的です。 従って、その様な更新の要求には対応を行わず、その代わりに適切だと感じる場合には、それらを支給することです。

 

		local dg = string.format("%s %s $", entity, 'update')
		udp:send(dg)
 
		t=t-updaterate -- 次の周回のために t を設定します。
	end


可能な限り一つ以上のメッセージを待機するため、従って実行終了までループします!

また、ここでは新しいものとして、もう一方では udp:send だけが予期されています! udp:receive は待機しているパケットを返します (または nil, およびエラーメッセージ)。 data は文字列であり、最終端の udp:send におけるペイロードです。Lua にて他の任意の文字列を取り扱うのと同様の方法で取り扱うことができます (無論、 Lua の文字列処理関数に精通していることは不可欠です)。

	repeat
		data, msg = udp:receive()
 
		if data then -- 覚えていますね? Lua では全ての値を true として評価しますが、残りの nil および false は?

ここでは string.match は仲間であり、 string.* の一部、および data (またそうであるに違いありません!) は文字列です。素晴らしい文字集合を明かすためにその一部を説明します (まだ私は熟知しておりませんので 5.4.1:パターン へのリンクを置いておきます)。

			ent, cmd, parms = data:match("^(%S*) (%S*) (.*)")
			if cmd == 'at' then
				local x, y = parms:match("^(%-?[%d.e]*) (%-?[%d.e]*)$")

もう一方にあるものなのか、または誰のものなのかを知らないため、受信した値が予期するものであると確認することは重要です。これには用例がありますので、アサーションを使用します。

さら忘れないで欲しいことは、"数値"と一致した場合でもまだ結果は文字列のままであることです。Lua の tonumber() を使用するお陰で変換は容易になります。

				assert(x and y)
				x, y = tonumber(x), tonumber(y)
				world[ent] = {x=x, y=y}

この場合は頻繁にトリガーを起動すべきではありませんが、予期しないメッセージおよびイベントを常に検査する(およびログも!)ことは良い考えです。コードに存在するバグまたはサーバに対して不正行為を試みようとする人々を発見するため支援ができます…。 決して忘れないで欲しいことは、クライアントは信用できないものであるということです。

			else
				print("unrecognised command:", cmd)
			end

data において nil の場合は、問題に関する短い説明を msg へ内包します (エラー ID に関しても兼用されます…)。最も一般的なものは 'timeout' であり、 socket:settimeout() は 0 であるため、データの待機をしていない時は常に、 'timeout' になります。しかし異なるエラー、およびその結果としての振る舞いを目視で確認すべきです。この場合は自ら保存を行おうとしないため、エラーが出ます。

		elseif msg ~= 'timeout' then 
			error("Network error: "..tostring(msg))
		end
	until not data
end

love.draw

この用例について対応するのは本当ではありませんが、描画は驚くほど簡単です。 world テーブルでのループが完了すると、全ての名前(キー)が表示され、それらの自身も統合的に格納されます。

function love.draw()
	-- 非常に単純 
	for k, v in pairs(world) do
		love.graphics.print(k, v.x, v.y)
	end
end

これでクライアントのコードは終了です。

サーバー

まず最初に、サーバーは少し異なりスタンドアロンの Lua 用プログラムです: LÖVE では実行できません。

再度、ソケットの require および UDP ソケットの作成から開始します。

(LuaSocket は標準で Lua へ組み込まれていません。Windows を使用している場合は Lua for Windows インストーラを入手するだけですが、 Mac および Linux は知りませんか? あなたのお仲間でしたら何か知っています :3)

local socket = require "socket"
local udp = socket.udp()

さらに再度、 'timeout' へ 0 を設定します。

今度は少し異なることをします。クライアントとは異なり、サーバーは、その'範囲'の場所、または粗悪なクライアントに関して特定する必要があります。従って、望むもの全てに対して幸いにも自動的に割り当ることができますが、その地のものを割り当てるようにサーバーに対して命じる必要があります。

最初の部分は、どのインタフェースへ割り当てるべきかであり、 '*' の基本的な意味は"それら全て"です。ポートは単純であり、それはシステムにより維持される 65535 (!) までの"ポート"の一覧です…実際は単なる数値です。

要点は特定のポートへ送信する場合、その後に"リスニング"中のポートのみ受信可能であり、同様にリスニング中のポートへ送信されたデータのみ読み取ることができます。

概して言えば、どの機器と対話を希望するかがアドレスであり、一方では対話を希望する機器側でプログラムを行うものがポートです。

O.png 一部のオペレーティングシステムにおいて、 0 から 1024 までのポートは "特権があるプロセス用に予約されています"。それらのシステムに対しては安全対策をしてください。概して言えば、多くの問題を回避するために対象範囲のポートを使用しないでください。  

udp:settimeout(0)
udp:setsockname('*', 12345)

ローカル変数群を全て宣言して後述のメインサーバーのループで使用します。恐らくクライアントの用例から一部を認識しますが、どうして朗々とした msg_or_ip? port_or_nil? の名前であるのか恐らく不思議に思うでしょう。

さて、今回は僅かに異なる関数を使用しており、そのときにどうなるか理解するでしょう。


local world = {} -- 世界の状態は空です
local data, msg_or_ip, port_or_nil
local entity, cmd, parms

無限ループは LOVE だけを知っている場合は恐らく使用されたことがないでしょうが、それらは非常に一般的です。そして実は LOVE に心臓はありますが、一切見ることはできません。不注意ですが、サーバー用のあるものを必要とします。さらにこの小さな変数は対象の停止をします。 :3

local running = true
 
print "Beginning server loop."
while running do

この次の行はお馴染みの様に見えますし、確かにそうですが、今回は udp:receivefrom() を使用しています。受信は類似していますが、返されるデータは、送信側の IP アドレスであり、さらに送信側のポートです (対象へメッセージを送信するために二種類の必要とされる事項を認識することが望ましい)。このクライアントの用例ではサーバーに対してソケットを割り当てる必要はありませんが、割り当て済み以外のソースから来た転送元のメッセージを無視するため、サーバーとしては全く役に立ちません。

(厳密には、クライアントにて udp:receivefrom() (およびその相手方である udp:sendto() ) を使用することができます。確かに、それを妨げる関数に関しては特別なものは何もありません。 send/receive は便宜的な関数であり、実際は sendto/receivefom が処理を担当します)

	data, msg_or_ip, port_or_nil = udp:receivefrom()
	if data then
		-- これら多くの素晴らしいパターンと一致します!
		entity, cmd, parms = data:match("^(%S*) (%S*) (.*)")

The server implements a few more commands than the client does, the 'move' command updates the position of an entity relative to its current position, 'at' simply sets an entity's location (which we saw in the client), then there's update, which loops through the server's world-state and sends 'at' commands back to the client. and finally there's 'quit', which kills the server.

		if cmd == 'move' then
			local x, y = parms:match("^(%-?[%d.e]*) (%-?[%d.e]*)$")
			assert(x and y) -- 検証は良いことですが、アサーションは役立つものです。
			-- 忘れないで欲しいことは、"数値"と一致した場合でもまだ結果は文字列のままであることです!
			-- Lua の tonumber() を使用するお陰で変換は容易になります。
			x, y = tonumber(x), tonumber(y)
			-- それは最終的にずっと隠匿されます。
			local ent = world[entity] or {x=0, y=0}
			world[entity] = {x=ent.x+x, y=ent.y+y}
		elseif cmd == 'at' then
			local x, y = parms:match("^(%-?[%d.e]*) (%-?[%d.e]*)$")
			assert(x and y) -- 検証は良いことですが、アサーションは役立つものです。
			x, y = tonumber(x), tonumber(y)
			world[entity] = {x=x, y=y}
		elseif cmd == 'update' then
			for k, v in pairs(world) do
				udp:sendto(string.format("%s %s %d %d", k, 'at', v.x, v.y), msg_or_ip,  port_or_nil)
			end
		elseif cmd == 'quit' then
			running = false;

理解するものとして、サーバーの CPU 負荷減少を支援する socket.sleep の呼び出し以外はもはやありません。 一般に、人々は CPU の使用を低くしておくためには活動阻 (停止) に頼るものと思われますが、これは阻止を行わなわいための知識として素晴らしいことです。

		else
			print("unrecognised command:", cmd)
		end
	elseif msg_or_ip ~= 'timeout' then
		error("Unknown network error: "..tostring(msg))
	end
 
	socket.sleep(0.01)
end
 
print "Thank you."

結論

UDP の使用方法は単純ですが、さらに非常に多くの権限を得るため開発者依存であり、UDP にはデータグラムが順番に到着、または全て等しく到着する保証はないので、これらのことからプロトコルの設計時には、確実な計算を取り入れる必要があります。

UDP データグラムには大きさの制限があります。 luasocket のドキュメンテーションでは 8 キロバイトを超えるものは非対応であると明確に注釈がありますが、率直に言うと非常に少ないと想定すべきです。

関連



そのほかの言語