Ostoskoriesimerkki

Ostoskori on kurssin ensimmäinen esimerkki, jossa kaikki tähän mennessä opitut palaset ovat yhtä aikaa käytössä: HTTP-protokollan tilattomuus, lomakkeet ja querystring, Jinja-templatet, MVC-työnjako ja syötteiden tarkistaminen. Sovellus on täysin tilaton — palvelimelle ei jää pyyntöjen välillä mitään — ja silti korissa säilyy tieto siitä, mitä siihen on laitettu.

Miksi tämä kannattaa lukea

Viikkotehtävässä 2 tehdään sama asia pelilaudalla: tila kulkee linkeissä ja lomakkeen piilokentissä eikä mitään saa tallentaa palvelimelle. Ostoskorin rakenne on siis suoraan sen tehtävän runko.

Ongelma

Käyttäjä lisää tuotteen koriin. Seuraava pyyntö on HTTP-protokollan kannalta täysin erillinen: palvelin ei tiedä siitä mitään, mitä edellisellä kerralla tapahtui. Mistä se siis tietää, mitä korissa on?

Vaihtoehtoja on neljä, ja jokaisella on paikkansa:

TapaTila sijaitseeTässä esimerkissä
TietokantaPalvelimella Ei — sovellus ei saa tallentaa mitään
Sessio tai evästeSelaimessa, osin palvelimella Ei — molemmat tallentavat tietoa selaimeen
Osoitteen parametriLinkissä Kyllä
Lomakkeen piilokenttäDokumentissa Kyllä

Kaksi viimeistä ovat ainoat, jotka toimivat ilman mitään palvelimelle tai selaimeen tallennettavaa. Ne ovat myös ne kaksi, jotka viikkotehtävässä 2 ovat sallittuja.

Tilan kiertokulku

Ostoskorin tilan kiertokulku Palvelimella oleva Python-tietorakenne muunnetaan json.dumps-funktiolla merkkijonoksi. Merkkijono päätyy selaimelle kahteen paikkaan: lomakkeen piilokenttään ja linkkien osoitteisiin. Seuraavassa pyynnössä se palaa palvelimelle, tarkistetaan ja muunnetaan takaisin tietorakenteeksi. Kierros alkaa alusta, eikä palvelimelle jää pyyntöjen välillä mitään. PALVELIN SELAIN PALVELIN, SEURAAVA PYYNTÖ Python-tietorakenne [{"id": 1, "tuote": "Kahvi", "lkm": 2, "hinta": 4.5}] json.dumps tiiviissä muodossa JSON-merkkijono [{"id":1,"tuote":"Kahvi", "lkm":2,"hinta":4.5}] {{ tallenna }} url_for(...) Lomakkeen piilokenttä <input type="hidden" value="[{&#34;id&#34;:1,...}]" /> autoescape koodaa lainausmerkit Linkin osoite ?tuotteet=%5B%7B%22id%22%3A1... &amp;lisaa=1 prosenttikoodaus ja entiteetit POST-lomake GET-linkki lue_tuotteet() json.loads + rakenteen tarkistus parametri on käyttäjän syötettä kierros alkaa alusta Palvelimelle ei jää pyyntöjen välillä mitään: koko ostoskori kulkee jokaisessa lomakkeessa ja jokaisessa linkissä. Sivun koko kasvaa siksi tuotteiden määrän neliössä.
Sama merkkijono kolmessa paikassa: palvelimen muuttujassa, piilokentässä ja linkin osoitteessa.

Kaaviossa on koko sovelluksen idea. Huomaa erityisesti, että merkkijono esiintyy selaimelle lähtevässä dokumentissa kahdessa eri muodossa: piilokentässä lainausmerkit ovat entiteetteinä ja linkin osoitteessa kaikki on prosenttikoodattua. Kumpikin koodaus tehdään eri työkalulla, ja molempia tarvitaan.

Tietorakenne

Kori on lista sanakirjoja. Jokaisella tuotteella on oma tunniste:

tuotteet = [
    {"id": 1, "tuote": "Kahvi", "lkm": 2, "hinta": 4.5},
    {"id": 2, "tuote": "Maito", "lkm": 1, "hinta": 1.2},
]
Miksi tunniste eikä listan indeksi?

Houkuttelevaa on viitata tuotteeseen sen paikalla listassa: ?poista=1 tarkoittaisi toista alkiota. Ongelma ilmenee heti ensimmäisen poiston jälkeen: indeksit siirtyvät, mutta sivulla jo näkyvät linkit osoittavat yhä vanhoihin paikkoihin. Käyttäjä poistaa väärän tuotteen — ja jos hän painaa selaimen paluupainiketta ja klikkaa uudelleen, väärän tuotteen poistaa vielä toistamiseen.

Tunniste ratkaisee tämän: se pysyy samana riippumatta siitä, mitä listalle tapahtuu. Uusi tunniste on suurin käytössä oleva plus yksi:

def uusi_tunniste(tuotteet):
    #default-parametri hoitaa tyhjän listan tapauksen
    return max((tuote["id"] for tuote in tuotteet), default=0) + 1

Serialisointi

Tietorakenne on muunnettava merkkijonoksi, jotta se mahtuu lomakkeen kenttään ja osoitteeseen. Siihen käytetään json-kirjastoa. Muodon tiiviydellä on väliä, koska osoitteen pituus on rajallinen:

import json

#separators poistaa turhat välilyönnit avainten ja arvojen väliltä
def tallenna_json(tuotteet):
    return json.dumps(tuotteet, separators=(",", ":"))

Viiden tuotteen korista syntyy tällainen merkkijono:

[{"id":1,"tuote":"Kahvi","lkm":2,"hinta":4.5},{"id":2,"tuote":"Maito","lkm":2,
"hinta":1.2},{"id":3,"tuote":"Leip\u00e4","lkm":2,"hinta":2.9},...]

Pituus on 232 merkkiä — mutta osoitteessa se on prosenttikoodattuna 436 merkkiä, koska jokainen lainausmerkki, aaltosulje ja pilkku vie koodattuna kolme merkkiä. Tämä kannattaa muistaa, kun arvioi osoitteen pituusrajaa.

Sivun koko kasvaa neliössä

Jokainen lomake ja jokainen linkki sisältää koko korin. Kun tuotteita on N, sivulla on N kappaletta N tuotteen mittaisia kopioita. Mitattuna se näyttää tältä:

TuotteitaJSON, merkkiäPisin linkki, merkkiä Sivu, tavua
1461032 066
31412485 571
523238710 982
209631 477115 887

Kahdellakymmenellä tuotteella pisin linkki on jo lähellä osoitteen noin 2000 merkin rajaa ja sivu on yli sata kilotavua. Siksi esimerkissä korin koko on rajoitettu, ja siksi viikkotehtävässä 2 tilan esitysmuoto on mietittävä tarkkaan. Tilattomuus maksaa kaistaa.

Piilokenttä on käyttäjän syötettä

Tämä on esimerkin tärkein opetus. Piilokentän nimi antaa ymmärtää, että sen sisältö olisi jotenkin suojattu. Se ei ole: arvon näkee sivun lähdekoodista, sitä voi muokata kehittäjätyökaluilla, ja saman parametrin voi kirjoittaa käsin suoraan osoitteeseen.

Tavallinen tapa varautua on napata JSONDecodeError-poikkeus. Se ei riitä, koska json.loads onnistuu myös silloin, kun tulos ei ole lista sanakirjoja:

Parametrin arvojson.loadsMitä tapahtuu ilman tarkistusta
ei-jsoniapoikkeus napataan, kori tyhjenee
5onnistuu for t in tuotteet kaatuu: TypeError
{}onnistuu t["id"] kaatuu
[1,2]onnistuu TypeError
[{"id":"x"}]onnistuu puuttuvat avaimet, KeyError
[{"lkm":"inf","hinta":"NaN"}] onnistuukelvoton luku päätyy laskentaan

Jokainen näistä tuottaisi 500-virheen. Ratkaisu on tarkistaa rakenne ja rajata arvot:

def lue_tuotteet(merkkijono):
    try:
        tuotteet = json.loads(merkkijono)
    except (json.JSONDecodeError, TypeError):
        return []

    #json.loads onnistuu myös luvulle, merkkijonolle ja sanakirjalle
    if not isinstance(tuotteet, list):
        return []

    kelvolliset = []
    for alkio in tuotteet[:MAX_TUOTTEITA]:
        if not isinstance(alkio, dict):
            continue
        try:
            kelvolliset.append({
                "id": int(alkio["id"]),
                "tuote": str(alkio["tuote"])[:MAX_NIMI],
                "lkm": rajaa_kokonaisluku(alkio["lkm"], 0, MAX_LKM, 1),
                "hinta": rajaa_luku(alkio["hinta"], 0.0, MAX_HINTA, 0.0),
            })
        except (KeyError, TypeError, ValueError):
            #yksittäinen kelvoton alkio ohitetaan, muut säilyvät
            app.logger.info("Ohitettiin kelvoton tuote ostoskorissa")

    return kelvolliset

Huomaa myös rajaus tuotteet[:MAX_TUOTTEITA] ja nimen katkaisu: ilman niitä käsin kirjoitettu parametri voisi kasvattaa sivun mielivaltaisen suureksi. Liukuluvuissa on oma sudenkuoppansa — float("inf") ja float("nan") eivät nosta poikkeusta, mutta json.dumps kirjoittaisi niistä arvot Infinity ja NaN, jotka eivät ole kelvollista JSONia. Siksi rajaa_luku tarkistaa arvon math.isfinite-funktiolla.

Toiminnot ja metodit

Sovelluksessa on kolme toimintoa: lisäys, määrän kasvatus ja poisto. Kaikki muuttavat tilaa, joten ne kuuluvat POST-metodille. GET on turvallinen metodi, jonka selain, välimuisti tai hakurobotti saa toistaa vapaasti.

def kasittele_toiminnot(tuotteet, data):
    lisaa = data.get("lisaa", 0, type=int)
    poista = data.get("poista", 0, type=int)

    if lisaa:
        for tuote in tuotteet:
            if tuote["id"] == lisaa:
                tuote["lkm"] = min(tuote["lkm"] + 1, MAX_LKM)

    if poista:
        tuotteet = [tuote for tuote in tuotteet if tuote["id"] != poista]

    nimi = data.get("tuote", "").strip()[:MAX_NIMI]
    if nimi:
        if len(tuotteet) >= MAX_TUOTTEITA:
            return tuotteet, f"Ostoskoriin mahtuu enintään {MAX_TUOTTEITA} tuotetta."
        tuotteet.append({
            "id": uusi_tunniste(tuotteet),
            "tuote": nimi,
            "lkm": rajaa_kokonaisluku(data.get("lkm", 1, type=int), 1, MAX_LKM, 1),
            "hinta": rajaa_luku(data.get("hinta", 10.0, type=float), 0.0, MAX_HINTA, 10.0),
        })

    return tuotteet, None

Esimerkissä on silti mukana myös linkkiversio samasta lisäystoiminnosta. Se on tarkoituksellinen vastaesimerkki: sen avulla näkee, miten sama tilamuutos voidaan tehdä osoitteen parametrilla, ja samalla sen, miksi niin ei kannata tehdä. Viikkotehtävässä 2 nappuloiden poisto tehdään kuitenkin juuri linkeillä, koska siellä harjoitellaan tilan kuljettamista osoitteessa.

POST, uudelleenohjaus ja GET

Kun lisäykseen vastataan suoraan sivulla, selaimen päivityspainike kysyy lomakkeen lähettämistä uudelleen ja sama tuote lisätään toistamiseen. Siksi käsittelijä vastaa uudelleenohjauksella:

if request.method == "POST":
    tuotteet, virhe = kasittele_toiminnot(tuotteet, request.form)

    if virhe is None:
        #303: selain hakee tuloksen GET-pyynnöllä, joten päivittäminen
        #ei lisää samaa tuotetta uudelleen
        return redirect(url_for("simplekori", tuotteet=tallenna_json(tuotteet)),
                        code=303)

Huomaa kuitenkin yksi rajoitus: POST/Redirect/GET vie koko tilan ohjausosoitteeseen, eli osoitteen pituusraja koskee sitäkin. Tässä esimerkissä se mahtuu, koska korin koko on rajoitettu. Viikkotehtävässä 2 rajaton undo-historia ei mahtuisi, joten siellä sen on kuljettava POST-lomakkeessa.

Template ja kolme koodausta

Tuotteen esitysasu on makrona, ja osoitteet muodostetaan url_for-funktiolla:

{% macro kuvaus(tuote) -%}
  {{ tuote.tuote }} — {{ tuote.lkm }} kpl à {{ "%.2f"|format(tuote.hinta) }} euroa
{%- endmacro %}

<input type="hidden" name="tuotteet" value="{{ tallenna }}" />

<a href="{{ url_for('simplekori', tuotteet=tallenna, lisaa=tuote.id) }}">Lisää yksi</a>

Samaan linkkiin osuu kolme eri koodausta, ja niiden sekoittaminen on yleisin virhe tässä tehtävätyypissä:

KoodausMitä tekeeKuka hoitaa
JSON-serialisointi Tietorakenteesta merkkijono json.dumps
Prosenttikoodaus Merkkijonosta kelvollinen osoite url_for
HTML-entiteetit Osoitteesta kelvollinen attribuutin arvo Jinjan autoescape

Mitään näistä ei tarvitse tehdä käsin. Jos osoite kootaan merkkijonoja liimaamalla, ainakin yksi kolmesta jää tekemättä — ja XHTML-mediatyypillä yksikin koodaamaton &-merkki kaataa koko sivun jäsennysvirheeseen.

MVC tässä esimerkissä

OsaOstoskorissa
Malli lue_tuotteet, tallenna_json, uusi_tunniste, kasittele_toiminnot — korin rakenne ja säännöt
Ohjain simplekori-näkymäfunktio — lukee pyynnön, kutsuu mallia, valitsee vastauksen
Näkymä simple.html makroineen — pelkkä esitysasu

Näkymäfunktiossa ei ole yhtään kulmasuljetta eikä templatessa yhtään laskentaa. Kts. MVC-arkkitehtuuri.

Luokkaversio

Esimerkistä on toinen versio, jossa tuotteet ovat oman Tuote-luokan olioita. Toiminnallisuus on sama; ero on siinä, että oma luokka ei serialisoidu JSONiksi ilman apua.

Oman luokan serialisointi

JSONiin kirjoitettaessa tarvitaan funktio, joka kertoo miten olio muunnetaan sanakirjaksi, ja luettaessa funktio, joka tunnistaa sanakirjan omaksi luokakseen. Tunnistaminen hoidetaan omalla avaimella:

@dataclass
class Tuote:
    id: int
    nimi: str
    lkm: int = 1
    hinta: float = 1.0

def encode_tuote(olio):
    """Serialisoi Tuotteen JSONiin sopivaksi sanakirjaksi."""
    if isinstance(olio, Tuote):
        return {"_tuote": True, "id": olio.id, "nimi": olio.nimi,
                "lkm": olio.lkm, "hinta": olio.hinta}
    raise TypeError(f"{olio!r} ei ole JSON-serialisoitava")

def as_tuote(sanakirja):
    """Deserialisoi sanakirjan takaisin Tuotteeksi, jos se on sellainen."""
    if not sanakirja.get("_tuote"):
        return sanakirja
    return Tuote(id=int(sanakirja["id"]), nimi=str(sanakirja["nimi"]),
                 lkm=int(sanakirja["lkm"]), hinta=float(sanakirja["hinta"]))

tallenna = json.dumps(tuotteet, default=encode_tuote, separators=(",", ":"))
tuotteet = json.loads(merkkijono, object_hook=as_tuote)
Tunnisteen on kuljettava serialisoinnin yli

Houkuttelevalta näyttävä ratkaisu on antaa luokan jakaa tunnisteet itse luokkamuuttujasta, joka kasvaa jokaisen uuden olion myötä. Se ei toimi tilattomassa sovelluksessa: CGI-ympäristössä laskuri nollautuu joka pyynnöllä, ja jos tunnistetta ei serialisoida mukaan, oliot saavat purkaessa uudet numerot. Silloin ?lisaa=3 osuu oikeaan tuotteeseen vain sattumalta.

Tunniste annetaan siis aina ulkopuolelta ja se on mukana encode_tuote-funktion tuottamassa sanakirjassa.

Milloin näin ei kannata tehdä

Tilattomuus on tässä harjoitus, ei suositus. Oikea verkkokauppa säilyttäisi korin palvelimella, koska silloin se ei katoa selainta vaihdettaessa, ei rajoitu neljään kilotavuun eikä kasvata jokaista sivulatausta. Vertailu:

TilaHyväHuono
Osoitteessa ja piilokentässä Ei vaadi mitään palvelimelta; linkin voi jakaa Pituusraja; sivun koko kasvaa; käyttäjän muokattavissa
Sessiossa Ei näy osoitteessa; helppo käyttää Vaatii evästeen ja salaisen avaimen; noin 4 kt
Tietokannassa Säilyy istunnon yli; ei kokorajaa Vaatii kannan ja käyttäjän tunnistamisen

Olennaista on, että syötteiden tarkistus tarvitaan kaikissa vaihtoehdoissa. Vain sen kohde vaihtuu.

Kokeiltavaa

  1. Lisää koriin muutama tuote ja katso osoiteriviä. Tunnistatko siitä JSON-merkkijonon prosenttikoodattuna?
  2. Avaa kehittäjätyökalut ja muokkaa piilokentän arvoa ennen lomakkeen lähettämistä. Mitä tapahtuu, jos kirjoitat sen tilalle 5? Entä jos muutat jonkin tuotteen hinnan negatiiviseksi?
  3. Kirjoita osoitteeseen käsin ?tuotteet=[{"id":1}]. Kaatuuko sovellus?
  4. Lisää kymmenen tuotetta ja laske sivun lähdekoodista, montako kertaa sama JSON-merkkijono esiintyy siinä.
  5. Mieti, mitä pitäisi muuttaa, jos tunnisteen tilalla käytettäisiin listan indeksiä. Mikä hajoaisi ensimmäisenä?

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti