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!