Google Cloud Run, Datastore ja Firestore

Tähän asti sovellukset on ajettu yhdellä palvelimella, jolla on oma tiedostojärjestelmä ja SQLite-tietokanta. Tällä luennolla siirrytään Googlen pilveen: sovellus ajetaan Cloud Run -palvelussa, ja tiedot tallennetaan hajautettuun Firestore-tietokantaan. Luennon lopussa käsitellään Firestore-tietokannan suunnittelua viikkotehtävän näkökulmasta.

Sovellus pilvessä

Palvelimettomassa (serverless) pilvipalvelussa sovelluksen kehittäjä ei ylläpidä palvelimia. Hän toimittaa sovelluksen, ja pilvipalvelu huolehtii sen ajamisesta, päivityksistä ja skaalautumisesta. Googlen pilvessä tähän on kaksi palvelua:

Molemmissa palveluissa sovelluksesta käynnistetään tarpeen mukaan yksi tai useampia instansseja. Mitä enemmän sovelluksella on liikennettä tai mitä hitaammin se vastaa pyyntöihin, sitä herkemmin palvelu käynnistää lisää instansseja. Lisäinstanssit nopeuttavat vastauksia mutta kuluttavat resursseja, ja Google laskuttaa niistä.

Sovellus ei voi luottaa omaan muistiinsa

Peräkkäiset pyynnöt voivat osua eri instansseille, ja instanssit käynnistyvät ja sammuvat ilman ennakkovaroitusta. Kaikki pysyvä tieto on siksi tallennettava tietokantaan, ei muuttujiin eikä paikallisiin tiedostoihin.

Flaskin evästepohjainen sessio toimii silti, koska tieto kulkee selaimen mukana: riittää, että kaikilla instansseilla on sama salainen avain. Palvelimen muistiin tallennettu sessio sen sijaan katoaisi heti, kun pyyntö osuu toiselle instanssille.

Cloud Run

Cloud Run ajaa HTTP-pyyntöihin vastaavaa sovellusta konttina. Konttia ei tarvitse rakentaa itse: kun sovellus julkaistaan lähdekoodista, Google rakentaa sen automaattisesti Cloud Build -palvelussa ja tallentaa sen Artifact Registryyn.

Mikä on kontti?

Ohjelmistokontti (engl. container) on paketti, jossa sovelluksen mukana on kaikki, mitä se tarvitsee toimiakseen: ohjelmointikielen tulkki, kirjastot, asetukset ja käyttöjärjestelmän perustyökalut. Koska kontti sisältää oman ympäristönsä, sovellus toimii samalla tavalla kehittäjän koneella, testissä ja pilvessä. Ei tarvitse arvailla, onko palvelimella oikea Python-versio tai tarvittavat kirjastot.

Kontti muistuttaa virtuaalikonetta, mutta on paljon kevyempi. Virtuaalikone sisältää kokonaisen käyttöjärjestelmän ytimineen. Kontit sen sijaan jakavat isäntäkoneen käyttöjärjestelmän ytimen, ja jokainen kontti on vain muista eristetty prosessi. Siksi kontti käynnistyy sekunneissa ja samalle koneelle mahtuu niitä paljon.

Kaksi käsitettä kannattaa erottaa toisistaan:

Kontin levykuva ei ole bitti bitiltä kopioitu kiintolevy, kuten sana voisi antaa ymmärtää, vaan kokoelma tiedostoja, joista kontin tiedostojärjestelmä muodostetaan. Arkikielessä käytetään usein myös englannin sanaa image.

Tunnetuin konttien tekemiseen ja ajamiseen käytetty työkalu on Docker. Siinä levykuvan rakentaminen kuvataan Dockerfile-tiedostossa. Tällä kurssilla Dockeria ei tarvita, koska Cloud Run rakentaa levykuvan lähdekoodista itse.

Miksi juuri kontti?

Nimi tulee rahtikontista. Rahtikontin sisällöstä riippumatta sitä voi käsitellä samoilla nostureilla ja kuljettaa samoilla laivoilla ja junilla. Ohjelmistokontti on samalla tavalla vakiomuotoinen: sama kontti toimii missä tahansa konttien ajamiseen tarkoitetussa ympäristössä. Sana kontti on vakiintunut suomenkieliseen tietotekniikkaan, ja sitä käytetään myös tässä. Englannin container käännetään toisinaan myös säiliöksi, mutta tässä merkityksessä se on harvinainen ja voi sekoittua muihin säiliö-sanan merkityksiin.

Ominaisuudet

Flask-sovellus Cloud Runiin

Julkaistavassa hakemistossa tarvitaan kolme tiedostoa. Sovellus on tavallinen Flask-sovellus, joka lukee kuunneltavan portin PORT-ympäristömuuttujasta ajettaessa omalla koneella:

# main.py
import os

from flask import Flask

app = Flask(__name__)
app.secret_key = os.environ["SECRET_KEY"]


@app.route("/")
def etusivu():
    return "Hei Cloud Runista!"


if __name__ == "__main__":
    # vain omalla koneella; Cloud Runissa sovelluksen käynnistää Gunicorn
    app.run(debug=True, port=int(os.environ.get("PORT", 8080)))

Riippuvuudet luetellaan requirements.txt-tiedostossa. Tuotannossa Flaskin omaa kehityspalvelinta ei käytetä, vaan pyynnöt käsittelee Gunicorn-palvelin:

Flask
gunicorn
google-cloud-firestore

Procfile kertoo, miten kontti käynnistää sovelluksen. Cloud Run välittää kuunneltavan portin PORT-ympäristömuuttujassa:

web: gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 main:app

Gunicorn ja muut WSGI-palvelimet

Flask-sovellus ei itse ota vastaan HTTP-yhteyksiä. Se on WSGI-sovellus: funktio, jolle annetaan pyynnön tiedot ja joka palauttaa vastauksen. Varsinaisen verkkoliikenteen hoitaa WSGI-palvelin, joka vastaanottaa HTTP-pyynnöt, muuntaa ne WSGI-muotoon, kutsuu sovellusta ja lähettää vastauksen takaisin selaimelle. Periaate on sama kuin CGI- ja WSGI-luennolla.

flask run ja app.run() käynnistävät Flaskin mukana tulevan kehityspalvelimen. Se on tarkoitettu vain kehitykseen: sitä ei ole suunniteltu turvalliseksi, vakaaksi eikä tehokkaaksi. Tuotannossa käytetään erillistä WSGI-palvelinta.

Gunicorn (Green Unicorn) on Pythonilla kirjoitettu WSGI-palvelin. Se käynnistää pääprosessin, joka ei itse käsittele pyyntöjä vaan hallitsee joukkoa työprosesseja: se käynnistää ne, jakaa pyynnöt niille ja käynnistää kaatuneen prosessin uudelleen. Yllä olevan Procfile-rivin valitsimet tarkoittavat seuraavaa:

Gunicorn ei toimi Windowsissa

Gunicorn toimii vain Unix-tyyppisissä käyttöjärjestelmissä, kuten Linuxissa ja macOS:ssä. Windowsissa sovellus ajetaan kehityksen aikana Flaskin kehityspalvelimella, joka riittää siihen hyvin. Gunicornin voi halutessaan ajaa Windowsin Linux-alijärjestelmässä (WSL). Cloud Runissa kontti on Linux, joten Gunicorn toimii siellä aina.

Gunicorn ei ole ainoa vaihtoehto. Flaskin dokumentaatio esittelee useita WSGI-palvelimia:

Pythonin WSGI-palvelimia
Palvelin Ominaisuudet
Gunicorn Yleisin valinta Linuxilla. Helppo asentaa ja määritellä. Ei toimi Windowsissa.
Waitress Kokonaan Pythonilla toteutettu, toimii myös Windowsissa. Hyvä valinta, kun sama palvelin halutaan kaikkiin ympäristöihin.
mod_wsgi Apache-palvelimen moduuli. Sopii, kun sovellus halutaan ajaa osana olemassa olevaa Apache-palvelinta.
uWSGI Tehokas ja monipuolinen, mutta monimutkainen määritellä. Projekti on ylläpitotilassa: siihen tehdään enää korjauksia, ei uusia ominaisuuksia. Uusiin projekteihin kannattaa valita jokin muu.

Asynkronisille Python-kehyksille on vastaava rajapinta ASGI ja sille omat palvelimensa, esimerkiksi Uvicorn ja Hypercorn. Tunnetuimpia ASGI-kehyksiä ovat FastAPI ja Quart. Quart on Flaskin asynkroninen vastine: se kuuluu samaan Pallets-projektiin kuin Flask, ja sen rajapinta on lähes sama, joten Flask-sovelluksen voi siirtää Quartiin melko pienin muutoksin. Tällä kurssilla käytetään Flaskia, joten ASGI-palvelimia ei tarvita.

Julkaisu tehdään Google Cloud CLI:n gcloud-komennolla samassa hakemistossa:

gcloud run deploy tulospalvelu --source . \
    --region europe-north1 \
    --allow-unauthenticated \
    --max-instances 2 \
    --set-secrets SECRET_KEY=flask-avain:latest

Komento rakentaa kontin, julkaisee sen ja tulostaa osoitteen, josta sovellus löytyy. --allow-unauthenticated tekee palvelusta julkisen, --max-instances rajoittaa instanssien määrää, ja --set-secrets välittää salaisen avaimen Secret Manageriin tallennetusta salaisuudesta ympäristömuuttujaksi.

Salainen avain Secret Manageriin

Avaimen voisi välittää myös tavallisena ympäristömuuttujana (--set-env-vars), mutta silloin se näkyy kaikille, jotka pääsevät katsomaan palvelun asetuksia. Secret Manager säilyttää sen erillään palvelun asetuksista. Salaisuus luodaan kerran:

python3 -c "import secrets; print(secrets.token_hex(32), end='')" \
    | gcloud secrets create flask-avain --data-file=-

Kustannukset

Oletuksena Cloud Run laskuttaa vain siltä ajalta, jonka instanssi käsittelee pyyntöjä: prosessoriajasta, muistista ja pyyntöjen määrästä. Kuukausittainen ilmaiskiintiö on laskutustilikohtainen ja riittää hyvin viikkotehtävien sovelluksiin.

App Engine

App Engine oli Googlen ensimmäinen palvelimeton sovellusalusta, ja sillä tätä kurssia on aiemmin toteutettu. Palvelu on edelleen toiminnassa ja saa päivityksiä, mutta Google suosittelee uusille käyttäjille Cloud Runia. Vastaan tulee silti usein vanhoja ohjeita ja esimerkkejä, joten on hyvä tuntea erot.

App Enginessa on kaksi ympäristöä. Standardiympäristössä sovellus ajetaan Googlen valmiissa ajoympäristössä, ja sillä on päivittäinen ilmaiskiintiö. Joustava ympäristö ajaa sovelluksen kontissa virtuaalikoneella, ja siitä maksetaan aina.

App Enginen standardiympäristö ja Cloud Run
Ominaisuus App Engine (standardi) Cloud Run
Ajettava yksikkö lähdekoodi valmiissa ajoympäristössä kontti, joka voidaan rakentaa lähdekoodista automaattisesti
Asetukset app.yaml komentorivin valitsimet, Procfile tai Dockerfile
Julkaisu gcloud app deploy gcloud run deploy --source .
Skaalautuminen nollaan kyllä kyllä
Laskutusperuste instanssitunnit prosessori- ja muistisekunnit pyyntöjen käsittelyn ajalta
Tiedostoihin kirjoittaminen vain /tmp-hakemistoon koko tiedostojärjestelmään, joka on muistissa
Alue yksi alue projektia kohden, ei vaihdettavissa jälkikäteen valittavissa palvelukohtaisesti
Googlen suositus uusille sovelluksille ei kyllä

Vanhoissa App Engine -esimerkeissä käytetään usein App Enginen omia palveluja kuten NDB-kirjastoa ja Memcachea. Ne toimivat vain App Enginessa, eikä niitä käytetä tällä kurssilla. Firestorea käytetään samalla Googlen Python-kirjastolla riippumatta siitä, ajetaanko sovellus App Enginessa, Cloud Runissa vai omalla koneella.

Datastore ja Firestore

Datastore ja Firestore ovat Googlen hajautettuja NoSQL-tietokantoja. Ne eivät ole kaksi erillistä tuotetta: Firestore on Datastoren seuraava versio, ja vanhat Datastore-tietokannat on päivitetty automaattisesti Firestoreksi. Firestore-tietokanta luodaan jommassakummassa kahdesta tilasta:

Tila valitaan tietokantaa luotaessa. Sen voi vaihtaa vain, jos tietokanta on tyhjä. Samassa projektissa voi olla useita tietokantoja eri tiloissa, mutta ilmaiskiintiö koskee niistä vain yhtä.

Tällä kurssilla käytetään vain Native-tilaa

Valitse tietokantaa luodessasi Native-tila. Datastore-tila esitellään vain siksi, että se tulee vastaan vanhoissa ohjeissa ja App Engine -esimerkeissä. Viikkotehtävien ja pääteohjausten koodi ei toimi Datastore-tilassa, koska siinä Firestoren kirjasto ei saa yhteyttä tietokantaan.

Vertailu

Firestore Native-tilassa ja Datastore-tilassa
Ominaisuus Firestore (Native-tila) Datastore-tila
Tietomalli kokoelmat ja dokumentit; dokumentilla voi olla alikokoelmia entiteetit, lajit (kind) ja avaimet; hierarkia muodostetaan esivanhempipolkuna
Python-kirjasto google-cloud-firestore google-cloud-datastore tai google-cloud-ndb
Käyttö selaimesta ja mobiilisovelluksesta kyllä, Firebase-kirjastoilla; pääsy rajataan tietoturvasäännöillä ei, vain palvelimelta
Reaaliaikaiset päivitykset kyllä: sovellus voi kuunnella dokumentin tai kyselyn muutoksia ei
Kyselyt Python-kirjaston metodit Python-kirjaston metodit ja SQL:n kaltainen GQL-kyselykieli
Liitokset (JOIN) ei ei
Merkkijonovertailu kirjainkoon huomioiva kirjainkoon huomioiva
Johdonmukaisuus vahva vahva (vanhassa Datastoressa kyselyt olivat vain lopulta johdonmukaisia)
Hierarkian muuttaminen dokumentin polku on pysyvä; siirto tehdään kopioimalla ja poistamalla esivanhempi on osa avainta eikä sitä voi muuttaa jälkikäteen
Dokumentin tai entiteetin enimmäiskoko 1 MiB 1 MiB
Hinnoittelu ja ilmaiskiintiö samat molemmissa samat molemmissa
Emulaattori omalla koneella gcloud emulators firestore start gcloud emulators firestore start --database-mode=datastore-mode
Käyttötarkoitus uudet sovellukset sovellukset, jotka jo käyttävät Datastore-rajapintaa

Firestoresta on lisäksi olemassa kaksi versiota, Standard ja Enterprise. Tällä kurssilla käytetään Standard-versiota, ja tämän luennon tiedot koskevat sitä.

Firestoren käyttö Pythonista

Firestorea käytetään Googlen tekemällä Python-kirjastolla google-cloud-firestore, joka asennetaan pipillä kuten muutkin kirjastot. Kirjasto hoitaa yhteyden tietokantaan, tunnistautumisen ja tietojen muuntamisen: ohjelmoija vain kutsuu sen metodeja. SQLiten kanssa vastaava rooli oli Pythonin sqlite3-moduulilla.

Firestore koostuu kokoelmista, joissa on dokumentteja. Dokumentti on joukko kenttiä, ja Pythonissa se käsitellään sanakirjana. Kyselyt tehdään aina yhteen kokoelmaan kerrallaan:

from google.cloud import firestore
from google.cloud.firestore_v1.base_query import FieldFilter

# Tietokantayhteyttä edustava olio luodaan kerran. Cloud Runissa
# tunnistautuminen tapahtuu automaattisesti palvelun oman palvelutilin avulla.
db = firestore.Client()

# uusi dokumentti; Firestore keksii tunnisteen itse
_, viite = db.collection("kilpailut").add({
    "nimi": "Jäärogaining",
    "alkuaika": "2025-03-15 08:00:00",
})

# yksi dokumentti tunnisteen perusteella
kilpailu = db.collection("kilpailut").document(viite.id).get()
if kilpailu.exists:
    print(kilpailu.to_dict()["nimi"])

# kysely: kaikki samannimiset kilpailut
kysely = db.collection("kilpailut").where(
    filter=FieldFilter("nimi", "==", "Jäärogaining"))
for dokumentti in kysely.stream():
    print(dokumentti.id, dokumentti.to_dict())

Tarkemmat ohjeet ja esimerkit ovat pääteohjauksessa 4 ja google-cloud-firestore-kirjaston dokumentaatiossa.

Kehitys omalla koneella

