Já é hora de escrever sobre um projeto mais completo. Aproveito para mostrar o módulo websockets para Python 3.4 que funciona muito bem com asyncio. Para não ter problemas de interface, eu resolvi escrever o cliente do chat em JavaScript. O cliente de exemplo foi baixado daqui. Como quase sempre, os exemplos são muito simples e nos deixam com água na boca sobre o que poderíamos realmente fazer. Quem já tentou escrever um chat em JavaScript sabe que WebSockets são uma mão na roda.

Este artigo faz parte de uma série que escrevo sobre o asyncio do Python 3.4. Você pode ler os outros artigos clicando aqui: Python e asyncio, Asyncio e corotinas e o Lendo o teclado.

A ideia de usarmos WebSockets visa demonstrar a facilidade do módulo websockets que deve ser instalado no Python 3.4:

pip install websockets

Uma vez instalado o módulo, podemos criar um servidor, como o mostrado na documentação do módulo:

    import asyncio
    import websockets

    @asyncio.coroutine
    def hello(websocket, path):
        name = yield from websocket.recv()
        print("< {}".format(name))
        greeting = "Hello {}!".format(name)
        yield from websocket.send(greeting)
        print("> {}".format(greeting))

    start_server = websockets.serve(hello, 'localhost', 8765)

    asyncio.get_event_loop().run_until_complete(start_server)
    asyncio.get_event_loop().run_forever()

Execute o servidor com:

py -3 server.py

Para testar este programa, precisaremos de uma página com nosso cliente de chat. Eu preparei uma página com todo o código html e javascript necessário. Baixe a página cliente.html aqui. Salve o arquivo e abra-o com seu browser preferido: Chrome, Firefox ou o IE (>10).

Como o servidor é muito simples, tudo que podemos fazer é enviar uma mensagem e recebê-la de volta, já que é um servidor do tipo Echo (eco). Veja também que o servidor de WebSockets é inicializado como nossos outros servidores com o módulo asyncio, mas que este não obedece uma interface com métodos definidos para conexão, recebimento de dados etc. O objeto retornado pelo módulo websockets é um objeto com o cliente já conectado. Vejamos primeiramente como criar o servidor:

    start_server = websockets.serve(hello, 'localhost', 8765)

A linha acima cria nosso servidor, chamando a função hello sempre que um novo cliente for executado. O nome localhost se refere a nosso computador e 8765 é a porta que utilizaremos para receber as conexões. Vejamos a função hello. Quando uma nova conexão for recebida, a função hello será chamado com dois parâmetros: o primeiro é o cliente já conectado e pronto para ser utilizado; e o segundo é o path ou o caminho usado (veremos isso depois em outro artigo). Na realidade, a corotina hello é responsável pelo tempo de vida e gestão da conexão do cliente. Quando a corotina hello termina, o cliente é desconectado. Veja também que usamos o yield from para enviar e receber dados. O uso do yield from permite que escrevamos nosso código como se sua execução fosse sequencial, como já discutimos nos outros artigos.

Um detalhe importante a notar é que a interface do módulo websockets já entrega os dados no formato de mensagem (como definido pelo protocolo). Diferentemente de um socket TCP/IP comum que trabalha com streams, entregando bytes. Quando o método recv retorna, uma mensagem inteira foi recebida, pouca importa quantos read foram feitos para completar esta tarefa. Esta característica vai facilitar muito a implementação do servidor de chat, uma vez que não precisaremos inventar um delimitador de mensagem, nem separar as mensagens manualmente em nosso código.

Em relação a nosso servidor de chat, o servidor de exemplo é bem limitado. A maior limitação é não permitir a comunicação entre clientes. A ideia do servidor de chat é enviar mensagens a todos os clientes conectados. Desta forma, o servidor deve ser informado sobre e registrar todas as conexões e desconexões do sistema. Vamos manter a lista dos clientes conectados com uma lista. Nossos clientes serão controlados por uma classe Cliente que veremos mais tarde. Observe a implementação parcial da classe Servidor:

    class Servidor:
        def __init__(self):
            self.conectados = []
        
        @property
        def nconectados(self):
            return len(self.conectados)
        
        @asyncio.coroutine
        def conecta(self, websocket, path):
            cliente = Cliente(self, websocket, path)
            if cliente not in self.conectados:
                self.conectados.append(cliente)
                print("Novo cliente conectado. Total: {0}".format(self.nconectados))
            yield from cliente.gerencia()

        def desconecta(self, cliente):
            if cliente in self.conectados:
                self.conectados.remove(cliente)
            print("Cliente {1} desconectado. Total: {0}".format(self.nconectados, cliente.nome))

Veja que apenas conecta é uma corotina. Na realidade, todo o trabalho é feito na classe Cliente, que disponibiliza o método gerencia como uma corotina. O importante agora é entender a manutenção da lista de conexões ativas.

Vejamos a classe Cliente (parcial):

    class Cliente:    
        def __init__(self, servidor, websocket, path):
            self.cliente = websocket
            self.servidor = servidor
            self.nome = None        
        
        @property
        def conectado(self):
            return self.cliente.open

        @asyncio.coroutine
        def gerencia(self):
            try:
                yield from self.envia("Bem vindo ao servidor de chat escrito em Python 3.4 com asyncio e WebSockets. Identifique-se com /nome SeuNome")
                while True:
                    mensagem = yield from self.recebe()
                    if mensagem:
                        print("{0} < {1}".format(self.nome, mensagem))
                        yield from self.processa_comandos(mensagem)                                            
                    else:
                        break
            except Exception:
                print("Erro")
                raise        
            finally:
                self.servidor.desconecta(self)

Como cada cliente tem seu próprio websocket e precisa se comunicar com o servidor, guardaremos estas referências como atributos. Já preparamos também a gestão de nomes, embora tenhamos inicializado o nome do Cliente com None. O método gerencia, que é uma corotina, envia uma mensagem de boas vindas ao cliente e como no exemplo anterior, utiliza yield from para realizar o envio no loop de eventos. Uma vez que a mensagem inicial é enviada, entramos em um loop infinito que espera uma mensagem do cliente. Quando a conexão é fechada ou acontece um erro, o valor de mensagem é igual a None, por isso, testamos o valor de mensagem para sair do loop infinito criado pelo while True. Da mesma forma que no primeiro servidor de exemplo, nosso cliente é desconectado quando a corotina gerencia termina. Aproveitamos o fim da corotina para informar ao servidor que este cliente está se desconectado.

Ao recebermos uma mensagem, iniciamos o processamento da mesma, utilizando o método corotina processa_comandos da classe Cliente:

        @asyncio.coroutine
        def processa_comandos(self, mensagem):        
            if mensagem.strip().startswith("/"):
                comandos=shlex.split(mensagem.strip()[1:])
                if len(comandos)==0:
                    yield from self.envia("Comando inválido")
                    return
                print(comandos)
                comando = comandos[0].lower()            
                if comando == "horas":
                    yield from self.envia("Hora atual: " + time.strftime("%H:%M:%S"))
                elif comando == "data":
                    yield from self.envia("Data atual: " + time.strftime("%d/%m/%y"))
                elif comando == "clientes":
                    yield from self.envia("{0} clientes conectados".format(self.servidor.nconectados))
                elif comando == "nome":
                    yield from self.altera_nome(comandos)
                elif comando == "apenas":
                    yield from self.apenas_para(comandos)
                else:
                    yield from self.envia("Comando desconhecido")
            else:
                if self.nome:
                    yield from self.servidor.envia_a_todos(self, mensagem)
                else:
                    yield from self.envia("Identifique-se para enviar mensagens. Use o comando /nome SeuNome")

Lembrando os bons velhos tempos do IRC, o método processa_comandos reconhece comandos iniciados pela barra /. Desta forma, caso um cliente envie para o servidor /horas, este retornará a hora atual do servidor. Implementamos também os comandos:

  • /data que envia a data atual;
  • /clientes que envia quantos clientes estão conectados ao servidor, 
  • /nome e /apenas que veremos mais adiante. 

Utilizamos o módulo shlex para simplificar o processamento dos comandos, uma vez que a função shlex.split permite processar uma linha de texto como uma linha de comandos do bash, reconhecendo valores entre aspas e retirando os espaços em branco entre os parâmetros. Caso o usuário envie uma mensagem que não se inicia por uma barra, esta mensagem será enviada a todos os outros usuários conectados.

Para melhorar nosso chat, utilizamos o comando /nome para configurar nosso nome. O servidor cuida para que apenas um usuário utilize cada nome, retornando uma mensagem de erro, caso o nome desejado já esteja em uso. Este comando é processado pelo método altera_nome da classe Cliente:

        @asyncio.coroutine
        def altera_nome(self, comandos):                
            if len(comandos)>1 and self.servidor.verifica_nome(comandos[1]):
                self.nome = comandos[1]
                yield from self.envia("Nome alterado com sucesso para {0}".format(self.nome))
            else:
                yield from self.envia("Nome em uso ou inválido. Escolha um outro.")

O método altera_nome simplesmente verifica se passamos um parâmetro depois do comando /nome, pois comandos é uma lista onde cada elemento é um parâmetro (mas o primeiro é o nome do comando em si). Usando o método verifica_nome do servidor, checamos se o nome é único e enviamos uma mensagem de confirmação ou de erro dependendo do resultado. O método verifica_nome da classe Servidor é apresentado abaixo:

        def verifica_nome(self, nome):
            for cliente in self.conectados:
                if cliente.nome and cliente.nome == nome:
                    return False
            return True

A verificação percorre toda a lista com os clientes conectados e verifica se um nome igual já foi registrado. Caso não encontre o nome na lista dos clientes já conectados, retorna True, permitindo o registro do nome pelo cliente que o solicitou.

Um outro comando interessante é o  /apenas que permite enviarmos uma mensagem apenas para determinado cliente. Vejamos a implementação do método apenas_para no cliente:

        @asyncio.coroutine
        def apenas_para(self, comandos):
            if len(comandos)<3:
                yield from self.envia("Comando incorreto. /apenas Destinatário mensagem")
                return
            destinatario = comandos[1]
            mensagem = " ".join(comandos[2:])
            enviado = yield from self.servidor.envia_a_destinatario(self, mensagem, destinatario)
            if not enviado:
                yield from self.envia("Destinatário {0} não encontrado. Mensagem não enviada.".format(destinatario))

E do método que realiza o envio na classe Servidor:

        @asyncio.coroutine
        def envia_a_destinatario(self, origem, mensagem, destinatario):        
            for cliente in self.conectados:            
                if cliente.nome == destinatario and origem != cliente and cliente.conectado:
                    print("Enviando de <{0}> para <{1}>: {2}".format(origem.nome, cliente.nome, mensagem))
                    yield from cliente.envia("PRIVADO de {0} >> {1}".format(origem.nome, mensagem))
                    return True
            return False

Um outro método importante é o que envia uma mensagem a todos os clientes conectados:

        @asyncio.coroutine
        def envia_a_todos(self, origem, mensagem):
            print("Enviando a todos")
            for cliente in self.conectados:            
                if origem != cliente and cliente.conectado:
                    print("Enviando de <{0}> para <{1}>: {2}".format(origem.nome, cliente.nome, mensagem))
                    yield from cliente.envia("{0} >> {1}".format(origem.nome, mensagem))

