Die verborgene Schätze von Faktor-IPS: Eigene Datentypen definieren
Neben Standard-Java-Primitiven und Wertetypen wie int und Double gibt es ein paar vordefinierte Datentypen wie Money (ein dezimaler Betrag mit fester Genauigkeit, kombiniert mit einer Währung) und die Möglichkeit in Faktor-IPS definierte Aufzählungen als Datentypen zu verwenden.

In dieser Folge der verborgenen Schätze von Faktor-IPS möchte ich zeigen wie einfach es ist eigene Datentypen zu erstellen und einige Anregungen geben, welche Datentypen man verwenden könnte. Das Konzept der Datentypen und welche von Faktor-IPS direkt unterstützt werden eklärt dieses PDF. Neben Standard-Java-Primitiven und Wertetypen wie int und Double gibt es ein paar vordefinierte Datentypen wie Money (ein dezimaler Betrag mit fester Genauigkeit, kombiniert mit einer Währung) und die Möglichkeit in Faktor-IPS definierte Aufzählungen als Datentypen zu verwenden. Im PDF gibt es auch ein kleines Beispiel für einen eigenen IsoDate
Datentypen, das ich hier etwas näher betrachten möchte:
public class IsoDate { private int year; private int month; private int day; public IsoDate(int year, int month, int day) { this.year = year; this.month = month; this.day = day; } public int getYear() { return year; } public int getMonth() { return month; } public int getDay() { return day; } public String toString() { String m = month < 10 ? "0" + month : "" + month; String d = day < 10 ? "0" + day : "" + day; return year + '-' + m + '-' + d; } @Override public int hashCode() { // TODO } @Override public boolean equals(Object obj) { // TODO } public static final IsoDate valueOf(String s) { if (s == null || s.equals("")) { return null; } String[] tokens = s.split("-"); int year = Integer.parseInt(tokens[0]); int month = Integer.parseInt(tokens[1]); int date = Integer.parseInt(tokens[2]); return new IsoDate(year, month, date); } }
Der Datentyp ist ein Objekt mit ein paar Attributen, das in einen String geschrieben und aus ihm instatiiert werden kann. Um den Datentypen zu verwenden muss der <DatatypeDefinitions>
-Section der .ipsproject-Datei ein Eintrag hinzugefügt werden:
<Datatype id="IsoDate" javaClass="org.faktorips.sample.datatypes.IsoDate" valueOfMethod="valueOf" />
Da der Datentyp direkt im Modellprojekt definiert ist muss noch javaProjectContainsClassesForDynamicDatatypes="true"
im Wurzeltag <IpsProject>
gesetzt werden.
Aber um ehrlich zu sein ist noch eine Datumsklasse so ziemlich das letzte was die Java-Welt braucht. Welche anderen Beispiele können wir finden? Es gibt einige weithin genutzte Klassen im JRE, die aus einem String instantiiert werden können. Nehmen wir zum Beispiel java.util.Currency
. Ein Währungsobjekt wird aus einem ISO 4217 Währungscode erstellt der auch von toString
zurückgegeben wird.
<Datatype id="Währung" javaClass="java.util.Currency" valueOfMethod="getInstance" />
Wir können noch einen Schritt weiter gehen. Währungen sind Wertobjekte (jede Instanz mit dem gleichen Währungscode ist funktional gleich). Dies können wir unserem Datentypen als Attribut hinzufügen(valueObject="true"
). Jetzt können wir die Währung in Faktor-IPS verwenden, aber wir müssen die ISO 4217 Währungscodes kennen und als String eintippen um sie zu benutzen. Wäre es nicht besser, aus einer Liste aller bekannter Währungen auswählen zu können? Wenn man die Dokumentation der DatatypeDefinitions in der .ipsproject
-Datei betrachtet findet man eine Eigenschaft isEnumType
mit zugehöriger getAllValuesMethod
. Erstere auf true
gesetzt un zweitere auf getAvailableCurrencies
und schon laufen wir in eine unschöne java.lang.ClassCastException: java.util.HashSet cannot be cast to [Ljava.lang.Object;
. Leider erwartet Faktor-IPS ein Array als Rückgabewert. Keine Sorge, weiter unten gibt es auch für dieses Problem eine Lösung, aber zuerst möchte ich eine andere Klasse betrachten, die besser auf die Erwartungen passt; java.util.TimeZone
zum Beispiel:
<Datatype id="TimeZone" javaClass="java.util.TimeZone" valueOfMethod="getTimeZone" valueObject="true" isEnumType="true" getAllValuesMethod="getAvailableIDs"/>
Et voilá, ein neuer IPS-Datentyp ohne eine einzige Zeile Javacode.
Die meisten Projekte haben bereits einige Aufzählungen für andere Systeme definiert, die sie gerne auch mit Faktor-IPs weiter verwenden möchten – nichts leichter als das. Nehmen wir als Beispiel die Sparten einer Versicherung:
public enum LineOfBusiness { FIRE, LIFE, HEALTH, LIABILITY }
Der zugehörige Datentyp sähe zum Beispiel so aus:
<Datatype id="Sparte" javaClass="org.faktorips.sample.datatypes.LineOfBusiness" valueObject="true" isEnumType="true" valueOfMethod="valueOf" getAllValuesMethod="values" />
Aber wenn man diesen Datentypen in Faktor-IPS nutzt sieht das mit den komplett groß geschriebenen Enum-Literalen nicht all zu gut aus. Vielleicht hat unsere Aufzählung ja auch schon ein nutzerfreundlicheres String-Attribut das wir verwenden können:
public enum LineOfBusiness { FIRE("Feuer"), LIFE("Leben"), HEALTH("Kranken"), LIABILITY("Haftpflicht"); private final String displayName; private LineOfBusiness(String displayName) { this.displayName = displayName; } public String getDisplayName() { return displayName; } }
Damit können wir Faktor-IPS anweisen den displayName
zur Anzeige zu verwenden und die Enum-Literale lediglich im Code:
<Datatype id="Sparte" javaClass="org.faktorips.sample.datatypes.LineOfBusiness" valueObject="true" isEnumType="true" valueOfMethod="valueOf" getAllValuesMethod="values" isSupportingNames="true" getNameMethod="getDisplayName" />
Damit zurück zum Währungsdatentyp. Da wir die Klasse Currency
nicht verändern können haben wir zwei Möglichkeiten:
- Wir können einen
org.faktorips.datatype.Datatype
nebst zugehörigemorg.faktorips.codegen.DatatypeHelper
erstellen, die sich um die Erzeugung des passenden Zugriffscodes kümmern. Dazu später mehr. - Wir können sie in unsere eigene Klasse verpacken, deren Methoden die erwartete Signatur bedienen.
Zuerst die zweite Option:
public class Waehrung { private final Currency currency; private Waehrung(Currency currency) { this.currency = currency; } public Currency getCurrency() { return currency; } public int hashCode() { return currency.hashCode(); } public boolean equals(Object obj) { return obj instanceof Waehrung && currency.equals(((Waehrung)obj).currency); } public String toString() { return currency.toString(); } public static Waehrung getInstance(String waehrungsCode) { return new Waehrung(Currency.getInstance(waehrungsCode)); } public static Waehrung[] getVerfuegbareWaehrungen(){ return Currency.getAvailableCurrencies().stream() .map(Waehrung::new) .collect(Collectors.toList()) .toArray(new Waehrung[0]); } }
Damit können wir den Datentypen so definieren:
<Datatype id="Currency" javaClass="org.faktorips.sample.datatypes.Waehrung" valueOfMethod="getInstance" valueObject="true" isEnumType="true" getAllValuesMethod="getVerfuegbareWaehrungen" />
Nur müssen wir jetzt immer mit Waehrungs- statt mit Currency-Objekten arbeiten und diese an Schnittstellen ein- und auspacken. Gut genug für ein Beispiel und hoffentlich etwas das zu eigenen Datentypen anregt, aber vielleicht ist hier doch Variante 1 die richtige.
Wenn wir die Datentyp-Klasse nicht verändern können müssen wir hoffen, dass ihre Signaturen stimmen. Aber was ist mit unseren eigenen Klassen, die wir anpassen können? Welche Klassen fallen einem da ein? Ein Beispiel könnten unsere Faktor-IPS-Produktbausteine sein. So mancher wünschte sich schon, diese direkt als Schlüssel in einer Tabelle zu verwenden. Also verwandeln wir eine Produktbaustein-Klasse in einen Datentypen. Dazu brauchen wir
- eine Möglichkeit einen Produktbaustein in einen String zu verwandeln (einfach; es gibt bereits die Methode
getId
) - eine Möglichkeit ihn aus diesem ID-String zu erzeugen (wozu wir wohl ein RuntimeRepository brauchen)
- die Liste aller verfügbaren Produktbausteine, um diese direkt auswählen zu können statt fehleranfällig und unvalidiert Runtime-IDs einzutippen (auch hier sollte das RuntimeRepository mit seiner Methode
getAllProductComponents
hilfreich sein) - möglicherweise einen besser zur Anzeige geeigneten Namen (den wir einfach mit einem eigenen Produktattribut definieren können)
Die Punkte 1 und 4 sind offensichtlich leicht zu erfüllen, aber wie kriegen wir ein RuntimeRepository in unsere statischen Methoden? Eine kleine Helferklasse scheint angebracht. Mit dem Namen der Table-of-Contents-Datei können wir ein neues ClassloaderRuntimeRepository
erzeugen. Da wir es in einem statischen Kontext halten und nicht jedes Mal wenn wir einen neuen Produktbaustein anlegen oder ändern unseren Arbeitsbereich neu laden möchten erstellen wir einen Manager für das Repository der prüft, ob der ToC aktualisiert wurde.
public class ProductDatatype<P extends ProductComponent> { private final class ClassloaderRuntimeRepositoryManagerCheckingFileModification extends ClassloaderRuntimeRepositoryManager { private final Path tocResourcePath; private AtomicReference<FileTime> lastModifiedTime; private ClassloaderRuntimeRepositoryManagerCheckingFileModification(ClassLoader classLoader, String basePackage, String pathToToc, Path tocResourcePath) { super(classLoader, basePackage, pathToToc); this.tocResourcePath = tocResourcePath; try { lastModifiedTime = new AtomicReference<FileTime>( Files.getLastModifiedTime(tocResourcePath)); } catch (IOException e) { lastModifiedTime = new AtomicReference<FileTime>(null); } } @Override protected boolean isRepositoryUpToDate(IRuntimeRepository actualRuntimeRepository) { if(actualRuntimeRepository==null)return false; try { FileTime modifiedTime = Files.getLastModifiedTime(tocResourcePath); FileTime lastTime = lastModifiedTime.getAndSet(modifiedTime); return lastTime.equals(modifiedTime); } catch (IOException e) { return false; } } } private final Class<P> clazz; private ClassloaderRuntimeRepositoryManager repositoryManager; public ProductDatatype(Class<P> clazz, String tocResource) { this.clazz = clazz; final Path tocResourcePath = Paths.get(tocResource); repositoryManager = new ClassloaderRuntimeRepositoryManagerCheckingFileModification(clazz.getClassLoader(), "", tocResource, tocResourcePath); } private IRuntimeRepository getRepository() { return repositoryManager.getCurrentRuntimeRepository(); } @SuppressWarnings("unchecked") public P byId(String runtimeId) { IProductComponent productComponent = getRepository().getProductComponent(runtimeId); if (clazz.isInstance(productComponent)) { return (P) productComponent; } return null; } public boolean isProductId(String runtimeId) { IProductComponent productComponent = getRepository().getProductComponent(runtimeId); return (clazz.isInstance(productComponent)); } @SuppressWarnings("unchecked") public P[] getAllProducts() { List<Product> allProductComponents = getRepository().getAllProductComponents(Product.class); return allProductComponents.toArray((P[]) Array.newInstance(clazz, allProductComponents.size())); } }
Damit ist es einfach, eine normale Produktbausteinklasse zum Datentyp zu machen:
private static final ProductDatatype<Produkt> DT = new ProductDatatype<>(Produkt.class, "org/faktorips/sample/model/internal/faktorips-repository-toc.xml"); public static Produkt byId(String runtimeId) { return DT.byId(runtimeId); } public static boolean isProductId(String runtimeId) { return DT.isProductId(runtimeId); } public static Produkt[] getAlleProdukte() { return DT.getAllProducts(); }
<Datatype id="Produkt" javaClass="org.faktorips.sample.model.Produkt" valueObject="true" isEnumType="true" valueOfMethod="byId" isParsableMethod="isProductId" valueToStringMethod="getId" getAllValuesMethod="getAlleProdukte" isSupportingNames="true" getNameMethod="getAnzeigeName" />
Und schon können wir unsere Produktbausteine als Datentypen verwenden, zum Beispiel als Schlüssel in einer Preistabelle. Natürlich funktioniert dieses Beispiel so nur in einem kombinierten Modell- und Produktprojekt; um mit getrennten Projekten und Deployments klar zu kommen brucht es noch etwas Arbeit, aber das sei dem Leser zur Übung gelassen, wie mein Professor zu sagen pflegte.
Zum Schluss möchte ich noch auf die „echten“ Datentypen eingehen, die in einem eigenen Plugin definiert werden und für die angepasster Code generiert wird, um einen möglicherweise noch besseren Währungs-Datentyp zu erhalten. Als erstes muss in Eclipse ein Plugin-Projekt angelegt werden, in dem eine Abhängigkeit zu org.faktorips.devtools.core
und eine Erweiterung zu org.faktorips.devtools.core.datatypeDefinition
angelegt werden. Dazu benötigen wir zwei Klassen: Den Datentyp und einen zugehörigen Helper. Der Datentyp leitet von GenericValueDatatype
ab und implementiert EnumDatatype
. Im Konstruktor setzen wir den qualifizierten Namen (wie wir ihn im XML bei id
eingetragen hatten), den Namen der Methode zur Erzeugung eines Wertobjekts("getInstance"
, wie zuvor) und den Namen der Methode die prüft, ob ein gegebener Wert geparst werden kann (auf null
, da Currency keine solche Methode hat). Wir müssen Methoden implementieren, die alle Werte zurückgeben (wie in unserer Wrapper-Klasse) und die parsbarkeit prüfen(dazu versuchen wir einfach eine Currency anzulegen und geben im Fehlerfall false
zurück). Der Helper ist nur ein leerer GenericValueDatatypeHelper
für unseren Datentyp.
public class WaehrungDatatype extends GenericValueDatatype implements EnumDatatype { public WaehrungDatatype() { setQualifiedName("Waehrung"); setValueOfMethodName("getInstance"); setIsParsableMethodName(null); } @Override public String[] getAllValueIds(boolean includeNull) { return Currency.getAvailableCurrencies().stream().map(Currency::toString).collect(Collectors.toList()) .toArray(new String[0]); } @Override public boolean isParsable(String currencyCode) { try { return Currency.getInstance(currencyCode) != null; } catch (Exception e) { return false; } } @Override public boolean isSupportingNames() { return false; } @Override public String getValueName(String id) { throw new UnsupportedOperationException(getClass().getName() + " is not supporting names."); } @Override public Class<?> getAdaptedClass() { return Currency.class; } @Override public String getAdaptedClassName() { return Currency.class.getName(); } }
public class WaehrungDatatypeHelper extends GenericValueDatatypeHelper { public WaehrungDatatypeHelper() { super(new WaehrungDatatype()); } }
Jetzt haben wir Javas Currency-Klasse als Faktor-IPS-Datentyp und müssen nicht selbst eine Liste mit allen Währungen pflegen.
Update 22.6:
Faktor-IPS 22.6 bringt einige Verbesserungen für selbstdefinierte Datentypen:
- Die Methode, die alle Werte zurückgibt, kann nun statt einem Array auch eine Collection als Rückgabewert haben.
- Auch Nicht-Aufzählungs-Datentypen können neben einer ID einen Namen haben.
- Currency ist ein offiziell in Faktor-IPS unterstützter Datentyp