WSGI, CGI ja Flask

Python-sovellus ei koskaan puhu selaimen kanssa suoraan, vaan aina jonkin www-palvelinrajapinnan välityksellä. Tällä kurssilla rajapintoja on kaksi:

Huomaa

Sovelluskoodiisi tämä ei vaikuta. Kirjoitat tavallisen Flask-sovelluksen, ja rajapinta vaihtuu vain sen mukaan, millä käynnistystiedostolla sovellus ajetaan. Tässä luennossa CGI:tä käsitellään vain siltä osin kuin sitä tarvitaan Flaskin käynnistämiseen users.jyu.fi-palvelimella.

Käytännön asennusohjeet ja harjoitukset ovat pääteohjauksessa 1.

WSGI lyhyesti

WSGI-sovellus on funktio (tai mikä tahansa kutsuttava olio), joka saa kaksi parametria ja palauttaa vastauksen sisällön tavujonoina:

# environ on dict, joka sisältää CGI-tyyliset ympäristömuuttujat: pyydetyn
#     osoitteen, metodin, otsakkeet ja pyynnön rungon (wsgi.input)
# start_response on funktio, jolla asetetaan HTTP-status ja HTTP-otsakkeet
def application(environ, start_response):
    sisalto = "Ympäristömuuttujat\n"
    for avain in environ:
        sisalto += "%s\t:\t%s\n" % (avain, environ[avain])

    status = "200 OK"
    otsakkeet = [
        ("Content-Type", "text/plain;charset=UTF-8"),
        ("Content-Length", str(len(sisalto.encode("UTF-8")))),
    ]
    start_response(status, otsakkeet)

    # Paluuarvo on iteroituva kokoelma tavujonoja, ei merkkijonoja
    return [sisalto.encode("UTF-8")]

Olennaista:

Flask on rakennettu Werkzeugin päälle, ja Werkzeug hoitaa juuri tämän rajapinnan. app-olio on WSGI-sovellus. Siksi et joudu itse kirjoittamaan edelläolevan kaltaista koodia kuin korkeintaan kerran opetusmielessä.

ASGI ja rajapintojen erot

ASGI (Asynchronous Server Gateway Interface) on WSGI:n asynkroninen seuraaja. Sitä ei tällä kurssilla käytetä, mutta se kannattaa tuntea, koska uudet Python-kehykset rakentuvat sen varaan.

WSGI-sovellus on tavallinen funktio, joka ottaa vastaan yhden pyynnön ja palauttaa yhden vastauksen. ASGI-sovellus on asynkroninen funktio, joka saa kolme parametria:

async def application(scope, receive, send):
    # scope kertoo yhteyden tyypin ja tiedot: "http", "websocket" tai "lifespan"
    # receive odottaa seuraavaa viestiä asiakkaalta
    # send lähettää viestin asiakkaalle
    await send({"type": "http.response.start", "status": 200,
                "headers": [(b"content-type", b"text/plain; charset=utf-8")]})
    await send({"type": "http.response.body", "body": "Hello World".encode("UTF-8")})

Erot ovat käytännössä nämä:

Quart on näistä lähimpänä tuttua: se on Flaskin rajapinnan asynkroninen toteutus samalta Pallets-projektilta, joten reitit, templatet ja request-olio toimivat samoin kuin Flaskissa. Käytännössä route-funktiot vain määritellään async def-muodossa:

from quart import Quart, render_template

app = Quart(__name__)

@app.route("/")
async def etusivu():
    return await render_template("lauta.html", koko=8)

Koska sovellus on ASGI-sovellus, Quartilla voi kirjoittaa myös WebSocket- käsittelijöitä ja striimattuja vastauksia, mikä ei Flaskilla onnistu. Suurin osa Flask-laajennuksista toimii sellaisenaan, ja dokumentaatiossa on oma ohjeensa Flask-sovelluksen siirtämiseen Quartille.

Kurssin loppupuolen tehtävissä sovellukset julkaistaan Googlen Cloud Run -palvelussa, jossa ASGI-sovellus toimii siinä missä WSGI-sovelluskin. Niissä Quart on siis ihan käyttökelpoinen valinta, jos haluat kirjoittaa asynkronista koodia. Sen sijaan users.jyu.fi-palvelimella Quartia ei voi käyttää, koska siellä ei aja edes WSGI-sovelluksia.

