Single Page Apps, Fetch API - asynkroninen javascript, websockets

Opetellaan käyttämään Javascriptin Fetch API -rajapintaa, jolla saadaan asynkronisesti kommunikoitua WWW-palvelimen kanssa. Myös Firestore-tietokantaan pääsee Javascriptilla käsiksi asynkronisen rajapinnan kautta. Tutustutaan Javascriptin Promiseihin. Kokeilleen myös websocketeja.

Fetch API

Tutustu Javascriptin promiseihin lukemalla Using Promises -artikkeli.

Hyödynnetään Fetch-rajapintaa lomakkeen täytön apuna. Käy läpi MDN:n Using the Fetch API -ohje. Voit myös katsoa vanhan ajax-luennon ja ajax-esimerkit, joissa käytetään jQuerya ja XMLHttpRequest-rajapintaa. Idea näissä on tismalleen sama kuin Fetch-rajapinnassa, mutta Fetch on tällä hetkellä järkevin rajapintavalinta käytettäväksi. Jos Javascript ei ole sinulle ennestään tuttu, niin kertaa TIEA2120 Web-käyttöliittymien ohjelmointi -kurssin ohjaustehtäviä

Kokeile seuraavaksi toteutettavan sovelluksen mallia. Täytä lomakkeelle tietoja. Kokeile syöttää postinumeroksi 40740 ja huomaat kuinka sovellus automaattisesti täyttää postitoimipaikan. Kokeile vaihtaa oppilaitosryhmää, niin sovellus vaihtaa automaattisesti oppilaitoslistan sisältöä. Lue tämän sivun ohjetta eteenpäin, niin toteutat itse vastaavanlaisen sovelluksen. Lähdekoodi (javascript), Lähdekoodi (flask / python), ryhmat.xml (template)

Flask ja JSON

Ensimmäisenä tarvii valmistella palvelimella toimiva ohjelma tuottamaan JSON-muotoista dataa. Luodaan flask-ohjelma, joka palauttaa listan postitoimipaikoista JSON-muodossa.

Luo ensimmäisenä uusi SQLite3-tietokanta. Käytä oppilaitos.sql-SQL-koodia.

tietokannan rakenne

Flask ja XML

Monesti tiedonsiirtomuotona ei olekkaan JSON vaan XML.

Toteuta samaan tapaan kuin edellä kaikkien oppilaitosryhmien hakeminen, mutta palauta listaus sellaisessa XML-muodossa, että sitä voidaan suoraan käyttää HTML-dokumentissa. Käytä mediatyyppinä text/xml. Muodosta tarvittava XML Jinja-templaten avulla:

<?xml version="1.0" encoding="UTF-8"?>
<select id="{{name}}" name="{{name}}" xmlns="http://www.w3.org/1999/xhtml">
{% for o in ryhmat %}
{% if loop.first %}
    <option selected="selected" value="{{o["ryhmaid"]}}">{{ o["nimi"] }}</option>
{% else %}
    <option value="{{o["ryhmaid"]}}">{{ o["nimi"] }}</option>
{% endif %}
{% endfor %}
</select>

Kts. malli

Käyttäminen poikkeaa hieman aiemmista, koska myös nyt halutaan erikseen varmistaa mikä on merkistö ja mediatyyppi:

    resp = make_response( render_template("ryhmat.xml",ryhmat=ryhmat, name="oppilaitosryhma"))
    resp.charset = "UTF-8"
    resp.mimetype = "text/xml"
        
    return resp

Vaihtoehtoisia toteutustapoja

XML-dokumentin voit toteuttaa myös muilla tavoilla kuin Jinja-templatella. Jinja on kaikista epävarmin tapa tuottaa ehjiä XML-dokumentteja. DOM-rajapinta (minidom) tai ElementTree ovat parempia.

DOM-rajapinta

Saman voi toteuttaa myös minidom-kirjaston avulla jolloin templatea ei tarvita. Vrt. Javascript ja DOM. Kokeile:

    from xml.dom.minidom import getDOMImplementation, parse, parseString

    impl = getDOMImplementation()
    # createDocument(nimiavaruus, juurielementti, dokumenttityyppi)
    doc = impl.createDocument("http://www.w3.org/1999/xhtml", "select", None)
    # pakko laittaa seuraava, koska jostakin syystä edellinen ei riitä
    doc.documentElement.setAttribute("xmlns", "http://www.w3.org/1999/xhtml")
    doc.documentElement.setAttribute("name", "ryhmat")
    doc.documentElement.setAttribute("id", "ryhmat")

    for ryhma in cur.fetchall():
        option = doc.createElement("option");
        txt = doc.createTextNode( ryhma['ryhmanimi'] );
        option.appendChild(txt)
        # minidom ei tue textContent-ominaisuutta
        # li.textContent = ryhma['ryhmanimi']
        option.setAttribute("value", str(ryhma["ryhmaid"]))
        doc.documentElement.appendChild(option)

    resp = make_response( doc.toxml('UTF-8') )
    resp.charset = "UTF-8"
    resp.mimetype = "text/xml"
    return resp
ElementTree

Voit myös tutustua pythonin xml.etree.ElementTree-kirjastoon ja muodostaa sen avulla tarvittavan xml-dokumentin:

    import xml.etree.ElementTree as ET
    xml = """<?xml version="1.0" encoding="UTF-8"?><select id="ryhmat" name="ryhmat"></select>"""

    root = ET.fromstring( xml )
    root.attrib["xmlns"] = "http://www.w3.org/1999/xhtml"
    for ryhma in cur.fetchall():
        option = ET.SubElement(root, "option")
        option.text = ryhma['ryhmanimi']
        option.attrib['value'] = str(ryhma["ryhmaid"])

    resp = make_response( ET.tostring( root, encoding="UTF-8", method="xml" ) )

Oppilaitokset

Toteuta samaan tapaan oppilaitosten hakeminen (vrt. edellä tehty yhden postitoimipaikan hakeminen). Rajaa haettavia oppilaitoksia jollakin oppilaitosryhmällä (ryhmaid). Jos ryhmää ei ole annettu, silloin hae kaikki oppilaitokset. Palauta oppilaitokset seuraavanlaisessa XML-muodossa. Luo XML-muoto joko DOM-rajapinnan tai ElementTreen avulla.

<oppilaitokset>
 <oppilaitos id="1">Helsingin kauppakorkeakoulu</oppilaitos>
 <oppilaitos id="2">Helsingin yliopisto</oppilaitos>
 <oppilaitos id="3">Joensuun yliopisto</oppilaitos>
 ...
</oppilaitokset>

Kts. malli

Javascript, Fetch ja JSON

Ota käyttöön valmis pohja. Pura samaan kansioon flask-sovelluksesi kanssa.

Muokkaa valmista osoite.js-tiedostoa. Toteutetaan postitoimipaikan valinta postinumeron perusteella.

  1. Lisää postinumeron change-tapahtumaan tapahtumankäsittelijä (hae_postitoimipaikka)
  2. Luo hae_postitoimipaikka-funktio, jossa Fetch-rajapinnan avulla kutsut aiemmin flaskilla tekemääsi postitoimipaikka-sivua.
    function hae_postitoimipaikka() {
    // asetukset fetch-kutsua varten
    // https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch
    let url = new URL("https://users.jyu.fi/~omatunnus/cgi-bin/ties4080/ohjaus5/flask.cgi/postitoimipaikka");
    // urliin on lisättävä ?postinumero=arvo, koska kyseessä on GET-tyyppinen request
    // Tämä tehdään seatchparams-objektin avulla
    // https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
    // kts. https://javascriptplayground.com/url-search-params/
    url.searchParams.append("postinro", document.forms[0].elements.postinumero.value);
    
    // huom! tämän on oltava asyncroninen funktio
      async function omafetch(url) {
        let response = await fetch(url);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
       // huom. datan sisältö ja rakenne on juuri se mitä itse on muodostettu palvelimella
        let data = await response.json();
        try {
          document.forms[0].elements.postitoimipaikka.value = data[0]["postitoimipaikka"];
        }
        catch (e) {
          document.forms[0].elements.postitoimipaikka.value = "tuntematon";
        }
      }
    
    // seuraava nappaa kiinni verkkovirheet ym. 
    // Jos annettua osoitetta ei löydy, niin se ei ole virhe
      omafetch(url).catch(e => {
         console.log('Virhe postitoimipaikan hakemisessa: ' + e.message);
      });
    }
  3. Kokeile toimiiko sovellus ja muuttuuko postitoimipaikan sisältö. Katso Web Developer -työkalujen Network-välilehdeltä (CTRL+SHIFT+E) mitä tapahtuu, kun vaihdat postinumerokentän sisältöä. Tarkista myös Javascript Consolen (CTRL+SHIFT+K) tulosteet.

Fetch ja XML

Toteutetaan seuraavaksi oppilaitoksen valinta oppilaitosryhmän perusteella.

  1. Tee Javascript-tiedostoon uusi funktio hae_ryhmat, jossa pyydät hae_ryhmat-sivua.
    async function hae_ryhmat() {
    
    	let response = await fetch("/~omatunnus/cgi-bin/ties4080/ohjaus5/flask.cgi/hae_ryhmat");
    	if (!response.ok) {
          		throw new Error(`HTTP error! status: ${response.status}`);
    	}
    	let data = await response.text();
    	let parser = new window.DOMParser();
    	let doc = parser.parseFromString( data, "text/xml" );
        // importoidaan saatu xml-dokumentti tähän dokumenttiin
        let select = document.importNode(doc.documentElement, 1);
        document.querySelector("#oppilaitosryhma").replaceWith(select);
        
    }
  2. Kutsu hae_ryhmat-funktiota sivun window.onload-tapahtumassa, niin oppilaitosryhmälistaus päivittyy heti sivun latauduttua.
    hae_ryhmat().catch(e => {
        console.log('Virhe ryhmien hakemisessa: ' + e.message); 
      });

Oppilaitoksen vaihtaminen oppilaitosryhmän perusteella

Tilaajan lisääminen

Malliratkaisu async/await-versio, javascript-lähdekoodi (async/await)

Lähdekoodi (flask / python), ryhmat.xml (template)

HTMX

HTMX-kirjaston avulla pystyy toteuttamaan edellä tehdyn oppilaitossovelluksen kirjoittamatta riviäkään javascript-koodia. Kts. mallisovellus, joka toimii samaan tapaan kuin aiemmin toteutettu virheilmoituksia lukuunottamatta. Tutki erityisesti sovelluksen html-lähdekoodia. Palvelimella toimivaan sovellukseen täytyi tehdä hieman muutoksia. Kts. Lähdekoodi (flask / python), _htmx-päätteiset funktiot, ryhmat.htmx (template)

Myös virheilmoitukset olisi mahdollista saada toimimaan htmx:n avulla. Keksitkö miten?

Firebase, Firestore ja javascript-sovellus

Web Sockets

Tutustu web socketien käyttämiseen Flask-SocketIO (Flask) ja Socket.IO (Javascript) -kirjastojen avulla. Yksinkertaisimmillaan pääset alkuun, kun kokeilet tehdä pienen chatin. Samat asiat käydään läpi kunnolla selitettyinä Flask-SocketIO:n Gettin Started -dokumentissa. Yritä saada pieni chat-toimimaan ja kokeile sitä useammalla eri selaimella samaan aikaan.

Chatin voi rakentaa suoraan Firestoren päälle. Kaikista kätevimmin se toimisi, jos käytettäisiin suoraan Firestoren omaa javascript-rajapintaa, mutta kokeile tehdä oma web socketien päälle:

from flask import Flask, render_template, request
from flask_socketio import SocketIO, send, emit, join_room, leave_room
import firebase_admin
from firebase_admin import credentials
from firebase_admin import firestore
import threading
from datetime import datetime

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your_secret_key'

socketio = SocketIO(app)

cred = credentials.Certificate('service.json') #firestoren kredentiaalit
firebase_admin.initialize_app(cred)

db = firestore.client()

# Tarvitaan säie, joka seuraa firestoren tapahtumia
# ei pitäisi olla tehokkuusongelma, koska näitä tulee vain yksi per instanssi
callback_done = threading.Event()

#seurataan firestoren muutoksia
def on_snapshot(doc_snapshot, changes, read_time):
    for change in changes:
        if change.type.name == 'ADDED':
            # viestin timestamp täytyy muuntaa stringiksi, jotta muunnos json-muotoon onnistuu
            data = change.document.to_dict()
            #pätkäistään ISO-muodosta ylimääräiset pois
            data['timestamp'] = data['timestamp'].isoformat().split(".")[0]
            #lähetetään viesti. Oletuksena menee json-muodossa
            socketio.emit("message", data, include_self=True)
    callback_done.set()

#järjestetään viestit aikaleiman mukaan
messages = db.collection("chat").order_by("timestamp", direction=firestore.Query.ASCENDING)

#seurataan muutoksia
doc_watch = messages.on_snapshot(on_snapshot)


@app.route('/')
def index():
    return render_template('index.html')

@socketio.on('join')
def handle_join(username):
    doc_ref = db.collection('chat').document()
    data = {"nick": username, 
        "message": "Saapuu paikalle",
        data['timestamp'] = datetime.now() #kannattaisiko käyttää selaimen aikaleimaa?
    }
    doc_ref.set(data)

@socketio.on('message')
def handle_message(data):
    doc_ref = db.collection('chat').document()
    data['timestamp'] = datetime.now() #kannattaisiko käyttää selaimen aikaleimaa?
    doc_ref.set(data)

@socketio.on('disconnect')
def handle_disconnect():
    doc_ref = db.collection('chat').document()
    data = {"nick": username, "message": "Poistui chatista"}
    data['timestamp'] = datetime.now()
    doc_ref.set(data)

if __name__ == '__main__':
    socketio.run(app, debug=True)
Sovellukseen tarvitset myös seuraavaan html- ja javascript-koodin:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Chat-sovellus</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
    <script>
    window.addEventListener("load", function() {
        let socket = io();
        let username = prompt("Nick:");  // Kysytään käyttäjän nick chattia varten
        socket.emit("join", username);  
        
        let messages = document.getElementById("messages");

        // Kuunnellaan palvelimelta tulevia viestejä
        socket.on("message", function(data) {
            let msg = document.createElement("p");
            let n = document.createElement("strong");
            msg.appendChild(n);
            n.textContent = data.timestamp + "<" + data.nick + "> ";
            msg.appendChild( document.createTextNode(data.message) );
            messages.appendChild(msg);
        });

        let msgInput = document.getElementById("msg");

        let sendMessage = function() {
            let message = {'nick': username, 'message': msgInput.value}
            socket.send(message);
            msgInput.value = "";
        }
        document.querySelector("button").addEventListener("click", sendMessage);
    });
    </script>
</head>
<body>
    <h2>Chat-sovellus</h2>
    <input id="msg" type="text" placeholder="Kirjoita viesti">
    <button>Lähetä</button>
    <div id="messages"></div>
</body>
</html>

Miten chat-sovellusta pitäisi tästä eteenpäin parannella? Mieti ratkaisutapoja ainakin seuraaviin ongelmiin. Osaa näistä on käsitelty Flask-SocketIO:n dokumentaatiossa.

Flask Web Sockets

Flask-SocketIO

The Async Core: Understanding Eventlet and Gevent in Flask-SocketIO

Firestoren hierarkiat ja transaktiot

Lisätietoa

Vanhoja versioita:
Yksinkertaisin ajax-malli (jquery), flask-osuuden lähdekoodi
vanha malliratkaisu Lähdekoodi (jQuery)

Käyttäjien kommentit

Kommentoi Lisää kommentti