Välimuisti, pilvifunktiot, PostgreSQL ja avoin data – Pääteohjaus 6
Tässä ohjauksessa haetaan tietoa ulkoisista rajapinnoista requests-kirjastolla ja tallennetaan vastaukset palvelimen välimuistiin HTTP-otsakkeiden ohjeiden mukaan. Lisäksi julkaistaan pilvifunktio, otetaan käyttöön PostgreSQL-tietokanta Cloud SQL -palvelussa ja jäsennetään XML-dataa ElementTreellä.
Ohjaus perustuu luentoihin XML ja ulkopuoliset datalähteet palvelimella, REST-rajapinnat: HTTP-välimuisti ja Python ja tietokannat: PostgreSQL, yhteyspooli ja Cloud SQL. Sovelluksen omien laskentojen välimuisti (Flask-Caching) käsiteltiin ohjauksessa 4.
- ohjaus6-malli.zip – kaikki tämän ohjauksen mallit ja niiden testit
- resepti-pg.sql – reseptitietokanta PostgreSQL:lle
- data.xml ja esimerkki.xhtml – XML-harjoitusten aineistot
Pura ohjaus6-malli.zip-paketti ja asenna tarvittavat kirjastot virtuaaliympäristöön:
cd ohjaus6-malli
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
Ulkoiset rajapinnat ja pakolliset otsakkeet
Kun palvelin hakee tietoa toisesta palvelusta, se on vieras, jonka on noudatettava talon sääntöjä. Useimmat julkiset rajapinnat edellyttävät, että pyynnössä kerrotaan, mikä sovellus tietoa hakee, ja rajoittavat pyyntöjen määrää. Kts. luennon luvut Datan hakeminen ja Välimuisti ja kohteliaisuus.
| Otsake | Tarkoitus |
|---|---|
User-Agent | Sovelluksen nimi ja yhteystieto, esim. Resepti/1.0 (ylläpito@example.fi).
Monet palvelut hylkäävät pyynnön, jos otsake puuttuu tai se on kirjaston oletusarvo, kuten
python-requests/2.32.3. |
Accept | Missä muodossa vastaus halutaan, esim. application/json. |
Authorization | API-avain tai token. Luetaan ympäristömuuttujasta tai Secret Managerista, ei koskaan koodista. |
| palvelukohtaiset | esim. GitHubin X-GitHub-Api-Version kiinnittää rajapinnan version. |
Harjoitellaan ensin rajapinta/lahde.py-harjoitusrajapinnalla, joka toimii omalla koneella ja käyttäytyy kuten moni julkinen rajapinta: se hylkää pyynnöt ilman omaa User-Agent-otsaketta, kertoo, kuinka kauan vastausta saa käyttää ja sallii 60 pyyntöä tunnissa. Harjoitusrajapinnan kanssa ei tarvitse pelätä, että oma IP-osoite joutuu estolistalle.
cd rajapinta
flask --app lahde run --port 5001
Kokeile ensin selaimella tai curlilla, mitä rajapinta vastaa:
curl -i http://127.0.0.1:5001/havainto
curl -i -A "Oma-sovellus/1.0 (oma@example.fi)" http://127.0.0.1:5001/havainto
Sama requests-kirjastolla (rajapinta/kokeile.py):
import time
import requests
URL = "http://127.0.0.1:5001/havainto"
TUNNISTE = {"User-Agent": "TIES4080-ohjaus6 (opiskelija@example.fi)"}
# 1. Ilman omaa User-Agent-otsaketta requests lähettää "python-requests/x.y.z".
vastaus = requests.get(URL, timeout=10)
print("1.", vastaus.status_code, vastaus.request.headers["User-Agent"], vastaus.json())
# 2. Sovelluksen nimi ja yhteystieto otsakkeessa.
vastaus = requests.get(URL, headers=TUNNISTE, timeout=10)
print("2.", vastaus.status_code, vastaus.json())
for nimi in ("Cache-Control", "Expires", "Last-Modified", "ETag", "X-RateLimit-Remaining"):
print(f" {nimi}: {vastaus.headers.get(nimi)}")
# 3. Ehdollinen pyyntö: onko tieto muuttunut sen jälkeen?
ehdot = dict(TUNNISTE, **{"If-Modified-Since": vastaus.headers["Last-Modified"]})
toinen = requests.get(URL, headers=ehdot, timeout=10)
print("3.", toinen.status_code, f"runko {len(toinen.content)} tavua")
# 4. Sama ETagilla. Jos tieto ehti muuttua, saadaan 200 ja uusi data.
ehdot = dict(TUNNISTE, **{"If-None-Match": vastaus.headers["ETag"]})
kolmas = requests.get(URL, headers=ehdot, timeout=10)
print("4.", kolmas.status_code, "X-RateLimit-Remaining:", kolmas.headers["X-RateLimit-Remaining"])
# 5. Aikakatkaisu: palvelin, joka ei vastaa, ei saa jumittaa ohjelmaa.
try:
requests.get("http://10.255.255.1/", timeout=2) # osoite, joka ei vastaa
except requests.Timeout:
print("5. Aikakatkaisu 2 sekunnin jälkeen")
except requests.RequestException as virhe:
print("5. Muu verkkovirhe:", type(virhe).__name__)
else:
print("5. Jokin vastasi (esim. välityspalvelin)")
Tuloste on esimerkiksi:
1. 403 python-requests/2.32.3 {'virhe': 'Kerro User-Agent-otsakkeessa sovelluksesi nimi ja yhteystieto'}
2. 200 {'lampotila': -2.5, 'mitattu': '2026-09-29T12:50:00+00:00'}
Cache-Control: max-age=30
Expires: Tue, 29 Sep 2026 12:51:28 GMT
Last-Modified: Tue, 29 Sep 2026 12:50:00 GMT
ETag: "1e4ee57fad565b65"
X-RateLimit-Remaining: 59
3. 304 runko 0 tavua
4. 304 X-RateLimit-Remaining: 57
5. Aikakatkaisu 2 sekunnin jälkeen
- Anna aina
timeout-parametri. Ilman sitä requests odottaa vastausta ikuisesti, ja jumittunut ulkoinen palvelu jumittaa myös oman sovelluksesi. vastaus.raise_for_status()muuttaa virhekoodit (4xx ja 5xx) poikkeukseksi. Ilman sitä virhesivu käsiteltäisiin kuin oikea vastaus.- Kun kiintiö on täynnä, rajapinta vastaa
429 Too Many Requestsja kertooRetry-After-otsakkeessa, kuinka monen sekunnin päästä saa yrittää uudelleen. Kokeile tehdä silmukassa yli 60 pyyntöä. Kiintiö nollautuu, kun käynnistät harjoitusrajapinnan uudelleen.
Palvelimen välimuisti
Jos sovellus hakee ulkoisen tiedon jokaisella sivupyynnöllä, se on hidas, kuluttaa ulkoisen palvelun kiintiötä ja lakkaa toimimasta, kun ulkoinen palvelu on alhaalla. Siksi vastaukset tallennetaan palvelimen välimuistiin. Tallennusaikaa ei tarvitse arvata, koska hyvin tehty rajapinta kertoo sen itse. Kts. luento HTTP-välimuisti.
Kuinka kauan vastausta saa käyttää: Expires ja Cache-Control
| Otsake | Esimerkki | Merkitys |
|---|---|---|
Cache-Control: max-age | max-age=30 | Vastausta saa käyttää 30 sekuntia. Menee Expires-otsakkeen edelle. |
Cache-Control: no-store | Vastausta ei saa tallentaa lainkaan. | |
Expires | Tue, 29 Sep 2026 12:51:28 GMT | Vanhempi tapa: kellonaika, jonka jälkeen vastaus on vanhentunut. Kelvoton arvo, kuten 0, tarkoittaa jo vanhentunutta. |
Last-Modified | Tue, 29 Sep 2026 12:50:00 GMT | Milloin tieto on viimeksi muuttunut. |
ETag | "1e4ee57fad565b65" | Tiedon version tunniste. Muuttuu aina, kun tieto muuttuu. |
Päivämäärät ovat HTTP-päiväyksiä
ja aina GMT-ajassa. Pythonissa ne jäsennetään
email.utils.parsedate_to_datetime-funktiolla ja muodostetaan
email.utils.format_datetime(aika, usegmt=True)-funktiolla.
Onko tieto muuttunut: If-Modified-Since ja If-None-Match
Kun tallennettu vastaus vanhenee, sitä ei tarvitse heittää pois. Välimuisti kysyy palvelulta ehdollisella pyynnöllä, onko tieto muuttunut:
If-Modified-Since: aiemman vastauksenLast-Modified-arvo sellaisenaanIf-None-Match: aiemman vastauksenETag-arvo sellaisenaan
Jos tieto ei ole muuttunut, palvelu vastaa 304 Not Modified ilman runkoa, ja
välimuisti jatkaa vanhan tiedon käyttöä uudella voimassaoloajalla. Muuten palvelu vastaa
tavallisesti 200 OK ja uudella datalla. ETag on tarkempi, koska
Last-Modified-aika on sekunnin tarkkuudella. Jos molemmat lähetetään, palvelin
käyttää ETagia.
Välimuistin toteutus
rajapinta/valimuisti.py toteuttaa edellä kuvatun välimuistin sanakirjana palvelimen muistissa:
import threading
import time
from email.utils import parsedate_to_datetime
import requests
OTSAKKEET = {
# Monet palvelut vaativat sovelluksen nimen ja yhteystiedon.
"User-Agent": "TIES4080-ohjaus6 (opiskelija@example.fi)",
"Accept": "application/json",
}
AIKAKATKAISU = 10 # sekuntia; ilman tätä jumittunut palvelu jumittaa myös sovelluksen
OLETUSAIKA = 60 # jos palvelu ei kerro vastauksen käyttöaikaa
_muisti = {} # url -> {"data", "vanhenee", "etag", "muutettu"}
_lukko = threading.Lock() # Flask voi käsitellä pyyntöjä rinnakkain säikeissä
def voimassaoloaika(vastaus):
"""Montako sekuntia vastausta saa käyttää. max-age menee Expires-otsakkeen edelle."""
for osa in vastaus.headers.get("Cache-Control", "").split(","):
nimi, _, arvo = osa.strip().partition("=")
if nimi.lower() == "no-store":
return 0
if nimi.lower() == "max-age" and arvo.isdigit():
return int(arvo)
expires = vastaus.headers.get("Expires")
if expires:
try:
return max(0, parsedate_to_datetime(expires).timestamp() - time.time())
except (TypeError, ValueError):
return 0 # kelvoton Expires tarkoittaa jo vanhentunutta
return OLETUSAIKA
def hae(url, otsakkeet=None):
"""Palauttaa (data, tila), jossa tila on "välimuisti", "304" tai "haettu"."""
with _lukko:
tallennettu = _muisti.get(url)
if tallennettu and time.time() < tallennettu["vanhenee"]:
return tallennettu["data"], "välimuisti"
kaikki = dict(OTSAKKEET, **(otsakkeet or {}))
if tallennettu:
if tallennettu["etag"]:
kaikki["If-None-Match"] = tallennettu["etag"]
if tallennettu["muutettu"]:
# Arvo annetaan täsmälleen sellaisena kuin palvelu sen lähetti.
kaikki["If-Modified-Since"] = tallennettu["muutettu"]
vastaus = requests.get(url, headers=kaikki, timeout=AIKAKATKAISU)
if vastaus.status_code == 304 and tallennettu:
with _lukko:
tallennettu["vanhenee"] = time.time() + voimassaoloaika(vastaus)
return tallennettu["data"], "304"
vastaus.raise_for_status() # 403, 429, 500 ... poikkeukseksi
uusi = {
"data": vastaus.json(),
"vanhenee": time.time() + voimassaoloaika(vastaus),
"etag": vastaus.headers.get("ETag"),
"muutettu": vastaus.headers.get("Last-Modified"),
}
with _lukko:
_muisti[url] = uusi
return uusi["data"], "haettu"
def jaljella(url):
"""Montako sekuntia välimuistissa oleva vastaus on vielä voimassa."""
with _lukko:
tallennettu = _muisti.get(url)
return max(0, int(tallennettu["vanhenee"] - time.time())) if tallennettu else 0
- Flask voi käsitellä pyyntöjä rinnakkain eri säikeissä, joten jaettua sanakirjaa käsitellään lukon kanssa.
- Välimuisti on prosessikohtainen. Jokaisella Cloud Run -instanssilla on oma välimuistinsa, ja se tyhjenee, kun instanssi sammuu. Jos välimuistin on oltava yhteinen, tarvitaan erillinen palvelu, kuten Redis (Memorystore), tai tallennus tietokantaan. Kts. Välimuisti Cloud Runissa.
- Sanakirja kasvaa rajatta, jos osoitteita on paljon. Silloin kannattaa käyttää
kokorajoitettua rakennetta kuten cachetools-kirjaston
LRUCache.
Oma palvelu kertoo puolestaan selaimelle, kuinka kauan sen vastausta saa käyttää. Kun data on välimuistissa vielä 20 sekuntia, ei selaimenkaan kannata kysyä uudelleen ennen sitä (rajapinta/sovellus.py):
LAHDE = os.environ.get("LAHDE", "http://127.0.0.1:5001/havainto")
app = Flask(__name__)
@app.get("/")
def havainto():
try:
data, tila = hae(LAHDE)
except requests.RequestException as virhe:
# Ulkoisen palvelun vika ei saa kaataa omaa palvelua.
app.logger.warning("Haku epäonnistui: %s", virhe)
return {"virhe": "Havaintoa ei juuri nyt saatu"}, 502
return (dict(data, lahde=tila), 200,
{"Cache-Control": f"public, max-age={jaljella(LAHDE)}"})
Käynnistä harjoitusrajapinta lyhyellä voimassaoloajalla ja sovellus toisessa ikkunassa:
VOIMASSA=10 MUUTOSVALI=60 flask --app lahde run --port 5001
flask --app sovellus run
- Lataa http://127.0.0.1:5000/ muutaman kerran. Vastauksen
lahde-kenttä kertoo, tuliko data ulkoisesta palvelusta (haettu), suoraan muistista (välimuisti) vai ehdollisen pyynnön jälkeen (304). Seuraa samalla harjoitusrajapinnan lokia. - Tarkista selaimen kehittäjätyökalujen Network-välilehdeltä, että selain käyttää omaa
välimuistiaan
Cache-Control-otsakkeen mukaan. Huomaa, että sivun päivitys (F5) ohittaa välimuistin. Käytä linkkiä tai osoiterivin Enteriä. - Sammuta harjoitusrajapinta. Mitä omalle palvelulle tapahtuu, kun välimuistissa on tietoa ja kun sitä ei ole? Muuta välimuistia niin, että se palauttaa vanhentuneen tiedon, jos uutta ei saada, ja kertoo sen käyttäjälle.
Oikea rajapinta: GitHub
GitHubin REST-rajapinta on hyvä kokeilukohde, koska se on avoin, dokumentoitu tarkasti ja käyttää kaikkia edellä käsiteltyjä otsakkeita:
- User-Agent on pakollinen. Pyyntö ilman sitä hylätään. Ohjeen mukaan arvoksi annetaan sovelluksen nimi tai oma GitHub-käyttäjätunnus.
Accept: application/vnd.github+jsonjaX-GitHub-Api-Version: 2022-11-28ovat suositeltuja otsakkeita.- Kirjautumatta saa tehdä 60 pyyntöä tunnissa
IP-osoitetta kohti. Jäljellä oleva määrä kerrotaan
x-ratelimit-remaining-otsakkeessa ja nollautumisaikax-ratelimit-reset-otsakkeessa (Unix-aikaleima). Kun kiintiö on täynnä, vastaus on 403 tai 429. - Vastauksissa on
Cache-Control,ETagja yleensäLast-Modified. Ehdolliset pyynnöt toimivat, mutta 304-vastaus säästää kiintiötä vain, jos pyyntö on tehty tokenilla kirjautuneena. Kirjautumatta välimuistin tuoreusaika on siis se, mikä säästää kiintiötä.
rajapinta/github.py hakee repositorion tiedot kolme kertaa samalla välimuistilla:
import sys
import time
from valimuisti import hae
GITHUB = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
repo = sys.argv[1] if len(sys.argv) > 1 else "python/cpython"
for kierros in range(3):
data, tila = hae(f"https://api.github.com/repos/{repo}", GITHUB)
print(f"{tila:10} {data['full_name']}: {data['stargazers_count']} tähteä")
time.sleep(2)
- Vaihda valimuisti.py-tiedoston
User-Agentomaksi, esim.TIES4080-oma-github-tunnus. Älä käytä sähköpostiosoitettasi, jos et halua sen päätyvän toisen palvelun lokeihin. - Aja ohjelma ja tulosta myös
x-ratelimit-remaining. Montako pyyntöä kolme hakua kulutti? - Kokeile samaa osoitetta ilman
User-Agent-otsaketta, esimerkiksicurl -i -H "User-Agent:" https://api.github.com/repos/python/cpython. - Tee Flask-sivu, joka näyttää annetun käyttäjän julkiset repositoriot
(
GET /users/{käyttäjä}/repos) ja käyttää välimuistia.
Toinen samaan tapaan toimiva palvelu on Wikipedian rajapinta, esim. https://fi.wikipedia.org/api/rest_v1/page/summary/Jyväskylä. Wikimedia vaatii User-Agent-käytännössään kuvaavan User-Agent-otsakkeen, jossa on yhteystieto, ja voi estää kirjastojen yleiset oletusarvot.
Cloud Run functions
Googlen pilvifunktiot (Cloud Run functions) ovat Cloud Run -palveluja, joissa kirjoitetaan pelkkä funktio. Palvelu rakentaa funktion ympärille HTTP-palvelimen ja kontin Functions Frameworkin avulla. Pilvifunktio sopii yksittäiseen toimintoon, kuten laskentaan, webhookiin tai tapahtumiin reagoimiseen. Kokonaiselle Flask-sovellukselle sopii paremmin tavallinen Cloud Run -palvelu. Esimerkiksi raskas laskenta, jota muu sovellus tarvitsee harvoin, on sopiva pilvifunktio.
Pilvifunktiot ovat tilattomia. Kun funktiota kutsutaan eikä vapaata instanssia ole, käynnistetään uusi (cold start), mikä hidastaa vastausta. Käynnistetty instanssi pysyy hetken elossa ja palvelee seuraavia kutsuja nopeammin. Kaikki, mitä voi käyttää uudelleen, kuten tietokantayhteydet ja välimuisti, pidetään siksi globaaleissa muuttujissa. Mikään ei kuitenkaan takaa, että seuraava kutsu tulee samalle instanssille. Lue Cloud Run functions best practices.
Malli (pilvifunktio/main.py):
import functions_framework
from cachetools import TTLCache, cached
ENINTAAN = 10000 # hidas algoritmi: suurempi luku varaisi instanssin pitkäksi aikaa
# Globaali muuttuja säilyy instanssin muistissa kutsujen välillä, joten sama
# instanssi voi vastata toistuvaan kyselyyn laskematta uudelleen. Eri
# instansseilla on kuitenkin kullakin oma välimuistinsa.
valimuisti = TTLCache(maxsize=100, ttl=3600)
@cached(valimuisti)
def laske(n):
"""Alkuluvut, jotka ovat pienempiä kuin n.
Algoritmi on tahallaan hidas, jotta välimuistin vaikutus näkyy.
Oikeassa sovelluksessa käytettäisiin Eratostheneen seulaa."""
alkuluvut = []
for luku in range(2, n):
if all(luku % jakaja != 0 for jakaja in range(2, luku)):
alkuluvut.append(luku)
return alkuluvut
@functions_framework.http
def alkuluvut(request):
arvo = request.args.get("n", "100")
# Syöte tulee käyttäjältä: tarkista tyyppi ja koko ennen laskentaa.
if not arvo.isdigit() or int(arvo) > ENINTAAN:
return {"virhe": f"n on kokonaisluku väliltä 0–{ENINTAAN}"}, 400
n = int(arvo) # int avaimena: "0100" ja "100" ovat sama laskenta
luvut = laske(n)
return {"n": n, "lukumaara": len(luvut), "alkuluvut": luvut}
requirements.txt:
functions-framework
cachetools
- Funktio saa parametrina Flaskin
request-olion ja palauttaa samoja arvoja kuin Flaskin näkymäfunktio. Sanakirjasta tulee JSON-vastaus. - Käyttäjältä tuleva
ntarkistetaan ennen laskentaa. Ilman rajaa kuka tahansa voisi pyytää laskennan, joka varaa instanssin minuuteiksi ja maksaa sinulle. - Välimuistina käytetään cachetools.TTLCache-rakennetta,
jonka koko (
maxsize) ja elinaika sekunteina (ttl) on rajattu. Välimuistin avain muodostetaan funktion parametreista, joten laskennalle annetaan kokonaisluku eikä merkkijonoa.
Kokeile ensin omalla koneella:
cd pilvifunktio
functions-framework --target=alkuluvut --debug
# selaimella http://localhost:8080/?n=10000
Julkaise funktio. --function on funktion nimi main.py-tiedostossa.
--max-instances 1 rajaa kustannukset harjoituksessa.
gcloud run deploy ohjaus6-malli --source . --function alkuluvut \
--base-image python314 --region europe-north1 \
--allow-unauthenticated --max-instances 1
- Kokeile mallia.
- Mittaa selaimen kehittäjätyökaluilla vastausaika ensimmäisellä kutsulla, toisella kutsulla ja muutaman minuutin tauon jälkeen. Mistä erot johtuvat?
- Katso Cloud Run -konsolin Logs-välilehdeltä, milloin uusi instanssi käynnistyy.
- Lisää funktioon
Cache-Control-otsake, jotta myös selain ja välityspalvelimet voivat käyttää vastausta uudelleen. Funktio voi palauttaa kolmikon (runko, tila, otsakkeet).
Cloud SQL
Otetaan käyttöön PostgreSQL-tietokanta Googlen pilvipalvelussa. Lue ensin luennon luvut PostgreSQL, Tärkeimmät erot SQLiteen ja Cloud SQL. Luennossa sama tehdään gcloud-komennoilla, tässä Cloud Consolessa.
Tätä tietokantaa voisi periaatteessa käyttää myös users.jyu.fi:ssä olevalla sovelluksella, mutta IT-palvelut ovat estäneet sen.
- Valitse sama projekti kuin aiemmissa ohjauksissa.
- Luo uusi Cloud SQL -instanssi
ja valitse tietokantamoottoriksi PostgreSQL. Älä paina Create-painiketta ennen kuin olet
käynyt läpi kaikki lisäasetukset.

- Valitse Cloud SQL:n tuoteversioksi (edition) Enterprise. Uusimmissa PostgreSQL-versioissa oletuksena on kalliimpi Enterprise Plus, jossa ei ole pieniä jaetun ytimen koneita. Valitse presetiksi Sandbox, jota muokataan vielä.
-
Valitse tietokannan versioksi uusin PostgreSQL.

- Keksi Instance ID, esim. ties4080. Tätä ei voi myöhemmin vaihtaa.
- Keksi postgres-ylläpitäjän salasana. Älä käytä sitä sovelluksessa.
- Valitse sijainniksi europe-north1 (Finland) ja Single zone. Usean vyöhykkeen versiota tarvitaan vain erityisen tärkeille palveluille.
- Valitse Customize your instance ja sieltä Machine configuration.
Oletuskone on aivan liian kallis testikäyttöön. Valitse Shared core ja 1 vCPU, 0.614 GB (

db-f1-micro). Se riittää pienelle tietokannalle, jolla ei ole paljon yhtäaikaisia käyttäjiä. Huomaa, että pienessä koneessa sallittujen yhteyksien määrä on pieni. Kts. Poolin koko. -
Valitse Storage: tyypiksi HDD ja kapasiteetiksi pienin 10 GB. Poista valinta kohdasta Enable automatic storage increases.