Rajapintojen vertailu
Rajapinta Prosessimalli Samanaikaisuus Soveltuu
CGI uusi prosessi joka pyynnölle ei mitään; tulkin käynnistys joka kerta pienet sovellukset ja rajoittuneet palvelimet, kuten users.jyu.fi
WSGI tulkki jää muistiin säie tai prosessi kutakin pyyntöä kohti tavalliset sivustot ja lomakesovellukset
ASGI tulkki jää muistiin useita pyyntöjä samassa säikeessä; odotusaika hyödynnetään WebSocketit, striimaus, paljon rinnakkaisia yhteyksiä

Sovelluslogiikan kannalta valinta ei useinkaan näy: reitit ja templatet kirjoitetaan samalla tavalla, ja rajapinta ratkaisee lähinnä sen, miten palvelin käynnistää sovelluksen ja montako pyyntöä se pystyy hoitamaan yhtä aikaa.

WSGI ja PythonAnywhere

PythonAnywhere ajaa sovelluksia WSGI-rajapinnan kautta. Web-välilehdellä luodaan uusi sovellus (Add a new web app), ja tyypiksi valitaan joko Manual configuration tai suoraan Flask.

WSGI configuration file on tiedosto, josta palvelin etsii nimenomaan application-nimisen olion. Flask-sovelluksessa riittää tuoda oma app ja nimetä se uudelleen:

import sys

# oman koodin kansio hakupolkuun
polku = "/home/oma_tunnus/mysite"
if polku not in sys.path:
    sys.path.insert(0, polku)

from oma import app as application

Log files -kohdasta löytyvät lokit:

import sys
print("tämä näkyy server.logissa")
print("tämä näkyy error.logissa", file=sys.stderr)

Huomaa ero CGI-ympäristöön: CGI-ohjelmassa print menisi suoraan selaimelle ja rikkoisi vastauksen.

Virheet saa myös suoraan selaimeen lisäämällä WSGI-konfiguraatiotiedostoon:

from werkzeug.debug import DebuggedApplication
application.debug = True
application = DebuggedApplication(application)
Varoitus

Älä käytä globaaleja muuttujia sovelluksen tilan säilyttämiseen. Globaalit muuttujat ovat prosessikohtaisia:

Sovelluksen tila on siis tallennettava muualle: dokumentin rakenteeseen, osoitteeseen, evästeisiin, sessioon tai tietokantaan.

CGI ja Flask users.jyu.fi-palvelimella

users.jyu.fi-palvelin ei aja WSGI-sovelluksia, joten Flask on käynnistettävä CGI-ohjelmana. CGI:stä riittää tietää seuraava:

Yksinkertaisin mahdollinen CGI-ohjelma näyttää tältä:

#!/usr/bin/python3
# -*- coding: utf-8 -*-

print("""Content-Type: text/plain; charset=UTF-8

Hello world!""")

Tätä käsin tulostamista ei tarvitse tehdä omassa sovelluksessa — Flask hoitaa otsakkeet puolestasi. Yllä oleva hello.cgi kannattaa silti kokeilla ensin, koska sillä varmistuu, että palvelimen asetukset ja tiedosto-oikeudet ovat kunnossa.

Asennuksen tarkistuslista

Viimeisimmät tiedot löytyvät aina digipalvelujen ohjeesta: CGI/SSI-tekniikoiden käyttäminen www-palveluissa (users.jyu.fi/groups.jyu.fi). Ohje on ristiriitatilanteessa aina tämän sivun edellä.

[tunnus@halava ties4080]$ chgrp users hello.cgi
[tunnus@halava ties4080]$ chmod 755 hello.cgi
[tunnus@halava ties4080]$ chmod go-w .
[tunnus@halava ties4080]$ ls -al hello.cgi
-rwxr-xr-x. 1 tunnus users 3777 Feb  5 09:59 hello.cgi

Yleisin syy Internal Server Error -virheeseen on jokin näistä, ei ohjelmakoodi.

Virtuaaliympäristö ja shebang

Flask asennetaan omaan virtuaaliympäristöön cgi-bin/ties4080/-kansioon:

python3 -m venv venv
. venv/bin/activate
pip install --upgrade pip
pip install Flask Flask-WTF
deactivate

Shebang-rivin on osoitettava tämän virtuaaliympäristön tulkkiin. Polku ei ole sama kuin se, jonka näet halava/jalava-koneessa pwd-komennolla, koska ohjelma suoritetaan karahka2-palvelimella:

#!/home/oma_tunnus/public_html/cgi-bin/ties4080/venv/bin/python

flask.cgi ja oma.py

flask.cgi on ainoa tiedosto, jossa CGI näkyy. Se toimii siltana: ottaa Flask-sovelluksen ja ajaa sen CGI-rajapinnan kautta. Sisällöltään se vastaa sitä, mitä PythonAnywhere kirjoittaa WSGI-konfiguraatiotiedostoon.

#!/home/oma_tunnus/public_html/cgi-bin/ties4080/venv/bin/python
# -*- coding: utf-8 -*-
# suorittaa Flask-sovelluksen CGI-ohjelmana users.jyu.fi-palvelimella
import sys
from wsgiref.handlers import CGIHandler

try:
    from oma import app as application
    from werkzeug.debug import DebuggedApplication

    if __name__ == "__main__":
        application.debug = True
        CGIHandler().run(DebuggedApplication(application))

except Exception:
    # Tänne päädyttäessä Werkzeug ei toimi, joten HTTP-otsake on tulostettava
    # itse. STDOUT menee tässä tapauksessa suoraan selaimelle.
    print("Content-Type: text/plain;charset=UTF-8\n")
    print("Sovellus ei käynnisty:\n")
    for virhe in sys.exc_info():
        print(virhe)

wsgiref on Pythonin vakiokirjastoa, ja sen CGIHandler on juuri se palanen, joka kääntää CGI-pyynnön WSGI-kutsuksi. Varsinainen sovellus on tavallinen Flask-sovellus tiedostossa oma.py — tiedoston nimen on vastattava flask.cgi:n from oma import app-riviä:

# -*- coding: utf-8 -*-
from flask import Flask, Response, request

app = Flask(__name__)

# @app.route määrää, mille osoitteelle tämä funktio suoritetaan
@app.route("/")
def hello_world():
    return Response("Hello World", content_type="text/plain; charset=UTF-8")

Sovellus avataan osoitteesta

https://users.jyu.fi/~oma_tunnus/cgi-bin/ties4080/flask.cgi/

Viimeinen /-merkki on olennainen, koska se vastaa @app.route("/")-riviä. Kaikki flask.cgi:n jälkeen tulevat polut ohjautuvat Flaskin reiteille: .../flask.cgi/laskuri vastaa reittiä @app.route("/laskuri").

Debuggaus

Ensisijainen tapa on debugata omalla koneella, jossa Flaskin oma kehityspalvelin näyttää virheet suoraan:

flask run --debug

Palvelimella syntaksivirheet löytyvät ajamalla ohjelma komentoriviltä virtuaaliympäristön tulkilla. Sovellus ei voi tällöin oikeasti toimia, mutta koodin virheet paljastuvat:

. venv/bin/activate
python oma.py

users.jyu.fi-palvelimen omiin lokitiedostoihin ei ole pääsyä, joten virheet on joko näytettävä selaimessa (DebuggedApplication, ks. edellä) tai kirjoitettava omaan lokitiedostoon:

import logging, os
logging.basicConfig(filename=os.path.abspath("../hidden/flask.log"),
                    level=logging.DEBUG)

try:
    yhteys = sqlite3.connect(os.path.abspath("../hidden/resepti"))
except sqlite3.Error as e:
    logging.debug("Kanta ei aukea: %s", e)
Varoitus

Älä kirjoita lokia cgi-bin-kansioon tai sen alikansioihin. Se ei toimi, koska kansioon ei saa olla kirjoitusoikeutta.

Lokia on helpointa seurata pääteyhteydellä:

tail -f ../hidden/flask.log

Käytä except-lohkossa aina mahdollisimman tarkkaa poikkeustyyppiä. Kaiken kaappaava except: piilottaa myös omat ohjelmointivirheesi.

Merkistöt

Sinun on aina tiedettävä, mikä merkistö on merkkijonoissa käytössä. Käytä kaikkialla UTF-8:aa.

Merkistö kerrotaan HTTP-otsakkeessa. Flaskissa se asetetaan Response-olion kautta:

return Response(sisalto, content_type="text/html; charset=UTF-8")
return Response(sisalto, content_type="text/plain; charset=UTF-8")
# kurssin viikkotehtävissä käytetään XHTML:ää:
return Response(sisalto, content_type="application/xhtml+xml; charset=UTF-8")

HTTP-otsakkeen pitäisi riittää, mutta jos merkistö mainitaan jossain muuallakin, myös siellä on oltava UTF-8: mahdollinen xml-deklaraatio, meta-elementti ja lomakkeen accept-charset-attribuutti.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fi" lang="fi">
  <head>
    <meta charset="UTF-8" />
    <title>Malli</title>
  </head>
  <body>
    <form action="" method="post" accept-charset="UTF-8">
    </form>
  </body>
</html>

Querystring ja lomakkeet

Sivun osoitteeseen voidaan liittää parametreja:

https://osoite.example/?parametri=arvo&parametri2=arvo1&parametri2=arvo2

https://osoite.example/?nimi=M%C3%A4lli%20Henkil%C3%B6&arvosana=5&arvosana=3&kurssi=Helppo+kurssi&kurssi=%C3%84ll%C3%B6+kurssi

Osoitteen perään lisätään ?-merkki, jonka jälkeen luetellaan avaimia ja arvoja &-merkillä eroteltuina. Samalla avaimella voi olla useita arvoja.

Parametrien lukeminen

Flaskissa parametrit luetaan Request-objektista. Lomakkeelta tulevat tiedot käsitellään täsmälleen samalla tavalla:

MultiDict toimii kuten tavallinen dict, mutta palauttaa avainta kohti vain ensimmäisen arvon. Kaikki arvot saa getlist-metodilla:

from flask import request

nimi = request.args.get("nimi", "Tuntematon")
# type-parametri muuntaa arvon ja palauttaa oletuksen, jos muunnos epäonnistuu
koko = request.args.get("l", 8, type=int)
arvosanat = request.args.getlist("arvosana")

Parametrien koodaaminen osoitteeseen

Osoitteessa esiintyvät erikoismerkit on koodattava percent-encoding-tavalla. Välilyönti, lainausmerkki, hakasulkeet ja ääkköset eivät kelpaa osoitteeseen sellaisenaan.

from urllib.parse import urlencode, quote_plus

# koko kyselymerkkijono kerralla, myös listat toimivat doseq-parametrilla
kysely = urlencode({"nimi": "Mälli Henkilö", "kurssi": "Ällö kurssi"})
# nimi=M%C3%A4lli+Henkil%C3%B6&kurssi=%C3%84ll%C3%B6+kurssi

# yksittäinen arvo
arvo = quote_plus("Ällö kurssi")

Flask-sovelluksessa osoitteet kannattaa rakentaa url_for-funktiolla, joka hoitaa koodauksen puolestasi:

from flask import url_for
osoite = url_for("lauta", l=8, p1="Mälli Henkilö")

Kun osoite sijoitetaan html-dokumenttiin, on lisäksi koodattava &-merkit &amp;-entiteetiksi. Tähän käytetään html.escape-funktiota:

import html
linkki = '<a href="' + html.escape(osoite) + '">Linkki</a>'
Muista

Jinja2-templateissa autoescape hoitaa html-koodauksen automaattisesti, joten templatessa riittää {{ osoite }}. Nämä ovat eri asioita: percent-encoding tekee merkkijonosta kelvollisen osoitteen, ja html.escape tekee osoitteesta kelvollisen attribuutin arvon. Molempia tarvitaan.

Vanha cgi-kirjasto

Vanhoissa esimerkeissä (myös tämän kurssin aiemmissa materiaaleissa) parametrit luetaan cgi-kirjaston FieldStorage-luokalla ja virheet näytetään cgitb-moduulilla:

import cgi, cgitb          # ei toimi enää Python 3.13:ssa
cgitb.enable()
fields = cgi.FieldStorage()
nimi = fields.getfirst("nimi", "Tuntematon")
arvosanat = fields.getlist("arvosana")
Vanhentunut

cgi- ja cgitb-moduulit poistettiin Pythonin vakiokirjastosta versiossa 3.13 (PEP 594). Flask-sovelluksessa niitä ei tarvita lainkaan: request.args ja request.form korvaavat FieldStorage-luokan, ja DebuggedApplication korvaa cgitb:n.

Jos joudut ylläpitämään vanhaa koodia, moduulit saa takaisin asentamalla legacy-cgi-paketin, jolloin vanhat import cgi- ja import cgitb-rivit toimivat sellaisenaan:

pip install legacy-cgi

Uutta koodia ei kannata kirjoittaa niiden varaan.

Esimerkkejä

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti