Spring Boot i baza podataka: Spring Data JPA

Lekcija 3 od 3 · Spring Boot

Podaci iz prethodne lekcije nestaju kada se server ugasi. Ovde se čuvaju u bazi podataka. Spring Data JPA pretvara Java klase u tabele i sam piše SQL upite – dovoljno je napisati interfejs. Na kraju je objašnjeno kako se aplikacija pakuje i postavlja na internet.

Pre nego što počneš: potrebno predznanje – REST API, isti pojmovi u Django-u (model, migracije).
Kako da učiš: radi u svom projektu korak po korak i posle svakog koraka pokreni server i proveri rezultat u pregledaču. Primeri su za Spring Boot 3 ili 4 i Javu 17+; svaki deo koda upiši u fajl naveden u komentaru na vrhu primera.

Dodavanje biblioteka

Na start.spring.io (ili u IntelliJ-u: desni klik na pom.xml → Add Dependency) dodaju se još dve zavisnosti:

ZavisnostŠta daje
Spring Data JPArad sa bazom preko Java klasa (Hibernate)
H2 Databasemala baza napisana u Javi; čuva se u fajlu u fascikli projekta – ne instalira se ništa

Kasnije se H2 lako zamenjuje bazom PostgreSQL ili MySQL – menjaju se samo zavisnost i podešavanja, kod ostaje isti.

# src/main/resources/application.properties
spring.application.name=zadaci
# baza u fajlu ./data/zadaci.mv.db (pravi se sama)
spring.datasource.url=jdbc:h2:file:./data/zadaci
spring.datasource.username=sa
spring.datasource.password=
# Hibernate pravi i menja tabele prema klasama
spring.jpa.hibernate.ddl-auto=update
# u konzoli se ispisuju SQL upiti – korisno za učenje
spring.jpa.show-sql=true

Entitet: klasa koja postaje tabela

Klasa Zadatak dobija anotaciju @Entity. Polje sa @Id je primarni ključ, a @GeneratedValue znači da baza sama dodeljuje brojeve. Geteri, seteri i prazan konstruktor ostaju kao u prethodnoj lekciji:

// src/main/java/rs/svetprogramiranja/zadaci/Zadatak.java
package rs.svetprogramiranja.zadaci;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.LocalDate;

@Entity                                    // tabela ZADATAK
public class Zadatak {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String naslov;
    private String predmet;
    private LocalDate rok;
    private boolean uradjen;

    public Zadatak() { }

    public Zadatak(String naslov, String predmet, LocalDate rok) {
        this.naslov = naslov;
        this.predmet = predmet;
        this.rok = rok;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getNaslov() { return naslov; }
    public void setNaslov(String naslov) { this.naslov = naslov; }
    public String getPredmet() { return predmet; }
    public void setPredmet(String predmet) { this.predmet = predmet; }
    public LocalDate getRok() { return rok; }
    public void setRok(LocalDate rok) { this.rok = rok; }
    public boolean isUradjen() { return uradjen; }
    public void setUradjen(boolean uradjen) { this.uradjen = uradjen; }
}

Pri pokretanju Hibernate pravi tabelu (u konzoli se vidi create table zadatak (...)). Svaki objekat je jedan red tabele, a svako polje jedna kolona.

Repozitorijum: interfejs bez koda

Za pristup tabeli dovoljno je napisati interfejs koji nasleđuje JpaRepository<Zadatak, Long> (tip entiteta i tip ključa). Spring pri pokretanju sam pravi klasu koja ga ostvaruje. Dobijaju se gotove metode findAll, findById, save, deleteById, count…, a nove se dodaju samo imenom metode:

// src/main/java/rs/svetprogramiranja/zadaci/ZadatakRepository.java
package rs.svetprogramiranja.zadaci;

import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ZadatakRepository extends JpaRepository<Zadatak, Long> {

    // Spring iz imena metode pravi upit:
    // SELECT * FROM zadatak WHERE uradjen = false ORDER BY rok
    List<Zadatak> findByUradjenFalseOrderByRokAsc();

    // WHERE upper(predmet) = upper(?)
    List<Zadatak> findByPredmetIgnoreCase(String predmet);

    // WHERE upper(naslov) LIKE upper('%' || ? || '%')
    List<Zadatak> findByNaslovContainingIgnoreCase(String deo);

    // WHERE rok < ? AND uradjen = false
    long countByRokBeforeAndUradjenFalse(LocalDate datum);
}
Deo imenaZnačenje u upitu
findBy… / countBy…SELECT / SELECT COUNT
…And…, …Or…više uslova
Containing, StartingWithLIKE '%x%', LIKE 'x%'
Before, After, GreaterThan<, >
IgnoreCasebez obzira na velika i mala slova
OrderBy…Asc/DescORDER BY

Servis koji koristi bazu

Servis sada umesto mape koristi repozitorijum. Kontroler iz prethodne lekcije ostaje potpuno isti – to je korist od podele na slojeve.

// src/main/java/rs/svetprogramiranja/zadaci/ZadatakService.java
package rs.svetprogramiranja.zadaci;

import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

@Service
public class ZadatakService {
    private final ZadatakRepository repo;

    public ZadatakService(ZadatakRepository repo) {
        this.repo = repo;
    }

    public List<Zadatak> svi() {
        return repo.findAll(Sort.by("rok"));           // SELECT ... ORDER BY rok
    }

    public Optional<Zadatak> nadji(long id) {
        return repo.findById(id);
    }

    public Zadatak dodaj(Zadatak z) {
        z.setId(null);                                 // id dodeljuje baza
        return repo.save(z);                           // INSERT
    }

    public Optional<Zadatak> izmeni(long id, Zadatak novi) {
        return repo.findById(id).map(z -> {
            z.setNaslov(novi.getNaslov());
            z.setPredmet(novi.getPredmet());
            z.setRok(novi.getRok());
            z.setUradjen(novi.isUradjen());
            return repo.save(z);                       // UPDATE
        });
    }

    public boolean obrisi(long id) {
        if (!repo.existsById(id)) return false;
        repo.deleteById(id);                           // DELETE
        return true;
    }

    public List<Zadatak> pretraga(String deo) {
        return repo.findByNaslovContainingIgnoreCase(deo);
    }
}

Pokreni server, dodaj nekoliko zadataka, zaustavi server i pokreni ga ponovo – zadaci su i dalje tu. U fascikli projekta pojavio se fajl data/zadaci.mv.db.

Početni podaci i pretraga

Klasa koja ostvaruje CommandLineRunner i označena je sa @Component izvršava se jednom, posle pokretanja – zgodno za ubacivanje primera u praznu bazu:

// src/main/java/rs/svetprogramiranja/zadaci/PocetniPodaci.java
package rs.svetprogramiranja.zadaci;

import java.time.LocalDate;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class PocetniPodaci implements CommandLineRunner {
    private final ZadatakRepository repo;

    public PocetniPodaci(ZadatakRepository repo) {
        this.repo = repo;
    }

    @Override
    public void run(String... args) {
        if (repo.count() == 0) {                       // samo kada je baza prazna
            repo.save(new Zadatak("Uraditi zadatke 1-5", "Matematika", LocalDate.now().plusDays(2)));
            repo.save(new Zadatak("Procitati lektiru", "Srpski", LocalDate.now().plusDays(7)));
            repo.save(new Zadatak("Nauciti Njutnove zakone", "Fizika", LocalDate.now().plusDays(4)));
        }
    }
}

Pretraga kroz API: u kontroler se dodaje metoda koja poziva servis.pretraga:

// dodatak u klasi ZadatakController
// import org.springframework.web.bind.annotation.RequestParam;

    // http://localhost:8080/api/zadaci/pretraga?q=lekt
    @GetMapping("/pretraga")
    public List<Zadatak> pretraga(@RequestParam String q) {
        return servis.pretraga(q);
    }

Pakovanje i postavljanje na internet

Cela aplikacija pakuje se u jedan .jar fajl, koji se pokreće na bilo kom računaru sa Javom:

mvnw.cmd clean package
java -jar target\zadaci-0.0.1-SNAPSHOT.jar

Za postavljanje na internet koriste se servisi koji pokreću Java aplikacije ili Docker kontejnere (npr. Render, Railway, Fly.io). Najčešće je dovoljan ovakav Dockerfile u korenu projekta:

# Dockerfile – prvo se aplikacija prevodi, pa se samo .jar prenosi u manju sliku
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN chmod +x mvnw && ./mvnw -q clean package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Na takvim servisima disk se obično briše pri svakom novom postavljanju, pa H2 baza u fajlu nije dobra za pravu upotrebu – tada se koristi PostgreSQL baza koju servis nudi, a podešavanja se zadaju promenljivim okruženja (SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD). Isti postupak sa Python-om i Django-om opisan je u lekciji Postavljanje na internet.

Zadaci za vežbu

Za svaki zadatak postoji primer, uputstvo i rešenje.

ZADATAK 01Težina: teže

Predmeti i zadaci

Dodaj entitet Predmet (polja: id, naziv, nastavnik) sa repozitorijumom, i povezivanje: svaki zadatak pripada jednom predmetu (veza više-prema-jedan). Dodaj rutu GET /api/predmeti/{id}/zadaci koja vraća zadatke tog predmeta, sortirane po roku.

Česte greške i saveti
  • Ako se promeni klasa entiteta, ddl-auto=update dodaje nove kolone, ali ne briše stare; za čist početak obriši fajl baze u fascikli data.
  • Ime metode u repozitorijumu mora tačno odgovarati imenima polja (findByNaslov… za polje naslov), inače aplikacija neće ni da se pokrene.
  • Lozinke za bazu ne upisuj u kod koji objavljuješ (GitHub) – zadaj ih kroz promenljive okruženja.
  • Za prave aplikacije sa nalozima korisnika koristi se Spring Security – ne pravi sopstveno čuvanje lozinki.

Proveri znanje

Otvori kviz

Spring Data JPA

1. Šta označava anotacija @Entity?




2. Ko piše klasu koja ostvaruje interfejs ZadatakRepository?




3. Koju operaciju izvršava repo.save(z) za nov objekat?




4. Zašto se kontroler nije menjao kada je dodata baza?