- Automaattisia varmuuskopioita ei harjoitellessa tarvita, mutta oikeissa tietokannoissa ne ovat välttämättömiä.
-
Tarkista, että asetuksesi vastaavat yhteenvetoa ja että hinta on noin 0,01 dollaria tunnissa. Jos hinta on suurempi, tarkista edelliset asetukset.

- Valitse Create instance ja odota instanssin valmistumista. Tarkempi ohje: Quickstart for Cloud SQL for PostgreSQL.
- Kopioi talteen instanssin Connection name, joka on muotoa
projekti:europe-north1:ties4080.
- Avaa yhteys Cloud Shellin avulla: Connect using Cloud Shell. Cloud Shelliin tulee valmis
komento, joka kysyy ylläpitäjän salasanan.
Nyt käytössäsi on psql-komentotyökalu, joka toimii samaan tapaan kuin sqlite3-työkalu. Jos jonkin merkin syöttäminen ei onnistu, kokeile näppäimistökuvakkeen valikosta Send key combination.
- Luo sovellukselle oma käyttäjä ja tietokanta. Sovellus ei käytä
postgres-ylläpitäjän tunnusta.
Käyttäjän voi luoda myös konsolin Users-välilehdeltä. Suomalaisen aakkosjärjestyksen saa kyselyihin ICU-kollaatiollaCREATE USER ohjaus6 WITH PASSWORD 'keksi-oma-pitka-salasana'; CREATE DATABASE ohjaus6 OWNER ohjaus6; \quitORDER BY nimi COLLATE "fi-x-icu", joten tietokannan omaa lokaalia ei tarvitse muuttaa. - Cloud SQL Auth Proxy on
suositeltu tapa ottaa yhteys omalta koneelta. Proxy tunnistautuu Google-tunnuksellasi ja salaa
yhteyden, joten tietokantaa ei tarvitse avata IP-osoitteille. Lataa proxy
ohjeen mukaan ja käynnistä se:
Nyt tietokanta näkyy omalla koneella osoitteessagcloud auth application-default login ./cloud-sql-proxy --port 5432 projekti:europe-north1:ties4080127.0.0.1:5432. - psql-työkalun saa omalle koneelle asentamalla PostgreSQL:n. Windowsiin se on saatavilla myös
zip-pakettina. Pura paketti esim. C:\mytemp-kansioon.
Halava- ja jalava-koneilla psql on valmiina, mutta niistä on estetty yhteydet yliopiston verkon
ulkopuolelle. Luo taulut toisessa ikkunassa:
resepti-pg.sql on muuten sama kuin SQLite-versio, muttapsql -h 127.0.0.1 -U ohjaus6 -d ohjaus6 -f resepti-pg.sqlAUTOINCREMENT-avaimet on korvattuGENERATED ALWAYS AS IDENTITY-sarakkeilla. - Vaihtoehtoisesti voit sallia yhteydet oman koneesi IP-osoitteesta
Connections-välilehden Add network-valinnalla.

