Nel post precedente abbiamo visto la definizione di interfacce e i vantaggi a esse collegati, sia in generale, sia declinate al mondo LabVIEW. Questo post riprende quindi i concetti visti illustrando un esempio pratico sull’uso di interfacce e classi. L’obiettivo del post è quello di fornire maggiori spunti e strumenti per utilizzare le interfacce con problemi reali.
Requisiti
Supponiamo di dover sviluppare un’applicazione per la gestione di una stazione di monitoraggio della qualità dell’aria. I requisiti prevedono che:
- L’applicazione gestisca una stazione di monitoraggio della qualità dell’aria (stazione principale), alla quale sono connessi dei sensori e un’altra stazione (stazione periferica).
- L’applicazione legga con un rate prefissato i valori di PM10 e di concentrazione di monossido di carbonio CO.
- L’applicazione legga da due sensori di polveri sottili PM10 e di monossido di carbonio CO, connessi alla stazione di monitoraggio principale.
- L’applicazione legga i valori di PM10 e CO acquisiti dalla stazione di monitoraggio periferica.
- L’applicazione logghi tutti i dati acquisiti su un terminale console.
- L’applicazione debba essere aperta ad estensioni come il supporto ad altre tipologie di sensori e ad altre modalità di logging.
Progettazione
Ora che abbiamo i requisiti dell’applicativo, passiamo alla progettazione di classi e interfacce.
Innanzitutto, prevediamo la classe Measurement usata per contenere tutti i dati relativi ad una misurazione: valore, unità di misura e la sorgente del dato di misura.
Passiamo ora alla parte di logging.
I requisiti richiedono di loggare i dati acquisiti su un terminale console. Creiamo quindi l’interfaccia DataLogger che definisce il metodo LogData e la classe ConsoleLogger che implementa tale interfaccia. L’uso dell’interfaccia DataLogger rende l’applicazione aperta ad altre modalità di logging. A titolo di esempio, definiamo anche le classi SQLDatabase e FileLogger. Entrambe implementano l’interfaccia DataLogger.
Prevediamo poi la classe DataLoggerManager che ha la responsabilità di gestire il logging dei dati. La classe DataLoggerManager possiede un attributo di tipo DataLogger (relazione di composizione). In questo modo, poiché la classe DataLoggerManager dipende dall’interfaccia DataLogger e non da una classe concreta, è possibile cambiare le modalità di logging iniettando un qualsiasi oggetto che soddisfi l’interfaccia DataLogger.
Di seguito è riportato il diagramma UML dell’interfaccia DataLogger, le classi che la implementano e la classe DataLoggerManager.
Procediamo la progettazione di classi e interfacce definendo l’architettura relativa ai sensori. Utilizziamo la classe base Sensor che definisce attributi come Name (nome del sensore), Serial Number, LastCalibration (data di ultima calibrazione) e metodi come Calibrate. Si noti che, ad eccezione di Name, i membri della classe Sensor non servono a soddisfare i requisiti riportati sopra. Servono invece a rendere l’esempio più vicino ad un’applicazione reale.
Rappresentiamo poi i sensori di polveri sottili PM10 e monossido di carbonio CO tramite due classi concrete derivate da Sensor. Le due classi sono rispettivamente la classe PM10Sensor e COSensor.
L’abilità dei due sensori di acquisire il valore di concentrazione di PM10 e di CO nell’aria non è definita nella classe Sensor. Sono invece definite due interfacce dedicate:
- CanMeasurePM10 che definisce il metodo GetPM10Value.
- CanMeasureCO che definisce il metodo GetCOValue.
Utilizzando le due interfacce sopra, le due classi che modellizzano i due tipi di sensori implementeranno solo l’interfaccia che effettivamente soddisfano. La classe PM10Sensor infatti implementa l’interfaccia CanMeasurePM10 e la classe COSensor implementa l’interfaccia CanMeasureCO.
Con questo approccio, se dovessimo aggiungere un nuovo tipo di sensore in grado di acquisire sia la concentrazione di PM10 che quella di CO, creeremo una nuova classe che implementerà entrambe le interfacce. Inoltre, se in futuro dovessimo acquisire un nuovo tipo di dato, creeremo una nuova interfaccia dedicata, senza toccare il codice esistente. Ciò soddisfa uno dei requisiti specificati sopra.
Di seguito è riportato il diagramma UML delle classi che modellizzano i sensori e le interfacce ad esse associate.
Concludiamo la progettazione di classi e interfacce modellizzando le stazioni di monitoraggio. Anche in questo caso prevediamo una classe base (AirMonitoringStation) e due classi derivate: MainStation e PeripheralStation.
La classe AirMonitoringStation definisce le proprietà Name (che è un nome rappresentativo per la stazione) e Location (che indica la posizione della stazione di monitoraggio). Anche in questo caso, si noti che la proprietà Location non è necessaria per il soddisfacimento dei requisiti ma rende più concreto l’esempio corrente.
Come da requisiti, la stazione periferica è in grado di fornire alla stazione principale i valori di concentrazione di PM10 e di CO. Dal punto di vista dell’acquisizione dati, quindi, la stazione periferica ha lo stesso ruolo dei due sensori di PM10 e di CO. Per questa ragione e per semplificare il lavoro alla stazione principale, sfruttiamo le stesse interfacce usate per i due sensori. Poiché la stazione periferica fornisce sia il valore di concentrazione di PM10 che quello di CO, la classe PeripheralStation implementa entrambe le interfacce: CanMeasurePM10 e CanMeasureCO.
La classe MainStation ha il compito di prelevare i valori di PM10 e CO dalle sorgenti dato che gestisce (cioè dai sensori e dalla stazione periferica). Grazie all’uso delle interfacce, la classe MainStation non dipende dalle classi concrete ma dalle interfacce stesse, disaccoppiando quindi tra loro le classi concrete e rendendo l’applicativo più flessibile, più facilmente manutenibile e testabile.
La classe MainStation, quindi, ha nei proprio attributi due liste, rispettivamente di tipo CanMeasurePM10 e CanMeasureCO. In aggiunta, la classe ha due metodi dedicati per leggere i valori di PM10 e di CO da tutte le sorgenti dato che gestisce. Tali metodi sono: GetPM10Values e GetCOValues.
Di seguito è riportato il diagramma UML delle classi che modellizzano le stazioni di monitoraggio e delle interfacce coinvolte.
Il diagramma UML di seguito riporta infine tutte le classi e interfacce definite e le loro relazioni. Per una più facile lettura del diagramma, la classe Measurement non è riportata.
Inplementazione
Implementiamo passo passo le classi e interfacce progettate sopra. In particolare, riporto frammenti di codice e screenshot sia per la versione LabVIEW che per la versione C#.
Partiamo dalla classe Measurement:
{
public double Value { get; set; }
public string Unit { get; set; }
public string Source { get; set; }
public Measurement(double value, string unit, string source)
{
this.Value = value;
this.Unit = unit;
this.Source = source;
}
}
Passiamo poi alle classi relative alla gestione dei sensori (classi Sensor, PM10Sensor e COSensor) e alle interfacce ad esse associate (CanMeasurePM10 e CanMeasureCO).
Le due interfacce CanMeasurePM10 e CanMeasureCO definiscono rispettivamente i metodi GetPM10Value e GetCOValue. Entrambi i metodi ritornato un oggetto di tipo Measurement:
{
public Measurement GetPM10Value();
}
public interface CanMeasureCO
{
public Measurement GetCOValue();
}
Come da progettazione, la classe Sensor definisce le proprietà Name, SerialNumber e LastCalibration e il metodo Calibrate:
{
public string Name { get; set; }
public string SerialNumber { get; set; }
public DateTime LastCalibration { get; set; }
public Sensor(string serialNumber, string name)
{
this.SerialNumber = serialNumber;
this.Name = name;
}
public virtual void Calibrate()
{
// Calibrate
this.LastCalibration = DateTime.Now;
}
}
Concludiamo l’implementazione della parte che riguarda i sensori con le due classi derivate PM10Sensor e COSensor. Le due implementano rispettivamente l’interfaccia CanMeasurePM10 e CanMeasureCO:
{
public PM10Sensor(string serialNumber, string name) :
base(serialNumber, name) { }
public Measurement GetPM10Value()
{
return new Measurement(new Random().Next(0,100),"ug/m3", this.Name);
}
}
public class COSensor : Sensor, CanMeasureCO
{
public COSensor(string serialNumber, string name) :
base(serialNumber, name) { }
public Measurement GetCOValue()
{
return new Measurement(new Random().Next(0, 60), "mg/m3", this.Name);
}
}
Passiamo ora all’implementazione della classe AirMonitoringStation e figlie.
La classe astratta AirMonitoringStation è un semplice contenitore degli attributi Location e Name:
{
public string Location { get; set; }
public string StationName { get; set; }
protected AirMonitoringStation(string location, string stationName)
{
this.Location = location;
this.StationName = stationName;
}
}
Come da progettazione, deriviamo due classi da AirMonitoringStation: MainStation e PeripheralStation.
La classe PeripheralStation, oltre ad essere derivata da AirMonitoringStation, implementa le due interfacce definite usate anche per i sensori (CanMeasurePM10 e CanMeasureCO):
{
public PeripheralStation(string location, string stationName) :
base(location, stationName) { }
public Measurement GetCOValue()
{
return new Measurement(new Random().Next(0, 60), "mg/m3", this.StationName);
}
public Measurement GetPM10Value()
{
return new Measurement(new Random().Next(0, 100), "ug/m3", this.StationName);
}
}
La classe MainStation invece è un po’ più complessa. Come definito in progettazione, la classe dipende dalle interfacce CanMeasurePM10 e CanMeasureCO e ha due metodi distinti per la lettura rispettivamente di PM10 e CO:
{
public MainStation(string location, string stationName) : base(location, stationName) { }
public List<CanMeasureCO>? COSources { get; set; }
public List<CanMeasurePM10>? PM10Sources { get; set; }
public List<Measurement>? GetPM10Values()
{
if (this.PM10Sources is null)
{
return null;
}
var measurementList = new List<Measurement>();
foreach (var item in this.PM10Sources)
{
measurementList.Add(item.GetPM10Value());
}
return measurementList;
}
public List<Measurement>? GetCOValues()
{
if (this.COSources is null)
{
return null;
}
var measurementList = new List<Measurement>();
foreach (var item in this.COSources)
{
measurementList.Add(item.GetCOValue());
}
return measurementList;
}
}
Passiamo infine alla parte di logging creando l’interfaccia DataLogger che definisce il metodo LogData. Il metodo LogData riceve in ingresso il parametro stringa data:
{
public void LogData(string data);
}
L’interfaccia DataLogger è soddisfatta dalla classe ConsoleLogger responsabile di scrivere su console il dato da loggare. Si noti che per la versione LabVIEW, dove l’output su console non è nativamente supportato, la console è simulata scrivendo in un indicatore stringa.
{
public void LogData(string data)
{
Console.WriteLine($"New record: {data}");}
}
}
Abbiamo poi la classe LoggerManager che gestisce l’operazione di logging. La classe non dipende da classi concrete ma dall’interfaccia DataLogger. Tramite il costruttore (o il metodo Create per LabVIEW) si inietta l’oggetto concreto (nel nostro caso l’oggetto ConsoleLogger).
{
private DataLogger logger;
public DataLoggerManager(DataLogger logger)
{
this.logger = logger;
}
public void LogData(string data)
{
this.logger.LogData(data);
}
}
Concludiamo l’esempio creando l’applicazione principale che mette assieme tutte le classi e interfacce create sopra.
Il programma inizializza gli oggetti di tipo MainStation, PeripheralStation, PM10Sensor, COSensor e ConsoleLogger. Quest’ultimo è poi iniettato nell’oggetto di tipo LoggerManager.
La lista di sorgenti PM10 per l’oggetto MainStation è composta dal sensore PM10Sensor e dall’oggetto PeripheralStation. Analogamente, la lista delle sorgenti di dati CO è composta dal sensore COSensor e di nuovo dall’oggetto PeripheralStation.
Infine, ad intervalli regolari, il programma interroga le sorgenti di PM10 e di CO e ne logga i dati tramite l’oggetto LoggerManager.
{
static void Main(string[] args)
{
// Initialization of Stations
var mainStation = new MainStation(location: "location 1", "Main Station");
var peripheralStation = new PeripheralStation(location: "location 2", "Peripherical Station 1");
// Initialization of Sensors
var myPM10Sensor = new PM10Sensor(serialNumber: "1234","PM10 sensor 1");
var myCOSensor = new COSensor(serialNumber: "ABCD", "CO sensor 1");
// Initalization of the LoggerManager obj
var loggerManager = new DataLoggerManager(new ConsoleLogger());
// Assignement of PM10 and CO sources to the mainStation
mainStation.PM10Sources = new List<CanMeasurePM10> { myPM10Sensor, peripheralStation };
mainStation.COSources = new List<CanMeasureCO> { myCOSensor, peripheralStation };
while (!Console.KeyAvailable)
{
// Return and log PM10 values
foreach (var measurement in mainStation.GetPM10Values())
{
loggerManager.LogData($"{measurement.Source} - {measurement.Value} {measurement.Unit}");
}
// Return and log CO values
foreach (var measurement in mainStation.GetCOValues())
{
loggerManager.LogData($"{measurement.Source} - {measurement.Value} {measurement.Unit}");
}
Thread.Sleep(1000);
}
}
}
Ed ecco qui l’esempio funzionante:
Con questo esempio si conclude il discorso sulle interfacce e i loro utilizzi. Grazie per il tempo dedicato alla lettura di questo articolo. Mi auguro che i concetti esposti siano sufficientemente chiari e possano essere d’aiuto nel risolvere problemi reali. Nel caso di dubbi, richieste o semplici curiosità, sono disponibile qui.
Lascio qui sotto il link per il download del progetto LabVIEW (versione 2023) e qualche link con altri esempi nell’uso di interfacce (per LabVIEW e non solo).
Al prossimo post!