Fetch, XML, htmx, Firestore ja WebSocketit
Tässä ohjauksessa tehdään lomake, joka hakee tietoja palvelimelta Fetch-rajapinnalla sekä JSON- että XML-muodossa. Sama lomake toteutetaan myös htmx-kirjastolla ilman omaa javascriptiä. Lopuksi kokeillaan Firestoren javascript-rajapintaa ja tehdään pieni chat WebSocketeilla.
Ohjaus perustuu luentoihin Palvelinpuolen rajapinnat: fetch, CORS ja htmx, XML ja ulkopuoliset datalähteet palvelimella ja Avoimet yhteydet: SSE ja WebSocket. Jos javascript ja promiset eivät ole ennestään tuttuja, kertaa TIEA2120-kurssin luento Fetch ja asynkroniset funktiot ja MDN:n artikkeli Using promises.
Malliratkaisu
Kokeile ensin valmista sovellusta. Syötä postinumeroksi 40740, niin sovellus täyttää postitoimipaikan. Vaihda oppilaitosryhmää, niin oppilaitosluettelo vaihtuu. Lisää tilaaja ja kokeile myös jättää kenttiä tyhjiksi.
- fetch-versio ja htmx-versio
- ohjaus5-malli.zip – malliratkaisun lähdekoodi ja testit
- pohja.zip – pohja, josta aloitat
- chat-malli.zip – chatin malliratkaisut WebSocketilla ja Socket.IO:lla
Tietokanta ja pohja
Pura pohja hakemistoon, jossa Flask-sovelluksesi on, esimerkiksi users.jyu.fi-palvelimen hakemistoon cgi-bin/ties4080/ohjaus5/. Pohjassa on:
- app.py – Flask-sovellus, joka avaa tietokannan ja palvelee sivun
- oppilaitos.sql – tietokanta, joka luodaan ensimmäisellä pyynnöllä
- templates/ohjaus5.xhtml – lomakesivu
- static/osoite.js ja static/tyylit.css
- flask.cgi – käynnistin users.jyu.fi-palvelimelle
SQLite tarkistaa viiteavaimet vain, jos se on erikseen pyydetty. Pohjan
tietokanta()-funktio antaa siksi jokaiselle yhteydelle komennon
PRAGMA foreign_keys = ON. Ilman sitä tietokantaan voisi tallentaa tilaajan,
jonka postinumeroa ei ole olemassa.
Sivun osoitteet ovat suhteellisia: kun sivu on osoitteessa …/flask.cgi/, osoite postitoimipaikka tarkoittaa …/flask.cgi/postitoimipaikka. Näin koodiin ei tarvitse kirjoittaa omaa käyttäjätunnusta.
Flask ja JSON
Tehdään ensin reitti, joka palauttaa kaikki postitoimipaikat JSON-muodossa. Flask muuntaa
listan ja sanakirjat JSONiksi itse, kun ne palautetaan näkymästä, ja asettaa
mediatyypiksi application/json:
@app.get("/postitoimipaikat")
def postitoimipaikat():
rivit = tietokanta().execute(
"SELECT postinro, postitoimipaikka FROM Postitoimipaikka ORDER BY postinro").fetchall()
# sqlite3.Row ei ole JSON-muotoinen, joten rivit muunnetaan sanakirjoiksi.
return [dict(r) for r in rivit]
Kts. malli.
Tee sitten reitti postitoimipaikka, joka palauttaa vain kyselyparametrina annetun postinumeron. Esimerkiksi osoite …/flask.cgi/postitoimipaikka?postinro=40740 palauttaa
[{"postinro": "40740", "postitoimipaikka": "Jyv\u00e4skyl\u00e4"}]
Flask kirjoittaa muut kuin ASCII-merkit oletuksena koodattuina (\u00e4 on
ä). Se on täsmälleen sama JSON kuin "Jyväskylä", ja selaimen
JSON.parse tuottaa kummastakin saman merkkijonon.
Varmista, että sovellus ei kaadu, jos postinumeroa ei anneta tai sitä ei löydy tietokannasta. Palauta silloin tyhjä lista: tuntematon postinumero ei ole palvelimen virhe. Muista käyttää parametrisoitua kyselyä.
Flask ja XML
Toteuta samaan tapaan oppilaitosryhmien hakeminen, mutta palauta ne
select-elementtinä, jonka voi liittää suoraan sivulle. Tee XML
Jinja-templatella templates/ryhmat.xml:
<?xml version="1.0" encoding="UTF-8"?>
<select id="{{ name }}" name="{{ name }}" xmlns="http://www.w3.org/1999/xhtml">
{% for o in ryhmat %}
<option value="{{ o['ryhmaid'] }}"{% if loop.first %} selected="selected"{% endif %}>{{ o['ryhmanimi'] }}</option>
{% endfor %}
</select>
Sarakkeiden nimet ovat tietokannassa RyhmaID ja RyhmaNimi.
sqlite3.Row ei välitä kirjainkoosta, joten o['ryhmanimi']
toimii. Nimiavaruus xmlns on pakollinen: ilman sitä selain ei tunnista
elementtiä XHTML:n select-elementiksi, kun se liitetään sivulle.
@app.get("/hae_ryhmat")
def hae_ryhmat():
ryhmat = tietokanta().execute(
"SELECT RyhmaID, RyhmaNimi FROM Oppilaitosryhma ORDER BY RyhmaNimi").fetchall()
xml = render_template("ryhmat.xml", ryhmat=ryhmat, name="oppilaitosryhma")
return Response(xml, mimetype="application/xml")
Kts. malli. XML:n mediatyyppi on
application/xml. Merkistöä ei tarvitse asettaa erikseen: Flask lisää
tekstimuotoisiin vastauksiin charset=utf-8 itse, ja XML-esittely kertoo
merkistön dokumentin sisällä.
Flask suojaa templateissa merkit automaattisesti vain, kun tiedoston pääte on
.html, .htm, .xml, .xhtml tai
.svg. Jos templaten nimi päättyy muuhun, esimerkiksi .htmx,
tietokannasta tuleva <-merkki päätyy sivulle sellaisenaan.
XML ElementTreellä
Jinja ei tiedä tuottavansa XML:ää, joten se ei voi taata, että tulos on
hyvinmuodostunut. ElementTree
rakentaa puun elementti kerrallaan ja suojaa merkit itse. Nimiavaruus annetaan
elementin nimessä aaltosulkeissa, ja register_namespace tekee siitä
oletusnimiavaruuden:
from xml.etree import ElementTree
XHTML = "http://www.w3.org/1999/xhtml"
ElementTree.register_namespace("", XHTML)
select = ElementTree.Element(f"{{{XHTML}}}select", {"id": "oppilaitosryhma",
"name": "oppilaitosryhma"})
for ryhma in ryhmat:
option = ElementTree.SubElement(select, f"{{{XHTML}}}option",
{"value": str(ryhma["ryhmaid"])})
option.text = ryhma["ryhmanimi"]
xml = ElementTree.tostring(select, encoding="unicode", xml_declaration=True)
return Response(xml, mimetype="application/xml")
Sama minidomilla
minidom toteuttaa
selaimesta tutun DOM-rajapinnan, vrt. Javascript ja DOM.
minidom ei kirjoita nimiavaruuden määrittelyä ulos, vaikka nimiavaruus annetaan
createDocument-kutsussa, joten xmlns-attribuutti on lisättävä
itse. Lisää XML:n käsittelystä XML-luennolla.
from xml.dom.minidom import getDOMImplementation
doc = getDOMImplementation().createDocument(XHTML, "select", None)
select = doc.documentElement
select.setAttribute("xmlns", XHTML)
select.setAttribute("id", "oppilaitosryhma")
select.setAttribute("name", "oppilaitosryhma")
for ryhma in ryhmat:
option = doc.createElement("option")
option.setAttribute("value", str(ryhma["ryhmaid"]))
option.appendChild(doc.createTextNode(ryhma["ryhmanimi"]))
select.appendChild(option)
return Response(doc.toxml(encoding="UTF-8"), mimetype="application/xml")
Oppilaitokset
Toteuta oppilaitosten hakeminen. Rajaa oppilaitokset kyselyparametrilla
ryhmaid; jos sitä ei anneta, palauta kaikki. Jos parametri ei ole
kokonaisluku, vastaa tilakoodilla 400. Palauta oppilaitokset omassa XML-muodossa ja
rakenna se ElementTreellä:
<oppilaitokset>
<oppilaitos id="1">Helsingin kauppakorkeakoulu</oppilaitos>
<oppilaitos id="2">Helsingin yliopisto</oppilaitos>
...
</oppilaitokset>
Kts. malli.
Javascript, Fetch ja JSON
Täydennä tiedostoa static/osoite.js. Toteutetaan ensin postitoimipaikan täyttäminen postinumeron perusteella.
- Lisää postinumerokentän
change-tapahtumaan käsittelijähae_postitoimipaikka. - Kirjoita funktio, joka hakee postitoimipaikan aiemmin tekemästäsi osoitteesta:
async function hae_postitoimipaikka() { const kentta = document.forms.lomake.elements.postitoimipaikka; // Suhteellinen osoite muunnetaan kokonaiseksi sivun osoitteen perusteella. const url = new URL(osoitteet.postitoimipaikka, location.href); // GET-pyynnön parametrit lisätään osoitteeseen: ?postinro=40740 url.searchParams.set("postinro", document.forms.lomake.elements.postinumero.value); try { const vastaus = await fetch(url, { headers: { "Accept": "application/json" } }); if (!vastaus.ok) { throw new Error(`HTTP-virhe ${vastaus.status}`); } // Datan rakenne on juuri se, jonka palvelin muodosti: lista. const data = await vastaus.json(); kentta.value = data.length > 0 ? data[0].postitoimipaikka : "tuntematon"; } catch (virhe) { // Verkkovirheet ja HTTP-virheet päätyvät tänne. kentta.value = ""; console.log("Virhe postitoimipaikan hakemisessa: " + virhe.message); } } - Kokeile sovellusta. Katso kehittäjätyökalujen Network-välilehdeltä, mitä tapahtuu, kun vaihdat postinumerokentän sisältöä, ja tarkista konsolin tulosteet.
Fetch ja XML
Toteutetaan seuraavaksi oppilaitoksen valinta oppilaitosryhmän perusteella.
- Tee funktio, joka hakee XML-dokumentin ja jäsentää sen.
DOMParserei heitä poikkeusta virheellisestä XML:stä vaan palauttaa virhedokumentin, joten jäsennyksen onnistuminen on tarkistettava itse:async function hae_xml(url) { const vastaus = await fetch(url, { headers: { "Accept": "application/xml" } }); if (!vastaus.ok) { throw new Error(`HTTP-virhe ${vastaus.status}`); } const doc = new DOMParser().parseFromString(await vastaus.text(), "application/xml"); if (doc.querySelector("parsererror")) { throw new Error("Palvelimen XML ei ole hyvinmuodostunutta"); } return doc; } - Tee funktio
hae_ryhmat, joka korvaa sivun tyhjänselect-elementin palvelimen palauttamalla:async function hae_ryhmat() { const doc = await hae_xml(new URL(osoitteet.ryhmat, location.href)); // Tuodaan elementti tähän dokumenttiin ja korvataan sillä tyhjä select. const select = document.importNode(doc.documentElement, true); document.getElementById("oppilaitosryhma").replaceWith(select); select.addEventListener("change", hae_oppilaitokset); await hae_oppilaitokset(); } - Kutsu
hae_ryhmat-funktiota sivunload-tapahtumassa:hae_ryhmat().catch(e => console.log("Virhe ryhmien hakemisessa: " + e.message)); - Tee funktio
hae_oppilaitokset, joka hakee valitun ryhmän oppilaitokset (parametriryhmaid) ja täyttää oppilaitosluettelon. Luo jokaisestaoppilaitos-elementistäoption-elementti; nimi asetetaantextContent-ominaisuuteen ja tunnistevalue-ominaisuuteen.
Mieti, miksi hae_oppilaitokset-käsittelijää ei voi kytkeä
load-tapahtumassa kuten postinumeron käsittelijää, vaan vasta
hae_ryhmat-funktiossa. Vihje: fetch-kutsut ovat
asynkronisia,
ja ryhmien select-elementti korvataan kokonaan uudella.
Tilaajan lisääminen
Lisäys tehdään progressiivisen parantamisen periaatteella: ensin lomake toimii ilman javascriptiä, sitten sama osoite palvelee myös fetch-pyyntöä.
- Näytä lomakkeen alla tietokannan tilaajat. Hae ne reitistä tilaajat, joka
palauttaa JSONin, ja lataa luettelo sivun latautuessa. Luo luettelon rivit
textContent-ominaisuudella, jolloin käyttäjän syöttämä teksti ei voi muuttua HTML:ksi. - Tee lomakkeesta tavalliseen tapaan toimiva: reitti lisaa_tilaaja
vastaanottaa POST-pyynnön, tarkistaa tiedot ja tallentaa tilaajan. Onnistuessa se ohjaa
takaisin lomakkeelle tilakoodilla 303; virheissä se näyttää sivun virheilmoituksineen
tilakoodilla 422.
- Etu- ja sukunimi sekä lähiosoite ovat pakollisia.
- Postinumeron on löydyttävä tietokannasta.
- Oppilaitos on valinnainen, mutta annetun on oltava olemassa.
- Lisää samaan reittiin vastaus fetch-pyynnöille. Kun pyynnön
Accept-otsake pyytää JSONia, palauta onnistuessa tilakoodi 201 ja uuden tilaajan osoiteLocation-otsakkeessa, ja virheissä tilakoodi 422 ja kenttäkohtaiset virheet (kts. tilakoodit):{"virhe": "Tarkista tiedot", "kentat": {"etunimi": "Etunimi puuttuu"}} - Muuta lomakkeen lähetys fetch-kutsuksi. Tilakoodi 422 on odotettu tilanne, joka
käsitellään ennen muita virheitä. Pohjassa jokaisen kentän vieressä on
span-elementti, jonka tunniste on kentän nimi ja pääte_error, joten virheet on helppo sijoittaa oikeaan paikkaan:async function lisaa_tilaaja(e) { // Tavallinen lähetys estetään vasta, kun tiedetään, että javascript toimii. e.preventDefault(); const lomake = e.target; const vastaus = await fetch(lomake.action, { method: "POST", headers: { "Accept": "application/json" }, body: new FormData(lomake) // koko lomakkeen sisältö }); const data = await vastaus.json(); if (vastaus.status === 201) { nayta_virheet({}); lomake.reset(); await hae_tilaajat(); } else if (vastaus.status === 422) { nayta_virheet(data.kentat); // esim. #etunimi_error } else { throw new Error(`HTTP-virhe ${vastaus.status}`); } }
Lomake ei käytä kirjautumista eikä istuntoa, joten CSRF-suojausta ei tarvita. Kun lomakkeen lähettäminen vaatii kirjautumisen, tarvitaan CSRF-tunniste.
htmx 4
htmx-kirjastolla saman sovelluksen voi toteuttaa
kirjoittamatta riviäkään javascriptiä. Palvelin palauttaa JSONin ja XML:n sijaan valmiita
HTML-palasia, ja htmx sijoittaa ne sivulle attribuuttien ohjeiden mukaan. Kts.
mallisovellus ja sen lähdekoodi paketissa
ohjaus5-malli.zip (templates/htmx/ ja
htmx_-alkuiset funktiot). Taustaa on
fetch-luennon htmx-luvussa.
Postinumerokenttä hakee postitoimipaikan ja korvaa sillä postitoimipaikkakentän. Kentän oma arvo lähtee pyynnössä mukaan kyselyparametrina, koska kentällä on nimi:
<input type="text" id="postinumero" name="postinumero"
hx-get="htmx/postitoimipaikka" hx-trigger="change"
hx-target="#postitoimipaikka" hx-swap="outerHTML" />
Oppilaitosryhmän vaihto hakee oppilaitokset samalla tavalla. Lomakkeen lähetys korvaa koko lomakkeen palvelimen palauttamalla versiolla:
<form id="lomake" action="lisaa_tilaaja" method="post"
hx-post="htmx/lisaa_tilaaja" hx-target="this" hx-swap="outerHTML">
- Virheet. Jos tiedoissa on virheitä, palvelin palauttaa lomakkeen virheilmoituksineen tilakoodilla 422. htmx 4 sijoittaa myös 4xx-vastaukset sivulle, joten virheilmoitukset näkyvät ilman erillistä koodia. htmx 2 jätti ne huomiotta.
- Tilaajaluettelon päivitys. Onnistuneen lisäyksen vastauksessa on
tyhjennetyn lomakkeen lisäksi
hx-partial-elementti, joka päivittää luettelon samalla vastauksella:<hx-partial hx-target="#tilaajat" hx-swap="innerHTML"> <li>Ada Lovelace, Katu 1, 40740 Jyväskylä</li> ... </hx-partial> - Toimii ilman javascriptiä. Lomakkeella on
hx-post-attribuutin rinnalla tavallinenaction, joten lähetys toimii myös silloin, kun htmx ei ole käytössä. - Mediatyyppi. htmx-sivu on tarjoiltava tyypillä
text/html, koska htmx ei tunnista lomakkeita XHTML-mediatyypillä tarjoillulla sivulla. Merkkauksen voi silti kirjoittaa polyglottina XHTML5:nä. - Merkkien suojaus. Nimeä htmx-palasten templatet .html-päätteisiksi, jotta Jinja suojaa niissä merkit.
Vertaa fetch- ja htmx-versioiden lähdekoodeja: palvelimen tiedot ja tarkistukset ovat samat, mutta htmx-versiossa näkymä muodostetaan kokonaan palvelimella.
Firebase, Firestore ja javascript-sovellus
- Project settings -sivulla voit lisätä projektiisi web-sovelluksen. Valitse Your apps-kohdasta web-sovelluksen kuvake.
- Keksi sovellukselle lempinimi. Älä valitse Firebase Hostingia.
- Valitse CDN. Saat valmiin koodin, jossa on projektisi asetukset
(
firebaseConfig). Googlen ohje käyttää<script type="module">-elementtiä, joka ei toimi Chromessa XHTML-sivulla; kts. Chrome ja moduuliskriptit. Kirjoita koodi sen sijaan tavalliseen javascript-tiedostoon ja lataa kirjastotimport()-kutsulla. Kokeile dokumenttien luomista ja listaamista:"use strict"; const VERSIO = "12.10.0"; // sama versio kaikille Firebasen moduuleille const firebaseConfig = { apiKey: "...", authDomain: "...", projectId: "...", storageBucket: "...", messagingSenderId: "...", appId: "..." }; window.addEventListener("load", async function () { const { initializeApp } = await import(`https://www.gstatic.com/firebasejs/${VERSIO}/firebase-app.js`); const { getFirestore, doc, setDoc, addDoc, collection, getDocs } = await import(`https://www.gstatic.com/firebasejs/${VERSIO}/firebase-firestore.js`); const db = getFirestore(initializeApp(firebaseConfig)); // Dokumentti omalla tunnisteella await setDoc(doc(db, "users", "alovelace"), { first: "Ada", last: "Lovelace", born: 1815 }); // Dokumentti automaattisella tunnisteella const uusi = await addDoc(collection(db, "users"), { first: "Alan", middle: "Mathison", last: "Turing", born: 1912 }); console.log("Lisättiin dokumentti", uusi.id); // Listaus. await-sanojen ansiosta edellä lisätyt näkyvät jo tässä. const tulos = await getDocs(collection(db, "users")); tulos.forEach(d => console.log(`${d.id} => ${JSON.stringify(d.data())}`)); });Kts. Add data to Cloud Firestore ja Get data with Cloud Firestore; käytä ohjeiden Web modular API -versiota. Kirjoittaminen onnistuu tässä vain, jos tietokannan säännöt sallivat sen; viikkotehtävässä selain saa ainoastaan lukea.
- Tutki tietokantasi Rules-sivua,
jolla säädetään tietokannan
käyttöoikeuksia.
Testitilassa luotuun tietokantaan on kaikilla luku- ja kirjoitusoikeus rajatun ajan.
Säännöt koskevat vain selaimesta tehtyjä pyyntöjä; palvelimen Admin SDK ohittaa ne.
Käytännössä javascriptille kannattaa sallia vain lukeminen niistä tiedoista, jotka ovat
sovelluksessa julkisia, ja hoitaa muu palvelimen kautta.
Vain kirjautuneille:
service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if request.auth != null; } } }Kaikki kielletty:
service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if false; } } }Kaikki sallittu – älä koskaan käytä tuotannossa:
service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if true; } } }
Chrome ja moduuliskriptit
Firebasen javascript-kirjasto on jaettu ES-moduuleiksi, jotka ladataan
import-lauseella. Chrome ei kuitenkaan suorita XHTML-sivulla
(application/xhtml+xml) lainkaan moduuliskriptejä: konsoliin tulee ilmoitus
Module scripts in XML documents are currently not supported, eikä koodia ajeta.
Firefox suorittaa ne.
Ratkaisu on dynaaminen import(). Se on tavallisen
skriptin sisällä käytettävä funktio, joka lataa moduulin ja palauttaa lupauksen, ja se
toimii Chromessa myös XHTML-sivulla:
<!-- ei toimi Chromessa XHTML-sivulla -->
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js";
</script>
<!-- toimii: tavallinen skripti -->
<script src="static/sovellus.js"></script>
// static/sovellus.js
window.addEventListener("load", async function () {
const { initializeApp } =
await import("https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js");
// ...
});
Tavallisessa skriptissä ei voi käyttää await-sanaa ylimmällä tasolla, joten
koodi kirjoitetaan async-funktion sisään, esimerkiksi sivun
load-tapahtuman käsittelijään.
Reaaliaikaiset päivitykset
Viikkotehtävän 5 tasolla 5 etusivun kilpailulistauksen on päivityttävä, kun joku muu
muuttaa tietokantaa. Firestore hoitaa tämän itse: onSnapshot kutsuu
annettua funktiota heti ensimmäisellä kerralla ja sen jälkeen aina, kun kyselyn tulos
muuttuu. Selain pitää Firestoreen avoinna yhteyttä, joten tätä ei tarvitse toteuttaa
omalla palvelimella.
window.addEventListener("load", async function () {
const { initializeApp } =
await import("https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js");
const { getFirestore, collection, query, orderBy, onSnapshot } =
await import("https://www.gstatic.com/firebasejs/12.10.0/firebase-firestore.js");
const db = getFirestore(initializeApp(firebaseConfig));
const lista = document.getElementById("kilpailut");
const kysely = query(collection(db, "kilpailut"), orderBy("alkuaika", "desc"));
// Palauttaa funktion, jolla kuuntelun voi lopettaa.
const lopeta = onSnapshot(kysely, tulos => {
const rivit = tulos.docs.map(d => {
const li = document.createElement("li");
li.textContent = `${d.data().kisanimi} ${d.data().alkuaika}`;
return li;
});
lista.replaceChildren(...rivit);
}, virhe => {
console.log("Kuuntelu epäonnistui: " + virhe.message);
});
});
- Kokoelman ja kenttien nimet (
kilpailut,kisanimi,alkuaika) ovat esimerkkejä; käytä omassa tietokannassasi olevia. - Kokeile avaamalla sivu kahteen selaimeen ja muuttamalla kilpailun tietoja Firestoren ylläpitonäkymässä tai toisessa selaimessa.
- Jokainen muuttunut dokumentti lasketaan lukuoperaatioksi. Kuuntele siksi vain sitä, mitä sivu todella näyttää.
- Kuuntelu toimii vain, jos tietokannan säännöt sallivat selaimen lukea kyseisen
kokoelman. Salli lukeminen vain julkisista tiedoista:
Säännöt koskevat vain selaimesta tehtyjä pyyntöjä. Palvelimen Admin SDK ohittaa ne, joten Flask-sovellus voi edelleen kirjoittaa kaikkiin kokoelmiin.rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { // Kilpailujen tiedot ovat julkisia: selain saa lukea mutta ei kirjoittaa. match /kilpailut/{kilpailu} { allow read: if true; allow write: if false; } // Kaikki muu, esimerkiksi joukkueiden omistajien sähköpostiosoitteet, // luetaan ja kirjoitetaan vain palvelimen kautta. match /{document=**} { allow read, write: if false; } } }
WebSocketit
Tehdään pieni chat, jossa viestit välittyvät kaikille avoimille selaimille heti. Toteutetaan se kahdella tavalla ja verrataan:
- Selaimen oma
WebSocket-rajapinta ja palvelimella flask-sock. Viestien muoto määritellään itse, ja uudelleenyhteys on kirjoitettava itse. - Socket.IO ja palvelimella
Flask-SocketIO. Socket.IO on oma
protokollansa WebSocketin päällä: se välittää nimettyjä tapahtumia, yhdistää uudelleen
itse ja käyttää tarvittaessa tavallisia HTTP-pyyntöjä. Selaimen
WebSocket-olio ei pysty puhumaan Socket.IO-palvelimelle eikä toisin päin.
Malliratkaisut ovat paketissa chat-malli.zip. Pitkäkestoiset yhteydet eivät toimi CGI:nä, joten aja chattia omalla koneellasi (python3 app.py) ja avaa se kahteen selainikkunaan. Taustaa on WebSocket-luvussa.
Selaimen oma WebSocket
Viestit ovat JSON-objekteja, joiden tyyppi kertoo, mistä on kyse.
Nimimerkki lähetetään vain liityttäessä, ja palvelin sitoo sen yhteyteen. Myöhemmät viestit
eivät siis voi väittää olevansa joltain muulta:
from flask_sock import ConnectionClosed, Sock
sock = Sock(app)
yhteydet = set()
@sock.route("/ws")
def ws(yhteys):
nick = None
try:
while True:
data = json.loads(yhteys.receive())
if data.get("tyyppi") == "liity" and nick is None:
nick = str(data.get("nick", "")).strip()[:30]
yhteydet.add(yhteys)
laheta_kaikille(viesti(nick, "saapuu paikalle"))
elif data.get("tyyppi") == "viesti" and nick is not None:
teksti = str(data.get("teksti", "")).strip()
if 0 < len(teksti) <= 500:
laheta_kaikille(viesti(nick, teksti))
except ConnectionClosed:
pass
finally:
yhteydet.discard(yhteys)
if nick is not None:
laheta_kaikille(viesti(nick, "poistui"))
Mallissa on lisäksi Origin-otsakkeen tarkistus: same-origin policy ei koske
WebSocket-kättelyä, joten muuten mikä tahansa sivusto voisi avata yhteyden chattiin.
Selaimessa yhteys avataan ja uudelleenyhteys tehdään itse:
function avaa() {
const osoite = new URL(document.body.dataset.ws, location.href);
osoite.protocol = location.protocol === "https:" ? "wss:" : "ws:";
yhteys = new WebSocket(osoite);
yhteys.addEventListener("open", () => {
viive = 1000;
yhteys.send(JSON.stringify({ tyyppi: "liity", nick: nick }));
});
yhteys.addEventListener("message", e => nayta(JSON.parse(e.data)));
yhteys.addEventListener("close", () => {
setTimeout(avaa, viive); // uusi yritys kasvavalla viiveellä
viive = Math.min(viive * 2, 30000);
});
}
Socket.IO
Socket.IO:ssa viestit ovat nimettyjä tapahtumia, ja kirjasto tunnistaa yhteydet
tunnisteella request.sid:
from flask_socketio import SocketIO, emit
socketio = SocketIO(app) # sallii oletuksena vain saman lähteen
nimimerkit = {}
@socketio.on("liity")
def liity(nick):
nimimerkit[request.sid] = str(nick or "").strip()[:30]
laheta_kaikille(viesti(nimimerkit[request.sid], "saapuu paikalle"))
@socketio.on("viesti")
def vastaanota(teksti):
nick = nimimerkit.get(request.sid)
teksti = str(teksti or "").strip()
if nick is not None and 0 < len(teksti) <= 500:
laheta_kaikille(viesti(nick, teksti))
@socketio.on("disconnect")
def poistu(*_):
nick = nimimerkit.pop(request.sid, None)
if nick is not None:
laheta_kaikille(viesti(nick, "poistui"))
Selaimessa Socket.IO yhdistää itse uudelleen; liittyminen lähetetään uudelleen
jokaisessa connect-tapahtumassa:
const socket = io();
socket.on("connect", () => socket.emit("liity", nick));
socket.on("viesti", nayta);
socket.emit("viesti", "Hei kaikki");
Chatin jatkokehitys
Molemmat mallit sitovat nimimerkin yhteyteen, tarkistavat viestien koon, muistavat 50
viimeisintä viestiä ja näyttävät viestit textContent-ominaisuudella. Mieti,
miten ratkaisisit seuraavat:
- Pitäisikö nimimerkki yhdistää kirjautuneeseen käyttäjään? Nyt kuka tahansa voi valita minkä tahansa nimimerkin.
- Mitkä viestit liittyvälle käyttäjälle pitäisi näyttää? Entä jos hän poistuu välillä ja palaa myöhemmin?
- Miten viestit säilyisivät palvelimen uudelleenkäynnistyksen yli? Kokeile tallentaa ne
Firestoreen ja seurata muutoksia
on_snapshot-funktiolla. - Voisiko viestiin vastata niin, että syntyy viestiketju?
- Miten tekisit erilliset chat-huoneet? Socket.IO:ssa on siihen valmiit huoneet.
- Käy läpi WebSocket-luvun tietoturvaohjeet: mitkä niistä mallit jo täyttävät ja mitkä puuttuvat?
Firestoren hierarkiat ja transaktiot
- Firestoressa dokumentilla voi olla alikokoelmia, jolloin syntyy hierarkia. Hierarkia
kannattaa, kun tiedot kuuluvat luontevasti toisen tiedon alle ja niitä haetaan yleensä sen
kautta, esimerkiksi joukkueen jäsenet. Oletuksena voit kuitenkin tehdä rakenteen
relaatiotietokannan tapaan erillisinä kokoelmina, joita yhdistävät avainkentät.
Transaktiot eivät vaadi hierarkiaa: yksi transaktio voi lukea ja muuttaa mitä tahansa dokumentteja, myös eri kokoelmista. Lue Transactions and batched writes. Transaktiota tarvitaan, kun samalla kertaa on muutettava useaa dokumenttia, esimerkiksi joukkuetta ja sen jäseniä, jos jäsenet ovat omina dokumentteinaan. Jos jäsenet tallennetaan samaan dokumenttiin joukkueen kanssa, kuten aiemmin relaatiotietokannassa JSON-muodossa, transaktiota ei tarvita, koska yhden dokumentin muutos on aina kokonainen.
Luodaan malliksi hierarkinen reseptitietokanta. Se ei ole paras esimerkki, koska reseptit toimisivat hyvin ilman hierarkiaakin.
ruokalajit = [ {"id": 1, "nimi": "Alkuruoka", "reseptit": []}, {"id": 2, "nimi": "Pääruoka", "reseptit": [ {"nimi": "Ananas Con Carne", "kuvaus": "Tulinen", "henkilomaara": 4}, {"nimi": "Banaanicurry", "kuvaus": "Mainio kasvisruoka", "henkilomaara": 2} ]}, {"id": 3, "nimi": "Lisäkeruoka", "reseptit": []}, {"id": 4, "nimi": "Väli- tai iltapala", "reseptit": []}, {"id": 5, "nimi": "Jälkiruoka", "reseptit": []}, {"id": 6, "nimi": "Suolainen leivonnainen", "reseptit": []}, {"id": 7, "nimi": "Makea leivonnainen", "reseptit": [ {"nimi": "Tiramisu", "kuvaus": "Hieno herkku", "henkilomaara": 6}, {"nimi": "Porkkanapiirakka", "kuvaus": "Suuri herkku", "henkilomaara": 8}, {"nimi": "Rommikakku", "kuvaus": "Hyytelökakku, ei tarvi paistaa", "henkilomaara": 6}, {"nimi": "Kauralastut", "kuvaus": "Helppo ja nopea", "henkilomaara": 4} ]} ] for ruokalaji in ruokalajit: # ruokalajit omilla tunnisteilla (id) rlaji = db.collection("ruokalaji").document(str(ruokalaji["id"])) rlaji.set({"nimi": ruokalaji["nimi"]}) # vaihtoehto 1: reseptit automaattisilla tunnisteilla # for resepti in ruokalaji["reseptit"]: # db.collection("ruokalaji", rlaji.id, "reseptit").document().set(resepti) # vaihtoehto 2: omat tunnisteet, jolloin testaaminen on helpompaa. # Huom. eri ruokalajien reseptit voivat saada saman tunnisteen. for numero, resepti in enumerate(ruokalaji["reseptit"], start=1): db.collection("ruokalaji", rlaji.id, "reseptit").document(str(numero)).set(resepti)Kokeile molempia tapoja luoda reseptit ja tutki, miten ne näkyvät Firestoren ylläpitonäkymässä. Poista samojen reseptien kaksoiskappaleet ylläpitonäkymän kautta.
- Aina ei ole käytettävissä valmista yksilöivää tietoa. Firestore osaa luoda dokumentille
yksilöllisen tunnisteen itse:
# luo uuden, vielä tyhjän dokumentin doc_ref = db.collection("tyyppi").document() # tunnisteen saa id-ominaisuudesta tunniste = doc_ref.id # sisällön voi tallentaa set-metodilla doc_ref.set(omadata) - Listaa kaikki ylätason kokoelmat:
for kokoelma in db.collections(): for doc in kokoelma.stream(): print(f"{doc.id} => {doc.to_dict()}") - Entä jos halutaan listata kaikki reseptit? Jokainen resepti on yksittäisen ruokalajin
reseptit-kokoelmassa. Yhden ruokalajin reseptit voi hakea näin:
Kaikkien reseptien hakemiseen tarvitaan collection group -kysely:# kaikki makeat leivonnaiset; ruokalajin id on 7 for resepti in db.collection("ruokalaji/7/reseptit").stream(): print(f"{resepti.id} => {resepti.to_dict()}")for doc in db.collection_group("reseptit").stream(): print(f"{doc.id} => {doc.to_dict()}")Kysely ei heti onnistu, vaan ohjelma kaatuu virheeseen, jonka mukaan kysely tarvitsee tietynlaisen indeksin. Virheilmoituksessa on linkki, jonka avaamalla indeksin saa luotua.
"The query requires a COLLECTION_GROUP_ASC index for collection reseptit and field type. You can create it here: https://console.firebase.google.com/...Indeksin luominen kestää tovin. Jos yrität kyselyä uudelleen ennen kuin indeksi on valmis, saat uuden virheilmoituksen:
400 The query requires a COLLECTION_GROUP_ASC index for collection reseptit and field type. That index is not ready yet. See its status here: https://console.firebase.google.com/... - Listaa reseptit ruokalajeittain seuraavalla tavalla:
Alkuruoka Pääruoka Ananas Con Carne Banaanicurry Lisäkeruoka Väli- tai iltapala Jälkiruoka Suolainen leivonnainen Makea leivonnainen Tiramisu Porkkanapiirakka Rommikakku KauralastutMalli
for ruokalaji in db.collection("ruokalaji").stream(): print(ruokalaji.to_dict()["nimi"]) for resepti in db.collection("ruokalaji", ruokalaji.id, "reseptit").stream(): print(f"\t{resepti.to_dict()['nimi']}") - Lisätietoa: Admin SDK Reference ja google-cloud-firestore.
Käyttäjien kommentit