Abwärtskompatibilität von DLL- und COM-Schnittstellen — Eine Entscheidungstabelle dafür, welche Änderungen Aufrufer brechen

· · COM, DLL, .NET, C#, C++, Abwärtskompatibilität, Versionierung, Legacy-Technologie, Nutzung vorhandener Ressourcen, Entscheidungstabelle

„Können wir für diesen Fix einfach die neue DLL austauschen, oder brauchen die Aufrufer auch einen Neubau?“ — wer eine gemeinsam genutzte DLL oder COM-Komponente pflegt, auf die mehrere Anwendungen verweisen, landet bei jedem Release bei genau dieser Frage. Beantworten Sie sie falsch, kann eine beim Kunden noch laufende alte EXE komplett den Start verweigern — oder schlimmer, sie startet weiterhin klaglos, während sich ihre Berechnungsergebnisse still und leise darunter ändern.

Was das Ganze knifflig macht: Die Entscheidung wird meist aus einem vagen „das fühlt sich riskant an“-Bauchgefühl heraus getroffen. In Wirklichkeit lässt sich fast mechanisch bestimmen, welche Änderungen die Kompatibilität brechen. Native DLLs haben klar definierte Regeln rund um Exporte und Aufrufkonventionen; COM hat die explizite, schriftlich niedergelegte eiserne Regel, dass Schnittstellen unveränderlich sind1; und .NET verfügt über eine veröffentlichte Liste von Kompatibilitätsänderungsregeln, die Microsoft selbst bei der Entwicklung der .NET-Bibliotheken verwendet2.

Auf diesem Blog haben wir die Grundlagen von COM bereits in Was ist COM / ActiveX / OCX? und dessen Designphilosophie in Was ist COM — Warum das Design von Windows-COM auch heute noch trägt behandelt. Dieser Artikel ordnet als Entscheidungstabelle, welche Änderungen für DLLs, COM und .NET-Assemblies jeweils Aufrufer brechen, und behandelt das Verfahren, wenn ein Kompatibilitätsbruch unvermeidbar wird.

1. Das Ergebnis zuerst

  • Kompatibilität hat drei Ebenen: binäre Kompatibilität (funktioniert ohne Neubau), Quellcode-Kompatibilität (funktioniert bei Neubau) und Verhaltenskompatibilität (das Verhalten ändert sich nicht). „Kein Neubau nötig“ ist nicht gleichbedeutend mit „sicher“ — Sie müssen auch die Verhaltenskompatibilität beurteilen.3
  • Für native DLLs gilt als Grundregel: Einen Export hinzuzufügen ist sicher; einen bestehenden Export zu ändern oder zu entfernen ist ein Bruch. Funktionssignaturen, Aufrufkonventionen und Struct-Layout sind der binäre Vertrag selbst.
  • Eine COM-Schnittstelle ist nach Veröffentlichung unveränderlich. Methoden nach der Veröffentlichung hinzuzufügen, zu entfernen oder umzuordnen verstößt gegen die Spezifikation; Änderungen müssen als neue Schnittstelle mit neuer IID hinzugefügt werden (IFoo → IFoo2).41
  • VB6-/VBA-Clients nutzen frühe Bindung, die Vtable-Slot-Positionen fest einprogrammiert und sie damit zu den Aufrufern macht, die am ehesten durch jede Änderung am Layout einer Schnittstelle brechen.
  • Für .NET wird veröffentlicht, was als Breaking Change an einer öffentlichen API gilt — Microsofts Kompatibilitätsänderungsregeln —, und die Liste klassifiziert nicht nur das Entfernen von Methoden oder Signaturänderungen, sondern auch das Hinzufügen von virtual zu einem Member und sogar das Umbenennen eines Parameters als Breaking Change.2
  • Semantische Versionierung ist die Konvention, bei einem Breaking Change die Hauptversion zu erhöhen, funktioniert aber erst, wenn Sie eine Definition dessen festlegen, was als Breaking Change gilt.5 Sie können die Entscheidungstabelle dieses Artikels als diese Definition verwenden.
  • Ist ein Kompatibilitätsbruch unvermeidbar, gehen Sie in dieser Reihenfolge vor: Alt und Neu parallel anbieten → eine Übergangsfrist → eine Bestandsaufnahme der Aufrufer → Abschaltung. Das Prinzip lautet, niemals alles auf einmal auszutauschen.

2. Die drei Ebenen der Kompatibilität — wer wann bricht

Was salopp als „Abwärtskompatibilität“ in einen Topf geworfen wird, gliedert sich tatsächlich in drei Ebenen. Selbst die offizielle Dokumentation von .NET klassifiziert Breaking Changes entlang der Linien Quellcode-, binärer und Verhaltenskompatibilität.3

Ebene Bedeutung Was beim Bruch passiert Wer hauptsächlich leidet
Binäre Kompatibilität Aufrufer funktionieren mit der neuen DLL ohne Neubau Einstiegspunkt beim Start nicht gefunden, eine MissingMethodException zur Laufzeit, Abstürze Beim Kunden noch laufende alte EXEs, Drittanbieter-Apps, die Sie nicht neu bauen können
Quellcode-Kompatibilität Aufrufer funktionieren, wenn Sie sie neu bauen Kompilierfehler beim nächsten Build Ein anderes internes Team, Entwickler, die die Quelle besitzen
Verhaltenskompatibilität Das spezifizierte Verhalten ändert sich nicht Ergebnisse, Timing oder die Art der geworfenen Ausnahme ändern sich ganz ohne Fehler Endanwender (und alle, die den entstehenden Vorfall am Ende untersuchen müssen)

Der wichtige Punkt zu diesen drei Ebenen ist, dass eine äußere Ebene brechen kann, selbst wenn eine innere Ebene intakt bleibt. Ein Fix, der beispielsweise die Bedeutung des Rückgabewerts einer bestehenden Funktion ändert, bewahrt sowohl die binäre als auch die Quellcode-Kompatibilität, während er nur die Verhaltenskompatibilität bricht. Weil diese Art von Änderung weder einen Linkfehler noch einen Kompilierfehler erzeugt, ist sie die Zeile in der Entscheidungstabelle, die am leichtesten übersehen wird.

Umgekehrt müssen Sie in einer Umgebung, in der jeder Aufrufer die Quelle besitzt und gleichzeitig neu gebaut werden kann (etwa ein internes System in einem einzigen Repository), nur Quellcode- und Verhaltenskompatibilität bewahren — binäre Kompatibilität kann aus den Anforderungen gestrichen werden. Ob irgendein Aufrufer Ihrer DLL ein Binary ist, das Sie nicht neu bauen können, ist die erste Weggabelung beim Lesen der Entscheidungstabelle.

3. Die Kompatibilitäts-Entscheidungstabelle für native DLLs (C/C++)

Die Kompatibilität einer nativen DLL wird durch ihre Export-Tabelle, Aufrufkonventionen und ihr Speicherlayout bestimmt. Wie eine DLL überhaupt gefunden und geladen wird, behandelt Wie die Namensauflösung von Windows-DLLs funktioniert; sobald das Laden gelingt, ist die folgende Tabelle der Maßstab zur Beurteilung der Kompatibilität.

Änderung Binäre Kompatibilität Anmerkungen
Eine exportierte Funktion hinzufügen Bricht nicht Der sicherste Weg, eine API zu erweitern. Verlassen Sie sich jedoch auf implizite Ordinalzahlen aus einer .def-Datei, kann das Hinzufügen einer Funktion bestehende Ordinalzahlen je nach Position neu nummerieren; verlinkt ein Client per Ordinalzahl, fixieren Sie die bestehenden Ordinalzahlen explizit und hängen neue am Ende an
Eine exportierte Funktion entfernen oder umbenennen Bricht Die Import-Auflösung schlägt fehl, was einen Fehler beim Laden oder bei GetProcAddress erzeugt
Die Signatur einer bestehenden Funktion ändern (Parameter hinzufügen/entfernen/umtypisieren, den Rückgabetyp ändern) Bricht Stack- und Registerübergabekonventionen stimmen nicht mehr überein. Dasselbe gilt für Rückgabewerte — wechseln Sie von einer Ganzzahl (RAX) zu einem Gleitkommawert (XMM0), liest der Aufrufer unter dem alten ABI Datenmüll. Es kann ohne Fehler laufen und einfach entgleisen
Die Aufrufkonvention ändern (__cdecl <-> __stdcall) Bricht (32-Bit) Auf x86 tauscht die Verantwortung für das Aufräumen des Stacks, was zu Stack-Beschädigung führt. x64 hat eine einzige Aufrufkonvention und ignoriert diese Spezifizierer faktisch, sodass diese Zeile eine 32-Bit-DLL-Angelegenheit ist
Eine Export-Ordinalzahl ändern Bricht, bedingt Ein per Ordinalzahl verlinkender Aufrufer ruft am Ende eine andere Funktion auf. Keine Auswirkung, wenn jeder Aufrufer nur per Name verlinkt
Einem vom Aufrufer allozierten Struct ein Feld hinzufügen Bricht Ein alter Aufrufer alloziert und übergibt weiterhin die kleinere Version (dies kann mit der unten besprochenen cbSize-Konvention gemildert werden)
Das Packing/Alignment eines öffentlichen Structs ändern (#pragma pack, /Zp, ein Toolchain-Wechsel) Bricht Die Offsets bestehender Member und die Gesamtgröße ändern sich, selbst wenn Sie keinen einzigen Member anfassen. cbSize kann Sie nicht vor einem verschobenen Layout retten, fixieren Sie das Packing daher explizit im öffentlichen Header
Interne Änderungen an einem Struct, das nur die DLL alloziert und freigibt Bricht nicht Wenn das Design nach außen nur einen Zeiger (ein Handle) offenlegt, darf sich das Innenleben frei ändern
Die Bedeutung eines Rückgabewerts oder Fehlercodes ändern Bricht nicht (aber die Verhaltenskompatibilität schon) Das Linken gelingt weiterhin, während sich das Verhalten ändert — das Muster, das am langsamsten entdeckt wird
Einer direkt exportierten C++-Klasse ein Datenmember oder eine virtuelle Funktion hinzufügen Bricht Objektgröße oder Vtable-Layout ändern sich. Nur eine nicht-virtuelle Memberfunktion hinzuzufügen lässt das Layout unverändert und bricht bestehende Clients nicht direkt, aber das direkte Exportieren einer C++-Klasse hat von vornherein keine Compiler-übergreifende Kompatibilität, und diese Einzelfallentscheidung jedes Mal treffen zu müssen, ist selbst ein Zeichen für ein fragiles ABI

Die Design-Leitlinie, zu der diese Tabelle führt, hat sich seit Jahrzehnten nicht geändert: Halten Sie die Grenze auf ein C-ABI beschränkt (extern "C"-Funktionen und einfache Structs) und erweitern Sie durch Hinzufügen von Funktionen. Dasselbe gilt, selbst wenn Sie eine native DLL aus C# bauen: Die in Wie man eine C#-Native-AOT-DLL von C/C++ aus aufruft behandelte Exportoberfläche wird genau nach dieser Tabelle verwaltet.

3.1 Die cbSize-Konvention — Win32s Trick, um Structs erweiterbar zu machen

Die klassische Gegenmaßnahme gegen „das Hinzufügen eines Struct-Members bricht Aufrufer“ ist die Win32-Konvention, ein Größenfeld an den Kopf des Structs zu setzen. Der Aufrufer füllt cbSize mit der ihm zur Kompilierzeit bekannten Struct-Größe und übergibt das; die DLL sieht sich diese Größe an, um zu bestimmen, welche Generation des Structs dieser konkrete Aufrufer versteht.

typedef struct KS_CONFIG {
    DWORD cbSize;      // Der Aufrufer setzt sizeof(KS_CONFIG)
    DWORD dwMode;
    DWORD dwTimeout;
    // Neue Member immer am Ende hinzufügen
} KS_CONFIG;

// DLL-Seite: cbSize nutzen, um Generationen zu unterscheiden, und für alte Aufrufer auf einen Standardwert zurückfallen
if (pConfig->cbSize >= FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD)) {
    timeout = pConfig->dwTimeout;   // Neuer Aufrufer
} else {
    timeout = DEFAULT_TIMEOUT;      // Alter Aufrufer
}

Tatsächlich wird das NOTIFYICONDATA-Struct der Windows-API genau nach diesem Schema generationsweise versioniert, und es ist offiziell dokumentiert, dass das Setzen des richtigen Werts in cbSize die Kompatibilität mit älteren Versionen von Shell32.dll erhält.6 Setzen Sie cbSize von Anfang an, also ab der allerersten Version, in die öffentlichen Structs Ihrer eigenen DLL, wandern spätere Erweiterungen von „Breaking Change“ auf die sichere Seite der Entscheidungstabelle. Allerdings müssen neue Member immer am Ende stehen, und das Ändern von Typ oder Reihenfolge eines bestehenden Members bleibt weiterhin verboten. Noch etwas: Bei einem für Ausgaben genutzten Struct übernimmt die DLL-Seite zusätzliche Verantwortung — Schreibvorgänge und Initialisierung müssen sich immer innerhalb der vom Aufrufer tatsächlich übergebenen cbSize bewegen. Bedingungslos die vollständige neue sizeof zu schreiben, läuft über den kleineren Puffer hinaus, den ein alter Aufrufer alloziert hat, und die DLL verursacht am Ende genau die Art von Bruch, die diese Konvention verhindern sollte.

4. Die eiserne Regel der COM-Schnittstellen — keine Änderungen nach Veröffentlichung

COM ist die Technologie, die auf dieses Problem die klarste Antwort gab. Gemäß der COM-Spezifikation folgt eine Schnittstelle diesen Regeln:

  • Eine Schnittstelle hat eine eindeutige IID (Interface-ID).1
  • Eine Schnittstelle ist unveränderlich. Einmal erstellt und veröffentlicht, darf kein Teil ihrer Definition jemals geändert werden.1
  • Eine Methode hinzuzufügen oder zu entfernen oder ihre Semantik zu ändern bedeutet nicht, „eine neue Version der alten Schnittstelle“ zu erstellen — es bedeutet, eine neue Schnittstelle mit einer anderen IID zu erstellen.4

Der Grund für eine solche Strenge ist, dass die eigentliche Substanz einer COM-Schnittstelle ein binäres Layout ist — die Vtable, eine Tabelle von Funktionszeigern. Ein C++- oder VB6-Client programmiert zur Kompilierzeit fest ein, dass „Slot 3 GetName ist“ — eine Position. Fügen Sie nach der Veröffentlichung eine Methode ein, ruft der alte Client, völlig ohne Fehler, eine andere Methode auf. Genau deshalb hat COM die Operation des „Änderns“ einer Schnittstelle selbst aus der Spezifikation gestrichen und stattdessen folgendes Erweiterungsverfahren bereitgestellt.

// v1: bereits veröffentlicht. Nie wieder ändern
[object, uuid(1111....)]
interface ICalc : IUnknown {
    HRESULT Add([in] long a, [in] long b, [out, retval] long* result);
};

// v2: eine neue Schnittstelle mit neuer IID. Erweitert durch Vererbung von ICalc
[object, uuid(2222....)]
interface ICalc2 : ICalc {
    HRESULT AddChecked([in] long a, [in] long b, [out, retval] long* result);
};

Die implementierende Klasse (die Coclass) implementiert sowohl ICalc als auch ICalc2; alte Clients nutzen ICalc genau wie zuvor weiter, und neue Clients fragen ICalc2 per QueryInterface an. Die offizielle Versionierungstheorie für RPC/COM legt es genauso dar: eine neue Schnittstelle, die von der alten erbt, entspricht einer Nebenversionserhöhung, während das Ändern einer bestehenden Methode oder eines Typs eine vollständig neue, nicht erbende Schnittstelle erfordert — das Äquivalent einer Hauptversionserhöhung.7 Was dieses Schema funktionieren lässt, ist, dass QueryInterface einem Aufrufer erlaubt, zur Laufzeit sicher zu prüfen, welche Schnittstellen unterstützt werden. Die Eleganz des Designs hinter diesem Mechanismus haben wir in Was ist COM genauer betrachtet.

4.1 Verantwortungsteilung zwischen CLSID, ProgID und IID

Beim Nachdenken über COM-Versionierung hilft es, die Rollen dreier Arten von Bezeichnern zu trennen.8

  • IID identifiziert eine Schnittstelle (einen Vertrag). Jede Änderung am Vertrag bedeutet immer eine neue IID.
  • CLSID identifiziert die implementierende Klasse. Sie können die Implementierung bei gleichbleibender CLSID frei austauschen, solange Sie den Vertrag jeder veröffentlichten Schnittstelle einhalten.
  • ProgID ist ein menschenlesbarer Alias (KomuraSoft.Calc.1), der zum Nachschlagen der zugehörigen CLSID in der Registrierung verwendet wird. Es ist üblich, sowohl eine versionierte ProgID als auch eine versionsunabhängige ProgID (KomuraSoft.Calc) zu führen, die immer auf die neueste Version zeigt, wobei letztere per CurVer auf die aktuelle Version abgebildet wird.8

Mit anderen Worten: „Die Implementierung aktualisieren“ gehört zur Welt von CLSID und ProgID, während „den Vertrag ändern“ zur Welt der IID gehört — vermengen Sie die beiden nicht. Möchten Sie die Registrierung ganz vermeiden, behandelt diese Option Was ist Reg-Free-COM?

4.2 Warum VB6-/VBA-Clients besonders zerbrechlich sind

Wenn VB6 oder VBA eine COM-Komponente über eine Projektreferenz nutzt (frühe Bindung), liest es die Typbibliothek zur Kompilierzeit, um Aufrufe aufzulösen. Frühe Bindung ist die empfohlene Form — Sie erhalten IntelliSense, Typprüfung und schnellere Ausführung9 —, doch der Preis ist, dass sie sich eng an das Layout der Typbibliothek bindet. Nicht nur eine Änderung an der Vtable einer Schnittstelle verursacht Ärger; sogar eine rein an den Definitionen der Typbibliothek vorgenommene Änderung kann als „die Referenz war beim Öffnen des Projekts defekt“ oder als Laufzeitfehler 430/438 auftauchen.

Das bedeutet, dass Komponenten mit VB6, VBA oder Excel-Makros als Aufrufer die Regel der Schnittstellen-Unveränderlichkeit am allerstrengsten durchsetzen müssen. Auch Typbibliotheken tragen eine Version (Major.Minor), die Sie bei jeder Vertragserweiterung erhöhen. Wie Sie beim Veröffentlichen typisierten .NET-Codes für VBA eine Typbibliothek generieren, behandelt Eine typisierte .NET-8-DLL von VBA aus aufrufen — dscom und TLB. Spät gebundene Clients, die nur CreateObject nutzen, lösen dagegen namentlich auf und sind damit widerstandsfähig gegen Layoutänderungen — sind aber ebenso einer Änderung an der Bedeutung einer Methode (Verhaltenskompatibilität) ausgesetzt.

5. .NET-Assembly-Kompatibilität — mechanisch mit den offiziellen Regeln beurteilen

.NET verfügt über einen veröffentlichten Satz von „Änderungsregeln für Kompatibilität“, den Microsoft selbst bei der Entwicklung der .NET-Bibliotheken verwendet und der jede Änderungsart als erlaubt (✔️), verboten (❌) oder beurteilungsbedürftig (❓) klassifiziert.2 Die Dokumentation stellt ausdrücklich fest, dass Sie sie unverändert als Kriterien für Ihre eigenen Bibliotheken übernehmen können, daher hier die wichtigsten Zeilen.

Änderung an einer öffentlichen API Urteil Anmerkungen
Eine Methode, einen Typ oder ein Member hinzufügen ✔️ Grundsätzlich sicher Achten Sie jedoch auf eine Ergänzung, die die bestehende Überladungsauflösung ändert. Einem öffentlichen Struct ein Instanzfeld hinzuzufügen ist eine Ausnahme: es ändert Größe und Layout und bricht Interop und Unsafe-Konsumenten
Einen öffentlichen Typ oder ein Member entfernen oder umbenennen ❌ Bruch Bricht zur Laufzeit mit einer MissingMethodException oder Ähnlichem
Eine Signatur ändern (Parameter hinzufügen/entfernen/umordnen/umtypisieren, oder den Rückgabetyp) ❌ Bruch Bricht sowohl binäre als auch Quellcode-Kompatibilität
Einen Parameter umbenennen ❌ Bruch Bricht benannte C#-Argumente und späte VB-Bindung. Leicht zu übersehen
virtual zu einem Member hinzufügen ❌ Bruch Eine klassische Falle, die sicher aussieht, weil es „nur eine Ergänzung“ ist. Ein Mismatch zwischen den IL-Instruktionen call/callvirt kann die Folge sein
virtual entfernen, oder einen virtuellen Member abstrakt machen ❌ Bruch Bricht Overrides in abgeleiteten Klassen
Einem nicht versiegelten öffentlichen Typ ein abstraktes Member hinzufügen ❌ Bruch Bestehende abgeleitete Klassen haben dafür keine Implementierung
Einen Typ versiegeln (sealed) ❌ Bruch Bestehende abgeleitete Klassen kompilieren nicht mehr
Einer Schnittstelle ein Member hinzufügen ❓ Beurteilungsbedürftig Kann mit einer Default-Interface-Implementierung (DIM) gemildert werden, die aber Sprach-/Laufzeitbedingungen mit sich bringt
Eine Konstante, oder einen Enum-Wert ändern, oder ein Enum-Member umbenennen/entfernen ❌ Bruch Der Wert wird beim Aufrufer zur Kompilierzeit fest einprogrammiert
Code so ändern, dass eine spezifischere Ausnahme geworfen wird ✔️ Erlaubt Bestehende catch-Blöcke funktionieren weiter
Auf einem bestehenden Codepfad eine neue Art von Ausnahme werfen ❌ Bruch Sie nur für einen neuen Parameterwert zu werfen, ist in Ordnung

Es ist nicht so einfach wie COMs „Schnittstellen sind unveränderlich“, aber die zugrunde liegende Philosophie ist dieselbe: Eine öffentliche API ist ein Vertrag; Sie können den Vertrag erweitern, aber den bestehenden Vertrag nicht ändern. Und dass Änderungen wie das Hinzufügen einer virtuellen Methode oder das Umbenennen eines Parameters — Dinge, die auf den ersten Blick sicher aussehen — als Breaking Change klassifiziert werden, ist genau der Grund, warum Sie nach der Tabelle und nicht nach Gefühl urteilen sollten.

5.1 Starke Namen und drei Versionsnummern

.NET-Assemblies tragen mehrere Versionsnummern, jede mit einer anderen Rolle.10

  • AssemblyVersion: die einzige Version, die die Laufzeitumgebung zur Identifizierung und zum Laden einer Assembly verwendet. Bei stark benannten Assemblies verlangt die CLR des .NET Framework eine exakte Übereinstimmung, sodass jede Erhöhung Aufrufer zwingt, eine Binding-Umleitung hinzuzufügen (.NET / .NET Core akzeptiert dagegen automatisch eine höhere Version). Um Umleitungen zu reduzieren, empfiehlt die offizielle Anleitung, nur die Hauptversion in AssemblyVersion widerzuspiegeln.
  • FileVersion (AssemblyFileVersion): nur in den Dateieigenschaften des Explorers sichtbar, ohne Auswirkung auf das Laufzeitverhalten. Der empfohlene Ort, um eine CI-Build-Nummer einzustempeln.
  • InformationalVersion: eine freiformatige, menschenlesbare Zeichenkette. Wird verwendet, um eine im Semver-Format formatierte Paketversion oder den Quell-Commit-Hash festzuhalten.

In der Praxis ist damit der funktionierende dreistufige Aufbau: Kompatibilität über die Paket-/Produktversion deklarieren (Semver), nur die Hauptversion in AssemblyVersion widerspiegeln und Builds mit FileVersion nachverfolgen.

6. Wie Sie Versionsnummern vergeben — Semver funktioniert erst mit einer „Definition“

Semantische Versionierung (Semver) passt in drei Zeilen: MAJOR bei einer inkompatiblen Änderung erhöhen, MINOR bei einer abwärtskompatiblen Funktionsergänzung, und PATCH bei einem abwärtskompatiblen Bugfix.5

Was sich leicht übersehen lässt: Die allererste Anforderung der Semver-Spezifikation ist, dass Software, die Semver verwendet, eine öffentliche API deklarieren muss.5 Ohne zu deklarieren, was als öffentliche API gilt, gibt es kein Kriterium dafür, was eine „inkompatible Änderung“ überhaupt ist, und ob die Hauptversion erhöht wird, hängt von der Tagesform der jeweiligen Person ab. An den meisten Stellen, an denen Semver in der Praxis nicht funktioniert, liegt das Problem nicht daran, wie die Nummern vergeben werden — sondern daran, dass diese Deklaration übersprungen wurde.

