Il buon Roberto (a patto di lasciarlo in pace) se ne e` uscito alla grande con NetRobots, che e` un server in Python che simula una arena di combattimento, fra robot che dialogano attraverso messaggi in formato JSON, inviati su protocollo HTTP.
JSON e HTTP sono stati scelti per permettere a robot scritti in diversi linguaggi (un must per la natura eterogena del gruppo), di poter dialogare con il server. La sequenza di eventi e`:
- un robot chiede al server di eseguire un comando, tipo “fai lo scan nella direzione 35, con ampiezza 90”, inviando il comando in JSON -> HTTP -> Socket
- il server HTTP riceve il comando, lo esegue nell’arena di gioco virtuale aggiornando lo stato, e invia una risposta al robot tipo “c’e` un robot nemico alla distanza 20”, sempre in formato JSON -> HTTP -> Socket
Il server HTTP e` stato scritto in Flask, che e` un server HTTP minimale (nel senso buono del termine), che da` ampio controllo al programmatore. Il codice risultante e` compatto e leggibile, in perfetto stile Python. Per esempio questo e` il codice che riceve una richiesta JSON di scanner da un robot e risponde:
mod_robot.route('/<token>/scan', methods=['PUT'])
@check_token
def scan(robot):
degree = int(float(request.form['degree']))
resolution = int(float(request.form['resolution']))
assert isinstance(robot, Robot)
time.sleep(app.app.game_board_th.get_sleep_time())
ret = robot.scan(degree, resolution)
if ret is not None:
resp = Response(response=json.dumps({'status': 'OK', 'distance': ret}),
status=200,
mimetype="application/json")
return resp
resp = Response(response=json.dumps({'status': 'KO', 'distance': None}),
status=406,
mimetype="application/json")
return resp
Il problema di Flask, e che e` pensato (giustamente) per servire richieste HTTP in ordine FIFO, ma i robot dall’altra parte essendo in battaglia e in perenne pericolo di vita o di morte, non sono molto dell’idea di inviare educate richieste aspettando il loro turno, e sono in continuo bombardamento di messaggi HTTP.
Ogni thread Flask riceve la sua richiesta HTTP e se fosse fair, risponderebbe ai Robot dividendo i turni di gioco in intervalli regolari, per dare modo ad ogni robot, anche a quelli scritti in VisualBasic, di elaborare la contromossa. Inoltre le latenze della rete non sono note a priori e senza arrivare agli estremi del gioco per corrispondenza, sarebbe fair dare modo anche ai robot installati in zone colpite dal digital-divide, di poter dire la loro. Ci sono stati casi di Robot che tempo di inviare un messaggio di benvenuto al server, e una cartolina a casa dicendo che stavano bene ed erano pronti alla sfida, gia` erano stati scannerizzati e bombardati un migliaio di volte e il datagram TCP di risposta aveva trovato solo un profondo pozzo nero.
Ma in Flask non e` facile mettere in sospensione le risposte, o ordinarle per turno. Anche rispondendo con un messaggio tipo “non e` il tuo turno”, si rischia comunque di avere un poll continuo di richieste dal robot.
Ovviamente con un po’ di (in)sano hacking e` sicuramente possibile trovare una soluzione piu` o meno elegante per Flask, ma noi siamo il Team PUG MoRe e ci meritiamo di piu` 🙂
ZeroMQ e` una libreria multilinguaggio (esistono versioni per quasi qualunque linguaggio di programmazione) che permette di scrivere facilmente un server che accetta comandi su connessioni in formato ZeroMQ, e inviare risposte ai robot scritti in altri linguaggi (un requirements del progetto NetRobots), ma che usano sempre la stessa libreria.
ZeroMQ puo` essere vista come una versione elegante (a livello di API) dei socket Linux. Le prestazioni sono di primissimo livello, tante` che e` stata scelta dal CERN per processare i dati dei suoi esperimenti scientifici, che non sono notoriamente parchi di banda o fiacchi nelle latenze, a meno che non si possano considerare le particelle sub-atomiche poche, lente e prevedibili 🙂
Uno dei punti di forza di ZeroMQ e` che scala dal piccolo (due thread nello stesso processo che si scambiano messaggi), al medio (due processi sullo stesso host che si scambiano messaggi), al grande (due processi su host diversi che si scambiano messaggi). Per ogni situazione ZeroMQ usa una versione ad-hoc del protocollo cercando di minimizzare (o portare a 0) l’overhead dell’invio e ricezione del messaggio, ma con il vantaggio di avere una sola API di processing all’interno del programma. Quindi uno puo` scalare una applicazione, lasciando invariata la logica interna (la API e` sempre la stessa) ma cambiando solo la configurazione di deploy, facendo diventare ad esempio un thread un processo su una macchina esterna.
ZeroMQ puo` essere usata anche per gestire situazioni di multi-threading, dato che la logica a scambio di messaggi e` piu` intuitiva da capire rispetto a semafori o simili e allo stesso tempo ZeroMQ non e` molto piu` lenta di una chiamata di funzione, se usata all’interno dello stesso processo, dove basta passare un puntatore al messaggio da elaborare.
ZeroMQ si pone ad un livello piu` basso rispetto a Flask, dato che e` a livello di libreria di comunicazione (tipo Socket) e non di application-server. Pero`:
- questo da` piu` liberta` di controllo, su come e quando ricevere, mettere in hold e inviare le risposte
- la API molto elegante, non fa sentire troppo la mancanza di un prodotto piu` high-level come Flask
La filosofia di ZeroMQ per scrivere una applicazione e` questa:
- io (ZeroMQ) ti do`:
- una serie di protocolli di comunicazione
- una serie di metodi di comunicazione fra socket (direct, publish-subscribe, broadcasting, etc…)
- la certezza che qualunque combinazione (lecita) di protocollo e metodo di comunicazione, te la supporto in modo che piu` efficiente e robusto non si puo`
- tu (programmatore) puoi:
- combinare i protocolli e i metodi di comunicazione nel modo che preferisci (una esplosione combinatoria), per creare la piattaforma di messagging ad-hoc che ti serve
- vedere l’applicazione come una serie di socket tra loro collegati, su cui leggono e scrivono processi consumer e producer
- scalare l’applicazione facilmente
Al contrario, un prodotto di messagging completo e omnicomprensivo come RabbitMQ, tende ad essere da una parte completo (logging, persistenza dei messaggi, etc..), ma dall’altra ristretto alle funzionalita` che ha lui per gestire i messaggi. La sua filosofia e` questa:
- queste sono le opzioni tipiche che ti offro per scambiare i messaggi
- quasi di sicuro, sei in una di queste situazioni tipiche e quindi hai tutto gia` pronto
- se non sei in una di queste situazioni, sono cavoli tuoi (che e` l’effetto Flask)
Evidentemente il CERN non era in una delle loro situazioni tipiche 🙂
I robot si connettono tramite ZeroMQ al server
self.get_game_server_socket().send(c.SerializeToString())
status = RobotStatus()
status.ParseFromString(self.get_game_server_socket().recv())
e inviano una sequenza di:
- send a command
- recv the answer
Riceveranno la risposta solo quando e` il loro turno. Quindi il “recv” li mette in attesa della risposta (tipica pattern ZeroMQ dove il receive fa anche da synchro). Tutto il resto del codice e` per serializzare e deserializzare un messaggio usando Google ProtoBuf che e` una libreria multi linguaggio per la serializzazione/deserializzazione binaria efficiente dei messaggi. Quindi ProtoBuf prende il posto di JSON e ZeroMQ di HTTP.
Il server NetRobots e` decisamente piu` interessante:
- apre un socket ZeroMQ esterno, da cui riceve le richieste dei robot
- si mette in sleep una qualche frazione di secondo, in attesa dell’inizio del nuovo turno di gioco
- nel mentre le richieste dei robot si accodano. ZeroMQ e` una libreria poco schizzinosa e ci pensa lei ad accodare lato server o nei casi piu` critici mettendo in hold i client
- quando e` il momento del nuovo turno di simulazione, inizia a gestire le richieste dei clients
- ZeroMQ e` multi threading e non e` un problema estrarre messaggi dalla coda (consumer), mentre altri thread spingono messaggi nella stessa coda (producer)
Il codice non e` di immediata comprensione dato che implementa una politica fair e tenta di evitare che uno stesso robot invii due richieste, senza aspettare la risposta, cercando di giocare quindi due o piu` volte nello stesso turno di gioco.
def process_robots_requests(self, client_socket):
self._board.tick(self._deltatime)
self.processed_robots.clear()
# First process queued robots.
queue = self.queued_robot_messages
self.queued_robot_messages = {}
# DEV-NOTE: these instructions are very important:
# * the code must work assuming there are no any more queued robots
# * a copy of the reference is maintaned for processing them
for token, v in queue.iteritems():
try:
self.process_robot_command(v['command'], v['sender_address'], client_socket)
self.processed_robots[token] = True
except:
self.debug_message("Unexpected error " + str(sys.exc_info()[0]))
client_socket.send_multipart([v['sender_address'], b'', b''])
# in ZMQ is mandatory sending an answer
# Update the robots according the new requests from clients.
again = True
while again:
sender_address = None
try:
sender_address, empty, binary_command = client_socket.recv_multipart(zmq.NOBLOCK)
command = MainCommand()
command.ParseFromString(binary_command)
self.process_robot_command(command, sender_address, client_socket)
except zmq.error.Again:
# there are no more messages to process in the queue
again = False
except:
# there is an error processing the command for this client.
self.debug_message("Unexpected error " + str(sys.exc_info()[0]))
if sender_address is not None:
client_socket.send_multipart([sender_address, b'', b''])
# in ZMQ is mandatory sending an answer
def process_robot_command(self, command, sender_address, client_socket):
if command.HasField('createRobot'):
self.process_create_robot_request(command.createRobot, sender_address, client_socket)
elif command.HasField('deleteRobot'):
self.process_delete_robot_request(command.deleteRobot, sender_address, client_socket)
elif command.HasField('robotCommand'):
token = command.robotCommand.token
if token in self.banned_robots:
# nothing to do, avoid to answer to these requests
pass
elif token in self.queued_robot_messages:
self.banned_robots[token] = True
robot = self._board.get_robot_by_token(token)
self.debug_message("Banned " + token + ". Board time is " + str(self._board.global_time()) + ", robot time is " + str(robot.last_command_executed_at_global_time))
else:
if token in self.processed_robots:
self.queued_robot_messages[token] = {'sender_address': sender_address, 'command': command }
robot = self._board.get_robot_by_token(token)
else:
self.processed_robots[token] = True
self.process_robot_request(command.robotCommand, sender_address, client_socket)
In questo codice la pattern di utilizzo delle ZeroMQ e` sinceramente un po’ astruso perche` e` stato utilizzato questo codice
sender_address, empty, binary_command = client_socket.recv_multipart(zmq.NOBLOCK)
che separa l’indirizzo del sender e il contenuto del messaggio ricevuto da un client Robot. Una volta processato il comando, la risposta viene inviata al client, usando
client_socket.send_multipart([sender_address, b'', status.SerializeToString()])
che in forma speculare invia il contenuto, rimettendo dentro al messaggio, l’indirizzo ricevente precedentemente estratto.
A differenza del codice Flask, in ZeroMQ i messaggi sono self-class-citzien, che possono essere manipolati esplicitamente:
- separare contenuto di un messaggio, da indirizzo del sender
- spostare un messaggio da un socket all’altro, anche di tipo diverso
- etc.. etc..
La mia impressione finale e` che se avessi usato RabbitMQ avrei fatto prima:
- non c’era bisogno di usare ProtoBuf
- le pattern di utilizzo erano gia` codificate, e nel 90% dei casi vanno gia` bene per quello che uno vuole fare.
- la curva di apprendimento e` minore
Pero` usare ZeroMQ e` stato veramente divertente:
- dopo un po’ uno realizza che le combinazioni con cui usare e comporre la libreria sono infinite come il gioco dei Lego: ci puoi fare il castello, o l’aereo o …
- e` un po’ la filosofia di Linux e delle pipes, dove uno puo` collegare ad un componente altri componenti, e cosi` via all’infinito
- uno magari ci mette piu` tempo la prima volta a capire la filosofia della libreria, ma dopo ha in mano uno strumento veramente molto potente e versatile
- con RabbitMQ avrei dovuto avere probabilmente un server separato, per la gestione dei messaggi, oltre al server Python. Un po’ come quando uno deve far girare un server MySQL insieme alla sua applicazione per avere le funzionalita` di database.. Le ZeroMQ sono come Sqlite, uno usa la libreria all’interno del server Python gia` scritto, e le funzionalita` che la fanno diventare una applicazione di messaging evoluta sono gia` tutte incluse nella libreria incorporata.
Inutile dire che mi aspetto che nel mondo commerciale prenderanno sempre piu` piede prodotti come RabbitMQ, dato che hanno la filosofia “tutto incluso”, vincente come tempi di apprendimento e utilizzo, rispetto al “tutto e` possibile” di ZeroMQ. Pero` lato divertimento la filosofia “tutto e` possibile” e` impagabile… 🙂