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.
Základ projektu
Začne tým, že si vytvoríme virtualenv! Bez toho sa ani nepohneme.
Sú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ý:
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-522
Facelift
Tento 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.
Stačí stiahnuť toto zipko a integrovať do svojho projektu. Je to veľmi jednoduché. Zip rozbalíme a prekopírujeme nasledovné súbory
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.
UI
Spraví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.
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).
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.
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:
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
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")
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.
Rooms
Presunieme 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é.
JavaScript
Backend 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.
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
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
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.
Záver
Postavili 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.
Onedlho sa opäť vrhneme na nejakú zaujímavú tému ;)
Miroslav Beka
Ahoj, volám sa Miro a som Pythonista. Programovať som začal na strednej. Vtedy frčal ešte turbo pascal. Potom prišiel matfyz, kadejaké zveriny ako Haskell, no najviac sa mi zapáčil Python.
Od vtedy v Pythone robím všetko. Okrem vlastných vecí čo si programujem pre radosť, som pracoval v ESETe ako automatizér testovania. Samozrejme, všetko v Pythone. Potom som skočil do inej firmy, tam taktiež Python na automatické testovanie aj DevOps. Viacej krát som účinkoval ako speaker na PyCon.sk, kde som odovzdával svoje skúsenosti.
Medzi moje obľúbené oblasti teda parí DevOps, Automatizovanie testovania a web development (hlavne backend).