Siirry sisältöön

b

Sovellus internetiin

Yhdistetään seuraavaksi osassa 2 tekemämme frontend omaan backendiimme.

Edellisessä osassa backendinä toiminut JSON Server tarjosi muistiinpanojen listan osoitteessa http://localhost:3001/notes frontendin käyttöön. Backendimme urlien rakenne on hieman erilainen (muistiinpanot ovat osoitteessa http://localhost:3001/api/notes) eli muutetaan frontendin tiedostossa src/services/notes.js määriteltyä muuttujaa baseUrl seuraavasti:

import axios from 'axios'
const baseUrl = 'http://localhost:3001/api/notes'
const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

// ...

export default { getAll, create, update }

Frontendin tekemä GET-pyyntö osoitteeseen http://localhost:3001/api/notes ei jostain syystä toimi:

Konsolissa näkyy virhe 'Access ... blocked by CORS policy'

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: web-sovelluksen selaimessa suoritettava JavaScript-koodi saa oletusarvoisesti kommunikoida vain samassa originissa olevan palvelimen kanssa. Koska palvelin on localhostin portissa 3001 ja frontend localhostin portissa 5173, 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 backendiin cors komennolla

npm install cors

Otetaan middleware käyttöön toistaiseksi sellaisella konfiguraatiolla, joka sallii kaikista origineista tulevat pyynnöt kaikkiin backendin Express routeihin:

const cors = require('cors')

app.use(cors())

Nyt frontend toimii muuten, mutta muistiinpanojen tärkeäksi muuttaminen ei vielä onnistu, sillä toiminnallisuutta backendissa ei vielä ole.

CORS:ista voi lukea tarkemmin esim. Mozillan sivuilta.

Sovelluksen suoritusympäristö näyttää nyt seuraavalta:

Kuvassa localhost:3000 toimiva React dev server ja localhost:3001 toimiva node backend, jotka molemmat käyttävät lokaalilla levylä olevia fs-tiedostoja. Kuvassa myös selaimessa oleva react-sovellus. joka yhteydessä dev-serveriin (mistä se saa js-tiedoston) sekä node-backendiin jonka reitilt /app/notes sen saa json-muotoisen datan

Selaimessa toimiva frontendin koodi siis hakee datan osoitteessa localhost:3001 olevalta Express-palvelimelta.

Sovellus Internetiin

Kun koko "stäkki" on saatu vihdoin kuntoon, siirretään sovellus Internetiin.

Sovellusten hostaamiseen, eli "internettiin laittamiseen" on olemassa lukematon määrä erilaisia ratkaisuja. Helpoimpia näistä sovelluskehittäjän kannalta ovat ns PaaS (eli Platform as a Service) ‑palvelut, jotka huolehtivat sovelluskehittäjän puolesta tietokannan ja suoritusympäristön asentamisen.

Kymmenen vuoden ajan PaaS-ratkaisujen ykkönen on ollut Heroku. Elokuun 2022 lopussa Heroku ilmoitti että 27.11.2022 alkaen alustan maksuttomat palvelut loppuvat. Jos olet valmis maksamaan hiukan, on Heroku edelleen varteenotettava vaihtoehto.

Esittelemme seuraavassa kaksi hieman uudempaa palvelua Fly.io:n:n sekä Renderin. Molemmilla on tarjolla jonkin verran ilmaista laskenta-aikaa.

Hienoinen ikävä puoli Fly.io:ssa on se, että saatat joutua kertomaan palvelulle luottokorttitietosi vaikka et käytä palvelun maksullista osaa. Renderin käyttöönotto saattaa olla jossain tapauksissa helpompaa, sillä Render ei edellytä mitään asennuksia omalle koneelle.

Molempia ratkaisuja varten muutetaan tiedoston index.js lopussa olevaa sovelluksen käyttämän portin määrittelyä seuraavasti:

const PORT = process.env.PORT || 3001app.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. Sekä Fly.io että Render konfiguroivat sovelluksen portin ympäristömuuttujan avulla.

Fly.io

Jos päätät käyttää Fly.io:ta, aloita asentamalla Fly.io tämän ohjeen mukaan ja luomalla itsellesi tunnus palveluun.

Oletusarvoisesti saat käyttöösi kaksi ilmaista virtuaalikonetta, ja pystyt käynnistämään molempiin yhden sovelluksen.

Aloita kirjautumalla komentoriviltä palveluun komennolla

fly auth login

HUOM jos komento fly ei toimi, kokeile toimiiko sen pidempi muoto flyctl. Esim. Macissa toimivat komennon molemmat muodot.

Sovelluksen alustus tapahtuu seuraavasti. Mene sovelluksen juurihakemistoon ja anna komento

fly launch

Anna sovellukselle nimi, tai anna Fly.io:n generoida automaattinen nimi, valitse "region" eli alue minkä konesaliissa sovelluksesi toimii. Älä luo sovellukselle postgres- sekä Upstash Redis-tietokantaa. Lopuksi vielä kysytään "Would you like to deploy now?" eli haluatko että sovellus myös viedään tuotantoympäristöön. Valitse "Ei".

Fly.io luo hakemistoosi tiedoston fly.toml, joka sisältää sovelluksen tuotantoympäristön konfiguraation.

Jotta sovellus saadaan konfiguroitua oikein, tulee tiedostoon konfiguraatioon tehdä pieni lisäys osiin [env]

[build]

[env]
  PORT = "3000" # add this

[http_service]
  internal_port = 3000 # ensure that this is same as PORT
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 0
  processes = ["app"]

Osaan [env] lisätään tarvittaessa (jos se ei jo siellä valmiiksi ole) määritelmä ympäristömuuttujalle PORT, jotta sovellus osaa käynnistää itsensä samaan samaan porttiin, missä Fly olettaa (määrittely kohdassa [services]) sen olevan käynnissä.

Sovellus voidaan nyt käynnistää komennolla

fly deploy

Jos kaikki menee hyvin, sovellus käynnistyy ja saat sen avattua selaimeen komennolla

fly apps open

Tämän jälkeen aina kun teet muutoksia sovellukseen, saat vietyä uuden version tuotantoon komennolla

fly deploy

Erittäin tärkeä komento on myös fly logs jonka avulla voit seurata tuotantopalvelimen konsoliin tulostuvia logeja. Logit on viisainta pitää koko ajan näkyvillä.

Render

Ohje olettaa, että Renderiin on kirjauduttu GitHub-tunnuksen avulla.

Kirjautumisen jälkeen luodaan uusi "web service":

fullstack content

Yhdistetään sovelluksen repositorio Renderiin:

fullstack content

Yhdistäminen onnistuu ainakin jos repositorio on julkinen.

Määritellään sovelluksen tiedot. Jos sovellus ei ole repositorion juuressa, tulee oikea hakemisto määritellä kohtaan Root directory:

fullstack content

Tämän jälkeen sovellus käynnistyy Renderiin. Sovelluksen sivu kertoo sovelluksen tilan ja osoitteen, mihin sovellus on käynnistynyt:

fullstack content

Oletusarvoisella konfiguraatiolla sovelluksen pitäisi uudelleenkäynnistyä jokaisen GitHubiin pushatun commitin myötä automaattisesti uudelleen. Jostain syystä tämä ei kuitenkaan toimi aina.

Sovelluksen saa myös uudelleenkäynnistettyä manuaalisesti:

fullstack content

Dashboardin kautta on myös mahdollista seurata sovelluksen lokia:

fullstack content

Huomaamme nyt lokista, että sovellus on käynnistynyt porttiin 10000. Sovellus saa käynnistysportin ympäristömuuttujan PORT kautta, eli on äärimmäisen tärkeää että tiedoston index.js loppu on päivitetty muotoon:

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

Frontendin tuotantoversio

Olemme toistaiseksi suorittaneet React-koodia sovelluskehitysmoodissa, jossa 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.

Viten avulla tehdyistä sovelluksista saadaan tehtyä tuotantoversio komennolla npm run build.

Suoritetaan nyt komento frontendin projektin juuressa.

Komennon seurauksena syntyy hakemiston dist (joka sisältää jo sovelluksen ainoan html-tiedoston index.html) sisään hakemisto assets, jonka alle generoituu sovelluksen JavaScript-koodin minifioitu versio. Vaikka sovelluksen koodi on kirjoitettu useaan tiedostoon, generoituu kaikki JavaScript yhteen tiedostoon. Samaan tiedostoon tulee myös kaikkien sovelluksen koodin tarvitsemien riippuvuuksien koodi.

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

!function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];c<i.length;c++)f=i[c],o[f]&&s.push(o[f][0]),o[f]=0;for(n in l)Object.prototype.hasOwnProperty.call(l,n)&&(e[n]=l[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,i=1;i<t.length;i++){var l=t[i];0!==o[l]&&(n=!1)}n&&(u.splice(r--,1),e=f(f.s=t[0]))}return e}var n={},o={2:0},u=[];function f(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,f),t.l=!0,t.exports}f.m=e,f.c=n,f.d=function(e,r,t){f.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},f.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"})

Staattisten tiedostojen tarjoaminen backendistä

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

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

cp -r dist ../backend

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

ls-komento näyttää tiedostot index.js, Procfile, package.json, package-lock.json sekä hakemistot dist ja node_modules

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

tarkastaa Express GET-tyyppisten HTTP-pyyntöjen yhteydessä ensin löytyykö pyynnön polkua vastaavan nimistä tiedostoa hakemistosta dist. 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/api/notes hoitaa backendin koodi.

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

import axios from 'axios'
const baseUrl = '/api/notes'
const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

// ...

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

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

Mentäessä osoitteeseen localhost:3001 selain renderöi react-sovelluksen, joka listaa muistiinpanot. Jokaisen muistiinpanon yhteydessä on sen tärkeyden muuttava nappi 'make important' tai 'make not important', näkymässä on myös lomake uuden muistiinpanon luomiseen. Tärkeyttä ei lomakkeella tarvitse voida asettaa, ainoastaan muistiinpanon sisältö.

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 dist olevan tiedoston index.html, jonka sisältö on seuraava:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React</title>
    <script type="module" crossorigin src="/assets/index-5f6faa37.js"></script>
    <link rel="stylesheet" href="/assets/index-198af077.css">
  </head>
  <body>
    <div id="root"></div>
    
  </body>
</html>

Sivu sisältää ohjeen ladata sovelluksen tyylit määrittelevän CSS-tiedoston, sekä kaksi script-tagia, joiden 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:

Välilehti Network kertoo että on tehty pyyntö GET localhost:3001/api/notes

Tuotantoa varten tehty suoritusympäristö näyttää siis seuraavalta:

Selain hakee json-muotoisen datan localhost:3001/api/notes reitiltä ja suoritettavan react-sovelluksen js-koodin sekä index.html-tiedoston osoitteesta localhost:3001. Backend hakee tarvitsemansa js-tiedostot ja index.html:n paikalliselta levyltä.

Toisin kuin sovelluskehitysympäristössä, kaikki sovelluksen tarvitsema löytyy nyt node/express-palvelimelta osoitteesta localhost:3001. Kun osoitteeseen mennään, renderöi selain pääsivun index.html mikä taas aiheuttaa sen, että React-sovelluksen tuotantoversio haetaan palvelimelta ja selain alkaa suorittamaan sitä. Tämä taas saa aikaan sen, että ruudulla näytettävä JSON-muotoinen data haetaan osoitteesta localhost:3001/api/notes.

Koko sovellus Internetiin

Kun sovelluksen "Internetiin vietävä" tuotantoversio todetaan toimivaksi paikallisesti, commitoidaan frontendin tuotantoversio backendin repositorioon ja pushataan koodi GitHubiin. Jos käytät Renderiä, saattaa automaattinen uudelleenkäynnistys toimia. Jos näin ei ole, käynnistä uusi versio itse dashboardin kautta tekemällä "manual deployment".

Fly.io:n tapauksessa sovelluksen uusi versio käynnistyy komennolla

fly deploy

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

Selain renderöi sovelluksen frontendin (joka näyttää palvelimella olevan datan) mentäessä sovelluksen heroku-urlin juureen

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.

Tuotannossa oleva sovellus näyttää seuraavalta:

Selain hakee json-muotoisen datan nameoftheapp.herokuapp.com/api/notes osoitteesta ja suoritettavan react-sovelluksen js-koodin sekä index.html-tiedoston osoitteesta  nameoftheapp.herokuapp.com. Backend hakee tarvitsemansa js-tiedostot ja index.html:n herokun palvelimen levyltä.

Nyt siis node/express-backend sijaitsee Fly.io:n/Renderin palvelimella. Kun selaimella mennään sovelluksen "juuriosoitteeseen", alkaa selain suorittamaan React-koodia, joka taas hakee JSON-muotoisen datan Fly.io:sta/Renderistä.

Frontendin deployauksen suoraviivaistus

Jotta uuden frontendin version generointi onnistuisi jatkossa ilman turhia manuaalisia askelia, lisätään uusia skriptejä backendin package.json-tiedostoon.

Fly.io

Fly.io:n tapauksessa skriptit näyttävät seuraavalta:

{
  "scripts": {
    // ...
    "build:ui": "rm -rf dist && cd ../frontend/ && npm run build && cp -r dist ../backend",
    "deploy": "fly deploy",
    "deploy:full": "npm run build:ui && npm run deploy",    
    "logs:prod": "fly logs"
  }
}
Huomautus Windows-käyttäjille

Huomaa, että näistä build:ui:n käyttämät shell-komennot eivät toimi natiivisti Windowsilla, jonka powershell käyttää eri komentoja. Tällöin skripti olisi

"build:ui": "@powershell Remove-Item -Recurse -Force dist && cd ../frontend && npm run build && @powershell Copy-Item dist -Recurse ../backend",

Mikäli skripti ei toimi Windowsilla, tarkista, että terminaalisi sovelluskehitysympäristössäsi on Powershell eikä esimerkiksi Command Prompt. Jos olet asentanut Git Bash ‑terminaalin, tai muun Linuxia matkivan terminaalin tai ympäristön, saatat pystyä ajamaan Linuxin kaltaisia komentoja myös Windowsilla.

Skripteistä npm run build:ui kääntää ui:n tuotantoversioksi ja kopioi sen. npm run deploy julkaisee Fly.io:n.

npm run deploy:full yhdistää molemmat edellä mainitut komennot. Lisätään lisäksi oma skripti npm run logs:prod lokien lukemiseen, jolloin käytännössä kaikki toimii npm-skriptein.

Huomaa, että skriptissä build:ui olevat polut riippuvat repositorioiden sijainnista.

Render

Renderin tapauksessa skriptit näyttävät seuraavalta:

{
  "scripts": {
    // ...
    "build:ui": "rm -rf dist && cd ../frontend && npm run build && cp -r dist ../backend",
    "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push"
  }
}

Skripteistä npm run build:ui kääntää ui:n tuotantoversioksi ja kopioi sen.

npm run deploy:full sisältää edellisen lisäksi vaadittavat git-komennot versionhallinnan päivittämistä varten.

Huomaa, että skriptissä build:ui olevat polut riippuvat repositorioiden sijainnista.

Proxy

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

Network-tabi kertoo että pyyntöön localhost:3000/api/notes vastataan statuskoodilla 404

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

const baseUrl = '/api/notes'

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

Vitellä luoduissa projekteissa ongelma on helppo ratkaista. Riittää, että frontendin repositorion tiedostoon vite.config.json lisätään seuraava määritelmä:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {    proxy: {      '/api': {        target: 'http://localhost:3001',        changeOrigin: true,      },    }  },})

Uudelleenkäynnistyksen jälkeen Reactin sovelluskehitysympäristö toimii proxynä. Jos React-koodi tekee HTTP-pyynnön palvelimen http://localhost:5173 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.

Huomaa, että yllä olevassa esimerkkimääritelmässä vain polulla /api-alkuiset pyynnöt välitetään 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, että sovelluksen uuden version tuotantoon vieminen edellyttää erillisessä repositoriossa olevan frontendin koodin tuotantoversion generoimista. 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. Aiheeseen tutustutaan kurssin osassa 11.

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

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

Frontendin koodiin tehdyt muutokset ovat frontendin repositorion branchissa part3-1.