Wenn es darum geht, CL(CMD)-Befehle auf IBM i auszuführen, gehen die meisten Benutzer immer noch über die traditionellen grünen Bildschirme (5250). Diese Schnittstellen sind zwar für Insider effektiv, aber für Nutzer mit wenig oder keinen IBM i-Kenntnissen oft komplex und wenig intuitiv. Heute gibt es Lösungen, um die Benutzererfahrung zu modernisieren, zu vereinfachen und zu bereichern.
Mit NTi und seiner Toolbox-Erweiterung, die als NuGet-Paket erhältlich ist, können Sie die System-API direkt abfragen QCDRCMDD, die von IBM bereitgestellt wird, um automatisch die vollständige Beschreibung eines CL-Befehls abzurufen.
Die Methode RetrieveCommandDefinition()
kapselt diese Operation vollständig ab, indem sie sie automatisch durchführt:
- 1️⃣ Der Anruf der API.
- 2️⃣ Das Abrufen des XML-Feeds (CDML) der Bestellung.
- 3️⃣ Die Deserialisierung dieses Streams in ein stark typisiertes C#-Objekt, das direkt in Ihren .NET-Anwendungen verwendet werden kann.
Je nach Ihren Bedürfnissen können Sie :
- Direktes Abrufen des rohen XML mit der Methode
RetrieveCommandDefinitionXml()
zur benutzerdefinierten Auswertung, insbesondere über APIs oder externe Analysetools.
- Verwenden Sie das strukturierte C#-Objekt, das von
RetrieveCommandDefinition()
zurückgegeben wird, um automatisch und dynamisch moderne Benutzeroberflächen zu erzeugen, insbesondere mit Blazor, wie in diesem Tutorial veranschaulicht.
So wird aus einer einzigen Anweisung
CommandDefinition commandDef = Conn.RetrieveCommandDefinition("CRTLIB", "QSYS", false);
Sie erhalten eine vollständige und sofort verwertbare Beschreibung in Ihrem Code, die unter anderem Folgendes umfasst:
- Die umfassende Liste der Parameter (obligatorisch und optional)
- ihre genauen Typen (Text, numerisch, qualifiziert, Liste usw.)
- Standard-, Sonder- und einmalige Werte
- Talle bedingten Abhängigkeiten (Control Prompt) zwischen den Parametern
- Die detaillierten Subelemente jedes komplexen Parameters
Diese Fähigkeit, die Struktur von CL-Befehlen dynamisch zu generieren, eröffnet neue Nutzungsmöglichkeiten wie die Erstellung interaktiver Formulare mit automatischer Vorbefüllung, die fortgeschrittene Automatisierung von Parametervalidierungen und die automatische Generierung fertiger CL-Befehle, um deren Ausführung und Wiederverwendung zu vereinfachen.
💡 Der in diesem Artikel vorgestellte Code dient lediglich der Veranschaulichung. Er stellt keine endgültige oder optimierte Lösung für alle Nutzungskontexte dar. Er veranschaulicht lediglich, was mit den Methoden der Toolbox Extensions NTi und Blazor möglich ist.
Das Szenario: eine dynamische Schnittstelle für einen beliebigen CL-Befehl erstellen
Um diese Möglichkeiten konkret zu veranschaulichen, werden wir in diesem Tutorial eine Blazor Web App (.NET 8) erstellen, die sich auf NTi und die Toolbox-Erweiterung stützt. Das Ziel wird sein, eine dynamische, generische Schnittstelle zu bauen, die es ermöglicht:
- Die detaillierte Struktur eines beliebigen CL-Befehls (Standard oder benutzerdefiniert) zu laden.
- Automatisch passende Eingabefelder zu generieren (Text, Dropdown-Listen, Kontrollkästchen usw.).
- Die komplette, gebrauchsfertige CL-Syntax dynamisch aufzubauen.
- Diesen bestätigten Befehl direkt auf IBM i auszuführen.
Das Ganze wird in einer modernen Benutzeroberfläche mit dem IBM Carbon-Design präsentiert, die sich radikal vom traditionellen IBM i-Ansatz unterscheidet.
Schritt 1: Die Struktur einer Bestellung dynamisch laden
Im ersten Schritt wird die komplette Struktur eines vom Benutzer eingegebenen CL-Befehls dynamisch geladen. Diese Struktur wird dann ausgewertet, um die Felder des Eingabeformulars automatisch zu generieren.
Zuallererst muss eine klassische Verbindung zum IBM i über NTi hergestellt werden:
- Installieren Sie das NuGet-Paket
Aumerial.Data.Nti
(NTi Data Provider). - Fügen Sie dann das NuGet-Paket
Aumerial.Toolbox
hinzu, um auf die CL-Methoden zuzugreifen. - Instantiieren Sie eine
NTiConnection
, die auf Ihren IBM i zeigt, und verweisen Sie auf die beiden Pakete in Ihrem Projekt.
Sobald diese Konfiguration vorgenommen wurde, kann die Anwendung das System abfragen, um die vollständige Beschreibung einer Bestellung abzurufen.
Konkret: Sobald der Benutzer den Namen eines Befehls eingibt (z. B. CRTLIB
), fragt die Blazor-Anwendung die IBM i API über NTi ab und ruft alle Informationen ab, die für den dynamischen Aufbau des Formulars erforderlich sind.
1️⃣ Meldung interner Datenstrukturen
Im Code der Blazor-Komponente deklarieren wir mehrere Variablen, die den Status des Formulars verwalten und die vom Benutzer eingegebenen Werte speichern werden:
// Hauptgegenstand, der die vollständige Definition des Auftrags enthält
private CommandDefinition? Command;
// Der aktuell ausgewählte Befehl
private Command? SelectedCommand;
// Speichern der einfachen Werte, die jedem Schlüsselwort (Kwd) der Befehlsparameter zugeordnet sind
private Dictionary CommandValues = new();
// Verwaltung von qualifizierten Parametern (z.B. Datei(NAME/LIB))
private Dictionary> QualifierValues = new();
// Erlaubt dem Nutzer, einen benutzerdefinierten Wert einzugeben (Override)
private Dictionary> QualifierCustom = new();
// Verwaltung von Unterelementen eines komplexen Parameters vom Typ ELEM
private Dictionary> ElementValues = new();
// Verwaltung von Mehrfachauswahlen über Kontrollkästchen
private Dictionary> CheckedValues = new();
Mithilfe dieser Wörterbücher kann jeder Datentyp, der mit den verschiedenen Parametern verknüpft ist, unabhängig und isoliert verwaltet werden, wodurch eine übersichtliche Verwaltung gewährleistet wird.
2️⃣ *Dynamisches Abrufen und Initialisieren von Daten
Wenn ein Benutzer einen Befehl eingibt und bestätigt, wird die Methode RetrieveCommandDefinition()
aufgerufen:
try
{
// Aufruf der Methode RetrieveCommandDefinition() von NTiConnection über Toolbox
Command = await Task.Run(() => DBService.Conn.RetrieveCommandDefinition(CommandName.ToUpper(), "QSYS", true));
// Überprüfen, ob eine gültige Bestellung zurückgegeben wurde
if (Command != null && Command.CmdDefinition.Any())
{
// Auswahl des ersten zurückgegebenen Befehls
SelectedCommand = Command.CmdDefinition.FirstOrDefault();
if (SelectedCommand != null)
{
// Wörterbücher vor der Verwendung zurücksetzen
CommandValues.Clear();
QualifierValues.Clear();
QualifierCustom.Clear();
CheckedValues.Clear();
ElementValues.Clear();
// Parameterdurchlauf für individuelle Initialisierung
foreach (var param in SelectedCommand.Parameters)
{
// Speicherung des Standardwerts oder leer, wenn nicht definiert
CommandValues[param.Kwd] = param.Dft ?? string.Empty;
// Initialisierung der Checkboxen für Einstellungen mit mehreren Auswahlmöglichkeiten
if (param.SpecialValues.Any() && int.TryParse(param.Max, out int max) && max > 1)
{
CheckedValues[param.Kwd] = new Dictionary();
foreach (var spcVal in param.SpecialValues.SelectMany(s => s.SpecialValues))
{
CheckedValues[param.Kwd][spcVal.Value] = false; // aucune sélection cochée par défaut
}
}
// Initialisierung von qualifizierten Parametern (Typ QUAL, z. B. FILE(NAME/LIB))
QualifierValues[param.Kwd] = new Dictionary();
QualifierCustom[param.Kwd] = new Dictionary();
int qualIndex = 0;
foreach (var qual in param.Qualifiers)
{
var key = !string.IsNullOrEmpty(qual.Prompt) ? qual.Prompt : $"{qual.Type}_{qualIndex++}";
// Wenn der Parameter vom Typ QUAL ist, initialisieren Sie die Werte standardmäßig auf leer
QualifierValues[param.Kwd][key] = param.Type == "QUAL" ? "" : (qual.Dft ?? "");
QualifierCustom[param.Kwd][key] = ""; // anfänglich immer leer für eine mögliche Benutzereingabe
}
// Initialisierung von Unterelementen für komplexe Parameter (Typ ELEM)
ElementValues[param.Kwd] = new Dictionary();
foreach (var elem in param.Elements)
{
ElementValues[param.Kwd][elem.Prompt] = elem.Dft ?? string.Empty;
}
}
}
}
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
Sobald die vollständige Struktur des Befehls abgerufen wurde, bestätigen wir, dass er korrekt definiert ist, und initialisieren die internen Wörterbücher, um jeden Wert, der mit diesem Befehl verbunden ist, separat zu speichern. Konkret durchlaufen wir jeden von der API zurückgegebenen Parameter, um festzustellen:
- Die Standardwerte, die in einfachen Feldern angezeigt werden sollen,
- Mehrfachoptionen (Kontrollkästchen) und ihr Anfangszustand (standardmäßig nicht markiert),
- Die möglichen qualifizierten Werte (Name/Bibliothek), die dem Nutzer präsentiert werden sollen,
- Unterelemente komplexer Einstellungen, die getrennt angezeigt werden sollen.
Diese Vorgehensweise garantiert, dass alle notwendigen Informationen, die in unseren Wörterbüchern klar strukturiert und isoliert sind, sofort zur Verfügung stehen, um dynamisch eine Schnittstelle zu erstellen, die für jeden CL-Auftrag geeignet ist, unabhängig von seiner Komplexität oder seinen Besonderheiten, mit der Gewissheit, dass alle Felder korrekt vorausgefüllt sind, dass die Dropdown-Listen, Checkboxen und bedingten Felder perfekt funktionieren und dass der Auftrag dynamisch ohne Informationsverlust rekonstruiert wird.
👉 Weitere Details über die zurückgegebene Struktur finden Sie in der Dokumentation der Methode RetrieveCommandDefinition.
Schritt 2: Eingabefelder dynamisch generieren
Sobald die vollständige Struktur des CL-Befehls geladen und unsere Zustandswörterbücher initialisiert sind (siehe Schritt 1), können wir in unserer Blazor-Schnittstelle dynamisch die für jeden Parameter geeigneten Eingabefelder erzeugen, die die von der IBM i über NTi zurückgegebenen Parameter getreu wiedergeben.
Konkret wird jeder Parameter aus SelectedCommand.Parameters
durchlaufen und eine bedingte Logik angewendet, die es ermöglicht, dynamisch die richtige Komponente anzuzeigen, je nach Typ und den auf der Seite von IBM i definierten Einschränkungen: einfacher Typ, spezielle Werte, qualifizierter Parameter, verschachtelte Elemente, Mehrfachauswahl usw.
Eine von Metadaten gesteuerte Logik
Jeder Parameter wird in einer @foreach
-Schleife verarbeitet, und je nach seinen Eigenschaften (Type, SpecialValues, Elements, etc.) wird ein spezifisches Rendering erstellt. Diese Logik beruht auf if/else
-Blöcken, die die von der API bereitgestellten Metadaten für jedes Feld dynamisch interpretieren.
@foreach (var param in SelectedCommand.Parameters)
{
if (param.Type == "QUAL")
{
foreach (var key in QualifierValues[param.Kwd].Keys)
{
<input class="bx--text-input" @bind="QualifierValues[param.Kwd][key]" placeholder="@key" />
}
}
else if (param.SpecialValues.Any())
{
<select class="bx--select-input" @bind="CommandValues[param.Kwd]">
<option value="">Choisir</option>
@foreach (var val in param.SpecialValues.SelectMany(s => s.SpecialValues))
{
<option value="@val.Value">@val.Value</option>
}
</select>
}
else if (param.Elements.Any())
{
<fieldset>
@foreach (var elem in param.Elements)
{
<input class="bx--text-input" @bind="ElementValues[param.Kwd][elem.Prompt]" placeholder="@elem.Prompt" />
}
</fieldset>
}
else if (param.Type == "CHAR")
{
<input class="bx--text-input" @bind="CommandValues[param.Kwd]" placeholder="@param.Prompt" maxlength="@param.Len" />
}
}
Las Rendering ist nicht nur dynamisch in seiner Struktur, sondern passt sich auch dem erwarteten Verhalten auf der IBM i-Seite an, dank der Metadaten, die von der API über NTi zurückgegeben werden.
- Die maximal zulässige Länge (
param.Len
) - Der Vorschlagstext (
param.Prompt
) - Die Standardwerte (
param.Dft
) - Die zulässigen Werte (
SpecialValues
) - Eingabegruppen (qualifiziert, verschachtelte Elemente)
Wenn ein Parameter vom Typ CHAR
mit einem Standardwert eine Länge von 10 Zeichen hat, wird dieser automatisch vorausgefüllt und die Länge durch das Attribut maxlength
auf 10 begrenzt. Wenn der Parameter einen qualifizierten Wert erwartet (z. B. Datei + Bibliothek), werden mehrere Felder angezeigt und mit ihren Unterteilen verknüpft.
Jedes Feld wird direkt an früher initialisierte Zustandswörterbücher (CommandValues
, QualifierValues
, ElementValues
usw.) gebindent, sodass die eingegebenen oder ausgewählten Werte ohne weitere Verarbeitung sofort erfasst werden können.
Verwaltung von Abhängigkeiten
Einige Befehle integrieren auch bedingte Abhängigkeiten zwischen Parametern, die als Control Prompt
bekannt sind und über SelectedCommand.Dependencies
offengelegt werden. Diese Regeln können in der Komponente ausgewertet werden, um bestimmte Felder zu deaktivieren, vorauszufüllen oder obligatorisch zu machen, je nachdem, welche Werte in andere Felder eingegeben werden. Beispiel: Wenn der Parameter TYPE
den Wert *USR
hat, dann wird OWNER
zum Pflichtfeld.
In diesem Tutorial wird nicht im Detail auf ihre Verwaltung in der Schnittstelle eingegangen, aber alle notwendigen Elemente sind über die Methode
RetrieveCommandDefinition()
zugänglich, sodass sie leicht in die Bazor-Komponente integriert werden können.
Schritt 3: Dynamisches Konstruieren des auszuführenden CL-Befehls
Zu diesem Zeitpunkt sind alle Felder der Schnittstelle gefüllt und ihre Werte in den internen Wörterbüchern (CommandValues
, QualifierValues
, ElementValues
, usw.) gespeichert.
Das Ziel ist es nun, die vollständige Syntax des CL-Befehls aus den gesammelten Daten dynamisch als ausführungsfertigen String auf der IBM i-Seite zu rekonstruieren.
Diese Implementierungslogik wird in einer eigenen Methode zentralisiert: BuildCommandCL()
:
string BuildCommandCL()
{
var commandCl = SelectedCommand.CommandName.ToUpperInvariant().Trim() + " ";
foreach (var param in SelectedCommand.Parameters)
{
if (!string.IsNullOrWhiteSpace(CommandValues[param.Kwd]))
{
string value = CommandValues[param.Kwd].ToUpperInvariant().Trim();
if (param.Kwd.Equals("TEXT", StringComparison.OrdinalIgnoreCase))
{
value = $"'{value}'"; // Le champ TEXT est entre quotes
}
commandCl += $"{param.Kwd.ToUpperInvariant()}({value}) ";
}
else
{
var segment = new List();
// Verschachtelte Elemente (ELEM)
if (ElementValues.TryGetValue(param.Kwd, out var elements))
{
var values = elements.Values.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v.ToUpperInvariant().Trim()).ToList();
if (values.Any())
{
segment.Add($"{param.Kwd.ToUpperInvariant()}({string.Join(" ", values)})");
}
}
// Qualifizierte Parameter (QUAL)
if (QualifierValues.TryGetValue(param.Kwd, out var qualifiers) &&
QualifierCustom.TryGetValue(param.Kwd, out var customQualifiers))
{
var values = qualifiers.Keys.Select(key => !string.IsNullOrWhiteSpace(customQualifiers[key])
? customQualifiers[key]
: qualifiers[key])
.Where(v => !string.IsNullOrWhiteSpace(v)).ToList();
if ((param.Kwd == "FILE" || param.Kwd == "SRCFILE") && values.Count < 2)
{
// Nichts generieren, wenn eines der Elemente fehlt
}
else if (values.Any())
{
var combined = string.Join("/", values.AsEnumerable().Reverse());
segment.Add($"{param.Kwd.ToUpperInvariant()}({combined.ToUpperInvariant().Trim()})");
}
}
// Mehrfachauswahlen (Checkbox)
if (CheckedValues.TryGetValue(param.Kwd, out var checkDict))
{
var checkedValues = checkDict.Where(x => x.Value).Select(x => x.Key.ToUpperInvariant().Trim());
if (checkedValues.Any())
{
segment.Add($"{param.Kwd.ToUpperInvariant()}({string.Join(",", checkedValues)})");
}
}
if (segment.Any())
{
commandCl += string.Join(" ", segment) + " ";
}
}
}
return commandCl.Trim();
}
Man initialisiert den endgültigen Befehl ausgehend vom Hauptnamen (DLTLIB
, CRTLIB
, usw.), gefolgt von einer Reihe von Parametern, die je nach ihrem Typ dynamisch formatiert werden. Für jeden Parameter werden mehrere Fälle behandelt.
- Wenn ein einfacher Wert eingegeben wird
CommandValues
, wird er direkt hinzugefügt, wobei der ParameterTEXT
(umgeben von einfachen Anführungszeichen) besonders behandelt wird. - Wenn kein einfaches Feld ausgefüllt ist, werden die Sekundärstrukturen analysiert
- Bei verschachtelten Parametern vom Typ
ELEM
setzt man die Unterwerte zusammen, indem man sie durch ein Leerzeichen trennt. - Bei qualifizierten
QUAL
-Parametern werden die Unterwerte (z. B. Datei/Bibliothek) kombiniert, wobei eventuelle manuelle Eingaben berücksichtigt werden (Override). Man vermeidet es, den Parameter hinzuzufügen, wenn ein Teil fehlt (insbesondere bei kritischen Paaren wie FILE). - Bei Mehrfachwerten
checkbox
werden alle gewählten Optionen mit einem Komma verkettet, wie in OPTION(*SRC, *MBR).
Jedes Segment wird in Großbuchstaben umgewandelt, bereinigt und dann mit dem Hauptbefehl verkettet. Das Endergebnis ist ein vollständiger, sauberer, konformer und ausführbarer CL-Befehl auf der IBM i-Seite - genau so, als wäre er in ein 5250-Terminal eingegeben worden, hier jedoch dynamisch von einer modernen Schnittstelle aus generiert.
Schritt 4: Den Befehl auf der IBM i-Seite ausführen
Nachdem der Befehl erstellt wurde (siehe Schritt 3), muss er nur noch auf dem IBM i ausgeführt werden. Dies geschieht in zwei Schritten: zunächst wird die Syntax überprüft, dann wird der Befehl tatsächlich ausgeführt.
Dies geschieht in einer Methode SubmitCommand()
, die beim Absenden des Blazor-Formulars aufgerufen wird:
private async Task SubmitCommand()
{
messageSubmit = "";
string commandCL = BuildCommandCL();
try
{
DBService.Conn.CheckCLCommand(commandCL); // Prüft die Syntax, ohne auszuführen
DBService.Conn.ExecuteClCommand(commandCL); // Führt den Befehl aus
messageSubmit = "Commande envoyée avec succès.";
await Task.Delay(4000); // Pause vor dem Zurücksetzen
ResetForm(); // Leere das Formular
ResetGenerateCommand(); // Leert den angezeigten Befehl
}
catch (Exception ex)
{
messageSubmit = $"{ex.Message}";
}
}
1️⃣ Vorabprüfung mit CheckCLCommand()
.
Vor dem Absenden muss man den erzeugten Befehl bestätigen. Dies wird mit der Methode CheckCLCommand()
überprüft, die auch über [NTi Toolbox Extension] (https://www.documentation.aumerial.com/toolbox/commande-CL/check-cl-command) verfügbar ist.
Sie fragt die IBM i ab, um die vollständige syntaktische Gültigkeit des Befehls zu analysieren, ohne ihn auszuführen: unbekannte Schlüsselwörter, falsch formatierter Wert, fehlender obligatorischer Parameter.
Diese Vorabprüfung vermeidet Fehler im laufenden Betrieb, indem sie exakt das gleiche Verhalten wie das System in der 5250-Umgebung nachbildet.
2️⃣ Ausführung mit ExecuteClCommand()
.
Nach der Bestätigung wird der Befehl über ExecuteClCommand()
weitergeleitet, eine Methode, die von der Klasse NTiConnection
des NTi DataProvider ausgestellt wird.
Sie wird in der bereits geöffneten Sitzung ausgeführt - mit ihrer kompletten Umgebung: Benutzer, aktiver Job, aktuelle Bibliothek, Berechtigungen.
Alle Rückmeldungen oder Systemfehler werden so wiedergegeben, als ob der Befehl direkt von einem IBM i-Terminal aus gestartet worden wäre.
Was auf der Blazor/.NET-Seite kontrolliert wird
Der gesamte Prozess wird in der .NET-Anwendung beherrscht. Sie orchestriert:
- Die dynamische Generierung des CL-Befehls mit
BuildCommandCL()
aus den in der Schnittstelle eingegebenen Werten. - Syntaxvalidierung durch
CheckCLCommand()
, die das Verhalten des Systems simuliert, ohne den Befehl tatsächlich auszuführen. - Die tatsächliche Ausführung mit
ExecuteClCommand()
, im Kontext der IBM i-Sitzung (Job, Benutzer, aktuelle Bibliothek). - Die Behandlung von Fehlern und Meldungen über einen Try/Catch-Block, um dem Benutzer das Feedback des IBM i-Systems zurückzugeben.
Der Befehl wurde erfolgreich ausgeführt und zeigt uns die Erfolgsmeldung an
Und die Bibliothek ist auf unserem IBM i gut angelegt:
Schlussfolgerung
Mit RetrieveCommandDefinition()
und RetrieveCommandDefinitionXml()
, die von ToolboxExtension angeboten werden, können Sie dynamisch Eingabeschnittstellen für jeden CL-Befehl erzeugen, indem Sie einfach auf die von der QCDRCMDD API zurückgegebenen Metadaten zurückgreifen.
RetrieveCommandDefinition()` kapselt diesen Prozess auf der .NET-Seite vollständig: Es fragt die IBM i-API ab, ruft den XML-Stream im CDML-Format ab, dekodiert ihn und deserialisiert ihn zu einem stark C#-geprägten Objekt. Es ist kein manuelles Parsen oder Manipulieren von XML erforderlich. Die gesamte Befehlsstruktur (Parameter, Typen, Werte, Abhängigkeiten...) ist sofort verfügbar und kann in Ihrem Code verwendet werden.
Diese Abstraktion reduziert den Arbeitsaufwand auf der Entwicklungsseite erheblich, sodass man sich ausschließlich auf UX und Geschäftslogik konzentrieren kann, ohne jemals die Struktur jedes Befehls manuell zu codieren.
Vor allem aber verlassen Sie endlich den starren Rahmen eines 5250-Terminals. Ihre Oberfläche wird portabel und ist über einen Browser, eine Geschäftsanwendung, einen Client oder eine containerisierte Plattform zugänglich. Mit anderen Worten, diese Änderung des Ansatzes eröffnet neue Nutzungsmöglichkeiten:
- Vereinfachte Formulare für nicht spezialisierte IBM i-Teams
- Zentralisierung von Bestellungen in einem Verwaltungsportal
- Integration in DevOps-Workflows oder Support-Tools
- Automatisierung von System- oder technischen Aufgaben, ohne Green Session
Sie verlassen das IBM i nicht - Sie erweitern es. In Richtung Web, in Richtung .NET, in Richtung morgen.
Die Toolbox-Methoden sollen Ihre IBM i-Kenntnisse nicht ersetzen, sie befreien Sie von der technischen Komplexität und verlängern sie in ein neues, agileres und zugänglicheres Ökosystem. Eine Zeitersparnis auf der Entwicklungsseite und eine klare Wertsteigerung Ihrer Anwendungen.
Quentin Destrade