Veja que enviamos a mensagem a todos da lista, mas que tomamos o cuidado para não enviar a mensagem ao mesmo cliente que a enviou, pois esta seria impressa uma segunda vez e nosso cliente Javascript já fez este trabalho por nós. A listagem completa abaixo:

    import asyncio
    import websockets
    import time
    import shlex

    class Servidor:
        def __init__(self):
            self.conectados = []
        
        @property
        def nconectados(self):
            return len(self.conectados)
        
        @asyncio.coroutine
        def conecta(self, websocket, path):
            cliente = Cliente(self, websocket, path)
            if cliente not in self.conectados:
                self.conectados.append(cliente)
                print("Novo cliente conectado. Total: {0}".format(self.nconectados))            
            yield from cliente.gerencia()

        def desconecta(self, cliente):
            if cliente in self.conectados:
                self.conectados.remove(cliente)
            print("Cliente {1} desconectado. Total: {0}".format(self.nconectados, cliente.nome))            

        @asyncio.coroutine
        def envia_a_todos(self, origem, mensagem):
            print("Enviando a todos")
            for cliente in self.conectados:            
                if origem != cliente and cliente.conectado:
                    print("Enviando de <{0}> para <{1}>: {2}".format(origem.nome, cliente.nome, mensagem))
                    yield from cliente.envia("{0} >> {1}".format(origem.nome, mensagem))

        @asyncio.coroutine
        def envia_a_destinatario(self, origem, mensagem, destinatario):        
            for cliente in self.conectados:            
                if cliente.nome == destinatario and origem != cliente and cliente.conectado:
                    print("Enviando de <{0}> para <{1}>: {2}".format(origem.nome, cliente.nome, mensagem))
                    yield from cliente.envia("PRIVADO de {0} >> {1}".format(origem.nome, mensagem))
                    return True
            return False

        def verifica_nome(self, nome):
            for cliente in self.conectados:
                if cliente.nome and cliente.nome == nome:
                    return False
            return True


    class Cliente:    
        def __init__(self, servidor, websocket, path):
            self.cliente = websocket
            self.servidor = servidor
            self.nome = None        
        
        @property
        def conectado(self):
            return self.cliente.open

        @asyncio.coroutine
        def gerencia(self):
            try:
                yield from self.envia("Bem vindo ao servidor de chat escrito em Python 3.4 com asyncio e WebSockets. Identifique-se com /nome SeuNome")
                while True:
                    mensagem = yield from self.recebe()
                    if mensagem:
                        print("{0} < {1}".format(self.nome, mensagem))
                        yield from self.processa_comandos(mensagem)                                            
                    else:
                        break
            except Exception:
                print("Erro")
                raise        
            finally:
                self.servidor.desconecta(self)

        @asyncio.coroutine
        def envia(self, mensagem):
            yield from self.cliente.send(mensagem)

        @asyncio.coroutine
        def recebe(self):
            mensagem = yield from self.cliente.recv()
            return mensagem

        @asyncio.coroutine
        def processa_comandos(self, mensagem):        
            if mensagem.strip().startswith("/"):
                comandos=shlex.split(mensagem.strip()[1:])
                if len(comandos)==0:
                    yield from self.envia("Comando inválido")
                    return
                print(comandos)
                comando = comandos[0].lower()            
                if comando == "horas":
                    yield from self.envia("Hora atual: " + time.strftime("%H:%M:%S"))
                elif comando == "data":
                    yield from self.envia("Data atual: " + time.strftime("%d/%m/%y"))
                elif comando == "clientes":
                    yield from self.envia("{0} clientes conectados".format(self.servidor.nconectados))
                elif comando == "nome":
                    yield from self.altera_nome(comandos)
                elif comando == "apenas":
                    yield from self.apenas_para(comandos)
                else:
                    yield from self.envia("Comando desconhecido")
            else:
                if self.nome:
                    yield from self.servidor.envia_a_todos(self, mensagem)
                else:
                    yield from self.envia("Identifique-se para enviar mensagens. Use o comando /nome SeuNome")

        @asyncio.coroutine
        def altera_nome(self, comandos):                
            if len(comandos)>1 and self.servidor.verifica_nome(comandos[1]):
                self.nome = comandos[1]
                yield from self.envia("Nome alterado com sucesso para {0}".format(self.nome))
            else:
                yield from self.envia("Nome em uso ou inválido. Escolha um outro.")

        @asyncio.coroutine
        def apenas_para(self, comandos):
            if len(comandos)<3:
                yield from self.envia("Comando incorreto. /apenas Destinatário mensagem")
                return
            destinatario = comandos[1]
            mensagem = " ".join(comandos[2:])
            enviado = yield from self.servidor.envia_a_destinatario(self, mensagem, destinatario)
            if not enviado:
                yield from self.envia("Destinatário {0} não encontrado. Mensagem não enviada.".format(destinatario))



    servidor=Servidor()
    loop=asyncio.get_event_loop()

    start_server = websockets.serve(servidor.conecta, 'localhost', 8765)

    try:
        loop.run_until_complete(start_server)
        loop.run_forever()
    finally:
        start_server.close()

Você pode baixar o arquivo completo clicando aqui.

Para testar, abra o arquivo cliente.html que você já deve ter salvo em seu computador. Se você já ativou o servidor com

py -3 servidor2.py

nosso cliente já deve estar conectado. Caso contrário, recarregue a página no navegador para forçar a reconexão. Quando conectado, o cliente exibe uma barra azul no topo da página. Para simular vários clientes, abra várias vezes o arquivo cliente.html. Vejamos uma sessão simples com 3 clientes:

No primeiro cliente:

/nome Cliente1
/horas

No segundo cliente:

/nome Cliente2
/data

No terceiro cliente:

/nome Cliente3
/apenas Cliente1 Olá 1
/apenas Cliente2 Olá 2
Olá todos!

Não esqueça de copiar linha por linha nas respectivas janelas. Digite ENTER para enviar a mensagem, uma de cada vez. Divirta-se!