Tipy a triky
04.10.2018
Miroslav Beka
Websockety - message board
Ahoj, naposledy jsme mluvili o websocketech ve flasku. Používali jsme knihovnu flask-socketio a prošli jsme základní funkcionalitu. Tato knihovna používá koncept místností nebo rooms, který slouží k tomu, abychom uměli adresovat klienty v nějakých skupinách.
Tento koncept se používá v chatových aplikacích, kde uživatelé vidí zprávy jen v místnosti, ve které se nacházejí. Nedostanou zprávy z žádné jiné.
Podíváme se tedy na tento koncept a abychom udělali i nějaký reálný příklad, uděláme vlastní chatovací appku. Uživatelé se budou moci přidat do stávající místnosti, chatovat s ostatními, vytvářet nové místnosti a podobně. Bude to velice jednoduchý message board.
Základ projektuZačne tým, že si vytvoríme virtualenv! Bez toho sa ani nepohneme.
$ mkdir websockets_message_board
$ cd websockets_message_board
$ virtualenv venv
$ . venv/bin/activateInstalujeme závislosti. Budeme používat totéž, co v předchozím článku.
(venv)$ pip install flask, flask-socketioJedeme na boilerplatě pro naši appku. Struktura vypadá asi takto:
▾ websockets_message_board/
▾ static/
▾ css/
main.css
▾ js/
main.js
▾ templates/
board.jinja
▸ venv/
server.pySoubory main.css a main.js jsou zatím prázdné, slouží pouze jako placeholder. Pokračujeme tedy se souborem server.py a lze jej naplnit kódem.
from flask import Flask
from flask import render_template
from flask import redirect
from flask import url_for
from flask_socketio import SocketIO
app = Flask(__name__)
app.config['SECRET_KEY'] = '\xfe\x060|\xfb\xf3\xe9F\x0c\x93\x95\xc4\xbfJ\x12gu\xf1\x0cP\xd8\n\xd5'
socketio = SocketIO(app)
### WEB CONTROLLER
@app.route("/")
def index():
return redirect(url_for("view_board"))
@app.route("/board/")
def view_board():
return render_template("board.jinja")
if __name__ == '__main__':
socketio.run(app, debug=True)Rozdíl oproti minimální flask appke je ten, že ji jinak spouštíme. Nepoužijeme
if __name__ == '__main__':
app.run()ale budeme ji spouštět přes socketIO.
if __name__ == '__main__':
socketio.run(app, debug=True)To proto, aby aplikace uměla spustit více vláken pro každého uživatele. Stejně tak je dobré vědět, že deployment na produkční server takové aplikace je trošku komplikovanější než když máme klasickou flask appku.
Obsah základního templejtu board.jinja (i jediného, který budeme používat) je následující:
<!DOCTYPE HTML>
<html>
<head>
<title>Short Term Memory Message Board</title>
<script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/socket.io/1.3.5/socket.io.min.js"></script>
<script type="text/javascript" src="{{ url_for("static", filename="js/main.js")}}"></script>
<link rel="stylesheet" type="text/css" href={{url_for("static", filename="css/main.css")}}>
</head>
<body>
Hello
</body>
</html>máme tam pár důležitých importů jako socket.io, jquery a také css a js soubory naší appky.
Takový jednoduchý základ můžeme spustit a uvidíme, jestli všechno šlape jak má
$(venv) python server.py
WebSocket transport not available. Install eventlet or gevent and gevent-websocket for improved performance.
* Serving Flask app "server" (lazy loading)
* Environment: production
WARNING: Do not use the development server in a production environment.
Use a production WSGI server instead.
* Debug mode: on
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
WebSocket transport not available. Install eventlet or gevent and gevent-websocket for improved performance.
* Debugger is active!
* Debugger PIN: 112-998-522FaceliftTento krok není vůbec potřebný, ale jelikož všichni mají rádi hezké věci, nainstalujeme si css framework zvaný semantic-ui. Je to fajn framework, mám s ním dobré zkušenosti. Dokumentace je možná trošku těžší na pochopení, ale kromě toho to funguje a hlavně vypadá moc hezky.
[Image]
Stačí stáhnout toto zipko a integrovat do svého projektu. Je to velmi jednoduché. Zip rozbalíme a překopírujeme následující soubory
• themes -> websockets_message_board/static/css/
• semantic.min.css -> websockets_message_board/static/css/
• semantic.min.js -> websockets_message_board/static/js/
Soubory semantic.min.js a semantic.min.css musím includnout na svou stránku, takže běžím do board.jinja a přihodím do hlavičky další řádky:
<head>
<title>Short Term Memory Message Board</title>
<script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/socket.io/1.3.5/socket.io.min.js"></script>
<script type="text/javascript" src="{{ url_for("static", filename="js/semantic.min.js")}}"></script>
<script type="text/javascript" src="{{ url_for("static", filename="js/main.js")}}"></script>
<link rel="stylesheet" type="text/css" href={{url_for("static", filename="css/semantic.min.css")}}>
<link rel="stylesheet" type="text/css" href={{url_for("static", filename="css/main.css")}}>
</head>Je důležité dát si pozor, abychom nejprve přidali jquery a až pak semantic.min.js, jinak se mi semantic-ui bude stěžovat, že neví najít jquery knihovnu. Ve složce themes jsou hlavně ikony a nějaké obrázky, které semantic-ui poskytuje.
Po instalaci css frameworku můžu hned vidět změnu v podobě jiného fontu na mé smutné stránce. Nic jiného tam ještě není.
UIUděláme nyní přibližný náčrt UI, abych věděl, jak appka asi bude vypadat a jaké funkce jí vlastně uděláme. Nebude to nic světoborného. Budeme mít jednu stránku kterou rozdělím na 3 sekce. Hlavní bude obsahovat zprávy, takže to bude můj message board. Boční panel bude obsahovat seznam místností, do kterých se budu umět přepínat. No a na spodní liště bude input pro moji zprávu.[Image]
Zhmotním tuto svou představu do kódu. Otevřu board.jinja a naházím tam nějaké <div> elementy. Jelikož používáme semnatic-ui jako náš css framework, budu rovnou používat třídy v html. Použijeme grid systém, který nám usnadní práci při ukládání ui elementů.
<body class="ui container">
<div class="ui grid">
<div class="ten wide column">
message board
</div> {# end ten wide column #}
<div class="six wide column">
rooms
</div> {# end six wide column #}
</div> {# end grid #}
<footer>
text input
</footer>
</body>Můžu zkusit naplnit tyto části i nějakým obsahem. Jen tak ze zvědavosti, jak to bude vypadat. Všechno bude zatím jen tak naoko (prototypování).
Začneme tím nejhlavnějším: message boardem
<div class="ten wide column">
<h1 id="room_heading" class="ui header">Johny @ Music room</h1>
<div id="msg_board">
<div class="ui mini icon message">
<i class="comment icon"></i>
<div class="content">
<div class="header">Johny</div>
<p>Hello there</p>
</div>
</div>
<div class="ui mini icon message">
<i class="comment icon"></i>
<div class="content">
<div class="header">Tommy</div>
<p>Hi!</p>
</div>
</div>
<div class="ui mini icon message">
<i class="comment icon"></i>
<div class="content">
<div class="header">Tommy</div>
<p>What's up?</p>
</div>
</div>
</div> {# end msg board #}
</div> {# end ten wide column #}Všechny zprávy jsem obalil do div s id msg_board, abych pak jednoduše uměl přidávat nové zprávy do tohoto elementu.[Image]
Uděláme totéž pro seznam místností. Rozhodl jsem se, že do tohoto postranního panelu strčíme i formulář pro změnu jména uživatele. Ten by měl mít možnost změnit své jméno. Bude to vypadat asi takto:
<div class="six wide column">
<h4 class="ui dividing header">Change username</h4>
<form id="choose_username" class="ui form" method="post">
<div class="field">
<div class="ui action input">
<input type="text" id="user_name" placeholder="username...">
<button class="ui button">Change</button>
</div>
</div>
</form>
<h4 class="ui dividing header">Rooms</h4>
<form id="choose_room" class="ui form" method="post">
<div class="grouped fields">
<label for="fruit">Select available room:</label>
<div id="room_list">
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="room" class="hidden" value="Lobby">
<label>Lobby</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="room" class="hidden" value="Music">
<label>Music</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="room" class="hidden" value="Movies">
<label>Movies</label>
</div>
</div>
</div>
<div class="field">
<input type="text" id="new_room" placeholder="create new room">
</div>
<button class="ui button"> Change Room</button>
</div>
</form>
</div> {# end six wide column #}[Image]
Také jsem přidal <input /> na vytváření nových místností. Myslím, že takovou možnost by uživatel mohl mít.
Poslední skládačkou bude input pro naše zprávy.
<footer>
<form id="send_msg_to_room" class="ui form" method="post">
<div class="field">
<div class="ui fluid action input">
<input type="text" id="msg_input" placeholder="message..."/>
<button class="ui button" value="send">send</button>
</div>
</div>
</form>
</footer>[Image]
Momentálně mi nebudou fungovat radio buttony, protože semantic-ui potřebuje tyto inicializovat v javascriptu. Pome tedy na to. Otevřeme main.js a píšeme
$(document).ready(function(){
// UI HANDLERS
$('.ui.radio.checkbox').checkbox();
});Stejně tak můžeme rovnou vybavit iniciální spojení přes websockety mezi klientem a serverem.
$(document).ready(function(){
var url = location.protocol + "//" + document.domain + ":" + location.port;
socket = io.connect(url);
// UI HANDLERS
$('.ui.radio.checkbox').checkbox();
});Posílání zpráv mohu rovnou i vyzkoušet v konzoli prohlížeče. Stačí otevřít developer tools, přejít na záložku console a tam už můžeme psát
socket.emit("test", "hello there")[Image]
Nicméně, nic se neděje, protože můj backend dosud není vůbec připraven. Vrhneme se tedy na server side a implementujeme místnosti – room.
RoomsPřesuneme se do souboru server.py a přidáme handler pro základní eventy které budeme používat: join, leave, msg_board, username_change
...
from flask_socketio import send, emit
from flask_socketio import join_room, leave_room
...
### WEB CONTROLLER
@app.route("/")
def index():
return redirect(url_for("view_board"))
@app.route("/board/")
def view_board():
return render_template("board.jinja")
## SOCKET CONTROLLER
@socketio.on("join")
def on_join(data):
username = data["user_name"]
room = data["room_name"]
join_room(room)
send("{} has entered the room: {}".format(username, room), room=room)
@socketio.on("leave")
def on_leave(data):
username = data["user_name"]
room = data["room_name"]
leave_room(room)
send("{} has left the room: {}".format(username, room), room=room)
@socketio.on("msg_board")
def handle_messages(msg_data):
emit("msg_board", msg_data, room=msg_data["room_name"])
@socketio.on("username_change")
def username_change(data):
msg = "user \"{}\" changed name to \"{}\"".format(
data["old_name"], data["new_name"])
send(msg, broadcast=True)
...Eventy join, leave a username_change fungují velmi jednoduše. Pokaždé se podívám na data, která mi přišla (proměnná data) a vytvořím jednoduchou zprávu, kterou pak broadcastuji na všechny uživatele v té dané místnosti.
Pokud si už pořádně nepamatuješ, co dělal ten broadcast, vzpomínej z minulého blogu.
Důležité je použití funkcí join_room a leave_room. Tyto pocházejí z knihovny flask-socketio, kterou jsme instalovali na začátku. Slouží k tomu, abychom přiřadili danou session do nějaké místnosti. Potom, když pošlu zprávu do místnosti, dostanou ji všichni v té místnosti. Je to fajn mechanismus jak kontaktovat jiné klienty a uspořádat si je do nějakých kategorií.
rooms nemusím nutně používat jen na chatovou funkcionalitu. Mohu to použít k tomu, abych si seřadil uživatele do nějaké společné skupiny, které posílám barsjaká data.
Dejme tomu, že bych měl appku o počasí, a nějaká skupina uživatelů by měla zájem o notifikace, jestli bude pršet. Tak tyto bych hodil do společné skupiny - místnosti - a notifikace bych posílal jen jim. Využití je tedy všelijaké.
JavaScriptBackend byl v tomto případě docela jednoduchý a nepotřebovali jsme toho mnoho implementovat. Zprávy se od našeho backendu jen odrážejí jako od relátka, který je dále rozesílá klientům.
Na straně klienta toho bude trošku více. Pokračujeme v souboru main.js. Nyní se pokusíme implementovat posílání zprávy a zobrazení příchozí zprávy na messageboard.
$(document).ready(function() {
...
// generate random user name if needed
setRandomNameAndRoom();
// join default room
joinRoom(socket);
// UI HANDLERS
$('.ui.radio.checkbox').checkbox();
// send message
$("form#send_msg_to_room").submit(function(event) {
userName = sessionStorage.getItem("userName");
roomName = sessionStorage.getItem("roomName");
msg = $("#msg_input").val();
sendMessage(socket, userName, roomName, msg);
this.reset();
return false;
});
// handle new message
socket.on("msg_board", function(data){
msg = '<div class="ui mini icon message">';
msg += '<i class="comment icon"></i>';
msg += '<div class="content">';
msg += '<div class="header">'+data["user_name"]+'</div>';
msg += '<p>' + data["msg"] + '</p>';
msg += '</div>';
msg += '</div>';
$("#msg_board").append(msg);
});
});
// HELPERS
function setRandomNameAndRoom(){
if (sessionStorage.getItem("userName") == null){
randomName = "user" + Math.floor((Math.random() * 100) + 1);
sessionStorage.setItem("userName", randomName);
sessionStorage.setItem("roomName", "Lobby");
};
};
function joinRoom(socket){
data = {
"room_name" : sessionStorage.getItem("roomName"),
"user_name" : sessionStorage.getItem("userName")
};
socket.emit("join", data);
};
function sendMessage(socket, userName, roomName, message){
data = {
"user_name" : userName,
"room_name" : roomName,
"msg" : msg
};
socket.emit("msg_board", data);
};
Na začátek vytvoříme nějaké random uživatelské jméno a zvolíme default místnost "Lobby". To abychom s tímto neměli starosti zatím. Používáme k tomu pomocné funkce, které si implementujeme stranou, aby nám nezavazovaly.
Jméno uživatele a název aktuální místnosti si udržuji v sessionStorage, což je fajn dočasné úložiště v prohlížeči. Přežije také reload stránky a navíc se mi tento způsob více líbí jak udržovat informaci v cookies.
Když máme potřebná data, můžeme se hned na začátku bouchnout do nějaké místnosti. V javascriptu používáme knihovnu socket.io, která ale žádný koncept místností nezná.
Pokud se podíváš do dokumentace(pozor! otevři si client api), zjistíš, že nic takového jako rooms se tam nezmiňuje. Takže to je věcička knihovny flask-socketio. Použijeme tedy klasický emit na handler join, který existuje na serveru.
Tento řádek $("form#send_msg_to_room").submit( se pomocí jquery napíchne na formulář a zachytí odeslání formuláře. Pak můžu dělat co se mi zachce a nakonec vrátím false, takže formulář se reálně ani neodešle.
Odeslání zprávy je přímočaré. Zjistím UserName, zjistím RoomName, vytáhnu si text zprávy a vše pošlu do funkce sendMessage.
Tato již zajistí zabalení informací do jsonu a posílám pomocí funkce emit. Posílám na handler msg_board, který jsem si udělal před chvilkou.
Zbývá mi vyřešit přijetí zprávy. To dělám pomocí funkce socket.on, kde dám kód, který bude proveden při přijetí zprávy. Tady si jednoduše (ale zato strašně ošklivě) slepím kus HTML, které pak strčím na konec elementu s id msg_board.
Než to budeš zkoušet, je fajn si ještě vymazat ty fejkové zprávy, které jsme tam dali natvrdo do HTML. Takže mažeme tyto řádky
<div class="ten wide column">
<h1 id="room_heading" class="ui header">Johny @ Music room</h1>
<div id="msg_board">
---> <div class="ui mini icon message">
---> <i class="comment icon"></i>
---> <div class="content">
---> <div class="header">Johny</div>
---> <p>Hello there</p>
---> </div>
---> </div>
---> <div class="ui mini icon message">
---> <i class="comment icon"></i>
---> <div class="content">
---> <div class="header">Tommy</div>
---> <p>Hi!</p>
---> </div>
---> </div>
---> <div class="ui mini icon message">
---> <i class="comment icon"></i>
---> <div class="content">
---> <div class="header">Tommy</div>
---> <p>What's up?</p>
---> </div>
---> </div>
</div> {# end msg board #}
</div> {# end ten wide column #}Pome tedy jako další věc vybavit změnu uživatelského jména.
$(document).ready(function(){
...
// set heading
updateHeading();
// set user name handler
$("form#choose_username").submit(function(event){
// get old and new name
var oldName = sessionStorage.getItem("userName");
var newName = $("#user_name").val();
//save username to local storage
sessionStorage.setItem("userName", newName);
// change ui
updateHeading();
// notify others
notifyNameChange(socket, oldName, newName);
//clear form
this.reset();
return false
});
});
function updateHeading(){
roomName = sessionStorage.getItem("roomName");
userName = sessionStorage.getItem("userName");
$("#room_heading").text(userName + " @ " + roomName);
};
function notifyNameChange(socket, oldName, newName){
data = {
"old_name" : oldName,
"new_name" : newName
}
socket.emit("username_change", data);
};Tak jako při posílání zprávy, napíchnu se na HTML formulář a zpracuji ho ještě před odesláním. Změny uložím do sessionStorage.
Přidal jsem ještě 2 vychytávky.
• funkce updateHeading nastaví aktuální název místnosti a uživatele jako hlavičku stránky,
• notifyNameChange dá všem uživatelům vědět, že si někdo změnil jméno.
Jméno si už můžu měnit, ale oznámení o změně jsem nedostal. Na to ještě musíme doplnit jeden event handler na message
$(document).ready(function(){
...
// system message
socket.on("message", function(data){
msg = '<div class="ui mini icon info message">';
msg += '<i class="bell icon"></i>';
msg += '<div class="content">';
msg += '<p>' + data + '</p>';
msg += '</div>';
msg += '</div>';
$("#msg_board").append(msg);
});
});
...Nyní se nám začnou zobrazovat i systémové notifikace o tom, co se děje. Kdo vešel do místnosti, kdo ji opustil nebo kdo si změnil jméno.
Poslední věcí, kterou musíme udělat, je selekce místností. Toto bude vyžadovat trošku více práce. Seznam stávajících místností si musíme udržovat na backendu. Ani na klientské části ani na backendu z knihovny flask-socketio neumím získat seznam všech místností. Musím si ho tedy udržovat sám.
from flask import g
...
DEFAULT_ROOMS = ["Lobby"]
...
@app.route("/board/")
def view_board():
all_rooms = getattr(g, "rooms", DEFAULT_ROOMS)
return render_template("board.jinja", rooms=all_rooms)
...
### SOCKET CONTROLLER
@socketio.on("join")
def on_join(data):
username = data["user_name"]
room = data["room_name"]
all_rooms = getattr(g, "rooms", DEFAULT_ROOMS)
if room not in all_rooms:
all_rooms.append(room)
emit("handle_new_room", {"room_name" : room}, broadcast=True)
join_room(room)
send("{} has entered the room: {}".format(username, room), room=room)Do templejtu board.jinja jsem si začal posílat nějaká data. Vyhodím tedy ty fejkové, které jsou tam natvrdo, a uděláme loop, ve kterém přidám všechny stávající místnosti.
<div id="room_list">
{% for room in rooms %}
<div class="field">
<div class="ui radio checkbox">
<input type="radio" name="room" class="hidden" value="{{room}}">
<label>{{room}}</label>
</div>
</div>
{% endfor %}
</div>Pokračuji v souboru main.js, kde si vytvořím funkce, které se postarají o změnu místnosti + pokud byla vytvořena nová, tak ji přidám do seznamu.
$(document).ready(function(){
...
// set room name heading
selectCurrentRoom();
updateHeading();
...
// set room handler
$("form#choose_room").submit(function(event){
newRoom = getRoomName();
// first leave current room
leaveRoom(socket);
// set new room
sessionStorage.setItem("roomName", newRoom);
updateHeading();
// join new room
joinRoom(socket);
//clear input
newRoom = $("#new_room").val("");
//clear message board
$("#msg_board").text("");
return false;
});
socket.on("handle_new_room", function(data){
item = '<div class="field">';
item += '<div class="ui radio checkbox">';
item += '<input type="radio" name="room" class="hidden" value="'+ data["room_name"] + '">';
item += '<label>' + data["room_name"] + '</label>';
item += '</div>'
item += '</div>'
$("div#room_list").append(item);
selectCurrentRoom();
});
});
...
function leaveRoom(socket){
data = {
"room_name" : sessionStorage.getItem("roomName"),
"user_name" : sessionStorage.getItem("userName")
};
socket.emit("leave", data);
};
function selectCurrentRoom(){
currentRoom = sessionStorage.getItem("roomName")
$(".ui.radio.checkbox").checkbox().each(function(){
var value = $(this).find("input").val();
if (value == currentRoom){
$(this).checkbox("set checked");
};
});
};
function getRoomName(){
roomName = $("#new_room").val();
if (roomName == ""){
roomName = $("input[type='radio'][name='room']:checked").val();
};
return roomName;
};Je zde několik pomocných funkcí, které mi pomáhají při výběru místnosti nebo při vytváření nové. Problematické části nastávají právě tehdy, když chci místnost i vytvářet. V podstatě ale nejde o žádné komplikované věci.
Funkce selectCurrentRoom mi pomůže přehodit radio button při změně místnosti. Tím, že používáme semantic-ui, tak se nám to také trošku zkomplikovalo, ale výsledek stojí za to.[Image]
ZávěrPostavili jsme takzvaný proof of concept, udělali jsme chatovací appku jen pomocí websocketů. Není to dokonalé a určitě je tam spousta much, to nám však nebránilo pochopit jak fungují websockety. Všechny zprávy žijí pouze v prohlížeči uživatele a nejsou uloženy na žádném serveru. Někdo to může považovat za chybu, někdo za fičúru. To už nechám na vás.
Celý projekt se dá stáhnout zde.
Zanedlouho se opět vrhneme na nějaké zajímavé téma ;)