Siirry sisältöön
a Node.js ja Expressb Sovellus internetiin
d Validointi ja ESLint

c

Tietojen tallettaminen MongoDB-tietokantaan

Ennen kuin siirrymme osan varsinaiseen aiheeseen eli tiedon tallettamiseen tietokantaan, tarkastellaan muutamaa tapaa Node-sovellusten debuggaamiseen.

Node-sovellusten debuggaaminen

Nodella tehtyjen sovellusten debuggaaminen on jossain määrin hankalampaa kuin selaimessa toimivan JavaScriptin. Vanha hyvä keino on tietysti konsoliin tulostelu. Se kannattaa aina. On mielipiteitä, joiden mukaan konsoliin tulostelun sijaan olisi syytä suosia jotain kehittyneempää menetelmää, mutta se ei ole koko totuus. Jopa maailman aivan eliittiin kuuluvat open source ‑kehittäjät käyttävät tätä menetelmää.

Visual Studio Code

Visual Studio Coden debuggeri voi olla hyödyksi joissain tapauksissa. Saat käynnistettyä sovelluksen debuggaustilassa seuraavasti (tässä ja muutamassa seuraavassa kuvassa muistiinpanoilla on kenttä date joka on poistunut sovelluksen nykyisestä versiosta):

Avataan run-tabi ja sieltä valinta start debugging

Huomaa, että sovellus ei saa olla samalla käynnissä "normaalisti" konsolista, sillä tällöin sovelluksen käyttämä portti on varattu.

Seuraavassa screenshot, jossa koodi on pysäytetty kesken uuden muistiinpanon lisäyksen:

Koodiin on lisätty breakpoint, johon suoritus pysähtyy. Vasemman puolen tabissa näkyvät muuttujien arvot, alhaalla Debugging console, jossa on mahdollista evaluoida koodia

Koodi on pysähtynyt rivillä 63 olevan breakpointin kohdalle ja konsoliin on evaluoitu muuttujan note arvo. Vasemmalla olevassa ikkunassa on nähtävillä myös kaikki ohjelman muuttujien arvot.

Ylhäällä olevista nuolista yms. voidaan kontrolloida debuggauksen etenemistä.

Itse en jostain syystä juurikaan käytä Visual Studio Coden debuggeria.

Chromen DevTools

Debuggaus onnistuu myös Chromen developer-konsolilla käynnistämällä sovellus komennolla:

node --inspect index.js

Debuggeriin pääsee käsiksi klikkaamalla Chromen developer-konsoliin ilmestyneestä vihreästä ikonista:

Developer-konsolitabille on ilmestynyt uusi valinta, joka on Elements-valinnan vasemmalla puolella oleva tekstitön laatikkosymboli

Debuggausnäkymä toimii kuten React-koodia debugattaessa. Sources-välilehdelle voidaan esim. asettaa breakpointeja eli kohtia joihin suoritus pysähtyy:

Developer-konsolista valittu source-tabi, ja näkyville tulleeseen koodiin on asetettu breakpoint. Suorituksen pysähtyessä aukeaa Watch-näkymä, joka kertoo muuttujien arvot

Ohjelman muuttujien arvoja voi evaluoida oikealla olevaan watch-ikkunaan.

Kaikki sovelluksen console.log-tulostukset tulevat debuggerin Console-välilehdelle. Voit tutkia siellä myös muuttujien arvoja ja suorittaa mielivaltaista JavaScript-koodia:

Console-tabille on mahdollista evaluoida mielivaltaista js-koodia, pysäytetyn koodin muuttujat ovat käytettävissä

Epäile kaikkea

Full Stack ‑sovellusten debuggaaminen vaikuttaa alussa erittäin hankalalta. Kun kohta kuvaan tulee myös tietokanta, ja frontend on yhdistetty backendiin, on potentiaalisia virhelähteitä todella paljon.

Kun sovellus "ei toimi", onkin selvitettävä missä vika on. On erittäin yleistä, että vika on sellaisessa paikassa, jota ei osaa ollenkaan epäillä, ja menee minuutti-, tunti- tai jopa päiväkausia ennen kuin oikea ongelmien lähde löytyy.

Avainasemassa onkin systemaattisuus. Koska virhe voi olla melkein missä vain, kaikkea pitää epäillä, ja tulee pyrkiä poissulkemaan ne osat, joissa virhe ei ainakaan ole. Konsoliin kirjoitus, Postman, debuggeri ja kokemus auttavat.

Virheiden ilmaantuessa ylivoimaisesti huonoin strategia on jatkaa koodin kirjoittamista. Se on tae siitä, että koodissa on pian kymmenen ongelmaa lisää ja niiden syyn selvittäminen on entistäkin vaikeampaa. Toyota Production Systemin periaate Stop and fix toimii tässäkin yhteydessä paremmin kuin hyvin.

MongoDB

Jotta saisimme talletettua muistiinpanot pysyvästi, tarvitsemme tietokannan. Useimmilla Tietojenkäsittelytieteen osaston kursseilla on käytetty relaatiotietokantoja. Melkein kaikissa tämän kurssin osissa käytämme MongoDB:tä, joka on ns. dokumenttitietokanta.

Tärkein syy Mongon käytölle kurssilla on se, että Mongo on tietokantanoviiseille helpompikäyttöinen kuin relaatiotietokannat. Kurssin osassa 13 tutustutaan relaatiotietokantoja käyttävien Node-sovellusten tekemiseen.

Mongon valinta tämän kurssin alkuun on siis tehty enimmäkseen pedagogisista perusteista. Itse suosittelen useimpiin sovelluksiin lähtökohtaisesti relaatiotietokantaa. Eli suosittelen lämpimästi tekemään myös tämän kurssin Osan 13.

Dokumenttitietokannat poikkeavat jossain määrin relaatiotietokannoista niin datan organisointitapansa kuin kyselykielensäkin suhteen. Dokumenttitietokantojen ajatellaan kuuluvan sateenvarjotermin NoSQL alle. Lyhyt johdanto dokumenttitietokantoihin on täällä.

Lue nyt linkitetty johdanto. Jatkossa oletetaan, että hallitset käsitteet dokumentti ja kokoelma (collection).

MongoDB:n voi asentaa paikallisesti omalle koneelle. Internetistä löytyy kuitenkin myös palveluna toimivia Mongoja, joista tämän hetken paras valinta on MongoDB Atlas.

Kun käyttäjätili on luotu ja kirjauduttu, Aloitetaan valitsemalla kokeiluihin sopiva ilmainen vaihtoehto

Valitaan 'shared', joka on ilmainen

Valitaan sopiva pilvipalvelu ja konesali, ja luodaan klusteri:

Valitaan esim AWS Stockholm ja klikataan Create cluster

Odotetaan että klusteri on valmiina, mihin menee noin useita minuutteja.

HUOM: Älä jatka eteenpäin ennen kun klusteri on valmis!

Luodaan security-välilehdeltä tietokantakäyttäjätunnus joka on siis eri tunnus kuin se, jonka avulla kirjaudutaan MongoDB Atlasiin:

Valitaan Security-välilehdeltä 'Username and password' ja luodaan käyttäjätunnus

Seuraavaksi tulee määritellä ne IP-osoitteet, joista tietokantaan pääsee käsiksi ja sallitaan yksinkertaisuuden vuoksi yhteydet kaikkialta:

Valitaan Network access ‑välilehdeltä 'Allow access from anywhere'

Lopulta ollaan valmiina ottamaan tietokantayhteys. Valitaan connect ja sen jälkeisestä näkymästä connect your application:

Valitaan Databases-välilehdeltä 'Connect'

Näkymä kertoo MongoDB URI:n eli osoitteen, jonka avulla sovelluksemme käyttämä MongoDB-kirjasto saa yhteyden kantaan:

näin avautuvasta näkymästä pitäisi löytyä connect-url

Osoite näyttää seuraavalta:

mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority

Olemme nyt valmiina kannan käyttöön.

Voisimme käyttää kantaa JavaScript-koodista suoraan Mongon virallisen MongoDB Node.js driver ‑kirjaston avulla, mutta se on ikävän työlästä. Käytämmekin hieman korkeammalla tasolla toimivaa Mongoose-kirjastoa.

Mongoosesta voisi käyttää luonnehdintaa object document mapper (ODM), ja sen avulla JavaScript-olioiden tallettaminen MongoDB:n dokumenteiksi on suoraviivaista.

Asennetaan Mongoose:

npm install mongoose

Ei lisätä MongoDB:tä käsittelevää koodia heti backendin koodin sekaan, vaan tehdään erillinen kokeilusovellus tiedostoon mongo.js:

const mongoose = require('mongoose')

if (process.argv.length<3) {
  console.log('give password as argument')
  process.exit(1)
}

const password = process.argv[2]

const url =
  `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority`

mongoose.set('strictQuery', false)
mongoose.connect(url)

const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

const note = new Note({
  content: 'HTML is easy',
  important: true,
})

note.save().then(result => {
  console.log('note saved!')
  mongoose.connection.close()
})

