Novo Blog

Novo endereço

https://blog.nilo.pro.br

sábado, 21 de janeiro de 2017

Migrando o servidor de chat para Python 3.6

Leia no novo blog

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!

#!/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
Para instalar no Ubuntu, baixe o install_python360.sh e rode com:
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 websockets

Outros 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:

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()
view raw server.py hosted with ❤ by GitHub
Antes de executar, temos que preparar um certificado SSL (no Linux).

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.

<!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>
view raw client.html hosted with ❤ by GitHub

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.







Nenhum comentário: