petomka

Curator
Donator
Nov. 1, 2017
6
Vorwort
Es ist kaum zu glauben, wie oft MySQL (oder sonstige Datenbankmanagementsysteme) nicht richtig bedient werden. Viel zu oft gibt es potentielle Schwachstellen, die nur darauf warten, von einem Angreifer ausgenutzt zu werden - meist spricht man dann von SQL-Injection (oder auch SQLi). Dabei injiziert man bösartige Befehle in das entsprechende Datenbankmanagementsystem.

Daher dieses Tutorial, wie man in das vermeiden kann, explizit für die Programmiersprache Java. Alles wird einfachstmöglich nur mit dem JDBC-Treiber ("Java Database Connectivity") erklärt. Für kleine Projekte ist das auch noch OK, für größere Projekte empfehlen sich dann Connection-Pools wie HikariCP.

Zum "Nachmachen" wird natürlich vorausgesetzt, dass ein entsprechendes DBM verfügbar ist. In diesem Tutorial wird nicht darauf eingegangen, wie man so etwas aufsetzt.

Am Ende des Tutorials sollte man in der Lage sein, eine Verbindung zu seinem DBM zu öffnen, Daten vom Server zu holen und neue Daten einzufügen. Bonuspunkte gibt es auf Transferleistungen wie neue Tabellen anlegen oder Daten aus der Datenbank gezielt zu löschen.

Einrichten der Verbindung
JDBC ist Teil der Java Standard Edition (JSE) und damit des Java Development Kits (JDK). Daher müssen keine weiteren Installationsschritte gemacht werden oder andere Bibliotheken eingebunden werden.

Aus anderen Tutorials ist vielleicht folgende Zeile (oder sowas ähnliches) bereits bekannt:
Java:
Class.forName("com.mysql.jdbc.Driver");

Ursprünglich war diese Zeile dafür verantwortlich, dass der Treiber geladen wird, bevor Datenbankverbindungen hergestellt werden. Seit JDBC Version 4.0 ist dies aber nicht länger nötig, die Zeile kann man also getrost weglassen, wenn man wenigstens Java 7 benutzt (siehe selbst: [hier]).

Bevor wir eine Verbindung zu unserer Datenbank aufbauen können, müssen wir natürlich erstmal wissen, wohin wir uns verbinden wollen. Im Normalfall müssen wir uns auch authentifizieren. Nun ist es so, dass ein DBM mehrere Datenbanken beherbergen kann, diese Information müssen wir also auch angeben. Eine typische Verbindungs-URL zu einem MySQL-DBM-Server sieht nun so aus:
jdbc:mysql://<host>:<port>/<datenbank>?<eigenschaft1>=<wert1>&<eigenschaft2>=<wert2>...
Läuft der DBM-Server auf der gleichen Maschine, wie der Code, den wir ausführen möchten (was meistens der Fall ist), ist der Host localhost oder 127.0.0.1. Der Standardport ist 3306. Was du bei Datenbank eintragen musst, musst du selbst wissen, da ich dein DBM nicht kenne. Was es so für Eigenschaften gibt, kann man [hier] sehen.


Die eigentliche Verbindung öffnen wir nun wie folgt über DriverManager#getConnection:
Java:
String URL = "jdbc:mysql://127.0.0.1:3306/minecraft";
Connection connection = DriverManager.getConnection(URL);

Zu beachten ist hierbei, dass dieser Aufruf fehlschlagen kann - in diesem Fall wollen wir, dass sich der Aufrufer darum kümmert, mehr dazu gleich.

Das erzeugte Connection-Objekt kann nun verwendet werden, um Daten aus der Datenbank herunterzuladen oder eben in diese einzufügen. Wichtig ist, dass die Gültigkeit dieser Verbindung nicht unbegrenzt ist, da diese nach einiger Zeit vom Datenbankserver geschlossen werden könnte. In diesem Fall muss man die Verbindung wieder erneut aufbauen. Folgendes Konstrukt würde sich dazu eignen:

Java:
private static final int MAX_TIMEOUT_SECONDS = 5; //Der Datenbanksserver bekommt 5 Sekunden Zeit, zu antworten
private Connection connection;

private Connection getConnection() throws SQLException {
    if(connection == null || !connection.isValid(MAX_TIMEOUT_SECONDS)) {
        connection = /*...*/;
    }
    return connection;
}
Hier verwenden wir Connection#isValid(int), um zu überprüfen, ob eine bereits instanzierte Verbindung noch gültig ist. Der Parameter, den wir übergeben, gibt an, wie lange wir maximal auf eine Antwort warten möchten. Übergeben wir eine 0, bedeutet das, wir warten eben bis in die Unendlichkeit. In unserem Beispiel würden wir eben bis zu 5 Sekunden lang warten. Deshalb: Datenbankabfragen (sowieso) niemals im Hauptthread laufen lassen!

Weiterhin haben wir dafür gesorgt, dass der Aufrufer, der getConnection() aufruft, sich nun um eine angebrachte Fehlerbehandlung kümmern muss.

Daten abfragen - Vorbereitung ist alles
Jetzt wollen wir in unserem Programm ein paar Daten aus unserer Datenbank verwenden. Sei nun folgende Tabelle, wir nennen sie players, in unserer Datenbank:
iduuidusernamebalance
00add19fa-6bf4-4a24-9f97-7eb69b9b692apetomka1200.0
1d9bd0473-40f5-4aab-b3bf-2bcb17518257SteuerungC800.0
21bd0d6ab-95d2-440f-b6cd-9151f22fa1d4RiotSeb400.0

Dabei ist id unser Primärschlüssel vom Typ INT und AUTO_INCREMENT. Weithin ist uuid vom Typ VARCHAR(36), username vom Typ VARCHAR(16) und balance vom Typ DOUBLE(9,2). Außerdem soll uuid UNIQUE sein.

Um jetzt mit einem Aufruf alle Daten aus dieser Tabelle in unseren Speicher zu laden, kann man wie folgt vorgehen:
Java:
public static class PlayerEntry {
    public int id;
    public UUID uuid;
    public String username;
    public double balance;
    /* Konstruktor mit allen Feldern...*/
}

public List<PlayerEntry> loadEntries() {
    try {
        Connection connection = getConnection(); //Connection ggf. öffnen
        try(PreparedStatement stmt = connection.prepareStatement("SELECT * FROM `players`")) { //Alle Einträge aus der Tabelle players auswählen
            try(ResultSet resultSet = stmt.executeQuery()) { //Die Abfrage ausführen
                List<PlayerEntry> result = new ArrayList<>();
                while(resultSet.next()) { //Anfangs zeigt der Zeiger von einem ResultSet vor den ersten Eintrag, daher while und nicht do-while
                    int id = resultSet.getInt("id");
                    UUID uuid = UUID.fromString(resultSet.getString("uuid"));
                    String username = resultSet.getString("username");
                    double balance = resultSet.getDouble("balance");
                    result.add(new PlayerEntry(id, uuid, username, balance));
                }
                return result;
            }
        }
    } catch(Exception e) { //Gerne auch spezifischer
        /*...*/; //Angebrachte Fehlerbehandlung, z.B. loggen
    }
    return Collections.emptyList(); //Könnte auch null sein, um einen Fehler anzuzeigen
}

Wir müssen uns hier auch nicht selbst darum kümmern, unser ResultSet oder unser PreparedStatement zu schließen - diese implementieren nämlich das Interface AutoCloseable und werden daher von unserem try-with-resources-Block automatisch geschlossen.

Falls eine Datenabfrage von weiteren Parametern abhängt, steht dazu bei Daten einfügen mehr dazu, diese der Abfrage richtig mitzugeben.

Bob hat sich nun das Tutorial soweit durchgelesen und findet es doof, dass er für jede Abfrage eine eigene Methode dafür schreiben soll. Bob denkt sich: "Kann ich das nicht einfacher machen?" Daraufhin schreibt er einen Codeschnipsel, testet etwas rum und freut sich, eine so einfache Lösung gefunden zu haben, die der Autor des Beitrags wohl übersehen hat. Bob hat nun folgende Methode geschrieben:
Java:
public ResultSet runQuery(String table, String projection) {
    try {
        Connection connection = getConnection();
        PreparedStatement stmt = connection.prepareStatement("SELECT " + projection + " FROM `" + table + "`");
        //Und bereits hier ist man anfällig für SQLi.
        return stmt.executeQuery();
    } catch (Exception e) {} //Es wird auch treu die unnötige Fehlerbehandlung weggelassen, wer braucht das schon
    return null;
}
Dass dieser Code für SQLi anfällig ist, merkt Bob leider erst am nächsten Tag, als seine Datenbank gelöscht ist, weil er nicht aufgepasst hat. Da ist wohl User-Input an diese Stelle gekommen. Sei nicht wie Bob.

Hinweis: Benutzt man später einen Connection aus einem Connection Pool, so möchte man diese Verbindung nach der einmaligen Benutzung wieder schließen (bzw. in den Pool zurückgeben). Dafür empfiehlt sich ebenfalls ein entsprechender try-with-resources-Block:
Java:
try(Connection connetion = getConnection()) {
    /*...*/
} catch (/*...*/) {
    /*...*/
}

Daten einfügen - Vorbereitung ist alles, Teil 2
Spannend wird es aber erst, wenn wir einen neuen Datensatz in unsere Tabelle einfügen möchten, denn hierbei wird es dann nochmal kritischer, dass man das richtig macht. Sagen wir, wir möchten nun folgende Daten einfügen:
uuidusernamebalance
10c6c219-dbeb-4b46-9c05-9a66c8815df2Taminoful1000.0

Dafür erstellen wir eine passende Methode, die nun das PreparedStatement ausnutzt:
Java:
public void addPlayer(UUID uuid, String username, double balance) {
    try {
        Connection connection = getConnection(); //wie oben
        try(PreparedStatement stmt = connection.prepareStatement("INSERT INTO `players` (`uuid`, `username`, `balance`) VALUES (?, ?, ?)")) {
            //Die Fragezeichen sind der Knackpunkt - durch das PreparedStatement wird SQLi verhindert. Jetzt kann man getrost die
            //übergebenen Werte einfügen.
            stmt.setString(1, uuid.toString()); //Wichtig: Indizierung fängt hier bei 1 an!
            stmt.setString(2, username);
            stmt.setDouble(3, balance);

            stmt.executeUpdate(); //Hier executeUpdate(), weil wir Daten manipulieren.
        }
    } catch (Exception e) { //wieder gerne spezifischer
        /*...*/; //Handling/Logging
    }
}

Dieser Methode können wir nun einfach die Werte unseres Datensatzes übergeben, welcher dann in die Tabelle eingefügt wird. Da id AUTO_INCREMENTed ist, müssen wir uns um diesen Wert nicht kümmern.

Besonders beim Einfügen von neuen Daten sind viele anfällig für SQLi - Beispiel sei das Loggen eines Chats, wo der Chatinput eines Benutzers unvorbereitet in die Datenbank geschrieben wird.
 
Zuletzt bearbeitet:

Users who are viewing this thema