Sovellusta ei tarvitse julkaista jokaisen muutoksen jälkeen. Flask-sovelluksen voi ajaa omalla koneella tavalliseen tapaan, ja tietokantana käytetään Google Cloud CLI:n mukana tulevaa emulaattoria. Emulaattori käynnistetään omaan pääteikkunaansa:

gcloud emulators firestore start --host-port=localhost:8081

Sovellukselle kerrotaan emulaattorin osoite ympäristömuuttujalla. Kun muuttuja on asetettu, google-cloud-firestore-kirjasto ottaa yhteyden emulaattoriin eikä pilvessä olevaan tietokantaan:

export FIRESTORE_EMULATOR_HOST=localhost:8081
export GOOGLE_CLOUD_PROJECT=demo-tulospalvelu
export SECRET_KEY=paikallinen-kehitysavain
python3 main.py
Kaksi yllätystä

Firestore-tietokannan suunnittelu

Relaatiotietokannassa suunnittelu alkaa tiedon rakenteesta, ja kyselyt kirjoitetaan sen jälkeen. Firestoressa järjestys on päinvastainen: ensin mietitään, mitä kyselyjä sovellus tekee, ja tietokanta rakennetaan niitä varten. Liitoksia ei ole, joten yhden sivun tarvitsemat tiedot kannattaa löytää mahdollisimman harvasta dokumentista.

Palvelimella vai selaimessa?

Native-tilassa Firestorea voi käyttää sekä palvelimelta että suoraan selaimesta javascriptillä. Moni asia on makuasia: saman toiminnon voi toteuttaa kummalla puolella tahansa. Viikkotehtävässä on kuitenkin määritelty, mitkä asiat on tehtävä palvelimella ja mitkä selaimessa.

Ratkaiseva ero on pääsynhallinta. Palvelimella ajettava koodi käyttää tietokantaa palvelun omilla oikeuksilla, ja sovellus itse päättää, mitä käyttäjä saa tehdä. Selaimesta tietokantaa käytetään suoraan, joten pääsy on rajattava Firestoren tietoturvasäännöillä. Ilman niitä kuka tahansa voi lukea ja muuttaa koko tietokantaa.

Dokumenttirakenne ja kustannukset

Firestoressa maksetaan jokaisesta luetusta, kirjoitetusta ja poistetusta dokumentista. Ilmaiskiintiö on päivittäinen:

Firestoren päivittäinen ilmaiskiintiö (Standard-versio)
ToimintoIlmaiseksi
Dokumenttien lukuja50 000 päivässä
Dokumenttien kirjoituksia20 000 päivässä
Dokumenttien poistoja20 000 päivässä
Tallennettua tietoa1 GiB

Dokumenttien määrä on siis suoraan kustannus. Jos yksi sivu näyttää tiedot kymmenestä dokumentista, sivun lataaminen maksaa kymmenen lukua; jos samat tiedot ovat yhdessä dokumentissa, se maksaa yhden. Ero voi helposti olla kymmenkertainen. Välimuisti vähentäisi lukuja, mutta siihen palataan vasta viimeisissä tehtävissä — yritä nyt löytää hyvä rakenne ilman sitä.

Kaikkea ei kuitenkaan voi koota yhteen dokumenttiin. Dokumentin enimmäiskoko on 1 MiB. Viikkotehtävän mallidata saattaisi vielä mahtua, mutta jos järjestelmä olisi käytössä vuosia, raja tulisi väistämättä vastaan. Älä tee tätä virhettä.

Esimerkkilaskelma: chat-sovellus

Tarkastellaan pientä IRC-tyyppistä chat-sovellusta, jossa on käyttäjiä, keskusteluryhmiä ja viestejä. Ryhmän sivu näyttää ryhmän uusimmat viestit, ja uusi viesti lähetetään lomakkeella. Viestit voi tallentaa Firestoreen kahdella tavalla:

Molemmissa malleissa viestiin tallennetaan lähettäjän nimi. Jos viestissä olisi vain viittaus käyttäjädokumenttiin, jokaisen viestin näyttäminen vaatisi lisäksi käyttäjädokumentin lukemisen. Tiedon toistaminen tällä tavoin (denormalisointi) on NoSQL-tietokannoissa tavallista, vaikka relaatiotietokannassa sitä vältettäisiin.

Laskelman oletukset: viesti vie tallennettuna noin 300 tavua, ryhmän sivu näyttää 50 uusinta viestiä, ja käyttäjä avaa ryhmän sivun keskimäärin kymmenen kertaa päivässä.

Päivittäiset lukuoperaatiot ja ryhmädokumentin täyttyminen
Suure Pieni sovellus Kymmenkertainen käyttö
Käyttäjiä / ryhmiä 50 / 5 500 / 20
Uusia viestejä päivässä 200 2 000
Ryhmäsivun latauksia päivässä 500 5 000
Malli A: lukuja päivässä 500 × (1 + 50) = 25 500 — ilmaiskiintiön sisällä 5 000 × (1 + 50) = 255 000 — kiintiö ylittyy
Malli B: lukuja päivässä 500 5 000
Malli B: ryhmädokumentti kasvaa noin 12 kt päivässä noin 30 kt päivässä
Malli B: dokumentti täynnä (1 MiB) noin 87 päivän kuluttua noin 35 päivän kuluttua

Kirjoituksia syntyy molemmissa malleissa yksi jokaista viestiä kohden, joten niissä ei ole eroa, ja kirjoitusten ilmaiskiintiö (20 000 päivässä) riittää molemmissa tilanteissa. Myöskään tallennustila ei ole ongelma: vuoden viestit vievät pienessä sovelluksessa noin 22 Mt ja kymmenkertaisessa noin 220 Mt, kun ilmaiskiintiö on 1 GiB.

Mallin A kymmenkertaisessa käytössä luvuista noin 205 000 päivässä ylittää ilmaiskiintiön, eli kuukaudessa noin 6,2 miljoonaa lukua. Noin 0,06 dollarin hinnalla sataatuhatta lukua kohden se tekee noin 3,7 dollaria kuukaudessa. Hinnat vaihtelevat tietokannan sijainnin mukaan, joten luku on suuntaa-antava.

Mitä laskelmasta voi päätellä?

Kirjoitustahti

Yhden dokumentin kirjoitukset käsitellään peräkkäin: dokumentti ja sen indeksit päivitetään kerralla, ja muutos vahvistetaan useaan palvelimeen ennen kuin kirjoitus on valmis. Siksi yhtä dokumenttia ei voi päivittää rajattoman nopeasti. Tarkka raja riippuu kuormasta — kirjoitustahdista, samanaikaisista pyynnöistä ja indeksien määrästä — ja sen ylittäminen näkyy hitautena ja virheinä.

Ajatellaan oikeaa rogaining-tapahtumaa. Joukkueet lähtevät yhteislähdössä, ja useita joukkueita saapuu letkassa ensimmäiselle rastille. Jos kaikki leimaukset olisivat yhdessä dokumentissa, kymmenet joukkueet kirjoittaisivat samaan dokumenttiin lähes samalla hetkellä. Vähintään kunkin joukkueen leimaukset on tallennettava omaan dokumenttiinsa.

Aikaleima indeksissä

Jos kokoelman dokumenteissa on indeksoitu kenttä, jonka arvo kasvaa dokumentista toiseen — kuten leimauksen aikaleima — kokoelmaan voi kirjoittaa enintään noin 500 kertaa sekunnissa. Viikkotehtävissä raja ei tule vastaan, mutta se kannattaa tietää. Jos kentän perusteella ei tehdä kyselyjä, sen voi jättää indeksoimatta, jolloin rajoitusta ei ole.

Tunnisteet

Käytä Firestoren itse luomia tunnisteita. Firestore hajauttaa ne tasaisesti, jolloin uudet dokumentit jakautuvat eri palvelimille. Peräkkäiset tunnisteet (1, 2, 3, …), joita pääteohjauksessa käytetään havainnollisuuden vuoksi, kasaavat kirjoitukset samaan kohtaan ja hidastavat tietokantaa suuressa kuormassa.

Järkevä kompromissi

Joukkueiden, sarjojen ja kilpailujen tietoja muutetaan harvoin, mutta niitä luetaan usein. Leimauksia kirjoitetaan kilpailun aikana jatkuvasti. Mieti siis:

Viikkotehtävän tietokanta kannattaa jakaa useaan dokumenttiin samaan tapaan kuin pääteohjauksen reseptitietokanta. Ihan kaikesta ei kuitenkaan kannata tehdä omaa dokumenttiaan kustannusten takia. Etsi järkevä kompromissi.

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti