Osan 3 oppimistavoitteet

  • Web-sovellusten toiminnan perusteet
    • RESTful-periaatteiden alkeet
      • HTTP-pyyntötyypit
      • statuskoodit
      • resurssiperustaiset URL:it
      • HTTP-pyyntöjen- safety ja idempotence-ominaisuudet
  • node.js/express
    • npm
      • sovelluksen luominen
      • riippuvuuksien hallinta
      • npm-skriptien alkeet
      • versiointi
      • nodemon
    • HTTP-pyyntöjen käsittely
      • reittien määrittely
      • pyyntöihin vastaaminen eri statuskoodein
    • middlewaret
    • node.js-sovellusten debuggaaminen
    • sovelluksen vieminen tuotantoon
    • sovelluskehitys- ja tuotantoversion tietokantojen erottaminen
  • mongo
    • dokumenttitietokantojen perusteet
    • mlab:in (tai vastaavan palvelun) mongon peruskäyttö
    • mongoose
      • skeema ja model
      • olioiden hakeminen, tallettaminen, editointi ja poisto
  • konfiguraatiot
    • ESlint

Muutama huomio

setState on asynkrooninen

Muutamat ovat jo törmänneet siihen, että React kutsuu funktiota setState asynkroonisesti, eli jos meillä on seuraava koodi

console.log(this.state.counter)
this.setState({counter: 55})
console.log(this.state.counter)

tulostavat molemmat rivit saman arvon, sillä Reactin tila ei saa uutta arvoa heti komennon this.setState jälkeen, vaan vasta sitten kun suorituksen alla oleva metodi on suoritettu loppuun ja setState on saanut mahdollisuuden suoritukselle.

console.log

Mikä erottaa kokeneen ja kokemattoman Javascript-ohjelmoijan? Kokeneet käyttävät 10-100 kertaa enemmän console.logia.

Paradoksaalista kyllä tämä näyttää olevan tilanne, vaikka kokematon ohjelmoija oikeastaan tarvitsisi console.logia (tai jotain muita debuggaustapoja) huomattavissa määrin kokenutta enemmän.

Eli kun joku ei toimi, älä arvaile vaan logaa tai käytä jotain muita debuggauskeinoja.

HUOM kun käytät komentoa console.log debuggaukseen, älä yhdistele asioita “javamaisesti” plussalla, eli sen sijaan että kirjoittaisit

console.log('propsin arvo on' + props)

erottele tulostettavat asiat pilkulla:

console.log('propsin arvo on', props)

Jos yhdistät merkkijonoon olion, tuloksena on suhteellisen hyödytön tulostustmuoto

propsin arvo on [Object object]

kun taas pilkulla tulostettavat asiat erotellessa saat developer-konsoliin olion, jonka sisältöä on mahdollista tarkastella.

Visual Studio Coden snippetit

VS Codeen on helppo määritellä “snippettejä”, eli Netbeansin “sout”:in tapaisia oikoteitä yleisesti käytettyjen koodinpätkien generointiin. Ohje snippetien luomiseen täällä

VS Code -plugineina löytyy myös hyödyllisiä valmiiksi määriteltyjä snippetejä, esim. tämä

Pakolliset tehtävät, tehtävien vaikutus arvosanaan

Joissain yhteyksissä on ollut pientä epäselvyyttä mitä tiettyjen tehtävien pakollisuus tarkoittaa, ja mikä eipakollisten tehtävien rooli on. Tarkennusta asiaan tehtävien sivun alussa.

linkkivinkit

Kurssisivun alaisuudessa on nyt osio, jonne kaikkien toivotaan lisäilevän hyödyllisiksi kokemiaan linkkejä. Lisääminen onnistuu tekemällä pull request tänne

Kun lisäät linkin, laita linkin yhteyteen pieni kuvaus mitä linkin takaa löytyy.

Node.js

Siirrämme tässä osassa fokuksen backendiin, eli palvelimella olevaan toiminnallisuuteen.

Backendin toteutusympäristönä käytämme Node.js:ää, joka on melkein missä vaan, erityisesti palvelimilla ja omalla koneellasikin toimiva, Googlen chrome V8 -Javascriptmoottoriin perustuva Javascriptin suoritusympäristö.

Kurssimateriaalia tehtäessä on ollut käytössä Node.js:n versio v8.6.0. Huolehdi että omasi on vähintään yhtä tuore (ks. komentoriviltä node -v).

Kuten osassa 1 todettiin, selaimet eivät vielä osaa uusimpia Javascriptin ominaisuuksia ja siksi selainpuolen koodi täytyy kääntää eli transpiloida esim babel:illa. Backendissa tilanne on kuitenkin toinen, uusin Node hallitsee riittävissä määrin myös Javascriptin uusia versioita (muutamia vielä standardoimattomia ominaisuuksia lukuunottamatta), joten suoritamme Nodella suoraan kirjoittamaamme koodia ilman transpilointivaihetta.

Tavoitteenamme on tehdä osan 2 muistiinpanosovellukseen sopiva backend. Aloitetaan kuitenkin ensin perusteiden läpikäyminen toteuttamalla perinteinen “hello world”-sovellus.

Osassa 2 oli jo puhe npm:stä, eli Javascript-projektien hallintaan liittyvästä, alunperin Node-ekosysteemistä kotoisin olevasta työkalusta. Mennään sopivaan hakemistoon ja luodaan projektimme runko komennolla npm init. Vastaillaan kysymyksiin sopivasti ja tuloksena on hakemiston juureen sijoitettu projektin tietoja kuvaava tiedosto package.json

{
  "name": "notebackend",
  "version": "0.0.1",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Matti Luukkainen",
  "license": "MIT"
}

Tiedosto määrittelee mm. että ohjelmamme käynnistyspiste on tiedosto index.js.

Tehdään kenttään scripts pieni lisäys:

{
  // ...
  "scripts": {
    "start": "node index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ...
}

Luodaan sitten sovelluksen ensimmäinen versio, eli projektin juureen sijoitettava tiedosto index.js ja sille seuraava sisältö:

console.log('hello world')

Voimme suorittaa ohjelman joko “suoraan” nodella, komentorivillä

node index.js

tai npm scriptinä

npm start

npm-skripti start toimii koska määrittelimme sen tiedostoon package.json

{
  // ...
  "scripts": {
    "start": "node index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ...
}

Vaikka esim. projektin suorittaminen onnistuukin suoraan käyttämällä komentoa node index.js, on npm-projekteille suoritettavat operaatiot yleensä tapana määritellä nimenomaan npm-skripteinä.

Oletusarvoinen package.json määrittelee valmiiksi myös toisen yleisesti käytetyn npm-scriptin eli npm test. Koska projektissamme ei ole vielä testikirjastoa, ei npm test kuitenkaan tee vielä muuta kuin suorittaa komennon

echo "Error: no test specified" && exit 1

Yksinkertainen web-palvelin

Muutetaan sovellus web-palvelimeksi:

const http = require('http')

const app = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' })
  res.end('Hello World')
})

const port = 3001
app.listen(port)
console.log(`Server running on port ${port}`)

Konsoliin tulostuu

Server running on port 3001

Voimme avata selaimella osoitteessa http://localhost:3001 olevan vaatimattoman sovelluksemme:

Palvelin toimii itseasiassa täsmälleen samalla tavalla riippumatta urlin loppuosasta, eli myös sivun http://localhost:3001/foo/bar sisältö on sama.

HUOM jos koneesi portti 3001 on jo jonkun sovelluksen käytössä, aiheuttaa käynnistäminen virheen:

> notebackend@0.0.1 start /Users/mluukkai/opetus/_2018fullstack_koodi/materiaali/osa3/notebackend
> node index.js

Server running on port 3001
events.js:182
      throw er; // Unhandled 'error' event
      ^

Error: listen EADDRINUSE :::3001
    at Object._errnoException (util.js:1019:11)

Sammuta portissa 3001 oleva sovellus (edellisessä osassa json-server käynnistettiin porttiin 3001) tai määrittele sovellukselle jokin toinen portti.

Tarkastellaan koodia hiukan. Ensimmäinen rivi

const http = require('http')

ottaa käyttöön Noden sisäänrakennetun web-palvelimen määrittelevän moduulin. Kyse on käytännössä samasta asiasta, mihin olemme selainpuolen koodissa tottuneet hieman syntaksiltaan erilaisessa muodossa:

import http from 'http'

Selaimen puolella käytetään (nykyään) ES6:n moduuleita, eli moduulit määritellään exportilla ja otetaan käyttöön importilla.

Node.js kuitenkin käyttää ns. CommonJS-moduuleja. Syy tälle on siinä, että Node-ekosysteemillä oli tarve moduuleihin jo kauan ennen kuin Javascript tuki kielen tasolla moduuleja. Node ei toistaiseksi tue ES-moduuleja, mutta tuki on todennäköisesti jossain vaiheessa tulossa.

CommonJS-moduulit toimivat kohtuullisessa määrin samaan tapaan kuin ES6-moduulit, ainakin tämän kurssin tarpeiden puitteissa.

Koodi jatkuu seuraavasti:

const app = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'text/plain' })
  response.end('Hello World')
})

koodi luo http-palvelimen metodilla createServer web-palvelimen, jolle se rekisteröi tapahtumankäsittelijän, joka suoritetaan jokaisen osoitteen http:/localhost:3001/ alle tulevan HTTP-pyynnön yhteydessä.

Pyyntöön vastataan statuskoodilla 200, asettamalla Content-Type-headerille arvo text/plain ja asettamalla palautettavan sivun sisällöksi merkkijono Hello World.

Viimeiset rivit sitovat muuttujaan app sijoitetun http-palvelimen kuuntelemaan porttiin 3001 tulevia HTTP-pyyntöjä:

const PORT = 3001
app.listen(PORT)
console.log(`Server running on port ${PORT}`)

Koska tällä kurssilla palvelimen rooli on pääasiassa tarjota frontille JSON-muotoista “raakadataa”, muutetaan heti palvelinta siten, että se palauttaa kovakoodatun listallisen JSON-muotoisia muistiinpanoja:

let notes = [
  {
    id: 1,
    content: 'HTML on helppoa',
    date: '2017-12-10T17:30:31.098Z',
    important: true
  },
  {
    id: 2,
    content: 'Selain pystyy suorittamaan vain javascriptiä',
    date: '2017-12-10T18:39:34.091Z',
    important: false
  },
  {
    id: 3,
    content: 'HTTP-protokollan tärkeimmät metodit ovat GET ja POST',
    date: '2017-12-10T19:20:14.298Z',
    important: true
  }
]

const app = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'application/json' })
  response.end(JSON.stringify(notes))
})

Käynnistetään palvelin uudelleen (palvelin sammutetaan painamalla ctrl ja c yhtä aikaa konsolissa) ja refreshataan selain.

Headerin Content-Type arvolla application/json kerrotaan, että kyse on JSON-muotoisesta datasta. Muuttujassa notes oleva taulukko muutetaan jsoniksi metodilla JSON.stringify(notes).

Kun avaamme selaimen, on tulostusasu sama kuin osassa 2 käytetyn json-serverin tarjoamalla muistiinpanojen listalla:

Voimme jo melkein ruveta käyttämään uutta backendiämme osan 2 muistiinpano-frontendin kanssa. Mutta vain melkein, sillä kun käynnistämme frontendin, tulee konsoliin virheilmoitus

Syy virheelle selviää pian, parantelemme kuitenkin ensin koodia muilta osin.

Express

Palvelimen koodin tekeminen suoraan Noden sisäänrakennetun web-palvelimen http:n päälle on mahdollista, mutta työlästä, erityisesti jos sovellus kasvaa hieman isommaksi.

Nodella tapahtuvaa web-sovellusten ohjelmointia helpottamaan onkin kehitelty useita http:tä miellyttävämmän ohjelmoitirajapinnan tarjoamia kirjastoja. Näistä ylivoimaisesti suosituin on express.

Otetaan express käyttöön määrittelemällä se projektimme riippuvuudeksi komennolla

npm install express --save

Riippuvuus tulee nyt määritellyksi tiedostoon package.json:

{
  // ...
  "dependencies": {
    "express": "^4.16.2"
  }
}

Riippuvuuden koodi asentuu kaikkien projektin riippuvuuksien tapaan projektin juuressa olevaan hakemistoon node_modules. Hakemistosta löytyy expressin lisäksi suuri määrä muutakin tavaraa

Kyseessä ovat expressin riippuvuudet ja niiden riippuvuudet ym… eli projektimme transitiiviset riippuvuudet.

Projektiin asentui expressin versio 4.16.2. Mitä tarkoittaa package.json:issa versiomerkinnän edessä oleva väkänen, eli miksi muoto on

"express": "^4.16.2"

npm:n yhteydessä käytetään ns. semanttista versiointia.

Merkintä ^4.16.2 tarkoittaa, että jos/kun projektin riippuvuudet päivitetään, asennetaan expressistä versio, joka on vähintään 4.16.2, mutta asennetuksi voi tulla versio, jonka patch eli viimeinen numero tai minor eli keskimmäinen numero voi olla suurempi. Pääversio eli major täytyy kuitenkin olla edelleen sama.

Voimme päivittää projektin riippuvuudet komennolla

npm update

Vastaavasti jos aloitamme projektin koodaamisen toisella koneella, saamme haettua ajantasaiset, package.json:in määrittelyn kanssa yhteensopivat riippuvuudet komennolla

npm install

Jos riippuvuuden major-versionumero ei muutu, uudempien versioiden pitäisi olla taaksepäin yhteensopivia, eli jos ohjelmamme käyttäisi tulevaisuudessa esim. expressin versiota 4.99.175, tässä osassa tehtävän koodin pitäisi edelleen toimia ilman muutoksia. Sen sijaan tulevaisuudessa joskus julkaistava express 5.0.0. voi sisältää sellaisia muutoksia, että koodimme ei enää toimisi.

Web ja express

Palataan taas sovelluksen ääreen ja muutetaan se muotoon:

const express = require('express')
const app = express()

let notes = [
  ...
]

app.get('/', (req, res) => {
  res.send('<h1>Hello World!</h1>')
})

app.get('/notes', (req, res) => {
  res.json(notes)
})

const PORT = 3001
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

Jotta sovelluksen uusi versio saadaan käyttöön, on sovellus uudelleenkäynnistettävä.

Sovellus ei muutu paljoa. Heti alussa otetaan käyttöön express, joka on tällä kertaa funktio, jota kutsumalla luodaan muuttujaan app sijoitettava express-sovellusta vastaava olio:

const express = require('express')
const app = express()

Seuraavaksi määritellään sovellukselle kaksi routea. Näistä ensimmäinen määrittelee tapahtumankäsittelijän, joka hoitaa sovelluksen juureen eli polkuun / tulevia HTTP GET -pyyntöjä:

app.get('/', (request, response) => {
  response.send('<h1>Hello World!</h1>')
})

Tapahtumankäsittelijäfunktiolla on kaksi parametria. Näistä ensimmäinen eli request sisältää kaikki HTTP-pyynnön tiedot ja toisen parametrin response:n avulla määritellään, miten pyyntöön vastataan.

Koodissa pyyntöön vastataan käyttäen response-olion metodia send, jonka kutsumisen seurauksena palvelin vastaa HTTP-pyyntöön lähettämällä vastaukseksi send:in parametrina olevan merkkijonon <h1>Hello World!</h1>. Koska parametri on merkkijono, tulee vastauksessa content-type-headerin arvoksi text/html, statuskoodiksi tulee oletusarvoisesti 200.

Routeista toinen määrittelee tapahtumankäsittelijän, joka hoitaa sovelluksen polkuun notes tulevia HTTP GET -pyyntöjä:

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

Pyyntöön vastataan response-olion metodilla json, joka lähettää HTTP-pyynnön vastaukseksi parametrina olevaa Javascript-olioa (eli taulukkoa notes) vastaavan JSON-muotoisen merkkijonon. Express asettaa headerin Content-type arvoksi application/json.

Pieni huomio JSON-muodossa palautettavasta datasta.

Aiemmassa, pelkkää Nodea käyttämässä versiossa, jouduimme muuttamaan palautettavan datan json-muotoon metodilla JSON.stringify:

response.end(JSON.stringify(notes))

Expressiä käytettäessä tämä ei ole tarpeen, sillä muunnos tapahtuu automaattisesti.

Kannattaa huomata, että JSON on merkkijono, eikä Javascript-olio kuten muuttuja notes.

Seuraava interaktiivisessa node-repl:issä suoritettu kokeilu havainnollistaa asiaa:

Saat käynnistettyä interaktiivisen node-repl:in kirjoittamalla komentoriville node. Esim. joidenkin komentojen toimivuuttaa on koodatessa kätevä tarkastaa konsolissa, suosittelen!

nodemon

Jos muutamme sovelluksen koodia, joudumme uudelleenkäynnistämään sovelluksen (eli ensin sammuttamaan konsolista ctrl ja c ja sitten käynnistämään uudelleen), jotta muutokset tulisivat voimaan. Verrattuna Reactin mukavaan workflowhun, missä selain päivittyi automaattisesti koodin muuttuessa tuntuu uudelleenkäynnistely kömpelöltä.

Ongelmaan ratkaisu on nodemon:

nodemon will watch the files in the directory in which nodemon was started, and if any files change, nodemon will automatically restart your node application.

Asennetaan nodemon määrittelemällä se kehitysaikaiseksi riippuvuudeksi (development dependency) komennolla:

npm install --save-dev nodemon

Tiedoston package.json sisältö muuttuu seuraavasti:

{
  //...
  "dependencies": {
    "express": "^4.16.2"
  },
  "devDependencies": {
    "nodemon": "^1.13.3"
  }
}

Jos nodemon-riippuvuus kuitenkin meni normaaliin “dependencies”-ryhmään, päivitä package.json manuaalisesti vastaamaan yllä näkyvää (versiot kuitenkin säilyttäen).

Kehitysaikaisilla riippuvuuksilla tarkoitetaan työkaluja, joita tarvitaan ainoastaan sovellusta kehitettäessä, esim. testaukseen tai sovelluksen automaattiseen uudelleenkäynnistykseen kuten nodemon.

Kun sovellusta suoritetaan tuotantomoodissa, eli samoin kun sitä tullaan suorittamaan tuotantopalvelimella (esim. Herokussa, mihin tulemme kohta siirtämään sovelluksemme), ei kehitysaikaisia riippuvuuksia tarvita.

Voimme käynnistää ohjelman nodemon:illa seuraavasti:

node_modules/.bin/nodemon index.js

Sovelluksen koodin muutokset aiheuttavat nyt automaattisen palvelimen uudelleenkäynnistymisen. Kannattaa huomata, että vaikka palvelin uudelleenkäynnistyy automaattisesti, selain täytyy kuitenkin refreshata, sillä toisin kuin Reactin yhteydessä, meillä ei nyt ole eikä tässä skenaariossa (missä palautamme JSON-muotoista dataa) edes voisikaan olla selainta päivittävää hot reload -toiminnallisuutta.

Komento on ikävä, joten määritellään sitä varten npm-skripti tiedostoon package.json:

{
  // ..
  "scripts": {
    "start": "node index.js",
    "watch": "nodemon index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ..
}

Skriptissä ei ole tarvetta käyttää nodemonin polusta sen täydellistä muotoa node_modules/.bin/nodemon sillä npm osaa etsiä automaattisesti suoritettavaa tiedostoa kyseisestä hakemistosta.

Voimme nyt käynnistää palvelimen sovelluskehitysmoodissa komennolla

npm run watch

Toisin kuin skriptejä start tai test suoritettaessa, joudumme sanomaan myös run.

Lisää routeja

Laajennetaan sovellusta siten, että se toteuttaa samanlaisen RESTful-periaatteeseen nojaavan HTTP-rajapinnan kun json-server.

REST

Representational State Transfer eli REST on Roy Fieldingin vuonna 2000 ilmestyneessä väitöskirjassa määritelty skaalautuvien web-sovellusten rakentamiseksi tarkoitettu arkkitehtuurityyli.

Emme nyt rupea määrittelemään REST:iä Fieldingiläisittäin tai rupea väittämään mitä REST on tai mitä se ei ole vaan otamme hieman kapeamman näkökulman miten REST tai RESTful API:t yleensä tulkitaan Web-sovelluksissa. Alkuperäinen REST-periaate ei edes sinänsä rajoitu Web-sovelluksiin.

Mainitsimme jo edellisestä osassa, että yksittäisiä asioita, meidän tapauksessamme muistiinpanoja kutsutaan RESTful-ajattelussa resursseiksi. Jokaisella resurssilla on URL eli sen yksilöivä osoite.

Erittäin yleinen konventio on muodostaa resurssien yksilöivät URLit liittäen resurssityypin nimi ja resurssin yksilöivä tunniste.

Oletetaan että palvelumme juuriosoite on www.example.com/api

Jos nimitämme muistiinpanoja note-resursseiksi, yksilöidään yksittäinen muistiinpano, jonka tunniste on 10 URLilla www.example.com/api/notes/10.

Kaikkia muistiinpanoja edustavan kokoelmaresurssin URL taas on www.example.com/api/notes

Resursseille voi suorittaa erilaisia operaatiota. Suoritettavan operaation määrittelee HTTP-operaation tyyppi, jota kutsutaan usein myös verbiksi:

URL verbi toiminnallisuus
notes/10    GET hakee yksittäisen resurssin
notes GET hakee kokoelman kaikki resurssit
notes POST luo uuden resurssin pyynnön mukana olevasta datasta
notes/10 DELETE    poistaa yksilöidyn resurssin
notes/10 PUT korvaa yksilöidyn resurssin pyynnön mukana olevalla datalla
notes/10 PATCH korvaa yksilöidyn resurssin osan pyynnön mukana olevalla datalla
     

Näin määrittyy suurin piirtein asia, mitä REST kutsuu nimellä uniform interface, eli jossain määrin yhtenäinen tapa määritellä rajapintoja, jotka mahdollistavat (tietyin tarkennuksin) järjestelmien yhteiskäytön.

Tämänkaltaista tapaa tulkita REST:iä on nimitetty kolmiportaisella asteikolla kypsyystason 2 REST:iksi. REST:in kehittäjän Roy Fieldingin mukaan tällöin kyseessä ei vielä ole ollenkaan asia, jota tulisi kutsua REST-apiksi. Maailman “REST”-apeista valtaosa ei täytäkään puhdasverisen Fieldingiläisen REST-apin määritelmää.

Joissain yhteyksissä (ks. esim Richardsom, Ruby: RESTful Web Services) edellä esitellyn kaltaista suoraviivaisehkoa resurssien CRUD-tyylisen manipuloinnin mahdollistavaa API:a nimitetään REST:in sijaan resurssipohjaiseksi arkkitehtuurityyliksi. Emme nyt kuitenkaan takerru liian tarkasti määritelmällisiin asioihin vaan jatkamme sovelluksen parissa.

Yksittäisen resurssin haku

Laajennetaan nyt sovellusta siten, että se tarjoaa muistiinpanojen operointiin REST-rajapinnan. Tehdään ensin route yksittäisen resurssin katsomista varten.

Yksittäisen muistiinpanon identifioi URL, joka on muotoa notes/10, missä lopussa oleva numero vastaa resurssin muistiinpanon id:tä.

Voimme määritellä expressin routejen poluille parametreja käyttämällä kaksoispistesyntaksia:

app.get('/notes/:id', (request, response) => {
  const id = request.params.id
  const note = notes.find(note => note.id === id )
  response.json(note)
})

Nyt app.get('/notes/:id', ...) käsittelee kaikki HTTP GET -pyynnöt, jotka ovat muotoa note/JOTAIN, missä JOTAIN on mielivaltainen merkkijono.

Polun parametrin id arvoon päästään käsiksi pyynnön tiedot kertovan olion request kautta:

const id = request.params.id

Jo tutuksi tulleella taulukon find-metodilla haetaan taulukosta parametria vastaava muistiinpano ja palautetaan se pyynnön tekijälle.

Kun sovellusta testataan menemällä selaimella osoitteeseen http://localhost:3001/notes/1, havaitaan että se ei toimi, selain näyttää tyhjältä. Tämä on tietenkin softadevaajan arkipäivää, ja on ruvettava debuggaamaan.

Vanha hyvä keino on alkaa lisäillä koodiin console.log-komentoja:

app.get('/notes/:id', (request, response) => {
  const id = request.params.id
  console.log(id)
  const note = notes.find(note => note.id === id)
  console.log(note)
  response.json(note)
})

Konsoliin tulostuu

1
undefined

eli halutun muistiinpanon id välittyy sovellukseen aivan oikein, mutta find komento ei löydä mitään.

Päätetään tulostella konsoliin myös find-komennon sisällä olevasta vertailijafunktiosta, joka onnistuu helposti kun tiiviissä muodossa oleva funktio note => note.id === id kirjoitetaan eksplisiittisen returnin sisältävässä muodossa:

app.get('/notes/:id', (request, response) => {
  const id = request.params.id
  const note = notes.find(note => {
    console.log(note.id, typeof note.id, id, typeof id, note.id === id)
    return note.id === id
  })
  console.log(note)
  response.json(note)
})

Jokaisesta vertailufunktion kutsusta tulostetaan nyt monta asiaa. Konsolin tulostus on seuraava:

1 'number' '1' 'string' false
2 'number' '1' 'string' false
3 'number' '1' 'string' false

ongelman syy selviää: muuttujassa id on tallennettuna merkkijono ‘1’ kun taas muistiinpanojen id:t ovat numeroita. Javascriptissä === vertailu katsoo kaikki eri tyyppiset arvot oletusarvoisesti erisuuriksi, joten 1 ei ole ‘1’.

Korjataan ongelma, muuttamalla parametrina oleva merkkijonomuotoinen id numeroksi:

app.get('/notes/:id', (request, response) => {
  const id = Number(request.params.id)
  const note = notes.find(note => note.id === id)
  response.json(note)
})

ja nyt yksittäisen resurssin hakeminen toimii

toiminnallisuuteen jää kuitenkin pieni ongelma.

Jos haemme muistiinpanoa sellaisella indeksillä, mitä vastaavaa muistiinpanoa ei ole olemassa, vastaa palvelin seuraavasti

HTTP-statuskoodi on onnistumisesta kertova 200. Vastaukseen ei liity dataa, sillä headerin content-length arvo on 0, ja samaa todistaa selain: mitään ei näy.

Syynä tälle käyttäytymiselle on se, että muuttujan note arvoksi tulee undefined jos muistiinpanoa ei löydy. Tilanne tulisi käsitellä palvelimella järkevämmin, eli statuskoodin 200 sijaan tulee vastata statuskoodilla 404 not found.

Tehdään koodiin muutos

app.get('/notes/:id', (request, response) => {
  const id = Number(request.params.id)
  const note = notes.find(note => note.id === id)

  if ( note ) {
    response.json(note)
  } else {
    response.status(404).end()
  }
})

Koska vastaukseen ei nyt liity mitään dataa käytetään statuskoodin asettavan metodin status lisäksi metodia end ilmoittamaan siitä, että pyyntöön tulee vastata ilman dataa.

Koodin haarautumisessa hyväksikäytetään sitä, että mikä tahansa Javascript-olio on truthy, eli katsotaan todeksi vertailuoperaatiossa. undefined taas on falsy eli epätosi.

Nyt sovellus toimii, eli palauttaa oikean virhekoodin. Sovellus ei kuitenkaan palauta mitään käyttäjälle näytettävää kuten web-sovellukset yleensä tekevät jos mennään osoitteeseen jota ei ole olemassa. Emme kuitenkaan tarvitse nyt mitään näytettävää, sillä REST API:t ovat ohjelmalliseen käyttöön tarkoitettuja rajapintoja ja pyyntöön liitetty virheestä kertova statuskoodi on riittävä.

Resurssin poisto

Toteutetaan seuraavaksi resurssin poistava route. Poisto tapahtuu tekemällä HTTP DELETE -pyyntö resurssin urliin:

app.delete('/notes/:id', (request, response) => {
  const id = Number(request.params.id)
  notes = notes.filter(note => note.id !== id)

  response.status(204).end()
})

Jos poisto onnistuu, eli poistettava muistiinpano on olemassa, vastataan statuskoodilla 204 no content sillä mukaan ei lähetetä mitään dataa.

Ei ole täyttä yksimielisyyttä siitä mikä statuskoodi DELETE-pyynnöstä pitäisi palauttaa jos poistettavaa resurssia ei ole olemassa. Vaihtoehtoja ovat lähinnä 204 ja 404. Yksinkertaisuuden vuoksi sovellus palauttaa nyt molemmissa tilanteissa statuskoodin 204.

Postman

Herää kysymys miten voimme testata poisto-operaatiota? HTTP GET -pyyntöjä on helppo testata selaimessa. Voisimme toki kirjoittaa Javascript-koodin, joka testaa deletointia, mutta jokaiseen mahdolliseen tilanteeseen testikoodinkaan tekeminen ei ole aina paras ratkaisu.

On olemassa useita backendin testaamista helpottavia työkaluja, eräs näistä on edellisessä osassa nopeasti mainittu komentorivityökalu curl.

Käytetään nyt kuitenkin postman-nimistä sovellusta.

Asennetaan postman ja kokeillaan

Postmanin käyttö on tässä tilanteessa suhteellisen yksinkertaista, riittää määritellä url ja valita oikea pyyntötyyppi.

Palvelin näyttää vastaavan oikein. Tekemällä HTTP GET osoitteeseen http://localhost:3001/notes selviää että poisto-operaatio oli onnistunut, muistiinpanoa, jonka id on 2 ei ole enää listalla.

Koska muistiinpanot on talletettu palvelimen muistiin, uudelleenkäynnistys palauttaa tilanteen ennalleen.

Visual Studio Coden REST client

Jos käytät Visual Studio Codea, voit postmanin sijaan käyttää VS Coden REST client -pluginia.

Kun plugin on asennettu, on sen käyttö erittäin helppoa. Tehdään projektin juureen hakemisto requests, jonka sisään talletetaan REST Client -pyynnöt .rest-päätteisinä tiedostoina.

Luodaan kaikki muistiinpanot hakevan pyynnön määrittelevä tiedosto get_all_notes.rest

Klikkaamalla Send request -tekstiä, REST client suorittaa määritellyn HTTP-pyynnön ja palvelimen vastaus avautuu editoriin:

Datan vastaanottaminen

Toteutetaan seuraavana uusien muistiinpanojen lisäys, joka siis tapahtuu tekemällä HTTP POST -pyyntö osoitteeseen http://localhost:3001/notes ja liittämällä pyynnön mukaan eli bodyyn luotavan muistiinpanon tiedot JSON-muodossa.

Jotta pääsisimme pyynnön mukana lähetettyyn dataan helposti käsiksi, tarvitsemme body-parser-kirjaston apua.

Otetaan body-parser käyttöön ja luodaan alustava määrittely HTTP POST -pyynnön käsittelyyn

const express = require('express')
const app = express()
const bodyParser = require('body-parser')

app.use(bodyParser.json())

//...

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

  response.json(note)
})

Tapahtumankäsittelijäfunktio pääsee dataan käsiksi viittaamalla request.body.

Ilman body-parser-käyttöönottoa pyynnön kentän body arvo olisi ollut määrittelemätön. body-parserin toimintaperiaatteena on, että se ottaa pyynnön mukana olevan JSON-muotoisen datan, muuttaa sen Javascript-olioksi ja sijoittaa request-olion kenttään body ennen kuin routen käsittelijää kutsutaan.

Toistaiseksi sovellus ei vielä tee vastaanotetulle datalle mitään muuta kuin tulostaa sen konsoliin ja palauttaa sen pyynnön vastauksessa.

Ennen toimintalogiikan viimeistelyä varmistetaan ensin postmanilla, että lähetetty tieto menee varmasti perille. Pyyntötyypin ja urlin lisäksi on määriteltävä myös pyynnön mukana menevä data eli body:

Näyttää kuitenkin siltä, että mitään ei mene perille, palvelin vastaanottaa ainoastaan tyhjän olion. Missä on vika? Olemme unohtaneet määritellä headerille Content-Type oikean arvon:

Nyt kaikki toimii! Ilman oikeaa headerin arvoa palvelin ei osaa parsia dataa oikeaan muotoon. Se ei edes yritä arvailla missä muodossa data on, sillä potentiaalisia datan siirtomuotoja eli Content-Typejä on olemassa suuri määrä.

Jos käytät VS Codea niin edellisessä luvussa esitelty REST client kannattaa asentaa viimeistään nyt. POST-pyyntö tehdään REST clientillä seuraavasti:

Eli pyyntöä varten on luotu oma tiedosto new_note.rest. Pyyntö on muotoiltu dokumentaation ohjetta noudatellen.

REST clientin eräs suuri etu postmaniin verratatuna on se, että pyynnöt saa kätevästi talletettua projektin repositorioon ja tällöin ne ovat helposti koko kehitystiimin käytössä. Postmanillakin on mahdollista tallettaa pyyntöjä, mutta tilanne menee helposti kaaoottiseksi etenkin jos työn alla on useita toisistaan riippumattomia projekteja.

Tärkeä sivuhuomio

Välillä debugatessa tulee vastaan tilanteita, joissa backendissä on tarve selvittää mitä headereja HTTP-pyynnöille on asetettu. Eräs menetelmä tähän on request-olion melko kehnosti nimetty metodi get, jonka avulla voi selvittää yksittäisen headerin arvon. request-oliolla on myös kenttä headers, jonka arvona ovat kaikki pyyntöön liittyvät headerit.

Ongelmia voi esim syntyä jos jätät vahingossa VS REST clientillä ylimmän rivin ja headerit määrittelevien rivien väliin tyhjän rivin. Tällöin REST client tulkitsee, että millekään headerille ei aseteta arvoa ja näin backend ei osaa tulkita pyynnön mukana olevaa dataa JSON:iksi.

Puuttuvan content-type-headerin ongelma selviää kun backendissa tulostaa pyynnön headerit esim. komennolla console.log(request.headers)

Palataan taas sovelluksen pariin. Kun tiedämme, että sovellus vastaanottaa tiedon oikein, voimme viimeistellä sovelluslogiikan:

app.post('/notes', (request, response) => {
  const maxId = notes.length > 0 ? notes.map(n => n.id).sort().reverse()[0] : 0
  const note = request.body
  note.id = maxId + 1

  notes = notes.concat(note)

  response.json(note)
})

Uudelle muistiinpanolle tarvitaan uniikki id. Ensin selvitetään olemassaolevista id:istä suurin muuttujaan maxId. Uuden muistiinpanon id:ksi asetetaan sitten maxId+1. Tämä tapa ei ole itseasiassa kovin hyvä, mutta emme nyt välitä siitä sillä tulemme pian korvaamaan tavan miten muistiinpanot talletetaan.

Tämän hetkisessä versiossa on vielä se ongelma, että voimme HTTP POST -pyynnöllä lisätä mitä tahansa kenttiä sisältäviä olioita. Parannellaan sovellusta siten, että kenttä content vaaditaan. Kentille important ja date asetetaan oletusarvot. Kaikki muut kentät hylätään:

const generateId = () => {
  const maxId = notes.length > 0 ? notes.map(n => n.id).sort().reverse()[0] : 1
  return maxId + 1
}

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

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

  const note = {
    content: body.content,
    important: body.important|| false,
    date: new Date(),
    id: generateId()
  }

  notes = notes.concat(note)

  response.json(note)
})

Tunnisteena toimivan id-kentän arvon generointilogiikka on eriytetty funktioon generateId.

Jos kenttä content puuttuu, vastataan statuskoodilla 400 bad request:

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

Huomaa, että returnin kutsuminen on tärkeää, jos sitä ei tapahdu, jatkaa koodi suoritusta metodin loppuun asti ja virheellinen muistiinpano tallettuu tietokantaan!

Jos content-kentällä on arvo, luodaan muistiinpano syötteen perusteella. Kuten edellisessä osassa mainitsimme, aikaleimoja ei kannata luoda selaimen koodissa, sillä käyttäjän koneen kellon aikaan ei voi luottaa. Aikaleiman eli kentän date arvon generointi tapahtuukin nyt palvelimen toimesta.

Jos kenttä important puuttuu, asetetaan sille oletusarvo false. Oletusarvo generoidaan nyt hieman erikoisella tavalla:

important: body.important || false,

jos sovelluksen vastaanottamassa muuttujaan body talletetussa datassa on kenttä important, tulee lausekkeelle sen arvo. Jos kenttää ei ole olemassa, tulee lausekkeen arvoksi oikeanpuoleinen osa eli important.

Jos ollaan tarkkoja, niin kentän body.important arvon ollessa false, tulee lausekkeen body.important || false arvoksi oikean puoleinen false

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan githubissa

Huomaa, että repositorion master-haarassa on myöhemmän vaiheen koodi, tämän hetken koodi on tagissa part3-1:

Jos kloonaat projektin itsellesi, suorita komento npm install ennen käynnistämistä eli komentoa npm start.

Tehtäviä

Tee nyt tehtävät 3.1-3.6

Huomioita HTTP pyyntötyyppien käytöstä

HTTP-standardi puhuu pyyntötyyppien yhteydessä kahdesta ominaisuudesta, safe ja idempotent.

HTTP-pyynnöistä GET:in tulisi olla safe:

In particular, the convention has been established that the GET and HEAD methods SHOULD NOT have the significance of taking an action other than retrieval. These methods ought to be considered “safe”.

Safety siis tarkoittaa, että pyynnön suorittaminen ei saa aiheutta palvelimelle sivuvaikutuksia eli esim. muuttaa palvelimen tietokannan tilaa, pyynnön tulee ainoastaan palauttaa palvelimella olevaa dataa.

Mikään ei automaattisesti takaa, että GET-pyynnöt olisivat luonteeltaan safe, kyseessä onkin HTTP-standardin suositus palvelimien toteuttajille. RESTful-periaatetta noudattaessa GET-pyyntöjä käytetäänkin aina siten, että ne ovat safe.

HTTP-standardi määrittelee myös pyyntötyypin HEAD, jonka tulee olla safe. Käytännössä HEAD:in tulee toimia kuten GET, mutta se ei palauta vastauksenaan muuta kuin statuskoodin ja headerit, viestin bodyä HEAD ei palauta ollenkaan.

HTTP-pyynnöistä muiden paitsi POST:in tulisi olla idempotentteja:

Methods can also have the property of “idempotence” in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request. The methods GET, HEAD, PUT and DELETE share this property

Eli jos pyynnöllä on sivuvaikutuksia, lopputulos on sama suoritetaanko pyyntö yhden tai useamman kerran.

Esim. jos tehdään HTTP PUT pyyntö osoitteeseen /notes/10 ja pyynnön mukana on { content: "ei sivuvaikutuksia", important: true }, on lopputulos sama riippumatta siitä kuinka monta kertaa pyyntö suoritetaan.

Kuten metodin GET safety myös idempotence on HTTP-standardin suositus palvelimien toteuttajille. RESTful-periaatetta noudattaessa GET, HEAD, PUT ja DELETE-pyyntöjä käytetäänkin aina siten, että ne ovat idempotentteja.

HTTP pyyntötyypeistä POST on ainoa joka ei ole safe eikä idempotent. Jos tehdään 5 kertaa HTTP POST -pyyntö osoitteeseen /notes siten että pyynnön mukana on { content: "monta samaa", important: true }, tulee palvelimelle 5 saman sisältöistä muistiinpanoa.

Middlewaret

Äsken käyttöönottamamme body-parser on terminologiassa niin sanottu middleware.

Middlewaret ovat funktioita, joiden avulla voidaan käsitellä request- ja response-olioita.

Esim. body-parser ottaa pyynnön mukana tulevan raakadatan request-oliosta, parsii sen Javascript-olioksi ja sijoittaa olion request:in kenttään body

Middlewareja voi olla käytössä useita jolloin ne suoritetaan peräkkäin siinä järjestyksessä kun ne on otettu koodissa käyttöön.

Toteutetaan itse yksinkertainen middleware, joka tulostaa konsoliin palvelimelle tulevien pyyntöjen perustietoja.

Middleware on funktio, joka saa kolme parametria:

const logger = (request, response, next) => {
  console.log('Method:',request.method)
  console.log('Path:  ', request.path)
  console.log('Body:  ', request.body)
  console.log('---')
  next()
}

Middleware kutsuu lopussa parametrina olevaa funktiota next, jolla se siirtää kontrollin seuraavalle middlewarelle.

Middleware otetaan käyttöön seuraavasti:

app.use(logger)

Middlewaret suoritetaan siinä järjestyksessä, jossa ne on otettu käyttään sovellusolion metodilla use. Middlewaret tulee myös määritellä ennen routeja jos ne halutaan suorittaa ennen niitä. On myös eräitä tapauksia, joissa middleware tulee määritellä vasta routejen jälkeen, käytännössä tällöin on kyse middlewareista, joita suoritetaan vain, jos mikään route ei käsittele HTTP-pyyntöä.

Lisätään routejen jälkeen seuraava middleware, jonka ansiosta saadaan routejen käsittelemättömistä virhetilanteista JSON-muotoinen virheilmoitus:

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

app.use(error)

Tehtäviä

Tee nyt tehtävät 3.7 ja 3.8

Yhteys frontendiin

Palataan yritykseemme käyttää nyt tehtyä backendiä osassa 2 tehdyllä React-frontendillä. Aiempi yritys lopahti seuraavaan virheilmoitukseen

Frontendin tekemä GET-pyyntö osoitteeseen http://localhost:3001/notes ei jostain syystä toimi. Mistä on kyse? Backend toimii kuitenkin selaimesta ja postmanista käytettäessä ilman ongelmaa.

Same origin policy ja CORS

Kyse on asiasta nimeltään CORS eli Cross-origin resource sharing. Wikipedian sanoin

Cross-origin resource sharing (CORS) is a mechanism that allows restricted resources (e.g. fonts) on a web page to be requested from another domain outside the domain from which the first resource was served. A web page may freely embed cross-origin images, stylesheets, scripts, iframes, and videos. Certain “cross-domain” requests, notably Ajax requests, are forbidden by default by the same-origin security policy.

Lyhyesti sanottuna meidän kontekstissa kyse on seuraavasta: websovelluksen selaimessa suoritettava Javascript-koodi saa oletusarvoisesti kommunikoida vain samassa originissa olevan palvelimen kanssa. Koska palvelin on localhostin portissa 3001 ja frontend localhostin portissa 3000, niiden origin ei ole sama.

Korostetaan vielä, että same origin policy ja CORS eivät ole mitenkään React- tai Node-spesifisiä asioita, vaan yleismaailmallisia periaatteita Web-sovellusten toiminnasta.

Voimme sallia muista origineista tulevat pyynnöt käyttämällä Noden cors-middlewarea.

Asennetaan cors komennolla

npm install cors --save

Otetaan middleware käyttöön ja sallitaan kaikki origineista tulevat pyynnöt:

const cors = require('cors')

app.use(cors())

Nyt frontend toimii! Tosin muistiinpanojen tärkeäksi muuttavaa toiminnallisuutta backendissa ei vielä ole.

CORS:ista voi lukea tarkemmin esim. Mozillan sivuilta.

Sovellus internettiin

Kun koko “stäkki” on saatu vihdoin kuntoon, siirretään sovellus internettiin. Viime aikoina on tullut uusia mielenkiintoisa sovellusten hostausmahdollisuuksia, esim. Zeit. Käytetään seuraavassa vanhaa kunnon Herokua.

Lisätään projektin juureen tiedosto Procfile, joka kertoo herokulle, miten sovellus käynnistetään

web: node index.js

Muutetaan tiedoston index.js lopussa olevaa sovelluksen käyttämän portin määrittelyä seuraavasti:

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

Nyt käyttöön tulee ympäristömuuttujassa PORT määritelty portti tai 3001 jos ympäristömuuttuja PORT ei ole määritelty. Heroku konfiguroi sovelluksen portin ympäristömuuttujan avulla.

Tehdään projektihakemistosta git-repositorio, lisätään .gitignore jolla seuraava sisältö

node_modules

Luodaan heroku-sovellus komennolla heroku create ja deployataan sovellus komennoilla git add -A, git commit -m "Initiate app." ja git push heroku master.

Jos kaikki meni hyvin, sovellus toimii:

Jos ei, vikaa voi selvittää herokun lokeja lukemalla, eli komennolla heroku logs.

Esim. tätä materiaalia tehdessä törmättiin ongelmaan joka aiheutti seuraavan tulostuksen lokeihin

Syynä ongelmalle oli se, että middlewarea cors asennettaessa oli unohtunut antaa optio –save, joka tallentaa tiedon riippuvuudesta tiedostoon package.json. Koska näin kävi, ei Heroku ollut asentanut corsia sovelluksen käyttöön.

HUOM ainakin alussa on järkevää tarkkailla herokussa olevan sovelluksen lokeja koko ajan. Parhaiten tämä onnistuu antamalla komento heroku logs -t, jolloin logit tulevat konsoliin sitä mukaan kun palvelimella tapahtuu jotain.

Myös frontend toimii herokussa olevan backendin avulla. Voit varmistaa asian muuttamalla frontendiin määritellyn backendin osoitteen viittaamaan localhost:3001:n sijaan herokussa olevaan backendiin.

Seuraavaksi herää kysymys miten saamme myös frontendin internettiin? Vaihtoehtoja on useita.

Frontendin tuotantoversio

Olemme toistaiseksi suorittaneet React-koodia sovelluskehitysmoodissa, missä sovellus on konfiguroitu antamaan havainnollisia virheilmoituksia, päivittämään koodiin tehdyt muutokset automaattisesti selaimeen ym.

Kun sovellus viedään tuotantoon, täytyy siitä tehdä production build eli tuotantoa varten optimoitu versio.

create-react-app:in avulla tehdyistä sovelluksista saadaan muodostettua tuotantoversio komennolla npm run build.

Suoritetaan nyt komento frontendin projektin juuressa.

Komennon seurauksena syntyy hakemiston build (joka sisältää jo sovelluksen ainoan html-tiedoston index.html) sisään hakemisto static, minkä alle generoituu sovelluksen Javascript-koodin minifioitu versio. Vaikka sovelluksen koodi on kirjoitettu useaan tiedostoon, generoituu kaikki Javascript yhteen tiedostoon, samaan tiedostoon tulee itseasiassa myös kaikkien sovelluksen koodin tarvitsemien riippuvuuksien koodi.

Minifioitu koodi ei ole miellyttävää luettavaa. Koodin alku näyttää seuraavalta:

!function(e){function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}var n={};t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:r})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="/",t(t.s=12)}([function(e,t,n){"use strict";function r(e){return"[object Array]"===E.call(e)}function o(e){return"[object ArrayBuffer]"===E.call(e)}function a(e){return"undefined"!==typeof FormData&&e instanceof FormData}function i(e){return"undefined"!==typeof ArrayBuffer&&ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer&&e.buffer instanceof ArrayBuffer}function u(e){return"string"===typeof e}function l(e){return"number"===typeof e}function s(e){return"undefined"===typeof e}function c(e){return null!==e&&"object"===typeof

Staattisten tiedostojen tarjoaminen backendistä

Eräs mahdollisuus frontendin tuotantoon viemiseen on kopioida tuotantokoodi, eli hakemisto build backendin repositorion juureen ja määritellä backend näyttämään pääsivunaan frontendin pääsivu, eli tiedosto build/index.html.

Aloitetaan kopioimalla frontendin tuotantokoodi backendin alle, projektin juureen. Omalla koneellani kopiointi tapahtuu frontendin hakemistosta käsin komennolla

cp -r build ../../osa3/notebackend

Backendin sisältävän hakemiston tulee nyt näyttää seuraavalta:

Jotta saamme expressin näyttämään staattista sisältöä eli sivun index.html ja sen lataaman Javascriptin ym. tarvitsemme expressiin sisäänrakennettua middlewarea static.

Kun lisäämme muiden middlewarejen määrittelyn yhteyteen seuraavan

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

tarkastaa express GET-tyyppisten HTTP-pyyntöjen yhteydessä ensin löytyykö pyynnön polkua vastaavan nimistä tiedostoa hakemistosta build. Jos löytyy, palauttaa express tiedoston.

Nyt HTTP GET -pyyntö osoitteeseen www.palvelimenosoite.com/index.html tai www.palvelimenosoite.com näyttää Reactilla tehdyn frontendin. GET-pyynnön esim. osoitteeseen www.palvelimenosoite.com/notes hoitaa backendin koodi.

Koska tässä tapauksessa sekä frontend että backend toimivat samassa osoitteessa, voidaan React-sovelluksessa tapahtuva backendin baseUrl määritellä suhteellisena URL:ina, eli ilman palvelinta yksilöivää osaa:

import axios from 'axios'
const baseUrl = '/notes'

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

// ...

Muutoksen jälkeen on luotava uusi production build ja kopioitava se backendin repositorion juureen.

Sovellusta voidaan käyttää nyt backendin osoitteesta http://localhost:3001:

Sovelluksemme toiminta vastaa nyt täysin osan 0 luvussa Single page app läpikäydyn esimerkkisovelluksen toimintaa.

Kun mennään selaimella osoitteeseen http://localhost:3001 palauttaa palvelin hakemistossa build olevan tiedoston index.html, jonka sisältö hieman tiivistettynä on seuraava:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>React App</title>
    <link href="/static/css/main.d2f2b65b.css" rel="stylesheet">
  </head>
  <body>
    <div id="root"></div>
    <script type="text/javascript" src="/static/js/main.c18b620c.js"></script>
  </body>
</html>

Sivu sisältää ohjeen ladata sovelluksen tyylit määrittelevän CSS-tiedoston, sekä script-tagin, jonka ansiosta selain lataa sovelluksen Javascript-koodin, eli varsinaisen React-sovelluksen.

React-koodi hakee palvelimelta muistiinpanot osoitteesta http://localhost:3001/api/notes ja renderöi ne ruudulle. Selaimen ja palvelimen kommunikaatio selviää tuttuun tapaan konsolin välilehdeltä Network:

Kun sovelluksen “internettiin vietävä” versio todetaan toimivan paikalliseksi, commitoidaan frontendin tuotantoversio backendin repositorioon ja pushataan koodi uudelleen herokuun.

Sovellus toimii moitteettomasti lukuunottamatta vielä backendiin toteuttamatonta muistiinpanon tärkeyden muuttamista:

HUOM ennen pushaamista on tietysti muistettava lisätä hakemisto build repositorioon!

Sovelluksemme tallettama tieto ei ole ikuisesti pysyvää, sillä sovellus tallettaa muistiinpanot muuttujaan. Jos sovellus kaatuu tai se uudelleenkäynnistetään, kaikki tiedot katoavat.

Tarvitsemme sovelluksellemme tietokannan. Ennen tietokannan käyttöönottoa katsotaan kuitenkin vielä muutamaa asiaa.

Frontendin deployauksen suoraviivaistus

Jotta uuden frontendin version generointi onnistuisi jatkossa ilman turhia manuaalisia askelia, tehdään frontendin repositorion juureen yksinkertainen shell-scripti, joka suorittaa uuden tuotantoversion buildaamisen eli komennon npm run build ja sen siirron backendin alle. Annetaan skriptille nimeksi deploy.sh. Sisältö on seuraava

#!/bin/sh
npm run build
cp -r build ../../osa3/notebackend/

Skripille pitää antaa vielä suoritusoikeudet:

chmod u+x deploy.sh

Skripti voidaan suorittaa frontendin juuresta komennolla ./deploy.sh

Backendin urlit

Backendin tarjoama muistiinpanojen käsittelyn rajapinta on nyt suoraan sovelluksen URL:in https://fullstack-notes.herokuapp.com alla. Eli https://fullstack-notes.herokuapp.com/notes on kaikkien mustiinpanojen lista ym. Koska backendin roolina on tarjota frontendille koneluettava rajapinta, eli API, olisi ehkä parempi erottaa API:n tarjoama osoitteisto selkeämmin, esim. aloittamalla kaikki sanalla api.

Tehdään muutos ensin muuttamalla käsin kaikki backendin routet:

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

Frontendin koodiin riittää seuraava muutos

import axios from 'axios'
const baseUrl = '/api/notes'

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

// ...

Muutosten jälkeen esim. kaikki muistiinpanot tarjoavan API-endpointin osoite on https://fullstack-notes.herokuapp.com/api/notes

Frontend on edelleen sovelluksen juuressa eli osoitteessa https://fullstack-notes.herokuapp.com/.

Sivuhuomautus: API:en versiointi

Joskus API:n urleissa ilmaistaan myös API:n versio. Eri versioita saatetaan tarvita, jos aikojen kuluessa API:in tehdään laajennuksia, jotka ilman versiointia hajoittaisivat olemassaolevia osia ohjelmista. Versioinnin avulla voidaan tuoda vanhojen rinnalle uusia, hieman eri tavalla toimivia versioita API:sta.

API:n version ilmaiseminen URL:issa ei kuitenkaan ole välttämättä, ainakaan kaikkien mielestä järkevää vaikka tapaa paljon käytetäänkin. Oikeasta tavasta API:n versiointiin kiistellään ympäri internettiä.

Proxy

Frontendiin tehtyjen muutosten seurauksena on nyt se, että kun suoritamme frontendiä sovelluskehitysmoodissa, eli käynnistämällä sen komennolla npm start, yhteys backendiin ei toimi:

Syynä tälle on se, että backendin osoite muutettiin suhteellisesti määritellyksi:

const baseUrl = '/api/notes'

Koska frontend toimii osoitteessa localhost:3000, menevät backendiin tehtävät pyynnöt väärään osoitteeseen localhost:3000/api/notes. Backend toimii kuitenkin osoitteessa localhost:3001

create-react-app:illa luoduissa projekteissa ongelma on helppo ratkaista. Riittää, että frontendin repositorion tiedostoon package.json lisätään seuraava määritelmä:

{
  "dependencies": {
    // ...
  },
  "scripts": {
    // ...
  },
  "proxy": "http://localhost:3001"
}

Uudelleenkäynnistyksen jälkeen Reactin sovelluskehitysympäristö toimii proxynä ja jos React-koodi tekee HTTP-pyynnön palvelimen http://localhost:3000 johonkin osoitteeseen joka ei ole React-sovelluksen vastuulla (eli kyse ei ole esim. sovelluksen Javascript-koodin tai CSS:n lataamisesta), lähetetään pyyntö edelleen osoitteessa http://localhost:3001 olevalle palvelimelle.

Nyt myös frontend on kunnossa, se toimii sekä sovelluskehitysmoodissa että tuotannossa yhdessä palvelimen kanssa.

Eräs negatiivinen puoli käyttämässämme lähestymistavassa on se, että sovelluksen uuden version tuotantoon vieminen edellyttää frontendin koodin tuotantoversion generoinnista ja sen backendin repositorioin kopioimisesta huolehtivan skriptin delpoy.sh suorittamisen. Tämä taas hankaloittaa automatisoidun deployment pipelinen toteuttamista. Deployment pipelinellä tarkoitetaan automatisoitua ja hallittua tapaa viedä koodi sovelluskehittäjän koneelta erilaisten testien ja laadunhallinnallisten vaiheiden kautta tuotantoympäristöön.

Tähänkin on useita erilaisia ratkaisuja (esim. sekä frontendin että backendin sijoittaminen samaan repositorioon), emme kuitenkaan nyt mene niihin.

Myös frontendin koodin deployaaminen omana sovelluksenaan voi joissain tilanteissa olla järkevää. create-react-app:in avulla luotujen sovellusten osalta se on suoraviivaista.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan githubissa, tagissa part3-2.

Tehtäviä

Tee nyt tehtävät 3.9-3.11

Node-sovellusten debuggaaminen

Node-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 kehityneempää menetelmää, mutta en ole ollenkaan samaa mieltä. Jopa maailman aivan eliittiin kuuluvat open source -kehittäjät käyttävät tätä mentelmää.

Visual Studio Code

Visual Studio Coden debuggeri voi olla hyödyksi joissain tapauksissa. 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 request.params 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 juurikaan käytä Visual Studio Code 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 kirjoittamalla chromen osoiteriville

chrome://inspect

Avautuvasta näkymästä valitaan debugattava sovellus:

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

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ä vaan, 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.

Mongo

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 (esim mlab ja MongoDbCloud) ja seuraava ohje olettaa, että käytössä on jo vuosien kokemuksella luotettavaksi havaittu mlab.

Mlab-kanta on helppo ottaa käyttöön suoraan Herokun kautta, vaikka tämä on maksutonta, edellyttää se luottokorttitietojen antamista Herokulle.

Määrittelemmekin seuraavassa kannan suoraan mlab:iin, jolloin luottokorttitietoja ei tarvita.

Aloita luomalla mlabiin käyttäjätili, saatuasi mlabilta verifiointimailin ja kirjauduttuasi mailin linkin kautta sisään, voit luoda tietokannan:

Määrittele esim. Amazon web services, tyypiksi ilmainen sandbox ja sijoituspaikaksi Irlanti ja anna kannalle sopiva nimi.

Kun kanta on hetken kuluttua valmis, mene tietokannan hallintanäkymään

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

Osoite näyttää seuraavalta:

mongodb://<dbuser>:<dbpassword>@ds211088.mlab.com:11088/fullstack-notes

Tarvitsemme kannan käyttöä varten dbuserin eli käyttäjätunnuksen. Käyttäjätunnuksen luominen tapahtuu tietokannan hallintanäkymästä

Jos luotiin käyttäjätunnus fullstack jonka salasana on sekred, on tietokannan osoite seuraava:

mongodb://fullstack:sekred@ds211088.mlab.com:11088/fullstack-notes

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')

// korvaa url oman tietokantasi urlilla. ethän laita salasanaa Gothubiin!
const url = 'mongodb://fullstack:sekred@ds211088.mlab.com:11088/fullstack-notes'

mongoose.connect(url)

const Note = mongoose.model('Note', {
  content: String,
  date: Date,
  important: Boolean
})

const note = new Note({
  content: 'HTML on helppoa',
  date: new Date(),
  important: true
})

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

Kun koodi suoritetaan komennolla node mongo.js lisää mongoose tietokantaaan uuden dokumentin.

Mlab:in hallintanäkymä näyttää lisäämämme datan:

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

Koodi sisältää muutamia mielenkiintoisia asioita. Aluksi avataan yhteys ja määritellään, että mongoose käyttää promiseja, eikä oldschool-takaisinkutsufunktioita:

const mongoose = require('mongoose')

const url = 'mongodb://fullstack:fullstack@ds211088.mlab.com:11088/fullstack-notes'

mongoose.connect(url)

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

Kannattaa muistaa, että tietokannan salasanaa ei kannata laittaa missään vaiheessa Githubiin!

Skeema

Yhteyden avaamisen jälkeen määritellään mustiinpanoa vastaava model:

const Note = mongoose.model('Note', {
  content: String,
  date: Date,
  important: Boolean
})

Modelin parametrina määritellään muistiinpanon skeema, joka kertoo mongooselle, miten muistiinpano-oliot tulee tallettaa tietokantaan.

Ensimmäisenä parametrina oleva 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 modelin määrittelyssä yksikkömuodossa (esim. Note).

Mongoosen dokumentaatiossa skeema ja sitä vastaava model määritellään kumpikin erikseen:

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

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

Koska meillä ei ole skeema-oliolle muuta käyttöä kuin modelin parametrina, käytämme hyväksemme sitä, että skeema voidaan määritellä modeleille suoraan antamalla toisena parametrina skeeman määrittelevä olio.

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: 'Selain pystyy suorittamaan vain javascriptiä',
  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.

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 => {
    // ...
  })

Tehtäviä

Tee nyt tehtävä 3.12

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')

const url = 'mongodb://fullstack:sekred@ds211088.mlab.com:11088/fullstack-notes'

mongoose.connect(url)

const Note = mongoose.model('Note', {
  content: String,
  date: Date,
  important: Boolean
})

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. Tehdään pieni apufunktio, jonka avulla yksittäinen muistiinpano saadaan muutettua mongon sisäisestä esitysmuodosta haluamaamme muotoon:

const formatNote = (note) => {
  return {
    content: note.content,
    date: note.date,
    important: note.important,
    id: note._id
  }
}

ja palautetaan HTTP-pyynnön vastauksena funktion avulla muotoiltuja oliota:

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

Nyt siis muuttujassa notes on taulukollinen mongon palauttamia olioita. Kun suoritamme operaation notes.map(formatNote) seurauksena on uusi taulukko, missä on jokaista alkuperäisen taulukon alkiota vastaava funktion formatNote avulla muodostettu alkio.

Jos kannasta haettavilla olioilla olisi suuri määrä kenttiä, apufunktio formatNote kannattaisi muotoilla hieman geneerisemmässä muodossa, esim:

const formatNote = (note) => {
  const formattedNote = { ...note._doc, id: note._id }
  delete formattedNote._id
  delete formattedNote.__v

  return formattedNote
}

Ensimmäinen rivi luo uuden olion, mihin kopioituu kaikki vanhan olion kentät. Uuteen olioon lisätään myös kenttä id:

const formattedNote = { ...note._doc, id: note._id }

Ennen olion palauttamista turhat kentät poistetaan.

Jos ohjelma käyttäisi muunkin tyyppisiä olioita kuin muistiinpanoja sopisi sama funktio niidenkin muotoiluun. Jatkon kannalta on kuitenkin parempi, että pidämme alkuperäisen version funktiosta.

On myös mahdollista estää mongoosea palauttamasta tiettyjen kenttien arvoa, tai pyytää sitä palauttamaan vain tietyt kentät. Saamme estettyä parametrin __v:n lisäämällä find-metodiin toiseksi parametriksi {__v: 0} seuraavasti:

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

Kyselyjen palauttamien kenttien määrittely tapahtuu Mongon syntaksin mukaan.

Tietokantamäärittelyjen eriyttäminen omaksi 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 = 'mongodb://fullstack:sekred@ds211088.mlab.com:11088/fullstack-notes'

mongoose.connect(url)

const Note = mongoose.model('Note', {
  content: String,
  date: Date,
  important: Boolean
})

module.exports = Note

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 määritellyn 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.

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(formatNote(savedNote))
    })
})

Muistiinpano-oliot siis luodaan Note-konstruktorifunktiolla. Pyyntöön vastataan save-operaation takaisinkutsufunktion sisällä. Näin varmistutaan, että operaatio 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ä funktiolla formatNote formatoitu muoto:

response.json(formatNote(savedNote))

Yksittäisen muistiinpanon tarkastelu muuttuu muotoon

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

Frontendin ja backendin yhteistominnallisuuden 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 mlabin hallintanäkymästä varsin hyödyllistä, usein myös suoraan tietokantaa käyttävät Node-apuohjelmat, kuten tiedostoon mongo.js kirjoittamamme koodi auttavat sovellukehityksen edetessä.

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan githubissa, tagissa part3-3.

Tehtäviä

Tee nyt tehtävät 3.13-3.15

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/5a3b80015b6ec6f1bdf68d 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(formatNote(note))
    })
    .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 sellasiella 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 '_doc' of null
    at formatNote (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/index.js:46:33)
    at Note.findById.then.note (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/index.js:65:21)

Nämä tilanteen on syytä erottaa toisistaan, ja itseasiassa jälkimmäinen poikkeus on oman koodimme /Users/mluukkai/opetus/\_fullstack/osa3-muisiinpanot/index.js:46 aiheuttama.

Muutetaan koodia seuraavasti:

app.get('/api/notes/:id', (request, response) => {
  Note
    .findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(formatNote(note))
      } 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 sessioita.

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:

loput 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) => {
  Note
    .findByIdAndRemove(request.params.id)
    .then(result => {
      response.status(204).end()
    })
    .catch(error => {
      response.status(400).send({ error: 'malformatted id' })
    })
})

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.

Muistiinpanon tärkeyden muuttamisen mahdollistava olemassaolevan muistiinpanon päivitys onnistuu helposti metodilla findOneAndUpdate. Tässä ja myöhemmin sivulla on findOneAndUpdate, mutta koodissa alla findByIdAndUpdate, joka vastaa findOneAndUpdate({ _id: id }, ...) kutsua.

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

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

  Note
    .findByIdAndUpdate(request.params.id, note, { new: true } )
    .then(updatedNote => {
      response.json(formatNote(updatedNote))
    })
    .catch(error => {
      console.log(error)
      response.status(400).send({ error: 'malformatted id' })
    })
})

Operaatio mahdollistaa myös muistiinpanon sisällön editoinnin. Päivämäärän muuttaminen ei ole mahdollista.

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

Pieni, mutta tärkeä detalji liittyen operaatioon findOneAndUpdate. 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 VS Code REST clientistä tehtyjen kokeilujen perusteella ja myös frontend toimii moitteettomasti tietokantaa käyttävän backendin kanssa.

Tehtäviä

Tee nyt tehtävät 3.16-3.18

Refaktorointia - promisejen ketjutus

Useat routejen tapahtumankäsittelijöistä muuttivat palautettavan datan oikeaan formaattiin kutsumalla metodia formatNote:

const formatNote = (note) => {
  return {
    id: note._id,
    content: note.content,
    date: note.date,
    important: note.important
  }
}

esim uuden muistiinpanon luomisessa metodia kutsutaan then:in parametrina palauttama olio parametrina:

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

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

})

Voisimme tehdä saman myös hieman tyylikkäämmin promiseja ketjuttamalla:

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

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

})

Eli ensimmäisen then:in takaisinkutsussa otamme mongoosen palauttaman olion savedNote ja formatoimme sen. Operaation tulos palautetaan returnilla. Kuten osassa 2 todettiin, promisen then-metodi palauttaa myös promisen. Eli kun palautamme formatNote(savedNote):n takaisinkutsufunktiosta, syntyy promise, jonka arvona on formatoitu muistiinpano. Pääsemme käsiksi arvoon rekisteröimällä then-kutsulla uuden tapahtumankäsittelijän.

Itseasiassa selviämme vieläkin tiiviimmällä koodilla:

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

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

})

koska formatNote on viite funktioon, on oleellisesti ottaen kyse samasta kuin kirjoittaisimme:

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

  note
    .save()
    .then(savedNote => {
      return {
        id: savedNote._id,
        content: savedNote.content,
        date: savedNote.date,
        important: savedNote.important
      }
    })
    .then(savedAndFormattedNote => {
      response.json(savedAndFormattedNote)
    })

})

Sovelluksen vieminen tuotantoon

