XML ja ulkopuoliset datalähteet palvelimella
Sovellus tarvitsee usein tietoa, jota se ei itse tuota: säähavaintoja, aikatauluja, kurssitietoja, maksutapahtumia. Tällä luennolla haetaan tietoa ulkopuolisesta rajapinnasta palvelimella, jäsennetään se turvallisesti ja tarjoillaan omalle sovellukselle sopivassa muodossa. Esimerkkinä on XML, koska se on avoimissa rajapinnoissa yhä yleinen ja koska sen käsittelyyn liittyy tietoturvakysymyksiä, joita JSONissa ei ole.
Miksi haku tehdään palvelimella
Selain osaa hakea dataa itsekin, kuten edellisellä luennolla nähtiin. Palvelimella haulla on silti neljä etua:
- CORS ei rajoita. Selain saa lukea toisen lähteen vastauksen vain, jos lähde sallii sen CORS-otsakkeilla. Suurin osa avoimista rajapinnoista ei salli, koska ne on tarkoitettu palvelinten käyttöön. Palvelimelle rajoitus ei koske.
- Avaimet pysyvät salassa. Moni rajapinta vaatii API-avaimen. Selaimeen laitettu avain on kaikkien luettavissa, ja avain on aina henkilökohtainen tai organisaatiokohtainen.
- Välimuisti on mahdollinen. Palvelin voi hakea datan kerran ja palvella sadat käyttäjät samasta tuloksesta. Ilman välimuistia jokainen kävijä kuormittaisi toisen organisaation palvelinta.
- Data voidaan muokata sopivaksi. Ulkopuolinen rajapinta harvoin palauttaa juuri sitä, mitä sivu tarvitsee. Palvelimella datasta voidaan poimia tarpeellinen ja muuttaa se JSONiksi tai HTML:ksi.
Datan hakeminen
Vakiokirjaston urllib.request riittää yksinkertaiseen hakuun.
Laajemmissa sovelluksissa käytetään yleensä kirjastoa
requests tai
httpx, joista jälkimmäinen toimii myös
asynkronisesti.
import urllib.request
AIKAKATKAISU = 5
ENIMMAISKOKO = 1024 * 1024
def hae_xml(osoite):
pyynto = urllib.request.Request(osoite, headers={
"User-Agent": "TIES4080-esimerkki (opetuskäyttö)",
"Accept": "application/xml",
})
with urllib.request.urlopen(pyynto, timeout=AIKAKATKAISU) as vastaus:
return vastaus.read(ENIMMAISKOKO).decode("utf-8")
Kolme asiaa on helppo unohtaa:
- Aikakatkaisu on pakollinen. Ilman
timeout-arvoa hidas tai jumiutunut palvelin jäädyttää oman sovelluksen. Pyyntöä käsittelevä työprosessi jää odottamaan, eivätkä muutkaan käyttäjät saa vastausta. - Vastauksen koko kannattaa rajata.
read()ilman rajaa lukee niin paljon kuin lähde lähettää. - Kerro kuka olet.
User-Agent-otsakkeesta lähteen ylläpitäjä näkee, mistä liikenne tulee. Monet palvelut myös vaativat sen.
XML:n jäsentäminen
Pythonin vakiokirjastossa on kaksi tapaa käsitellä XML:ää. Vanhempi
xml.dom.minidom
toteuttaa selaimesta tutun DOM-rajapinnan: getElementsByTagName,
createElement, appendChild. Uudempi ja käytännössä aina
parempi valinta on
xml.etree.ElementTree,
joka on kevyempi ja jossa haut kirjoitetaan lyhyemmin.
| Tehtävä | minidom | ElementTree |
|---|---|---|
| jäsennä merkkijono | parseString(teksti) |
fromstring(teksti) |
| etsi elementit | getElementsByTagName("asema") |
findall("asema") |
| attribuutin arvo | node.getAttribute("nimi") |
node.get("nimi") |
| tekstisisältö | node.firstChild.nodeValue |
node.text |
| muotoiltu tuloste | toprettyxml(indent=" ") |
ElementTree.indent() ja tostring() |
minidom on edelleen kätevä kahdessa tilanteessa: kun XML halutaan tulostaa sisennettynä ja kun koodi kirjoitetaan rinnakkain selaimen javascriptin kanssa, jolloin sama DOM-rajapinta molemmissa päissä helpottaa vertailua.
Nimiavaruudet
Avointen rajapintojen XML käyttää lähes aina nimiavaruuksia. ElementTreessä
elementin nimi on silloin muotoa {nimiavaruus}elementti, mikä on
hakulausekkeissa hankalaa. Siksi nimiavaruuksille annetaan lyhenteet
sanakirjassa:
from defusedxml.ElementTree import fromstring
NIMIAVARUUS = "https://appro.mit.jyu.fi/ties4080/havainnot"
NS = {"h": NIMIAVARUUS}
juuri = fromstring(teksti)
for asema in juuri.findall("h:asema", NS):
print(asema.get("nimi"))
for havainto in asema.findall("h:havainto", NS):
print(havainto.get("aika"), havainto.findtext("h:lampotila", None, NS))
Huomaa, että lyhenne on oma valintasi eikä liity siihen, mitä lyhennettä lähde käyttää. Merkitystä on vain nimiavaruuden osoitteella.
Puuttuvat ja kelvottomat arvot
Vieraasta lähteestä tuleva data on harvoin täydellistä: kenttä voi puuttua, olla
tyhjä tai sisältää tekstiä siellä, missä odotit lukua. findtext palauttaa
oletusarvon, kun elementtiä ei löydy, ja muunnokset kannattaa tehdä varovasti:
def luku(teksti):
try:
return float(teksti)
except (TypeError, ValueError):
return None
Jos jäsennys olettaa datan täydelliseksi, sovellus kaatuu sinä päivänä, kun yksi mittausasema on huollossa.
Turvallisuus: älä jäsennä vierasta XML:ää sellaisenaan
XML-standardi sallii dokumentin määritellä omia entiteettejä. Tämä ominaisuus tekee XML:stä vaarallisen, jos dokumentti tulee lähteestä, johon et luota.
XXE (XML External Entity): dokumentti määrittelee entiteetin, joka osoittaa palvelimen tiedostoon tai verkko-osoitteeseen. Kun jäsennin korvaa entiteetin, tiedoston sisältö päätyy dokumenttiin — ja sitä kautta esimerkiksi sovelluksen tulosteeseen.
<!DOCTYPE havainnot [
<!ENTITY salaisuus SYSTEM "file:///etc/passwd">
]>
<havainnot><asema nimi="&salaisuus;" /></havainnot>
Billion laughs: sisäkkäiset entiteetit monistavat toisiaan, jolloin muutaman kilotavun dokumentti laajenee gigatavuiksi ja kaataa palvelimen muistin.
Ratkaisu on kirjasto defusedxml, joka tarjoaa samat rajapinnat kuin vakiokirjasto mutta turvallisilla oletuksilla. Vaihto on yhden rivin kokoinen:
# ennen
from xml.etree.ElementTree import fromstring
# jälkeen
from defusedxml.ElementTree import fromstring
Entiteettejä käyttävä dokumentti aiheuttaa silloin poikkeuksen
EntitiesForbidden eikä hiljaista tietovuotoa. Oman sovelluksen
tuottaman XML:n voi jäsentää vakiokirjastolla, mutta yksinkertaisinta on käyttää
defusedxml-kirjastoa kaikkialla.
Virheenkäsittely ja häiriöt
Ulkopuolinen lähde on aina epäluotettava: se voi vastata hitaasti, palauttaa virheen, kaatua kesken vastauksen tai lähettää rikkinäistä XML:ää. Nämä ovat eri tilanteita ja ansaitsevat eri viestit:
try:
data = jasenna(hae_xml(DATALAHDE))
except urllib.error.HTTPError as e:
return vanha_data, f"Datalähde vastasi tilakoodilla {e.code}"
except urllib.error.URLError as e:
return vanha_data, f"Datalähteeseen ei saatu yhteyttä: {e.reason}"
except TimeoutError:
return vanha_data, "Datalähde ei vastannut ajoissa"
except ElementTree.ParseError as e:
return vanha_data, f"Datalähteen XML ei kelpaa: {e}"
Olennaista on, mitä tapahtuu virheen jälkeen. Sovelluksen ei kannata näyttää virhesivua, jos edellinen haku onnistui: vanha data varoituksen kanssa on lähes aina parempi kuin tyhjä sivu. Infonäyttö, joka kertoo tuntia vanhat lukemat ja niiden aikaleiman, on käyttökelpoinen; tyhjä ruutu ei ole.
Välimuisti ja kohteliaisuus
Jos data haetaan jokaisella sivulatauksella, sadan kävijän sivu tekee sata pyyntöä toisen organisaation palvelimeen — usein saman minuutin aikana ja samalla tuloksella. Se on sekä hidasta että epäkohteliasta, ja moni rajapinta rajoittaa pyyntöjen määrää.
from datetime import datetime, timezone
VALIMUISTI_SEKUNTIA = 60
valimuisti = {"data": None, "haettu": None}
def havainnot():
nyt = datetime.now(timezone.utc)
tuore = (valimuisti["haettu"] is not None
and (nyt - valimuisti["haettu"]).total_seconds() < VALIMUISTI_SEKUNTIA)
if tuore:
return valimuisti["data"]
data = jasenna(hae_xml(DATALAHDE))
valimuisti.update(data=data, haettu=nyt)
return data
Sopiva välimuistin ikä seuraa datan päivitystahtia: minuutin välein päivittyvää säähavaintoa ei kannata hakea sekunnin välein eikä kerran vuorokaudessa päivittyvää aikataulua minuutin välein. Muista myös näyttää käyttäjälle, minkä ikäistä data on. Edellä oleva ratkaisu säilyttää välimuistin prosessin muistissa; useamman prosessin tai instanssin sovelluksessa tarvitaan yhteinen välimuisti, kuten Flask-Caching sopivalla taustapalvelulla.
XML:n tuottaminen
Joskus oma sovellus on se, joka tarjoaa XML:ää muille. ElementTreellä puu
rakennetaan elementti kerrallaan, ja nimiavaruus annetaan elementin nimessä
aaltosulkeissa. register_namespace määrää, millä lyhenteellä
nimiavaruus kirjoitetaan ulos; tyhjä lyhenne tekee siitä oletusnimiavaruuden:
from xml.etree import ElementTree
ElementTree.register_namespace("", NIMIAVARUUS)
juuri = ElementTree.Element(f"{{{NIMIAVARUUS}}}havainnot", {"paivitetty": aikaleima})
for asema in asemat:
a = ElementTree.SubElement(juuri, f"{{{NIMIAVARUUS}}}asema",
{"tunnus": asema["tunnus"], "nimi": asema["nimi"]})
xml = ElementTree.tostring(juuri, encoding="unicode", xml_declaration=True)
return Response(xml, mimetype="application/xml")
Mediatyyppi on application/xml, kuten
rajapintaluennolla todettiin. Jos XML on
tarkoitettu ihmisen luettavaksi, minidom osaa sisentää sen:
from xml.dom import minidom
muotoiltu = minidom.parseString(xml).toprettyxml(indent=" ")
Sama onnistuu myös pelkällä ElementTreellä funktiolla
ElementTree.indent(juuri) ennen tostring-kutsua.
Houkutus kirjoittaa "<asema nimi='" + nimi + "'>" on suuri,
mutta lopputulos rikkoutuu heti, kun datassa on <-merkki, &-merkki tai
heittomerkki. Jäsennin hoitaa merkkien suojauksen puolestasi, kun rakennat puun
elementeistä. Sama pätee HTML:ään: siellä työn tekee Jinja.
Entä JSON?
Uudemmat rajapinnat tarjoavat yleensä JSONia, ja moni tarjoaa molempia. JSONin
käsittely on suoraviivaisempaa, koska json.loads tuottaa suoraan
Pythonin sanakirjoja ja listoja eikä nimiavaruuksia tarvita. JSONissa ei myöskään ole
entiteettejä, joten XXE:n kaltaista ongelmaa ei ole. Muut tämän luennon asiat —
aikakatkaisu, koon rajoitus, virheenkäsittely, välimuisti ja puuttuvien arvojen
varalta varautuminen — pätevät kuitenkin täsmälleen samoin.
XML:ää tarvitaan edelleen, kun lähde tarjoaa vain sitä. Suomalaisista avoimista rajapinnoista esimerkiksi Ilmatieteen laitoksen havaintodata on XML-muotoista (WFS-rajapinta), ja moni viranomaisaineisto noudattaa XML-pohjaisia standardeja.
Esimerkkisovellus
Esimerkissä on kaksi ohjelmaa. datalahde.py jäljittelee ulkopuolista
avointa rajapintaa ja tarjoilee sääasemien havainnot XML-muodossa.
app.py hakee datan, jäsentää sen ja tarjoaa kolmessa muodossa:
HTML-sivuna, JSONina ja uudelleen muodostettuna XML:nä.
- xml-esimerkki.zip — lähdekoodi, testit ja käynnistysohjeet
Datalähteeseen voi pyytää vikatilanteita kyselyparametreilla, jolloin virheenkäsittelyn näkee toiminnassa:
| Parametri | Mitä tapahtuu |
|---|---|
?viive=3 | lähde vastaa hitaasti ja aikakatkaisu laukeaa |
?virhe=1 | lähde vastaa tilakoodilla 503 |
?paha=1 | lähde palauttaa XML:n, joka yrittää lukea palvelimen tiedoston; defusedxml torjuu sen |
Sovelluksen testit kattavat jäsentämisen, välimuistin, kolme vikatilannetta,
XXE-torjunnan ja XML-viennin. Kokeile myös hakea samaa sivua peräkkäin: datalähteen
osoite /laskuri kertoo, montako pyyntöä sinne on tehty, ja luku ei
kasva ennen kuin välimuisti vanhenee.
Käyttäjien kommentit