DEVJOURNAL #1.0: DB BACKUP&RESTORE WEBTOOL IN JAVA

In dieser ersten Beitragsreihe möchte ich ein Webtool implementieren, dass von verschiedenen Datenbanken regelmäßig Backups ziehen kann und diese Backup-Dumps auch wieder in die jeweilige Datenbank einspielen kann. Die Beitragsreihe wird insbesondere folgende Aspekte professioneller Softwareentwicklung beleuchten:
- Design und Implementierung von (Web)Anwendungen,
- Clean Code, Refactorings und Redesigns nach objektorientierten Maßstäben,
- neue oder sich ändernde Anforderungen,
- Auslieferung und Betrieb in der Cloud nach DevOps-Methoden und Best-Practices.
Insbesondere ist es das Ziel, die zahlreichen Designentscheidungen und deren Konsequenzen hervorzuheben, die im Laufe eines Softwareprojektes (oft auch unbewusst) getroffen werden.
Inhalt
Überblick
Folgende Anforderungen soll die Anwendung zunächst erfüllen:
- Der Benutzer kann einen Zeitplan anlegen, wie häufig er einen Dump einer bestimmten Datenbank erstellen möchte
- Der Benutzer kann zwischen verschiedenen Datenbankimplementierungen auswählen, also z.B. PostgreSQL, MongoDB, MySQL, …
- Der Benutzer kann sich alle erzeugten Dumps anzeigen lassen und durch Auswahl eines Dumps diesen wieder in die Datenbank einspielen lassen.
Ein paar Rahmenbedingungen sind gesetzt: Java ist Programmiersprache, das Ganze wird als eine Webanwendung implementiert unter Benutzung des Spring Boot-Frameworks und des Web MVC Patterns. Das User Interface wird zunächst serverseitig mithilfe des Frameworks Thymeleaf gerendert.
Initialisieren des Spring Boot Projektes
Wir legen uns zunächst ein Spring Boot Projekt an. Dafür kann die Website https://start.spring.io/ verwendet werden. Folgende Konfiguration verwenden wir für unser Spring Boot Projekt:

Mit Klick auf „Generate“ und anschließendem Import in unsere IDE können wir mit der Entwicklung unseres Datenbank Backup&Restore-Tools beginnen!
Unsere ersten „Stories“ sind:
- Auflisten aller vorhandenen Backup-Pläne
- Anlegen & Persistieren neuer Backup-Pläne
- Anlage eines neuen Backup-Plans erzeugt automatisch in einem festgelegten Intervall Backup-Dumps
Story #1: Auflisten von Backupplänen
Wir arbeiten test-getrieben, und überlegen uns einmal, was genau unser BackupPlan an Informationen enthalten soll. Wir benötigen im WebMVC-Pattern drei Komponenten: einen Controller, ein Model und einen View. Beginnen wir mit dem Model.
Design
Bevor wir mit der eigentlichen Implementierung beginnen, nehmen wir uns etwas Zeit, um die Quelltextstruktur zu modellieren. Zwei Diagramme erzeugen wir für unseren ersten Use Case: ein Sequenzdiagramm, um die Kommunikation zwischen den Objekten zu zeigen und ein Klassendiagramm, um die statische Quelltextstruktur zu zeigen.

Der Benutzer ruft die Zieladresse der Anwendung auf und der Request wird dem BackupPlanController zugespielt. Dieser ruft den BackupPlanService auf und fragt mit findAll() eine Sammlung aller BackupPläne ab. Der Service gibt die Sammlung heraus, der Controller befüllt damit das Template und die Thymeleaf-Engine spielt das entsprechende HTML-Dokument aus. Natürlich ist die Struktur hier noch überschaubar. Das Klassendiagramm dazu sieht folgendermaßen aus:

Das Objekt BackupPlan kapselt alle Informationen, wie und wann ein Dump erstellt werden soll. Dafür benötigen wir zunächst:
- Die Verbindungsdetails der Datenbank, gegen die sich verbunden werden soll
- Das Zielverzeichnis, in dem der Dump abgelegt werden soll
- Das Intervall, wie oft ein Dump erzeugt werden soll
- Ein Name (oder anderer Identifier)
Hier treffen wir die erste Designentscheidung: wir verzichten auf DTOs, um Codeduplikationen und Transformationslogik zu vermeiden. Das hält den Quelltext einfach und hilft uns, schnell einen ersten Entwurf zu erstellen.
Das Design ist nicht spektakulär: es ist eine Implementierung des Web MVC Pattern, mit einem zwischengeschalteten Service-Layer, der Validierungen, Fachlogik und ggf. Transformationen übernimmt.
Model
Hier die erste Implementierung des BackupPlans, mit den oben genannten Informationen als Attribute:
package com.example.dbbackuprestore.backupplan;
public final class BackupPlan {
private String dbHost;
private String dbPort;
private String dbImplementation;
private String targetDirectory;
private String name;
private Long delayInSeconds;
private boolean isActive = false;
public BackupPlan(String dbHost, String dbPort, String dbImplementation, String name,
String targetDirectory, long delayInSeconds)
{
this.dbHost = dbHost;
this.dbPort = dbPort;
this.dbImplementation = dbImplementation;
this.targetDirectory = targetDirectory;
this.delayInSeconds = delayInSeconds;
this.name = name;
}
public BackupPlan() {}
public String getTargetDirectory()
{
return targetDirectory;
}
public Long getDelayInSeconds()
{
return delayInSeconds;
}
public String getName()
{
return name;
}
public String getDbImplementation()
{
return dbImplementation;
}
public void setDbImplementation(String dbImplementation)
{
this.dbImplementation = dbImplementation;
}
public String getDbHost()
{
return dbHost;
}
public void setDbHost(String dbHost)
{
this.dbHost = dbHost;
}
public String getDbPort()
{
return dbPort;
}
public void setDbPort(String dbPort)
{
this.dbPort = dbPort;
}
public boolean isActive()
{
return isActive;
}
public void setActive(boolean active)
{
isActive = active;
}
public void setTargetDirectory(String targetDirectory) {
this.targetDirectory = targetDirectory;
}
public void setName(String name) {
this.name = name;
}
public void setDelayInSeconds(long delayInSeconds) {
this.delayInSeconds = delayInSeconds;
}
}
Code-Sprache: JavaScript (javascript)
Den Zugriff auf die BackupPläne bilden wir über einen Service ab, den BackupPlanService. Da diese Klasse das erste Mal tatsächlich Funktionalität bietet, schreiben wir zunächst einen Test für das Auflisten aller BackupPläne.
package com.example.dbbackuprestore.backupplan;
import java.util.Collection;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public class BackupPlanServiceTest {
@Test
public void listBackupPlans() {
BackupPlanService backupPlanService = new BackupPlanService();
Collection<BackupPlan> backupPlans = backupPlanService.findAll();
Assertions.assertTrue(backupPlans.isEmpty());
}
}
Code-Sprache: JavaScript (javascript)
Da noch keine Backup-Pläne gespeichert werden können, bekommen noch keine Backup-Pläne zurück. Dennoch haben wir damit erstmal die grobe Quelltextstruktur erstellt und können als nächstes den BackupPlanController implementieren, der die Requests vom Benutzer verarbeitet.
Controller
Der Controller holt sich alle vorhandenen BackupPläne und befüllt damit ein Thymeleaf-Template. Auch hier schreiben wir den Test zuerst:
package com.example.dbbackuprestore.backupplan;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(classes = {BackupPlanController.class, BackupPlanService.class})
@AutoConfigureMockMvc
public class BackupPlanControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
public void listBackupPlans() throws Exception {
mockMvc.perform(get("/backupplans")).andExpect(status().isOk());
}
}
Code-Sprache: JavaScript (javascript)
Der Test testet zunächst nur, ob der Endpunkt „/“ von der Anwendung bedient wird und den Statuscode 200 OK zurückgibt. Die Controllerimplementierung sieht dann folgendermaßen aus:
@Controller
public class BackupPlanController
{
private final BackupPlanService backupPlanService;
private static final Log LOGGER = LogFactory.getLog(BackupPlanController.class);
public BackupPlanController(BackupPlanService backupPlanService){
this.backupPlanService = backupPlanService;
}
@GetMapping("/")
public String home(Model model){
Collection<BackupPlan> dbBackupPlans = backupPlanService.findAll();
model.addAttribute("backupplans", dbBackupPlans);
return "home";
}
Code-Sprache: JavaScript (javascript)
View
Beim View handelt es sich um Thymeleaf-Templates, und die Startseite soll alle bereits angelegten BackupPläne anzeigen:
<head>
<link rel=“stylesheet“ type=“text/css“ href=“/css/home.css“>
</head>
<body>
<div class=“center“>
<h2>DB Backup & Restore</h2>
<br>
<br>
<h3>Your backup plans</h3>
<table>
<tbody>
<tr th:each=“backupplan : ${backupplans}“>
<td>
<a th:href=“@{/get/{name}(name=${backupplan.name})}“>
<button th:text=“${backupplan.name}“></button>
</a>
</td>
</tr>
</tbody>
</table>
</div>
</body>
Beim dem UI handelt es sich erst einmal um eine Prototypische Implementierung. Eventuell greifen wir später auf eine anspruchsvollere Webtechnologie zurück. Für den Anfang reicht ein rudimentäres Webinterface aber aus.
Start der Anwendung
Die erste Hürde ist geschafft, das technische Rahmenwerk für die Webanwendung steht. Die Anwendung kann nun via
mvn spring-boot:run
oder über die IDE ausgeführt werden.
Da der BackupPlanService momentan noch eine leere Sammlung zurückgibt, sieht es momentan noch folgendermaßen aus:

Im nächsten Use Case werden wir die Möglichkeit schaffen, BackupPläne anzulegen, und dann wird der View auch entsprechend Inhalt anzeigen.
Story #2: Anlegen und Speichern von Backupplänen
Im nächsten Use Case wollen wir dem Benutzer die Möglichkeit geben, Backuppläne anzulegen. Dafür müssen wir unseren BackupPlanService erweitern und ein neues BackupPlanRepository anlegen.
Design
Auch hier starten wir zunächst mit einem UML-Ablauf- und -klassendiagramm, um unser Design festzulegen:
Es ist typisch für das Web MVC Pattern, dass nur der Service-Layer auf Repositories zugreifen darf: daher entsteht solch eine Kaskadierung. Im Klassendiagramm sieht man die Konsequenz dieses Vorgehens:


BackupPlanController, BackupPlanService und BackupPlanRepository haben alle eine Abhängigkeit zum BackupPlan. Das ist dem Umstand geschuldet, dass wir noch keine Layer-spezifischen DTOs einsetzen, hat aber den Vorteil, dass wir nur eine Stelle ändern müssen, wenn sich Informationen über unseren BackupPlan ändern.
Die Implementierung der Speicherung erfordert eine Anpassung unseres Models.
Model
Wir müssen unseren BackupPlanService anpassen sowie ein neues BackupPlanRepository anlegen, dass wir uns die Datenbankzugriffe kapselt.
Beginnen wir mit dem neuen BackupPlanRepository:
package com.example.dbbackuprestore.backupplan;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface BackupPlanRepository extends MongoRepository<BackupPlan, String>
{
BackupPlan findByName(String name);
}
Code-Sprache: CSS (css)
Als Datenbankimplementierung benutzen wir MongoDB, und die typischen CRUD-Funktionalitäten werden durch das Ableiten vom Interface MongoRepository autogeneriert. Im BackupPlanService verwenden wir das BackupPlanRepository.
Hier ist der angepasste Test für den BackupPlanService, der nun zusätzliche Validierungslogik übernehmen soll:
public class BackupPlanServiceTest {
private BackupPlanRepository mockRepository;
private BackupPlanService backupPlanService;
@BeforeEach
public void setup() {
mockRepository = Mockito.mock(BackupPlanRepository.class);
backupPlanService = new BackupPlanService(mockRepository);
}
@Test
public void listBackupPlans() {
when(mockRepository.findAll()).thenReturn(Collections.emptyList());
Collection<BackupPlan> backupPlans = backupPlanService.findAll();
Assertions.assertTrue(backupPlans.isEmpty());
}
@Test
public void saveValidBackupPlan(){
BackupPlan backupPlan = new BackupPlan("localhost", "27017", "mongo", "test", Files.currentFolder() + "src/test/resources/", 1L);
backupPlanService.save(backupPlan);
Mockito.verify(mockRepository).save(backupPlan);
}
@Test
public void dontSaveInvalidPortBackupPlan(){
String invalidPort = "iamnotanumber";
BackupPlan backupPlan = new BackupPlan("localhost", invalidPort, "mongo", "test", Files.currentFolder() + "src/test/resources", 1L);
IllegalArgumentException exception = expectException(backupPlan);
assertTrue(exception.getMessage().contains("invalid port"));
Mockito.verifyNoInteractions(mockRepository);
}
@Test
public void dontSaveInavlidTargetDirectoryBackupPlan() {
String relativePath = "src/test/resources";
BackupPlan backupPlan = new BackupPlan("localhost", "27017", "mongo", "test", relativePath, 1L);
IllegalArgumentException exception = expectException(backupPlan);
assertTrue(exception.getMessage().contains("invalid target directory"));
Mockito.verifyNoInteractions(mockRepository);
}
@Test
public void dontSaveWithNullFields() {
BackupPlan backupPlan = new BackupPlan(null, null, null, null, null, 1L);
assertThrows(NullPointerException.class, () -> backupPlanService.save(backupPlan));
}
@Test
public void dontSaveWithNegativeDelay() {
long negativeDelay = -1L;
BackupPlan backupPlan = new BackupPlan("localhost", "27017", "mongo", "test", Files.currentFolder() + "src/test/resources", negativeDelay);
IllegalArgumentException exception = expectException(backupPlan);
assertTrue(exception.getMessage().contains("delay must be bigger than 0"));
}
private IllegalArgumentException expectException(BackupPlan backupPlan) {
return assertThrows(IllegalArgumentException.class, () -> backupPlanService.save(backupPlan));
}
}
Code-Sprache: JavaScript (javascript)
Im Test „mocken“ wir das BackupPlanRepository, da es als neue Abhängigkeit hinzugekommen ist. In den Tests erwarten wir zusätzliche Validierungslogik: der Port muss eine ganze Zahl sein, und das Zielverzeichnis ein absolutes Verzeichnis. Hier die dazu passende Serviceimplementierung:
@Service
public class BackupPlanService {
private BackupPlanRepository repository;
public BackupPlanService(BackupPlanRepository repository) {
this.repository = repository;
}
public Collection<BackupPlan> findAll() {
return repository.findAll();
}
public void save(BackupPlan backupPlan) throws IllegalArgumentException {
validateBackupPlan(backupPlan);
repository.save(backupPlan);
}
private void validateBackupPlan(BackupPlan backupPlan) {
if (!hasValidPort(backupPlan)) {
throw new IllegalArgumentException("BackupPlan has invalid port!");
}
if (!hasValidTargetDirectory(backupPlan)) {
throw new IllegalArgumentException("BackupPlan has invalid target directory!");
}
if(!delayIsBiggerThanZero(backupPlan)){
throw new IllegalArgumentException("BackupPlan delay must be bigger than 0!");
}
}
private boolean delayIsBiggerThanZero(BackupPlan backupPlan) {
return backupPlan.getDelayInSeconds() > 0;
}
private boolean hasValidPort(BackupPlan backupPlan) {
String validPortRegex = "^[0-9]+$";
return backupPlan.getDbPort().matches(validPortRegex);
}
private boolean hasValidTargetDirectory(BackupPlan backupPlan) {
File file = new File(backupPlan.getTargetDirectory());
return file.isAbsolute();
}
}
Code-Sprache: PHP (php)
Unser Service ist nun bereit, vom BackupPlanController aufgerufen zu werden.
Controller
Der BackupPlanController benötigt zwei neue Methoden: eine, um den View für das Formular zum Erstellen eines BackupPlans zu erzeugen, und eine, um die Speicherung des BackupPlans durchzuführen. Passen wir wieder zuerst unseren Test entsprechend an:
@SpringBootTest(classes = {BackupPlanController.class})
@AutoConfigureMockMvc
public class BackupPlanControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private BackupPlanService backupPlanService;
@Test
public void listBackupPlans() throws Exception {
mockMvc.perform(get("/backupplans/")).andExpect(status().isOk());
}
@Test
public void getBackupForm() throws Exception {
mockMvc.perform(get("/backupplans/add")).andExpect(status().isOk());
}
}
Code-Sprache: PHP (php)
Und die entsprechende Implementierung des BackupPlanControllers:
@Controller
@RequestMapping(value = "/backupplans")
public class BackupPlanController {
private final BackupPlanService backupPlanService;
public BackupPlanController(BackupPlanService backupPlanService){
this.backupPlanService = backupPlanService;
}
@GetMapping("")
public String home(Model model) {
Collection<BackupPlan> dbBackupPlans = backupPlanService.findAll();
model.addAttribute("backupplans", dbBackupPlans);
return "home";
}
@GetMapping("/add")
public String getBackupPlanForm(Model model) {
BackupPlan backupPlan = new BackupPlan();
model.addAttribute("backupplan", backupPlan);
return "addBackupPlan";
}
@PostMapping("/add")
public String createBackupPlan(@ModelAttribute BackupPlan backupPlan){
backupPlanService.save(backupPlan);
return "createSuccess";
}
}
Code-Sprache: PHP (php)
View
Der View muss nun ein Template „addBackupPlan“ bereitstellen, der das Formular zu Erstellung eines BackupPlans enthält, sowie ein Template für die erfolgreiche Erzeugung.
<head>
<title>Create a new database backupplan</title>
</head>
<div class="center">
<h2>Create Database Backup Plan</h2>
<form action="#" th:action="@{/backupplans/add}" th:object="${dbbackupplan}" method="post">
<label>Name:</label>
<input type="text" class="form-control" th:field="*{name}">
<br/>
<br/>
<label>Database implementation:</label>
<select th:field="*{dbImplementation}">
<option th:value="MongoDB" th:text="MongoDB"></option>
</select>
<br/>
<br/>
<label>Database Host:</label>
<input type="text" class="form-control" th:field="*{dbHost}" placeholder="localhost">
<br/>
<label>Database Port:</label>
<input type="text" class="form-control" th:field="*{dbPort}" placeholder="27017">
<br/>
<br/>
<label>Delay (in seconds):</label>
<input type="text" class="form-control" th:field="*{delay}" placeholder="600">
<br/>
<label>Dump Directory:</label>
<input type="text" class="form-control" th:field="*{targetDirectory}" placeholder="C:\Users\boerners\Desktop\">
<br/>
<br/>
<a th:href="@{/}">Cancel</a>
<input type="submit" value="Create new backup plan">
</form>
</div>
Code-Sprache: HTML, XML (xml)
Und für die erfolgreiche Speicherung, createSuccess.html:
<h2>Backup Plan Creation successful!</h2>
<a th:href="@{/backupplans}">Back</a>
Code-Sprache: HTML, XML (xml)
Zu guter Letzt fügen wir in unsere Startseite noch ein Button für das Hinzufügen von BackupPlänen hinzu:
<a href="/backupplans/add">
<button>
Add BackupPlan
</button>
</a>
Code-Sprache: HTML, XML (xml)
Wenn wir die Anwendung nun starten, bekommen wir ein rudimentäres HTML-View, über das wir Backuppläne anzeigen und anlegen lassen können:


Start MongoDB & DB Backup&Restore Webapp
Wenn die Anwendung startet, wird sie versuchen, sich gegen eine MongoDB unter localhost:27017 zu verbinden. Diese kann mit Docker Desktop einfach gestartet werden:
docker run -d -p 27017:27017 mongo:latest
Die DB Backup&Restore App kann nun mit o.g. Befehl oder in eurer IDE gestartet werden.
Im nächsten Blogpost werden wir konkretes Verhalten implementieren: wir werden zunächst ein Dump-Mechanismus für MongoDBs bereitstellen. Ziel ist es, dass für jeden angelegten BackupPlan ein separater Thread ausgeführt wird, der alle X Sekunden ein Backup-Dump erstellt.
