Poglavje 6 P3.1 Podatkovna baza in REST API

P3.2

6.1 Priprava podatkovnega modela za MongoDB z Mongoose

Kot prikazuje slika 6.1, se podatki pri MVC načrtovalskem vzorcu nahajajo v modelu.

Podatki se pri MVC načrtovalskem vzorcu nahajajo v modelu

Slika 6.1: Podatki se pri MVC načrtovalskem vzorcu nahajajo v modelu

V končni fazi bo krmilnik posredoval podatke pogledu, vendar podatki ne bodo shranjeni tam, ampak v modelu, podatkovni bazi. V našem primeru bomo uporabljali podatkovno bazo MongoDB, za katero je najprej treba pripraviti podatkovni model.

Pri vključitvi podatkovne baze v našo aplikacijo bomo sledili naslednjim korakom:

  1. povezava aplikacije s podatkovno bazo,
  2. opredelitev sheme in modelov,
  3. dodajanje testnih podatkov v podatkovno bazo in
  4. prenos rešitve v produkcijsko okolje.

V tem trenutku podatkovne baze še sploh nismo kreirali in nam je tudi ni treba. Če bi uporabljali kakšen drug sklad tehnologij, bi lahko zaradi tega prišlo do napake, pri uporabi MongoDB pa nam ni treba kreirati podatkovne baze pred povezavo nanjo, saj se ob prvi uporabi samodejno kreira.

MongoDB podatkovna baza in uporaba Mongoose z Express za modeliranje podatkov in obvladovanje povezave na podatkovno bazo

Slika 6.2: MongoDB podatkovna baza in uporaba Mongoose z Express za modeliranje podatkov in obvladovanje povezave na podatkovno bazo

Kot prikazuje slika 6.2 bomo v okviru tega poglavja uporabili Mongoose skupaj z Express in Node.js za modeliranje podatkov in dostop do podatkovne baze. V naslednjem koraku bomo kreirali podatkovno bazo in dodali testne podatke.

6.1.1 Povezovanje Express aplikacije in MongoDB z Mongoose

Express aplikacijo bi lahko z MongoDB podatkovno bazo povezali kar neposredno s pomočjo privzetega gonilnika, ki sicer deluje zadovoljivo, vendar je delo z njim kar zapleteno, zato bomo pri povezovanju Express aplikacije s podatkovno bazo MongoDB uporabljali gonilnik Mongoose, kot to prikazuje slika 6.3.

Uporaba Mongoose pri povezovanju Express aplikacije in MongoDB podatkovne baze

Slika 6.3: Uporaba Mongoose pri povezovanju Express aplikacije in MongoDB podatkovne baze

V razvojnem okolju je treba najprej namestiti podatkovno bazo MongoDB. Le-to si lahko namestite neposredno na računalnik, kjer je na uradni spletni strani na voljo dokumentacija za namestitev v vseh najbolj pogosto uporabljanih platformah, kot so Linux, macOS in Windows. V nadaljevanju pa bomo predstavili namestitev v okolju Docker (glej poglavje 6.1.1.1), ki jo bomo tudi uporabljali na predavanjih.

Opomba: Pri platformi Windows je treba namestiti tudi MongoDB Database Tools, če želimo uporabljati dodatne ukaze v ukazni vrstici (npr. mongoimport, mongodump itd.). Orodja kopiramo v mapo bin kjer imamo nameščen MongoDB. Priporočljivo je, da pot kjer imamo nameščeno MongoDB podatkovno bazo (npr. C:\Program Files\MongoDB\Server\5.0\bin) dodamo v sistemsko spremenljivko PATH in posledično dostopamo do MongoDB ukazov povsod preko ukazne vrstice.

6.1.1.1 Namestitev MongoDB podatkovne baze v Docker okolju

Najprej v datoteki docker-compose.yml poskrbimo za opredelitev nove storitve mongo-db, ki bo kreirala nov vsebnik web-dev-mongo-db na podlagi obstoječe slike mongo.

docker-compose.yml

version: "3.5"
services:
  web-dev-node-api:
    container_name: web-dev-app
    ports:
      - "3000:3000"
    image: web-dev-server
    build: ./
    networks:
      - web-dev-network
  web-dev-mongo-db:
    image: mongo
    container_name: web-dev-mongo-db
    ports:
      - "27017-27019:27017-27019"
    networks:
      - web-dev-network
networks:
  web-dev-network:
    name: web-dev-network
    driver: bridge

Sedaj z naslednjim ukazom poskrbimo, da se za novo storitev podatkovne baze MongoDB kreira ustrezen vsebnik, medtem ko vsebnika zaenkrat še ne poženemo.

$ docker-compose up web-dev-mongo-db --no-start

 ✔ Container web-dev-mongo-db  Created

Namig: Če bi želeli odstraniti vse vsebnike (tj. Node.js strežnik in MongoDB podatkovno bazo), lahko uporabimo ukaz docker-compose down. Koristna sta tudi ukaza docker-compose start, ki vsebnike požene in docker-compose stop, ki aktivne vsebnike ustavi. Pri spremembah izvorne kode aplikacije moramo ponovno zahtevati izgradnjo slike za vsebnik, zato je takrat smiselna uporaba ukaza docker-compose up --no-start --build.

MongoDB vsebnik web-dev-mongo-db, kjer je nameščena zadnja verzija MongoDB podatkovne baze, je kreiran in ga lahko z naslednjim ukazom poženemo.

$ docker start web-dev-mongo-db

web-dev-mongo-db

Namig: Ker se vsebnik požene v načinu izvajanja v ozadju (v konzolo se ne izpisuje trenutni status), lahko npr. v novem zavihku konzole z ukazom docker-compose logs -f zahtevamo prikaz trenutnih dnevniških zapisov, ki se sproti osvežujejo. To bo predvsem koristno za odkrivanje morebitnih napak pri nadaljnjem razvoju aplikacije.

Delovanje dostopa do MongoDB podatkovne baze lahko preizkusimo z zagonom odjemalca mongosh, ki je tudi nameščen v vsebniku web-dev-mongo-db s podatkovno bazo.

$ docker exec -it web-dev-mongo-db mongosh

Current Mongosh Log ID: 6513bb24d8a0b42f1c4706ef
Connecting to:          mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+1.10.6
Using MongoDB:          7.0.1
Using Mongosh:          1.10.6

test> 

Namig: Morebitna opozorila (npr. onemogočena kontrola dostopa, poročanje, uporaba XFS datotečnega sistema, manjkajoče začetne skripte idr.), ki se izpišejo, lahko za potrebe lokalnega razvoja zaenkrat ignoriramo.

6.1.1.2 Vključitev gonilnika Mongoose v aplikacijo

S spodnjim ukazom zahtevamo namestitev Mongoose gonilnika ter vključitev reference na knjižnico v datoteko package.json.

$ npm install --save mongoose

Opazimo lahko, da se sprememba doda tudi v datoteko package.json.

package.json

{
  "name": "demo",
  "description": "Demo application for Web Development course",
  "author": "Dejan Lavbič <dejan@lavbic.net>",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "~4.18.2",
    "hbs": "~4.2.0",
    "mongoose": "^7.5.3"
  }
}

6.1.1.3 Dodajanje Mongoose povezave v našo aplikacijo

Mongoose deluje tako, da ob zagonu vzpostavi množico več ponovno uporabnih povezav (privzeto \(5\)), ki jih deli med vsemi zahtevami. To je dobra praksa pri uporabi podatkovne baze, saj lahko operacija povezovanja traja dlje časa.

Omenjena funkcionalnost predstavlja začetke implementacije REST API vmesnikov, zato kreiramo novo mapo api in podmapo models, kot je prikazano spodaj.

  • api
    • models
      • db.js ← logika za povezovanje na MongoDB podatkovno bazo

Funkcionalnost povezovanja na podatkovno bazo bomo implementirali v modelu db.js, kjer najprej poskrbimo za odvisnost od knjižnice Mongoose.

api/models/db.js

const mongoose = require("mongoose");

Povezava na podatkovno bazo se izvede ob zagonu aplikacije, zato dodamo odvisnost v datoteko server.js.

server.js

const express = require("express");
const path = require("path");
/**
 * Database connection
 */
require("./api/models/db.js");

const hbsRouter = require("./hbs/routes/hbs");

Na podatkovno bazo, ki se nahaja na istem računalniku (kar velja za naše razvojno okolje z Dockerjem), se povežemo zelo enostavno tako, da dodamo naslednji vrstici v datoteko db.js.

api/models/db.js

const mongoose = require("mongoose");

const dbURI = "mongodb://127.0.0.1/Demo";
mongoose.connect(dbURI);

Pri povezovanju na MongoDB podatkovno bazo potrebujemo povezovalni niz122, ki je v naslednji obliki in smo ga že vključili v naš model, kjer lahko določene privzete podatke tudi izpustimo:

mongodb://uporabnisko_ime:geslo@streznik:vrata/podatkovna_baza

Za lažje obvladovanje in razhroščevanje povezovanja na podatkovno bazo bomo dodali spremljanje dogodkov pri povezovanju na podatkovno bazo, kar dodamo v db.js.

api/models/db.js

const mongoose = require("mongoose");

const dbURI = "mongodb://127.0.0.1/Demo";
mongoose.connect(dbURI);

mongoose.connection.on("connected", () =>
  console.log(`Mongoose connected to ${dbURI}.`)
);
mongoose.connection.on("error", (err) =>
  console.log(`Mongoose connection error: ${err}.`)
);
mongoose.connection.on("disconnected", () =>
  console.log("Mongoose disconnected")
);

Ravno tako pomembno pa je tudi obvladovanje dogodkov pri zapiranju povezav na podatkovno bazo, kjer je treba biti pozoren na več dogodkov, t.j. SIGUSR2, SIGINT in SIGTERM, kar dodamo v db.js

api/models/db.js

const gracefulShutdown = async (msg, callback) => {
  await mongoose.connection.close();
  console.log(`Mongoose disconnected through ${msg}.`);
  callback();
};
process.once("SIGUSR2", () => {
  gracefulShutdown("nodemon restart", () =>
    process.kill(process.pid, "SIGUSR2")
  );
});
process.on("SIGINT", () => {
  gracefulShutdown("app termination", () => process.exit(0));
});
process.on("SIGTERM", () => {
  gracefulShutdown("Cloud-based app shutdown", () => process.exit(0));
});

6.1.2 Zakaj je treba modelirati podatke?

Na predavanjih P1.1 smo v okviru poglavja 1.3.3 že na kratko predstavili podatkovno bazo MongoDB, kjer smo omenili, da gre za dokumentno usmerjeno podatkovno bazo in ne tradicionalno relacijsko podatkovno bazo. S tem pridobimo veliko svobodo pri delu s podatki, saj ne potrebujemo strogo opredeljene sheme. Kljub temu pa včasih to vseeno želimo ali celo potrebujemo. To velja tudi v primeru naše aplikacije, kjer bodo podatki na seznamu lokacij jasno strukturirani.

Vse podatke posamezne lokacije vedno potrebujemo in vsaka lokacija mora uporabljati konsistentno strukturo poimenovanja. Brez te zahteve bi v aplikaciji težko našli želene podatke in jih uporabili. Iz tega izhaja tudi motiv za postopen prenos podatkov najprej iz pogledov v krmilnike in nato v model (glej sliko 6.4).

Premik podatkov iz krmilnika v model pri MVC načrtovalskem vzorcu

Slika 6.4: Premik podatkov iz krmilnika v model pri MVC načrtovalskem vzorcu

Pri tem procesu smo namreč postopoma razmišljali o podatkih in modeliranju podatkov, ki se dejansko ukvarja z opisovanjem strukture podatkov, ki jo želimo uporabljati v okviru aplikacije. Strukturo podatkov bi lahko opisali ročno, lahko pa si tudi pomagamo s knjižnico Mongoose.

6.1.2.1 Kako bomo uporabili Mongoose?

Mongoose je vmesna raven programske opreme in skrbi predvsem za preslikavo objektov v dokumente in obratno123. Ena izmed ključnih prednosti je, da lahko podatkovni model obvladujemo kar neposredno iz aplikacije. Pred opredelitvijo podatkovnega modela pa omenimo nekaj ključnih konceptov MongoDB podatkovne baze:

  • v MongoDB se vsak zapis v podatkovni bazi imenuje dokument124,
  • skupek dokumentov v MongoDB imenujemo zbirka125 (v kontekstu relacijske podatkovne baze gre za analogen pojem tabele),
  • v Mongoose se definicija dokumenta imenuje shema126,
  • posamezna podatkovna entiteta je opredeljena v shemi s potjo127.

V zbirki dokumentov je lahko več dokumentov. Vsak dokument vsebuje podatke, kjer je njihova struktura opredeljena s shemo. Vsaka shema je sestavljena iz več poti in vsaka pot ima lahko opredeljenih več lastnosti.

Model je preprosto prevedena različica sheme in vsa interakcija s podatki pri uporabi Mongoose poteka z uporabo modela.

Če si pogledamo primer, ki smo ga že predstavili v enem izmed prejšnjih predavanj v poglavju 1.3.3, je potem tudi v nadaljevanju podan primer sheme.

MongoDB dokument

{
  "predpona": "gospod",
  "ime": "Janez",
  "priimek": "Kranjski",
  "vzdevek": "stari",
  _id: ObjectId("52279effc62ca8b0c1000007")
}

MongoDB shema

{
  predpona: String,
  ime: String,
  priimek: String,
  vzdevek: String
}

Pri MongoDB dokumentu lahko opazimo tudi lastnost _id, ki je nismo opredelili v shemi. Gre za enolični identifikator (npr. analogno primarnemu ključu v relacijski shemi) vsakega dokumenta, ki ga MongoDB samodejno ustvari, ko je dokument kreiran.

Pri opredelitvi sheme operiramo z enostavno strukturo 'ime poti': 'objekt lastnosti', kot je to prikazano v predhodnem primeru. Tip poti v shemi, ki ga moramo pri opredelitvi sheme vedno vključiti, je lahko v naslednjih vnaprej opredeljenih vrednostih:

  • String predstavlja poljuben niz, zapisan v UTF-8,
  • Number je enostavna predstavitev vseh števil, kjer long in double podatkovna tipa privzeto nista podprta, sta pa na voljo preko vtičnikov,
  • Date128 predstavlja datum in je po navadi v ISODate formatu,
  • Boolean predstavlja resnično ali neresnično trditev,
  • Buffer se uporablja za binarne podatke, kot so npr. slike,
  • Mixed predstavlja poljuben podatkovni tip,
  • Array predstavlja polje istih podatkovnih tipov ali polje ugnezdenih dokumentov,
  • ObjectId za opredelitev enoličnega identifikatorja, ki je različen od _id in se po navadi uporablja za referenciranje ostalih dokumentov.

6.1.3 Podatki iz registra kulturne dediščine

Pri implementaciji naše aplikacije bomo za seznam zanimiv lokacij, ki jih bomo prikazovali, uporabili podatke iz registra kulturne dediščine.

Izvorno CSV datoteko kulturne_dediscine.json, ki vsebuje omenjene podatke, bomo shranili v mapo data. V nadaljevanju je prikazan izsek iz datoteke, kjer nas v tej fazi zanimajo predvsem atributi podatkov.

data/kulturne-dediscine.csv

ESD,Ime,Sinonimi,Zvrst,Tip,Gesla,Opis,Datacija,Avtorji,LokacijaOp,Podrocja,Zavod,Varstvo,Obcina,Y,X,WGS X,WGS Y
29884,Šentrupert na Dolenjskem - Muzej na prostem,,stavbe,profana stavbna dediščina,"muzej na prostem, kozolec na psa, kozolec s plaščem, enojni kozolec, nizki kozolec, toplar, vzporedni kozolec",Muzej na prostem sestavljajo skupina 17 kozolcev različnih tipov in dve enostavni sušilni napravi (belokranjska ostrv in ribniški kozouček). Postavitev iz 2012 prikazuje genezo kozolca na Slovenskem in raznolikost kozolcev v Mirnski dolini.,"zadnja četrtina 18. stol., 19. stol., prva polovica 20. stol., prva četrtina 21. stol., 2012",,Muzej na prostem je urejen na južnem obrobju Šentruperta na Dolenjskem.,"etnologija, krajinska arhitektura, tehniška zgodovina",ZVKD Novo mesto,spomenik lokalnega pomena,ŠENTRUPERT,507458,92302,45.9748324693,15.0914351743
...

Omenjeno mapo data tudi vključimo v datoteko .dockerignore ter tako zahtevamo, da se pri strežbi aplikacije v okolju Docker ne vključi pri gradnji slike.

.dockerignore

.git*
dockerfile
docker-compose*
.dockerignore
.jshintrc
package-lock.json
node_modules
*.md
data

6.1.4 Opredelitev sheme z Mongoose

Shemo naše podatkovne baze bomo opredelili v mapi models in jo bomo tudi uvozili v db.js, da bo le-ta na voljo celotni aplikaciji.

Najprej si pripravimo novo datoteko locations.js, kjer bomo opredelili shemo.

api/models/locations.js

const mongoose = require("mongoose");

Dodamo tudi referenco na shemo locations.js v datoteki db.js.

api/models/db.js

process.on("SIGTERM", () => {
  gracefulShutdown("Cloud-based app shutdown", () => process.exit(0));
});
require("./locations");

6.1.4.1 Osnovna opredelitev sheme

Trenutno imamo testne podatke shranjene v datoteki kulturne-dediscine.csv, ki se nahaja v mapi data. Omenjeni podatki se še ne uporabljajo v naši aplikaciji, lahko pa na njihovi osnovi opredelimo shemo podatkov. Pri tem bomo uporabili pravila za preverjanje veljavnosti podatkov, ki nam zagotavljajo določeno stopnjo kakovosti shranjenih podatkov.

api/models/locations.js

const mongoose = require("mongoose");

const locationSchema = mongoose.Schema({
  id: {
    type: Number,
    required: [true, "Unique identifier is required!"],
  },
  name: { type: String, required: [true, "Name is required!"] },
  synonyms: {
    type: [String],
    validate: {
      validator: (v) => v == undefined || (Array.isArray(v) && v.length > 0),
      message: "If synonyms are provided, they must be an array of strings!",
    },
  },
  category: { type: String, required: [true, "Category is required!"] },
  type: { type: String, required: [true, "Type is required!"] },
  keywords: {
    type: [String],
    validate: {
      validator: (v) => Array.isArray(v) && v.length > 0,
      message: "At least one keyword is required!",
    },
  },
  description: {
    type: String,
    required: [true, "Description is required!"],
  },
  datation: { type: String, required: false },
  authors: { type: String, required: false },
  location: {
    type: String,
    required: [true, "Location description is required!"],
  },
  fields: {
    type: [String],
    validate: {
      validator: (v) => Array.isArray(v) && v.length > 0,
      message: "At least one field is required!",
    },
  },
  institution: { type: String, required: [true, "Institution is required!"] },
  heritage: { type: String, required: [true, "Heritage is required!"] },
  municipality: { type: String, required: [true, "Municipality is required!"] },
  coordinates: {
    type: [Number],
    validate: {
      validator: (v) => Array.isArray(v) && v.length == 2,
      message: "Coordinates must be an array of two numbers!",
    },
    index: "2dsphere",
  },
  rating: { type: Number, default: 0, min: 0, max: 5 },
});

V določenih primerih je smiselno opredeliti privzete vrednosti, kot je to prikazano pri podatku rating. V tem primeru je to naravna izbira, saj v trenutku, ko dodamo novo lokacijo, le-ta še nima komentarjev in je zato smiselno povprečno oceno nastaviti na \(0\).

V opredelitev sheme lahko vključimo tudi osnovno preverjanje ustreznosti podatkov, kot je to npr. uporabljeno pri podatku id, saj zahtevamo, da je ta podatek obvezen. Če bi poskušali v podatkovno bazo MongoDB shraniti dokument, ki ne bi vseboval tega podatka, bi pri tem dobili napako s sporočilom Unique identifier is required!. Preverjanje ustreznosti podatkov je uporabljeno tudi pri atributu rating, kjer zahtevamo, da je podano število v intervalu \([0, 5]\). Prav tako pa imamo pri drugih podatkih (npr. coordinates) opredeljene še kompleksnejša pravila, kjer zahtevamo, da je podatek v obliki polja, ki mora vsebovati natanko \(2\) vrednosti.

6.1.4.2 Uporaba geografskih podatkov

Izračun razdalje od trenutne lokacije do izbrani lokacij bo potekal v okviru MongoDB podatkovne baze, ki nam omogoča shranjevanje podatkov koordinat z zemljepisno širino129 in dolžino130 ter celo uporabo indeksov nad takšnimi podatki. Tako lahko uporabniki izvajajo performančno učinkovite poizvedbe nad geografskimi podatki, kar je v našem primeru, ko takšno aplikacijo gradimo, zelo koristno.

Geografski podatki so v MongoDB shranjeni skladno s specifikacijo GeoJSON. Pri vključitvi teh podatkov v MongoDB s pomočjo Mongoose je treba:

  • opredeliti pot kot polje podatkovnega tipa Number,
  • omenjeni poti dodati 2dsphere indeks.

Zelo pomembna je vključitev 2dsphere indeksa, saj nam omogoča računanje razdalj v realnem času kar pri poizvedovanju po podatkih.

6.1.4.3 Opredelitev bolj kompleksne sheme z odvisnimi dokumenti

Do sedaj so bili podatki dokaj preprosti in brez odvisnosti. Ko začnemo razmišljati o podrobnostih lokacije v okviru naše aplikacije, pa imamo v mislih bolj kompleksne strukture podatkov, kot so npr. komentarji, ki jih bomo hranili v obliki polja objektov.

V okviru relacijske podatkovne baze bi takšne podatke hranili v ločenih tabelah in bi jih kasneje združili na ravni poizvedbe, vendar dokumentne podatkovne baze ne delujejo na takšen način. Kot prikazuje slika 6.5, je pri načinu shranjevanja podatkov v dokumentno usmerjeno NoSQL podatkovno bazo kar nekaj razlik v primerjavi z relacijsko podatkovno bazo.

Razlike med načinom shranjevanja podatkov v relacijsko podatkovno bazo in dokumentno usmerjeno NoSQL podatkovno bazo

Slika 6.5: Razlike med načinom shranjevanja podatkov v relacijsko podatkovno bazo in dokumentno usmerjeno NoSQL podatkovno bazo

V okviru MongoDB je na voljo koncept odvisnih dokumentov131, ki nam omogočajo shranjevanje takšnih ponavljajočih podatkov. Odvisni dokumenti so zelo podobni dokumentom, tako da imajo svoj enolični identifikator _id in svojo shemo, vendar so del drugega dokumenta.

Zato bomo, v skladu z dobro prakso, komentarje modelirali, kot je prikazano v nadaljevanju.

api/models/location.js

const mongoose = require("mongoose");

const commentSchema = new mongoose.Schema({
  author: { type: String, required: [true, "Author is required!"] },
  rating: {
    type: Number,
    required: [true, "Rating is required!"],
    min: 0,
    max: 5,
  },
  comment: { type: String, required: [true, "Comment is required!"] },
  createdOn: { type: Date, default: Date.now },
});

const locationSchema = mongoose.Schema({
  ...,
  comments: {
    type: [commentSchema],
  },
});

6.1.4.4 Prevajanje sheme v model

Prevajanje sheme v model je s pomočjo Mongoose zelo preprosto, in sicer z naslednjim ukazom:

mongoose.model('Location', locationsSchema, 'Locations');

kjer velja:

  • mongoose je ime povezave,
  • Location je ime modela,
  • locationsSchema je shema, ki jo uporabimo in
  • Locations je (opcijsko) ime MongoDB zbirke.

Proces prevajanja sheme v model je podrobneje prikazan na sliki 6.6.

Proces preslikovanja sheme v model pri uporabi Mongoose

Slika 6.6: Proces preslikovanja sheme v model pri uporabi Mongoose

Zdaj moramo le še dodati ukaz za prevajanje sheme v model.

api/models/locations.js

const mongoose = require("mongoose");
const commentSchema = new mongoose.Schema({
  ...
});
const locationSchema = mongoose.Schema({
  ...
});
mongoose.model("Location", locationSchema, "Locations");

V nadaljevanju moramo našo prazno podatkovno bazo še napolniti s podatki.

6.1.5 Uporaba MongoDB ukazne lupine za dodajanje podatkov

Namig: Za uspešno izvedbo ukazov v nadaljevanju poskrbite, da je vaša podatkovna baza pognana, sicer jo poženite z ukazom docker start sp-mongo-db.

V lokalnem razvojnem okolju, kjer uporabljamo Docker (glej poglavje 6.1.1.1), lahko do podatkovne baze MongoDB dostopamo z ukazom mongosh oz. docker exec -it sp-mongo-db mongosh.

Namig: Ko do podatkovne baze dostopate iz lokalnega razvojnega okolja, morate za odjemalski dostop kreirati nov proces (tj. odpreti nov terminal), v katerem dostopate do obstoječega procesa, kjer se izvaja MongoDB podatkovna baza.

Ob uspešni povezavi nam sistem izpiše nekaj osnovnih podatkov o MongoDB podatkovni bazi.

$ docker exec -it web-dev-mongo-db mongosh
Current Mongosh Log ID: 6513bea187ffc38ed8ac9307
Connecting to:          mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+1.10.6
Using MongoDB:          7.0.1
Using Mongosh:          1.10.6

test> 

Za prikaz vseh lokalnih MongoDB podatkovnih baz uporabimo ukaz show dbs, kjer nam sistem vrne seznam obstoječih podatkovnih baz in velikost, ki jo zasedejo na trdem disku.

test> show dbs
Demo    12.00 KiB
admin   40.00 KiB
config  72.00 KiB
local   40.00 KiB

Nato se z ukazom use Demo prestavimo v našo podatkovno bazo Demo in povemo, da želimo uporabljati določeno podatkovno bazo. Če podatkovna baza še ne obstaja, jo s tem ukazom tudi kreiramo.

test> use Demo
switched to db Demo

Demo> 

Za prikaz zbirk v podatkovni bazi uporabimo ukaz show collections.

Demo> show collections
Locations

Demo> 

Sedaj lahko z ukazom db.Lokacije.insertOne({ ... }) v zbirko Locations dodamo nov zapis. Pri uspešni izvedbi ukaza je rezultat število kreiranih dokumentov (v našem primeru 1).

Demo> db.Locations.insertOne({
  "id": 58,
  "name": "Celje - Celjski grad",
  "category": "stavbe",
  "type": "profana stavbna dediščina",
  "keywords": [
    "grad",
    "ruševina"
  ],
  "description": "Izjemno razsežna srednjeveška trdnjava 14. in 15. stol. s stanovanjskim jedrom, izstopajočim stolpom in obrambnimi zidovi je postavljena na zaščiteno lego na grič nad Savinjo.",
  "location": "Na JV robu mesta Celje, na naravno dvignjeni legi nad strugo Savinje je postavljena trdnjava.",
  "institution": "ZVKD Celje",
  "heritage": "spomenik lokalnega pomena",
  "municipality": "CELJE",
  "coordinates": [
    46.2195704036,
    15.272580993
  ],
  "synonyms": [
    "Gornje Celje",
    "Stari grad",
    "Cylie"
  ],
  "datation": "12. stol., prva četrtina 14. stol., 1323, druga polovica 16. stol., 1566-1579",
  "fields": [
    "umetnostna zgodovina",
    "arheologija"
  ]
})
{
  acknowledged: true,
  insertedId: ObjectId("63511ab1b6ed459a4a620cb8")
}

Demo> 

Namig: Za brisanje vseh zapisov iz zbirke (npr. iz Locations) lahko uporabimo ukaz db.Locations.remove({}), če pa želimo zbirko v celoti odstraniti (npr. Locations), potem uporabimo ukaz db.Locations.drop().

V podatkovno bazo lahko sedaj posredujemo poizvedbo db.Locations.find(), ki nam vrne vse dokumente, ki so shranjeni v zbirki Locations. V odgovoru lahko opazimo, da nam MongoDB samodejno doda polje _id, ki je enolični identifikator dokumenta v zbirki.

Demo> db.Locations.find()
[
  {
    _id: ObjectId("63511ab1b6ed459a4a620cb8"),
    id: 58,
    name: 'Celje - Celjski grad',
    category: 'stavbe',
    type: 'profana stavbna dediščina',
    keywords: [ 'grad', 'ruševina' ],
    description: 'Izjemno razsežna srednjeveška trdnjava 14. in 15. stol. s stanovanjskim jedrom, izstopajočim stolpom in obrambnimi zidovi je postavljena na zaščiteno lego na grič nad Savinjo.',
    location: 'Na JV robu mesta Celje, na naravno dvignjeni legi nad strugo Savinje je postavljena trdnjava.',
    institution: 'ZVKD Celje',
    heritage: 'spomenik lokalnega pomena',
    municipality: 'CELJE',
    coordinates: [ 46.2195704036, 15.272580993 ],
    synonyms: [ 'Gornje Celje', 'Stari grad', 'Cylie' ],
    datation: '12. stol., prva četrtina 14. stol., 1323, druga polovica 16. stol., 1566-1579',
    fields: [ 'umetnostna zgodovina', 'arheologija' ]
  }
]

Demo> 

Trenutni dokument, ki hrani podatke o lokaciji, ni popoln, saj ne vsebuje komentarjev. Komentarje želimo hraniti kot odvisne dokumente lokaciji, kar lahko dosežemo s posodobitvijo obstoječega dokumenta z ukazom db.Locations.updateOne({ ... }, { ... }).

Z ukazom db.Locations.updateOne({ name: 'Celje - Celjski grad' }, { $push: { ... } }) obstoječemu dokumentu lokacije, ki ima naziv Celje - Celjski grad, dodamo nov komentar. Pri uspešni izvedbi ukaza opazimo, da je poizvedba za posodobitev podatkov (1. parameter klica metode) našla 1 zapis in ga tudi spremenila oz. mu dodala podani komentar (2. parameter klica metode).

> db.Locations.updateOne({
    name: "Celje - Celjski grad"
  }, {
    $push: {
      comments: {
        _id: ObjectId(),
        author: "Dejan Lavbič",
        rating: 5,
        datum: new Date("Apr 4, 2010"),
        comment: "Odlična lokacija, ne morem je prehvaliti."
      }
    }
  })
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}

Lahko dodamo še en komentar.

> db.Locations.updateOne({
    name: "Celje - Celjski grad"
  }, {
    $push: {
      comments: {
        _id: ObjectId(),
        avtor: "Vladimir Putin",
        ocena: 2,
        datum: new Date("Jul 30, 2016"),
        besediloKomentarja: "Čisti dolgčas, še kava je zanič. Edina svetla točka je razstava mučilnih naprav."
      }
    }
  })
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}

6.1.6 Uvoz testnih podatkov

Za lažji uvoz testnih podatkov bomo uporabili podatke iz registra kulturne dediščine v obliki CSV iz datoteke kulturne-dediscine.csv, ki smo jih predstavili že v poglavju 6.1.3.

Najprej bomo podatke iz oblike CSV pretvorili v obliko JSON in jih nekoliko preoblikovali, da bodo ustrezni za našo shemo. Pomagali si bomo z enostavnim programom v db-import.js, ki je predstavljen v nadaljevanju.

data/db-import.js

const fs = require("fs");
const mongoose = require("mongoose");

let comments = [
  {
    name: "Celje - Celjski grad",
    comments: [
      {
        _id: { $oid: new mongoose.mongo.ObjectId() },
        author: "Dejan Lavbič",
        rating: 5,
        comment: "Odlična lokacija, ne morem je prehvaliti.",
        createdOn: { $date: "2010-04-04T00:00:00Z" },
      },
      {
        _id: { $oid: new mongoose.mongo.ObjectId() },
        author: "Vladimir Putin",
        rating: 2,
        comment:
          "Čisti dolgčas, še kava je zanič. Edina svetla točka je razstava mučilnih naprav.",
        createdOn: { $date: "2016-07-30T00:00:00Z" },
      },
    ],
  },
  {
    name: "Ljubljana - Cankarjeva spominska soba na Rožniku",
    comments: [
      {
        _id: { $oid: new mongoose.mongo.ObjectId() },
        author: "Ivan Cankar",
        rating: 4,
        comment: "Nič ni več tako kot je bilo.",
        createdOn: { $date: "1918-12-11T00:00:00Z" },
      },
    ],
  },
];

let locations = fs
  .readFileSync("./kulturne-dediscine.csv", "utf8")
  .split("\r\n")
  .filter((v) => v.length > 0 && v.indexOf("ESD") == -1)
  .map((x) => {
    let row = "",
      split = false;
    for (const element of x) {
      split = element == '"' ? !split : split;
      row += element == "," && !split ? "|" : element;
    }
    row = row.replace(/"/g, "").replace(/\s+/g, " ").split("|");
    let y = {
      _id: { $oid: new mongoose.mongo.ObjectId() },
      id: parseInt(row[0], 10),
      name: row[1],
      category: row[3],
      type: row[4],
      keywords: row[5].split(", "),
      description: row[6],
      location: row[9],
      institution: row[11],
      heritage: row[12],
      municipality: row[13],
      coordinates: [parseFloat(row[17]), parseFloat(row[16])],
    };
    if (row[2].length > 0) y.synonyms = row[2].split(", ");
    if (row[7].length > 0) y.datation = row[7];
    if (row[8].length > 0) y.authors = row[8];
    if (row[10].length > 0) y.fields = row[10].split(", ");
    let comment = comments.find((v) => v.name == y.name);
    if (comment) {
      let averageRating = 0;
      averageRating = parseInt(
        comment.comments.reduce((acc, { rating }) => {
          return acc + rating;
        }, 0) / comment.comments.length,
        10
      );
      y.comments = comment.comments;
      y.rating = averageRating;
    }
    return y;
  });

fs.writeFileSync(
  "./kulturne-dediscine.json",
  JSON.stringify(locations, null, 2)
);

Pretvorbo izvedemo z naslednjimi ukazi:

$ cd data
$ node ./db-import.js

Z omenjenim procesom smo podatke iz vhodne datoteke kulturne-dediscine.csv pretvorili v podatke izhodne datoteko kulturne-dediscine.json ter dodali nekaj testnih komentarjev. V nadaljevanju je prikazan primer podatkov iz obeh datotek.

data/kulturne-dediscine.csv

ESD,Ime,Sinonimi,Zvrst,Tip,Gesla,Opis,Datacija,Avtorji,LokacijaOp,Podrocja,Zavod,Varstvo,Obcina,Y,X,WGS X,WGS Y
29884,Šentrupert na Dolenjskem - Muzej na prostem,,stavbe,profana stavbna dediščina,"muzej na prostem, kozolec na psa, kozolec s plaščem, enojni kozolec, nizki kozolec, toplar, vzporedni kozolec",Muzej na prostem sestavljajo skupina 17 kozolcev različnih tipov in dve enostavni sušilni napravi (belokranjska ostrv in ribniški kozouček). Postavitev iz 2012 prikazuje genezo kozolca na Slovenskem in raznolikost kozolcev v Mirnski dolini.,"zadnja četrtina 18. stol., 19. stol., prva polovica 20. stol., prva četrtina 21. stol., 2012",,Muzej na prostem je urejen na južnem obrobju Šentruperta na Dolenjskem.,"etnologija, krajinska arhitektura, tehniška zgodovina",ZVKD Novo mesto,spomenik lokalnega pomena,ŠENTRUPERT,507458,92302,45.9748324693,15.0914351743
...

data/kulturne-dediscine.json

[
  {
    "_id": {
      "$oid": "635a62f5dc5d7968e68464c1"
    },
    "id": 29884,
    "name": "Šentrupert na Dolenjskem - Muzej na prostem",
    "category": "stavbe",
    "type": "profana stavbna dediščina",
    "keywords": [
      "muzej na prostem",
      "kozolec na psa",
      "kozolec s plaščem",
      "enojni kozolec",
      "nizki kozolec",
      "toplar",
      "vzporedni kozolec"
    ],
    "description": "Muzej na prostem sestavljajo skupina 17 kozolcev različnih tipov in dve enostavni sušilni napravi (belokranjska ostrv in ribniški kozouček). Postavitev iz 2012 prikazuje genezo kozolca na Slovenskem in raznolikost kozolcev v Mirnski dolini.",
    "location": "Muzej na prostem je urejen na južnem obrobju Šentruperta na Dolenjskem.",
    "institution": "ZVKD Novo mesto",
    "heritage": "spomenik lokalnega pomena",
    "municipality": "ŠENTRUPERT",
    "coordinates": [15.0914351743, 45.9748324693],
    "datation": "zadnja četrtina 18. stol., 19. stol., prva polovica 20. stol., prva četrtina 21. stol., 2012",
    "fields": ["etnologija", "krajinska arhitektura", "tehniška zgodovina"]
  },
  ...
]

JSON datoteko kulturne-dediscine.json bomo sedaj najprej prenesli v Docker vsebnik, vse podatke uvozili in nato datoteko izbrisali.

$ cd data
$ docker cp ./kulturne-dediscine.json web-dev-mongo-db:/kulturne-dediscine.json
$ docker exec -it web-dev-mongo-db mongoimport --db Demo --collection Locations --mode upsert --upsertFields id --jsonArray --file kulturne-dediscine.json
2022-10-20T10:48:46.091+0000    connected to: mongodb://localhost/
2022-10-20T10:48:46.809+0000    2144 document(s) imported successfully. 0 document(s) failed to import.
$ docker exec -it web-dev-mongo-db rm -rf kulturne-dediscine.json

Z ukazom docker exec -it sp-mongo-db bash -c "mongosh Demo --eval 'db.Locations.find()'" lahko preverimo vsebino podatkovne baze in potrdimo, da je bil uvoz podatkov uspešen.

Za potrebe uvoza podatkov je bilo potrebno izvesti več zaporednih ukazov, ki jih lahko sedaj združimo v skripto import:db-docker in jo vključimo v package.json. Poleg tega dodajmo še skripto delete:db-docker, ki obstoječe lokacije iz podatkovne baze izbriše in skripto view:db-docker, ki nam vrne seznam lokacij v podatkovni bazi.

package.json

{
  "name": "demo",
  "description": "Demo application for Web Development course",
  "author": "Dejan Lavbič <dejan@lavbic.net>",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "start": "node server.js",
    "import:db-docker": "docker cp ./data/kulturne-dediscine.json web-dev-mongo-db:/kulturne-dediscine.json; docker exec -it web-dev-mongo-db mongoimport --db Demo --collection Locations --mode upsert --upsertFields id --jsonArray --file kulturne-dediscine.json; docker exec -it web-dev-mongo-db rm -rf kulturne-dediscine.json",
    "delete:db-docker": "docker exec -it web-dev-mongo-db bash -c \"mongosh Demo --eval 'db.Locations.deleteMany({})'\"",
    "view:db-docker": "docker exec -it web-dev-mongo-db bash -c \"mongosh Demo --eval 'db.Locations.find()'\""
  },
  "dependencies": {
    "express": "~4.18.2",
    "hbs": "~4.2.0",
    "mongoose": "^7.5.3"
  }
}

Opomba: V operacijskih sistemih Microsoft Windows 10 je treba biti pozoren na uporabo narekovajev ukazih v privzeti ukazni vrstici Command prompt.

6.1.7 Vključitev podatkovne baze

6.1.7.1 Gostovanje MongoDB podatkovne baze na MongoDB Atlas

Uporabljali bomo ponudnika MongoDB podatkovne baze v oblaku MongoDB Atlas, ki v okviru brezplačnega dostopa omogoča uporabo 1 podatkovne baze z do 512 MB podatkov.

Ko je registracija uspešna in elektronski naslov potrjen, kreiramo novo gručo (angl. Cluster) podatkovnih baz tipa Shared, kjer priporočamo nastavitev naslednjih parametrov:

  • Cloud Provider & Region: AWS, Frankfurt (eu-central-1),
  • Cluster Tier: M0 Sandbox (Shared RAM, 512 MB Storage),
  • Additional Settings: MongoDB 5.0, No Backup,
  • Cluster Name: Demo.

Nato poskrbimo za nastavitev dostopa do podatkovne baze z izbranih lokacij (Security → Network Access → Add IP address), kjer je treba dodati IP naslove, ki jim je dovoljen dostop.

Priporočilo: Za potrebe testiranja lahko kot IP naslov dodate 0.0.0.0/0, ki dovoljuje dostop s poljubne lokacije, kar sicer ni priporočljivo. To je sicer za produkcijske sisteme zelo nepriporočljivo, ker predstavlja varnostno tveganje.

Prav tako dodajte novega uporabnika app (Security → Database Access), ki bo dostopal do podatkovne baze in bo imel bralni in pisalni dostop.

Sedaj lahko pridobite povezavo do podatkovne baze (Deployment → Database → Demo → Connect → Connect your application → Drivers), ki je v naslednji obliki:

mongodb+srv://app:<password>@demo.548y4py.mongodb.net/Demo?retryWrites=true&w=majority

Z naslednjim ukazom lahko iz lokalnega Docker okolja preverimo delovanje naše oddaljene MongoDB podatkovne baze, ki jo gostujemo na MongoDB Atlas.

Namig: Če Docker vsebnik z MongoDB podatkovno bazo in odjemalcem mongosh ni pognan, za zagon uporabite ukaz docker start sp-mongo-db.

$ docker exec -it web-dev-mongo-db bash -c "mongosh \"mongodb+srv://demo.548y4py.mongodb.net/Demo\" --username app"
Enter password: ************
Current Mongosh Log ID: 64f88c9c1295cd0f415444f6
Connecting to:          mongodb+srv://<credentials>@demo.548y4py.mongodb.net/Demo?appName=mongosh+1.10.6
Using MongoDB:          6.0.9
Using Mongosh:          1.10.6

Atlas atlas-znx0ni-shard-0 [primary] Demo>

Namig: Bodite pozorni na to, da je verzija vašega odjemalca (MongoDB shell) novejša ali enaka verziji strežnika (MongoDB server), sicer lahko imate težave. V našem primeru je odjemalec novejši kot strežnik, kar je še sprejemljivo.

6.1.7.2 Prenos podatkov iz lokalne podatkovne baze na MongoDB Atlas

Ko imamo vzpostavljeno produkcijsko verzijo podatkovne baze na MongoDB Atlas, je treba vzpostaviti mehanizem za prenos podatkov iz razvojne verzije v produkcijsko verzijo podatkovne baze.

Najprej z ukazom mongodump naredimo posnetek stanja razvojne podatkovne baze v Docker okolju. Vsebina podatkovne baze se izvozi v trenutno mapo (v okolju Docker), kar zahtevamo s parametrom -o ./.

$ docker exec -it sp-mongo-db bash -c "mongodump -h localhost -d Demo -o ./"
2022-10-24T09:34:23.015+0000    writing Demo.Locations to Demo/Locations.bson
2022-10-24T09:34:23.025+0000    done dumping Demo.Locations (2144 documents)

Sedaj z ukazom mongorestore obnovimo vsebino razvojne podatkovne baze na produkcijsko verzijo podatkovne baze na MongoDB Atlas. Pri tem s parametrom --drop pred začetkom uvoza izbrišemo morebitne obstoječe podatke.

Namig: Ukaz za obnovitev podatkovne baze mongorestore, vključno z večino potrebnih parametrov, pridobite preko MongoDB Admin konzole, kjer izberete Deployment → Database → vaša podatkovna baza → ... → Command Line Tools → Binary Import and Export Tools → mongorestore.

$ docker exec -it web-dev-mongo-db bash -c "mongorestore --drop --uri mongodb+srv://app@demo.548y4py.mongodb.net ./Demo/Locations.bson"
Enter password for mongo user:

2023-09-06T14:33:47.201+0000    checking for collection data in Demo/Locations.bson
2023-09-06T14:33:47.201+0000    reading metadata for Demo.Locations from Demo/Locations.metadata.json
2023-09-06T14:33:47.237+0000    dropping collection Demo.Locations before restoring
2023-09-06T14:33:47.403+0000    restoring Demo.Locations from Demo/Locations.bson
2023-09-06T14:33:50.100+0000    [########################]  Demo.Locations  1.69MB/1.69MB  (100.0%)
2023-09-06T14:33:51.831+0000    [########################]  Demo.Locations  1.69MB/1.69MB  (100.0%)
2023-09-06T14:33:51.831+0000    finished restoring Demo.Locations (2144 documents, 0 failures)
2023-09-06T14:33:51.831+0000    restoring indexes for collection Demo.Locations from metadata
2023-09-06T14:33:51.831+0000    index: &idx.IndexDocument{Options:primitive.M{"2dsphereIndexVersion":3, "background":true, "name":"coordinates_2dsphere", "v":2}, Key:primitive.D{primitive.E{Key:"coordinates", Value:"2dsphere"}}, PartialFilterExpression:primitive.D(nil)}
2023-09-06T14:33:52.043+0000    2144 document(s) restored successfully. 0 document(s) failed to restore.

Povežemo se na oddaljeno produkcijsko podatkovno bazo na MongoDB Atlas in preverimo, ali so podatki na voljo.

$ docker exec -it web-dev-mongo-db bash -c "mongosh \"mongodb+srv://demo.548y4py.mongodb.net/Demo\" --username app"
Enter password: ************

Current Mongosh Log ID: 64f88e100d62c1d7553569df
Connecting to:          mongodb+srv://<credentials>@demo.548y4py.mongodb.net/Demo?appName=mongosh+1.10.6
Using MongoDB:          6.0.9
Using Mongosh:          1.10.6

Atlas atlas-znx0ni-shard-0 [primary] Demo> show collections
Locations
Atlas atlas-znx0ni-shard-0 [primary] Demo> db.Locations.find().count()
2144

Če je bil uvoz uspešen, se nam prikažejo vsi podatki, ki smo jih že vnesli v lokalno razvojno podatkovno bazo v okolju Docker in so sedaj na voljo tudi v produkcijski podatkovni bazi na MongoDB Atlas.

Nato zaporedje ukazov za izvoz podatkov iz lokalne razvojne podatkovne baze v oddaljeno produkcijsko podatkovno bazo vključimo v skripto transfer:db-docker-atlas v datoteki package.json, kar nam bo olajšalo kasnejše izvoze podatkov.

package.json

{
  ...
  "scripts": {
    ...,
    "transfer:db-docker-atlas": "docker exec -it web-dev-mongo-db bash -c \"mongodump -h localhost -d Demo -o ./\"; docker exec -it web-dev-mongo-db bash -c \"mongorestore --drop --uri mongodb+srv://app@demo.548y4py.mongodb.net ./Demo/Locations.bson\"; docker exec -it web-dev-mongo-db bash -c \"rm -rf ./Demo\""
  },
  ...
}

6.1.7.3 Izbira podatkovne baze glede na razvojno okolje

V tem trenutku imamo tako vzpostavljeno ločena lokalno razvojno, testno in oddaljeno produkcijsko okolje, ki imajo vsak svojo podatkovno bazo, kot prikazuje slika 6.7.

Razvojno, testno in produkcijsko okolje ter pripadajoči podatkovni bazi

Slika 6.7: Razvojno, testno in produkcijsko okolje ter pripadajoči podatkovni bazi

Glede na naš cilj ločitve izvajalnih okolij mora naša aplikacija v razvojnem in testnem okolju uporabljati lokalno podatkovno bazo v Docker-ju, v produkcijskem okolju na MongoDB Atlas podatkovno bazo.

To bomo dosegli z uporabo spremenljivke NODE_ENV v okolju Node.js. Pri zagonu aplikacije preprosto preverimo, ali ima okoljska spremenljivka NODE_ENV nastavljeno vrednost na production, saj bomo takrat uporabili povezovalni niz na produkcijsko podatkovno bazo na MongoDB Cloud, ki je opredeljena v okoljski spremenljivki process.env.MONGODB_ATLAS_URI.

Okoljske spremenljivke lahko enostavno shranimo v datoteko .env, kamor v našem primeru dodamo MONGODB_ATLAS_URI.

.env

# MongoDB Atlas
MONGODB_ATLAS_URI=mongodb+srv://app:***@demo.548y4py.mongodb.net/Demo?retryWrites=true&w=majority

Opomba: Geslo je v MongoDB Atlas povezavi skrito za zvezdicami (*).

Pomembno je tudi, da datoteke .env ne vključimo v spremembe izvorne kode, ki jih shranjujemo v oddaljenem GitHub repozitoriju, saj občutljive podatke (kot je npr. povezava na MongoDB Atlas podatkovno bazo z uporabniškim imenom in geslom) hranimo zgolj v okoljskih spremenljivkah. S tem razlogom datoteko .env vključimo med prezrte datoteke v .gitignore.

.gitignore

node_modules
.env

Okoljske spremenljivke iz omenjene datoteke .env bo Docker samodejno prebral in vrednosti naložil v okolje, medtem ko lahko v okviru Node.js aplikacije to dosežemo z uporabo knjižnice dotenv, ki jo namestimo.

$ npm install dotenv --save

Ob zagonu aplikacije v datoteki server.js nato zgolj zahtevamo funkcionalnosti omenjene knjižnice.

server.js

/**
 * Load environment variables
 */
require("dotenv").config();

const express = require("express");
const path = require("path");

V datoteko db.js dodamo pogoj, kjer preverjamo, ali se aplikacija izvaja v produkcijskem ali testnem okolju. Prav tako popravimo izpis povezovalnega niza na MongoDB podatkovno bazo, kjer pri izpisu skrijemo geslo.

api/models/db.js

const mongoose = require("mongoose");

let const dbURI = "mongodb://127.0.0.1/Demo";
if (process.env.NODE_ENV === "production")
  dbURI = process.env.MONGODB_ATLAS_URI;
else if (process.env.NODE_ENV === "test") 
  dbURI = "mongodb://web-dev-mongo-db/Demo";
mongoose.connect(dbURI);

mongoose.connection.on("connected", () =>
  console.log(`Mongoose connected to ${dbURI.replace(/:.+?@/, ":*****@")}.`)
);

Uporabniškega imena in gesla za dostop do oddaljene MongoDB podatkovne baze, ki jo gostujemo na MongoDB Atlas, ne razkrivamo v izvorni kodi, ampak uporabimo spremenljivko MONGODB_ATLAS_URI, ki smo jo predhodno nastavili kot okoljsko spremenljivko.

Obstoječe parametre gradnje Docker slike v dockerfile spremenimo tako, da odstranimo privzeto nastavitev okoljske spremenljivke NODE_ENV, ki jo bomo nastavljali po potrebi (v lokalnem okolju Docker in na oddaljenem Fly).

dockerfile

FROM node:18-alpine
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
# Default application directory for all subsequent commands (COPY, RUN, CMD etc.).
WORKDIR /usr/src/app
# Copy package.json and install dependencies.
# Docker will cache node_modules, if package.json and package-lock.json are not changed.
COPY package.json ./
RUN npm install
# Copy source code of the application
COPY . .
EXPOSE 3000
CMD [ "npm", "run", "start" ]

V lokalnem testnem okolju bomo vrednost NODE_ENV okoljske spremenljivke nastavili kar v okviru docker-compose.yml.

docker-compose.yml

services:
  web-dev-node-api:
    container_name: web-dev-app
    ports:
      - "3000:3000"
    image: web-dev-server
    build: ./
    environment:
      - NODE_ENV=test
    networks:
      - web-dev-network

Za produkcijsko okolje Fly bomo nastavili privzeti vrednosti okoljskih spremenljivk NODE_ENV in MONGODB_ATLAS_URI.

$ fly secrets set NODE_ENV=production
$ fly secrets set MONGODB_ATLAS_URI="mongodb+srv://app:*****@demo.548y4py.mongodb.net/Demo?retryWrites=true&w=majority"

Nato lahko preverimo ali so okoljse spremenljivke na oddaljenem okolju Fly nastavljene, kjer se prikažejo zgolj zgoščene vrednosti, saj jih sistem ne sme razkrivati.

$ fly secrets list

NAME                    DIGEST                  CREATED AT 
MONGODB_ATLAS_URI       0451df420bb55b6d        1m52s ago 
NODE_ENV                a331102148f18977        15m50s ago

6.1.7.4 Testiranje delovanja

Pri zagonu strežnika bomo dodali način, v katerem trenutno deluje, da bomo lažje preverili delovanje upoštevanja okoljskih spremenljivk pri razvoju.

server.js

/**
 * Start server
 */
app.listen(port, () => {
  console.log(
    `Demo app started in ${
      process.env.NODE_ENV || "development"
    } mode listening on port ${port}!`
  );
});

Če aplikacijo poženemo v lokalnem razvojnem okolju (glej 1. scenarij na sliki 6.7), vidimo iz izpisov, da je strežnik v razvojnem načinu in smo se povezali z lokalno podatkovno bazo.

$ nodemon
...
Demo app started in development mode listening on port 3000!
Mongoose connected to mongodb://127.0.0.1/Demo.

Sedaj preverimo še povezovanje na produkcijsko podatkovno bazo na MongoDB Atlas, kjer pred zagonom aplikacije z nodemon nastavimo okoljsko spremenljivko NODE_ENV.

$ NODE_ENV=production nodemon
...
Demo app started in production mode listening on port 3000!
Mongoose connected to mongodb+srv:*****@demo.548y4py.mongodb.net/Demo?retryWrites=true&w=majority.

Namig: Če uporabljate okolje Windows, potem boste mogoče morali uporabiti drugačen način hkratnega nastavljanja okoljskih spremenljivk in zagona aplikacije.

Če aplikacijo poženemo v lokalnem testnem okolju s pomočjo Docker-ja (glej 2. scenarij na sliki 6.7), lahko iz izpisov vidimo, da smo se povezali s podatkovno bazo znotraj Docker omrežja.

$ docker-compose stop web-dev-node-api
[+] Running 1/1
 ⠿ Container sp-app  Stopped   
$ docker-compose rm web-dev-node-api
? Going to remove sp-app Yes
[+] Running 1/0
 ⠿ Container sp-app  Removed
$ docker rmi web-dev-server
Untagged: web-dev-server:latest
Deleted: sha256:ca72824ee79fefb8be9fd1ce54922824d5633c02344583f4f286ad29b84483f2
$ docker-compose build --no-cache web-dev-node-api
[+] Building 8.0s (11/11) FINISHED
$ docker-compose up --force-recreate --no-deps -d web-dev-node-api
[+] Running 1/1
 ⠿ Container web-dev-app  Started
$ docker logs web-dev-app
> demo@0.1.0 start
> node server.js
Demo app started in test mode listening on port 3000!
Mongoose connected to mongodb://web-dev-mongo-db/Demo.

Opomba: Najprej vsebnik ustavimo in odstranimo, nato odstranimo še sliko, in oboje ponovno zgradimo ter poženemo.

Na koncu preverimo še delovanje v celoti na produkcijskem okolju v oblaku Fly. Po pregledu dnevniških zapisov na oddaljenem sistemu Fly opazimo, da se je aplikacija povezala s produkcijo podatkovno bazo na MongoDB Atlas.

$ fly deploy
...
$ fly logs
... Demo app started in production mode listening on port 3000!
... Mongoose connected to mongodb+srv:*****@demo.548y4py.mongodb.net/Demo?retryWrites=true&w=majority.

6.2 Priprava REST API-ja za dostop do MongoDB podatkovne baze

Do tega trenutka smo vzpostavili MongoDB podatkovno bazo, vendar lahko do nje dostopamo zgolj preko ukazne vrstice. V nadaljevanju bomo zgradili REST API (glej sliko 6.8), ki nam bo omogočal interakcijo s podatkovno bazo s pomočjo HTTP zahtev, ter izvajal standardne CRUD132 operacije.

V nadaljevanju bomo s pomočjo Express, Node.js in Mongoose zgradili REST API za dostop do MongoDB podatkovne baze

Slika 6.8: V nadaljevanju bomo s pomočjo Express, Node.js in Mongoose zgradili REST API za dostop do MongoDB podatkovne baze

6.2.1 REST API

REST133 je bolj arhitekturni stil kot striktno določen protokol. REST je brez stanja, t.j. stanja oz. zgodovine trenutnega uporabnika se ne zaveda.

API134 omogoča aplikacijam medsebojno komunikacijo.

Iz tega izhaja, da je REST API vmesnik do aplikacije brez stanja. V našem primeru bomo s pomočjo tega pristopa implementirali dostop do podatkovne baze. Pri osnovnem delovanju REST API pričakuje zahtevo, ki se na strani strežnika obdela (po potrebi dostopa do podatkovne baze) in vrne odgovor, kot je prikazano na sliki 6.9.

Zaporedje procesiranja pri REST zahtevi

Slika 6.9: Zaporedje procesiranja pri REST zahtevi

6.2.2 URL zahteva

Kot že omenjeno, želimo z REST API-jem implementirati standardne CRUD operacije:

  • dodajanje novega elementa,
  • branje seznama več elementov,
  • branje določenega elementa,
  • posodabljanje določenega elementa,
  • brisanje določenega elementa.

Za obvladovanje podatkov v zvezi z lokacijami bi si lahko zamislili naslednje akcije, predstavljene v tabeli 6.1.

Tabela 6.1: URL naslovi in parametri REST API-ja za lokacijo
Akcija URL naslov Parametri Primer
Kreiranje nove lokacije /locations http://<streznik>/api/locations
Branje seznama lokacij /locations http://<streznik>/api/locations
Branje določene lokacije /locations locationId http://<streznik>/api/locations/123
Posodobitev določene lokacije /locations locationId http://<streznik>/api/locations/123
Brisanje določene lokacije /locations locationId http://<streznik>/api/locations/123

Kot je razvidno iz tabele 6.1 ima vsaka akcija isti URL naslov, medtem ko 3 akcije pričakujejo tudi dodatni parameter za določitev točne lokacije.

6.2.3 Metoda zahteve

Če želimo uporabiti isti URL naslov za vse akcije, je za medsebojno razlikovanje treba uporabiti različne metode zahtev, ki so predstavljene v tabeli 6.2. Pri metodi zahteve gre za navodilo strežniku, kakšno akcijo naj izvede.

Tabela 6.2: Metode zahteve, ki se uporabljajo pri REST API
Metoda zahteve Uporaba Odgovor
POST Dodajanje novega dokumenta v podatkovno bazo. Nov podatkovni objekt v podatkovni bazi.
GET Branje obstoječega dokumenta iz podatkovne baze. Objekt, ki predstavlja odgovor na poizvedbo.
PUT Posodabljanje dokumenta v podatkovni bazi. Posodobljen podatkovni objekt iz podatkovne baze.
DELETE Brisanje obstoječega dokumenta iz podatkovne baze. NULL

Poleg najbolj pogosto uporabljenih metod zahtev, predstavljenih v tabeli 6.2, so na voljo tudi druge, in sicer HEAD, CONNECT, OPTIONS, TRACE in PATCH.

Če sedaj posodobimo tabelo 6.1 s podatki o metodah zahtev iz tabele 6.2, dobimo naslednjo tabelo 6.3.

Tabela 6.3: URL naslovi, parametri in metode REST API za lokacijo
Akcija Metoda URL naslov Parametri Primer
Kreiranje nove lokacije POST /locations http://<server>/api/locations
Branje seznama lokacij GET /locations http://<server>/api/locations
Branje določene lokacije GET /locations/:locationId locationId http://<server>/api/locations/123
Posodobitev določene lokacije PUT /locations/:locationId locationId http://<server>/api/locations/123
Brisanje določene lokacije DELETE /locations/:locationId locationId http://<server>/api/locations/123

V tabeli 6.3 imamo osnovne metode, povezane z lokacijami, manjkajo pa nam še odvisni dokumenti, v našem primeru so to komentarji, ki so podrobneje opisani v tabeli 6.4.

Tabela 6.4: URL naslovi, parametri in metode REST API za komentarje
Akcija Metoda URL naslov Parametri Primer
Kreiranje novega komentarja POST /locations/:locationId/
comments
locationId http://<server>/api/
locations/123/comments
Branje določenega komentarja GET /locations/:locationId/
comments/:commentId
locationId, commentId http://<server>/api/
locations/123/comments/abc
Posodobitev določenega komentarja PUT /locations/:locationId/
comments/:commentId
locationId, commentId http://<server>/api/
locations/123/comments/abc
Brisanje določenega komentarja DELETE /locations/:locationId/
comments/:commentId
locationId, commentId http://<server>/api/
locations/123/comments/abc

6.2.4 Odgovor (podatek in status) API-ja

Dobra praksa pri uporabi API-ja je, da pri vsaki zahtevi dobimo odgovor, ki je sestavljen iz podatkov in HTTP šifre statusa. API podatke po navadi vrača v XML ali JSON obliki. V našem primeru bomo uporabljali JSON obliko, ki je skladna z uporabljeno MEAN arhitekturo, prav tako pa zahteva prenos manjše količine podatkov kot XML oblika. Pri odgovoru odjemalec vedno pričakuje tudi HTTP šifro statusa, najbolj pogosto uporabljene so zbrane v tabeli 6.5.

Tabela 6.5: HTTP šifre statusa
Šifra statusa Naziv Primer uporabe
200 OK Uspešna GET ali PUT zahteva.
201 Created Uspešna POST zahteva.
204 No content Uspešna DELETE zahteva.
400 Bad request Neuspešna GET, POST ali PUT zahteva, zaradi napačne vsebine.
401 Unauthorized Zahteva zaščitenega URL naslova z napačnimi prijavnimi podatki.
403 Forbidden Zahteva, ki ni dovoljena.
404 Not found Neuspešna zahteva, zaradi napačnega parametra v URL naslovu.
405 Method not allowed Metoda zahteve ni dovoljena za zahtevan URL naslov.
409 Conflict Neuspešna POST zahteva, ko že obstaja objekt z istimi podatki.
500 Internal server error Težava s strežnikom ali podatkovno bazo.

Verjetno je največ uporabnikom poznana šifra statusa 404, ki predstavlja neuspešno zahtevo, ko določene strani, ki jo uporabnik zahteva, ni mogoče najti. Vendar to še zdaleč ni edina, ampak obstajajo še številne druge, povezane z napakami na strani odjemalca oz. strežnika, preusmeritve in uspešne zahteve, kot je razvidno iz tabele 6.5.

6.3 Implementacija API-ja z ogrodjem Express

Aplikacijo smo že začeli graditi s pomočjo ogrodja Express, zdaj pa bi želeli dodati še dostop do podatkovne baze s pomočjo dostopa preko REST API-ja. Cilj je ločitev dostopa do podatkovne baze od aplikacije, zato bomo celotno izvorno kodo, povezano z implementacijo API-ja, hranili v mapi api, kjer že imamo opredeljeno začetno verzijo modelov, kjer se naša aplikacija poveže na podatkovno bazo.

$ mkdir api/routes
$ mkdir api/controllers

6.3.1 Priprava usmerjevalnika

V začetno datoteko naše aplikacije server.js dodamo referenco na usmerjevalnik v okviru implementacije API-ja.

server.js

const hbsRouter = require("./hbs/routes/hbs");
const apiRouter = require("./api/routes/api");

V aplikacijo dodamo tudi preusmeritev vseh zahtev /api na pravkar opredeljen usmerjevalnik apiRouter.

server.js

/**
 * HBS routing
 */
app.use("/", hbsRouter);
/**
 * API routing
 */
app.use("/api", apiRouter);

Nato kreiramo novo datoteko api/routes/api.js, kjer bo implementiran usmerjevalnik za API. Izvorna koda usmerjevalnika je neposredna implementacija predvidenih poti za lokacije (glej tabelo 6.3) in komentarje (glej tabelo 6.4).

api/routes/api.js

const express = require("express");
const router = express.Router();
const ctrlLocations = require("../controllers/locations");
const ctrlComments = require("../controllers/comments");

/**
 * Locations
 */
router.get("/locations/distance", ctrlLocations.locationsListByDistance);
router.get("/locations/search", ctrlLocations.locationsListByMultiFilter);
router.get(
  "/locations/codelist/:codelist",
  ctrlLocations.locationsListCodelist
);
router.post("/locations", ctrlLocations.locationsCreate);
router.get("/locations/:locationId", ctrlLocations.locationsReadOne);
router.put("/locations/:locationId", ctrlLocations.locationsUpdateOne);
router.delete("/locations/:locationId", ctrlLocations.locationsDeleteOne);

/**
 * Comments
 */
router.post("/locations/:locationId/comments", ctrlComments.commentsCreate);
router.get(
  "/locations/:locationId/comments/:commentId",
  ctrlComments.commentsReadOne
);
router.put(
  "/locations/:locationId/comments/:commentId",
  ctrlComments.commentsUpdateOne
);
router.delete(
  "/locations/:locationId/comments/:commentId",
  ctrlComments.commentsDeleteOne
);

module.exports = router;

Pri opredelitvi API URL naslova se pogosto uporabljajo parametri, s katerimi določimo odvisne dokumente, kot je to v našem primeru komentarjev, ki so odvisni od lokacije. Vključitev parametra v URL naslov je preprosta, in sicer imenu spremenljivke samo dodamo predpono dvopičja :.

6.3.2 Priprava osnutka krmilnika

Pri pripravi osnutka krmilnika moramo pripraviti začetno kodo, da bo API sploh deloval, funkcionalnosti pa v krmilniku dejansko ne bo implementirane nobene, zgolj vračanje enostavnega besedila OK.

V nadaljevanju bomo zato pripravili metode v obeh krmilnikih locations.js in comments.js.

api/controllers/locations.js

const locationsListByDistance = (req, res) => {
  res.status(200).json({ status: "OK" });
};
const locationsListByMultiFilter = (req, res) => {
  res.status(200).json({ status: "OK" });
};
const locationsCreate = (req, res) => {
  res.status(201).json({ status: "OK" });
};
const locationsReadOne = (req, res) => {
  res.status(200).json({ status: "OK" });
};
const locationsListCodelist = (req, res) => {
  res.status(200).json({ status: "OK" });
};
const locationsUpdateOne = (req, res) => {
  res.status(200).json({ status: "OK" });
};
const locationsDeleteOne = (req, res) => {
  res.status(204).json({ status: "OK" });
};
module.exports = {
  locationsListByDistance,
  locationsListByMultiFilter,
  locationsListCodelist,
  locationsCreate,
  locationsReadOne,
  locationsUpdateOne,
  locationsDeleteOne,
};

api/controllers/comments.js

const commentsCreate = (req, res) => {
  res.status(201).json({ status: "OK" });
};
const commentsReadOne = (req, res) => {
  res.status(200).json({ status: "OK" });
};
const commentsUpdateOne = (req, res) => {
  res.status(200).json({ status: "OK" });
};
const commentsDeleteOne = (req, res) => {
  res.status(204).json({ status: "OK" });
};
module.exports = {
  commentsCreate,
  commentsReadOne,
  commentsUpdateOne,
  commentsDeleteOne,
};

6.3.3 Testiranje osnutka API-ja

Za testiranje enostavnih GET zahtev lahko uporabimo kar spletni brskalnik, kamor vnesemo želeni URL naslov in dobimo pričakovan odgovor, kot je to prikazano na sliki 6.10.

Testiranje REST API GET zahteve `/locations/distance`

Slika 6.10: Testiranje REST API GET zahteve /locations/distance

Za bolj kompleksno testiranje vseh metod API vmesnika pa priporočamo bolj napredno orodje, kot sta npr. Postman ali Thunder Client, ki nam omogočata napredno simuliranje različnih REST klicev.

Najboljše razmerje med učinkovitostjo in enostavnostjo uporabe pa nam nudi konzolno orodje curl, ki ga bomo tudi mi uporabljali v nadaljevanju pri testiranju razvitih REST API metod. Zahtevo, ki smo jo izvedli s pomočjo brskalnika (glej sliko 6.10), s pomočjo curl izvedemo tako, kot prikazuje naslednji primer.

$ curl -X GET http://localhost:3000/api/locations/distance
{"status":"OK"}

6.3.4 Vključitev modela

Za API je ključnega pomena, da ima dostop do podatkovne baze, saj nam v nasprotnem primeru ne bi veliko koristil. Dostop do podatkovne baze imamo že omogočen preko modela. Ker bomo do modela dostopali v krmilniku, dodamo odvisnosti v datotekah locations.js in comments.js.

api/controllers/locations.js

const mongoose = require("mongoose");
const Location = mongoose.model("Location");
const locationsListByDistance = (req, res) => {
  res.status(200).json({ status: "OK" });
};

api/controllers/comments.js

const mongoose = require("mongoose");
const Location = mongoose.model("Location");
const commentsCreate = (req, res) => {
  res.status(201).json({ status: "OK" });
};

6.4 GET metode za branje podatkov iz MongoDB

GET metode se pri REST API vmesniku uporabljajo za poizvedovanje po podatkovni bazi in vračanje rezultatov poizvedb. V našem primeru imamo v usmerjevalniku api/routers/api.js opredeljene 5 GET metod, kot je prikazano v tabeli 6.6.

Tabela 6.6: 5 GET metode v okviru naše aplikacije
Akcija Metoda URL naslov Parametri Primer
Branje seznama lokacij glede na lokacijo
(glej poglavje 6.4.3)
GET /locations/distance http://<server>/api/
locations/distance
Branje seznama lokacije glede na filter
(glej poglavje 6.4.4)
GET /locations/search http://<server>/api/
locations/search
Branje vrednosti šifrantov
(glej poglavje 6.4.5)
GET /locations/codelist/
:codelist
codelist http://<server>/api/
locations/codelist/category
Branje določene lokacije
(glej poglavje 6.4.1)
GET /locations/:locationId locationId http://<server>/api/
locations/123
Branje določenega komentarja
(glej poglavje 6.4.2)
GET /locations/:locationId/
comments/:commentId
locationId, commentId http://<server>/api/
locations/123/comments/abc

6.4.1 Iskanje dokumenta

Mongoose dostopa do podatkovne baze preko modelov, kar je tudi razlog, da smo model Locations vključili v oba krmilnika api/controllers/comments.js in api/controllers/locations.js.

MongoDB ima preko dostopa z Mongoose na voljo številne metode za iskanje podatkov, kjer so ključne naslednje:

  • find se uporablja za splošno iskanje, glede na podan filter iskanja,
  • findById išče dokument s podanim enoličnim identifikatorjem,
  • findOne vrne prvi rezultat ujemanja podanega filtra iskanja,
  • geoNear (preko funkcije aggregate) išče zadetke, ki so geografsko blizu podani zemljepisni širini in dolžini.

Za potrebe iskanja določene lokacije bomo uporabili metodo findById.

api/controllers/locations.js

const locationsReadOne = async (req, res) => {
  let location = await Location.findById(req.params.locationId)
    .select("-id")
    .exec();
  res.status(200).json(location { status: "OK" });
};

Sedaj lahko preverimo delovanje metode tako, da najprej ugotovimo ID lokacije iz MongoDB podatkovne baze, kjer uporabimo predhodno pripravljeno skripto view:db-docker.

$ npm run view:db-docker
> demo@0.1.0 view:db-docker
> docker exec -it sp-mongo-db bash -c "mongosh Demo --eval 'db.Locations.find()'"
Current Mongosh Log ID: 635a2a8d85c2a42a4dadfe2b
Connecting to:          mongodb://127.0.0.1:27017/Demo?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+1.6.0
Using MongoDB:          6.0.2
Using Mongosh:          1.6.0
[
  {
    _id: ObjectId("635a62f5dc5d7968e68464c1"),
    id: 29884,
    name: 'Šentrupert na Dolenjskem - Muzej na prostem',
    ...
  },
  ...
]

Uporabili bomo kar prvo kulturno dediščino na seznamu, in sicer muzej na prostem v Šentrupertu na Dolenjskem z ID-jem 635a62f5dc5d7968e68464c1.

Opomba: Naši podatki v obliki dokumentov v podatkovni bazi MongoDB imajo dva enolična identifikatorja _id in id. V okviru delovanja naše aplikacije bomo vedno uporabljali _id, medtem ko id predstavlja zgolj uvožen podatke iz registra kulturne dediščine in v tistem viru predstavlja enolični identifikator, ki ga pri nas hranimo zgolj kot referenco.

Nato s pomočjo ID-ja dokumenta sestavimo zahtevo, ki nam vrne zahtevan komentar iz podatkovne baze preko REST API-ja.

$ curl http://localhost:3000/api/locations/635a62f5dc5d7968e68464c1

Kot odgovor strežnik vrne JSON dokument, ki ustreza zapisu v MongoDB podatkovni bazi in je skladen z Mongoose shemo, ki smo jo opredelili v poglavju 6.1.4.1.

{
  "synonyms": [],
  "rating": 0,
  "_id": "635a62f5dc5d7968e68464c1",
  "id": 29884,
  "name": "Šentrupert na Dolenjskem - Muzej na prostem",
  "category": "stavbe",
  "type": "profana stavbna dediščina",
  "keywords": [
    "muzej na prostem",
    "kozolec na psa",
    "kozolec s plaščem",
    "enojni kozolec",
    "nizki kozolec",
    "toplar",
    "vzporedni kozolec"
  ],
  "description": "Muzej na prostem sestavljajo skupina 17 kozolcev različnih tipov in dve enostavni sušilni napravi (belokranjska ostrv in ribniški kozouček). Postavitev iz 2012 prikazuje genezo kozolca na Slovenskem in raznolikost kozolcev v Mirnski dolini.",
  "location": "Muzej na prostem je urejen na južnem obrobju Šentruperta na Dolenjskem.",
  "institution": "ZVKD Novo mesto",
  "heritage": "spomenik lokalnega pomena",
  "municipality": "ŠENTRUPERT",
  "coordinates": [45.9748324693, 15.0914351743],
  "datation": "zadnja četrtina 18. stol., 19. stol., prva polovica 20. stol., prva četrtina 21. stol., 2012",
  "fields": ["etnologija", "krajinska arhitektura", "tehniška zgodovina"],
  "comments": []
}

6.4.1.1 Obvladovanje napak

Trenutna težava naše implementacije je, da vrne zgolj uspešen odgovor, v primeru neuspešne zahteve pa ne vrne ničesar. Dobra praksa pri pripravi API-ja je obvladovanje in lovljenje napak, zato moramo poskrbeti tudi za to in odjemalcu vedno vrniti odgovor, tudi ko pride do napake. V našem primeru lahko pride do naslednjih napak:

  • metoda findById ne vrne nobene lokacije in
  • med izvajanjem metode findById pride do napake.

api/controllers/locations.js

const locationsReadOne = async (req, res) => {
  try {
    let location = await Location.findById(req.params.locationId)
      .select("-id")
      .exec();
    if (!location)
      res.status(404).json({
        message: `Location with id '${req.params.locationId}' not found`,
      });
    else res.status(200).json(location);
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
};

Sedaj lahko simuliramo obe napaki. Najprej iskanje dokumenta z enoličnim identifikatorjem, ki ne obstaja.

Opomba: Pri kreiranju zahteve z ukazom curl bomo uporabili parameter -i, ki v izpisu vrne tudi glavo odgovora strežnika, da lahko preverimo tudi šifro statusa.

$ curl -i http://localhost:3000/api/locations/735a62f5dc5d7968e68464c1

HTTP/1.1 404 Not Found
...
{"message":"Location with id '735a62f5dc5d7968e68464c1' not found."}

Nato pa še iskanje dokumenta z napačno obliko enoličnega identifikatorja, kar povzroči napako na strežniku.

$ curl -i http://localhost:3000/api/locations/1

HTTP/1.1 500 Internal Server Error
...
{"message":"Cast to ObjectId failed for value \"1\" (type string) at path \"_id\" for model \"Location\""}

6.4.2 Iskanje odvisnega dokumenta

Komentarji so v našem primeru shranjeni kot deli dokumenta lokacije (odvisni dokumenti), tako da moramo najprej poiskati lokacijo, nato pa v tem dokumentu poiskati ustrezen komentar. Pri implementaciji metode commentsReadOne v krmilniku api/controllers/comments.js si lahko pomagamo z implementacijo metode locationsReadOne iz prejšnjega poglavja 6.4.1. Ker ne potrebujemo vseh podatkov lokacije, lahko že v poizvedbi z uporabo .select("atribut1 atribut2 ...") omejimo, katere podatke potrebujemo. Prav tako je v okviru MongoDB na voljo metoda id, s pomočjo katere lahko iščemo odvisne dokumente glede na podan enolični identifikator.

api/controllers/comments.js

const commentsReadOne = async (req, res) => {
  try {
    let location = await Location.findById(req.params.locationId)
      .select("name comments")
      .exec();
    if (!location)
      res.status(404).json({
        message: `Location with id '${req.params.locationId}' not found`,
      });
    else if (!location.comments || location.comments.length == 0)
      res.status(404).json({ message: "No comments found." });
    else {
      let comment = location.comments.id(req.params.commentId);
      if (!comment)
        res.status(404).json({
          message: `Comment with id '${req.params.commentId}' not found.`,
        });
      else {
        res.status(200).json({
          location: {
            _id: req.params.locationId,
            name: location.name,
          },
          comment,
          status: "OK"
        });
      }
    }
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
};

Sedaj lahko preverimo delovanje metode tako, da najprej ugotovimo ID lokacije in ID komentarja iz MongoDB podatkovne baze pri lokaciji Celje - Celjski grad, kateri smo pri uvozu podatkov dodali komentarje.

$ docker exec -it web-dev-mongo-db bash -c "mongosh Demo --eval 'db.Locations.find({name: \"Celje - Celjski grad\"})'"
Connecting to:          mongodb://127.0.0.1:27017/Demo?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+1.6.0
Using MongoDB:          6.0.2
Using Mongosh:          1.6.0
[
  {
    _id: ObjectId("635a62f5dc5d7968e6846574"),
    name: 'Celje - Celjski grad',
    ...,
    comments: [
      {
        _id: ObjectId("635a62f5dc5d7968e68464be"),
        author: 'Dejan Lavbič',
        rating: 5,
        comment: 'Odlična lokacija, ne morem je prehvaliti.',
        createdOn: ISODate("2010-04-04T00:00:00.000Z")
      },
      {
        _id: ObjectId("635a62f5dc5d7968e68464bf"),
        author: 'Vladimir Putin',
        rating: 2,
        comment: 'Čisti dolgčas, še kava je zanič. Edina svetla točka je razstava mučilnih naprav.',
        createdOn: ISODate("2016-07-30T00:00:00.000Z")
      }
    ]
  }
]

Nato s pomočjo ID-ja dokumenta sestavimo zahtevo, ki nam vrne zahtevano lokacijo in komentar iz podatkovne baze preko REST API-ja.

$ curl -i http://localhost:3000/api/locations/635a62f5dc5d7968e6846574/comments/635a62f5dc5d7968e68464be

HTTP/1.1 200 OK
...
{
  "location": {
    "_id": "635a62f5dc5d7968e6846574",
    "name": "Celje - Celjski grad"
  },
  "comment": {
    "_id": "635a62f5dc5d7968e68464be",
    "author": "Dejan Lavbič",
    "rating": 5,
    "comment": "Odlična lokacija, ne morem je prehvaliti.",
    "createdOn": "2010-04-04T00:00:00.000Z"
  }
}

Preverimo lahko še napake, in sicer najprej če lokacija nima nobenega komentarja.

$ curl -i http://localhost:3000/api/locations/635a62f5dc5d7968e6846575/comments/1

HTTP/1.1 404 Not Found
...
{"message":"No comments found."}

Do napake pride tudi, če zahtevamo neobstoječ komentar pri obstoječi lokaciji.

$ curl -i http://localhost:3000/api/locations/635a62f5dc5d7968e6846574/comments/1

HTTP/1.1 404 Not Found
...
{"message":"Comment with id '1' not found."}

Naslednja napaka je zahteva po komentarju neobstoječe lokacije.

$ curl -i http://localhost:3000/api/locations/73512fc5022f6bd9e0dfe53e/comments/2

HTTP/1.1 404 Not Found
...
{"message":"Location with id '73512fc5022f6bd9e0dfe53e' not found."}

Še zadnja možna napaka pa je napaka na strežniku, ko podamo napačno obliko enoličnega identifikatorja.

$ curl -i http://localhost:3000/api/locations/1/comments/2

HTTP/1.1 500 Internal Server Error
...
{"message":"Cast to ObjectId failed for value \"1\" (type string) at path \"_id\" for model \"Location\""}

6.4.3 Geografsko iskanje več dokumentov

Spletna stran aplikacije naj bi prikazala seznam zanimivih lokacij, glede na trenutni položaj uporabnika. MongoDB ima na voljo nekaj geografskih funkcij iskanja, ki nam bodo to opravilo olajšale.

Pri zahtevi za prikaz lokacij bomo imeli naslednje parametre:

  • obvezni parameter lng predstavlja zemljepisno dolžino (angl. longitude),
  • obvezni parameter lat predstavlja zemljepisno širino (angl. latitude) in
  • opcijski parameter maxDistance predstavlja omejitev iskanja, do katere največje razdalje (v km) od podane točke naj vrača rezultate (privzeta vrednost je 5),
  • opcijski parameter nResults predstavlja omejitev iskanja, in sicer zgornjo mejo števila zadetkov (privzeta vrednost je 10).

Celotna implementacija funkcije za prikaz najbližjih lokacij locationsListByDistance je prikazana v nadaljevanju. Uporabili smo cevovod združevanja (angl. aggregation pipelines), kjer lahko nad podatkovno bazo MongoDB v zaporedju izvajamo željene operacije. V našem primeru bo to:

  • $geoNear, ki vrača vse dokumente z GPS koordinatami v željenem radiju od podane koordinate,
  • $project omogoča izbiro ali odstranjevanju atributov iz rezultata in
  • $limit, ki omejuje največje število zadetkov.

Rezultat poizvedba predstavlja vse dokumente, ki ustrezajo podanim pogojem, kjer ima vsak dokument dodan atribut distance z izračunano razdaljo od točke v parametru do posamezne lokacije.

api/controllers/locations.js

const locationsListByDistance = async (req, res) => {
  let lng = parseFloat(req.query.lng);
  let lat = parseFloat(req.query.lat);
  let distance = parseFloat(req.query.maxDistance);
  distance = 1000 * (isNaN(distance) ? 5 : distance);
  let nResults = parseInt(req.query.nResults);
  nResults = isNaN(nResults) ? 10 : nResults;
  if (!lng || !lat)
    res
      .status(400)
      .json({ message: "Query parameters 'lng' and 'lat' are required." });
  else {
    try {
      let locations = await Location.aggregate([
        {
          $geoNear: {
            near: {
              type: "Point",
              coordinates: [lng, lat],
            },
            distanceField: "distance",
            spherical: true,
            maxDistance: distance,
          },
        },
        { $project: { comments: false, id: false } },
        { $limit: nResults },
      ]);
      if (!locations || locations.length == 0)
        res.status(404).json({ message: "No locations found." });
      else res.status(200).json(locations { status: "OK" });
    } catch (err) {
      res.status(500).json({ message: err.message });
    }
  }
};

Sedaj lahko preverimo delovanje metode s testno zahtevo, kjer kot začetno točko podamo GPS koordinate lokacije UL FRI in zahtevamo največ 2 lokaciji v radiju 5 km.

Opomba: Pri izračunu oddaljenosti točk se upošteva zračna razdalja.

$ curl -i http://localhost:3000/api/locations/distance?lng=14.469027&lat=46.050129&maxDistance=5&nResults=2

HTTP/1.1 200 OK
...
[
  {
    "_id": "635a62f5dc5d7968e68467e3",
    "name": "Ljubljana - Cankarjeva spominska soba na Rožniku",
    ...
    "distance": 858.0692052049162,
  },
  {
    "_id": "635a62f5dc5d7968e6846914",
    "name": "Ljubljana - Viško pokopališče",
    ...
    "distance": 1158.460509757077,
  },
]

Kot odgovor dobimo pričakovana 2 zadetka, Cankarjeva spominska soba na Rožniku na razdalji ~ 858 m in Viško pokopališče na razdalji ~ 1.158 m.

Če ponovimo iskanje v okolici UL FRI, ampak tokrat v radiju 500 m, nam sistem vrne napako, saj takšnih lokacij ni.

$ curl -i http://localhost:3000/api/locations/distance?lng=14.469027&lat=46.050129&maxDistance=0.5

HTTP/1.1 404 Not Found
...
{"message":"No locations found."}

Če manjkata edina obvezna parametra GPS koordinat v obliki lng in lat, sistem ravno tako vrne napako.

$ curl -i http://localhost:3000/api/locations/distance

HTTP/1.1 400 Bad Request
...
{"message":"Query parameters 'lng' and 'lat' are required"}

Prav tako pa lahko pride do napake na strežniku, če podamo neustrezne parametre.

$ curl -i http://localhost:3000/api/locations/distance?lng=200&lat=300

HTTP/1.1 500 Internal Server Error
...
{"message":"geo near accepts just one argument when querying for a GeoJSON point. Extra field found: $maxDistance: 5000.0"}

6.4.4 Napredno iskanje

Poleg geografskega iskanja, ki smo ga uvedli v prejšnjem poglavju 6.4.3, bomo dodali še možnost naprednega iskanja po različnih vrednostih šifrantov. Lokacije, ki so shranjene v podatkovni bazi, so opisane s številnimi atributi; v okviru te funkcionalnosti pa bomo podprli iskanje po naslednjih atributih: category, type, keywords, institution, municipality in fields ter nResults, ki omeji zgornjo število zadetkov. Vsi parametri so opcijski. Če ne podamo nobene omejitve, sistem vrne prvih 10 lokacij (privzeta vrednost nResults je 10).

api/controllers/locations.js

const mongoose = require("mongoose");
const Location = mongoose.model("Location");
const allowedCodelists = [
  "category",
  "type",
  "keywords",
  "institution",
  "municipality",
  "fields",
];
...
const locationsListByMultiFilter = async (req, res) => {
  let filter = [];
  // Exclude fields
  filter.push({ $project: { comments: false, id: false } });
  // Filter by codelist if provided
  allowedCodelists.forEach((codelist) => {
    let value = req.query[codelist];
    if (value) filter.push({ $match: { [codelist]: value } });
  });
  // Maximum number of results
  let nResults = parseInt(req.query.nResults);
  nResults = isNaN(nResults) ? 10 : nResults;
  filter.push({ $limit: nResults });
  // Perform database search and return results
  try {
    let locations = await Location.aggregate(filter).exec();
    if (!locations || locations.length === 0)
      res.status(404).json({ message: "No locations found." });
    else res.status(200).json(locations { status: "OK" });
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
};

Pri preverjanju delovanja naprednega iskanja si lahko zamislimo scenarij, kjer nas zanimajo lokacije, ki so kategorizirane (category) kot parki, tipa (type) vrtnoarhitekturna dediščina, vsebujejo ključno besedo (keywords) parki in spadajo v sklop (fields) krajinska arhitektura. Pri rezultatih se omejimo (nResults) zgolj na 2 zadetka.

Opomba: Pri testiranju spletnih zahtev je treba parametre spletnega naslova ustrezno kodirati, za kar bi v aplikaciji poskrbeli programsko s pomočjo podpornih knjižnic, v našem primeru ročnega testiranja z orodjem curl pa si lahko pomagamo s spletnim orodjem URL Encode.

$ curl http://localhost:3000/api/locations/search?category=parki%20in%20vrtovi&type=vrtnoarhitekturna%20dedi%C5%A1%C4%8Dina&keywords=grad&fields=krajinska%20arhitektura&nResults=2
[
  {
    "_id": "635a62f5dc5d7968e68465a0",
    "name": "Dolane - Park gradu Borl",
    "category": "parki in vrtovi",
    "type": "vrtnoarhitekturna dediščina",
    "keywords": ["park", "vrt", "drevored", "grad"],
    "description": "Danes le še delno ohranjena parterna zasnova, do katere je vodil dvostranski drevored. Zaradi pomanjkanja prostora je bila odmaknjena od gradu. Ob gradu je bil urejen manjši vrt.",
    "location": "Južno in okoli gradu Borl, ob vznožju strmega griča ob Dravi, ob lokalni cesti Dolane - Zavrč.",
    "institution": "ZVKD Maribor",
    "heritage": "neznano",
    "municipality": "CIRKULANE",
    "coordinates": [16.000902598, 46.3722801631],
    "datation": "17. stol.",
    "fields": ["krajinska arhitektura"],
  },
  {
    "_id": "635a62f5dc5d7968e6846a32",
    "name": "Otočec - Grajski park",
    "category": "parki in vrtovi",
    "type": "vrtnoarhitekturna dediščina",
    "keywords": ["park", "grad"],
    "description": "Park obdaja grad Otočec na otoku reke Krke. Parkovna zasnova se je prilagajala umetni širitvi otoka. Formalne zasnove iz preteklosti se niso ohranile, tako da je park sedaj urejen v duhu krajinskega sloga.",
    "location": "Park leži na otoku sredi Krke, okoli gradu Otočec pod magistralno cesto Ljubljana-Obrežje. Park seka lokalna cesta.",
    "institution": "ZVKD Novo mesto",
    "heritage": "spomenik državnega pomena",
    "municipality": "NOVO MESTO",
    "coordinates": [15.2361221585, 45.8380219125],
    "synonyms": ["Park gradu Otočec"],
    "datation": "19. stol., 20. stol.",
    "fields": ["krajinska arhitektura"],
  },
]

6.4.5 Seznami šifrantov

Za potrebe naprednega iskanja, predstavljenega v prejšnjem poglavju 6.4.4 bomo implementirali metodo krmilnika locationsListCodelist, ki bo vračala vrednosti izbranih šifrantov s katerimi lahko izvajamo napredno iskanje.

api/controllers/locations.js

const locationsListCodelist = async (req, res) => {
  let codelist = req.params.codelist;
  if (!allowedCodelists.includes(codelist))
    res.status(400).json({
      message: `Parameter 'codelist' must be one of: ${allowedCodelists.join(
        ", "
      )}`,
    });
  else {
    try {
      let codeListValues = await Location.distinct(codelist).exec();
      if (!codeListValues || codeListValues.length === 0)
        res
          .status(404)
          .json({ message: `No codelist found for '${codelist}.'` });
      else res.status(200).json(codeListValues { status: "OK" });
    } catch (err) {
      res.status(500).json({ message: err.message });
    }
  }
};

S pomočjo parametra zahteve codelist, ki sprejme vrednosti category, type, keywords, institution, municipality in fields, lahko zahtevamo vrednosti željenih šifrantov, kot to prikazuje spodnji primer za category.

$ curl http://localhost:3000/api/locations/codelist/category
[
  "arheološka najdišča",
  "drugi objekti in naprave",
  "kulturna krajina",
  "naselja in njihovi deli",
  "ostalo",
  "parki in vrtovi",
  "spominski objekti in kraji",
  "stavbe",
  "stavbe s parki ali z vrtovi"
]

Naloge iz vsebinskega sklopa MongoDB

Interaktivno reševanje praktičnih problemov, kjer izberete enega izmed možnih odgovorov. Po pravilno rešeni nalogi lahko nadaljujete na naslednjo, sproti pa se beleži napredek, kjer se upošteva koliko poskusov v povprečju ste potrebovali za pravilni odgovor.

Dodatno gradivo

  • Getting MEAN with Mongo, Express, Angular, and Node, 2nd edition (Holmes and Herber 2019)
    • Part 2: Building a Node web application
      • Chapter 5: Building a data model with MongoDB and Mongoose
      • Chapter 6: Writing a REST API: Exposing the MongoDB database to the application

  1. angl. connection string↩︎

  2. angl. Object-Document Modeler (ODM)↩︎

  3. angl. document↩︎

  4. angl. collection↩︎

  5. angl. schema↩︎

  6. angl. path↩︎

  7. https://docs.mongodb.com/manual/reference/method/Date/↩︎

  8. angl. latitude↩︎

  9. angl. longitude↩︎

  10. angl. subdocument↩︎

  11. angl. Create, Read, Update, Delete↩︎

  12. angl. REpresentational State Transfer↩︎

  13. angl. Application Program(ming) Interface↩︎