Na época do lançamento do Python 3.4, eu estava tão contente com a integração do Asyncio que escrevi um servidor de chat aqui. O tempo passou e novas versões do Python foram lançadas. Resolvi então migrar o servidor para Python 3.6.
Uma das grandes mudanças que ocorreram no Python 3.5, foi o suporte a async e await para substituir @asyncio.corroutine e yield from respectivamente. Esta pequena mudança por si só já facilita em muito a leitura do código, que ficou mais leve. Mas uma das principais mudanças do Python 3.6 são as f-strings que facilitam a formação de mensagens.
Primeiro, vamos preparar o ambiente. É preciso instalar o Python 3.6. Se você utiliza Windows, basta baixar o pacote no site da Python.org.
Ubuntu 16.10
Se você utiliza Ubuntu 16.10, ainda precisa baixar os fontes e compilar... mas seguindo a recomendação de amigos do Telegram, resolvi experimentar com o pyenv!
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/sh | |
sudo apt update | |
sudo apt-get install -y make build-essential libssl-dev zlib1g-dev libbz2-dev \ | |
libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev libncursesw5-dev \ | |
xz-utils git curl | |
curl -L https://raw.githubusercontent.com/yyuu/pyenv-installer/master/bin/pyenv-installer | bash | |
cat >> ~/.bash_profile <<EOF | |
export PATH="/$HOME/.pyenv/bin:$PATH" | |
eval "\$(pyenv init -)" | |
eval "\$(pyenv virtualenv-init -)" | |
EOF | |
source ~/.bash_profile | |
pyenv install 3.6.0 | |
pyenv virtualenv 3.6.0 p6 | |
pyenv local p6 | |
pip install websockets |
bash install_python360.sh
Como alguns pacotes precisam ser instalados no Ubuntu, ele vai usar sudo. Esteja pronto para digitar a senha. No meu caso, como uso docker (docker run -rm -t -i ubuntu:16.10 /bin/bash), rodei o script como root. Se você instalar no seu usuário, ele vai chamar o sudo quando necessário. Eu gravei um pequeno vídeo do que aconteceu na minha instalação:
Windows
Depois de instalar o Python 3.6.0, instale o websockets com pip3 install websocketsOutros sistemas
Instale o Python 3.6.0 e o módulo websockets.O novo servidor
Mudando @asyncio.coroutine para async def, o código já fica mais claro. Em uma segunda passagem, eu substitui os yield from por await. Como estamos usando Python 3.6, não custa adaptar as strings para f-strings. E para terminar a migração, configurei o log para que o código não fique cheio de prints! Ficou assim:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import asyncio | |
import websockets | |
import time | |
import shlex | |
import ssl | |
import os | |
import logging | |
import logging.config | |
class Servidor: | |
def __init__(self): | |
self.conectados = [] | |
@property | |
def nconectados(self): | |
return len(self.conectados) | |
async def conecta(self, websocket, path): | |
cliente = Cliente(self, websocket, path) | |
if cliente not in self.conectados: | |
self.conectados.append(cliente) | |
logging.info(f"Novo cliente conectado({websocket.remote_address[0]} {websocket.remote_address[1]}). Total: {self.nconectados}") | |
await cliente.gerencia() | |
def desconecta(self, cliente): | |
if cliente in self.conectados: | |
self.conectados.remove(cliente) | |
logging.info(f"Cliente {cliente.nome} desconectado. Total: {self.nconectados}") | |
async def envia_a_todos(self, origem, mensagem): | |
for cliente in self.conectados: | |
if origem != cliente and cliente.conectado: | |
logging.debug(f"Enviando de <{origem.nome}> para <{cliente.nome}>: {mensagem}") | |
await cliente.envia(f"{origem.nome} >> {mensagem}") | |
async def envia_a_destinatario(self, origem, mensagem, destinatario): | |
for cliente in self.conectados: | |
if cliente.nome == destinatario and origem != cliente and cliente.conectado: | |
logging.debug(f"Enviando de <{origem.nome}> para <{cliente.nome}>: {mensagem}") | |
await cliente.envia(f"PRIVADO de {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 | |
async def gerencia(self): | |
try: | |
await self.envia("Bem vindo ao servidor de chat escrito em Python 3.6 com asyncio e WebSockets." | |
"Identifique-se com /nome SeuNome") | |
while True: | |
mensagem = await self.recebe() | |
if mensagem: | |
logging.info(f"{self.nome} < {mensagem}") | |
await self.processa_comandos(mensagem) | |
else: | |
break | |
except Exception as e: | |
logging.error(f"Erro: {e}", exc_info=e) | |
finally: | |
self.servidor.desconecta(self) | |
async def envia(self, mensagem): | |
await self.cliente.send(mensagem) | |
async def recebe(self): | |
mensagem = await self.cliente.recv() | |
return mensagem | |
async def processa_comandos(self, mensagem): | |
if mensagem.strip().startswith("/"): | |
comandos = shlex.split(mensagem.strip()[1:]) | |
if not len(comandos): | |
await self.envia("Comando inválido") | |
return | |
logging.debug(f"Comandos recebidos: {comandos}") | |
comando = comandos[0].lower() | |
if comando == "horas": | |
await self.envia("Hora atual: " + time.strftime("%H:%M:%S")) | |
elif comando == "data": | |
await self.envia("Data atual: " + time.strftime("%d/%m/%y")) | |
elif comando == "clientes": | |
await self.envia(f"{self.servidor.nconectados} clientes conectados") | |
elif comando == "nome": | |
await self.altera_nome(comandos) | |
elif comando == "apenas": | |
await self.apenas_para(comandos) | |
else: | |
await self.envia("Comando desconhecido") | |
else: | |
if self.nome: # Verifica se o usuário já definiu seu nome. | |
await self.servidor.envia_a_todos(self, mensagem) | |
else: | |
await self.envia("Identifique-se para enviar mensagens. Use o comando /nome SeuNome") | |
async def altera_nome(self, comandos): | |
"""Altera o nome do usuário corrente, mas verifica se este é único""" | |
if len(comandos) > 1 and self.servidor.verifica_nome(comandos[1]): | |
self.nome = comandos[1] | |
await self.envia(f"Nome alterado com sucesso para {self.nome}") | |
else: | |
await self.envia("Nome em uso ou inválido. Escolha um outro.") | |
async def apenas_para(self, comandos): | |
"""Envia a mensagem apenas para um cliente específico""" | |
if len(comandos) < 3: | |
await self.envia("Comando incorreto. /apenas Destinatário mensagem") | |
return | |
destinatario = comandos[1] | |
mensagem = " ".join(comandos[2:]) | |
enviado = await self.servidor.envia_a_destinatario(self, mensagem, destinatario) | |
if not enviado: | |
await self.envia(f"Destinatário {destinatario} não encontrado. Mensagem não enviada.") | |
def server_context(): | |
"""Cria o contexto do SSL, carregando o certificado e a chave gerados""" | |
cert = os.path.join(os.path.dirname(__file__), 'cert.pem') | |
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS) | |
ssl_context.load_cert_chain(cert, keyfile="key.pem") | |
return ssl_context | |
LOG_FORMAT = ('%(levelname) -6s %(asctime)s %(name) -20s %(funcName) -25s %(lineno) -5d: %(message)s') | |
if __name__ == "__main__": | |
logging.config.dictConfig( | |
{ | |
'version': 1, | |
'formatters': {'verbose': {'format': LOG_FORMAT}}, | |
'loggers': {'': {'level': 'DEBUG', 'handlers': ['console']}, | |
'websockets': {'level': 'INFO', 'handlers': ['console']}}, | |
'handlers': { | |
'console':{ | |
'level':'DEBUG', | |
'class':'logging.StreamHandler', | |
'formatter': 'verbose' | |
}, | |
} | |
}) | |
servidor = Servidor() | |
loop = asyncio.get_event_loop() | |
start_server = websockets.serve(servidor.conecta, '0.0.0.0', 8765, ssl=server_context()) | |
try: | |
loop.run_until_complete(start_server) | |
loop.run_forever() | |
finally: | |
start_server.close() |
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
O cliente
Hoje não tem como escapar do Javascript. Fiz poucas alterações no código, a maior delas foi simplesmente cosmética e agora o texto rola para baixo automaticamente quando novas mensagens chegam.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="pt_BR"> | |
<meta charset="utf-8" /> | |
<title>WebSocket Chat</title> | |
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css"> | |
<script src="https://code.jquery.com/jquery-2.1.1.min.js"></script> | |
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.17.1/moment.js"></script> | |
<script language="javascript" type="text/javascript"> | |
var loc = window.location, new_uri | |
new_uri = "wss://" + loc.hostname + ":8765/hello" | |
var wsUri = new_uri | |
var output; | |
function init() { | |
output = document.getElementById("output"); | |
testWebSocket(); | |
fixsize(); | |
$('#texto').focus(); | |
$( "#envia" ).click(function() { | |
doSend( $('#texto').val()); | |
$('#texto').val(''); | |
$('#texto').focus(); | |
}); | |
$('#texto').keydown(function(event){ | |
if(event.keyCode == 13) { | |
event.preventDefault(); | |
$( "#envia" ).click(); | |
return false; | |
} | |
}); | |
} | |
function now(){ | |
return moment().format("YY/MM/DD HH:mm:ss") | |
} | |
function testWebSocket() | |
{ | |
websocket = new WebSocket(wsUri); | |
websocket.onopen = function(evt) { onOpen(evt) }; | |
websocket.onclose = function(evt) { onClose(evt) }; | |
websocket.onmessage = function(evt) { onMessage(evt) }; | |
websocket.onerror = function(evt) { onError(evt) }; | |
} | |
function onOpen(evt) { | |
$('#envia').attr('disabled', false); | |
$('#constatus').text("WebSockets Chat - Conectado"); | |
$("#principal").removeClass().addClass("panel panel-primary"); | |
} | |
function onClose(evt) | |
{ | |
$('#envia').attr('disabled', true); | |
$('#constatus').text("WebSockets Chat - Desconectado"); | |
$("#principal").removeClass().addClass("panel panel-danger"); | |
} | |
function onMessage(evt) | |
{ | |
writeToScreen('<span style="color: blue;">'+ now() +' \uD83E\uDC36 '+ evt.data+'</span>'); | |
} | |
function onError(evt) | |
{ | |
writeToScreen('<span style="color: red;">ERROR:</span> ' + evt.data); | |
$("#principal").removeClass().addClass("panel panel-danger"); | |
} | |
function doSend(message) | |
{ | |
if(message) | |
{ | |
writeToScreen('<span style="color: black;">'+now()+' \uD83E\uDC34 '+ message + '</span>'); | |
websocket.send(message); | |
} | |
} | |
function writeToScreen(message) | |
{ | |
var pre = document.createElement("p"); | |
pre.style.wordWrap = "break-word"; | |
pre.innerHTML = message; | |
output.appendChild(pre); | |
output.scrollTop = output.scrollHeight; | |
} | |
function fixsize() | |
{ | |
content_height = $(window).height() - $('#input').height() - 150; | |
$('#output').height(content_height); | |
} | |
$( window ).unload(function() { | |
websocket.close(); | |
}); | |
$( window ).resize(fixsize); | |
$( document ).ready( init ); | |
</script> | |
<body> | |
<div class="container-fluid"> | |
<div id="principal" class="panel panel-info"> | |
<div id="constatus" class="panel-heading">WebSockets Chat</div> | |
<form role="form" class="panel-body"> | |
<div class="form-group"> | |
<div id="output" class="row-fluid panel-body" style="overflow-y: scroll;"> | |
</div> | |
<div id="input" > | |
<label for="textof">Enviar:</label> | |
<input type="text" class="form-control" id="texto"> | |
<button id="envia" type="button" class="btn btn-default" disabled>Envia</button> | |
</div> | |
</div> | |
</form> | |
</div> | |
</div> | |
</body> | |
</html> |
Rodando
Imaginando que você esteja no mesmo diretório dos arquivos deste post, vamos criar um servidor web simples com python, claro:
python -m SimpleHTTPServer 8080
Deixe rodando e abra um outro terminal. Vamos executar nosso servidor:
python server.py
E finalmente, abra o browser usando localhost ou seu ip:
http://localhost:8080/cliente.html
Observação: como utilizamos um certificado auto-assinado, precisamos dar permissão ao browser de abrir a página. Como o websocket apenas usa SSL, abra uma outra janela no browser, mas na porta 8765:
https://localhost:8765
Siga o procedimento de seu browser para abriar a página. Normalmente você deve clicar em um botão dizendo que quer continuar acessando a página. Se tudo der certo, você receberá a mensagem: Invalid request. Feche a janela e recarrege o cliente em:
http://localhost:8080/cliente.html
Ele agora deve ter conectado normalmente. Abra outra janela no mesmo endereço.
Digite:
/nome X
e depois envie uma mensagem. Ela deve aparecer na outra janela. Você deve digitar /nome Nome antes de enviar mensagens. Teste com vários clientes, modifique e divirta-se.