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.
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:
| Tapa | Tila sijaitsee | Tässä esimerkissä |
|---|---|---|
| Tietokanta | Palvelimella | Ei — sovellus ei saa tallentaa mitään |
| Sessio tai eväste | Selaimessa, osin palvelimella | Ei — molemmat tallentavat tietoa selaimeen |
| Osoitteen parametri | Linkissä | 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
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},
]
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ä:
| Tuotteita | JSON, merkkiä | Pisin linkki, merkkiä | Sivu, tavua |
|---|---|---|---|
| 1 | 46 | 103 | 2 066 |
| 3 | 141 | 248 | 5 571 |
| 5 | 232 | 387 | 10 982 |
| 20 | 963 | 1 477 | 115 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 arvo | json.loads | Mitä tapahtuu ilman tarkistusta |
|---|---|---|
ei-jsonia | poikkeus | napataan, kori tyhjenee |
5 | onnistuu | for t in tuotteet kaatuu: TypeError |
{} | onnistuu | t["id"] kaatuu |
[1,2] | onnistuu | TypeError |
[{"id":"x"}] | onnistuu | puuttuvat avaimet, KeyError |
[{"lkm":"inf","hinta":"NaN"}] |
onnistuu | kelvoton 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ä:
| Koodaus | Mitä tekee | Kuka 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ä
| Osa | Ostoskorissa |
|---|---|
| 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)
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:
| Tila | Hyvä | 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
- Lisää koriin muutama tuote ja katso osoiteriviä. Tunnistatko siitä JSON-merkkijonon prosenttikoodattuna?
- 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? - Kirjoita osoitteeseen käsin
?tuotteet=[{"id":1}]. Kaatuuko sovellus? - Lisää kymmenen tuotetta ja laske sivun lähdekoodista, montako kertaa sama JSON-merkkijono esiintyy siinä.
- Mieti, mitä pitäisi muuttaa, jos tunnisteen tilalla käytettäisiin listan indeksiä. Mikä hajoaisi ensimmäisenä?
Lisätietoa
- HTTP-protokolla — tilattomuus, metodit ja POST/Redirect/GET
- Flask ja MVC-arkkitehtuuri
- json — JSON encoder and decoder
- Viikkotehtävä 2
Käyttäjien kommentit