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
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
Koodi on pysähtynyt nuolen osoittaman breakpointin kohdalle ja konsoliin on evaluoitu muuttujan note arvo. Vasemmalla olevassa ikkunassa on nähtävillä myös muuta ohjelman tilaan liittyvää.
Ylhäällä olevista nuolista yms. voidaan kontrolloida debuggauksen etenemistä.
Itse en jostain syystä juurikaan käytä Visual Studio Coden debuggeria.
Chromen dev tools
Debuggaus onnisuu myös Chromen developer-konsolilla, käynnistämällä sovellus komennolla:
node --inspect index.js
Debuggeriin pääsee käsiksi klikkaamalla chromen devloper-konsoliin ilmestyneestä vihreästä ikonista
Debuggausnäkymä toimii kuten React-koodia debugattaessa, Sources-välilehdelle voidaan esim. asettaa breakpointeja, eli kohtia joihin suoritus pysähtyy:
Huomaa, että tällä hetkellä eräs debuggeriin liittyvä bugi aiheuttaa sen, että breakpointit toimivat ainoastaan jos koodissa käytetään koodin pysäyttävää debugger-komentoa.
Kaikki sovelluksen console.log-tulostukset tulevat debuggerin Console-välilehdelle. Voit myös tutkia siellä muuttujien arvoja ja suorittaa mielivaltaista Javascript-koodia:
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. Lisää dokumenttitietokannoista ja NoSQL:stä Tietokantojen perusteiden viikon 7 materiaalista.
Lue nyt Tietokantojen perusteiden dokumenttitietokantoja kuvaava osuus. 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:
Valitaan AWS ja Frankfurt ja luodaan klusteri.
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:
annetaan käyttäjälle luku- ja kirjoitustoikeus kaikkiin tietokantoihin
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
Sallitaan yksinkertaisuuden vuoksi yhteydet kaikkialta:
Lopultakin ollaan valmiina ottamaan tietokantayhteyden. Valitaan Connect your application ja Short SRV connection string
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
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 })
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
Kuten näkymä kertoo, on muistiinpanoa vastaava dokumentti lisätty tietokannan test kokoelmaan (collection) nimeltään notes.
Tietokanta lienee loogisempaa nimetä paremmin. Kuten dokumentaatio sanoo, kontrolloidaan kannan nimeä tietokanta-URI:in perusteella
eli 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.
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 sovellus luo muistiinpanoa vastaavan model:in avulla muistiinpano-olion:
const note = new Note({
content: 'Browser can execute only Javascript',
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 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.
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 })
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:
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.map(note => note.toJSON()))
});
});
Nyt siis muuttujassa notes on taulukollinen mongon palauttamia olioita. Kun suoritamme operaation notes.map(note => note.toJSON()) seurauksena on uusi taulukko, missä on jokaista alkuperäisen taulukon alkiota vastaava metodin toJSON avulla muodostettu alkio.
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 })
.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 })
.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:
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!
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 bodyParser = require('body-parser')
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.
Muut operaatiot
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.toJSON())
})
})
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 toJSONformatoitu muoto:
response.json(savedNote.toJSON())
Yksittäisen muistiinpanon tarkastelu muuttuu muotoon
app.get('/api/notes/:id', (request, response) => {
Note.findById(request.params.id).then(note => {
response.json(note.toJSON())
})
})
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:
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-3.
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ä 5a3b80015b6ec6f1bdf68d ei ole minkään tietokannassa olevan muistiinpanon tunniste, jää selain "jumiin" sillä palvelin ei vastaa pyyntöön koskaan.
Palvelimen konsolissa näkyykin virheilmoitus:
Kysely on epäonnistunut ja kyselyä vastaava promise mennyt tilaan rejected. Koska emme käsittele promisen epäonnistumista, ei pyyntöön vastata koskaan. Osassa 2 tutustuimme jo promisejen virhetilanteiden käsittelyyn.
Lisätään tilanteeseen yksinkertainen virheidenkäsittelijä:
app.get('/api/notes/:id', (request, response) => {
Note.findById(request.params.id)
.then(note => {
response.json(note.toJSON())
})
.catch(error => {
console.log(error);
response.status(404).end()
})
})
Kaikissa virheeseen päättyvissä tilanteissa HTTP-pyyntöön vastataan statuskoodilla 404 not found. Konsoliin tulostetaan tarkempi tieto virheestä.
Tapauksessamme on itseasiassa olemassa kaksi erityyppistä virhetilannetta. Toinen vastaa sitä, että yritetään hakea muistiinpanoa 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) ...
Toinen virhetilanne taas vastaa tilannetta, missä haettavan muistiinpanon id on periaatteessa oikeassa formaatissa, mutta tietokannasta ei löydy indeksillä mitään:
Method: GET Path: /api/notes/5a3b7c3c31d61cbd9f8a0343 Body: {} --- TypeError: Cannot read property 'toJSON' of null at Note.findById.then.note (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/index.js:27:24) at process._tickCallback (internal/process/next_tick.js:178:7)
Nämä tilanteet on syytä erottaa toisistaan, ja itseasiassa jälkimmäinen poikkeus on oman koodimme aiheuttama.
Muutetaan koodia seuraavasti:
app.get('/api/notes/:id', (request, response) => {
Note.findById(request.params.id)
.then(note => {
if (note) { response.json(note.toJSON()) } else { response.status(404).end() } })
.catch(error => {
console.log(error)
response.status(400).send({ error: 'malformatted id' }) })
})
Jos kannasta ei löydy haettua olioa, muuttujan note arvo on undefined ja koodi ajautuu else-haaraan. Siellä vastataan kyselyyn 404 not found_
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 virhetilainteiden 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:
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.toJSON())
} 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' && error.kind === 'ObjectId') {
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(bodyParser.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)
bodyParser 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(bodyParser.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 statauskoodi 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.toJSON())
})
.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
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-4.