Introduzione
Senza voler fare troppa filosofia, lasciatemi dire che la conoscenza è l’essenza della vita e la persistenza dei dati è l’essenza della conoscenza. Ecco… dopo questa massima, molti di voi saranno tentati di abbandonare la lettura di questo articolo. Spero però di riuscire a trattenervi, preannunciandovi che, nei prossimi paragrafi, cercheremo di sovvertire l’approccio solito al design di una base di dati, che ogni informatico ha utilizzato nel corso della sua esperienza di studio e di lavoro.
Quando noi sviluppatori progettiamo un software cominciamo considerando i dati da manipolare, poi le relazioni tra di essi, il modo migliore di organizzarli sul database e infine come leggerli e scriverli. In questo modo la nostra progettazione è incentrata sui dati. Esiste in questo caso il rischio che il nostro modello non sia “legato” al resto dell’architettura del software e possa quindi inficiarne le prestazioni. Potremmo sbagliare la scelta dei dati da indicizzare o la scelta delle chiavi primarie o secondarie se guardiamo solo i dati.
Un aneddoto
Per spiegarvi come affrontare le problematiche legate alla persistenza, vi racconto un aneddoto. In passato ho lavorato a stretto contatto con delle figure “mitologiche”, chiamate DBA (“DataBase Administrator”) che, nell’opinione comune, erano i detentori del verbo, gli unici in grado di massimizzare le prestazioni dei database e per questo costavano tanto. Un giorno uno di loro mi disse: “Quando progetti una tabella non devi ragionare solo sui dati che deve contenere ma soprattutto sulle query che andrai a fare!”. Allora capii che i DBA avevano ragione di esistere e di incidere sui costi di progetto. Nelle prossime pagine mostreremo un approccio di design basato proprio su questo concetto.
Negli articoli [1] e [2] è stato illsutrato il pattern Canonical Data Model, con cui abbiamo imparato a progettare il nostro modello dati. Abbiamo anche imparato a definire le interfacce dei nostri servizi OSGi considerando gli aspetti di “business” del prodotto. E proprio da qui partiremo con la nostra analisi.
Progettare il database
Mostreremo ora il nostro approccio per la progettazione dei database e lo faremo usando come riferimento SensorMix [3]. Tra i moduli principali di questo sistema, troviamo quello che si occupa della persistenza delle informazioni (Data Service) ed è nello sviluppare questo modulo che noi abbiamo applicato le teniche che abitualmente usiamo a lavoro.
SensorMix raccoglie informazioni da dispositivi esterni, eterogenei tra di loro e che usano protocolli e formati dati differenti. I dispositivi esterni sono chiamati sensori, mentre i dati che inviano a SensorMix sono chiamati campioni. Esistono diversi tipi di sensori (Arduino, Android, iOS) e di campioni (temperatura, posizione, segnale WiFI, etc.).
L’architettura di SensorMix, come spiegato in [1] e [2], è incentrata su un data model che definisce le informazioni che transitano, ed è compito del Data Service memorizzare queste informazioni su un qualche supporto di storage. Questo sevizio fornisce funzioni di raccolta dei campioni e permette di consultare i dati raccolti per realizzare applicazioni che ne visualizzano l’andamento dei valori su grafici, esportano i log su PDF, effetuano elaborazioni per calcolarne valori statistici e così via. A me è toccato il compito di trasformare questa interfaccia in un servizio di persistenza. Proviamo a fare lo stesso esercizio insieme.
Java first!
Per tenere fede al nostro mantra “Java first”, ho deciso di utilizzare le Java Persistence API. JPA è una soluzione che permette una modellazione molto efficace e permette di creare le entity della base dati partendo dalle classi Java, realizzando codice disaccoppiato rispetto ai tipi primitivi del database e delle sue tabelle. Inoltre, apprezziamo JPA perchè ci permette di usare database con dialetti differenti in maniera trasparente.
L’astrazione di JPA è realizzata annotando le classi e quindi dovremmo applicare lo stesso trattamento alle classi del data model di SensorMix. Ma non vogliamo inquinare il data model con concetti tipici della persistenza, in quanto in SensorMix stesso segue i concetti del Canonical Data Model [1] e quindi è orientato al transito delle informazioni tra applicazioni piuttosto che alla loro memorizzazione su base di dati.
Come detto nel nostro approccio non si parte dai dati; guardiamo quindi il problema da un punto di vista “business”: prendiamo in considerazione i metodi del servizio, in particolare quello che permette ai sensori o alle applicazioni esterne di interagire con il sistema.
List<String> listSamplesTypes(); List<AbstractSample> getSamples(String sensorId, String sampleType, Date from, Date to, Long limitFrom, Long limitCount); long countSamples(String sensorId, String sampleType, Date from, Date to); SampleReport getSampleReport(String sensorId, String sampleType, Date from, Date to); List<String> listSensorsIds(); List<Sensor> getSensors(List<String> sensorIds, Date from, Date to); void registerSensor(Sensor sensor); void recordSamples(List<AbstractSample> samples);
Il metodo listSamplesTypes restituisce l’elenco dei tipi dei campioni senza duplicazioni, mentre il getSamples restituisce la lista di campioni, completa o filtrata, a seconda del valore dei parametri di ingresso. Ci sono poi due metodi, countSamples e getSampleReport, che permettono di avere statistiche sul numero di campioni; invece, i metodi listSensorsIds e getSensors permettono di avere dati relativi ai sensori. I metodi principali per immettere dati nel sistema, quelli che poi saranno resi persistenti dal servizio che stiamo sviluppando, sono registerSensor e recordSamples.
L’astrazione dei campioni
Soffermiamoci sul metodo getSamples il quale restituisce oggetti di tipo AbstractSample. Questa classe, come si intuisce dal nome, è astratta; quindi dovremo memorizzare sul nostro database delle istanze concrete. Ma non è immediato creare una base dati che permetta l’estrazione di classi derivate concrete eterogenee in maniera semplice e immediata.
Proviamo allora a cercare una soluzione analizzando quali parametri accettano i metodi in ingresso, ovvero quali filtri vanno applicati alle query. I metodi, infatti, non devono filtrare su tutti i campi dei singoli campioni, ma solo su un sottoinsieme di essi: tipo di sensore, identificativo del sensore, data di campionamento. Quindi non ha senso avere tante colonne, bastano quelle utili ai filtri. Le altre potrebbero stare tutte in una colonna, codificate in qualche modo. Il modo giusto con cui codificare le altre colonne può essere scelto in base a criteri di leggibilità, performance di lettura-scrittura, dimensione dei dati, etc. Nel nostro caso, dovendo lavorare con un’elevata mole di dati, abbiamo optato per una serializzazione di tipo binario per ridurre al minimo l’occupazione dei dati. In sostanza la classe JPA per effettuare la persistenza di un campione è la seguente.
@Entity @Table(name = "abstractsample") @Access(AccessType.FIELD) public class JpaAbstractSample { @Id @GeneratedValue private Long id; @Column(name = "sensorId", nullable = false) private String sensorId; @Temporal(TemporalType.TIMESTAMP) @Column(name = "time", nullable = false) private Date time; @Column(name = "type", nullable = false) private String type; @Lob @Column(name = "sample", nullable = false) private byte[] value; ... }
Indipendentemente dai tipi di campioni, il codice per estrarli dal database ha la seguente forma:
List jass = q.getResultList(); for (Iterator<?> i = jass.iterator(); i.hasNext();) { JpaAbstractSample u = (JpaAbstractSample) i.next(); samples.add(localSerializer.get().deserialize(u.getValue())); }
E questo è il codice per salvare un campione sul database:
JpaAbstractSample s = new JpaAbstractSample(); s.setSensorId(sample.getSensorId()); s.setTime(sample.getTime()); s.setType(sample.getType()); s.setValue(localSerializer.get().serialize(sample)); em.persist(s);
Abbiamo quindi risolto il problema del’astrazione dei campioni illustrato precedentemente e il codice ottenuto è molto leggibile e pulito. Di contro abbiamo perso in leggibilità dei dati sul database e abbiamo “duplicato il data model”.
Riguardo al primo punto c’è da dire che il nostro obiettivo è quello di consultare i dati dall’interfaccia di SensorMix e non direttamente dal database, quindi non è una grossa perdita. Per il secondo punto, l’impatto è limitato visto che nel nuovo data model le classi presenti sono soltanto due.
Come ottimizzare?
Supponiamo che al nostro SensorMix siano collegati migliaia di sensori che ogni giorno mandano centinaia di campioni ciascuno. Si capisce come nel giro di qualche settimana avremo raccolto milioni di dati, e gestirli può essere complesso sia per ottimizzarne l’accesso, sia per la manutenzione, poichè la dimensione delle tabelle diventa elevata.
Per risparmiare spazio abbiamo deciso di utilizzare la serializzazione binaria ma questo ha inevitabili conseguenze sulle prestazioni: la serializzazione/deserializzaizone delle classi aggiunge un ritardo nelle fasi di scrittura e lettura.
Test delle prestazioni
Per valutarne l’impatto è interessante analizzare l’ordine di grandezza di questo tempo di ritardo. Un primo esempio (figura 1) rappresenta i tempi di inserimento di 3000 campioni per volta.
La figura 2 mostra invece gli andamenti dei tempi di risposta di MySQL al crescere del numero di dati presenti nel database.
I dati dei grafici sono stati ottenuti utilizzando un test unitario in cui abbiamo aggiunto dei log per stampare i tempi di esecuzione. Il test è stato fatto girare nell’ambiente di sviluppo su un laptop con processore Core i7 2670QM, 8 GB di RAM e HD da 750 GB a 7200 rpm. Non serve soffermarsi tanto sul valore assoluto dei tempi quanto sull’ordine di grandezza e sull’andamento delle curve.
Vediamo che, per elevati numeri di righe presenti nel DB, i tempi di risposta sono nel’ordine dei 100 secondi e che questi tempi crescono in funzione del numero di righe nel database.
Utilizzando questi ordini di grandezza, analizziamo i tempi di serializzazione. Esistono numerosi serializzatori per Java, e on-line si trovano numerosi benchmark che li mettono a confronto: diventa facile perdersi tra i milioni di post a favore dell’uno o dell’altro. Tra i più gettonati troviamo Fast-Serializer, Kryo, proto-stuff, Java serializer. Potete provarli tutti oppure fidarvi di noi e delle prove che abbiamo fatto e che ci hanno portato a conclusione che Kryo è il serializzatore che serializza nel minor spazio possibile nel minor tempo possibile. Kryo permette di scrivere codice molto pulito dato che si può serializzare una qualunque classe anche senza annotarla o facendole implemetare interfacce particolari (come ad esempio java.io.Serializable).
Nello stesso test si vede che i tempi di serializzazione di Kryo sono dell’ordine delle decine di microsecondi mentre quelli di deserializzazione sono dell’ordine di qualche microsecondo. Confrontando i tempi di scrittura si vede che per persistere 3000 campioni sono necessari poco meno di 2 secondi mentre per serializzarli qualche decina di millisecondi. Per la lettura dei dati possiamo verificare che, per leggere 333000 campioni, sono necessari più di 160 secondi, mentre per deserializzarli meno di un secondo. Possiamo quindi concludere che utilizzando Kryo i tempi di serializzazione/deserializzazione sono trascurabili rispetto a quelli di lettura/scrittura dal database.
Lavorare sul data model
Possiamo ottenere ulteriori ottimizzazioni lavorando sul nostro data model. Se indicizziamo le colonne utilizzate per filtrare, sulle quali quindi il database svolgerà istruzioni di confronto e ordinamento, si ottengono significativi incrementi delle prestazioni. La dichiarazione degli indici è stata introdotta nell’ultima versione di JPA, la 2.1.
Come si vede, abbiamo ridotto i tempi di circa il 30% anche se, al crescere del nuemero di righe in tabella, l’ottimizzazione si riduce. Possiamo migliorare ancora utilizzando la cache in memoria nativa di JPA ma questo ha ripercussioni sull’occupazione di memoria che aumenta, visto l’elevato numero di campioni in tabella.
Un aspetto importante da considerare è che l’utilizzo della serializzazione binaria comporta il congelamento del data model. Se si aggiungono ad esempio dei campi a una classe, non sarà più possibile accedere ai record memorizzati precedentemente. Possiamo estendere la classe originale con una sottoclasse che contenga i campi nuovi: questa soluzione però viola le regole di design software poichè ci sono più classi che rappresentano lo stesso concetto.
In alternativa, possiamo prevedere una migrazione dei dati, interrompendo il servizio in produzione per effettuare la migrazione. SensorMix mostra un esempio di come sia possibile effettuare una migrazione senza perdere dati grazie all’uso di OSGi e Camel ma, per ragioni di spazio, mostreremo questo esempio in un prossimo articolo.
I vantaggi di JPA
Un altro aspetto interessante è l’implementazione JPA che si utilizza. Ne esistono diverse e ognuna presenta dei vantaggi e degli svantaggi rispetto alle altre. Noi ci siamo concentrati su due implementazioni in particolare: Apache OpenJPA e EclipseLink.
Per scegliere quale implementazione utilizzare, abbiamo preparato i test unitari della nostra applicazione utilizzando JUnit, aggiungendo dei log per stampare i tempi di esecuzione; poi abbiamo creato un persistence.xml di test con tre differenti persistence unit:
<persistence-unit name=“sm_mysql_db_test” transaction-type=“RESOURCE_LOCAL”> <class>com.google.developers.gdgfirenze.datamodeljpa.JpaAbstractSample</class> <class>com.google.developers.gdgfirenze.datamodeljpa.JpaSensor</class> <properties> <property name=“javax.persistence.jdbc.url” value=“jdbc:mysql://localhost:3306/sensormix_db” /> <property name=“javax.persistence.jdbc.user” value=“root” /> <property name=“javax.persistence.jdbc.password” value=“tr3no” /> <property name=“javax.persistence.jdbc.driver” value=“com.mysql.jdbc.Driver” /> <property name=“eclipselink.ddl-generation” value=“drop-and-create-tables” /> </properties> </persistence-unit> <persistence-unit name=“sm_hsql_db_test” transaction-type=“RESOURCE_LOCAL”> <class>com.google.developers.gdgfirenze.datamodeljpa.JpaAbstractSample</class> <class>com.google.developers.gdgfirenze.datamodeljpa.JpaSensor</class> <properties> <property name=“javax.persistence.jdbc.url” value=“jdbc:hsqldb:mem:resource_history” /> <property name=“javax.persistence.jdbc.user” value=“sa” /> <property name=“javax.persistence.jdbc.password” value=““ /> <property name=“javax.persistence.jdbc.driver” value=“org.hsqldb.jdbcDriver” /> <property name=“eclipselink.ddl-generation” value=“drop-and-create-tables” /> <property name=“eclipselink.ddl-generation.output-mode” value=“database” /> </properties> </persistence-unit> <persistence-unit name=“sm_openjpa_mysql_db_test” transaction-type=“RESOURCE_LOCAL”> <provider>org.apache.openjpa.persistence.PersistenceProviderImpl</provider> <class>com.google.developers.gdgfirenze.datamodeljpa.JpaAbstractSample</class> <class>com.google.developers.gdgfirenze.datamodeljpa.JpaSensor</class> <properties> <property name=“javax.persistence.jdbc.url” value=“jdbc:mysql://localhost:3306/sensormix_db” /> <property name=“javax.persistence.jdbc.user” value=“root” /> <property name=“javax.persistence.jdbc.password” value=“tr3no” /> <property name=“javax.persistence.jdbc.driver” value=“com.mysql.jdbc.Driver” /> <property name=“openjpa.RuntimeUnenhancedClasses” value=“supported” /> <property name=“openjpa.jdbc.SynchronizeMappings” value=“buildSchema(SchemaAction='add, deleteTableContents',ForeignKeys=true)” /> </properties> </persistence-unit>
Nel primo caso, utilizziamo EclipseLink con MySQL, nel secondo EclipseLink con HSQLDB e nel terzo OpenJPA con MySQL. Lanciando i test in sequenza per tutti e tre i persistence unit, abbiamo tirato fuori un benchmark reale sui nostri dati e sull’implementazione realizzata, oltre che testare l’implementazione stessa.
In pratica abbiamo scritto una sola implementazione del nostro servizio e i relativi test unitari. Grazie all’astrazione di JPA, possiamo confrontare le prestazioni sia delle implementazioni JPA che dei DBMS in modo da scegliere quello più performante, utilizzando i veri casi d’uso della nostra applicazione. Dai nostri test è risultato che la soluzione migliore è quella di utilizzare EclipseLink con HSQLDB.
JPA e OSGi
Nell’articolo [4] abbiamo spiegato come l’OSGi imponga dei vincoli al classloading. Questo inevitabilmente può creare problemi nell’utilizzo di tecniche quali ad esempio reflection e operazioni sui classpath. Ora, senza spiegare il dettaglio del funzionamento di JPA, ricordiamo solo che, in fase di avvio, il core dell’engine JPA cerca tutte le classi elencate nel persistence.xml per scansionare le annotazioni e creare le entity e di conseguenza il database.
Poichè siamo all’interno di un container OSGi, il bundle dell’engine JPA non può accedere al nostro bundle nè per leggere il persistence nè per leggere le classi e le loro annotazioni. Per fortuna esistono dei wrapper che permettono di utilizzare JPA in ambiente OSGi. Quelli più utilizzati sono Apache Aries e Spring-ORM. Sono entrambi validi, ma al momento Spring-ORM risulta più versatile e soprattutto l’unico compatibile con JPA 2.1.
Per utilizzare Spring-ORM è necessario creare una cartella spring nel resource path di maven con all’interno un file beans.xml.
<context:property-placeholder properties-ref=“dataSourceProperties” /> <bean id=“emf” class=“org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean”> <property name=“persistenceUnitName” value=“sensormix_db” /> <property name=“jpaVendorAdapter”> <bean class=“org.springframework.orm.jpa.vendor.EclipseLinkJpaVendorAdapter”> <property name=“showSql” value=“true” /> </bean> </property> <property name=“jpaProperties”> <props> <prop key=“eclipselink.ddl-generation”>create-tables</prop> <prop key=“eclipselink.logging.level”>INFO</prop> <prop key=“eclipselink.weaving”>false</prop> <prop key=“javax.persistence.jdbc.driver”>${ sensormix_db.driverClassName}</prop> <prop key=“javax.persistence.jdbc.url”>${sensormix_db.url}</prop> <prop key=“javax.persistence.jdbc.user”>${sensormix_db.username}</prop> <prop key=“javax.persistence.jdbc.password”>${ sensormix_db.password}</prop> </props> </property> </bean> <!-- Beans of Test Application --> <bean id=“sensormixService” class=“com.google.developers.gdgfirenze.dataservice.SensormixServiceJpaImpl”> <property name=“entityManagerFactory” ref=“emf” /> </bean>
All’interno del file dichiariamo un bean di tipo LocalContainerEntityManagerFactoryBean, classe messa a disposizione dal framework di Spring, a cui passiamo le informazioni del nostro PersistenceUnit: persistenceUnitName, adapter (ovvero implementazione JPA da utilizzare), property per l’accesso al database e property per le impostazioni tipiche dell’implementazione. Nel file viene anche dichiarato un altro bean di tipo SensormixServiceJpaImpl che è l’implentazione del SensormixService realizzata seguendo le logiche illustrate in precedenza.
Molto interessante notare come i parametri di connessione al DB vengano passati come variabili utilizzando la sintassi ${}. Questo ci permette di valorizzare le variabili attraverso il servizio ConfigAdmin di OSGi illustrato in [4]. Per poter utilizzare questa feature è necessario aggiungere nel file beans.xml le prime 2 righe riportate, ossia la dichiarazione del property-placeholder.
Le inizializzazioni di OSGi e di Spring
Nella nostra cartella spring abbiamo aggiunto anche un file osgi-beans.xml con cui abbiamo separato le inizializzazioni OSGi da quelle di Spring. Con questo piccolo trucco si ha il vantaggio di scindere la dichiarazione delle nostre istanze dalla registrazione dei servizi OSGi.
<osgix:cm-properties id=“dataSourceProperties” persistent-id=“sensormix.jpa.persistenceunit”> <prop key=“sensormix_db.driverClassName”>com.mysql.jdbc.Driver</prop> <prop key=“sensormix_db.url”>jdbc:mysql://localhost:3306/sensormix_db</prop> <prop key=“sensormix_db.username”>itms</prop> <prop key=“sensormix_db.password”>itms</prop> </osgix:cm-properties> <osgi:service ref=“sensormixService”> <osgi:interfaces> <value>com.google.developers.gdgfirenze.service.SensormixService</value> <value>com.google.developers.gdgfirenze.osgi.SensormixAdminInterface</value> </osgi:interfaces> </osgi:service>
Vediamo infatti che nella prima parte dell’osgi-beans.xml ci sono le dichiarazioni di default delle variabili di configurazione che vengono settate nel ConfigAdmin. Queste dichiarazioni verranno sovrascritte nel caso si aggiunga l’apposito file di configurazione nella cartella /etc di Karaf. Nella seconda parte del file vediamo la registrazione di un servizio che implementa due interfacce, SensormixService e SensormixAdminInterface, e che ha come riferimento il bean dichiarato nel file beans.xml.
La variante con OpenJPA
Se per qualche motivo decidessimo di utilizzare OpenJPA invece di EclipseLink basterà semplicemente sostituire la dichiarazione del bean emf nel beans.xml con quella seguente:
<bean id=“emf” class=“org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean”> <property name=“persistenceUnitName” value=“sensormix_db” /> <property name=“jpaVendorAdapter”> <bean class=“org.springframework.orm.jpa.vendor.OpenJpaVendorAdapter”> <property name=“showSql” value=“true” /> </bean> </property> <property name=“jpaProperties”> <props> <prop key=“javax.persistence.jdbc.driver”>${ sensormix_db.driverClassName}</prop> <prop key=“javax.persistence.jdbc.url”>${sensormix_db.url}</prop> <prop key=“javax.persistence.jdbc.user”>${sensormix_db.username}</prop> <prop key=“javax.persistence.jdbc.password”>${ sensormix_db.password}</prop> <prop key=“openjpa.RuntimeUnenhancedClasses”>supported</prop> <prop key=“openjpa.Log”>DefaultLevel=TRACE, Runtime=TRACE, Tool=INFO, SQL=TRACE </prop> <prop key=“openjpa.jdbc.SynchronizeMappings”>buildSchema( SchemaAction='add,deleteTableContents',ForeignKeys=true) </prop> </props> </property> </bean>
Se invece decidiamo di utilizzare PostgresSQL o HSQLDB al posto di MySQL, basterà cambiare il driver definito nell’osgi-beans.xml.
Conclusioni
In questo articolo vi abbiamo mostrato come approcciare la persistenza partendo dalla logica di business per trovare il modo migliore di rappresentare in nostri dati su un database. Abbiamo fatto delle scelte implementative senza concetti precostituiti, ma basandoci su considerazioni pratiche quali la leggibilità del codice e l’utilizzo reale dei dati. Abbiamo poi validato le nostre scelte utilizzando strumenti di uso comune senza portar via tempo agli sviluppi. Abbiamo infine mostrato come, ancora una volta, il framework OSGi ci permetta di implementare software modulari e flessibili. Certo non siamo diventati tutti DBA e non potremo richiedere un aumento al lavoro, ma abbiamo aggiunto altri bit alla nostra conoscenza e quindi alla nostra vita.
Giuseppe Gerla è software engineering manager presso Thales Italia, sede di Firenze. Si è laureato in ingegneria elettronica nel 2005 e, negli anni a seguire, ha accumulato esperienze in molti linguaggi di programmazione e ambienti di sviluppo, realizzando software real-time per sistemi embedded, CAD di progettazione 3D e sistemi di supervisione. Recentemente ha concentrato i suoi sforzi per la realizzazione di un sistema di supervisione per tramvie ad alta scalabilità e affidabilità.