Osa 3

Tämän osan aiheita ovat versionhallinta (Git) sovelluksen kehittämisessä, sovelluksen siirtäminen tuotantoon julkiselle palvelimelle, virheiden etsiminen koodista sekä koodin rakenteen parantaminen jakamalla se tiedostoihin.

Kurssin lisämateriaalissa on Git-vinkkejä, joista on hyötyä sovelluksen kehittämisessä tällä kurssilla ja muutenkin.

Versionhallinta

Käytämme kurssilla versionhallintaan GitHubia. Seuraavaksi käymme läpi esimerkin, jossa aloitamme sovelluksen kehityksen GitHubissa.

Teemme pienen sovelluksen, joka tallentaa tietokantaan sivuston kävijöiden määrän ja näyttää tämän tiedon etusivulla. Esimerkki olettaa, että GitHubiin on luotu uusi repositorio tsoha-visitors, jossa on tiedosto README.md mutta ei vielä muuta.

Seuraavat komennot kloonaavat repositorion omalle koneelle, luovat sovellusta varten virtuaaliympäristön sekä asentavat tarvittavat kirjastot. Tässä ja myöhemmin vastaavissa kohdissa user on käytetty GitHub-tunnus.

$ git clone https://github.com/user/tsoha-visitors.git
Cloning into 'tsoha-visitors'...
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (3/3), done.
$ cd tsoha-visitors/
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install flask
(venv) $ pip install flask-sqlalchemy
(venv) $ pip install psycopg2
(venv) $ pip install python-dotenv

Luomme tietokantaan yhden taulun, johon tallennetaan jokaisen kävijän vierailuaika:

CREATE TABLE visitors (id SERIAL PRIMARY KEY, time TIMESTAMP);

Sovellus muodostuu seuraavista tiedostoista:

app.py

from flask import Flask
from flask import redirect, render_template
from flask_sqlalchemy import SQLAlchemy
from os import getenv

app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = getenv("DATABASE_URL")
db = SQLAlchemy(app)

@app.route("/")
def index():
    db.session.execute("INSERT INTO visitors (time) VALUES (NOW())")
    db.session.commit()
    result = db.session.execute("SELECT COUNT(*) FROM visitors")
    counter = result.fetchone()[0]
    return render_template("index.html", counter=counter) 

index.html

Tervetuloa!
<p>
Olet sivuston {{ counter }}. käyttäjä

.env

DATABASE_URL=postgresql:///user

Ideana on, että aina kun käyttäjä lataa etusivun, tauluun visitors lisätään uusi rivi. Tämän jälkeen haetaan taulun rivien määrä, joka kertoo kävijöiden yhteismäärän.

Nyt voimme kokeilla suorittaa sovelluksen:

(venv) $ flask run

Sovelluksen käyttäminen näyttää tältä:

Tiedostojen lisääminen repositorioon

Koska sovelluksen ensimmäinen versio toimii, nyt on hyvä hetki lisätä sovelluksen tiedostot repositorioon. Hyödyllinen komento on git status, joka näyttää repositorion tilanteen. Komento antaa nyt seuraavan tuloksen:

(venv) $ git status
On branch main
Your branch is up to date with 'origin/main'.

Untracked files:
  (use "git add <file>..." to include in what will be committed)

	.env
	__pycache__/
	app.py
	templates/
	venv/

nothing added to commit but untracked files present (use "git add" to track)

Komento antaa listan tiedostoista ja hakemistoista, joita ei ole repositoriossa:

Tärkeä asia versionhallinnassa on päättää, mitkä tiedostot laitetaan repositorioon kaikkien saataville. Tässä tapauksessa repositorioon kuuluvat app.py ja templates, jotka muodostavat sovelluksen toteutuksen. Sen sijaan .env, __pycache__ ja venv eivät kuulu repositorioon, koska ne liittyvät sovelluksen kehittäjän ympäristöön eivätkä sovelluksen toteutukseen.

Komento git add laittaa tiedostoja lisättäväksi:

(venv) $ git add app.py
(venv) $ git add templates

Nyt git status näyttää muuttuneen tilanteen näin:

(venv) $ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	new file:   app.py
	new file:   templates/index.html

Untracked files:
  (use "git add <file>..." to include in what will be committed)

	.env
	__pycache__/
	venv/

Tämä näyttää hyvältä, koska oikeat tiedostot ovat menossa repositorioon, joten voimme suorittaa komennot git commit ja git push:

(venv) $ git commit -m "Create first version"
(venv) $ git push

Tämän seurauksena sovelluksen nykyinen versio on tallessa GitHubissa.

Tiedosto .gitignore

Tiedosto .env ja hakemistot __pycache__ ja venv eivät siis kuulu repositorioon, mutta ne näkyvät häiritsevästi listassa aina, kun suoritamme komennon git status.

Hyödyllinen tiedosto on .gitignore, joka määrittää, mitä tiedostoja ja hakemistoja emme halua viedä repositorioon. Tässä tapauksessa tiedoston sisältö voisi olla:

.env
__pycache__
venv

Tämän tiedoston luomisen jälkeen git status alkaa näyttää siistimmältä:

(venv) $ git status
On branch main
Your branch is up to date with 'origin/main'.

Untracked files:
  (use "git add <file>..." to include in what will be committed)

	.gitignore

nothing added to commit but untracked files present (use "git add" to track)

Tiedosto .gitignore kuitenkin lisätään repositorioon:

(venv) $ git add .gitignore 
(venv) $ git commit -m "Add .gitignore"
(venv) $ git push

Tästä lähtien tiedostossa .gitignore mainitut tiedostot ja hakemistot eivät ole ehdolla repositorioon lisättäväksi. Projektin kehityksen aikana tiedostoon .gitignore voi lisätä tarvittaessa lisää sisältöä.

Sovelluksen riippuvuudet

Komento pip freeze kertoo, mitkä ovat sovelluksen riippuvuudet eli mitä kirjastoja sovellus tarvitsee toimiakseen. Kun suoritamme komennon nyt, saamme seuraavan tuloksen:

(venv) $ pip freeze
click==7.1.2
Flask==1.1.2
Flask-SQLAlchemy==2.4.4
itsdangerous==1.1.0
Jinja2==2.11.3
MarkupSafe==1.1.1
pkg-resources==0.0.0
psycopg2==2.8.6
python-dotenv==0.15.0
SQLAlchemy==1.3.23
Werkzeug==1.0.1

Tämä lista kertoo jokaisesta kirjastosta, minkä kirjaston version sovellus vaatii. Huomaa, että jos suoritat komennon itse, voit saada vähän eri versioita.

Sovelluksen riippuvuuksista on tapana tehdä tiedosto requirements.txt. Tämä tiedosto tallennetaan repositorioon:

(venv) $ pip freeze > requirements.txt
(venv) $ git add requirements.txt 
(venv) $ git commit -m "Add requirements"
(venv) $ git push

Nyt jos toinen henkilö hakee sovelluksen GitHubista, hän voi asentaa virtuaaliympäristöönsä tarvittavat kirjastot seuraavalla komennolla:

(venv) $ pip install -r requirements.txt

Tietokannan rakenne

Repositoriosta puuttuu vielä tieto siitä, mikä on sovelluksen käyttämän tietokannan rakenne. Tätä varten luomme tiedoston schema.sql, joka sisältää tietokannan skeeman. Tässä sovelluksessa tietokannassa on vain yksi taulu ja tiedoston sisältö on seuraava:

CREATE TABLE visitors (id SERIAL PRIMARY KEY, time TIMESTAMP);

Lisäämme uuden tiedoston repositorioon:

(venv) $ git add schema.sql 
(venv) $ git commit -m "Add SQL schema"
(venv) $ git push

Tästä lähtien sovelluksen tarvitsemat taulut voi luoda tietokantaan seuraavasti ohjaamalla tiedostossa schema.sql olevat komennot PostgreSQL-tulkille:

(venv) $ psql < schema.sql

Huomaa, että aina kun sovelluksen tietokannan rakenne muuttuu, myös tiedostosta schema.sql täytyy tehdä uusi versio ja lähettää se repositorioon. Tämän avulla repositoriossa on aina tieto siitä, millainen on sovelluksen senhetkinen tietokanta.

Sovellus tuotantoon

Sovelluksen laittaminen tuotantoon tarkoittaa, että sovellus julkaistaan käyttäjille. Tällä kurssilla harjoittelemme tuotantoon laittamista Fly.io-palvelun avulla. Fly.io tarjoaa ilmaiseksi palvelintilaa, jonne voi sijoittaa muun muassa Flaskilla toteutetun web-sovelluksen.

HUOM! Vuoden 2023 alusta alkaen sovelluksen ei tarvitse olla testattavissa tuotantopalvelimella. Riittää, että sen saa käynnistettyä paikallisesti. Lisätietoja täällä.

Käymme läpi seuraavaksi esimerkin, jossa siirrämme äsken luodun kävijäsovelluksen Fly.ioon. Jotta voit käyttää Fly.ioa, sinun täytyy luoda ensin tunnus palveluun. Tunnuksen luominen on ilmaista, mutta huomaa, että Fly.io tarjoaa myös maksullisia palveluja. Voit myös kirjautua Github-tunnuksillasi.

Flyn ilmaisversiossa on tiettyjä rajoituksia erityisesti suorituskykyyn liittyen. Tämän kurssin puitteisiin ilmaisversion tarjoamat ominaisuudet kuitenkin riittävät hyvin. Voit lukea palvelun rajoituksista tarkemmin täältä sekä ilmaisten Postgres-tietokantojen rajoituksista täältä.

Fly.iossa olevaa sovellusta voidaan hallinnoida kahdella tavalla: nettiselaimella Fly.ion sivuston hallintapaneelin kautta tai omalle koneelle asennettavan komentorivityökalun avulla. Huomaa asennuksen lopussa tuleva ohjeistus lisätä flyctl PATH-ympäristömuuttujaan. Pääset muokkaamaan oikeaa tiedostoa esimerkiksi komennolla gedit ~/.bashrc. Lisää annetut rivit tiedoston loppuun. Seuraava ohje näyttää, miten komentorivityökalu toimii.

Komentorivityökalu

Komentorivityökalun käyttö alkaa kirjautumalla sisään:

$ fly auth login

Komento avaa nettiselaimen, jonka kautta pystyy kirjautumaan. Kirjautumisen jälkeen komentorivityökalu on käyttökunnossa.

Komento fly help näyttää listan komentorivityökalun komennoista. Vastaavasti voi myös pyytää neuvoa tietyn komennon käyttämisestä: esimerkiksi fly apps help kertoo, miten komentoa fly apps käytetään.

Sovelluksen luonti

Komento fly launch luo uuden Fly.io-sovelluksen:

$ fly launch
Creating app in /home/user/tsoha-visitors
Scanning source code
Detected a Python app
Using the following build configuration:
	Builder: paketobuildpacks/builder:base
? App Name (leave blank to use an auto-generated name): tsoha-visitors
Automatically selected personal organization: user
? Select regions:  [Use arrows to move, type to filter]
  Amsterdam, Netherlands (ams)
  ...
> Frankfurt, Germany (fra)
  São Paulo (gru)
  ...
  Miami, Florida (US) (mia)
? Select region: fra (Frankfurt, Germany)
Created app tsoha-visitors in organization personal
Wrote config file fly.toml
? Would you like to set up a Postgresql database now? No
We have generated a simple Procfile for you. Modify it to fit your needs and run "fly deploy" to deploy your application.

Komento pyytää käyttäjältä sovelluksen nimeä sekä palvelimen sijaintia. Palvelimen sijainniksi kannattaa valita Euroopan alueella sijaitseva palvelin, esimerkiksi Frankfurt.

Jokaisella Fly.iossa olevalla sovelluksella tulee olla yksilöllinen nimi. Tämän materiaalin kirjoitushetkellä kukaan ei ollut luonut sovellusta nimellä tsoha-visitors, joten sovelluksen luonti onnistui. Kuitenkaan et voi itse luoda tämän nimistä sovellusta, koska se on jo olemassa. Jos et anna sovellukselle nimeä, sille tulee automaattisesti satunnainen nimi. Jos teet sovelluksen vain kokeilua tai tätä kurssia varten, satunnainen nimi riittää hyvin.

Komento myös kysyy, luodaanko samalla projektille Postgres-tietokanta. Älä kuitenkaan luo tietokantaa vielä, tai saatat joutua ongelmiin tietokantaan yhdistämisen kanssa.

Komento luo automaattisesti kaksi tiedostoa, joita Fly.io käyttää sovelluksen hallintaan palvelimella.

Ensimmäinen tiedosto on fly.toml, jota Fly.io käyttää projektin konfigurointiin. Tiedostoon generoituu automaattisesti tarvittavat tiedot. Vaihdetaan kuitenkin sovelluksen portti vastaamaan Flaskin oletuksena käyttämää porttia 5000. Muutetaan kohdat

[env]
  PORT ="8080"

[[services]]
  internal_port = 8080

muotoon

[env]
  PORT = "5000"

[[services]]
  internal_port = 5000

Toinen tiedosto on nimeltään Procfile, jota käytetään sovelluksen käynnistämiseen. Joudumme muokkaamaan sitäkin hieman. Tiedoston sisältö on aluksi seuraava:

# Modify this Procfile to fit your needs
web: gunicorn server:app

Muutetaan se muotoon

web: gunicorn app:app

Tiedostossa oleva komento gunicorn app:app on vastuussa sovelluksen käynnistämisestä tuotantopalvelimella. Tähän asti olemme paikallisesti käyttäneet tähän komentoa flask run, mutta sitä ei suositella käytettäväksi tuotannossa, joten käytetään siihen tuotantoversiossa gunicornia. Määrittelimme nyt, että tyyppiä “web” oleva sovellus käynnistetään komennolla gunicorn app:app. Tässä ensimmäinen app viittaa moduulin nimeen app.py ja toinen app viittaa koodissa luotavan Flask-olion nimeen.

Tätä varten asennetaan projektiin vielä gunicorn:

(venv) $ pip install gunicorn

Tämän jälkeen tiedosto requirements.txt tulee saattaa ajan tasalle:

(venv) $ pip freeze > requirements.txt

Tässä vaiheessa on hyvä laittaa muutokset talteen versionhallintaan:

$ git add fly.toml
$ git add requirements.txt
$ git add Procfile
$ git commit -m "Add Fly.io config"
$ git push

Tietokanta ja ympäristömuuttujat

Luodaan seuraavaksi sovellukselle tietokanta komennolla fly postgres create. Komento kysyy sovellukselle nimeä (tietokanta on erillinen Fly.io-sovelluksensa), sijaintia sekä kuinka tehokkaan tietokannan haluaa luoda. Alueeksi kannattaa valita sama, jossa varsinainen sovelluksesi sijaitsee.

$ fly postgres create
? Choose an app name (leave blank to generate one): tsoha-visitors-db
? Select regions:  [Use arrows to move, type to filter]
  Amsterdam, Netherlands (ams)
  ...
> Frankfurt, Germany (fra)
  São Paulo (gru)
  ...
  Miami, Florida (US) (mia)
? Select regions: Frankfurt, Germany (fra)
? Select configuration:  [Use arrows to move, type to filter]
> Development - Single node, 1x shared CPU, 256MB RAM, 1GB disk
  Production - Highly available, 2x shared CPUs, 4GB RAM, 40GB disk
  Production - Highly available, 4x shared CPUs, 8GB RAM, 80GB disk
  Specify custom configuration
? Select configuration: Development - Single node, 1x shared CPU, 256MB RAM, 1GB disk
Creating postgres cluster tsoha-test1-db in organization personal
Creating app...
Setting secrets...

Tietokanta täytyy vielä erikseen yhdistää sovellukseemme:

$ fly postgres attach --app tsoha-visitors tsoha-visitors-db --database-name postgres
? Database "postgres" already exists. Continue with the attachment process? Yes

Jos komento ei onnistu heti tietokannan luomisen jälkeen, odota muutama minuutti ja yritä uudelleen.

Huomaa, että määritämme käytettävän tietokannan nimeksi postgres, sillä muuten komento luo uuden tietokannan, jonka nimeksi tulisi sovelluksen nimi. Emme kuitenkaan halua tällaista tilannetta, sillä kohta tarvitsemamme komento fly postgres connect käyttää automaattisesti tietokantaa nimeltä postgres.

Nyt voimme yhdistää tietokantaan näin ja luoda sinne taulun visitors:

$ fly postgres connect -a tsoha-visitors-db
postgres=# CREATE TABLE visitors (id SERIAL PRIMARY KEY, time TIMESTAMP);
postgres=# \q

Olisimme myös voineet luoda taulun näin ohjaamalla sinne tiedoston schema.sql komennot:

$ fly postgres connect -a tsoha-visitors-db < schema.sql

Kun Fly.io luo tietokannan, se asettaa samalla ympäristömuuttujan DATABASE_URL, minkä ansiosta sovellus toimii suoraan myös tuotannossa, jos se käyttää tätä ympäristömuuttujaa. Voimme tarkastaa sovelluksen ympäristömuuttujat näin:

$ fly secrets list
NAME        	DIGEST          	CREATED AT
DATABASE_URL	e26ed699c4d898b8	1h29m ago

Fly.io ei paljasta ympäristömuuttujien sisältöä, vaan näet vain, minkä nimisiä muuttujia projektissa on.

Voimme myös asettaa ympäristömuuttujia tarvittaessa itse. Esimerkiksi istuntojen käyttäminen vaatii muuttujan SECRET_KEY asettamista, mikä onnistuu näin:

$ fly secrets set SECRET_KEY=(avain tähän)

Sovelluksen julkaiseminen

Nyt kaikki alkaa olla valmista ja voimme julkaista sovelluksen:

$ fly deploy

Fly.io tekee automaattisesti health checkin projektille jokaisen julkaisun yhteydessä. Jos nämä testit eivät mene läpi, sovelluksen uuden version julkaisu perutaan ja palataan aiempaan versioon. Vastaasi tulee todennäköisesti seuraavanlainen virheviesti:

2022-10-19T21:46:49Z   [info]    raise exc.NoSuchModuleError(
2022-10-19T21:46:49Z   [info]sqlalchemy.exc.NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:postgres

Tämä johtuu SQLAlchemy-kirjastossa version 1.3.23 jälkeen tapahtuneesta muutoksesta tietokantaosoitteen käsittelyssä. Kun se ennen hyväksyi sekä postgresql://- että postgres://-alkuiset osoitteet, sen uusimmat versiot hyväksyvät vain postgresql://-alkuiset osoitteet. Fly.io antaa tietokannan osoitteen ‘‘väärässä’’ muodossa, joten helppo korjaus tälle on määrittää tietokannan osoite koodissa seuraavanlaisesti ja tehdä uusi julkaisu.

app.config["SQLALCHEMY_DATABASE_URI"] = getenv("DATABASE_URL").replace("://", "ql://", 1)

Toinen vaihtoehto olisi päivittää SQLAlchemyn versio requirements.txt-tiedostossa versioon 1.3.23, mutta tämä tarkoittaisi myös Flask-SQLAlchemy-kirjaston version päivittämistä versioon 2.x.x.

Kun sovellus on julkaistu, voimme avata sen selaimeen

$ fly open

Jos kaikki meni hyvin, tuloksena on:

Virheiden etsiminen

Tavallinen tilanne web-sovelluksen kehityksessä on, että sovellus ei toimi oikealla tavalla. Tämä voi näkyä niin, että sovellus ei käynnisty lainkaan tai jokin sovelluksen toiminto tuottaa virhesivun. Tästä ei kannata kuitenkaan hätkähtää, vaan virheen syy löytyy yleensä aina tutkimalla lokeja ja tarvittaessa lisäämällä debug-tulostusta koodiin.

Lokien tutkiminen

Web-sovellus tulostaa toimintansa aikana lokitietoa, jonka avulla voi jäljittää sovelluksessa esiintyviä virheitä. Esimerkiksi kun suoritamme paikallisesti komennon flask run, lokitiedot ilmestyvät komentoikkunaan.

Jos kaikki menee hyvin, komentoikkunaan voi tulla seuraavan tapaisia viestejä:

(venv) $ flask run
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
127.0.0.1 - - [03/Jul/2020 13:28:03] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [03/Jul/2020 13:28:05] "GET / HTTP/1.1" 200 -

Tämä kertoo, että sovellus on käynnistetty ja sen etusivua on ladattu kahdesti. Molemmilla kerroilla vastaus on annettu HTTP-koodilla 200, mikä tarkoittaa onnistunutta pyyntöä.

Tehdään nyt testimielessä sovellukseen tahallinen bugi muuttamalla SQL-kyselyä niin, että sanan SELECT tilalla on väärin kirjoitettu SELEC:

    result = db.session.execute("SELEC COUNT(*) FROM visitors")

Tämän seurauksena sovellus antaa virhesivun, jonka viestinä on Internal Server Error:

Tällainen virhesivu kertoo hyvin vähän siitä, mikä mahdollinen virhe on, mutta voimme mennä heti tutkimaan lokin sisältöä:

(venv) $ flask run
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
[2020-07-03 13:30:39,544] ERROR in app: Exception on / [GET]
Traceback (most recent call last):
  File "/tsoha-visitors/venv/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1278, in _execute_context
    cursor, statement, parameters, context
  File "/tsoha-visitors/venv/lib/python3.6/site-packages/sqlalchemy/engine/default.py", line 593, in do_execute
    cursor.execute(statement, parameters)
psycopg2.errors.SyntaxError: syntax error at or near "SELEC"
LINE 1: SELEC COUNT(*) FROM visitors
        ^
The above exception was the direct cause of the following exception:

(...)

  File "/tsoha-visitors/app.py", line 15, in index
    result = db.session.execute("SELEC COUNT(*) FROM visitors")
        ^

(...)

[SQL: SELEC COUNT(*) FROM visitors]
(Background on this error at: http://sqlalche.me/e/13/f405)
127.0.0.1 - - [03/Jul/2020 13:30:39] "GET / HTTP/1.1" 500 -

Tästä näkee, että virheen syynä on syntax error at or near "SELEC" ja virhe ilmenee rivillä 15 tiedostossa app.py. Tällaisen tiedon avulla virhe on helppoa korjata.

Jos sovellus aiheuttaa virheen Fly.iossa, vastaavan lokin saa Fly.ion komentorivityökalun avulla näkyviin seuraavasti:

$ fly logs

Vaikein asia lokin lukemisessa on, että virheen sattuessa lokissa on yleensä paljon rivejä. Vaatii kokemusta tunnistaa, mitkä rivit ovat oleellisia virheen etsimisen kannalta. Yleensä kannattaa etsiä lokista virheilmoitusta sekä sovelluksen koodin riviä, jolla virhe tapahtui.

Debug-tulostukset

Sovelluksen toimintaa voi tutkia myös debug-tulostuksilla, joiden tekemiseen sopii vanha kunnon print-komento. Voimme esimerkiksi tulostaa sovelluksen eri vaiheissa ongelmaan mahdollisesti liittyvien muuttujien arvoja ja tarkastaa, että ne ovat kunnossa.

Esimerkiksi voimme tehdä seuraavan debug-tulostuksen, joka tulostaa muuttujan counter arvon:

    result = db.session.execute("SELECT COUNT(*) FROM visitors")
    counter = result.fetchone()[0]
    print("counter is now", counter)

Nyt kun suoritamme sovelluksen ja käymme sivulla, lokiin ilmestyy seuraavaa tietoa:

(venv) $ flask run
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
counter is now 12
127.0.0.1 - - [03/Jul/2020 13:53:57] "GET / HTTP/1.1" 200 -

Lokista katsomalla näemme siis, että sovellus suoritti kyseisen rivin ja sillä hetkellä muuttujan counter arvo oli 12.

Kun ongelma on saatu korjattua ja debug-tulosteita ei enää tarvita, muista kuitenkin siivota debug-tulosteet pois koodista.

Sovelluksen rakenne

Pienen sovelluksen voi toteuttaa mainiosti yhtenä tiedostona app.py, joka käsittelee kaikki sivupyynnöt, mutta suuremmassa projektissa (kuten tällä kurssilla) koodi kannattaa jakaa sopivasti moduuleihin ja funktioihin. Moduuli on tiedosto, jossa on Python-koodia ja jonka voi ottaa mukaan import-komennolla.

Flask mahdollistaa monia tapoja toteuttaa sovelluksen rakenne, ja tutustumme seuraavaksi yhteen tapaan kävijäsovelluksen yhteydessä. Huomaa, että todellisuudessa näin pientä sovellusta ei olisi järkeä jakaa osiin, vaan tämä on vain esimerkki.

Tärkein periaate sovelluksen rakenteen suunnittelussa on, että moduulit ja funktiot antavat sovellukselle selkeän rakenteen ja sovellusta on mukavaa kehittää. Jos nämä vaatimukset eivät täyty, sovelluksen rakenne ei ole hyvä.

Seuraavassa on mahdollinen tapa jakaa kävijäsovellus moduuleiksi:

Moduuli app

app.py

from flask import Flask

app = Flask(__name__)

import routes

Kuten ennenkin, sovelluksen päämoduuli on app, joka käynnistää sovelluksen. Koodi luo Flask-olion sekä ottaa lopuksi mukaan moduulin routes.

Moduuli db

db.py

from app import app
from flask_sqlalchemy import SQLAlchemy
from os import getenv

app.config["SQLALCHEMY_DATABASE_URI"] = getenv("DATABASE_URL")
db = SQLAlchemy(app)

Moduuli db huolehtii tietokantaan liittyvistä asioista. Tässä sovelluksessa moduuli määrittää tietokannan osoitteen ja luo db-olion, jonka kautta tietokantaa voidaan käyttää.

Moduuli routes

routes.py

from app import app
import visits
from flask import render_template

@app.route("/")
def index():
    visits.add_visit()
    counter = visits.get_counter()
    return render_template("index.html", counter=counter)

Moduulin routes tehtävänä on käsitellä sivupyynnöt. Toisin kuin ennen, sivupyynnön käsittelijä ei suorita tietokantakomentoja vaan kutsuu moduulissa visits olevia funktioita.

Moduuli visits

visits.py

from db import db

def add_visit():
    db.session.execute("INSERT INTO visitors (time) VALUES (NOW())")
    db.session.commit()

def get_counter():
    result = db.session.execute("SELECT COUNT(*) FROM visitors")
    counter = result.fetchone()[0]
    return counter

Moduuli visits sisältää funktiot add_visit ja get_counter, joiden avulla sovelluksessa pystyy lisäämään tiedon vierailusta sekä hakemaan vierailujen määrän.


Tässä moduulit viittaavat toisiinsa muutamilla eri tavoilla. Periaatteena on, että kun moduuli tarvitsee toisessa moduulissa määriteltyä funktiota tai oliota, toinen moduuli otetaan mukaan import-rivillä. Huomaa, että moduulissa oleva koodi (kuten olioiden app ja db luominen) suoritetaan vain kerran, vaikka moduuli otetaan mukaan useita kertoja.

Tällainen rakenne sopii moneen sovellukseen: moduulit app, db ja routes muodostavat sovelluksen perustan ja tämän lisäksi on muita moduuleja, jotka toteuttavat sovelluksen toimintoja. Tässä esimerkissä moduuli visits toteuttaa käynteihin liittyvät toiminnot ja tämän moduulin funktioita on kätevä kutsua sivupyynnön käsittelijästä. Jos sovelluksessa on paljon sivuja, voi olla myös paikallaan jakaa sivupyyntöjen käsittely useampaan moduuliin.

Sovelluksen kehittyessä kuuluu asiaan refaktoroida sen koodia eli muuttaa tarvittaessa sovelluksen rakennetta. On hyvä aloittaa yksinkertaisesta rakenteesta ja tarvittaessa jakaa myöhemmin koodia osiin sen sijaan, että tekee sovellukselle heti aluksi “varmuuden vuoksi” monimutkaisen rakenteen.

Esimerkki: Keskustelu

Seuraava esimerkkisovellus kokoaa yhteen tähän asti käsiteltyjä asioita. Sovelluksen aiheena on keskustelupalsta, jossa käyttäjä voi luoda tunnuksen ja lähettää viestejä. Kaikki viestit ovat samassa ketjussa aikajärjestyksessä.

Sovelluksen käyttäminen näyttää tältä:

Sovelluksen koko lähdekoodi on GitHubissa osoitteessa https://github.com/hy-tsoha/tsoha-chat, ja käymme tässä tarkemmin läpi sovelluksen toimintaa.

Sovelluksen tietokanta muodostuu kahdesta taulusta:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username TEXT UNIQUE,
    password TEXT
);

CREATE TABLE messages (
    id SERIAL PRIMARY KEY,
    content TEXT,
    user_id INTEGER REFERENCES users,
    sent_at TIMESTAMP
);

Taulu users sisältää tiedot sivuston rekisteröityneistä käyttäjistä. Jokaisesta käyttäjästä tallennetaan käyttäjätunnus ja salasanan hajautusarvo. Huomaa, että sarake username on UNIQUE, mikä takaa, että jokaisella käyttäjällä on eri tunnus.

Taulu messages sisältää jokaisen viestin sisällön, viittauksen lähettäjään ja lähetysajan.

Sovellus muodostuu viidestä moduulista. Kuten edellisessä osiossa, app on päämoduuli, db huolehtii tietokannasta ja routes käsittelee sivupyynnöt. Näiden lisäksi messages toteuttaa viestien hakemisen ja lähettämisen sekä users vastaa käyttäjien hallinnasta.

Sovelluksen etusivu näyttää lähetetyt viestit aikajärjestyksessä:

routes.py

@app.route("/")
def index():
    list = messages.get_list()
    return render_template("index.html", count=len(list), messages=list)

messages.py

def get_list():
    sql = "SELECT M.content, U.username, M.sent_at FROM messages M, users U" \
          "WHERE M.user_id=U.id ORDER BY M.id"
    result = db.session.execute(sql)
    return result.fetchall()

Moduulin messages funktio get_list hakee jokaisen viestin sisällön, lähettäjän ja lähetysajan. Koska viestit ja lähettäjät ovat eri tauluissa, tässä tarvitaan kahden taulun kysely.

Seuraava koodi käsittelee käyttäjän lähettämän viestin:

routes.py

@app.route("/send", methods=["POST"])
def send():
    content = request.form["content"]
    if messages.send(content):
        return redirect("/")
    else:
        return render_template("error.html", message="Viestin lähetys ei onnistunut")

messages.py

def send(content):
    user_id = users.user_id()
    if user_id == 0:
        return False
    sql = "INSERT INTO messages (content, user_id, sent_at) VALUES (:content, :user_id, NOW())"
    db.session.execute(sql, {"content":content, "user_id":user_id})
    db.session.commit()
    return True

Moduulin messages funktio send lisää uuden viestin tietokantaan. Funktio palauttaa True, jos viestin lähetys onnistui, ja muuten False. Funktio hakee viestin lähettäjän id-numeron funktiolla user_id. Jos id-numero on 0, käyttäjä ei ole kirjautunut eikä viestiä voi lähettää.

Seuraavan koodin avulla käyttäjä voi kirjautua sisään:

routes.py

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "GET":
        return render_template("login.html")
    if request.method == "POST":
        username = request.form["username"]
        password = request.form["password"]
        if users.login(username, password):
            return redirect("/")
        else:
            return render_template("error.html", message="Väärä tunnus tai salasana")

users.py

def login(username, password):
    sql = "SELECT id, password FROM users WHERE username=:username"
    result = db.session.execute(sql, {"username":username})
    user = result.fetchone()
    if not user:
        return False
    else:
        if check_password_hash(user.password, password):
            session["user_id"] = user.id
            return True
        else:
            return False

Tässä sivu login ottaa vastaan sekä GET- että POST-metodia käyttävän sivupyynnön. Jos metodi on GET, käyttäjälle näytetään kirjautumissivu. Jos taas metodi on POST, käsitellään lomake, jonka kautta käyttäjä kirjautuu sisään.

Moduulin users funktio login hoitaa varsinaisen kirjautumisen. Jos käyttäjä antaa oikean tunnuksen ja salasanan, istunnon kenttään user_id asetetaan käyttäjän id-numero. Tämä ilmaisee, että käyttäjä on kirjautunut sisään sovellukseen.

Käyttäjän kirjautuminen voidaan tarkastaa funktiolla user_id:

users.py

def user_id():
    return session.get("user_id", 0)

Tämä funktio antaa joko käyttäjän id-numeron tai arvon 0, jos käyttäjä ei ole kirjautunut sisään.

Funktio logout puolestaan kirjaa käyttäjän ulos poistamalla kentän user_id istunnosta:

users.py

def logout():
    del session["user_id"]

Sovelluksessa on myös rekisteröintitoiminto, jonka avulla uusi käyttäjä voi luoda tunnuksen ja salasanan sivustolle:

routes.py

@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "GET":
        return render_template("register.html")
    if request.method == "POST":
        username = request.form["username"]
        password1 = request.form["password1"]
        password2 = request.form["password2"]
        if password1 != password2:
            return render_template("error.html", message="Salasanat eroavat")
        if users.register(username, password1):
            return redirect("/")
        else:
            return render_template("error.html", message="Rekisteröinti ei onnistunut")

users.py

def register(username, password):
    hash_value = generate_password_hash(password)
    try:
        sql = "INSERT INTO users (username, password) VALUES (:username, :password)"
        db.session.execute(sql, {"username":username, "password":hash_value})
        db.session.commit()
    except:
        return False
    return login(username, password)

Sivun login tavoin myös sivu register käsittelee sekä GET- että POST-pyyntöjä ja joko näyttää lomakkeen uuden tunnuksen luomiseen tai käsittelee käyttäjän lähettämän lomakkeen.

Moduulin users funktio register toteuttaa rekisteröinnin. Jos rekisteröinti onnistui, funktio kirjaa saman tien käyttäjän sisään ja palauttaa arvon True. Muussa tapauksessa funktio palauttaa arvon False.

Koska taulun users sarakkeessa username on määre UNIQUE, rivin lisääminen komennolla INSERT epäonnistuu, jos tunnus on jo käytössä. Tämä tilanne käsitellään try/except-rakenteella, minkä ansiosta käyttäjä saa tiedon siitä, ettei rekisteröinti onnistunut.