You are currently viewing Implementazione delle librerie Twisted per protocolli client-server multithread in Python

Implementazione delle librerie Twisted per protocolli client-server multithread in Python

Introduzione

Durante la scrittura del Pesto, uno dei nostri software dedicato alla completa epurazione dei dati su dischi/SSD, abbiamo dovuto decidere quale approccio utilizzare nella gestione dei singoli processi di pulizia o scrittura di dati nei dispositivi. Dopo una breve (quasi inesistente) discussione, abbiamo scelto un modello client-server e quindi ci siamo messi subito al lavoro per implementare le varie funzioni.

Questa scelta è stata fortemente influenzata dal fatto che risultava comodo effettuare le operazioni sui dischi da remoto su un’altra macchina predisposta unicamente per eseguire tali azioni.

Dunque abbiamo suddiviso i task del programma in “task da server” (operazioni lettura/scrittura sui dischi) e “task da client” (ricezione stato/risultati delle operazioni e interfaccia grafica).

Fatto ciò ci siamo incagliati nel problema fondamentale, ovvero il protocollo di comunicazione client-server. Inizialmente sviluppammo un semplice protocollo UDP tramite impiego delle librerie socket che però non dava garanzia della ricezione completa dei comandi da parte del server. Allora siamo passati ad un protocollo TCP sempre impiegando le librerie socket ma, andando avanti con il progetto, piccoli ma gravi errori di protocollo causavano notevoli problemi mentre la complessità aumentava e subentrava silenziosa la gestione dei thread necessaria per le operazioni multiple che il programma deve effettuare.

Tutto ciò ci ha spinti a scoprire ed impiegare le librerie Twisted, risparmiandoci la pena di dover scrivere un protocollo dal nulla e permettendoci di velocizzare notevolmente la scrittura delle altre parti del programma.

Cos’è Twisted e a cosa serve

Twisted è un framework di programmazione di rete basato su eventi che fornisce numerosi strumenti utili per la semplificazione della stesura di un protocollo di rete.

Con Twisted è possibile costruire echo server, web server, client mail, client SSH, …

Ciò che a noi interessa, in questo caso, è costruire un protocollo di comunicazione client-server in grado di permettere al client di inviare specifici comandi a seguito di specifici eventi causati da azioni che l’utente compie sull’interfaccia grafica o sul terminale.

Implementazione lato server

A fine illustrativo, costruiamo un piccolo server TCP in grado di ricevere stringhe di testo, elaborandole per eseguire specifiche azioni.

from twisted.internet import protocol, reactor
from twisted.protocols.basic import LineOnlyReceiver

class Server(LineOnlyReceiver):
    def connectionMade(self):
        self.sendLine(b"Connessione con il server stabilita! Maggia!")
        print("Client connected.")

    def connectionLost(self, reason):
        print(f"Connection lost.")

    def dataReceived(self, data):
        data = data.decode('utf-8').rstrip('\n\r')
        print(f"Received data: {data}")
        if data == 'ciao':
            self.sendLine(b"Ciao! Come va?")
        if data == 'addio':
            self.sendLine(b"Noooo non te ne andareeee....")
            self.transport.loseConnection()
        if 'scrivi: ' in data:
            data = data.lstrip("scrivi: ")
            if self.factory.saveText(data):
                self.sendLine(b"Messaggio salvato!")


class ServerFactory(protocol.Factory):
    protocol = Server

    def startFactory(self):
        self.fp = open('file.txt', 'a')

    def stopFactory(self):
        self.fp.close()

    def saveText(self, data):
        self.fp.write(data + '\n')
        self.fp.flush()
        return True


def main():
    reactor.listenTCP(1234, ServerFactory())
    reactor.run()


if __name__ == "__main__":
    main()

Nel codice del server sono presenti 3 elementi chiave:

  • Protocollo → Server
  • Factory → ServerFactory
  • Reactor → reactor

Il reactor costituisce il loop di eventi di twisted e gestisce tutti gli eventi e i messaggi interni del programma.

La classe factory è un oggetto che ci permette di separare le funzioni “locali” da quelle di comunicazione che vengono lasciate esclusivamente al protocollo.

Reactor

Il reactor viene chiamato nella funzione main() con i metodi listenTCP e run.

Il “comando” listenTCP(porta, factory()) costruisce il vero e proprio server, utilizzando una specifica porta che l’utente inserisce come argomento e chiamando una factory, che si occuperà di creare il protocollo e stabilire la connessione. Nel nostro caso, viene utilizzata la porta 1234 e la factory ServerFactory.

Con run(), si avvia l’event loop di Twisted finché il server non verrà terminato o non si verifichi un errore catastrofico.

Factory

Un oggetto factory può essere visto come una vera e propria fabbrica di protocolli che esegue però anche tutte le operazioni associate ad una singola connessione.

In questo caso abbiamo utilizzato 2 metodi “prefabbricati”, ovvero startFactory e stopFactory, e un metodo scritto da noi per salvare il testo su un file, saveText.

Il metodo startFactory(self) viene chiamato ogni volta che viene richiesta una nuova connessione ed esegue tutto il codice al suo interno un’unica volta. Nel nostro caso, viene aperto un file file.txt in modalità append.

Il metodo stopFactory(self) viene invece impiegato quando cade la specifica connessione e, come per startFactory, esegue il codice al suo interno un’unica volta. Qui, viene solo chiuso il file file.txt.

Il metodo saveText(self, data), scritto da noi, viene chiamato dal protocollo quando un messaggio contiene la stringa ‘scrivi: ‘ al suo interno e scrive sul file file.txt tutto ciò che segue il comando.

Protocollo

Il protocollo è l’oggetto che gestisce tutta la comunicazione con il client connesso.

Vengono definiti 3 principali metodi:

  • connectionMade
  • connectionLost
  • dataReceived

Il metodo connectionMade viene chiamato quando una connessione viene stabilita, eseguendo il codice al suo interno. Nel nostro caso, viene inviato un messaggio al client che conferma all’utente l’avvenuta connessione.

Il metodo connectionLost, come si evince dal nome, esegue il codice che contiene quando la connessione viene terminata o persa.

Il metodo dataReceived invece rappresenta il core del server, in un certo senso. Infatti dentro a questo metodo viene stabilito come il server deve comportarsi in base ai messaggi che riceve.

Quando la connessione viene stabilita, il server invia un messaggio di conferma al client, per permettere all’utente di stabilire se la connessione è stata effettivamente stabilita.

Se il client invia il comando ‘ciao’, il server risponderà con ‘Ciao! Come va?’.

Se il client invia un comando ‘scrivi: <testo>’, dove <testo> verrà sostituito con un messaggio in particolare, il server salverà il testo inviato in un file locale e risponderà con ‘Testo salvato!’.

Se il client invia il comando ‘addio’, il server risponderà con ‘Noooo non te ne andareeee….’ e la connessione verrà chiusa.

Implementazione lato client

Ora che abbiamo creato il nostro server, è necessario comunicare con esso tramite un client. Come nel caso del server, abbiamo 3 oggetti chiave:

  • Protocollo → Client
  • Factory → ClientFactory
  • Reactor Thread → ReactorThread
from twisted.protocols.basic import LineOnlyReceiver
from twisted.internet import reactor, protocol
from threading import Thread

receiver = None


class Client(LineOnlyReceiver):
    def connectionMade(self):
        global receiver
        receiver = self

    def connectionLost(self, reason):
        print("Connection lost.")

    def lineReceived(self, line):
        line = line.decode('utf-8')
        self.factory.update(line)

    def sendMsg(self, msg: str):
        if self is None:
            print("Cannot send message to server. No connection.")
        else:
            msg = msg.encode('utf-8')
            self.sendLine(msg)

    def disconnect(self):
        self.transport.loseConnection()


class ClientFactory(protocol.ClientFactory):
    protocol = Client

    def clientConnectionFailed(self, connector, reason):
        print("Connection failed.")

    def update(self, data):
        print(data)


class ReactorThread(Thread):
    def __init__(self, host: str, port: int):
        Thread.__init__(self)
        self.host = host
        self.port = port
        self.protocol = Client
        self.factory = ClientFactory()
        self.reactor = reactor

    def run(self):
        self.reactor.connectTCP(self.host, self.port, self.factory)
        self.reactor.run(installSignalHandlers=False)

    def stop(self):
        self.reactor.callFromThread(Client.disconnect, receiver)

    def send(self, msg: str):
        self.reactor.callFromThread(Client.sendMsg, receiver, msg)

    def reconnect(self):
        self.reactor.connectTCP(self.host, self.port, self.factory)
        self.reactor.callFromThread(Client.disconnect, receiver)

def main():
    host = "127.0.0.1"
    port = 1234
    r_thread = ReactorThread(host, port)
    r_thread.start()
    while True:
        cmd = input()
        if cmd == 'reconnect':
            r_thread.reconnect()
        else:
            r_thread.send(cmd)

if __name__ == '__main__':
    main()

In questo programma viene impiegata la libreria threading per separare la comunicazione client-server dall’interfaccia utente. Questo perché se avviamo il reactor nel main thread, esso “prende possesso” del processo e l’utente dovrà attendere la conclusione del programma per inserire comandi, il che non è veramente ideale per i nostri scopi.

Abbiamo dunque 2 thread per client: un thread (il main thread) per inserire i comandi da inviare al server e un altro thread per il reactor.

Reactor Thread

Il thread per il reactor è definito in ReactorThread che è una classe Thread che viene istanziata all’avvio del programma.

Quando viene istanziato un nuovo ReactorThread è necessario specificare l’indirizzo del server al quale ci si vuole connettere e la relativa porta. All’avvio del thread viene eseguito il metodo run e il reactor effettua un tentativo di connessione al server con il metodo connectTCP nel quale devono essere specificati indirizzo IP, porta e il client factory.

Gli altri metodi utilizzati nell’esempio sono:

  • stop → Chiama il metodo disconnect del protocollo per effettuare una disconnessione
  • send → Chiama il metodo sendMsg del protocollo per inviare un messaggio proveniente dal main thread
  • reconnect → Effettua un nuovo tentativo di connessione al server (se nel main thread viene inserito il comando ‘reconnect‘)

Client Factory

Come nel caso del server, anche qui abbiamo una “fabbrica” di client che può eseguire vari task locali lasciando al protocollo il solo compito di comunicare con il server.

Nel nostro esempio sono presenti 2 metodi:

  • clientConnectionFailed → Stampa “Connection failed” se il client non riesce a connettersi.
  • update → Stampa il messaggio ricevuto dal server sul terminale.

Protocollo

Nel protocollo definiamo i metodi utili alla comunicazione con il server:

  • connectionMade → “copia” il protocollo attivo in una variabile globale receiver utilizzabile dal ReactorThread in modo da poter inviare i comandi scritti sul main thread.
  • connectionLost → Stampa “Connection lost” se la connessione viene interrotta dal server.
  • lineReceived → Viene chiamato quando arriva un messaggio dal server e lo invia al metodo update del ClientFactory in modo da stampare il messaggio ricevuto sul terminale.
  • sendMsg → Se si è connessi al server invia il messaggio proveniente dall’input del main thread, altrimenti stampa un errore specificando che non esiste una connessione.
  • disconnect → Rompe la connessione con il server.

Considerazioni finali

Anche se sarebbe stato molto interessante costruire un protocollo di comunicazione di “basso livello” con le librerie socket, per contro avremmo dovuto impiegare un sacco di tempo ed energie per questo aspetto, che nel nostro caso non è determinante in quanto il Pesto non è un progetto che richieda una così alta efficienza in termini di comunicazione tra client e server.

Perciò, Twisted si è rivelato essere un potente strumento capace di velocizzare notevolmente lo sviluppo del software.