Koodi siis olettaa, että sille annetaan parametrina MongoDB Atlasissa luodulle käyttäjälle määritelty salasana. Komentoriviparametriin se pääsee käsiksi seuraavasti:

const password = process.argv[2]

Kun koodi suoritetaan komennolla node mongo.js salasana lisää Mongoose tietokantaan uuden dokumentin.

Voimme tarkastella tietokannan tilaa MongoDB Atlasin hallintanäkymän Browse collections-osasta:

Valitaan Databases-välilehdeltä 'Browse collections'

Kuten näkymä kertoo, on muistiinpanoa vastaava dokumentti lisätty tietokannan test kokoelmaan (collection) nimeltään notes:

Näkymä kertoo luodun tietokannan ja siihen sisältyvän kokoelman

Tuhotaan oletusarvoisen nimen saanut kanta test. Päätetään käyttää tietokannasta nimeä noteApp, joten muutetaan tietokanta-URI muotoon

const url =
  `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority`

Suoritetaan ohjelma uudelleen:

Näkymä 'Browse collections' näyttää nyt halutun nimen noteApp sisältävän tietokannan

Data on nyt oikeassa kannassa. Hallintanäkymä sisältää myös toiminnon create database, joka mahdollistaa uusien tietokantojen luomisen hallintanäkymän kautta. Kannan luominen etukäteen hallintanäkymässä ei kuitenkaan ole tarpeen, sillä MongoDB Atlas osaa luoda kannan automaattisesti jos sovellus yrittää yhdistää kantaan, jota ei ole vielä olemassa.

Skeema

Yhteyden avaamisen jälkeen määritellään muistiinpanon skeema ja sitä vastaava model:

const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

Ensin muuttujaan noteSchema määritellään muistiinpanon skeema, joka kertoo Mongooselle, miten muistiinpano-oliot tulee tallettaa tietokantaan.

Modelin Note määrittelyssä ensimmäisenä parametrina oleva merkkijono Note määrittelee, että Mongoose tallettaa muistiinpanoa vastaavat oliot kokoelmaan nimeltään notes, sillä Mongoosen konventiona on määritellä kokoelmien nimet monikossa (esim. notes), kun niihin viitataan skeeman määrittelyssä yksikkömuodossa (esim. Note).

Dokumenttikannat, kuten MongoDB ovat skeemattomia, eli tietokanta itsessään ei välitä mitään sinne talletettavan tiedon muodosta. Jopa samaan kokoelmaan on mahdollista tallettaa olioita, joilla on täysin eri kentät.

Mongoosea käytettäessä periaatteena on kuitenkin se, että tietokantaan talletettavalle tiedolle määritellään sovelluksen koodin tasolla skeema, joka määrittelee minkä muotoisia olioita kannan eri kokoelmiin talletetaan.

Olioiden luominen ja tallettaminen

Seuraavaksi tiedoston mongo.js sovellus luo muistiinpanoa vastaavan model:in avulla muistiinpano-olion:

const note = new Note({
  content: 'HTML is Easy',
  important: false,
})

Modelit ovat ns. konstruktorifunktioita, jotka luovat parametrien perusteella JavaScript-olioita. Koska oliot on luotu modelien konstruktorifunktiolla, niillä on kaikki modelien ominaisuudet eli joukko metodeja, joiden avulla olioita voidaan mm. tallettaa tietokantaan.

Tallettaminen tapahtuu metodilla save. Metodi palauttaa promisen, jolle voidaan rekisteröidä then-metodin avulla tapahtumankäsittelijä:

note.save().then(result => {
  console.log('note saved!')
  mongoose.connection.close()
})

Kun olio on tallennettu kantaan, kutsutaan then:in parametrina olevaa tapahtumankäsittelijää, joka sulkee tietokantayhteyden komennolla mongoose.connection.close(). Ilman yhteyden sulkemista ohjelman suoritus ei pääty.

Tallennusoperaation tulos on takaisinkutsun parametrissa result. Yhtä olioa tallentaessamme tulos ei ole kovin mielenkiintoinen. Olion sisällön voi esim. tulostaa konsoliin, jos haluaa tutkia sitä tarkemmin sovelluslogiikassa tai esim. debugatessa.

Talletetaan kantaan myös pari muuta muistiinpanoa muokkaamalla dataa koodista ja suorittamalla ohjelma uudelleen.

HUOM: Valitettavasti Mongoosen dokumentaatiossa käytetään joka paikassa promisejen then-metodien sijaan takaisinkutsufunktioita, joten sieltä ei kannata suoraan copy-pasteta koodia, sillä promisejen ja vanhanaikaisten callbackien sotkeminen samaan koodiin ei ole kovin järkevää.

Olioiden hakeminen tietokannasta

Kommentoidaan koodista uusia muistiinpanoja generoiva osa ja korvataan se seuraavalla:

Note.find({}).then(result => {
  result.forEach(note => {
    console.log(note)
  })
  mongoose.connection.close()
})

Kun koodi suoritetaan, kantaan talletetut muistiinpanot tulostuvat:

Mongoon tallennetut muistiinpanot tulostuvat konsoliin, muistiinpanoilla on myös kenttä _id jonka Mongo on luonut

Oliot haetaan kannasta Note-modelin metodilla find. Metodin parametrina on hakuehto. Koska hakuehtona on tyhjä olio {}, saimme kannasta kaikki notes-kokoelmaan talletetut oliot.

Hakuehdot noudattavat MongoDB:n syntaksia.

Voisimme hakea esim. ainoastaan tärkeät muistiinpanot seuraavasti:

Note.find({ important: true }).then(result => {
  // ...
})

Tietokantaa käyttävä backend

Nyt meillä on periaatteessa hallussamme riittävä tietämys ottaa MongoDB käyttöön sovelluksessamme.

Aloitetaan nopean kaavan mukaan, copy-pastetaan tiedostoon index.js Mongoosen määrittelyt eli

const mongoose = require('mongoose')

// ÄLÄ KOSKAAN TALLETA SALASANOJA GitHubiin!
const url =
  `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority`

mongoose.set('strictQuery',false)
mongoose.connect(url)

const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

ja muutetaan kaikkien muistiinpanojen hakemisesta vastaava käsittelijä seuraavaan muotoon:

app.get('/api/notes', (request, response) => {
  Note.find({}).then(notes => {
    response.json(notes)
  })
})

Voimme todeta selaimella, että backend toimii kaikkien dokumenttien näyttämisen osalta:

Mongoon tallennetut muistiinpanot renderöityvät selaimeen JSON-muodossa

Toiminnallisuus on muuten kunnossa, mutta frontend olettaa, että olioiden yksikäsitteinen tunniste on kentässä id. Emme myöskään halua näyttää frontendille MongoDB:n versiointiin käyttämää kenttää __v.

Eräs tapa muotoilla Mongoosen palauttamat oliot haluttuun muotoon on muokata kannasta haettavilla olioilla olevan toJSON-metodin palauttamaa muotoa. Metodin muokkaus onnistuu seuraavasti:

noteSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

Vaikka Mongoose-olioiden kenttä _id näyttääkin merkkijonolta, se on todellisuudessa olio. Määrittelemämme metodi toJSON muuttaa sen merkkijonoksi kaiken varalta. Jos emme tekisi muutosta, siitä aiheutuisi ylimääräistä harmia testien yhteydessä.

Muistiinpanojen hakemisesta vastaavaan käsittelijään ei tarvitse tehdä muutoksia eli seuraava koodi

app.get('/api/notes', (request, response) => {
  Note.find({}).then(notes => {
    response.json(notes)
  })
})

kutsuu jokaiselle tietokannasta luettavalle muistiinpanolle automaattisesti metodia toJSON muodostaessaan vastausta.

Tietokantamäärittelyjen eriyttäminen moduuliksi

Ennen kuin täydennämme backendin muutkin osat käyttämään tietokantaa, eriytetään Mongoose-spesifinen koodi omaan moduuliinsa.

Tehdään moduulia varten hakemisto models ja sinne tiedosto note.js:

const mongoose = require('mongoose')

mongoose.set('strictQuery', false)

const url = process.env.MONGODB_URI
console.log('connecting to', url)mongoose.connect(url)
  .then(result => {    console.log('connected to MongoDB')  })  .catch((error) => {    console.log('error connecting to MongoDB:', error.message)  })
const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

noteSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

module.exports = mongoose.model('Note', noteSchema)

Noden moduulien määrittely poikkeaa hiukan osassa 2 määrittelemistämme frontendin käyttämistä ES6-moduuleista.

Moduulin ulos näkyvä osa määritellään asettamalla arvo muuttujalle module.exports. Asetamme arvoksi modelin Note. Muut moduulin sisällä määritellyt asiat, esim. muuttujat mongoose ja url eivät näy moduulin käyttäjälle.

Moduulin käyttöönotto tapahtuu lisäämällä tiedostoon index.js seuraava rivi:

const Note = require('./models/note')

Näin muuttuja Note saa arvokseen saman olion, jonka moduuli määrittelee.

Yhteyden muodostustavassa on pieni muutos aiempaan:

const url = process.env.MONGODB_URI

console.log('connecting to', url)

mongoose.connect(url)
  .then(result => {
    console.log('connected to MongoDB')
  })
  .catch((error) => {
    console.log('error connecting to MongoDB:', error.message)
  })

Tietokannan osoitetta ei kannata kirjoittaa koodiin, joten osoite annetaan sovellukselle ympäristömuuttujan MONGODB_URI välityksellä.

Yhteyden muodostavalle metodille on nyt rekisteröity onnistuneen ja epäonnistuneen yhteydenmuodostuksen käsittelevät funktiot, jotka tulostavat konsoliin tiedon siitä, onnistuiko yhteyden muodostaminen:

Konsoliin tulostuu virheilmoitus 'error connecting to Mongo, bad auth'

On useita tapoja määritellä ympäristömuuttujan arvo. Voimme esim. antaa sen ohjelman käynnistyksen yhteydessä seuraavasti:

MONGODB_URI=osoite_tahan npm run dev

Eräs kehittyneempi tapa on käyttää dotenv-kirjastoa. Asennetaan kirjasto komennolla

npm install dotenv

Sovelluksen juurihakemistoon tehdään sitten tiedosto nimeltään .env, jonne tarvittavien ympäristömuuttujien arvot määritellään. Tiedosto näyttää seuraavalta:

MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority
PORT=3001

Huomaa, että sinun tulee sisällyttää salasanasi osaksi url:ia, thepasswordishere tilalle.

Määrittelimme samalla aiemmin kovakoodaamamme sovelluksen käyttämän portin eli ympäristömuuttujan PORT.

Tiedosto .env tulee heti gitignorata, sillä emme halua julkaista tiedoston sisältöä verkkoon!

Kuva havainnollistaa sitä että .env on lisätty tiedostoon .gitignore

dotenvissä määritellyt ympäristömuuttujat otetaan koodissa käyttöön komennolla require('dotenv').config() ja niihin viitataan Nodessa kuten "normaaleihin" ympäristömuuttujiin syntaksilla process.env.MONGODB_URI.

Muutetaan nyt tiedostoa index.js seuraavasti:

require('dotenv').config()const express = require('express')
const app = express()
const Note = require('./models/note')
// ..

const PORT = process.env.PORTapp.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

On tärkeää, että dotenv otetaan käyttöön ennen modelin note importtaamista. Tällöin varmistutaan siitä, että tiedostossa .env olevat ympäristömuuttujat ovat alustettuja kun moduulin koodia importoidaan.

Tärkeä huomio Fly.io:n käyttäjille

Koska Fly.io ei hyödynnä gitiä, menee myös .env-tiedosto Fly.io:n palvelimelle, ja ympäristömuuttujien arvo välittyy myös sinne.

Tietoturvallisempi vaihtoehto on kuitenkin estää tiedoston .env siirtyminen Fly.io:n tekemällä hakemiston juureen tiedosto .dockerignore, jolla on sisältö

.env

ja asettaa ympäristömuuttujan arvo komennolla:

fly secrets set MONGODB_URI='mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority'

Koska .env-tiedosto määrittelee myös ympäristömuuttujan PORT arvon, on .env:in ignorointi oikeastaan välttämätöntä jotta sovellus ei yritä käynnistää itseään väärään portiin.

Renderiä käytettäessä tietokannan osoitteen kertova ympäristömuuttuja määritellään dashboardista käsin:

fullstack content

Tietokannan käyttö reittien käsittelijöissä

Muutetaan nyt kaikki operaatiot tietokantaa käyttävään muotoon.

Uuden muistiinpanon luominen tapahtuu seuraavasti:

app.post('/api/notes', (request, response) => {
  const body = request.body

  if (body.content === undefined) {
    return response.status(400).json({ error: 'content missing' })
  }

  const note = new Note({
    content: body.content,
    important: body.important || false,
  })

  note.save().then(savedNote => {
    response.json(savedNote)
  })
})

Muistiinpano-oliot siis luodaan Note-konstruktorifunktiolla. Pyyntöön vastataan metodin save takaisinkutsufunktion sisällä. Näin varmistutaan, että operaation vastaus tapahtuu vain, jos operaatio on onnistunut. Palaamme virheiden käsittelyyn myöhemmin.

Takaisinkutsufunktion parametrina oleva savedNote on talletettu muistiinpano. HTTP-pyyntöön palautetaan kuitenkin automaattisesti siitä metodilla toJSON formatoitu muoto:

response.json(savedNote)

Yksittäisen muistiinpanon tarkastelu muuttuu muotoon

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id).then(note => {
    response.json(note)
  })
})

Frontendin ja backendin yhteistoiminnallisuuden varmistaminen

Kun backendia laajennetaan, kannattaa sitä testailla aluksi ehdottomasti selaimella, Postmanilla tai VS Coden REST Clientillä. Seuraavassa kokeillaan uuden muistiinpanon luomista tietokannan käyttöönoton jälkeen:

Luotaessa VS coden rest clientillä muistiinpano, avautuva näkymä havainnollistaa HTTP-kutsuun saatua vastausta

Vasta kun kaikki on todettu toimivaksi, kannattaa siirtyä testailemaan, että muutosten jälkeinen backend toimii yhdessä myös frontendin kanssa. Kaikkien kokeilujen tekeminen ainoastaan frontendin kautta on todennäköisesti varsin tehotonta.

Todennäköisesti voi olla kannattavaa edetä frontin ja backin integroinnissa toiminnallisuus kerrallaan, eli ensin voidaan toteuttaa esim. kaikkien muistiinpanojen näyttäminen backendiin ja testata, että toiminnallisuus toimii selaimella. Tämän jälkeen varmistetaan, että frontend toimii yhteen muutetun backendin kanssa. Kun kaiken on todettu olevan kunnossa, siirrytään seuraavan ominaisuuden toteuttamiseen.

Kun kuvioissa on mukana tietokanta, on tietokannan tilan tarkastelu MongoDB Atlasin hallintanäkymästä varsin hyödyllistä. Usein myös suoraan tietokantaa käyttävät Node-apuohjelmat, kuten tiedostoon mongo.js kirjoittamamme koodi auttavat sovelluskehityksen edetessä.

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part3-4.

Todellisen full stack ‑sovelluskehittäjän vala

Sovelluksemme koostuu nyt frontendin ja backendin lisäksi myös tietokannasta. Virhelähteiden määrä siis kasvaa ja päivitämme full stack ‑kehittäjän valaa seuraavasti:

Full stack ‑ohjelmointi on todella hankalaa, ja sen takia lupaan hyödyntää kaikkia ohjelmointia helpottavia keinoja:

  • pidän selaimen konsolin koko ajan auki
  • tarkkailen säännöllisesti selaimen network-välilehdeltä, että frontendin ja backendin välinen kommunikaatio tapahtuu oletusteni mukaan
  • tarkkailen säännöllisesti palvelimella olevan datan tilaa, ja varmistan että frontendin lähettämä data siirtyy sinne kuten oletin
  • pidän silmällä tietokannan tilaa: varmistan että backend tallentaa datan sinne oikeaan muotoon
  • etenen pienin askelin
  • käytän koodissa runsaasti console.log-komentoja varmistamaan sen, että varmasti ymmärrän jokaisen kirjoittamani koodirivin, sekä etsiessäni koodista mahdollisia bugin aiheuttajia
  • jos koodini ei toimi, en kirjoita enää yhtään lisää koodia, vaan alan poistamaan toiminnan rikkoneita rivejä tai palaan suosiolla tilanteeseen, missä koodi vielä toimi
  • kun kysyn apua kurssin Discord- tai Telegram-kanavalla, tai muualla internetissä, muotoilen kysymyksen järkevästi, esim. täällä esiteltyyn tapaan

Virheiden käsittely

Jos yritämme hakea selaimella sellaista yksittäistä muistiinpanoa, jota ei ole olemassa (eli esim. urliin http://localhost:3001/api/notes/5c41c90e84d891c15dfa3431, jossa 5c41c90e84d891c15dfa3431 ei ole minkään tietokannassa olevan muistiinpanon tunniste, on palvelimelta saatu vastaus null.

Muutetaan koodia niin, että tapauksessa, jossa muistiinpanoa ei ole olemassa, lähetään vastauksena HTTP-statuskoodi 404 Not Found. Toteutetaan lisäksi yksinkertainen catch-lohko, jossa käsitellään tapaukset, joissa findById-metodin palauttama promise päätyy rejected-tilaan:

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id)
    .then(note => {
      if (note) {        response.json(note)      } else {        response.status(404).end()      }    })
    .catch(error => {      console.log(error)      response.status(500).end()    })})

Jos kannasta ei löydy haettua olioa, muuttujan note arvo on null ja koodi ajautuu else-haaraan. Siellä vastataan kyselyyn statuskoodilla 404 Not Found. Jos findById-metodin palauttama promise päätyy rejected-tilaan, kyselyyn vastataan statuskoodilla 500 Internal Server Error. Konsoliin tulostetaan tarkempi tieto virheestä.

Olemattoman muistiinpanon lisäksi koodista löytyy myös toinen virhetilanne, joka täytyy käsitellä. Tässä virhetilanteessa muistiinpanoa yritetään hakea virheellisen muotoisella id:llä eli sellaisella, joka ei vastaa MongoDB:n id:iden muotoa.

Jos teemme näin, tulostuu konsoliin:


Method: GET
Path:   /api/notes/5a3b7c3c31d61cb9f8a0343
Body:   {}
---
{ CastError: Cast to ObjectId failed for value "5a3b7c3c31d61cb9f8a0343" at path "_id"
    at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11)
    at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13)
    ...

Kun findById-metodi saa argumentikseen väärässä muodossa olevan id:n, se heittää virheen. Tästä seuraa se, että metodin palauttama promise päätyy rejected-tilaan, jonka seurauksena catch-lohkossa määriteltyä funktiota kutsutaan.

Tehdään pieniä muutoksia koodin catch-lohkoon:

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(note)
      } else {
        response.status(404).end()
      }
    })
    .catch(error => {
      console.log(error)
      response.status(400).send({ error: 'malformatted id' })    })
})

Jos id ei ole hyväksyttävässä muodossa, ajaudutaan catch:in avulla määriteltyyn virheidenkäsittelijään. Sopiva statuskoodi on 400 Bad Request koska kyse on juuri siitä:

The request could not be understood by the server due to malformed syntax. The client SHOULD NOT repeat the request without modifications.

Vastaukseen on lisätty myös hieman dataa kertomaan virheen syystä.

Promisejen yhteydessä kannattaa melkeinpä aina lisätä koodiin myös virhetilanteiden käsittely, muuten seurauksena on usein hämmentäviä vikoja.

Ei ole koskaan huono idea tulostaa poikkeuksen aiheuttanutta olioa konsoliin virheenkäsittelijässä:

.catch(error => {
  console.log(error)  response.status(400).send({ error: 'malformatted id' })
})

Virheenkäsittelijään joutumisen syy voi olla joku ihan muu kuin mitä on tullut alun perin ajatelleeksi. Jos virheen tulostaa konsoliin, voi säästyä pitkiltä ja turhauttavilta väärää asiaa debuggaavilta sessioilta.

Aina kun ohjelmoit ja projektissa on mukana backend, tulee ehdottomasti koko ajan pitää silmällä backendin konsolin tulostuksia. Jos työskentelet pienellä näytöllä, riittää että konsolista on näkyvissä edes pieni kaistale:

Kuva havainnollistaa sitä miten sovelluskehittäjän tulee pitää koko ajan esillä editorin lisäksi konsolia johon backend on käynnistetty

Virheidenkäsittelyn keskittäminen middlewareen

Olemme kirjoittaneet poikkeuksen aiheuttavan virhetilanteen käsittelevän koodin muun koodin sekaan. Se on välillä ihan toimiva ratkaisu, mutta on myös tilanteita, joissa on järkevämpää keskittää virheiden käsittely yhteen paikkaan. Tästä on huomattava etu esim. jos virhetilanteiden yhteydessä virheen aiheuttaneen pyynnön tiedot logataan tai lähetetään johonkin virhediagnostiikkajärjestelmään, esim. Sentryyn.

Muutetaan routen /api/notes/:id käsittelijää siten, että se siirtää virhetilanteen käsittelyn eteenpäin funktiolla next, jonka se saa kolmantena parametrinaan:

app.get('/api/notes/:id', (request, response, next) => {  Note.findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(note)
      } else {
        response.status(404).end()
      }
    })
    .catch(error => next(error))})

Eteenpäin siirrettävä virhe annetaan funktiolle next parametrina. Jos funktiota next kutsuttaisiin ilman parametria, käsittely siirtyisi ainoastaan eteenpäin seuraavaksi määritellylle routelle tai middlewarelle. Jos funktion next kutsussa annetaan parametri, siirtyy käsittely virheidenkäsittelymiddlewarelle.

Expressin virheidenkäsittelijät ovat middlewareja, joiden määrittelevällä funktiolla on neljä parametria. Virheidenkäsittelijämme näyttää seuraavalta:

const errorHandler = (error, request, response, next) => {
  console.error(error.message)

  if (error.name === 'CastError') {
    return response.status(400).send({ error: 'malformatted id' })
  }

  next(error)
}

// tämä tulee kaikkien muiden middlewarejen ja routejen rekisteröinnin jälkeen!
app.use(errorHandler)

Virheenkäsittelijä tarkastaa, onko kyse CastError-poikkeuksesta eli virheellisestä olio-id:stä. Jos on, käsittelijä lähettää pyynnön tehneelle selaimelle vastauksen käsittelijän parametrina olevan response-olion avulla. Muussa tapauksessa se siirtää funktiolla next virheen käsittelyn Expressin oletusarvoisen virheidenkäsittelijän hoidettavaksi.

Huomaa, että virheidenkäsittelijämiddleware tulee rekisteröidä muiden middlewarejen sekä routejen rekisteröinnin jälkeen.

Middlewarejen käyttöönottojärjestys

Koska middlewaret suoritetaan siinä järjestyksessä, missä ne on otettu käyttöön funktiolla app.use, on niiden määrittelyn kanssa oltava tarkkana.

Oikeaoppinen järjestys on tämä:

app.use(express.static('build'))
app.use(express.json())
app.use(requestLogger)

app.post('/api/notes', (request, response) => {
  const body = request.body
  // ...
})

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

// olemattomien osoitteiden käsittely
app.use(unknownEndpoint)

const errorHandler = (error, request, response, next) => {
  // ...
}

// virheellisten pyyntöjen käsittely
app.use(errorHandler)

JSON-parseri on syytä ottaa käyttöön melkeinpä ensimmäisenä. Jos järjestys olisi seuraava

app.use(requestLogger) // request.body on tyhjä

app.post('/api/notes', (request, response) => {
  // request.body on tyhjä
  const body = request.body
  // ...
})

app.use(express.json())

ei HTTP-pyynnön mukana oleva data olisi loggerin eikä POST-pyynnön käsittelyn aikana käytettävissä, vaan kentässä request.body olisi tyhjä olio.

Tärkeää on myös ottaa käyttöön olemattomien osoitteiden käsittely viimeisenä.

Myös seuraava järjestys aiheuttaisi ongelman:

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

// olemattomien osoitteiden käsittely
app.use(unknownEndpoint)

app.get('/api/notes', (request, response) => {
  // ...
})

Nyt olemattomien osoitteiden käsittely on sijoitettu ennen HTTP GET ‑pyynnön käsittelyä. Koska olemattomien osoitteiden käsittelijä vastaa kaikkiin pyyntöihin 404 Unknown Endpoint, ei mihinkään sen jälkeen määriteltyyn reittiin tai middlewareen (poikkeuksena virheenkäsittelijä) enää mennä.

Muut operaatiot

Toteutetaan vielä jäljellä olevat operaatiot, eli yksittäisen muistiinpanon poisto ja muokkaus.

Poisto onnistuu helpoiten metodilla findByIdAndDelete:

app.delete('/api/notes/:id', (request, response, next) => {
  Note.findByIdAndDelete(request.params.id)
    .then(result => {
      response.status(204).end()
    })
    .catch(error => next(error))
})

Vastauksena on molemmissa "onnistuneissa" tapauksissa statuskoodi 204 No Content eli jos olio poistettiin tai olioa ei ollut mutta id oli periaatteessa oikea. Takaisinkutsun parametrin result perusteella olisi mahdollisuus haarautua ja palauttaa tilanteissa eri statuskoodi, jos sille on tarvetta. Mahdollinen poikkeus siirretään jälleen virheenkäsittelijälle.

Muistiinpanon tärkeyden muuttamisen mahdollistava olemassa olevan muistiinpanon päivitys onnistuu helposti metodilla findByIdAndUpdate:

app.put('/api/notes/:id', (request, response, next) => {
  const body = request.body

  const note = {
    content: body.content,
    important: body.important,
  }

  Note.findByIdAndUpdate(request.params.id, note, { new: true })
    .then(updatedNote => {
      response.json(updatedNote)
    })
    .catch(error => next(error))
})

Operaatio mahdollistaa myös muistiinpanon sisällön editoinnin.

Huomaa, että metodin findByIdAndUpdate parametrina tulee antaa normaali JavaScript-olio eikä uuden olion luomisessa käytettävä Note-konstruktorifunktiolla luotu olio.

Huomioi operaatioon findByIdAndUpdate liittyen, että oletusarvoisesti tapahtumankäsittelijä saa parametrikseen updatedNote päivitetyn olion ennen muutosta olleen tilan. Lisäsimme operaatioon parametrin { new: true }, jotta saamme muuttuneen olion palautetuksi kutsujalle.

Backend vaikuttaa toimivan Postmanista ja VS Coden REST Clientistä tehtyjen kokeilujen perusteella. Myös frontend toimii moitteettomasti tietokantaa käyttävän backendin kanssa.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part3-5.