UF6: POO. Introducció a la persistència en BD

Resultats d'aprenentatge

  1. Gestiona informació emmagatzemada en bases de dades relacionals mantenint la integritat i la consistència de les dades.
  2. Gestiona informació emmagatzemada en bases de dades objecte-relacionals mantenint la integritat i la consistència de les dades.
  3. Utilitza bases de dades orientades a objectes, analitzant-ne les característiques i aplicant tècniques per mantenir la persistència de la informació.

Persistència

Quina persistència?

Al programari necessitem mètodes de persistència per tal de guardar el seu estat. Això s'articula mitjançant una capa de persistència.

Aquesta persistència pot implementar-se de diferents formes, però sempre necessitarem una memòria no volàtil com a emmagatzemament, o sigui, un o diversos arxius físics. El format o organització dependrà de les nostres necessitats.

Criteris per decidir la forma de persistència:

  • És una aplicació monousuari o multiusuari?
  • Es comparteix informació a la xarxa amb altres clients o aplicacions?
  • Hi ha un volum molt alt de dades?
  • Hi ha un esquema estable (ben estructurat)?
  • Quins requisits qualitatius tenim: disponibilitat, escalabilitat, latència, rendiment, consistència...?

Si la resposta està a prop d'una aplicació monousuari, sense connexió amb altres clients, i poques dades, segurament podem gestionar-ho mitjançant persistència en fitxer. Caldria, en tot cas, decidir quin és el format d'aquest fitxer, ja que podem utilitzar solucions existents sense necessitar inventar-nos un format a mida.

Si la resposta s'assembla a aplicació multiusuari amb informació compartida per la xarxa i grans volums de dades, estem a prop de necessitar un sistema de gestió de base de dades (SGBD).

Els SGBD resolen problemes habituals que ens trobem en el desenvolupament d'aplicacions. Entre ells:

  • Definició, creació, manteniment i control d'accés a una base de dades.
  • Gestió de transaccions i concurrència (segons el model).
  • Facilitats per recuperar dades en cas de danys.
  • Gestió d'autoritzacions i accés remot.
  • Regles de comportament de les dades en funció de la seva estructura.

Base de dades d'aplicació o d'integració

Una base de dades d’integració és una base de dades que actua com a magatzem de dades de diverses aplicacions i, per tant, integra dades d'aquestes aplicacions.

Una base de dades d'aplicació es controla i accedeix des d'una sola aplicació. Per a compartir dades amb altres aplicacions, l'aplicació que controla la base de dades hauria de proporcionar serveis.

La recomanació general és la d'evitar bases de dades d'integració. En general, les bases de dades d’integració comporten problemes greus perquè la base de dades esdevé un punt d’acoblament entre les aplicacions que hi accedeixen. Generalment es tracta d'un acoblament profund que augmenta significativament el risc que suposa canviar aquestes aplicacions i dificulta la seva evolució.

Model relacional vs NoSQL

Les bases de dades actuals responen, moltes d'elles, al model relacional. Aquest model ha triomfat segurament gràcies a l'establiment d'un estàndard per a la gestió de dades, l'SQL. Existeixen alguns altres models que tenen sentit per a casos concrets, i que cal considerar: bases de dades en graf, multivalor o orientades a objecte. Però han anat perdent ressò en favor de les bases de dades NoSQL. El creixement d'aquestes es veu afavorit pel Big Data i les aplicacions en temps real. Són sistemes habitualment no estructurats (sense esquema), i poden persistir al costat de solucions relacionals en models de persistència poliglota.

Sense sortir del model relacional, solem tenir extensions sense esquema. Per exemple, camps amb contingut JSON. O taules d'atributs que permeten fer JOINs addicionals. Aquestes extensions ens permeten tenir dades sense esquema dins d'un esquema, tot i que aquestes dades no són tan accessibles des de consultes SQL.

El que cal evitar és tenir esquemes implícits al codi d'accés. Només en pocs casos pot tenir sentit no tenir-ne un esquema:

  • Camps a mida imposats per l'usuari
  • Objectes sense un tipus uniforme (esdeveniments)
  • Pot ser més fàcil fer migracions d'esquemes (implícits)

Punts a considerar a l'hora de decidir-se:

  • És una BBDD amb o sense esquema (relacionals vs NoSQL)?
  • Existeixen relacions, que utilitzarem per navegar aquesta informació?
  • La velocitat és un aspecte crític? Les relacionals sacrifiquen la velocitat en favor de la normalització.
  • És important tenir escalabilitat? Les NoSQL escalen millor horitzontalment.
  • Les relacionals ofereixen les propietats ACID, mentre les NoSQL són BASE.

Característiques ACID

En el context de bases de dades, ACID (acrònim anglès de Atomicity, Consistency, Isolation, Durability) són tot un seguit de propietats que ha de complir tot sistema de gestió de bases de dades per tal de garantir que les transaccions (operacions sobre les dades) siguin fiables.

Concretament, l'acrònim ACID significa:

  • Atomicitat: Una transacció o bé finalitza correctament i confirma o bé no deixa cap rastre de la seva execució.
  • Consistència: La concurrència de transaccions no pot produir resultats anòmals.
  • llament (o Isolament): Cada transacció del sistema s'ha d'executar com si fos l'única que s'executa en aquell moment en el sistema.
  • Durabilitat: Si es confirma una transacció, el resultat d'aquesta ha de ser definitiu i no es pot perdre.

Característiques BASE

Les característiques BASE estan associades a les BBDD NoSQL. Es basen en el teorema CAP (Consistency-Availability-Partition Tolerance), que afirma que és molt difícil tenir més de dues d'aquestes propietats alhora.

Són l'acrònim de:

  • Basic Availability: la base de dades funciona la majoria del temps.
  • Soft-State: no cal tenir consistència a l'escriptura, ni les rèpliques han de ser consistents.
  • Eventual consistency: la consistència es pot tenir més tard en el temps (funcionament mandrós).

JDBC

JDBC (Java DataBase Connectivity): API estàndard que permet llençar consultes a una BD relacional.

JDBC és el model de persistència bàsic a Java. En funció de la mida i el tipus de projecte és possible que necessitem ajuda per implementar aspectes recurrents al codi:

  • Operacions CRUD: si necessitem fer CRUD per moltes taules, seria una feina molt feixuga.
  • Generació de queries SQL: ens facilita tenir queries universals ben formades (dialectes).
  • Gestió de transaccions: gestió per threads de transaccions i connexions a la BBDD.
  • Control de concurrència (optimista, pessimista): mecanismes amb versions/timestamps o amb bloqueig de files.

Els paquets java.sql y javax.sql formen part de Java SE i contenen un bon nombre d’interfícies i algunes classes concretes, que conformen l’API de JDBC. Els components principals de JDBC són: els controladors, les connexions, les sentències i els resultats.

Controladors (drivers)

Un controlador JDBC és una col·lecció de classes Java que us permet connectar-vos a una determinada base de dades. Per exemple, MySQL té el seu propi controlador JDBC. Un controlador JDBC implementa moltes de les interfícies JDBC. Quan el codi utilitza un controlador JDBC determinat, en realitat només utilitza les interfícies estàndard JDBC. El controlador JDBC concret que s’utilitza s’amaga darrere de les interfícies JDBC. D’aquesta manera podeu connectar un nou controlador JDBC sense que el vostre codi ho noti.

Els drivers estan disponibles quan la llibreria (jar) corresponent està al classpath. Per exemple, per a MySQL 8.x podem comprovar-ho amb:

try {
    Class.forName("com.mysql.cj.jdbc.Driver");
} catch (ClassNotFoundException e) {
    System.out.println(name + "Falta llibreria");
}

Connexions (connections)

Una vegada carregat i inicialitzat un controlador JDBC, heu de connectar-vos a la base de dades. Es fa obtenint una connexió a la base de dades mitjançant l’API JDBC i el controlador carregat. Tota comunicació amb la base de dades es fa a través d’una connexió. Una aplicació pot tenir més d’una connexió oberta a una base de dades alhora, però cal estalviar connexions, ja que són cares, i tancar-les sempre.

Per obtenir una connexió, necessitem una URL, que és dependent de la BBDD concreta. Per exemple:

jdbc:mysql://localhost:3306/test
jdbc:sqlite:path/test.db

Podem obtenir una connexió:

Connection connection = DriverManager.getConnection(url, user, password);
// sentències ...
connection.close();

Una manera alternativa, i recomanable, d'obtenir una connexió és utilitzant el try-with-resources, ja que Connection és un AutoCloseable.

try (Connection connection = DriverManager.getConnection(url, user, password)) {
    // sentències ...
} catch (SQLException e) {
    e.printStackTrace();
}

Sentències (statements)

Una setència és el que utilitzeu per executar consultes i actualitzacions a la base de dades. Podeu utilitzar alguns tipus diferents d’enunciats. Cada declaració correspon a una sola consulta o actualització. Tenim bàsicament dos tipus de sentències en funció de si la sentència SQL té o no paràmetres (comodins amb ?).

Statement

try (Statement st = connection.createStatement()) {

    int count = st.executeUpdate(sql1); // INSERT, UPDATE o DELETE
    // o bé...
    try (ResultSet rs = st.executeQuery(sql2)) { // SELECT
        // processament...
    }
}

PreparedStatement

try (PreparedStatement ps = conn.prepareStatement(sql)) {

    ps.setType1(1, valor1); // 1 a N, on Type pot ser Int, String...
    ps.setType2(2, valor2);

    int count = ps.executeUpdate(); // INSERT, UPDATE o DELETE
    // o bé...
    try (ResultSet rs = ps.executeQuery()) { // SELECT
        // processament...
    } 
}

Conjunts de resultats (ResultSets)

Quan es realitza una consulta a la base de dades, s'obté un conjunt de resultats. A continuació, podeu recórrer aquest ResultSet per llegir el resultat de la consulta.

try (ResultSet rs = st.executeQuery(sql)) { 

    while (rs.next()) {
        Type1 valor1 = rs.getType1(1);
        Type2 valor2 = rs.getType2(2);
        // o bé...
        Type1 valor1 = rs.getType1("nom_columna1");
        Type2 valor2 = rs.getType2("nom_columna2");
    }
}

Altres accions

Inserció i obtenció d'una clau generada

De vegades es vol obtenir la clau que s'acaba de generar a una columna automàticament. Això es pot definir a MySQL amb AUTO_INCREMENT o bé a PostgreSQL amb SERIAL. Es pot aconseguir en dos pasos:

  1. utilitzant el paràmetre Statement.RETURN_GENERATED_KEYS quan es crea el PreparedStatement.
  2. Obtenint el ResultSet mitjançant PreparedStatement.getGeneratedKeys(). Habitualment només hi haurà una columna, la posició 1.
int key;
String sql = "INSERT INTO taula (valor) VALUES (?)";       
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {                
    ps.setString(1, valor);
    ps.executeUpdate();
    try (ResultSet rs = ps.getGeneratedKeys()) {
        if (rs.next()) {
            key = rs.getInt(1);
        }
    }
}

java.sql.Date, java.sql.Time, and java.sql.Timestamp

Aquestes classes estenen la funcionalitat d'altres Java per representar l'equivalent en SQL. Per exemple, java.sql.Date expressa el dia, mes i any. I java.sql.Time representa hora, minuts i segons. Finalment, java.sql.Timestamp representa java.util.Date fins als nanosegons.

Conversions entre java.sql.Date i java.util.Date: java.sql.Date estén (extends) java.util.Date. Per tant: tots els java.sql.Date són també java.util.Date.

En general, als nostres programes sempre es pot utilitzar java.util.Date, la data genèrica a Java.

Però a JDBC s'utilitza java.sql.Date en dos casos:

  • void PreparedStatement.setDate(int index, Date sdate). Per crear un java.sql.Date a partir d'un java.util.Date:
    • sdate = new java.sql.Date(udate.getTime()). Per exemple:
      • preparedStatement.setDate(3, new java.sql.Date(tasca.getDataInici().getTime()));
    • sdate = java.sql.Date.valueOf(LocalDate.of(yyyy, mm, dd)).
  • Date ResultSet.getDate(...) retorna un java.sql.Date. Però no cal fer res especial: es pot assignar a un java.util.Date.
    • udate = sdate. Per exemple:
      • tasca.setDataInici(result.getDate("data_inici"));

Transaccions

Una transacció és un conjunt d’accions que s’han de dur a terme com una única acció atòmica. O totes es fan, o cap.

Inicieu una transacció per aquesta invocació:

connection.setAutoCommit(false);

Ara podeu continuar fent consultes i actualitzacions de taules. Totes aquestes accions formen part de la transacció.

Si alguna acció intentada dins de la transacció falla, haureu de desfer la transacció. Això es fa així:

connection.rollback();

Si totes les accions tenen èxit, hauríeu de confirmar la transacció. Un cop es fa, les accions són permanents a la base de dades i no hi ha marxa enrera. Es fa així:

connection.commit();

Exemple amb try/catch:


try (Connection conn = factory.getConnection()) {
    try {
        conn.setAutoCommit(false);

        // sentències...

        conn.commit();

    } catch (Exception e) {
        conn.rollback();
        throw e;
    }
} catch (SQLException e) {
    throw new RuntimeException("problema SQL", e);
}

Pool de connexions

El pool de connexions és un conegut patró d’accés a dades, que té com a objectiu principal reduir les despeses implicades en la realització de connexions de bases de dades i en operacions de bases de dades de lectura / escriptura.

En poques paraules, un pool de connexions és, al nivell més bàsic, una implementació de memòria cau de connexió de bases de dades, que es pot configurar per adaptar-se a requisits específics.

Es poden implementar adhoc, utilitzar llibreries de tercers o bé les de les implementacions del controlador JDBC. Sempre que sigui possible, és millor utilitzar un pool de connexions que fer-ho amb DriverManager.getConnection(...). Un pool ens crea un objecte javax.sql.DataSource, que permet obtenir connexions amb el seu mètode getConnection(). A més, el mètode close() de la connexió no la tanca, per poder reutilitzar-la un altre cop.

DataSource ds = // construir-lo segons la base de dades
Connection conn = ds.getConnection(); // obté una nova connexió
...
conn.close(); // retorna la connexió al pool (no la tanca)

Patrons de disseny

Model de capes d'una aplicació

Les capes d'una aplicació que necessita persistència podrien establir-se de la següent manera:

  • Presentació: la part que s'encarrega de la interacció amb l'usuari. En el patró MVC, inclouria la vista i el controlador.
  • Domini: lògica de negoci, relacionant les dades amb el seu comportament.
  • Dades: comunicació amb altres sistemes que fan tasques necessàries per a la nostra aplicació. Per exemple, la base de dades.

On s'executa cada capa?

  • Presentació: per a clients rics, al client. Per a B2C, al servidor (HTML).
  • Domini: al servidor, més fàcil manteniment. Al client, si és desconnectat. Si cal dividir-la, cal aïllar les dues parts.
  • Dades: al servidor, excepte si és un client desconnectat, llavors cal gestionar sincronitzacions.

Patrons del model

Dos estils d'implementació:

  • Senzill: similar al disseny de DB, un objecte de domini per taula. Ús del patró Active record (objecte que embolica una fila d'una taula, encapsula l'accés i afegeix lògica de domini en aquestes dades).
  • Ric: disseny diferent de la DB, amb herència, estratègies i altres patrons. Ús del patró Data Mapper (una capa de Mappers que mou les dades entre objectes i una base de dades mantenint-les independents les unes de les altres i del mateix mapper).

Patrons de la base de dades relacional

Cal fer un mapeig entre objectes i el món relacional perquè quan programem bases de dades relacionals, el seu model és diferent del dels objectes en memòria. Aquesta és una de les principals dificultats quan treballem amb els dos mons.

El patró general es diu Gateway: un objecte que encapsula l'accés a un recurs o sistema extern. Hi ha dues possibles implementacions:

  • Row data gateway: una instància del gateway per cada registre que retorna una consulta.
  • Table data gateway (o DAO): una instància gestiona tots els registres en una taula. Els registres es retornen a Record Sets.

Patró DAO (Data Access Object)

A continuació es pot veure el diagrama de classes del patró DAO i un exemple de seqüència.

Aquest patró utilitza un objecte de transferència, TransferObject, per intercanviar informació entre el client i la base de dades. Aquest objecte és una estructura de dades sense lògica de processament.

Cada objecte DAO realitza operacions sobre una taula: creació, lectura, modificació i esborrat.

És convenient utilitzar sempre una interfície per definir un DAO, per tal d'aïllar millor el domini de les dades. I no retornar mai objectes associats a la capa de base de dades, per exemple, evitant fer visible els ResultSet.

Estratègies DAO

Objecte de transferència

L'objecte de transferència es pot utilitzar com es pot veure al següent exemple.

// suposem que a resultSet hi ha un registre d'una cerca

Persona persona = new Persona(); // transfer object
persona.setId(resultSet.getInt("id"));
persona.setName(resultSet.getString("name"));
...
return persona;

Els objectes de transferència poden implementar-se de diferents maneres, en particular, poden ser mutables o immutables. La preferència general seria que fossin immutables al client que els utilitza, malgrat que això pot significar complicar-los pel que fa a la seva programació.

  • Com a l'exemple, amb getters i setters. La més convencional.
  • Amb una interfície immutable, encara que la seva implementació sigui mutable. La més correcta.
  • Amb camps públics i sense getters ni setters. La més senzilla.
  • Alternativament, es pot utilitzar un Map<String, Object>, similar al concepte schemaless. No requereix classes, però es perd la validació en temps de compilació. Relacionat amb els JSON.

Col·lecció d'objectes de transferència

Com ja s'ha comentat, és millor no exposar objectes associats a la capa de dades al client. Per exemple, evitar fer visible els ResultSet. Així encapsulem i evitem dependències i haver de gestionar excepcions de tipus SQLException. En aquesta estratègia el DAO crea una sentència SQL i executa una consulta per obtenir un ResultSet. Llavors el DAO processa el ResultSet per recuperar tantes files de resultats coincidents com ho sol·liciti el client que fa la crida. Per a cada fila, el DAO crea un objecte de transferència i l’afegeix a una col·lecció que es retorna al client.

List<Persona> persones = new ArrayList<>();

while (resultSet.next()) {

    Persona persona = new Persona(); // transfer object
    persona.setId(resultSet.getInt("id"));
    persona.setName(resultSet.getString("name"));
    persones.add(to);
}

return persones;

Factoria de DAO

Quan tenim diversos DAO que cal crear per diferents taules, un patró habitual és la factoria de DAO.

public class DAOFactory {

  private static DAOFactory instance;

  private DAOFactory() {
    // init ConnectionFactory
  }

  public static DAOFactory getInstance() {
  
    if (instance == null)
      instance = new DAOFactory();

    return instance;
  }

   public CustomerDAO getCustomerDAO() {
      // implementar-ho
   }

   public AccountDAO getAccountDAO() {
      // implementar-ho
   }

   public OrderDAO getOrderDAO() {
      // implementar-ho
   }
}

Control de concurrència

Quan tractem de llegir i modificar dades, podríem afrontar alguns dilemes sobre la integritat i la validesa de la informació. Aquests dilemes sorgeixen a causa de les operacions de bases de dades xocant entre elles; per exemple, dues operacions d’escriptura o una operació de lectura i escriptura que col·lideixen.

Les estratègies per resoldre aquesta situació poden ser:

  • Bloqueig pessimista: s'espera una col·lisió, i llavors es fa un bloqueig dels recursos implicats. Cap altre client pot accedir-los fins que no es lliurin.
  • Bloqueig optimista: la col·lisió és poc probable. Es deixa fer, i quan acaba el processament, es comprova si hi ha hagut un problema.
  • Bloqueig massa optimista: no s'esperen col·lisions. Potser és un sistema monousuari.

Les transaccions dels SGBD resolen aquests problemes gràcies a la implementació de les característiques ACID. Primer, totes les sentències individuals que s'executen són atòmiques, és a dir, o bé s'executen o no ho fan, però mai provoquen problemes de consistència. Segon, podem estendre aquesta capacitat per a un conjunt de sentències mitjançant l'ús de transaccions.

Les estratègies de resolució de col·lisions poden ser:

  • Rendir-se.
  • Mostrar el problema, i deixar que decideixi l'usuari.
  • Barrejar els canvis.
  • Registrar el problema, i que ho resolgui algú altre més tard.
  • Ignorar la col·lisió.

Tanmateix, les transaccions no sempre són la solució en l'àmbit de les aplicacions. Les lectures/escriptures de dades separades en el temps poden provocar contenció de dades. Per exemple, una transacció no pot esperar que un usuari modifiqui les dades després d'haver-les llegit en un formulari.

Els mecanismes del bloqueig pessimista s'implementen mitjançant ordres que permeten bloquejar registres de la base de dades. Es fa perquè hi ha alta contenció i el cost de bloquejar és menor que el de fer enrere una transacció.

En canvi, el bloqueig optimista s'implementa fent que hi hagi un error si hi ha hagut col·lisió, i llavors cal que l'usuari torni a fer l'operació. Pot implementar-se amb un control de timestamps, comptadors o versions d'un registre. La modificació dels registres actualitza aquests valors, i pot utilitzar-se com a condició per fer fallar una transacció. Aquest podria ser una possible transacció:

  • Inici: guardar un timestamp/versió que marca l'inici de la transacció
  • Fer canvis: llegir i intentar escriure a la base de dades
  • Validar: veure si les dades modificades són les marcades inicialment
  • Confirmar/Desfer: si no hi ha conflicte, fer els canvis; si hi ha, desfer-los.