Table of Contents

AL Coderichtlinien

Diese Seite erläutert bzw. ergänzt Richtlinien für die Programmierung von unitop Extensions. Ganz allgemein gelten folgende Quellen als valide Regeln, sofern nicht in der Entwicklungsrichtlinie zur unitop Entwicklung eine abweichende oder verfeinernde Regelung getroffen wird:

Keine Überraschungen und nichts verstecken

Generell ist Code so aufzubauen, das eine spätere Anpassung und Wartung gut möglich ist. Ein anderer Entwickler (mit Kenntnis der ggf. angepassten Standardprozesse) muss in der Lage sein, sich auch ohne spezifische Einweisung in den Code einarbeiten zu können. Das Prinzip ist: "Überrasche niemanden. Verstecke nichts."

Code muss sich immer da befinden, wo man ihn erwartet auch wenn man das Projekt nicht gut kennt

Keine Überraschung und damit erlaubt ist z. B.:

  • Tabelle stellt eine Klasse dar
  • Die Funktionen der Klasse sind von der Klasse aus zu finden. Bevorzugt über Funktionen der Tabelle (= Tabellenfunktion vorhanden, die dann Funktionen in Codeunits aufruft). Noch tolerabel wenn die Funktionen in namentlich erkennbaren und in der Ordnerstruktur zuzuordnenden Orten liegen.
  • Pages haben nur Funktionen, die etwas mit der Darstellung zu tun haben. Alle Funktionen für den Datensatz liegen in der Tabelle und/oder relevanter Codeunit(s)
  • Funktionalitäten im Workspace logisch organisiert (Ordnerstruktur, Namen der Objekte, Namen der Funktionen)
  • Es werden nur dann globale Variablen verwendet, wenn es technisch nicht anders geht

Versteckt und damit verboten ist z. B.:

  • Sammelcodeunit für unzusammenhängende Funktionen, die nichts miteinander zu tun haben
  • Funktioneller Code auf Pages
  • Funktionen für eine Tabelle verstreut in Codeunits und keine Mindestvorsorge dafür, das man sie finden kann.

Code muss sich selber erläutern

Lesbar und damit erlaubt ist z. B.:

  • Auf der obersten Ebene werden nur Funktionen aufgerufen, die sprechende Namen haben. Es ist daher auch ohne Blick in die Tiefe erkennbar, wie der Prozess aufgebaut ist.
  • Jede Funktion erfüllt immer nur einen Zweck, und ihr Name lässt den Zweck erahnen.
  • Jede Funktion erfüllt Ihren Zweck mit möglichst wenig Verzweigungen. Wenn Verzweigungen benötigt werden, dann werden Unterfunktionen implementiert. Die Funktion selber bleibt lesbar.

Nicht lesbar und damit verboten ist z. B.:

  • Es ist mehrmals am Scrollrad zu drehen, um die Funktion ganz zu sehen.
  • Eine Funktion macht alles selber. Prüfen, Verzweigen, Abwickeln, Werte zurückgeben, Daten schreiben, etc.
  • Eine Funktion heißt so, als würde prüften sie etwas, aber in Wirklichkeit schreibt sie auch Daten.
  • Eine Funktion heißt so, als täten Sie etwas im Verkauf, erledigt aber etwas im Einkauf.

Code muss testbar sein

Lassen sich Funktionen nicht in einem automatisierten Test prüfen, ist das ein Hinweis auf eine überarbeitungswürdige Struktur. Das Test-Framework ist in solchen Fällen nicht die Ursache – es macht lediglich sichtbar, dass die Funktion für automatisierte Tests neu strukturiert werden sollte.

Allgemeine Codequalität

Wir verwenden für die Entwicklung von unitop mehrere CodeCops, die global im Workspace wirken. Über diese CodeCops werden eine Reihe von Konventionen erzwungen. Dazu gehören z. B. der Schutz vor Breaking Changes, die Verwendung obsoleter Technologie und die Verletzung von Regeln zu Affixen.

Es gibt ebenfalls eine große Zahl an Cops, die zu Warnungen im Code führen. Das Ziel ist es, so zu entwickeln, dass keine neuen, zusätzlichen Warnungen entstehen und unitop praktisch frei von Warnungen zu halten.

CodeCops

Die im Workspace aktivierten CodeCops erzeugen Warnungen für eine Vielzahl an unsauberen Konstruktionen. Die Warnungen müssen berücksichtigt werden. Code muss ohne Warnungen kompilieren.

Es ist nicht gestattet, eigenständig Warnungen zu unterdrücken, weder durch globale Einstellungen (Workspace, app.json, ruleset.json) noch durch Pragmas im Code. Ausnahmen sind nur in Abstimmung mit der Entwicklungsleitung möglich.

Eine Ausnahme kann bei Warnungen durch die Verwendung obsoleter Strukturen in bestimmten Fällen gemacht werden. Hier kann es übergangsweise erforderlich sein, Referenzen zu obsoleten Strukturen beiz. B.halten, und die dabei entstehenden Warnungen können entweder toleriert oder durch Pragmas unterdrückt werden. Dies passiert v.a. dann, wenn eine Struktur als obsolet deklariert ist, aber noch kein Ersatz verfügbar ist.

Präfix/Suffix

Um Konflikte mit mehrfach verwendeten Namen zu umgehen müssen für Objektnamen und innerhalb von Tabellen für Feldnamen spezifische Präfixe oder Suffixe vergeben werden. Für die Entwicklung von unitop ist das Kürzel GOB bei Microsoft registriert. Technisch betrachtet ist es egal, ob das Kürzel als Präfix oder Suffix verwendet wird - für die Entwicklung von unitop wird durchgängig ein Präfix verwendet.

Objektnamen werden mit Präfix plus einem Leerzeichen vergeben. Anbei ein Beispiel:

codeunit 5059450 "GOB Address Format"

Innerhalb von Objekten müssen nur Feldnamen in Table Extensions, die ihrerseits Tabellen der BaseApps erweitern, mit Präfix plus einem Leerzeichen vergeben werden. Anbei ein Beispiel:

field(5059560; "GOB Bill-to Cust.-No. Member"; Code[20])
{
}
field(5059580; "GOB Registered On"; Date)
{
}
field(5059581; "GOB Update Membership at"; Date)
{
}
Hinweis

Die vergebene Feldnummer muss im Fall einer Table Extension aus dem GOB-Nummernkreis kommen. Zudem muss die Feldnummer über den gesamten unitop Workspace hinweg eindeutig sein. D. h. Es darf keine zweite Table Extension auf die gleiche Tabelle mit dieser Feldnummer geben.

Präfix/Suffix für Funktionen und andere Controls

Generell muss das Präfix GOB immer verwendet werden, wenn eine Erweiterung des Microsoft Standard erfolgt. Dies gilt zum Beispiel für

  • global procedures in Table-, Page-, Report- und Enum-Extensions
  • Bei Zugriffsmodifikatoren in Extension Objekten, z. B. protected var
  • Neue Controls oder Control-Gruppen auf Pages und Reports

Verwendung von Pragmas

Pragmas sind generell zu meiden. Erlaubter Einsat z. B.schränkt sich auf die Behebung von Warnungen durch anstehende Obsoletions in Business Central, auf die nicht unmittelbar korrekt (= entsprechendes Refactoring) reagiert werden kann. Weitere Einzelfälle sind von Lead Developern freizugeben.

Wenn Pragmas eingesetzt werden, dann ist die kleinstmögliche Umschließung von Quellcode zu wählen. Auf keinen Fall dürfen ganze Codeblöcke von Pragmas umschlossen werden, wenn es eine Möglichkeit für kleinteiligere Umschließungen gibt. Es besteht anderenfalls die Gefahr, dass im umschlossenen Block neu auftretende Probleme nicht zu durchaus erwünschten neuen Warnungen bzw. Fehlern führen.

Dokumentation im Quellcode

Dokumentation von Objekten

Es werden keine Dokumentations-Trigger verwendet. Die erforderliche Kommentierung von Objekten erfolgt über die Verwendung von git und der jeweiligen Verbindung zu konkreten Entwicklungstätigkeiten (Commits, Pull Requests). Darüber hinaus sorgt der Entwickler für eine sinnvolle Ordnerstruktur der Quellcodedateien.

  • Zur Handhabung von Entwickler-Aufgaben siehe Azure DevOps.
  • Zur Handhabung der Ordner- und Dateistruktur siehe Struktur einer App.

Dokumentation von Quellcode-Änderungen

Es findet keine Kommentierung von Quellcode statt, der bei der Erledigung einer Entwicklungsaufgabe verändert wird. Zu ändernder Quellcode wird nicht auskommentiert, sondern überschrieben bzw. gelöscht. Die erforderliche Dokumentation dieser Tätigkeiten und auch die Sicherung des Zustandes vor und nach der Änderung erfolgt über die Verwendung von git und der jeweiligen Verbindung zu konkreten Entwicklungstätigkeiten (Commits, Pull Requests).

  • Zur Handhabung von Entwickler-Aufgaben siehe Azure DevOps.

Dokumentation zum Verständnis von Quellcode

Kommentierung zum Verständnis von Quellcode ist in geeigneten Fällen zulässig. Hier gilt allerdings eine entscheidende Einschränkung: Die Kommentierung ist nur zulässig, wenn ein Codeabschnitt funktionell erläutert werden muss weil z. B. eine komplexe Kalkulation oder etwas Vergleichbares stattfindet. Die Kommentierung ist unzulässig, wenn sie durch eine bessere Struktur des Quellcode überflüssig sein könnte. In aller Regel ist ein Kommentar falsch der durch das Auslagern eines Codeblocks in eine sprechend benannte Funktion ersetzt werden kann.

  • Verboten: Wenn Kommentare benötigt werden um den Programmablauf verstehen zu können, ist der Code schlecht strukturiert. Die Kommentierung ist damit unzulässig.
  • Erlaubt, wenn nicht durch obiges verboten: In einer Funktion, die nur genau eine Aufgabe erfüllt, und trotzdem von Erläuterung profitiert.

Zur Strukturierung von Quellcode siehe Anwendung objektorientierter Prinzipien in AL.

Dokumentation von Funktionen und Objekten

Das Kommentieren von Funktionen oder ganzen Objekten ist grundsätzlich erlaubt. Jedoch gelten auch hier Richtlinien wie damit umgegangen werden soll. Funktionskommentare sind in Englisch zu verfassen.

  • Erlaubt, wenn es dem Verständnis der Lösung tatsächlich hilft: In einer globalen Funktion die aus Sicht des Entwicklers einer Erklärung bedarf, weil sie eine zentrale Rolle in der Lösung spielt.
  • Verboten, wenn der Name der Funktion/Objekt samt Parametern bereits alles über den Inhalt und Verwendung verrät: Eine Funktion mit dem Namen CheckSetupNoSeries() ist verständlich und bedarf keiner genaueren Erläuterung. Eine Funktion die Teil einer Schnittstelle ist und z. B. lautet CreateCustomerFromMyWebShop(CustomerTemplateCode: Code[20]) kann hingegen von einem Kommentar profitieren, wenn gewisse Voraussetzungen gegeben sein müssen etc.

Hier ein weiteres Beispiel:


/// <summary>
/// Sets the Calculated Pension Result in the Pension Buffer (Sub) Header
/// </summary>
/// <param name="PensionBufferHeader">PensionBuffer Header to calculate. Temp Records not supported.</param>
procedure RunPensionCalculation(var PensionBufferHeader: Record "GOB Pension Buffer Header")
var
    TariffGroup: Record "GOB Tariff Group";
    ContractRetirementType: Enum "GOB Contract Retirement Type";
begin
    PensionBufferHeader.TestField("Tariff Group Code");
    TariffGroup.Get(PensionBufferHeader."Tariff Group Code");

    case TariffGroup."Contract Retirement Type" of
        ContractRetirementType::"Single Calculations",
        ContractRetirementType::"Sum Amounts":
            CalculatePensionResultFromBufferHeader(PensionBufferHeader);
        ContractRetirementType::"Sum Results":
            CalculatePensionResultFromBufferSubHeaders(PensionBufferHeader);
    end;
end;

In lokalen oder internen Funktionen sind Funktionskommentare nicht verboten, müssen dann aber beim Review ggf. begründet werden. Im Zweifel entscheidet der Reviewer. Grundsätzlich ist von jedem Entwickler zu prüfen, ob seine Namensgebung der Funktion samt Variablen auch so angepasst werden kann, dass ein Funktionskommentar überflüssig ist.

Hinweis

Es ist nicht erlaubt in einem Funktionskommentar oder Kommentar zu einem Objekt Referenzen zu Feldnamen und/oder Objekten herzustellen. Beispiel: "Die Funktion macht ... wenn im Feld "GOB My cool field" ...". Es besteht keine Möglichkeit später noch festzustellen, ob das benannte Feld nicht schon obsolet ist oder vielleicht doch anders lautet. Besser: Der Entwickler beschreibt den Prozess der mit der Funktion/Objekt unterstützt wird. Auch hier entscheidet im Zweifel der Reviewer.

Dokumentation in Unit Tests

In Unit Tests sind spezifische Kommentare/Dokumentation zwingend erforderlich. Tests werden im Feature/Scenario/Given/When/Then-Schema kommentiert. Zu den Vorgaben für Tests siehe Test Units.

Namenskonventionen

Für unitop gelten die im Folgenden aufgeführten Konventionen für Namen:

Namenskonventionen - technisch

Zur Benennung von Elementen außer Dateinamen gelten folgenden Hinweise. Zur Benennung kann im Detail immer ein gewisser Interpretationsspielraum bestehen. Benennungen die explizit nicht erlaubt sind werden daher besonders benannt.

Allgemeine Regeln für alle Elemente:

  • Elemente werden sprechend benannt
  • Elemente sind immer Englisch benannt
  • Ungarische Notation ist verboten (= recItem, decSomeNumber, parSomething, locItem, ...). Also: kein Präfix für den Typ oder den Kontext voranstellen
  • In den Elementen können Teile sprechend abgekürzt werden
Art Beispiele Hinweise
Variablen Boolean: IsLicensed
Integer,Decimal, etc.: QuantityOfWhatever
Record: Contact
Text, Code, etc.: SomeNameWeHandle
Keine Leerzeichen
CamelCase um Wörter zu trennen
Funktionen Prüfung mit Rückgabe boolean: IsModuleLicensed()
Funktion als Anweisung: DoPostInvoice(), CheckPreconditionsAreValid()
Analog zu den Regeln für Variablen.
Zu Ergänzen um einen Anweisungscharakter
Event Publisher OnBeforeInitSomeFunction(), OnAfterSomePointInFunction() Analog zu den Regeln für Funktionen.
Zu ergänzen um den möglichst exakten Kontext in dem das Event ausgelöst wird.
Der Name muss klar unterscheidbar von anderen Publishern im selben Kontext sein und möglichst klar erkennen lassen wo im Programmfluss das Event erhoben wird.
Event Subscriber ModifyDataOnAfterSomePointInFunction() Analog zu den Regeln für Funktionen.
Der Name des Publishers wird an den Namen der Funktion mit angehängt.
Textkonstanten RecIdNotFoundErr, Error01Err, ExpectedMsgTxt, Text01Txt Sprechende Namen bevorzugt, generische Namen erlaubt wenn im Kontext nur eine/wenige Konstanten vorkommen.

Namenskonventionen - inhaltlich

Die Benennung aller Objekte inkl. Tabellen und Pages folgt den üblichen Konventionen von Microsoft. Teilweise werden Namen für zentrale Tabellen und Masken von den Product Ownern vorgegeben. Es können sich dann in der Umsetzung Abweichungen von den vorgegebenen Namen ergeben:

  • Abweichungen sind ggf. kosmetisch (z. B. die richtige Verwendung von Einzahl/Mehrzahl/Rechtschreibung) -> Der PO wird vom Entwickler informiert.
  • Abweichungen sind schwerwiegender (z. B. ein in der Branche üblicher begriff ist in anderen Branchen missverständlich, kollidiert mit schon existierenden Begriffen des BC Standard, etc.). In solchen Fällen ist immer gemeinsam mit dem PO eine Lösung zu finden.

Key Namenskonventionen

In Business Central können Keys mit einem Namen versehen werden. Da es aktuell noch keine Verwendung der Namen im Client und / oder dem Quellcode gibt folgt die GOB den folgenden Regeln:

  • Jeder Primärschlüssel bekommt den Namen PK
  • Sekundärschlüssel werden fortlaufend mit Key01 bzw. GOBKey01 in Extension Objekten benannt
  • Sofern ein Sekundärschlüssel genau einem bestimmten Zwecks dient und eine Mehrfachverwendung nicht absehbar ist, darf einem Key ein zweckgebundener, sprechender Name gegeben werden. Dies sollte jedoch nur in Ausnahmefällen angewandt und kann durch den Reviewer unterbunden werden.

API Namenskonventionen

API Objekte in unitop folgen einer einheitlichen Namenskonvention. Im Kern werden die Regeln der Microsoft-Dokumentation angewendet und dann anhand folgender Regeln verfahren:

  • Name der API Page mit API und Versionsnummer
  • APIGroup startet mit unitop
  • APIPublisher ist gob
  • Es wird camelCase verwendet
  • EntityName ist Singular
  • EntitySetName ist Plural
  • Caption entspricht dem EntitySetName

Hier ein Beispiel:

page 5059545 "GOB UDC OutbQueue API1.0"
{
    APIGroup = 'unitopUdc';
    APIPublisher = 'gob';
    APIVersion = 'v1.0';
    Caption = 'udcChanges',
    Comment = 'de-DE=udcChanges';
    EntityName = 'udcChange';
    EntitySetName = 'udcChanges';
}
Wichtig

Bei der Erstellung von API-Pages gibt es Pflichtfelder und -Properties zu beachten. Weiterführende Informationen sind der Microsoft Dokumentation zu entnehmen. https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-develop-custom-api

Insbesondere müssen SystemId als Feld exponiert und ODataKeyFields auf SystemId gesetzt werden, damit Datensätze über die API aktualisiert werden können. Siehe dazu den Abschnitt SystemId und ODataKeyFields in API Pages weiter unten.

API Richtlinien

In API Pages sollen keine TableRelations genutzt werden, da dies im Zusammenhang mit dem Befehl "Expand" zu falschen Ergebnissen führen kann. Stattdessen sollen für weiterführende Daten separate Page Parts in der API Page eingebunden werden.

Wichtig

Ein Part darf aufgrund des eindeutigen Entitätsnamens nur einmal pro API Page genutzt werden! Sollte es also nötig sein für Felder mit Beziehung zur selben Tabelle einen Part unterschiedlich gefiltert mehrfach in einer API Page einz. B.nden, muss stattdessen eine (oder mehrere) neue Subpage(s) erstellt und eingebunden werden.

Hier ein Beispiel:

table 5578221 "GOB Master Data"
{
    ...
    field(1; "Sell-to Contact No."; Code[20])
        {
            Caption = 'Sell-to Contact No.',
                Comment = 'de-DE=Verk.-an Kontaktnr.';
        }
    
    field(2; "Bill-to Contact No."; Code[20])
        {
            Caption = 'Bill-to Contact No.',
                Comment = 'de-DE=Rech.-an Kontaktnr.';
        }
    ...
}

page 5578316 "GOB Master Data API1.0"
{
    ...
    field(SellToContactNo; Rec."Sell-to Contact No.")
        {
        }
    field(BillToContactNo; Rec."Bill-to Contact No.")
        {
        }
    ...
    part(SellToContSubData; "GOB SellToCont SubData API1.0")
        {
            Caption = 'SellToContSubDatasets', Locked = true;
            EntityName = 'SellToContSubDataset';
            EntitySetName = 'SellToContSubDatasets';
            SubPageLink = "Sell-to Contact No." = field("Contact No.");
        }
    part(BillToContSubData; "GOB BillToCont SubData API1.0")
        {
            Caption = 'BillToContSubDatasets', Locked = true;
            EntityName = 'BillToContSubDataset';
            EntitySetName = 'BillToContSubDatasets';
            SubPageLink = "Bill-to Contact No." = field("Contact No.");
        }
    ...
}
page 5578317 "GOB SellToCont SubData API1.0"
{
    ...
    PageType = API;
    SourceTable = Contact;
    ...
}
page 5578318 "GOB BillToCont SubData API1.0"
{
    ...
    PageType = API;
    SourceTable = Contact;
    ...
}
Hinweis

Bevor mehrere API-Pages für dieselbe Tabelle erstellt werden, sollte geprüft werden, ob man die bereitgestellten Daten nicht auch durch Filter unterscheiden kann. Beispiel: Es werden für interessierte, angemeldete oder nachrückende Teilnehmer in den Veranstaltungszeilen nicht prinzipiell drei API-Pages benötigt, da sich diese lediglich durch den Status unterscheiden. Stattdessen kann beim Request die entsprechende Filterung mitgegeben und das Ergebnis entsprechend reduziert werden.

SystemId und ODataKeyFields in API Pages (Aktualisierbarkeit von Datensätzen über API Pages)

Business Central API Pages ermöglichen nicht nur das Lesen von Daten, sondern auch das Anlegen, Aktualisieren und Löschen von Datensätzen über HTTP-Methoden wie POST, PATCH und DELETE. Damit ein Datensatz über PATCH oder DELETE gezielt angesprochen werden kann, muss der API-Consumer den Datensatz eindeutig identifizieren können. Dafür wird in OData-basierten APIs ein eindeutiger Schlüssel benötigt.

Business Central stellt für jeden Datensatz automatisch eine SystemId bereit – eine GUID, die einen Datensatz tabellenübergreifend eindeutig identifiziert und sich nie ändert. Damit OData-Clients diesen Schlüssel für Anfragen auf einzelne Datensätze nutzen können, muss er einerseits als Feld auf der API Page sichtbar sein und andererseits über das Property ODataKeyFields als der zu verwendende Schlüssel deklariert werden. Fehlt eines von beidem, liefert die API zwar Daten zurück, das Aktualisieren oder Löschen einzelner Datensätze über die API ist jedoch nicht möglich.

Die unitop-Standard-APIs erfüllen diese Anforderungen bereits konsequent. In projektbezogenen API Pages wird dies jedoch nicht immer umgesetzt, was dazu führt, dass APIs bereitgestellt werden, über die keine Datensätze aktualisiert werden können.

In jeder API Page müssen daher folgende zwei Anforderungen erfüllt sein:

  1. Das Feld SystemId der Quelltabelle muss als Feld auf der API Page exponiert werden.
  2. Das Property ODataKeyFields muss auf SystemId gesetzt werden.
Wichtig

Das Weglassen von SystemId oder ODataKeyFields verhindert das Aktualisieren und Löschen von Datensätzen über die API. Diese Anforderung gilt verbindlich für alle API Pages – auch für projektspezifische Entwicklungen.

Beispiel:

page 5059545 "GOB Example API1.0"
{
    APIGroup = 'unitopExample';
    APIPublisher = 'gob';
    APIVersion = 'v1.0';
    Caption = 'examples';
    EntityName = 'example';
    EntitySetName = 'examples';
    ODataKeyFields = SystemId;
    PageType = API;
    SourceTable = "GOB Example Table";

    layout
    {
        area(Content)
        {
            repeater(GroupName)
            {
                field(id; Rec.SystemId)
                {
                    Caption = 'id', Locked = true;
                }
                field(someField; Rec."Some Field")
                {
                }
            }
        }
    }
}

Formatierung und Einrückung

Formatierung und Einrückung werden über AutoFormat (Shift+Alt+F) erreicht. Davon abweichende Formatierung des Quellcode ist falsch.

Zu-/Aufklappen über Region Directive

Die Nutzung der Region Directive ist im unitop-Workspace nicht erlaubt. Beispiel:

Verboten:

#region Generic Item
[EventSubscriber(ObjectType::Codeunit, Codeunit::"GOB Generic Item", 'OnBeforeOpenGenericItemVariantSelection', '', true, true)]
local procedure GOBGenericItemOnBeforeOpenGenericItemVariantSelection()
var
    GenericItemTxt: Label 'GenericItem', Locked = true;
begin
    HandSignalToEmitterCode(GenericItemTxt);
end;
#endregion

Application Area und Usage Category

Alle Elemente die in der GUI von Business Central sichtbar werden benötigen das Property Application Area. Das betrifft damit Pages und Reports. Die Application Area muss gesetzt sein damit betroffene Elemente sichtbar werden.

ApplicationArea = GOBunitop;

Für die überwiegende Menge der unitop Funktionen kann die obige Ausprägung verwendet werden. Lediglich bei Elementen, die im Bereich Service und Produktion ergänzt werden, kann fallweise entschieden werden z. B. Suite als Application Area zu verwenden.

Das Property Usage Category sorgt dafür das Pages und Reports über die Suche auffindbar werden. Das Property muss daher je Page und Report gesetzt sein sofern der Anwender das Objekt über die Suche auffinden und nutzen können soll.

Data Classification

Tabellen und Tabellenfelder (sowohl originär als auch in Table Extensions) benötigen das Property Data Classification. Das Property muss zum einen vorhanden sein und zum anderen mit einem konkreten Wert belegt sein. ToBeClassified ist unzulässig.

Die Klassifizierung die für unitop programmatisch festgelegt wird, wird für den Anwender zum Vorschlag für sein DSGVO Reporting. Dementsprechend setzen wir für unitop Werte ein, die dem Sinn einer Tabelle bzw. v. a. der einzelnen Felder am nächsten kommen.

Wichtig

Wenn es sich um eine temporäre Tabelle handelt, also das Property "Table Type" auf "temporary" gesetzt ist, dann muss dass Property "DataClassification" auf "SystemMetaData" geändert werden.

Actions und Groups, die wir Standard-Pages hinzufügen, werden nie promoted entwickelt. Das gilt auch, wenn der PO das eigentlich fordert. Ausnahmen müssen mit der Entwicklungsleitung abgesprochen sein. Promoted Actions auf Pages müssen, sofern nötig, je Branche über Profile und die dazugehörigen Anpassungsmöglichkeiten implementiert werden.

Folgende Implementierungen sind daher verboten:

area(Promoted)
actionref(RefName; ActionName)
Wichtig

Die Verwendung der Legacy Properties Promoted, PromotedIsBig, PromotedCategory und PromotedOnly ist generell verboten.

Zu der Regel gibt es eine Ausnahme: Wenn eine Action bzw. Group des BC Standard durch eine alternative Action bzw. Group in unitop ersetzt werden muss, dann sollte in unitop der Zustand des Standard wieder hergestellt werden. (Beispiel: unitop ersetzt die Senden Aktion des Standard mit der Entsprechung aus dem Belegversand.)

Actions und Groups, die wir in unitop-Pages entwickeln, dürfen promoted entwickelt werden.

Felder werden immer ohne eine explizite Änderung der Importance entwickelt. Der PO kann eine andere Importance anfordern. Damit sind folgende Properties nur auf explizite Forderung hin erlaubt:

Importance = Additional;
Importance = Promoted;
Importance = Optional;

Permission Sets

Für jede Extension müssen Berechtigungssätze mitgeliefert werden. Die entsprechenden Dateien können auf unterschiedlichen Wegen erzeugt werden. Es ist unzulässig eine unitop Extension ohne Permission Sets freizugeben.

Obsoletion

Schemata und öffentliche Codestrukturen können, sobald diese einmal released waren, nicht mehr einfach gelöscht werden. Stattdessen müssen diese Strukturen entsprechend in den Status obsolete gebracht werden.

Implementierung von Obsoletions

Es wird jeweils ein sprechender Text als Grund angegeben und die Version, ab der die Funktion nicht mehr verfügbar ist. Das ist i.d.R. die Version, in die gerade hineinentwickelt wird. Beispiel: Wir entwickeln aktuell für Release 2020.3 -> s.u. für entsprechende Tags.

Beispiel für Obsoletions von Funktionen und Publishern:

[Obsolete('Was wrong and replaced by...','2020.3.x.x')]
procedure Send(SharePointRequest: Codeunit "GOB SharePoint Request"; var SharePointResponse: Codeunit "GOB SharePoint Response") Success: Boolean

Beispiel für Obsoletions von Objekten und Feldern:

ObsoleteState = Pending;
ObsoleteReason = 'Moved to new field "SPFieldType" of type Enum';
ObsoleteTag = '2020.3.x.x';

An einigen Stellen kann es vorkommen, dass Microsoft zu einem Feature etwas Neues erfindet bzw. eine Alternative oder neue Implementierung anbietet. Das war/ist z. B. bei der Preisfindung der Fall. Wenn es in unitop Funktionalität gibt, die z. B. die alte Preisfindung erweitert hat, unterstützen wir auch solange die alte Variante, bis Microsoft diese tatsächlich aus dem Quellcode entfernt. Der Zeitpunkt, zu dem Microsoft den Code entfernt ist für uns aber nur schwer zu ermitteln, da die Termine (wie bei der Preisfindung) sich auch manchmal um mehrere Jahre verzögern. Unsere Erweiterung alter Features von Microsoft sind natürlich ebenfalls als obsolete zu kennzeichnen bzw. bereits gekennzeichnet. Solche unitop Funktionalitäten werden erst dann entfernt, wenn sich der Code nicht mehr kompilieren lässt, weil Microsoft tatsächlich den alten Code entfernt hat. Als Obsolete-Tag setzen wir in so einem Fall den Tag "DependsOnMS" und nicht die Zielversion, wie in den anderen Fällen:

ObsoleteState = Pending;
ObsoleteReason = 'Pending Obsolete because of new price logic of Microsoft. This will be removed in a future release.';
ObsoleteTag = 'DependsOnMS';
Wichtig

Bei unitop Bereinigungsaktionen ist folgendes Vorgehen zu beachten:

  1. Prüfung der Obsolete-Funktionalität: Zunächst ist zu prüfen, ob eine als obsolete markierte Funktionalität tatsächlich entfernt werden kann.

  2. Behandlung von Microsoft-Abhängigkeiten: Wenn die Entfernung vom Zeitplan von Microsoft abhängt, muss der Versions-Tag in "DependsOnMS" geändert werden – selbst wenn aktuell noch ein spezifischer Versions-Tag vorhanden ist.

  3. Ziel der Bereinigung: Nach Abschluss der Bereinigungsaktion dürfen im unitop Quelltext keine Versions-Tags mehr vorhanden sein, die älter sind als die Version, bis zu der bereinigt wurde.

Im Fall von Schemata ist zu prüfen, ob Updatecode geschrieben werden muss. Dies ist immer dann der Fall, wenn die Daten erhalten bleiben sollen und deshalb in neue Strukturen kopiert werden müssen.

Für Pages, Page Extensions und Page-Elemente: Im Grunde genauso, wie für Tabellen. Allerdings ist noch nicht in allen Konstellationen alles möglich. Wenn der Objekttyp nicht obsoleted werden kann (aktuell: Page-Extension), dann werden alle enthaltenen Elemente obsoleted. Ziel ist immer der möglichst vollständige Hinweis an jeden Entwickler, der sich auf betroffene Strukturen stützt.

Beim Obsoleten wird im ersten Schritt der Status auf Pending gesetzt. Erst wenn eine Zeit von mindestens einem Jahr vergangen ist, ändern wir diese Obsoletions auf den Status Removed oder löschen diese.

Grundsätzliches zu Obsoletions

Prinzipiell soll vermieden werden, dass nach einem Update die PTE-Erweiterungen von unitop-Objekten in Kundenprojekten nicht mehr funktionieren, weil wir unsere alten Objekte einfach "ausgehöhlt" haben. Im besten Fall wird das im Kundenprojekt bemerkt, weil es dann plötzlich Fehlermeldungen gibt, im schlechteren Fall nutzen sie die alten Funktionen weiter, erhalten aber inkonsistente oder sogar falsche Daten ohne es sofort zu merken. In jedem Fall läuft dies auf Service Tickets und RFAs hinaus.

Wichtig

Damit wir einen sanften Übergang von alter Funktionalität zu neuer Funktionalität gewährleisten können, darf alter Quellcode (wenn möglich) nicht einfach gelöscht werden.

Mögliche Obsoletion-Strategien
Obsolete Mit Nachfolger Ohne Nachfolger
Felder Upgrade-Routine zur Verfügung stellen und prüfen, ob und inwiefern bei Nutzung des alten Feldes nun zusätzlich das neue Feld angesprochen werden kann. Den Quellcode des alten Feldes (wenn möglich) stehen lassen.
Funktionen Aus der alten Funktion (wenn möglich) die neue Funktion aufrufen. Den Quellcode (wenn möglich) stehen lassen.
Publisher Den Aufruf des alten Publisher mit dem Aufruf des neuen Publishers kombinieren, sodass beide Publisher zum Tragen kommen können. Den Aufruf des alten Publishers (wenn möglich) stehen lassen.
Objekte Wenn das Nachfolge-Objekt ähnlich strukturiert ist, könnten Sie (wenn möglich) die alten Funktionen des Objekts auf neue Funktionen des neuen Objekts umleiten. Den Inhalt des alten Objekts (wenn möglich) stehen lassen.
Tests Die Tests werden für das Nachfolge-Objekt (wenn möglich) kopiert und angepasst. Die alten Tests bleiben erhalten bis die Obsoletions tatsächlich gelöscht werden. Die Tests bleiben erhalten bis die Obsoletions tatsächlich gelöscht werden.

Dabei gilt natürlich, dass diese Vorgehensweise nicht in allen Fällen funktionieren kann, aber sie sollte zumindest geprüft werden. Zudem kann es bei relativ neuen Entwicklungen, die vielleicht erst in 1-2 Kundenprojekten installiert sind, auch die Rückmeldung aus dem Kundenprojekt geben, dass bisher keine Anpassungen erfolgt sind und daher das Obsoleten ohne die oben beschriebenen Übergänge vollzogen werden kann. Schlussendlich wollen wir für unsere Kollegen und Kunden eine Übergangsphase schaffen, damit genug Handlungszeit im Projekt besteht.

Option vs. Enum

Anstelle des Datentyps Option kann der neue Objekttyp Enum verwendet werden. Die Verwendung wird folgendermaßen entschieden:

Szenario Datentyp
Weitere Extensions sollen Optionswerte hinzufügen können. Enum
Variable als Parameter an Funktionen zu übergeben. Enum
Keine Änderung der Optionswerte zu erwarten. Nie als Parameter zu übergeben. Option
Im Zweifel Enum

Event Subscriber

Keine unnötige Ausführung von Subscriber-Code

In Subscribern muss immer früh geprüft werden, ob das gewünschte Szenario läuft bzw. ob die Bedingungen erfüllt sind unter denen der Code wirken soll. Falls nein sollte ein möglichst frühes exit erfolgen.

if not IsRelevantStuff() then
  exit;

Ebenfalls muss immer verhindert werden, das Insert/Modify/Delete-Trigger auch dann ausgelöst werden, wenn dies explizit nicht gewollt ist. Siehe auch unten für einen alternativen Weg, solche Subscriber ganz zu vermeiden.

if not RunTrigger() then
  exit;

Keine unnötige Störung durch temporäre Daten

In Subscribern (und sonstigen reaktiven Konstrukten, s.u.), die auf Datenänderungen reagieren, ist immer zu entscheiden was bei Auslösung durch temporäre Datensätze geschehen soll. In fast allen Fällen von kaskadierendem Löschen oder Aktualisieren von abhängigen Daten ist dieses Verhalten für temporäre Datensätze nicht erwünscht, da sonst leicht reale Daten zerstört werden.

if Rec.IsTemporary() then
  exit;

Keine Prüfung auf Lizenz oder Zugriffsrechte

Subscriber müssen mit Ausnahme von begründeten Einzelfällen immer zur Laufzeit mit Fehler abbrechen, wenn keine Objektlizenz und/oder keine Ausführungsberechtigung besteht. Anderenfalls werden Programmteile aufgrund von Lizenzfehlern bzw. Einrichtungsfehlern für den Anwender unerkannt nicht durchlaufen und Daten können inkonsistent werden.

SkipOnMissingLicense und SkipOnMissingPermission müssen immer false sein.

Kein Code in Subscribern

Generell ist ein Subscriber auf einen Funktionsaufruf zu beschränken. In Subscribern direkt implementierte Logik funktioniert zwar, jedoch fehlen dann oft Möglichkeiten, Individualanpassungen und etwaige Workarounds in Projekten zu schaffen.

Falsch

[EventSubscriber(ObjectType::Table, Database::Customer, 'OnAfterValidateEvent', 'Address 2', false, false)]
local procedure OnAfterValidate_Address2(var Rec: Record Customer; var xRec: Record Customer; CurrFieldNo: Integer)
var
    EqualAddressErr: Label 'The second address cannot be the same as the first one.',
    Comment = 'de-DE=Die zweite Adresse darf nicht gleich der ersten Adresse sein.';
begin
    if Rec."Address 2" = Rec.Address then
        Error(EqualAddressErr);
end;

Richtig

[EventSubscriber(ObjectType::Table, Database::Customer, 'OnAfterValidateEvent', 'Address 2', false, false)]
local procedure OnAfterValidate_Address2(var Rec: Record Customer; var xRec: Record Customer; CurrFieldNo: Integer)
begin
    PreventAddressEqualsAddress2(Rec, xRec, CurrFieldNo);
end;

local procedure PreventAddressEqualsAddress2(var Customer: Record Customer; var OldCustomer: Record Customer; CurrFieldNo: Integer)
var
    EqualAddressErr: Label 'The second address cannot be the same as the first one.',
        Comment = 'de-DE=Die zweite Adresse darf nicht gleich der ersten Adresse sein.';
    IsHandled: Boolean;
begin
    OnBeforePreventAddressEqualsAddress2(Customer, OldCustomer, CurrFieldNo, IsHandled);
    if IsHandled then
        exit;
    if Customer."Address 2" = Customer.Address then
        Error(EqualAddressErr);
end;

[IntegrationEvent(false, false)]
local procedure OnBeforePreventAddressEqualsAddress2(var Customer: Record Customer; var OldCustomer: Record Customer; CurrFieldNo: Integer; var IsHandled: Boolean)
begin
end;

Subscriber vs. Trigger bei Feldern

Es gibt in AL grundsätzlich zwei verschiedene Methoden um Code nach einem Validate eines Tabellenfeldes auszuführen. Sie können in einer Codeunit das zugehörige OnAfterValidate Event abonnieren. Das ist die Version, die auch unter C/AL möglich war:

[EventSubscriber(ObjectType::Table, Database::Customer, 'OnAfterValidateEvent', 'Address 2', false, false)]
local procedure OnAfterValidate_Address2(var Rec: Record Customer; var xRec: Record Customer; CurrFieldNo: Integer)
var
    EqualAddressErr: Label 'The second address cannot be the same as the first one.',
      Comment = 'de-DE=Die zweite Adresse darf nicht gleich der ersten Adresse sein.';
begin
    PreventAddressEqualsAddress2(Rec, xRec, CurrFieldNo);
end;

In AL gibt es aber auch eine zweite Variante, die an dieser Stelle eindeutig empfohlen wird. Dabei kann man den notwendigen Code direkt in die zugehörige Tableextension schreiben:

tableextension 50111 "CustomerExt" extends Customer
{
    fields
    {
        modify("Address 2")
        {
            trigger OnAfterValidate()
            var
                EqualAddressErr: Label 'The second address cannot be the same as the first one.',
                  Comment = 'de-DE=Die zweite Adresse darf nicht gleich der ersten Adresse sein.';
            begin
                PreventAddressEqualsAddress2(Rec, xRec, CurrFieldNo);
            end;
        }
    }
}

Neben dem Trigger OnAfterValidate() wird auch der Trigger OnBeforeValidate() von Microsoft angeboten. Die Dokumentation von Microsoft ist hier zu finden:

OnBeforeValidate() Trigger

OnAfterValidate() Trigger

Der Vorteil bei der Verwendung der Trigger in der Tableextension ist der, dass der Code, der zu dem Tabellenfeld gehört, einfacher wiederzufinden ist, da es pro App nur eine Tableextension geben darf, jedoch mehrere Subscriber in unterschiedlichen Codeunits existieren können. Das steigert in der Regel die Übersichtlichkeit. Aber auch hier gilt der Grundsatz, dass Triggercode kurz ist und möglichst schnell in Unterfunktionen verzweigen sollte.

Hinweis

Ein weiterer Vorteil ist, dass man in der Tableextension auch auf Variablen zugreifen kann, die in der Tabelle als protected var gekennzeichnet sind. In einem Subscriber hat man hingegen keine Möglichkeit auf diese Variablen zuzugreifen. Entsprechende Doku von Microsoft ist hier zu finden.

Hinweis

Die gleiche Empfehlung gilt auch für Validate-Trigger auf Pages. Auch hier sollte man nach Möglichkeit die Trigger auf der Pageextension nutzen. Auch auf der Pageextension kann man also im Modify(Control)-Block Trigger ansprechen. Aktuell mögliche Trigger hier sind: OnBeforeValidate(), OnAfterValidate(), OnLookup(), OnDrilldown(), OnAssistEdit(), OnAfterAfterLookup(). Über das Verhalten und die Funktionsweise der Trigger kann man sich beginnend bei OnBeforeValidate in der Microsoft Dokumentation informieren.

Umgang mit fehlenden Publishern im Business Central Standard

Es kann vorkommen, das im Standard von Business Central ein geeigneter Publisher fehlt. Diesen Publisher kann nur Microsoft entscheiden und für zukünftige Versionen einfügen. Eine entsprechende Anforderung kann hier hinterlegt und verfolgt werden:

https://github.com/Microsoft/ALAppExtensions

Zugriff auf Standard-Funktionen

In der Entwicklung von unitop Extensions nutzen wir das Extension Target "Extension". Daher sind nur Funktionen von Business central ansprechbar die "external" sind.

Umgang mit internen Funktionen im Business Central Standard

Für die Verwendbarkeit durch Extensions muss eine Funktion "external" sein. Eine entsprechende Anforderung kann hier hinterlegt und verfolgt werden:

https://github.com/Microsoft/ALAppExtensions

Code-Duplikation

Es kann vorkommen, das der Code von Microsoft oder einem Partner nicht erweitert werden kann und stattdessen dupliziert werden muss. Insgesamt laden wir uns damit immer technische Schulden auf, daher ist dies auf das nötige Minimum zu begrenzen. Es gelten ferner folgende Regeln:

  • Die Duplikation ersetzt niemals eine anderweitig tragbare Lösung durch normale Extension-Mechanismen

  • Die Duplikation muss technisch eindeutig begründbar sein, z. B.: Es ist ein Objekttyp, der nicht erweitert werden kann.

  • Die Duplikation wird je Funktion per Kommentar als solche kenntlich gemacht. Der Kommentar folgt diesem Schema:

    //This code has been copied from:
    //Module:
    //Object:
    //Version:
    //Reason:
    //Last re-merge with original source:
    

    Die Kommentare sind entsprechend zu aktualisieren, wenn der kopierte Code aktualisiert wird.

  • Der kopierte Code muss explizit nicht allen unseren Vorgaben folgen, die Struktur kann zum besseren späteren Vergleich mit dem Original beibehalten werden.

  • Der duplizierte Code wird immer mit Tests untersetzt, ggf. werden Tests der Quelle mit kopiert und/oder eigene Tests geschrieben.

Standard durch unitop ersetzen

Bietet Microsoft keine geeigneten Einstiegspunkte oder ist es ökonomisch sinnvoller kann es dazu kommen, dass Aktionen und Funktionen ohne direkte Sichtbarkeit für den Anwender aus bzw. eingeblendet werden. Dieser Fall sollte so implementiert werden, dass entweder die Aktion aus dem Standard oder die unitop Aktion sichtbar sind. Die Aktion aus dem Standard sollte mit enabled = not unitopActive und visible = not unitopActive deaktiviert werden, sodass der Anwender keine Möglichkeit hat die Standard Aktion durch Personalisierung wieder einz. B.enden. Die Steuerung ob unitop oder Microsoft sollte nie Pauschal durch das Installieren der Extension, sondern immer Abhängig von dem jeweiligen aktivierten oder deaktiviertem unitop Modul sein. Eine Unterscheidung, ob der aufrufende Benutzer ein lizenzierter, named unitop User ist, oder nicht, gibt es bei aktivierten Kunden nicht, d.h. die Steuerung findet immer pro Kunde und nicht pro User statt. Es wäre somit nicht möglich, mit 15 Mitarbeitern den unitop Belegversand zu nutzen und mit 10 weiteren den Microsoft Standard.

Partielle Datensätze

Seit Business Central v17 (Fall 2020) beherrscht die Plattform partielle Datensätze. Zur Doku hier: Partial Records.

Die Nutzung dieser Fähigkeit ist obligatorisch. Der Entwickler entscheidet in seiner Umsetzung über den Einsatz der entsprechenden Befehle und der Reviewer kann die Entscheidung ggf. einfordern oder übersteuern. Dabei gilt generell: v. a. Schleifen und ähnliche Konstruktionen (z. B. Reports) können profitieren, aber auch der Zugriff auf einzelne Datensätze wird schneller.

Szenario Anwendung partieller Datensätze
Leseoperationen, die sicher gekapselt sind und hinreichend bekannt ist, dass keine zusätzlichen Lesebedarfe durch Projektanpassungen hinzukommen. Immer
Leseoperationen, die ggf. von Dritten mit genutzt werden, aber ein hoher Performancegewinn wahrscheinlich ist. Immer. Zusätzlich Publisher anbieten, über den ggf. AddLoadFields() möglich ist.
Leseoperationen, die ggf. von Dritten mit genutzt werden und ein eher geringer Performancegewinn wahrscheinlich ist. Optional

Aufbau und Ablauf Confirm

Alle Confirm-Abfragen müssen den gleichen Aufbau haben. Im ersten Teil der Frage wird der Umstand erläutert und auf die Konsequenzen des Bejahens hingewiesen. Nach dem Block folgt immer ein doppelter Zeilenumbruch gefolgt von der Frage "Möchten Sie fortfahren?". Verneint der Benutzer die Abfrage wird, sofern eine Interaktion mit dem Benutzer erforderlich ist, der Text "Benutzerabbruch" ausgegeben. Alle Fälle ohne Benutzerinteraktion geben, analog zum Microsoft Standard, einen leeren Error aus. Die Ausgabe des leeren Errors ist Default. Ebenfalls muss jedes Confirm so aufgebaut sein, dass ohne GUI Einfluss auf die Antwort genommen werden kann. Dies kann über Publisher, GuiAllowed() oder die Codeunit Confirm Management umgesetzt werden. Auch die englischen Übersetzungen "Do you want to continue" und "Canceled by user" sind verpflichtend.

Beispiel:

local procedure ChangeDimensionNormInVariants(Item: Record Item)
var
    ItemVariant: Record "Item Variant";
    ConfirmManagement: Codeunit "Confirm Management";
    ChangeVariantsQst: Label 'If you change this field, all related item variants are also changed.\\Do you want to continue?',
        Comment = 'de-DE=Wenn Sie dieses Feld ändern, werden alle zugehörigen Artikelvarianten ebenfalls geändert.\\Möchten Sie fortfahren?';
    CanceledByUserErr: Label 'Canceled by user.',
        Comment = 'de-DE=Benutzerabbruch.';
begin
    ItemVariant.SetRange("Item No.", Item."No.");
    if ItemVariant.FindSet() then
        if ConfirmManagement.GetResponse(ChangeVariantsQst, false) then
            repeat
                ItemVariant.Validate("GOB Dimension Norm Code", Item."GOB Dimension Norm Code");

                OnBeforeModifyVariants(Item, ItemVariant);

                ItemVariant.Modify(true);
            until ItemVariant.Next() = 0
        else
            Error(CanceledByUserErr);
end;

Erkenntnisse zu IsEmpty + Datenzugriff

Entgegen der bisherigen Vorgehensweise Find-Abfragen mit einem if not IsEmpty() then zu beginnen oder einem if IsEmpty() then exit; vorzeitig zu verlassen, hat dieses Vorgehen Performance-Nachteile. Zu diesen Erkenntnissen kam im August 2024 waldo, was er in seinem folgenden Blog erläutert: Link zum Blog

Daher unterlassen wir die zusätzliche Abfrage nun ebenfalls: Vor Get- oder Find-Befehlen wird keine IsEmpty-Abfrage gemacht.

Hinweis

Von dieser Regelung sind DeleteAll und ModifyAll ausgeschlossen. Hier macht das IsEmpty aus Performance-Gründen Sinn und soll weiterhin genutzt werden.

Entwicklung von Upgrade-Code

Wenn es nötig ist zu einer laufenden Entwicklung Upgrade-Code zu implementieren, soll immer gleich vorgegangen werden. Dabei seien hier folgende zwei Voraussetzungen gegeben:

  • Der Entwickler hat bereits entschieden, dass aus einem bestimmten Grund Upgrade-Code nötig ist.
  • In der Extension existiert bereits eine Upgrade-Codeunit nach folgendem Beispiel:
codeunit 5599484 "GOB My unitop Extension Upgrade"
{
    Subtype = Upgrade;

    trigger OnUpgradePerCompany()
    begin
        RunUpgradeCodeAdvancedAddInsPerCompany();
    end;

    local procedure RunUpgradeCodeAdvancedAddInsPerCompany()
    begin
        UpgradeIconsInAASetup();
    end;
    ...

Grundsätzlich wird mit Upgrade-Tags gearbeitet (vgl. Microsoft Standard). Die Dokumentation zu diesem Thema findet sich hier: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-upgrading-extensions. Ein Upgrade-Tag setzt sich - laut Dokumentation Microsoft - so zusammen: [CompanyPrefix]-[ID]-[Description]-[YYYYMMDD].

Der Aufbau der einzelnen Bestandteile des Upgrade-Tags wird wie folgt festgelegt:

  • [CompanyPrefix] - GOB (oder im Projekt PTE)
  • [ID] - Nummer des PBIs oder Bugs aus Azure DevOps das der Grund für das Upgrade war
  • [Description] - kann frei vom Entwickler festgelegt werden
  • [YYYYMMDD] - z. B. 20221004

Für unitop haben wir entschieden eine eigene Tabelle "Upgrade-Tag" zu nutzen. Erster Schritt um Upgrade-Code zu implementieren ist daher die Erstellung eines passenden Upgrade-Tags (vgl. Microsoft Link oben). Da es sich hier um eine fixe Zeichenkette handelt wird in jedem Fall eine Funktion erstellt, die diesen Code zurück gibt. Zusätzlich wird zu jedem Upgrade-Tag eine Funktion benötigt, die einen Upgrade-Tag-Puffer mit zusätzlichen Informationen initialisiert.

Diese Funktionen werden pro App in einer eigenen Codeunit, die dem Benennungsschema "[Prefix][App-Kürzel] Upgrade Tag Definition" folgt, verwaltet. Dabei sind Abkürzungen natürlich erlaubt.

Die "Upgrade Tag Definition" muss außerdem noch einen Event-Subscriber enthalten, um beim Anlegen/Kopieren von Mandanten direkt alle vorhandenen Upgrade Tags zu setzen.

Beispiel:

codeunit 1234567 "GOB ABC Upgrade Tag Definition"
{
    [EventSubscriber(ObjectType::Codeunit, Codeunit::"GOB Upgrade Tag", OnGetPerCompanyUpgradeTags, '', false, false)]
        local procedure OnGetPerCompanyUpgradeTags(var UpgradeTagBuffer: Codeunit "GOB Upgrade-Tag Buffer")
        begin
            GetAllUpgradeTags(UpgradeTagBuffer);
        end;
    
        internal procedure GetAllUpgradeTags(var UpgradeTagBuffer: Codeunit "GOB Upgrade-Tag Buffer")
        begin
            UpgradeTagBuffer.Add(MoveDescriptionToNewDescription_GetUpgradeTagBuffer());
        end;
    
        internal procedure GetMoveDescriptionToNewDescription(): Code[250]
        begin
            exit('DemoUpgradeTags-2022200-MoveDescriptionToNewDescription-20220907');
        end;
    
        internal procedure MoveDescriptionToNewDescription_GetUpgradeTagBuffer() UpgradeTagBuffer: Codeunit "GOB Upgrade-Tag Buffer"
        var
            AppInfo: ModuleInfo;
            UpgradeTagDescLbl: Label 'MoveDescriptionToNewDescription', Locked = true;
            UpgradeWorkItemLinkLbl: Label 'https://dev.azure.com/gob/unitop%20apps/_workitems/edit/12345', Locked = true;
        begin
            NavApp.GetCurrentModuleInfo(AppInfo);
            UpgradeTagBuffer.Add(
                GetMoveDescriptionToNewDescriptionUpgradeTag(),
                UpgradeTagDescLbl,
                UpgradeWorkItemLinkLbl,
                AppInfo);
        end;
}

Als Nächstes kann die Upgrade-Routine entwickelt werden. Der Programmablauf geschieht zwingend nach folgendem Muster:

  1. Abfragen, ob der entsprechende Upgrade-Tag bereits in der Upgrade-Tag Tabelle enthalten ist. Wenn ja, wird kein Upgrade durchgeführt.
  2. ... falls nein, Upgrade Code ausführen
  3. ... Upgrade-Tag in Upgrade-Tag Tabelle eintragen und damit den Vorgang protokollieren.

Zur Vereinfachung kann die Codeunit 5625515 "GOB EUT Upgrade Helper" verwendet werden. Für komplexere Fälle steht aber auch die Codeunit 5625070 "GOB Upgrade Tag" direkt zur Verfügung.

Beispiel:

    procedure MyUpgradeExample()
    var
        ...
        UpgradeTagDefinition: Codeunit "GOB Upgrade Tag Definition";
        EUTUpgradeHelper: Codeunit "GOB EUT Upgrade Helper";
        AppInfo: ModuleInfo;
        ...
    begin
        // Step 1 - Query whether the corresponding upgrade tag is already contained in the upgrade tag table
        if EUTUpgradeHelper.HasUpgradeTag(UpgradeTagCode) then
            exit;

        // Step 2
        EUTUpgradeHelper.StartUpgrade(UpgradeTagDefinition.MoveDescriptionToNewDescription_GetUpgradeTagBuffer());
        // [Implement your Upgrade-Code here]
        // ...
        // ...

        // Step 3 - Log the upgrade in upgrade tag table
        EUTUpgradeHelper.EndUpgrade();
    end;
Hinweis

Die Extension, die das Upgrade-Toolkit für Upgrade-Code zur Verfügung stellt heißt "Essential Upgrade Toolkit". Dort ist sowohl die unitop Variante der Tabelle "Upgrade-Tag" sowie eine Codeunit mit Basisfunktionen verfügbar.

Upgrade-Tags & Neuinstallation der Anwendung

Bei der Neuinstallation von unitop (der entsprechenden Extension) muss berücksichtigt werden, dass die Upgrade-Tags alle gesetzt werden. Das bedeutet sie müssen alle in die Tabelle Upgrade-Tag als "erledigt" eingetragen werden. Dies soll verhindern dass Upgrade-Code ungewollt ausgeführt wird. vgl. hierzu:

https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-upgrading-extensions

Beispiel:

codeunit 77101 "PTE Install Customer Class"
{
    Subtype = Install;

    var
        ...
        UpgradeTagDefinition: Codeunit "GOB Upgrade Tag Definition";

    trigger OnInstallAppPerCompany()
    var
        CurrentAppInfo: ModuleInfo;
    begin
        NavApp.GetCurrentModuleInfo(CurrentAppInfo);

        if CurrentAppInfo.DataVersion() = Version.Create(0, 0, 0, 0) then
            HandleFreshInstall()
        else
            HandleReinstall();
    end;

    local procedure HandleFreshInstall()
    begin
        AddUpgradeTags();
    end;

    local procedure AddUpgradeTags()
    var
        UpgradeTag: Codeunit "GOB Upgrade Tag";
        UpgradeTagBuffer: Codeunit "GOB Upgrade-Tag Buffer";
    begin
        UpgradeTagDefinition.GetAllUpgradeTags(UpgradeTagBuffer);
        UpgradeTag.SetUpgradeTagsFromBuffer(UpgradeTagBuffer);
    end;
}

Manuelle Upgrades

Manuelle Upgrades bieten die Möglichkeit, ein Upgrade im Anschluss an das automatisierte Upgrade, manuell auszuführen. Dieses Szenario kann zum Beispiel dann auftreten, wenn in einer Extension ein Upgrade angestoßen wird, für das eine zweite Extension benötigt wird, welche allerdings noch nicht installiert wurde.

Vorgehen

Wie auch bei der Entwicklung von Upgrade-Code, muss auch bei den manuellen Upgrades eine Upgrade-Routine in einer Upgrade Codeunit geschrieben werden. Die Upgrade-Routine soll bei einem manuellen Upgrade allerdings nur einen "Manual Upgrade" Datensatz erstellen. Dieser Datensatz wird später den eigentlichen Upgrade-Code starten.

codeunit 77103 "GOB Example Manual Upgrade"
{
    Subtype = Upgrade;

    trigger OnUpgradePerCompany()
    begin
        CreateManualUpgradeEntry();
    end;

    local procedure CreateManualUpgradeEntry()
    var
        ManualUpgrade: Record "GOB Manual Upgrade";
        ManualUpgradeFunctions: Codeunit "GOB Manual Upgrade";
        ManualUpgradeCreation: Codeunit "GOB Manual Upgrade Creation";
        UpgradeTagMgt: Codeunit "GOB Upgrade Tag";
        UpgradeObjectType: Option Codeunit,Report;
        ObjectID: Integer;
        IsUnitopUpgrade: Boolean;
        WorkItemURL, UpgradeDescription : Text[1024];
        CompanyToUpgrade: Text[30];
        UpgradeTag: Code[250];
        FromVersionNo, ToVersionNo: Text[50];
    begin
        UpgradeTag := ManualUpgradeCreation.GetExampleManualUpgradeTag();
        CompanyToUpgrade := CopyStr(CompanyName(), 1, 30);
        UpgradeObjectType := UpgradeObjectType::Codeunit;
        ObjectID := Codeunit::"Example Manual Upgrade";
        UpgradeDescription := 'Manual Upgrade Example';
        IsUnitopUpgrade := true;
        WorkItemURL := 'https://gob.visualstudio.com/unitop%20apps/_workitems/edit/<ID>';
        NavApp.GetCurrentModuleInfo(AppInfo);
        FromVersionNo := Format(AppInfo.DataVersion());
        ToVersionNo := Format(AppInfo.AppVersion());
        

        if not ManualUpgradeFunctions.CheckIfManualUpgradeAlreadyExists(UpgradeTag, CompanyToUpgrade) then
            ManualUpgradeFunctions.CreateNewManualUpgrade(ManualUpgrade, UpgradeTag, CompanyToUpgrade, UpgradeObjectType, ObjectID, UpgradeDescription, IsUnitopUpgrade, WorkItemURL, FromVersionNo, ToVersionNo);
    end;
}

Durch ein Upgrade wird jetzt, für jeden Mandanten, ein neues manuelles Upgrade angelegt.

Als Nächstes muss die eigentliche Upgrade-Routine geschrieben werden. Hierfür kann entweder eine Codeunit oder ein Report genutzt werden. Der gewählte Objekttyp muss in der Funktion "CreateNewManualUpgrade" in dem Parameter "UpgradeObjectType" vermerkt werden. Die ID des Objektes muss in dem Parameter "ObjectID" vermerkt werden.

Wichtig

Das Objekt, welches die Upgrade-Routine beinhaltet, muss diese in dem OnRun trigger aufrufen.

codeunit 77119 "Example Manual Upgrade"
{
    trigger OnRun()
    begin
        UpgradeContactEMail();
    end;

    local procedure UpgradeContactEMail()
    var
        Contact: Record Contact;
    begin
        if Contact.FindSet() then
            Contact.ModifyAll("E-Mail 2", StrSubstNo('%1%2', Contact."E-Mail 2", '.de'));
    end;
}

Der programmiertechnische Teil ist damit abgeschlossen.

Gestartet wird das manuelle Upgrade über den Button "Upgrade starten" auf der Seite "Manuelle Upgrades":

Die Seite "Manuelle Upgrades" enthält den Button "Upgrade starten" zum Auslösen des manuellen Upgrades.

Das manuelle Upgrade wird in vier Schritten ausgeführt.

  1. Prüfung auf die in der Funktion "CreateNewManualUpgrade" vorhandenen Parameter und auf bereits existierende Upgrade-Tags.
  2. Durchlaufen des im manuellen Upgrade angegebenen Objektes.
  3. Das manuelle Upgrade in den Upgrade-Tags vermerken.
  4. Den Nutzer über den erfolgreichen Durchlauf des Upgrades informieren.

Dont's

  • Der Upgrade-Tag darf nicht manuell in die Tabelle "GOB Upgrade-Tag" geschrieben werden.
  • Die Parameter der Funktion "CreateNewManualUpgrade" müssen korrekt ausgefüllt werden, da es andernfalls zu Fehlern im Upgrade kommen kann.
  • Die Upgrade-Routine darf nicht in dem selben Objekt sein, wie die Funktion, die das manuelle Upgrade erstellt.
  • Die Upgrade-Routine muss in einer "normalen" Codeunit (nicht Subtype = Upgrade) oder in einem Report sein.
  • Damit diese Upgrade-Technik (welche in Summe eher ein Edge-Case ist) funktioniert, dürfen keine obsolete Felder beinhaltet sein, da das tatsächliche Objekt welches die Migration durchführt keine Upgrade-Codeunit ist. Demnach müssen "alte" Felder für solch ein Szenario erhalten bleiben. Umgehen kann man jedoch so mit ihnen, dass man Sie aus den Pages entfernt, und dann ein Release später die Felder tatsächlich als obsolete kennzeichnet.