Tipy a triky
04.10.2018
Miroslav Beka
Websockety - message board
Ahoj, naposledy sme hovorili o websocketoch vo flasku. Používali sme knižnicu flask-socketio a prešli sme si základnú funkcionalitu. Táto knižnica používa koncept miestností alebo rooms, ktorý slúži na to, aby sme vedeli adresovať klientov v nejakých skupinách.
Tento koncept sa používa v chatových aplikáciách, kde používatelia vidia správy len v miestnosti, v ktorej sa nachádzajú. Nedostanú správy zo žiadnej inej.
Pozrieme sa teda na tento koncept a aby sme spravili aj nejaký reálny príklad, spravíme vlastnú chatovaciu appku. Používatelia sa budú môcť pridať do existujúcej miestnosti, chatovať s ostatnými, vytvárať nové miestnosti a podobne. Bude to veľmi jednoduchý message board.[Image]
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/activateInštalujeme závislosti. Budeme používať to isté, čo v predchádzajúcom článku
(venv)$ pip install flask, flask-socketioIdeme na boilerplate pre našu appku. Štruktúra vyzerá asi takto:
▾ websockets_message_board/
▾ static/
▾ css/
main.css
▾ js/
main.js
▾ templates/
board.jinja
▸ venv/
server.pySúbory main.css a main.js sú zatiaľ prázdne, slúžia len ako placeholder. Pokračujeme teda so súborom server.py a ideme ho naplniť kódom.
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)Rozdiel oproti minimálnej flask appke je ten, že ju inak spúšťame. Nepoužijeme
if __name__ == '__main__':
app.run()ale budeme ju spúšťať cez socketIO.
if __name__ == '__main__':
socketio.run(app, debug=True)To preto, aby aplikácia vedela spustiť viacero vlákien pre každého používateľa. Tak isto je dobré vedieť, že deployment na produkčný server takejto aplikácie je trošku komplikovanejší ako keď máme klasickú flask appku.
Obsah základného templejtu board.jinja (aj jediného, ktorý budeme používať) je nasledovný:
<!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 zopár dôležitých importov ako socket.io, jquery a tak isto aj css a js súbory našej appky.
Takýto jednoduchý základ môžeme spustiť a uvidíme, či všetko šlape ako 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 nie je vôbec potrebný, ale keďže všetci majú radi pekné veci, nainštalujeme si css framework zvaný semantic-ui. Je to fajn framework, mám s ním dobré skúsenosti. Dokumentácia je možno trošku tažšia na pochopenie, ale okrem toho to funguje a hlavne vyzerá veľmi pekne.
[Image]
Stačí stiahnuť toto zipko a integrovať do svojho projektu. Je to veľmi jednoduché. Zip rozbalíme a prekopírujeme nasledovné súbory
• themes -> websockets_message_board/static/css/
• semantic.min.css -> websockets_message_board/static/css/
• semantic.min.js -> websockets_message_board/static/js/
Súbory semantic.min.js a semantic.min.css musím includnuť na svoju stránku, takže bežím do board.jinja a prihodím do hlavičky ďalšie riadky:
<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é dať si pozor, aby sme najprv pridali jquery a až potom semantic.min.js, inak sa mi semantic-ui bude sťažovať, že nevie nájsť jquery knižnicu. V priečinku themes sú hlavne ikony a nejaké obrázky, ktoré semantic-uiposkytuje.
Po inštalácií css frameworku môžem hneď vidieť zmenu v podobe iného fontu na mojej smutnej stránke. Nič iné tam ešte nieje.
UISpravíme teraz približný náčrt UI, aby som vedel, ako appka asi bude vyzerať a aké funkcie jej vlastne spravíme. Nebude to nič svetoborné. Budeme mať jednu stránku ktorú rozdelím na 3 sekcie. Hlavná bude obsahovať správy, takže to bude môj message board. Bočný panel bude obsahovať zoznam miestností, do ktorých sa budem vedieť prepínať. No a na spodnej lište bude input pre moju správu.[Image]
Zhmotním túto svoju predstavu do kódu. Otvorím board.jinja a nahádžem tam nejaké <div> elementy. Keďže používame semnatic-ui ako náš css framework, budem rovno používať triedy v html. Použijeme grid systém, ktorý nám zjednoduší prácu pri ukladaní ui elementov.
<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ôžem skúsiť naplniť tieto časti aj nejakým obsahom. Len tak zo zvedavosti, ako to bude vyzerať. Všetko bude zatiaľ len tak naoko (prototypovanie).
Začneme tým najhlavnejším: message boardom
<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šetky správy som obalil do div s id msg_board aby som potom jednoducho vedel pridávať nové správy do tohto elementu.[Image]
Spravíme to isté pre zoznam miestností. Rozhodol som sa, že do tohto bočného panelu strčíme aj formulár na zmenu mena používateľa. Ten by mal mať možnosť zmeniť svoje meno. Bude to vyzerať 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 isto som pridal aj <input /> na vytváranie nových miestností. Myslím, že takúto možnosť by používateľ mohol mať.
Poslednou skladačkou bude input pre naše sprá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álne mi nebudú fungovať radio buttony, pretože semantic-ui potrebuje tieto inicializovať v javascripte. Pome teda na to. Otvoríme main.js a píšeme
$(document).ready(function(){
// UI HANDLERS
$('.ui.radio.checkbox').checkbox();
});Tak isto môžeme rovno vybaviť iniciálne spojenie cez websockety medzi klientom a serverom.
$(document).ready(function(){
var url = location.protocol + "//" + document.domain + ":" + location.port;
socket = io.connect(url);
// UI HANDLERS
$('.ui.radio.checkbox').checkbox();
});Posielanie správ môžem rovno aj vyskúšať v konzole prehliadača. Stačí otvoriť developer tools, prejsť na záložku console a tam už môžeme písať
socket.emit("test", "hello there")[Image]
Avšak, nič sa nedeje, pretože môj backend ešte nie je vôbec pripravený. Vrhneme sa teda na server side a implementujeme miestnosti - room.
RoomsPresunieme sa do súboru server.py a pridáme handler pre základné eventy ktoré budeme používať: 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ú veľmi jednoducho. Zakaždým sa pozriem na dáta, ktoré mi prišli (premenná data) a vytvorím jednoduchú správu, ktorú potom broadcastujem na všetkých používateľov v tej danej miestnosti.
Ak si už poriadne nepamätáš, čo robil ten broadcast, pospomínaj z minulého blogu.
Dôležité je použitie funkcií join_room a leave_room. Tieto pochádzajú z knižnice flask-socketio, ktorú sme inštalovali na začiatku. Slúžia na to aby sme priradili danú session do nejakej miestnosti. Potom, keď pošlem správu do miestnosti, dostanú ju všetci v tej miestnosti. Je to fajn mechanizmus ako kontaktovať iných klientov a usporiadať si ich do nejakých kategórií.
rooms nemusím nevyhnutne používať len na chatovú funkcionalitu. Môžem to použiť na to, aby som si zoradil používateľov do nejakej spoločnej skupiny, ktorej posielam barsjaké dáta.
Dajme tomu, že by som mal appku o počasí, a nejaká skupina používateľov by mala záujem o notifikácie, či bude pršať. Tak týchto by som hodil do spoločnej skupiny - miestnosti - a notifikácie by som posielal len im. Využitie je teda všakovaké.
JavaScriptBackend bol v tomto prípade celkom jednoduchý a nepotrebovali sme toho veľa implementovať. Správy sa od nášho backendu len odrážajú ako od relátka, ktorý ich ďalej rozposiela klientom.
Na strane klienta toho bude trošku viacej. Pokračujeme v súbore main.js. Teraz sa pokúsime implementovať posielanie správy a zobrazenie prichádzajúcej sprá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čiatok vytvoríme nejaké random meno používateľa a zvolíme default miestnosť "Lobby". To aby sme s týmto nemali starosti zatiaľ. Používame na to pomocné funkcie, ktoré si implementujeme bokom, aby nám nezavadzali.
Meno používateľa a názov aktuálnej miestnosti si udržiavam v sessionStorage, čo je fajn dočasné úložisko v prehliadači. Prežije aj reload stránky a navyše sa mi tento spôsob viacej páči ako udržiavať informáciu v cookies.
Keď máme potrebné dáta, môžeme sa hneď na začiatku buchnúť do nejakej miestnosti. V javascripte používame knižnicu socket.io, ktorá ale žiadny koncept miestností nepozná. Ak sa pozrieš do dokumentácie (pozor! otvor si client api), zistíš, že nič také ako rooms sa tam nespomína. Takže to je vecička knižnice flask-socketio. Použijeme teda klasický emit na handler join, ktorý existuje na servery.
Tento riadok $("form#send_msg_to_room").submit( sa pomocou jquery napichne na formulár a zachytí odoslanie formuláru. Potom môžem robiť čo sa mi zachce a nakoniec vrátim false, takže formulár sa reálne ani neodošle.
Odoslanie správy je priamočiare. Zistím UserName, zistím RoomName, vytiahnem si text správy a všetko pošlem do funkcie sendMessage.
Táto už zabezpečí zabalanie informácií do jsonu a posielam pomocou funkcie emit. Posielam na handler msg_board, ktorý som si spravil pred chvíľkou.
Ostáva mi vyriešiť prijatie správy. To robím pomocou funkcie socket.on, kde dám kód, ktorý sa vykoná pri prijatí správy. Tu si jednoducho (ale zato strašne škaredo) pozliepam kus HTML, ktoré potom strčím na koniec elementu s id msg_board.
Predtým, ako to budeš skúšať, je fajn si ešte vymazať tie fejkové správy, ktoré sme tam dali natvrdo do HTML. Takže mažeme tieto riadky
<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 teda ako ďalšiu vec vybaviť zmenu používateľského mena.
$(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 ako pri posielaní správy, napichnem sa na HTML formulár a spracujem ho ešte pred odoslaním. Zmeny uložím do sessionStorage.
Pridal som ešte 2 vychytávky.
• funkcia updateHeading nastaví aktuálny názov miestnosti a používateľa ako hlavičku stránky,
• notifyNameChange dá všetkým používateľom vedieť, že si niekto zmenil meno.
Meno si už môžem meniť, ale notifikáciu o zmene som nedostal. Na to ešte musíme doplniť 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);
});
});
...Teraz sa nám začnú zobrazovať aj systémové notifikácie o tom, čo sa deje. Kto vošiel do miestnosti, kto ju opustil alebo kto si zmenil meno.
Poslednou vecou, ktorú musíme spraviť, je selekcia miestností. Toto bude vyžadovať trošku viacej práce. Zoznam existujúcich miestností si musíme udržiavať na backende. Ani na klientskej časti ani na backende z knižnice flask-socketio neviem získať zoznam všetkých miestností. Musím si ho teda udržiavať 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 som si začal posielať nejaké dáta. Vyhodím teda tie fejkové, ktoré sú tam natvrdo, a spravíme loop, v ktorom pridám všetky existujúce miestnosti.
<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čujem v súbore main.js, kde si vytvorím funkcie, ktoré sa postarajú o zmenu miestnosti + ak bola vytvorená nová, tak ju pridám do zoznamu.
$(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 tu viacero pomocných funkcií, ktoré mi pomáhajú pri výbere miestnosti alebo pri vytváraní novej. Problematické časti nastávajú práve v tedy, keď chcem miestnosť aj vytvárať. V podstate ale nejde o žiadne komplikované veci.
Funkcia selectCurrentRoom mi pomôže prehodiť radio button pri zmene miestnosti. Tým, že používame semantic-ui tak sa nám to tiež trošku skomplikovalo, ale výsledok stojí za to.[Image]
ZáverPostavili sme takzvaný proof of concept, spravili sme chatovaciu appku len pomocou websocketov. Nie je to dokonalé a určite je tam veľa múch, to nám však nebránilo pochopiť ako fungujú websockety. Všetky správy žijú len v prehliadači používateľa a nie sú uložené na žiadnom serveri. Niekto to môže považovať za chybu, niekto za fičúru. To už nechám na vás.
Celý projekt sa dá stiahnuť tu.
Onedlho sa opäť vrhneme na nejakú zaujímavú tému ;)