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 en ole ollenkaan samaa mieltä. 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

    35

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

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

    36e

    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 dev tools

    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

    37

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

    38eb

    Ohjelman muuttujien arvoja voi evaluoida oikealla olevaan watch-ikkunaan.

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

    39ea

    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, mitä 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 tarkastelusta, missä 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 laitoksen kursseilla on käytetty relaatiotietokantoja. Tällä kurssilla käytämme MongoDB:tä, joka on ns. dokumenttitietokanta.

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

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

    MongoDB:n voi luonnollisesti asentaa 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, Atlas kehoittaa luomaan klusterin:

    57

    Valitaan AWS ja Frankfurt ja luodaan klusteri.

    58

    Odotetaan että klusteri on valmiina, tähän menee noin 10 minuuttia.

    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:

    59

    annetaan käyttäjälle luku- ja kirjoitusoikeus kaikkiin tietokantoihin

    60

    HUOM muutamissa tapauksissa uusi käyttäjä ei ole toiminut heti luomisen jälkeen. On saattanut kestää jopa useita minuutteja ennen kuin käyttäjätunnus on ruvennut toimimaan.

    Seuraavaksi tulee määritellä ne ip-osoitteet, joista tietokantaan pääsee käsiksi

    61ea

    Sallitaan yksinkertaisuuden vuoksi yhteydet kaikkialta:

    62

    Lopultakin ollaan valmiina ottamaan tietokantayhteyden. Klikataan connect

    63ea

    Valitaan Connect your application:

    64ea

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

    Osoite näyttää seuraavalta:

    mongodb+srv://fullstack:<password>@cluster0-ostce.mongodb.net/test?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 mongon dokumenteiksi on suoraviivaista.

    Asennetaan Mongoose:

    npm install mongoose --save

    Ei lisätä mongoa 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-ostce.mongodb.net/test?retryWrites=true`
    
    mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true })
    
    const noteSchema = new mongoose.Schema({
      content: String,
      date: Date,
      important: Boolean,
    })
    
    const Note = mongoose.model('Note', noteSchema)
    
    const note = new Note({
      content: 'HTML is Easy',
      date: new Date(),
      important: true,
    })
    
    note.save().then(response => {
      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 collections-osasta

    65

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

    66a

    Tuhotaan kanta test. Päätetään käyttää tietokannasta nimeä note-app muutetaan siis tietokanta-URI muotoon

    mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/note-app?retryWrites=true

    Suoritetaan ohjelma uudelleen.

    68

    Data on nyt oikeassa kannassa. Hallintanäkymä sisältää myös toiminnon create database, joka mahdollistaa uusien tietokantojenluomisen 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,
      date: Date,
      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 Mongo ovat skeemattomia, eli tietokanta itsessään ei välitä mitään sinne talletettavan tiedon muodosta. Samaan kokoelmaankin 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',
      date: new Date(),
      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 copypasteta 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:

    70ea

    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 mongon 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 mongo käyttöön sovelluksessamme.

    Aloitetaan nopean kaavan mukaan, copypastetaan tiedostoon index.js Mongoosen määrittelyt, eli

    const mongoose = require('mongoose')
    
    // ÄLÄ KOSKAAN TALLETA SALASANOJA githubiin!
    const url =
      'mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true'
    
    mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true })
    
    const noteSchema = new mongoose.Schema({
      content: String,
      date: Date,
      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:

    44ea

    Toiminnallisuus on muuten kunnossa, mutta frontend olettaa, että olioiden yksikäsitteinen tunniste on kentässä id. Emme myöskään halua näyttää frontendille mongon 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ä.

    Palautetaan HTTP-pyynnön vastauksena toJSON-metodin avulla muotoiltuja oliota:

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

    Nyt siis muuttujassa notes on taulukollinen mongon palauttamia olioita. Kun taulukko lähetetään JSON-muotoisena vastauksena, jokaisen taulukon olion toJSON-metodia kutsutaan automaattisesti JSON.stringify-metodin toimesta.

    Tietokantamäärittelyjen eriyttäminen moduuliksi

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

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

    const mongoose = require('mongoose')
    
    const url = process.env.MONGODB_URI
    console.log('connecting to', url)mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true })
      .then(result => {    console.log('connected to MongoDB')  })  .catch((error) => {    console.log('error connecting to MongoDB:', error.message)  })
    const noteSchema = new mongoose.Schema({
      content: String,
      date: Date,
      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, { useNewUrlParser: true, useUnifiedTopology: true })
      .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ä, onnistuuko yhteyden muodostaminen:

    45e

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

    MONGODB_URI=osoite_tahan npm run watch

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

    npm install dotenv --save

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

    MONGODB_URI=mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true
    PORT=3001

    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!

    45ae

    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.

    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,
        date: new Date(),
      })
    
      note.save().then(savedNote => {
        response.json(savedNote)
      })
    })

    Muistiinpano-oliot siis luodaan Note-konstruktorifunktiolla. Pyyntöön vastataan save-operaation 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 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:

    46e

    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 kaikki 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.

    Virheiden käsittely

    Jos yritämme mennä selaimella sellaisen yksittäisen muistiinpanon sivulle, jota ei ole olemassa, eli esim. urliin http://localhost:3001/api/notes/5c41c90e84d891c15dfa3431 missä 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 mikä ei vastaa mongon 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 alunperin 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:

    15b

    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 parametrina:

    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)
    }
    
    app.use(errorHandler)

    Virhekäsittelijä tarkastaa onko kyse CastError-poikkeuksesta, eli virheellisestä olioid:stä, jos on, se 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 hoidettavavksi.

    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 seuraavassa:

    app.use(express.static('build'))
    app.use(express.json())
    app.use(logger)
    
    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 melkeimpä ensimmäisenä. Jos järjestys olisi seuraava

    app.use(logger) // 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ä, 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 findByIdAndRemove:

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

    Vastauksena on statuskoodi 204 no content molemmissa "onnistuneissa" tapauksissa, 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 olemassaolevan 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. Päivämäärän muuttaminen ei ole mahdollista.

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

    Pieni, mutta tärkeä detalji liittyen operaatioon findByIdAndUpdate. 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, ja myös frontend toimii moitteettomasti tietokantaa käyttävän backendin kanssa.

    Kun muutamme muistiinpanon tärkeyttä, tulostuu backendin konsoliin ikävä varoitus

    48

    Googlaamalla virheilmoitusta löytyy ohje ongelman korjaamiseen. Eli kuten mongoosen dokumentaatio kehottaa lisätään tiedostoon note.js yksi rivi:

    const mongoose = require('mongoose')
    
    mongoose.set('useFindAndModify', false)
    // ...
    
    module.exports = mongoose.model('Note', noteSchema)

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