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:

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:

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.

Sama asia kahdella rajapinnalla
TehtäväminidomElementTree
jäsennä merkkijonoparseString(teksti) fromstring(teksti)
etsi elementitgetElementsByTagName("asema") findall("asema")
attribuutin arvonode.getAttribute("nimi") node.get("nimi")
tekstisisältönode.firstChild.nodeValue node.text
muotoiltu tulostetoprettyxml(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 ja billion laughs

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.

Älä kokoa XML:ää merkkijonoista

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ä.

Datalähteeseen voi pyytää vikatilanteita kyselyparametreilla, jolloin virheenkäsittelyn näkee toiminnassa:

Vikatilanteiden kokeilu
ParametriMitä tapahtuu
?viive=3lähde vastaa hitaasti ja aikakatkaisu laukeaa
?virhe=1lähde vastaa tilakoodilla 503
?paha=1lä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.

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti