Anwendungsmigration (Teil 2)

Veröffentlicht: 01. Apr 2002 | Aktualisiert: 08. Nov 2004

Von Bernd Marquardt

Die Migration von bestehenden Projekten ist eine Herausforderung. Vor allem deshalb, weil Sie erstmal herausgefinden müssen, was neugeschrieben werden muss und was so bleiben kann, wie es ist. Der Artikel stellt einige Grundüberlegungen an und zeigt dann an Beispielen, wie Sie mit der Problematik umgehen. Verstärkt soll der Artikel die C++-Seite des Migrationsthemas betrachten.

Er ist in zwei Teile gegliedert. Der erste Teil geht auf den Aufruf von unmanaged code durch unmanaged code ein. Zusätzlich zeigt er, wie .NET-Code Funktionen in DLLs, COM- und ActiveX-Objekten aufrufen kann. Der zweite Teil stellt dann die umgekehrte Richtung dar: Unmanaged code ruft .NET-Code auf.

Auf dieser Seite

 Teil 2: Aufruf von managed .NET-Code aus unmanaged Code
 C++-Wrapper-Klassen
 Migration von Benutzerschnittstellen
 Zusammenfassung

Teil 2: Aufruf von managed .NET-Code aus unmanaged Code

Im Teil 1 haben wir aus .NET-Code den unmanaged Code aufgerufen, um vorhandene Komponenten und DLLs weiter zu benutzen. Wie sieht das in der anderen Richtung aus? Kann unmanaged Code "alter" Komponenten managed Code von .NET aufrufen? Dies wäre z.B. interessant, wenn eine existierende Applikation um eine neue Komponente erweitert werden soll, für deren Implementation sich eine managed .NET-Klasse anbietet. Das Problem hierbei ist natürlich, dass der alte Code von .NET und der schönen, neuen Welt überhaupt nichts weiß. Aber auch für diese Aufgabenstellung gibt es eine Lösung: Man muss dem alten Code die neue .NET-Klasse nur als COM-Komponente zur Verfügung stellen und schon sollte das Ganze funktionieren.

Eine simple .NET-Klasse soll das demonstrieren. Sie addiert oder subtrahiert Zahlen und hängt Strings aneinander:

namespace MyLib 
{ 
  using System; 
  using System.Runtime.InteropServices; 
  [ClassInterface(ClassInterfaceType.AutoDual)] 
  public class Class1 
  { 
    public Class1() 
    { 
    } 
    public int Add(int iZahl1, int iZahl2) 
    { 
      return iZahl1 + iZahl2; 
    } 
    public int Sub(int iZahl1, int iZahl2) 
    { 
      return iZahl1 - iZahl2; 
    } 
    public string AddString(string strText) 
    { 
      string strHelp = strText + strText; 
      return strHelp; 
    } 
  } 
}

Die Klasse wird als ganz normale .NET-Komponente kompiliert. Das Entscheidende hierbei sind jedoch zwei Zeilen im Code:

  using System.Runtime.InteropServices; 
  [ClassInterface(ClassInterfaceType.AutoDual)]

Die erste fügt den Namespace System.Runtime.InteropServices in das Projekt ein. Das ist noch nicht sonderlich spannend. Die darauf folgende Zeile sorgt mit dem Attribut ClassInterface dafür, dass das Interface der.NET-Klasse als COM-Interface zur Verfügung steht.

Als nächstes müssen Sie mit dem Tool TlbExp.exe eine Typbibliothek erzeugen und die .NET-Komponente mit RegAsm.exe registrieren. Nun kann diese Klasse in altem nativen Code instanziert und genutzt werden (hier mit VB6, Hinweis: Referenz auf die Typbibliothek nicht vergessen):

Private Sub Command1_Click() 
  Dim a As New Class1 
  Dim iRes As Integer 
  Dim strTest As String 
  iRes = a.Add(2, 4) 
  MsgBox iRes 
  strTest = a.AddString("Aber Hallo ") 
  MsgBox strTest 
End Sub

Wenn Sie das Beispiel laufen lassen, sollten zwei Messageboxen erscheinen, eine mit der Zahl 6 und eine mit dem Text "Aber Hallo Aber Hallo ". Nun können wir existierende Projekte mit neuen .NET-Klassen erweitern, ohne die gesamte Applikation neu zu schreiben.

Auch dies ist eine Möglichkeit, um eine Applikation Schritt für Schritt in die .NET-Welt zu migrieren.

Hinweis: Es sollte noch erwähnt werden, dass die .NET-Klasse mit dem ClassInterface-Attribut auch von .NET-Clientcode problemlos aufgerufen werden kann. Das Attribut muss nicht entfernt werden.

 

C++-Wrapper-Klassen

Kommen wir nun zum Thema der sog. Hüll- oder Wrapper-Klassen. In Visual Studio .NET bietet C++ die Möglichkeit, managed und unmanaged Code in eine einzige Datei zu schreiben. Die Unterscheidung, ob eine Objekt in der managed Welt läuft, oder nicht, wird durch das neue Schlüsselwort __gc gesteuert.

Beginnen wir mit einer einfachen hypothetischen C++-Klasse, die als unmanaged Code vorliegt:

class CppClass  
{  
  public:  
  // Konstruktor  
  CppClass()  
  { 
    ... 
  }  
  // Destruktor  
  ~CppClass()  
  { 
    ... 
  }  
  // Methoden  
  int f(int a, int b)  
  { 
    return a + b;  
  }  
};

Für diese Klasse wollen wir eine einfache Wrapper-Klasse schreiben, die uns die Benutzung aus .NET-Code heraus ermöglichen soll.

__gc class ManClass  
{  
  public:  
  // Konstruktor  
  ManClass()  
  {  
    m_pC = new CppClass();  
  }  
  // Freigabe  
  ~ManClass()  
  {  
    delete m_pC;  
  }  
  // Methoden  
  int f(int a, int b)  
  {  
    return m_pC->f(a, b);  
  }  
  private:  
  CppClass * m_pC = NULL;  
};

Sehen wir uns diese Lösung etwas genauer an. Wir haben eine C++-Klasse als "Managed Extension" (ManClass), die von jeder anderen .NET-Sprache aufgerufen werden kann und die unmanaged Klasse (CppClass) umhüllt. Die Aufrufe werden, wie bei einem Proxy, einfach nur weitergeleitet. Die Klasse ManClass hat, grundsätzlich betrachtet, die gleiche Funktionalität, wie die Klasse CppClass.

Die generelle Vorgehensweise bei der Erstellung einer Wrapper-Klasse ist folgende:

  • Erzeugen einer managed Klasse (hier ManClass)

  • Deklaration einer Zeigervariablen für die Instanz der unmanaged Klasse (hier: CppClass *)

  • Für jeden Konstruktor in CppClass den entsprechenden Kontruktor in ManClass erzeugen, in dem jeweils eine Instanz von CppClass mit dem new-Operator erstellt und über den entsprechenden Konstruktor der CppClass initialisiert wird

  • Erstellen eines Destruktors in der Klasse ManClass, in dem die Instanz von CppClass mit dem delete-Operator zerstört wird

  • Für jede öffentliche Methode (public) in CppClass eine korrespondierende Methode in ManClass erstellen, die einfach den Aufruf der Methode an die Klasse "CppClass" weiterleitet und ggf. die erforderlichen Typkonvertierungen (Marshaling) durchführt

Die Wrapper-Klasse aus dem obigen Beispiel kann dann folgendermaßen aus managed C++-Code aufgerufen und benutzt werden (die Klassen CppClass und ManClass sind wie oben gelistet):

#include "stdafx.h" 
#using <mscorlib.dll> 
#include <tchar.h> 
using namespace System; 
class CppClass 
{ 
  ... 
}; 
__gc class ManClass 
{ 
  ... 
}; 
int _tmain(void) 
{ 
  ManClass* m1 = new ManClass(); 
  int iRes = m1->f(1, 2); 
  Console::WriteLine(iRes.ToString()); 
  return 0; 
}

Nun ist das Erstellen einer managed Wrapper-Klasse leider nicht immer ganz so einfach, wie oben beschrieben. Es würde den Rahmen dieses Artikel sprengen, wenn er auf alle Details der Wrapper-Klassen eingehen würde.

Mit der obigen Wrapper-Klasse kann jedoch unter ganz bestimmten Vorraussetzungen ein Problem entstehen. Der Garbage Collector des .NET-Frameworks ruft den Destruktor der mamaged Wrapper-Klasse (ManClass) nämlich irgendwann auf.

Das bedeutet, dass die Instanz der CppClass (unmanaged) ebenfalls irgendwann verschwindet und die Resourcen, die in dieser Klasse auf dem Heap angelegt wurden, evtl. längere Zeit noch allokiert bleiben. Da wir im managed Code keinen delete-Operator zum expliziten Aufruf des Destruktors der managed Klasse haben, können wir da auch nichts steuern oder beeinflussen.

Aber auch dieses Problem kann man lösen. Wir müssen unsere managed Klasse (ManClass) etwas erweitern, um eine gesteuerte Freigabe der Resourcen zu ermöglichen. Nachteil dieser Methode ist allerdings, dass wir diese Freigabe durch einen Methodenaufruf explizit anstoßen müssen.

Wir leiten die Wrapper-Klasse von der Schnittstelle IDisposable ab. Nun können wir die Methode void Dispose() implementieren. In dieser Methode wird mit dem delete-Operator die Instanz von CppClass zerstört. Außerdem muss man die Zeigervaribale (m_pC) hier unbedingt auf NULL setzen, um spätere, ungewollte Zugriffe zu verhindern. Damit werden natürlich auch die in dieser Klasse angelegten Resourcen freigegeben.

Wichtig ist jedoch, dass in der Dispose-Methode verhindert wird, dass der Garbage Collector den Destruktor der Klasse ManClass noch einmal aufruft - dies würde natürlich zu Problemen führen. Die Unterdückung der Garbage Collection für unser Objekt wird durch den Aufruf der statischen Methode GC::SuppressFinalize(this) ermöglicht. Die Wrapper-Klasse sieht nun folgendermaßen aus:

__gc class ManClass : public IDisposable 
{ 
private: 
  CppClass* m_pC; 
public: 
  ManClass() 
  { 
    m_pC = new CppClass(); 
    Console::WriteLine("Constructor ManClass\n"); 
  } 
  void Dispose() 
  { 
    delete m_pC; 
    m_pC = NULL; 
    GC::SuppressFinalize(this); 
    Console::WriteLine("Dispose ManClass\n"); 
  } 
  ~ManClass() 
  { 
    delete m_pC; 
    m_pC = NULL;        
    Console::WriteLine("Destructor ManClass\n"); 
  } 
  int f() 
  { 
    return m_pC->f(); 
  } 
};

Der Destruktor in der managed Klasse (ManClass) muss auf jeden Fall auch ausprogrammiert werden, da der Garbage Collector die Resourcen auflöst, wenn die Methode Dispose in der managed Klasse nicht aufgerufen wird.

Die Benutzung der Klasse mit der Dispose-Methode kann man in der folgenden main-Funktion sehen:

int _tmain(void) 
{ 
  ManClass* m1 = new ManClass(); 
  int iRes = m1->f(1, 2); 
  m1->Dispose(); 
  Console::WriteLine(iRes.ToString()); 
//  iRes = m1->f(2, 3);  // Error!!! 
  ManClass* m2 = new ManClass(); 
  iRes = m2->f(2, 3); 
  Console::WriteLine(iRes.ToString()); 
  return 0; 
}

Nach dem Anlegen und Benutzen der Instanz m1 wird die Dispose-Methode dieses Objekts aufgerufen. Genau jetzt wird (wie mit dem delete-Operator) die Instanz zerstört und die dazugehörigen Resourcen werden freigegeben. Eine weitere Benutzung von m1 führt zu einem Laufzeitfehler (auskommentiert). Die zweite Instanz m2 von ManClass wird durch den Garbage Collector entsorgt, da Dispose() nicht aufgerufen wird.

 

Migration von Benutzerschnittstellen

Die Migration von Benutzerschnittstellen ist leider nicht so problemlos, wie man es sich wünschen würde. Wenn Sie bisher mit Visual C++ und der MFC (Microsoft Foundation Classes) gearbeitet haben, dann muss man das User-Interface mit den .NET-Windows-Forms bis auf wenige Codeteile neu entwickeln. Beide Ansätze (MFC, Windows-Forms) sind zu unterschiedlich, um eine einfache Migration zu ermöglichen.

Wenn das User-Interface migriert werden soll, so ist sicher die beste Lösung, das ganze mit C# neu zu entwickeln.

Warum in C#? Es gibt im Moment leider keinen Resourcen-Editor für Windows-Forms-Resourcen unter C++. Sie müssten also die Positionen und Eigenschaften der Steuerelemente komplett per Programmcode erzeugen! Nur wenn sie unmanaged Code mit der MFC erstellen, steht ihnen der bekannte Resourcen-Editor von Visual C++ zur Verfügung. Dieser ist jedoch nicht für .NET-Windows-Forms geeignet.

 

Zusammenfassung

Leider gibt es keinen allgemeinen Migrationsweg, der für alle vorhandenen alten Applikationen einfach anwendbar ist. Eine Migration ist dann relativ problemlos möglich, wenn eine komponentenorientierte Software vorliegt. In diesem Fall sind mehrere Ansätze möglich:

  • Man kann die existierende Applikation mit neuen .NET-Komponenten erweitern und den restlichen Code unverändert lassen

  • Man kann die Komponenten eine nach der anderen in .NET-Code migrieren und im letzten Schritt das User-Interface erneuern

  • Man kann zunächst des User-Interface nach Windows-Forms migrieren und alle Komponenten unverändert übernehmen

Die richtige Vorgehensweise hängt jedoch von der Art der zu migrierenden Applikation ab. Kommen wir also noch einmal auf die anfangs des Artikels aufgeführte Liste der Softwarearten zurück:

Visual C++

Visual Basic

Systemnahe Software mit API-Aufrufen

Branchenlösungen

NT-Services

Datenbanklastige Software

Algorithmen (mathematisch)

Mehrschichtige Applikationen

Steuerungssoftware

Applikationen mit viel COM-Zugriff

Hochperformante Software

Kaufmännische Software

MFC-Applikation (allgemein)


Gerätetreiber


Compiler, Linker, Tools


Steuerungssoftware, hochperformante Software, MFC-Applikationen und Gerätetreiber werden wahrscheinlich aus unterschiedlichen Gründen gar nicht oder erst relativ spät nach .NET migriert werden. Bei Steuerungssoftware und Gerätetreibern hat man es häufig mit direkten Hardwarezugriffen zu tun und bei MFC-Applikationen ist aufgrund der anderen Struktur von .NET-Windows-Forms eher eine Neuentwicklung angesagt. Je höher der Aufwand, desto später wird man wohl eine Migration in Erwägung ziehen.

Mathematische Algorithmen lassen sich relativ leicht migrieren, da die mathematischen Ausdrücke in beiden Welten praktisch gleich programmiert werden. Hier muss evtl. nur die Bearbeitung großer Datenmengen auf dem Heap überarbeitet und angepasst werden.

Auch die Migration von Compilern und Linkern ist hochinteressant und wird meiner Meinung nach bald angegangen werden. Schreibt man einen Compiler z.B. in C#, so kann er dann auf jeder .NET-Plattform laufen. Und wir wollen mal hoffen, dass es das .NET-Framework bald auch auf unterschiedlichen Hardwareplattformen gibt.

Ein besonderes Augenmerk sollte man auf die NT-Services werfen. Solche Dienste laufen häufig auf Servern und sollen möglichst lange problemlos ihre Arbeit verrichten. Jeder Netzwerk-Administrator bekommt einen stark erhöhten Blutdruck, wenn er einen Server herunterfahren muss, weil eine Applikation so viele Speicherlecks verursacht hat, dass nichts mehr geht. Gerade hier kann der Garbage Collector gute Dienste leisten und .NET-basierte Server-Anwendungen können in Zukunft wesentlich stabiler laufen. Ein klarer Grund für eine schnelle Migration.

Auf der anderen Seite werden (oder wurden) die meisten mehrschichtigen Applikationen wohl in Visual Basic geschrieben. Aufgrund der in diesem Artikel beschriebenen Mirgrationsmöglichkeiten ist es denkbar, dass sie relativ schnell migriert werden. Aber auch hier muss viel Code neu entwickelt werden.

Auf jeden Fall wartet viel Arbeit auf uns. Der Umstieg von altem Code auf den neuen .NET-Code wird sicherlich viele Jahre dauern (ähnlich wie die langwierige Ablösung des 16-bit Windows). Zunächst wird es wohl viele gemischte Applikationen geben. Wir können nicht damit rechnen, dass alle alten Anwendungen in einem Jahr migriert sind und dann nur noch die schöne, neue .NET-Welt um uns herum zu sehen ist.

Aber anfangen müssen wir auf jeden Fall!