Groovy im Fluss - ein Beispiel aus der Praxis
Wenn es einem Wasserzähler mal schlecht geht und er falsche Werte anzeigt, dann kann das daran liegen, dass er Mist misst. Dann wird er in der Regel auf Herz und Nieren überprüft. Wasserzählern aus Berlin passiert dies bald auf dynamische Art mit Groovy in einem Javaprojekt.
Ich arbeite für meine Firma akquinet in einem Projekt bei den Berliner Wasserbetrieben in Berlin als Entwickler. Die BWB nimmt sogenannte Befundprüfungen bei Wasserzählern vor, denen nicht mehr zugetraut wird, dass sie die korrekten qm Wasser anzeigen, die durch sie hindurch gerauscht sind. Und das geht so: Der Zähler wird in eine Apparatur eingebaut, den Prüfstand, und durch ihn definierte Mengen Wasser gepumpt. Die Abweichung, die bei der Messung zwischen der tatsächlichen und der erwarteten Anzeige des Zählers entstehen, werden in einer Datei notiert.
Das System, an dem mein Projekt arbeitet, verwaltet den Lebenszyklus solch eines Wasserzählers, und also verwalten wir auch den Weg des Zählers während der Befundprüfung. Da müssen dann auch Messurkunden erstellt werden, in welchen das Messverhalten der Zähler festgehalten wird. Unter anderem steht in einer solchen Urkunde die Messabweichungen in Prozent (die aus der Datei des Prüfstandes). Und es sollen die Durchflüsse in Liter pro Zeiteinheit da drin aufgeführt sein, die zu dieser Messabweichung geführt haben. Dummerweise stehen diese Durchflüsse aber nicht in der Datei vom Prüfstand - aber man kann sie errechnen aus einer Tabelle.
Jeder Wasserzähler hat eine Nennweite oder einen Nenndurchfluss (genauer beschrieben auf der-brunnen.de). Die Nennweite ist in m³/h angegeben und zeigt, dass der Zähler für eine Wassermenge von soundsoviel Wasser pro Stunde ausgelegt ist. Dann sind solche Zähler noch in Klassen eingeteilt, A bis C. Und schließlich gibt es die unterschiedlichen Prüfpunkte bei der Messung, Qmin, Qmax und Qtrenn genannt. Man schaue sich folgende Tabelle an:
Nennweite < 15 | Nennweite >= 15 | |
Klasse A | Qmin: 0,04 Qtrenn: 0,10 | Qmin: 0,08 Qtrenn: 0,30 |
Klasse B | Qmin: 0,02 Qtrenn: 0,08 | Qmin: 0,03 Qtrenn: 0,20 |
Klasse C | Qmin: 0,01 Qtrenn: 0,015 | Qmin: 0,006 Qtrenn: 0,015 |
In dieser Tabelle kann man nun, wenn man die Nennweite, die Klasse und den Prüfpunkt des Zählers kennt, zu einem Faktor gelangen. Wird nun dieser Faktor mit der Nennweite des Zähler multipliziert, dann ergibt sich der Durchfluss. Der Prüfpunkt Qmax fehlt, denn Qmax liefert immer den Faktor 2, ist also unabhängig von Klasse und Nennweite.
Ein Beispiel: Wir untersuchen einen Zähler der Klasse A mit einer Nennweite von 2,5 am Prüfpunkt Qmin und bekommen eine Messabweichung von 10%. Dann kann man in der Tabelle nachschlagen: Die Nennweite 2,5 m³/h ist kleiner als 15 m³/h, also linke Spalte anschauen. Klasse A ist gegeben, also obere linke Zelle anschauen. Am Prüfpunkt Qmin soll gemessen werden, also Faktor 0,04. Nennweite 2,5 m³/h mal Faktor 0,04 ergibt einen erwarteten Durchfluss von 0,1 m³/h oder 100 l/h (Liter pro Stunde). Eine Messabweichung von 10% bedeutet also, dass 110 l/h tatsächlich gemessen wurden.
Okay, so weit, so gut. Der Kunde wollte nun, dass unser System zum einen den tatsächlichen Durchfluss zu einer gegebenen Messabweichung errechnet. Und er wollte, dass die Faktorentabelle leicht änderbar bleiben sollte, da die Faktoren sich wohl ab und an ändern würden (er sprach von einer "Propertiesdatei" - muss sich zu oft mit Javaentwicklern unterhalten haben ;-) ).
Herrje, was tun? Unser Projekt ist ein reines Javaprojekt. Die Tabelle könnte man also tatsächlich in einer Propertiesdatei abbilden, aber das wäre nicht sehr schön, oder? So viele Zeilen mit so viel Redundanz drin. Abgesehen mal davon, dass man ein Problem hätte, kleiner oder größer gleich 15 abzubilden. Und was wäre, wenn zusätzliche Bereiche abgedeckt werden müßten, also z.B. kleiner 2, größer gleich 2 bis kleiner 15 und größer gleich 15? Nee, Propertiesdateien sind gut für andere Dinge.
Was ist mit XML? Jaja, abgesehen vom Klammerwahn, der mich in letzter Zeit immer öfter befällt, wenn ich mit XML-Dateien zu tun habe. Wenn man die Tabelle in XML gegossen hätte, dann könnte man doch gut per XPath auf die Daten zugreifen, oder? Naja, könnte man, wenn da nicht die Bereiche wie 0 bis 15 wären - wie greift man pragmatisch auf Bereiche in XML zu?
An diesem Punkt habe ich dann die GroovyConsole aufgemacht und ein wenig zu experimentieren begonnen. Groovy verfügt quasi von Haus aus über Ranges. Die Notation sieht so aus:
assert 1..3 == [1,2,3]
1..3
ist ein Range vom den Integerwerten 1 bis 3. Er kann wie eine Liste von 1, 2 und 3 verstanden werden.Kleiner 15 und größer gleich 15 kann man sich auch als Ranges vorstellen: 0 bis 14,9999... und 15 bis unendlich bzw. bis zum größtmöglichen im System darstellbaren Fließkommawert. In Groovy kann man das so darstellen:
0.0..(15.0 - (Float.MIN_VALUE as BigDecimal))
15.0..(Float.MAX_VALUE as BigDecimal)
Warum mache ich soetwas wie
15.0 - (Float.MIN_VALUE as BigDecimal)
? Weil, es gilt, in Java wie auch in Groovy:assert 15 == 15 - Float.MAX_VALUE
Das kommt von der Bytereihenfolge im Rechner (engl. Endianess).
In Groovy ist ein Fließkommawert immer BigDecimal. Wenn ich nun also
Float.MIN_VALUE
in BigDecimal
konvertiere (in Groovy elegant gelöst durch das Schlüsselwort as
), dann ergibt sich der kleinste darstellbare Wert unterhalb von 15.Gut, die oberste Tabellenzeile ist abgebildet. Man stelle sich das nun in einer Map vor. Maps werden von Groovy auch als First Class Citizens behandelt. Das sieht dann so aus:
def map =
[(0.0..(15.0 - (Float.MIN_VALUE as BigDecimal))):
'A',
(15.0..(Float.MAX_VALUE as BigDecimal)):
'B']
map.find{ eintrag -> 12.0 in eintrag.key}.value
Führt man dies in einer GroovyConsole aus, dann ist das Ergebnis
'A'
. Über die find
-Methode können die Einträge einer Map durchsucht werden. Die Closure, die ich dieser Methode mitgebe, wird zur Iteration über alle Einträge benutzt. Ein Eintrag besteht aus dem Schlüssel (key
) und dem Wert (value
). Wenn der Rückgabewert der Closure true
ist, dann wird der entsprechende Eintrag zurückgegeben (beachte: return
gibt es zwar noch als Schlüsselwort in Groovy, aber wenn's nicht angeben wird, dann gilt die letzte Anweisung im Codeblock automatisch als Rückgabewert). In obigem Beispiel ist 12.0
nur in dem Range enthalten, dem als Schlüssel der Wert 'A'
entspricht.Super, mit Maps hatte ich also recht schnell eine Struktur gefunden, mit der ich mir die Durchflüsse berechnen kann. Der Rest ging dann fast wie von selbst und heraus kam dann das hier:
class DurchflusstabelleImpl {
static def QMAX_FAKTOR = 2
static def tabelle = [
(0.0..(15.0 - (Float.MIN_VALUE as BigDecimal))):
[
A:[QMIN: 0.04,
QTRENN: 0.10,
QMAX: QMAX_FAKTOR],
B:[QMIN: 0.02,
QTRENN: 0.08,
QMAX: QMAX_FAKTOR],
C:[QMIN: 0.01,
QTRENN: 0.015,
QMAX: QMAX_FAKTOR]
],
(15.0..(Float.MAX_VALUE as BigDecimal)):
[
A:[QMIN: 0.08,
QTRENN: 0.30,
QMAX: QMAX_FAKTOR],
B:[QMIN: 0.03,
QTRENN: 0.20,
QMAX: QMAX_FAKTOR],
C:[QMIN: 0.006,
QTRENN: 0.015,
QMAX: QMAX_FAKTOR]
]
]
int get(float nennweite,
String metrologischeKlasse,
String gesuchterWert,
float messabweichungInProzent) {
def faktor = tabelle
.find{ eintrag -> // #1
(nennweite as BigDecimal) in eintrag.key
}.value
.find{ eintrag -> // #2
metrologischeKlasse == eintrag.key
}.value
.find{ eintrag -> // #3
gesuchterWert == eintrag.key
}.value
def durchfluss = // #4
nennweite * faktor * 1000
(durchfluss +
durchfluss * messabweichungInProzent / 100)
.round()
}
}
Erklärt sich jetzt fast von selbst, gell? Die Map-Verschachtelung ist die konsequente Weiterführung des obigen Beispieles: Die Nennweitenbereiche sind Schlüssel in Maps, deren Werte wiederum Maps mit Klassen als Schlüssel sind, deren Werte wiederum Maps mit Prüfpunkten als Schlüssel sind, deren Werte dann schließlich die Faktoren sind, um die es hier die ganze Zeit geht.
Das Heraussuchen geht dann auch sehr einfach: an Stelle
#1
wird der Nennweiten-Bereich herausgesucht, an Stelle #2
die Klasse und an Stelle #3
dann der Prüfpunkt, der dann schließlich den gesuchten Faktor ausspuckt. An Stelle #4
wird dann der Durchfluss (in Liter) berechnet und an Stelle #5
dann schließlich die Messabweichung hinzuberechnet. Fertig. Man stelle sich vor, man hätte dies auf diese Weise in Java programmiert - brrrr, da schüttelt's mich.(Anmerkung am Rande: Warum hab' ich da drei
find
-Methoden benutzt? Groovy erlaubt den Zugriff auf Maps viel eleganter, etwa so:def map = [a:1]
assert 1 == map['a']
assert 1 == map.a
Es hätte also völlig gereicht, wenn ich die Abfragen
#1
, #2
und #3
so geschrieben hätte; um an den Faktor zu kommen:def faktor = tabelle
.find{ eintrag -> // #1
(nennweite as BigDecimal) in eintrag.key
}.value[metrologischeKlasse][gesuchterWert]
// ^#2 ^#3
Da war ich auch hin- und hergerissen, ob ich das nicht tatsächlich wie in der zweiten Variante gezeigt hätte schreiben sollen. Aus Gründen der Lesbarkeit habe ich mich dagegen entschieden: Es sieht mit drei
find
-Methoden symmetrischer aus und man erkennt besser die drei Ebenen der Abfrage (Nennweitenbereich, Klasse und Prüfpunkt).Wirklich fertig? Naja, fast. Das war eine Groovy-Lösung, und wir haben ein Javaprojekt. Genau, ist gar kein Problem, weil einfach zu lösen:
Die Tabelle ist in einer Groovydatei und dort schon gleich in einer Klasse namens
DurchflusstabelleImpl
. Bei uns im Projekt gilt die Konvention: XImpl
ist die Default-Implementierung des Typs und damit Java-Interfaces X
. Das Interface sieht in Java so aus:public interface Durchflusstabelle {
int get(float nennweite,
String metrologischeKlasse,
String gesuchterWert,
float messabweichungInProzent);
}
Diesen Typ implementieren wir in Groovy, wie wir es in Java auch tun würden: per
implements
:class DurchflusstabelleImpl
implements Durchflusstabelle {
/* Implementierung */
}
Damit hat die Groovyklasse das Javainterface implementiert.
Jetzt erklärt das auch, warum die Methode
get
eine typisierte Signatur hat. Stattint get(float nennweite,
String metrologischeKlasse,
String gesuchterWert,
float messabweichungInProzent)
hätte ich ja auch einfach
def get(nennweite,
metrologischeKlasse,
gesuchterWert,
messabweichungInProzent)
schreiben können. Aber dann würde nicht die vom Interface vorgeschrieben Methode implementiert werden, sondern die Methode
Object get(Object nennweite,
Object metrologischeKlasse,
Object gesuchterWert,
Object messabweichungInProzent)
Genug der Details. Wie erzeuge ich nun ein Objekt vom Typ
Durchflusstabelle
? Hierzu gibt es in Groovy mehrere Mechanismen. Ich habe mich für den GroovyClassLoader entschieden. Eine Factory soll uns die Erzeugung des begehrten Objektes kapseln. In Java sieht das dann so aus:import groovy.lang.GroovyClassLoader;
public class DurchflusstabelleFactory
{
private static final String DURCHFLUSSTABELLE_GROOVY =
"durchflusstabelle.groovy";
public static Durchflusstabelle createDurchflusstabelle()
{
Class clazz = DurchflusstabelleFactory.class;
ClassLoader parent = clazz.getClassLoader(); // #1
GroovyClassLoader loader = new GroovyClassLoader(parent);
Class groovyClass = loader.parseClass( #2
clazz.getResourceAsStream(DURCHFLUSSTABELLE_GROOVY));
try
{
// #3
return (Durchflusstabelle) groovyClass.newInstance();
}
catch (Exception programmierfehler)
{
throw new RuntimeException("Konnte Klasse " +
DURCHFLUSSTABELLE_GROOVY + " nicht laden!",
programmierfehler);
}
}
}
Erklärt sich eigentlich schon fast von selbst: An Stelle
#1
besorgen wir uns den Java-Classloader, der die Factory geladen hat. Damit erzeugen wir einen neuen Kind-Classloader, den GroovyClassLoader
. Bei #2
laden wir dann die Groovyklasse, übergeben sie dem Groovy-Klassenlader zum Parsen und erhalten ein Java-Class
-Objekt zurück. Mit dem können wir dann bei #3
per normalem Java-Reflection eine Instanz erzeugen und damit hat die Factory ihre Aufgabe erfüllt.Mit dem von der Factory erzeugten Objekt kann man übrigens wie mit jedem anderen Javaobjekt umgehen, es gibt da nichts weiter zu beachten. Durch die Factory und das Interface haben wir die Durchflusstabelle komplett gekapselt: Kein Benutzer dieser Groovy-Tabelle weiss etwas von ihrer wider-Java-natürlichen Herkunft, was ja durchaus ein Thema sein kann, wenn im Team noch nicht alle Groovy sprechen.
Der Clou an der Sache ist, wenn man sich nochmal die Anforderungen des Kunden anschaut, die da nach einer Art Propertiesdatei riefen: Dank Groovy haben wir eine ausführbare Spezifikation geschaffen, eine Plusquampropertiesdatei sozusagen. Ändern sich die Faktoren der Durchflusstabelle beim Kunden, dann kann er in die Datei direkt die neuen Faktoren schreiben. Ja mehr noch, sogar neue Klassen und Messpunkte kann er ohne großes Aufsehen da definieren (okay, okay, bei den Nennweiten-Bereichen würde ich ihn dann doch lieber an die Hand nehmen wollen, nur für alle Fälle).
Wir haben diese Story, den Arbeitspunkt, übrigens viermal so hoch eingeschätzt, wie wir dann tatsächlich dafür gebraucht haben. Das schreibe ich nicht der effizienteren Arbeitsweise dank Groovy zu - es könnte auch einfach die Euphorie und der Spaß gewesen sein, mit Groovy zu arbeiten ;-)
Die Klassen
DurchflusstabelleFactory
und DurchflusstabelleImpl
habe ich bei ByteMyCode abgelegt.