Kannettavan koneen osoite vaihtuu verkosta toiseen, ja jokainen sallittu osoite on avoin ovi tietokantaan. Älä koskaan salli kaikkia osoitteita (
0.0.0.0/0). - Kun et käytä Cloud SQL -tietokantaa, pysäytä (Stop) se. Cloud SQL -instanssi on
jatkuvasti päällä ja kuluttaa krediittejä, vaikka sitä ei käytettäisi. Kun et tarvitse
tietokantaa enää, poista (Delete) se kokonaan.

Tietokantayhteys ja yhteyspooli
PostgreSQL-tietokantaa käytetään Pythonissa psycopg
(versio 3) -kirjastolla. Kyselyt kirjoitetaan kuten SQLitessä, mutta parametrien paikkamerkki on
%s. Web-sovelluksessa yhteydet otetaan yhteyspoolista,
jotta jokaisella pyynnöllä ei tarvitse avata uutta yhteyttä.
pip install "psycopg[binary]" psycopg-pool
tietokanta/tietokanta.py luo poolin. Yhteystiedot luetaan ympäristömuuttujista, ja Cloud Runissa yhteys muodostetaan Unix-soketin kautta:
"""PostgreSQL-yhteyspooli. Asetukset luetaan ympäristömuuttujista."""
import atexit
import os
from psycopg.conninfo import make_conninfo
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
def yhteystiedot():
# Cloud Runissa Cloud SQL näkyy Unix-sokettina hakemistossa /cloudsql/.
# Omalla koneella yhteys otetaan TCP:llä, esim. Cloud SQL Auth Proxyn kautta.
instanssi = os.environ.get("CLOUD_SQL_YHTEYS") # projekti:alue:instanssi
palvelin = f"/cloudsql/{instanssi}" if instanssi else os.environ.get("DB_HOST", "127.0.0.1")
return make_conninfo(
host=palvelin,
port=os.environ.get("DB_PORT", "5432"),
dbname=os.environ["DB_NIMI"],
user=os.environ["DB_KAYTTAJA"],
password=os.environ["DB_SALASANA"],
)
pooli = ConnectionPool(
yhteystiedot(),
kwargs={"row_factory": dict_row},
min_size=1,
max_size=int(os.environ.get("POOLIN_KOKO", "5")),
check=ConnectionPool.check_connection,
open=True,
)
atexit.register(pooli.close)
Pooli lainaa yhteyden with-lohkon ajaksi, vahvistaa muutokset (commit) lohkon
päättyessä ja palauttaa yhteyden pooliin (tietokanta/sovellus.py):
@app.get("/reseptit")
def reseptit():
with pooli.connection() as yhteys:
rivit = yhteys.execute("""
SELECT r.ReseptiID AS id, r.Nimi AS nimi, r.Henkilomaara AS henkilomaara,
l.Nimi AS ruokalaji
FROM Resepti r
JOIN Ruokalaji l ON l.RuokalajiID = r.RuokalajiID
ORDER BY r.Nimi COLLATE "fi-x-icu"
""").fetchall()
return rivit
try:
with pooli.connection() as yhteys:
uusi = yhteys.execute("""
INSERT INTO Resepti (Nimi, Henkilomaara, RuokalajiID)
VALUES (%s, %s, %s)
RETURNING ReseptiID AS id
""", (nimi, henkilomaara, ruokalaji)).fetchone()
except psycopg.errors.ForeignKeyViolation:
return {"virhe": "Tarkista tiedot", "kentat": {"ruokalaji": "Tuntematon ruokalaji"}}, 422
return uusi, 201, {"Location": url_for("resepti", rid=uusi["id"])}
Käynnistä sovellus omalla koneella proxyn kautta. Salasanaa ei kirjoiteta koodiin:
cd tietokanta
export DB_HOST=127.0.0.1 DB_NIMI=ohjaus6 DB_KAYTTAJA=ohjaus6
read -s DB_SALASANA && export DB_SALASANA
flask --app sovellus run
- Hae /reseptit ja /reseptit/1.
- Lisää resepti:
Tarkista, että vastaus oncurl -i -X POST http://127.0.0.1:5000/reseptit \ -H "Content-Type: application/json" \ -d '{"nimi": "Äitin pulla", "henkilomaara": 8, "ruokalaji": 1}'201 CreatedjaLocation-otsake osoittaa uuteen reseptiin, ja että Ä-alkuinen resepti on listauksen lopussa. - Kokeile tuntematonta ruokalajia. Tietokannan viite-eheys hylkää lisäyksen, ja sovellus muuttaa
virheen vastaukseksi
422. - Lisää sovellukseen reseptin muokkaus (
PATCH) ja poisto (DELETE) REST-luennon mukaisesti. - Julkaise sovellus Cloud Runiin luennon Cloud SQL -luvun ohjeiden
mukaan:
--add-cloudsql-instances,CLOUD_SQL_YHTEYS-ympäristömuuttuja, salasana Secret Manageriin ja--max-instances.
Jos et käytä PostgreSQL-tietokantaasi, pysäytä se. Kurssin jälkeen poista instanssi ja muut Googlen pilveen luomasi resurssit.
Avoin data: XML
Käytetään muiden tuottamia XML-dokumentteja tiedonlähteenä. Pythonin ElementTree-rajapinta on yksinkertaisempi kuin DOM-rajapinta, joka on tuttu TIEA2120-kurssilta. Vieraalta palvelimelta tuleva XML jäsennetään defusedxml-kirjastolla, jolla on sama rajapinta, mutta joka torjuu entiteettihyökkäykset. Lue luennon luvut XML:n jäsentäminen, Nimiavaruudet ja Turvallisuus.
Kilpailun tiedot
Jäsennetään https://appro.mit.jyu.fi/ties4080/ohjaus/ohjaus6/data.xml-tiedosto.
Sen rakenne on sama kuin aiemmin käytetyissä JSON-versioissa: juuren alla ovat kilpailun tiedot,
rastit-, sarjat- ja tupa-elementit, ja jokaisen sarjan alla
sen joukkueet.
- Hae dokumentti requestsillä ja jäsennä se. Anna jäsentimelle
vastaus.content(tavut) eikävastaus.text, jotta merkistö luetaan XML-esittelystä:import requests from defusedxml.ElementTree import fromstring vastaus = requests.get("https://appro.mit.jyu.fi/ties4080/ohjaus/ohjaus6/data.xml", headers={"User-Agent": "TIES4080-ohjaus6"}, timeout=10) vastaus.raise_for_status() juuri = fromstring(vastaus.content) print(juuri.findtext("nimi"), juuri.findtext("alkuaika")) - Tulosta kilpailun nimi, kesto, alku- ja loppuaika.
findpalauttaa ensimmäisen suoran lapsen, jotenjuuri.findtext("nimi")ei löydä joukkueen nimeä.findtextpalauttaaNone, jos elementtiä ei ole, ja tyhjän merkkijonon, jos elementti on tyhjä. - Tulosta kaikkien joukkueiden nimet. Kaikki joukkueet löytyvät polulla
.//joukkueet. - Tulosta joukkueet sarjoittain: ensin sarjan nimi ja sen alle sisennettynä sarjan joukkueet ja niiden jäsenet aakkosjärjestyksessä.
Malli (xml/lue_data.py):
import sys
from defusedxml.ElementTree import parse
def teksti(elementti, polku, oletus=""):
"""Alielementin teksti tai oletusarvo, jos elementti puuttuu tai on tyhjä."""
arvo = elementti.findtext(polku)
return arvo.strip() if arvo and arvo.strip() else oletus
def main(tiedosto="data.xml"):
juuri = parse(tiedosto).getroot()
print(f"{teksti(juuri, 'nimi')}: {teksti(juuri, 'alkuaika')} – {teksti(juuri, 'loppuaika')}")
rastit = juuri.findall("rastit")
print(f"Rasteja {len(rastit)}, joista numerottomia:",
", ".join(sorted(teksti(r, "koodi") for r in rastit
if not teksti(r, "koodi")[:1].isdigit())))
for sarja in sorted(juuri.findall("sarjat"), key=lambda s: int(teksti(s, "kesto", "0"))):
print(f"\nSarja {teksti(sarja, 'nimi')}")
joukkueet = sorted(sarja.findall("joukkueet"), key=lambda j: teksti(j, "nimi").casefold())
for joukkue in joukkueet:
jasenet = [j.text.strip() for j in joukkue.findall("jasenet") if j.text]
print(f" {teksti(joukkue, 'nimi')} ({', '.join(jasenet)})")
leimaukset = juuri.findall("tupa")
print(f"\nRastileimauksia {len(leimaukset)}")
if __name__ == "__main__":
main(*sys.argv[1:])
Tee lopuksi tiedoista Flask-sovellus, joka tuottaa XHTML-sivun. Mallissa (xml/sovellus.py ja xml/templates/kilpailu.xhtml) data haetaan verkosta enintään kerran viidessä minuutissa.
XML ja nimiavaruudet
XML-dokumentissa voi olla elementtejä useista nimiavaruuksista. XHTML-sivulla voi olla esimerkiksi
MathML-kaavoja ja SVG-kuvia. ElementTree tallentaa elementin nimen muodossa
{nimiavaruus}nimi, ja haussa etuliitteet kytketään nimiavaruuksiin sanakirjalla.
Etuliitteiden ei tarvitse olla samoja kuin dokumentissa. Ratkaisevaa on nimiavaruuden URI.
Samanniminen elementti eri nimiavaruudessa on eri elementti.
Tee ohjelma, joka tulostaa esimerkki.xhtml-dokumentista
- kaikkien
h2-otsikoiden tekstit - kaikkien MathML-osioiden
mi-elementtien sisällön - kaikkien SVG-monikulmioiden (
polygon) pisteet (points) - SVG-linkin
xlink:href-attribuutin arvon
Malli (xml/nimiavaruudet.py):
import sys
from defusedxml.ElementTree import parse
NA = {
"h": "http://www.w3.org/1999/xhtml",
"m": "http://www.w3.org/1998/Math/MathML",
"s": "http://www.w3.org/2000/svg",
"xl": "http://www.w3.org/1999/xlink",
}
def main(tiedosto="esimerkki.xhtml"):
juuri = parse(tiedosto).getroot()
print("Juuren nimi:", juuri.tag)
# 1. Kaikkien h2-otsikoiden tekstit
print("h2:", [h2.text for h2 in juuri.iterfind(".//h:h2", NA)])
# 2. MathML:n muuttujat (mi-elementit)
print("mi:", [mi.text for mi in juuri.iterfind(".//m:mi", NA)])
# 3. SVG-monikulmioiden pisteet
for monikulmio in juuri.iterfind(".//s:polygon", NA):
print("polygon:", monikulmio.get("points"))
# 4. Nimiavaruudessa olevan attribuutin lukeminen: {URI}nimi
for linkki in juuri.iterfind(".//s:a", NA):
print("xlink:href:", linkki.get(f"{{{NA['xl']}}}href"))
# Ilman nimiavaruutta haku ei löydä mitään, koska kaikki elementit
# ovat XHTML:n nimiavaruudessa.
print("Ilman nimiavaruutta:", juuri.findall(".//h2"))
if __name__ == "__main__":
main(*sys.argv[1:])
Rakenteeltaan rikkinäisiä HTML-sivuja (tag soup) ei voi jäsentää XML-jäsentimellä. Niihin käytetään esimerkiksi BeautifulSoup-kirjastoa, jolla on oma rajapintansa, tai lxml.html-moduulia, joka tuottaa ElementTree-yhteensopivan puun. Ennen sivun kaapimista tarkista palvelun käyttöehdot ja robots.txt. Avoin rajapinta on aina parempi vaihtoehto.
Käyttäjien kommentit