Database med Javalin
I vores webapplikation bruger vi PostgreSQL som database.
Java kommunikerer med databasen gennem JDBC.
Vi bruger HikariCP som connection pool, så applikationen kan genbruge databaseforbindelser.
Når applikationen er bygget op med services og mappere, ser flowet typisk sådan ud:
Controller
↓
Service
↓
Mapper
↓
Connection pool
↓
PostgreSQL
Når projektet skal bruge PostgreSQL og HikariCP, skal de tilføjes til pom.xml.
PostgreSQL-driveren gør det muligt for Java at kommunikere med PostgreSQL.
HikariCP håndterer connection poolen.
Brug de versionsnumre, der er angivet i semesterets setup/dependency-side:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>VERSION</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>VERSION</version>
</dependency>
Efter ændringer i pom.xml skal Maven hente dependencies.
En databaseforbindelse er en ressource, som skal håndteres ordentligt.
I stedet for at oprette en helt ny forbindelse hver gang vi skal udføre en SQL-kommando, bruger vi en connection pool.
Connection poolen holder et antal forbindelser klar, som kan lånes og afleveres igen.
I vores setup bruger vi HikariCP.
Et enkelt eksempel på konfiguration:
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydatabase");
config.setUsername("postgres");
config.setPassword("postgres");
HikariDataSource dataSource = new HikariDataSource(config);
I den færdige applikation samler vi denne konfiguration i en ConnectionPool-klasse.
Når en mapper skal tale med databasen, henter den en forbindelse fra connection poolen:
try (Connection connection = connectionPool.getConnection()) {
// brug connection
}
try-with-resources sørger for, at forbindelsen bliver lukket igen.
Det betyder ikke, at hele connection poolen nødvendigvis lukkes. Connectionen afleveres tilbage til poolen.
En connection pool kan eksempelvis være samlet i en klasse:
public class ConnectionPool {
private static HikariDataSource dataSource;
public static ConnectionPool getInstance(
String user,
String password,
String url,
String db) {
// initialisering af pool
return new ConnectionPool();
}
public Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
}
Vores konkrete ConnectionPool indeholder mere konfiguration.
Det vigtige for mapperen er, at den kan gøre:
Connection connection = connectionPool.getConnection();
Et typisk setup kan se sådan ud:
private static final String USER = "postgres";
private static final String PASSWORD = "postgres";
private static final String URL =
"jdbc:postgresql://localhost:5432/%s?currentSchema=public";
private static final String DB = "mydatabase";
private static final ConnectionPool connectionPool =
ConnectionPool.getInstance(USER, PASSWORD, URL, DB);
Her er:
localhost
den computer, hvor PostgreSQL kører.
5432
er PostgreSQLs port.
mydatabase
er navnet på databasen.
En mapper har ansvaret for at oversætte mellem Java-objekter og database-data.
For eksempel:
User object
↕
UserMapper
↕
users table
En mapper kan indeholde metoder som:
getAllUsers()
getUserById(int id)
createUser(User user)
updateUser(User user)
deleteUser(int id)
Controlleren bør ikke indeholde SQL.
SQL hører hjemme i mapperen.
En simpel SQL-query:
SELECT *
FROM users;
I Java:
String sql = "SELECT * FROM users";
Opret en connection:
try (Connection connection = connectionPool.getConnection()) {
}
Opret en PreparedStatement:
try (PreparedStatement ps = connection.prepareStatement(sql)) {
}
Kør query’en:
ResultSet rs = ps.executeQuery();
ResultSet indeholder de rækker, som databasen returnerede.
Vi kan gå gennem rækkerne med rs.next():
while (rs.next()) {
String username = rs.getString("username");
}
Hvis kolonnen er et heltal:
int id = rs.getInt("id");
Hvis kolonnen er en dato:
LocalDate date = rs.getObject("date", LocalDate.class);
Mapperen kan omdanne en database-række til et Java-objekt:
while (rs.next()) {
String username = rs.getString("username");
String password = rs.getString("password");
User user = new User(username, password);
}
Hvis vi har flere rækker, samler vi objekterne i en liste:
List<User> users = new ArrayList<>();
while (rs.next()) {
String username = rs.getString("username");
String password = rs.getString("password");
User user = new User(username, password);
users.add(user);
}
public List<User> getAllUsers(ConnectionPool connectionPool) {
List<User> users = new ArrayList<>();
String sql = """
SELECT username, password
FROM users
""";
try (Connection connection = connectionPool.getConnection();
PreparedStatement ps = connection.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String username = rs.getString("username");
String password = rs.getString("password");
User user = new User(username, password);
users.add(user);
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return users;
}
I jeres egentlige projekt bør databasefejl håndteres med den exception-struktur, I har valgt.
Hvis SQL’en indeholder værdier fra brugeren, skal vi ikke bygge SQL’en ved at sætte strings sammen.
Undgå:
String sql =
"SELECT * FROM users WHERE username = '" + username + "'";
Brug i stedet en parameter:
String sql =
"SELECT * FROM users WHERE username = ?";
Sæt parameteren:
PreparedStatement ps =
connection.prepareStatement(sql);
ps.setString(1, username);
Kør query’en:
ResultSet rs = ps.executeQuery();
? er en placeholder.
Tallet 1 betyder den første parameter.
public User getUserByUsername(
String username,
ConnectionPool connectionPool) {
String sql = """
SELECT username, password
FROM users
WHERE username = ?
""";
try (Connection connection = connectionPool.getConnection();
PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, username);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
String password = rs.getString("password");
return new User(username, password);
}
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return null;
}
Når vi indsætter data:
INSERT INTO users (username, password)
VALUES (?, ?);
I Java:
String sql = """
INSERT INTO users (username, password)
VALUES (?, ?)
""";
Sæt værdierne:
ps.setString(1, user.getUsername());
ps.setString(2, user.getPassword());
Kør:
ps.executeUpdate();
executeUpdate() bruges til SQL, der ændrer data.
public void createUser(
User user,
ConnectionPool connectionPool) {
String sql = """
INSERT INTO users (username, password)
VALUES (?, ?)
""";
try (Connection connection = connectionPool.getConnection();
PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, user.getUsername());
ps.setString(2, user.getPassword());
ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
Et eksempel:
UPDATE users
SET password = ?
WHERE username = ?;
Java:
String sql = """
UPDATE users
SET password = ?
WHERE username = ?
""";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, newPassword);
ps.setString(2, username);
ps.executeUpdate();
}
Husk WHERE.
Uden WHERE risikerer du at ændre alle rækker.
Et eksempel:
DELETE FROM users
WHERE username = ?;
Java:
String sql = """
DELETE FROM users
WHERE username = ?
""";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, username);
ps.executeUpdate();
}
Igen: vær meget opmærksom på WHERE.
Brug:
executeQuery()
når du forventer et ResultSet.
Typisk:
SELECT
Brug:
executeUpdate()
når du ændrer databasen.
Typisk:
INSERT
UPDATE
DELETE
Hvis databasen selv genererer et ID, kan vi bede JDBC om det genererede ID.
SQL:
INSERT INTO users (username, password)
VALUES (?, ?);
Java:
PreparedStatement ps =
connection.prepareStatement(
sql,
Statement.RETURN_GENERATED_KEYS);
Efter executeUpdate():
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) {
int id = keys.getInt(1);
}
}
Det er nyttigt, når Java-objektet skal kende det ID, databasen har tildelt.
Mapperen arbejder med databasen.
Servicen arbejder med programmets logik.
Eksempel:
public class UserService {
private UserMapper userMapper;
public User login(String username, String password) {
User user = userMapper.getUserByUsername(username);
if (user != null &&
user.getPassword().equals(password)) {
return user;
}
return null;
}
}
Mapperen:
public User getUserByUsername(String username) {
// SQL
}
Controlleren:
public static void login(Context ctx) {
String username = ctx.formParam("username");
String password = ctx.formParam("password");
User user = userService.login(username, password);
// HTTP response
}
Dermed bliver ansvaret fordelt:
Controller
HTTP
↓
Service
forretningslogik
↓
Mapper
SQL
↓
Database
Databaseoperationer kan fejle.
JDBC-metoder kan blandt andet kaste SQLException.
Eksempel:
try {
// databasekode
} catch (SQLException e) {
// håndter databasefejl
}
En databasefejl er ikke nødvendigvis brugerens fejl.
Hvis databasen ikke svarer, skal brugeren ikke have en besked som:
Du har skrevet forkert password.
Programmet skal i stedet håndtere den uventede fejl korrekt.
I større projekter kan man bruge en egen DatabaseException:
catch (SQLException e) {
throw new DatabaseException(
e,
"Could not get users from database");
}
Det gør det muligt at skelne mellem databasefejl og andre typer fejl.
Brug try-with-resources:
try (Connection connection = connectionPool.getConnection()) {
// databasearbejde
}
Det samme gælder PreparedStatement og ResultSet:
try (Connection connection = connectionPool.getConnection();
PreparedStatement ps = connection.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
// brug rs
}
Det er en god vane at lade Java håndtere lukningen automatisk.
Mapperen kan selvfølgelig også bruge JOINs.
SELECT users.username, tasks.title
FROM users
JOIN tasks
ON users.id = tasks.user_id;
Resultatet kan læses fra ResultSet:
String username = rs.getString("username");
String title = rs.getString("title");
Hvis query’en bliver mere kompleks, kan det være relevant at mappe resultatet til en DTO i stedet for direkte til en entity.
En database kan indeholde views.
Et view kan samle en kompleks query:
CREATE VIEW user_tasks AS
SELECT users.username, tasks.title
FROM users
JOIN tasks
ON users.id = tasks.user_id;
Mapperen kan derefter læse fra viewet:
SELECT *
FROM user_tasks;
Views er stadig en del af databasen. Java behøver ikke kende den underliggende kompleksitet.
Databasekode er typisk integrationstest-stof.
En integrationstest kan for eksempel kontrollere, at:
Java
↓
Mapper
↓
PostgreSQL
faktisk fungerer.
En unit test af UserService behøver derimod ikke nødvendigvis kontakte databasen.
Her kan vi bruge dependency injection og give servicen en test-double for mapperen.
Hvis gruppen arbejder med en lokal PostgreSQL-database, kan databasen deles som en backup.
I pgAdmin:
- Højreklik på databasen.
- Vælg
Backup.... - Vælg et filnavn.
- Brug et passende backup-format.
- Sørg for at både struktur og data er med.
- Gem backup-filen.
- Del filen med gruppen.
For at gendanne:
- Opret databasen.
- Højreklik på databasen.
- Vælg
Restore.... - Vælg backup-filen.
- Gendan databasen.
- Refresh databasen i pgAdmin.
| Jeg vil… | Jeg bruger… |
|---|---|
| Få en databaseforbindelse | connectionPool.getConnection() |
Køre SELECT | executeQuery() |
Køre INSERT | executeUpdate() |
Køre UPDATE | executeUpdate() |
Køre DELETE | executeUpdate() |
| Sende en værdi til SQL | PreparedStatement |
| Læse en række | ResultSet |
| Læse tekst | rs.getString() |
| Læse et heltal | rs.getInt() |
| Gå til næste række | rs.next() |
| Mappe database → Java | Mapper |
| Placere forretningslogik | Service |
| Kommunikere med HTTP | Controller |
Browser
↓
Controller
↓
Service
↓
Mapper
↓
ConnectionPool
↓
PostgreSQL
↓
ResultSet
↓
Entity / DTO
↓
Service
↓
Controller
↓
Thymeleaf
↓
Browser
Mapperen skal kende SQL.
Servicen skal kende programmets regler.
Controlleren skal kende HTTP.
Databasen skal gemme data.