Node-RED編のWebSocket通信を中継する方法の課題点を改良します。
複数のPaPeRo iを制御できる様にする
Node-RED版ではサーバプログラムは二つのWebSocketコネクションのデータを単に中継するだけだったので、一組の(PaPeRo i+中継クライアント)とパペロアプリの中継しかできませんでした。複数の(PaPeRo i+中継クライアント)とパペロアプリの組を中継できる様にするために、以下の様な方法をとることにします。
(1) URLはNode-RED版同様、すべての中継クライアントはwss://サーバアドレス/ws/papero、すべてのパペロアプリはwss://サーバアドレス/ws/controllerに接続する
(2) 中継クライアントは接続した直後にサーバにパペロの識別子を登録する
(3) パペロアプリは接続先wss://サーバアドレス/ws/controllerへの接続時にシミュレータIDとしてパペロの識別子を指定する
(4) サーバプログラムはパペロの識別子が一致する/ws/paperoと/ws/controllerのコネクション同士を中継する
これで複数組の中継が可能となり、またパペロの識別子を知っていてそれを登録する通信プロトコルをしゃべれるものでなければ既存の通信を邪魔できなくなるので、wss://サーバアドレス/ws/paperoにWebSocketで接続されただけで通信が阻害されてしまうNode-RED版に比べてだいぶマシと言えると思います。
サーバをPython3/Tornadoで実装する
このサーバプログラムをTornadoを使ってPython3で実装します。TornadoでWebSocketサーバを作るためにはtornado.websocket.WebSocketHandlerを継承したサブクラスを作りon_message等のメソッドをオーバーライドします。
(1) サブクラスの初期化
WebSocketサーバにWebSocketクライアントから接続があるごとにサブクラスのオブジェクトが作られます。オブジェクトの初期化は__init__()
ではなく、initialize()で行います。ここでは引数で渡された種別(typ)とパペロ識別子を保存しています。種別は中継クライアント=’p’、パペロアプリ(controller)=’c’と決めます。また、パペロ識別子とコネクションの対応を、それぞれ中継クライアントごととパペロアプリごとに登録するための辞書をクラス変数として保持します(papero_dic, controller_dic)。
import json
import argparse
from logging import (getLogger, basicConfig, DEBUG, INFO, WARN, ERROR,)
import tornado.ioloop
import tornado.web
import tornado.websocket
logger = getLogger(__name__)
class WsHandler(tornado.websocket.WebSocketHandler):
def initialize(self, typ, papero_id=None):
logger.info('({}.{}) self={}'.format(typ, papero_id, self))
self.typ = typ
self.papero_id = papero_id
papero_dic = {}
controller_dic = {}
引数(typ)は、順序が入れ替わりますが以下の様にWebSocketサーバー起動時に渡すことができます。パペロ識別子は渡していないのでデフォルトのNoneとなります。なお引数–portでポート番号を指定できる様にしています。
if __name__ == '__main__':
basicConfig(format='%(asctime)-15s %(module)s %(funcName)s %(levelname)s %(message)s')
logger = getLogger() # root logger
logger.setLevel(DEBUG)
logger.info('start')
ap = argparse.ArgumentParser()
ap.add_argument('--port', type=int, help='port number', default=8888)
args = ap.parse_args()
port = args.port
app = tornado.web.Application([
(r"/ws/controller", WsHandler, dict(typ='c', )),
(r"/ws/papero", WsHandler, dict(typ='p', )),
])
app.listen(port)
tornado.ioloop.IOLoop.current().start()
(2) コネクションopen/close
WsHandlerクラスの定義に戻ります。接続時に、Node-RED版の中継クライアントとも接続できるようにパペロ識別子””に自分を対応づけてクラス変数の辞書に登録します。
close時には辞書から自分を削除します。
def open(self):
self.set_papero_id('')
logger.info('({}.{}) self={}'.format(self.typ, self.papero_id, self))
def my_dic(self):
return self.papero_dic if self.typ == 'p' else self.controller_dic
def is_papero(self):
return self.typ == 'p'
def set_papero_id(self, pid):
self.papero_id = pid
dic = self.my_dic()
logger.info('({}) papero_id=<{}>'.format(self.typ, pid))
dic[pid] = self
def on_close(self):
logger.info('({}.{}) '.format(self.typ, self.papero_id, self))
self.del_me(self)
def del_me(self, cli):
dic = self.my_dic()
pid = cli.papero_id
if pid in dic:
if dic[pid] == cli:
logger.info('({}.{}) obj={}'.format(self.typ, pid, cli))
dic.pop(pid)
(3) データ受信
受信時には中継クライアントによるパペロ識別子登録メッセージ、パペロアプリによる初期化メッセージであればパペロ識別子を登録しなおします。また、相手側のテーブルで自分と同じパペロ識別子を探し、コネクションが存在すればそこに受信したデータを送信します。但し中継クライアントから受信した”Destination”が”RelayServer”となっているメッセージはブロックします。
def on_message(self, msg):
pid = self.papero_id
try:
mdic = json.loads(msg)
except Exception as e:
return
logger.debug('({}.{}) mdic["Name"]={}'.format(self.typ, self.papero_id, mdic['Name']))
if self.is_papero():
dst_dic = self.controller_dic
# papero側(中継クライアント)は最初にパペロ識別子メッセージ(中継しない)を送ってくる
# {"Destination": "RelayServer", "Name":"RegPaperoID", "PaperoID":"xxxx"}
if mdic.get('Destination') == 'RelayServer':
dst_dic = None # 転送しない
if mdic.get('Name') == 'RegPaperoID':
newid = mdic.get('PaperoID')
if newid is not None:
if pid is not None:
logger.info('delete my old papero_id(p): <{}>'.format(pid))
self.papero_dic.pop(pid)
pid = newid
self.set_papero_id(pid)
else:
dst_dic = self.papero_dic
# controller側は通常のSelectSimRobotメッセージ(中継する)のSimulatorIDをIDとして使用する
# {"Name":"SelectSimRobot","RobotName":"","SimulatorID":"xxxx"}
if mdic.get('Name') == 'SelectSimRobot':
newid = mdic.get('SimulatorID')
if newid is not None:
if pid is not None:
logger.info('delete my old papero_id(c): <{}>'.format(pid))
self.controller_dic.pop(pid)
pid = newid
self.set_papero_id(pid)
if dst_dic is None:
return
if pid not in dst_dic:
return
self.send_msg(dst_dic[pid], msg, mdic['Name'])
def send_msg(self, dst, msg, name):
logger.debug('({}.{}) msg["Name"]={}'.format(self.typ, self.papero_id, name))
dst.write_message(msg)
サーバプログラムはこの1ファイルだけです。(wsrelaysvr.pyとします)
サーバをHerokuで動かす
Herokuも無期限無償(但しクレジットカード登録をしないと月550時間まで)だったので今回はこちらで試してみました。Herokuではgitでアプリを管理すると言うことなのでUbuntu16.04を使いました。
python3.6が必要とのことなので、pyenvをインストール(方法は他をご参照ください)した上でpyenvからインストールします。また、Python用のチュートリアルに従いpipenvをインストールします。
$ pyenv install 3.6.4
$ pyenv local 3.6.4
$ pip3 install pipenv
同じくチュートリアルに従いHeroku CLIをインストールします。herokuコマンドが使えるようになりますので、アカウントを作った上でログインします。
$ heroku login
ファイルをそろえる
必要なファイルがいくつかあるのでチュートリアルを参考に以下の様にしてみました。理解が不完全なのでちょっとした間違いがあるかも知れませんが問題無く動作しましたので良しとしています。新しくディレクトリを作ってこれらのファイルを作成してください。
(1) Pipfile
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
[packages]
tornado = "*"
[requires]
python_version = "3.6"
(2) Procfile
web: python wsrelaysvr.py --port=$PORT
$PORTで渡される指定されたポート番号を使わねばならない仕組みで値は毎回変わります。アクセス時にはこのポートではなくポート指定なしとします。
(3) app.json
{
"name": "WebSocket Relay Server for PaPeRo i"
}
(4) requirements.txt
tornado==4.5.3
(5) runtime.txt
python-3.6.4
(6) wsrelaysvr.py
上記
Heroku実行手順
(1) gitの初期化とコミット
ファイル一式を置いたディレクトリで実行します。
$ git init
$ git add *
$ git commit -m "first commit"
(2) Herokuアプリの生成
$ heroku create
ここで生成されたアプリ名、URLが表示されます。アプリ名を指定することもできます。
(3) デプロイ
$ git push heroku master
(4) ログの確認
$ heroku logs -t
(5) 状態の確認
$ heroku ps
(6) サーバアプリの停止
$ heroku ps:scale web=0
(7) サーバアプリの起動
$ heroku ps:scale web=1
このサーバに対応したパペロ識別子を送る事ができる中継クライアントは、次回ご紹介する予定です。