Sovelluksen pitäisi toimia tuotannossa, eli herokussa sellaisenaan. Frontendin muutosten takia on tehtävä siitä uusi tuotantoversio ja kopioitava se backendiin.

Sovellusta voi käyttää sekä frontendin kautta https://fullstack-notes.herokuapp.com, ja myös API:n https://fullstack-notes.herokuapp.com/api/notes suora käyttö selaimella ja postmanilla onnistuu.

Sovelluksessamme on tällä hetkellä eräs ikävä piirre. Tietokannan osoite on kovakoodattu backendiin ja samaa tietokantaa käytetään sekä tuotannossa, että sovellusta kehitettäessä.

Tarvitsemme oman kannan sovelluskehitystä varten. Luodaan mlabiin toinen tietokanta ja sille käyttäjä.

Tietokannan osoitetta ei kannata kirjoittaa koodiin. Eräs hyvä tapa tietokannan osoitteen määrittelemiseen on ympäristömuuttujien käyttö.

Talletetaan kannan osoite ympäristömuuttujaan MONGODB_URI.

Ympäristömuuttujiin pääsee Node-sovelluksesta käsiksi seuraavasti:

const mongoose = require('mongoose')

const url = process.env.MONGODB_URI

// ...

module.exports = Note

Tee muutos koodiin ja deployaa uusi versio herokuun. Sovelluksen pitäisi toimia kun asetat ympäristömuuttujan arvo herokuun komennolla heroku config:set

heroku config:set MONGODB_URI=mongodb://fullstack:sekred@ds211088.mlab.com:11088/fullstack-notes

Sovelluksen pitäisi toimia muutosten jälkeen. Aina kaikki ei kuitenkaan mene suunnitelmien mukaan. Jos ongelmia ilmenee, heroku logs auttaa. Oma sovellukseni ei toiminut muutoksen jälkeen. Loki kertoi seuraavaa

eli tietokannan osoite olikin jostain syystä määrittelemätön. Komento heroku config paljasti että olin vahingossa määritellyt ympäristömuuttujan MONGO_URL kun koodi oletti sen olevan nimeltään MONGODB_URI.

Muutoksen jälkeen sovellus ei toimi paikallisesti, koska ympäristömuuttujalla MONGODB_URI ei ole mitään arvoa. Tapoja määritellä ympäristömuuttujalle arvo on monia, käytetään nyt dotenv-kirjastoa.

Asennetaan kirjasto komennolla

npm install dotenv --save

Sovelluksen juurihakemistoon tehdään sitten tiedosto nimeltään .env, minne tarvittavien ympäristömuuttujien arvot asetetaan. Määritellään tiedostoon sovelluskehitystä varten luodun tietokannan osoite:

MONGODB_URI=mongodb://fullstack:sekred@ds111078.mlab.com:11078/fullstact-notes-dev

Tiedosto .env tulee heti gitignorata sillä emme halua julkaista .env -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

Otetaan dotenv käyttöön seuraavasti:

const mongoose = require('mongoose')

if ( process.env.NODE_ENV !== 'production' ) {
  require('dotenv').config()
}

const url = process.env.MONGODB_URI

// ...

module.exports = Note

Nyt dotenvissä olevat ympäristömuuttujat otetaan käyttöön ainoastaan silloin kun sovellus ei ole production- eli tuotantomoodssa (kuten esim. Herokussa).

Uudelleenkäynnistyksen jälkeen sovellus toimii taas paikallisesti.

Node-sovellusten konfigurointiin on olemassa ympäristömuuttujien ja dotenvin lisäksi lukuisia vaihtoehtoja, mm. node-conf. Ympäristömuuttujien käyttö riittää meille nyt, joten emme rupea overengineeraamaan. Palaamme aiheeseen kenties myöhemmin.

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan githubissa, tagissa part3-4.

Tehtäviä

Tee nyt tehtävät 3.19 - 3.21

Lint

Ennen osan lopetusta katsomme vielä nopeasti paitsioon jäänyttä tärkeää työkalua lintiä. Wikipedian sanoin:

Generically, lint or a linter is any tool that detects and flags errors in programming languages, including stylistic errors. The term lint-like behavior is sometimes applied to the process of flagging suspicious language usage. Lint-like tools generally perform static analysis of source code.

Staattisesti tyypitetyissä, käännettävissä kielissä esim. Javassa ohjelmointiympäristöt, kuten NetBeans osaavat huomautella monista koodiin liittyvistä asioista, sellaisistakin, jotka eivät ole välttämättä käännösvirheitä. Erilaisten staattisen analyysin lisätyökalujen, kuten checkstylen avulla voidaan vielä laajentaa Javassa huomautettavien asioiden määrää koskemaan koodin tyylillisiä seikkoja, esim. sisentämistä.

Javascript-maailmassa tämän hetken johtava työkalu staattiseen analyysiin, eli “linttaukseen” on ESlint.

Asennetaan ESlint backendiin kehitysaikaiseksi riippuvuudeksi komennolla

npm install eslint --save-dev

Tämän jälkeen voidaan muodostaa alustava ESlint-konfiguraatio komennolla

node_modules/.bin/eslint --init

Vastaillaan kysymyksiin:

Konfiguraatiot tallentuvat tiedostoon .eslintrc.js:

module.exports = {
    "env": {
        "node": true
        "es6": true
    },
    "extends": "eslint:recommended",
    "rules": {
        "indent": [
            "error",
            4
        ],
        "linebreak-style": [
            "error",
            "unix"
        ],
        "quotes": [
            "error",
            "single"
        ],
        "semi": [
            "error",
            "never"
        ]
    }
};

Muutetaan heti konfiguraatioista sisennystä määrittelevä sääntö, siten että sisennystaso on 2 välilyöntiä

"indent": [
    "error",
    2
],

Esim tiedoston index.js tarkastus tapahtuu komennolla

node_modules/.bin/eslint index.js

Kannattaa ehkä tehdä linttaustakin varten npm-skripti:

{
  // ...
  "scripts": {
    "start": "node index.js",
    "watch": "nodemon index.js",
    "lint": "eslint ."
  },
  // ...
}

Nyt komennot npm run lint suorittaa tarkastukset koko projektille.

Myös hakemistossa build oleva frontendin tuotantoversio tulee näin tarkastettua. Sitä emme kuitenkaan halua, eli tehdään projektin juureen tiedosto .eslintignore ja sille seuraava sisältö

build

Näin koko hakemiston build sisältö jätetään huomioimatta linttauksessa.

Lintillä on jonkin verran huomautettavaa koodistamme:

Ei kuitenkaan korjata ongelmia vielä.

Parempi vaihtoehto kuin linttauksen suorittaminen komentoriviltä on konfiguroida editorille lint-plugin, joka suorittaa linttausta koko ajan. Näin pääset korjaamaan pienet virheet välittömästi. Tietoja esim. Visual Studion ESlint-pluginsta täällä.

VS Coden ESlint-plugin alleviivaa tyylisääntöjä rikkovat kohdat punaisella:

Näin ongelmat on helppo korjata koodiin heti.

ESlintille on määritelty suuri määrä sääntöjä, joita on helppo ottaa käyttöön muokkaamalla tiedostoa .eslintrc.js.

Otetaan käyttöön sääntö eqeqeq joka varoittaa, jos koodissa yhtäsuuruutta verrataan muuten kuin käyttämällä kolmea = -merkkiä. Sääntö lisätään konfiguraatiotiedostoon kentän rules alle.

"rules": {
  // ...
  "eqeqeq": "error"
},

Tehdään samalla muutama muukin muutos tarkastettaviin sääntöihin.

Estetään rivien lopussa olevat turhat välilyönnit, vaaditaan että aaltosulkeiden edessä/jälkeen on aina välilyönti ja vaaditaan myös konsistenttia välilyöntien käyttöä nuolifunktioiden parametrien suhteen:

"rules": {
  // ...
  "eqeqeq": "error",
  "no-trailing-spaces": "error",
  "object-curly-spacing": [
      "error", "always"
  ],
  "arrow-spacing": [
      "error", { "before": true, "after": true }
  ]
},

Oletusarvoinen konfiguraatiomme ottaa käyttöön joukon valmiiksi määriteltyjä sääntöjä eslint:recommended

"extends": "eslint:recommended",

Mukana on myös console.log-komennoista varoittava sääntö- Yksittäisen sääntö on helppo kytkeä pois päältä määrittelemällä sen “arvoksi” konfiguraatiossa 0. Tehdään toistaiseksi näin säännölle no-console.

"rules": {
  // ...
  "eqeqeq": "error",
  "no-trailing-spaces": "error",
  "object-curly-spacing": [
      "error", "always"
  ],
  "arrow-spacing": [
      "error", { "before": true, "after": true }
  ],
  "no-console": 0
},

HUOM kun teet muutoksia tiedostoon .eslintrc.js, kannattaa muutosten jälkeen suorittaa linttaus komentoriviltä ja varmistaa että konfiguraatio ei ole viallinen:

Jos konfiguraatiossa on jotain vikaa, voi editorin lint-plugin näyttää mitä sattuu.

Monissa yrityksissä on tapana määritellä yrityksen laajuiset koodausstandardit ja näiden käyttöä valvova ESlint-konfiguraatio. Pyörää ei kannata välttämättä keksiä uudelleen ja voi olla hyvä idea ottaa omaan projektiin joku käyttöön jossain muualla hyväksi havaittu konfiguraatio. Viime aikoina monissa projekteissa on omaksuttu AirBnB:n Javascript-tyyliohjeet ottamalla käyttöön firman määrittelemä ESLint-konfiguraatio.

Sovelluksen tämän hetkinen koodi on kokonaisuudessaan githubissa, tagissa part3-5.

Tehtäviä

Tee nyt viimeinen tehtävä 3.22