Ein realistisches Betriebsmodell für eine intern verteilte DLL sieht so aus.

  1. Den Umfang der öffentlichen API deklarieren — bei einer nativen DLL sind das die exportierten Funktionen und öffentlichen Header, bei COM das IDL/die Typbibliothek, bei .NET öffentliche Typen und Member. Halten Sie ausdrücklich fest, dass „alles außerhalb davon ein internes Implementierungsdetail ist, das sich ohne Vorwarnung ändern kann“.
  2. Eine Definition eines Breaking Change übernehmen — bringen Sie die Entscheidungstabellen dieses Artikels aus den Kapiteln 3 und 5, zusammen mit .NETs Änderungsregeln2, als „unsere eigene Definition“ in Ihr Repository ein.
  3. Die Prüfung automatisieren — für .NET können Werkzeuge wie Package Validation und ApiCompat die binäre Kompatibilität gegenüber der vorherigen Version mechanisch verifizieren.11 Das eliminiert die „vermutlich passt schon“-Beurteilung während des Reviews.
  4. Ein Kompatibilitätsfeld zu den Release-Notes hinzufügen — legen Sie jedes Mal ausdrücklich einen von drei Werten fest: kein Neubau nötig / Neubau empfohlen / Breaking Change enthalten. Das ist ein Mechanismus, um die Antwort auf „können wir die neue DLL einfach austauschen?“ schriftlich festzuhalten, bevor überhaupt jemand fragen muss.

7. Das Verfahren, wenn ein Kompatibilitätsbruch unvermeidbar ist

Erweist sich eine Änderung, die die Entscheidungstabelle als Breaking Change kennzeichnet, als unvermeidbar, gehen Sie so vor, dass Sie beide Versionen parallel anbieten, statt eine gegen die andere auszutauschen.

  1. Alt und Neu parallel anbieten — bei COM heißt das, IFoo2 hinzuzufügen, während IFoo bestehen bleibt (Kapitel 4). Bei einer nativen DLL fügen Sie eine neue Funktion hinzu (FooEx) oder lassen eine neu benannte DLL neben der alten koexistieren. Bei .NET liefern Sie die Änderung als neues Paket mit erhöhter Hauptversion aus und halten die alte Hauptversion nur für Bugfixes am Leben.
  2. Eine Übergangsfrist festlegen — in .NET kann das Attribut [Obsolete] eine Compilezeit-Warnung auslösen. Für nativen/COM-Code deklarieren Sie es über Header-Kommentare und Release-Notes, und legen Sie das geplante Entfernungsdatum ausdrücklich fest. Der Schlüssel ist, sich auf ein tatsächliches Datum festzulegen, nicht auf ein vages „wir entfernen es irgendwann“.
  3. Bestandsaufnahme der Aufrufer machen — bauen Sie eine Liste, wer noch die alte API aufruft, indem Sie internen Quellcode durchsuchen, Installer-Verteilungsdatensätze prüfen und, bei COM, die Nutzung von Registrierungsreferenzen prüfen. Finden Sie ein Binary, das Sie nicht neu bauen können — ein Tool, das jemand gebaut hat, der inzwischen ausgeschieden ist, oder eine Drittanbieter-App —, verlängern Sie entweder die Lebensdauer der alten API speziell für diesen Fall, oder überbrücken Sie mit einem Wrapper.
  4. Die alte API entfernen — erst nachdem die Bestandsaufnahme bestätigt hat, dass keine Aufrufer mehr übrig sind, entfernen Sie sie und erhöhen die Hauptversion.

Dieses Verfahren ist teuer. Genau deshalb ist paradoxerweise eine kleine API von der allerersten Version an mit der Entscheidungstabelle im Hinterkopf zu entwerfen — da alles, was Sie nie veröffentlichen, überhaupt keine Kompatibilitätsverpflichtung mit sich bringt — die größte Kompatibilitätsabsicherung, die Sie haben.

8. Zusammenfassung

  • Denken Sie über Kompatibilität in drei Ebenen: binär, Quellcode und Verhalten. Die Verhaltenskompatibilität kann selbst ohne Neubau brechen.3
  • Für native DLLs gilt: Hinzufügen ist sicher; einen bestehenden Export, eine Signatur oder ein Struct-Layout zu ändern ist ein Bruch. Geben Sie Structs ein cbSize-Feld, um Erweiterungsraum zu lassen.6
  • Eine COM-Schnittstelle ist nach Veröffentlichung unveränderlich. Fügen Sie Änderungen als neue Schnittstelle mit neuer IID hinzu (IFoo2) und lassen Sie Aufrufer per QueryInterface unterscheiden.147 Setzen Sie das besonders streng durch, wann immer früh gebundene VB6-/VBA-Clients beteiligt sind.
  • Für .NET können Sie mit den offiziellen Kompatibilitätsänderungsregeln mechanisch urteilen. Achten Sie auf „scheinbar sichere“ Änderungen — virtual hinzufügen, einen Parameter umbenennen, einen Typ versiegeln —, die als Breaking Change klassifiziert werden.2
  • Ein realistischer dreistufiger Aufbau: AssemblyVersion trägt nur die Hauptversion, FileVersion verfolgt den Build, und Semver deklariert die Kompatibilität.10
  • Semver funktioniert erst, wenn Sie eine öffentliche API und eine Definition eines Breaking Change deklarieren.5 Übernehmen Sie die Entscheidungstabelle als diese Definition und prüfen Sie sie automatisch mit Werkzeugen wie Package Validation.11
  • Brechen Sie tatsächlich etwas, gehen Sie parallel anbieten → Übergangsfrist → Bestandsaufnahme → Entfernung. Niemals alles auf einmal auszutauschen, ist genau das, was die beim Kunden noch laufende alte EXE schützt.

Verwandte Artikel

Verwandte Beratungsbereiche

Die KomuraSoft LLC (合同会社小村ソフト) übernimmt das Kompatibilitätsdesign für DLLs, COM-Komponenten und .NET-Bibliotheken, auf die andere Systeme verweisen, die Bestandsaufnahme öffentlicher APIs und die Einrichtung einer Versionierungsrichtlinie sowie das Design und die Implementierung von Erweiterungen — das IFoo2-Muster, paralleles Anbieten —, die bestehende Clients nicht brechen.

  1. Microsoft Learn, Interface Design Rules. Dazu, dass Schnittstellen von COM-Objekten eine eindeutige IID benötigen und dass kein Teil der Definition einer Schnittstelle geändert werden darf, sobald sie erstellt und veröffentlicht wurde (Unveränderlichkeit).  2 3 4 5

  2. Microsoft Learn, Change rules for compatibility (.NET). Dazu, dass .NET-API-Änderungen als erlaubt, verboten oder beurteilungsbedürftig klassifiziert werden; dass das Entfernen oder Umbenennen öffentlicher Typen/Member, Signaturänderungen, Parameterumbenennungen, das Hinzufügen/Entfernen von virtual, Versiegelung und Änderungen an Konstanten-/Enum-Werten allesamt als verboten (Breaking Change) klassifiziert werden; dass das Hinzufügen eines Members zu einer Schnittstelle beurteilungsbedürftig ist; und dass Bibliotheksautoren die Regeln als Bewertungskriterium für ihre eigene Bibliothek verwenden können.  2 3 4 5

  3. Microsoft Learn, Breaking changes (.NET library guidance). Dazu, dass Breaking Changes als quellcode-brechend, verhaltens-brechend oder binär-brechend klassifiziert werden, und dass binär-brechende Änderungen dazu führen, dass eine gegen die alte Version kompilierte Assembly zur Laufzeit mit einer MissingMethodException oder Ähnlichem fehlschlägt.  2 3

  4. Microsoft Learn, Interface Pointers and Interfaces. Dazu, dass COM-Schnittstellen unveränderlich sind und das Hinzufügen oder Entfernen einer Methode oder das Ändern ihrer Semantik bedeutet, eine neue Schnittstelle statt einer neuen Version der alten zu erstellen, wobei die IID den Vertrag eindeutig definiert.  2 3

  5. semver.org, Semantic Versioning 2.0.0. Dazu, MAJOR bei inkompatiblen API-Änderungen zu erhöhen, MINOR bei abwärtskompatiblen Funktionsergänzungen und PATCH bei abwärtskompatiblen Bugfixes; dass Software, die Semver verwendet, eine öffentliche API deklarieren muss; und dass eine rückwärtsinkompatible Änderung der öffentlichen API immer eine MAJOR-Versionserhöhung erfordert.  2 3 4

  6. Microsoft Learn, NOTIFYICONDATAW structure (shellapi.h). Dazu, dass die Struct-Größe im Member cbSize gesetzt wird, dass das Struct über Generationen hinweg erweitert wurde, und dass das Setzen des passenden cbSize-Werts die Kompatibilität mit älteren Versionen von Shell32.dll erhält.  2

  7. Microsoft Learn, The Versioning Theory for RPC and COM. Dazu, dass das Erstellen einer neuen Schnittstelle der beste Weg ist, Funktionalität in COM zu erweitern, dass eine neue Schnittstelle, die von der alten erbt, einer Nebenversionserhöhung entspricht, dass Änderungen an bestehenden Methoden oder Typen eine vollständig neue, nicht erbende Schnittstelle erfordern, und dass QueryInterface Aufrufern erlaubt zu prüfen, was unterstützt wird.  2

  8. Microsoft Learn, COM Registry Keys. Dazu, dass die CLSID die GUID ist, die eine COM-Klasse identifiziert, dass die ProgID eine menschenlesbare, ohne Eindeutigkeitsgarantie auf eine CLSID abgebildete Zeichenkette ist, dass die versionsunabhängige ProgID per CurVer auf die neueste Klassenversion abgebildet wird, und dass der Interface-Schlüssel IIDs registriert.  2

  9. Microsoft Learn, OLE programmatic identifiers, late binding, and early binding (Project). Dazu, dass frühe Bindung über eine Projektreferenz in VBA empfohlen wird, dass späte Bindung (CreateObject/ProgID) beim Schreiben von Code keine Member anzeigt und langsamer läuft, und dass frühe Bindung eine Referenz auf die Ziel-Objektbibliothek erfordert. 

  10. Microsoft Learn, Versioning (.NET library guidance). Dazu, dass AssemblyVersion von der Laufzeitumgebung zum Laden verwendet wird und bei stark benannten Assemblies unter .NET Framework eine exakte Übereinstimmung erfordert, dass empfohlen wird, nur die Hauptversion in AssemblyVersion aufzunehmen, dass FileVersion für die Windows-Anzeige gedacht ist, ohne Auswirkung auf das Laufzeitverhalten, dass InformationalVersion zusätzliche Versionsinformationen festhält, und dass Semver 2.0.0 für NuGet-Paketversionen empfohlen wird.  2

  11. Microsoft Learn, NuGet package compatibility rules. Dazu, dass binär-brechende Änderungen vermieden werden müssen, dass die Werkzeuge Package Validation und ApiCompat die Kompatibilität gegenüber einer Baseline-Version automatisch erkennen können, und dass AssemblyVersion zwischen Releases niemals sinken darf.  2

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Wenn ich einer DLL nur eine Funktion hinzufüge, müssen Aufrufer dann neu gebaut werden?
In der Regel nein — wird nur eine exportierte Funktion hinzugefügt, funktionieren bestehende Aufrufer unverändert weiter, da die Import-Auflösung weiterhin gelingt, solange Name, Signatur, Aufrufkonvention oder Export-Ordinalzahl einer bestehenden Funktion nicht geändert werden. Fügen Sie jedoch einem vom Aufrufer allozierten und übergebenen Struct ein Feld hinzu oder ändern Sie die Bedeutung des Rückgabewerts oder Fehlercodes einer bestehenden Funktion, kann die Verhaltenskompatibilität brechen, obwohl kein Neubau erzwungen wird. Die Grundregel lautet: Eine Funktion hinzuzufügen ist sicher, eine bestehende Signatur zu ändern ist ein Bruch.
Warum ist es verboten, einer COM-Schnittstelle nach der Veröffentlichung eine Methode hinzuzufügen?
Weil eine Schnittstelle gemäß der COM-Spezifikation nach ihrer Veröffentlichung unveränderlich ist. Die eigentliche Substanz einer Schnittstelle ist ein binärer Layout-Vertrag — die Vtable, eine geordnete Liste von Funktionszeigern —, und das Einfügen, Entfernen oder Umordnen von Methoden bedeutet, dass ein altes Binary an der zur Kompilierzeit fest einprogrammierten Slot-Position eine andere Methode aufruft. Ein Anhängen am Ende verschiebt zwar keine bestehenden Slots, führt aber eine neue Gefahr ein: Ein neuer Client kann eine alte Komponenteninstanz erhalten und annehmen, die angehängte Methode sei vorhanden, und dann einen Slot aufrufen, der dort in Wirklichkeit nicht existiert. Deshalb ist selbst ein Anhängen unter derselben IID nicht erlaubt. Wenn Sie Funktionalität hinzufügen möchten, fügen Sie eine neue Schnittstelle mit einer neuen IID hinzu (IFoo2) und lassen die bestehende IFoo unangetastet. Aufrufer können dann zur Laufzeit über QueryInterface sicher feststellen, ob sie mit der alten oder der neuen Schnittstelle sprechen.
Wie sollte ich AssemblyVersion, FileVersion und InformationalVersion in .NET unterschiedlich verwenden?
AssemblyVersion ist die einzige Version, die die Laufzeitumgebung zur Identifizierung und zum Laden einer Assembly verwendet; bei stark benannten Assemblies verlangt die CLR des .NET Framework eine exakte Übereinstimmung, sodass jede Erhöhung Aufrufer zwingt, eine Binding-Umleitung hinzuzufügen. Deshalb empfiehlt die offizielle Anleitung, in AssemblyVersion nur die Hauptversion widerzuspiegeln. FileVersion erscheint nur in den Dateieigenschaften des Explorers und hat keine Auswirkung auf das Laufzeitverhalten, was sie zu einem guten Ort macht, um eine CI-Build-Nummer einzustempeln. InformationalVersion ist eine freiformatige, menschenlesbare Zeichenkette, die verwendet wird, um eine im Semver-Format formatierte Version oder einen Commit-Hash festzuhalten.
Löst die Einführung semantischer Versionierung (Semver) Kompatibilitätsprobleme von allein?
Nein, Semver allein löst das nicht. Semver ist die Konvention, bei jeder rückwärtsinkompatiblen Änderung die Hauptversion zu erhöhen, setzt aber voraus, dass Sie bereits festgelegt haben, was als Ihre öffentliche API und was als Breaking Change gilt. Kleben Sie Versionsnummern auf Releases, ohne diese Definition, variiert die Beurteilung von Person zu Person, und das Schema funktioniert nicht mehr. Semver wird erst dann sinnvoll, wenn Sie so etwas wie die Entscheidungstabelle dieses Artikels (für native DLLs) oder Microsofts Kompatibilitätsänderungsregeln (für .NET) als Ihre eigene Definition eines Breaking Change übernehmen und diese Definition in Ihren Release-Prozess einbacken.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog