Socketprogrammierung mit C++ und dem Windows API
Wir leben in einer Welt von SOAP und REST WebServices. Wir haben standardisierte Protokolle und Zugriffswege, welche zudem einfach mit einer Firewall kontrolliert werden können. Freie und auch kommerzielle Bibliotheken, mit denen aus C++-Anwendungen heraus auf diese Services zugegriffen werden kann, vereinfachen die Nutzung dieser. Was sollte uns also bewegen, dass wir uns mit der Socketprogrammierung beschäftigen, um einen Service anzusprechen oder zu schreiben? Einiges!
- Deutlich geringerer Overhead: SOAP und Rest nutzen http und übertragen im Textformat. Wir hingegen können binär übertragen und sparen entsprechend viel Overhead ein.
- Geschwindigkeit: Die Konvertierung in Text erfordert viel Zeit, gerade bei Massenverarbeitungen. Auch hier bringt uns eine Übertragung im Binärformat Vorteile.
- Optimale Umsetzung der eigenen Anforderungen: wir können den Service so gestalten, dass er unsere Anforderungen gerade an die auszutauschenden Daten erfüllt, genau in unsere Landschaft passt und wir brauchen keine Kompromisse einzugehen.
Kommen Sie also mit auf eine Reise in die Socketprogrammierung mit C++, hier speziell mit dem Windows API; eine Übertragung in Standard C++ ist allerdings einfach möglich, so dass die Reise zum Beispiel auch für Linuxentwickler interessant sein wird.
Bevor wir auf die Reise gehen, noch ein paar Worte vorweg: Dieser Artikel konzentriert sich auf die Umsetzung einer Server- und einer zugehörigen Clientanwendung unter Betrachtung verschiedener Aspekte der Socketprogrammierung mit C++, um eine stabile, verfügbare Anwendung umzusetzen. Alle Aspekte der Socketprogrammierung zu behandeln würde den Rahmen bei weitem sprengen.
Reisevorbereitung: C++-Socketprogrammierung initialisieren
Bevor wir unsere Reise antreten können, müssen wir die Koffer packen und den Fahrschein kaufen. In die Koffer gehört die Headerdatei Winsock2.h und die Bibliothek ws2_32.lib. Letztere findet sich innerhalb der Anwendung in der Nutzung der DLL ws2_32.dll wieder. Damit haben wir den Grundbedarf gedeckt. In unseren Koffer gehört allerdings auch die Headerdatei Ws2tcpip.h, welche unter anderem Funktionen rund um die Adressermittlung enthält.
Unser Koffer ist gepackt, lassen Sie uns das Reiseticket kaufen – oder technisch ausgedrückt: die Socketumgebung initialisieren. Hier definiert das Windows API die Funktion WSAStartup(). Sie initialisiert durch Angabe der gewünschten Version die Socketumgebung und liefert Informationen über diese zurück, siehe Listing 1.
Listing 1: C++ - Intitialisierung der Socketumgebung
WSADATA wsadata = {}; int errorValue = WSAStartup( 0x0202, &wsadata );
Die gewünschte Socketversion wird im ersten Parameter angegeben. Die ws2_32.dll unterstützt aktuell die Versionen 1.0, 1.1, 2.0, 2.1 und 2.0. Über den zweiten Parameter WSADATA erhalten wir Informationen über die Socketimplementierung zurück. Die Attribute sind allerdings inzwischen veraltet und existieren im Wesentlichen aus Kompatibilitätsgründen zur Socketversion 1.1. Die möglichen Fehlercodes sind in der API-Dokumentation ausführlich beschrieben. Wichtig zu wissen ist, dass WSAStartup() den Wert 0 zurückliefert, wenn die Ausführung erfolgreich gewesen ist.
Los geht's: C++ Socketserver bereitstellen
Die Koffer sind gepackt, das Ticket gelöst, auf geht's zu unserem ersten Socketserver.
Zunächst benötigen wir unseren Stecker, unser Socket, an welchen die weiteren Funktionen gebunden werden. WSASocket() erstellt uns den benötigten Stecker. Mit dem Anlegen des Sockets geben wir allerdings schon die Richtung vor, wie der Socket anschließend verwendet werden kann. Schauen wir uns die Parameter der Reihe nach an.
Address family als erster Parameter gibt die sogenannte Adressfamilie für den Socket an. AF_INET bzw. AF_INET6 stehen uns hier immer zur Verfügung. Sie dienen der Nutzung in einer IPv4- bzw. IPv6-Umgebung. Wenn ein entsprechender Provider auf Ihrem System installiert ist, steht Ihnen auch AF_NETBIOS zur Nutzung mit den NetBios zur Verfügung. In diesem Artikel widmen wir uns allerdings ausschließlich den beiden erstgenannten.
Sockettype ist der zweite Paraemter und legt den Typ des Sockets fest. Die einzelnen Werte sind in der Dokumentation von WSASocket zu finden. Wir werfen hier einen Blick auf zwei sehr häufig genutzte Typen. SOCK_STREAM dient für Zweiwegeverbindungen auf Grundlage des TCP-Protokolls und wird in Client-Server-Umbebungen verwendet. SOCK_DGRAM als zweiter häufig genutzter Typ dient der Kommunikation mit sogenannten Datagrammen auf Grundlage des UDP-verbindungslosen Protokolls. Der Sender des Datagramms erhält bei UDP keine Nachricht, ob das Packet angekommen ist, ebenso ist nicht sichergestellt, dass ein gesendetes Packet auch beim Server ankommt. Häufig verwendet wird SOCK_DGRAM zum Beispiel bei Informationsservern, die spezielle Informationen an Clients schicken, ohne ein Interesse an einer Antwort zu haben.
Das Protokoll gibt das zu nutzende Protokoll an. Im Rahmen des Sockettyps SOCK_STREAM steht uns hier das Protokoll IPPROTO_TCP zur Verfügung. Ein Blick in die Dokumentation zeigt noch ein paar weitere Optionen, die unter anderem auf dem Typ SOCK_RAW aufsetzen. Wollen Sie zum Beispiel ein eigenes ping-Tool schreiben, benötigen Sie SOCK_RAW als Sockettyp und IPROTO_ICMP als Protokoll.
Für die Protokollinfo können wir NULL übergeben, was WSASocket mitteilt, diese Information aus dem zugehörigen Socketprovider zu ermitteln.
Group ist der nächste Parameter. Hier können wir den Wert 0 übergeben, wenn wir keine spezielle Socketgruppe bilden wollen.
Flags ist der letzte Parameter. Über ihn steuern wir die weitere Verwendung des Socket. Für unsere Zwecke ist an dieser Stelle der Wert WSA_FLAG_OVERLAPPED interessant, welcher die Nutzung des Overlapped Parameter in den weiteren Socketfunktionen ermöglicht.
Haben wir einen Socket für unsere Serveranwendung erstellt, müssen wir ihn an eine Adresse binden. Dies übernimmt die Funktion bind(). Bevor wir uns allerdings bind() näher ansehen, gehen wir den Satz von eben eine kleines Stück zurück: an eine Adresse binden. Wir benötigen also zunächst die Adressinformationen des Rechners, auf welchem der Server ausgeführt wird. Während in älteren Anwendungen hier noch die Funktion gethostbyname() verwendet wurde, ist diese in den Microsoft-Headerdateien inzwischen als veraltet – deprecated – eingestuft. Versuchen Sie Ihre Anwendung mit gethostbyname() zu übersetzten, erhalten Sie eine entsprechende Fehlermeldung. getaddrinfo() heißt die neue Funktion, mit welcher wir an die Adressinformationen eines Rechners gelangen und zwar an alle Adressinformationen.
Hostname ist der erste Parameter von getaddrinfo(). Er steht für den Rechnernamen, für welchen Adressinformationen zu ermitteln sind. Für unsere Serveranwendung benötigen wir den Namen des Rechners, auf welchem die Anwendung läuft. Die Windows API-Funktion getComputerName() liefert uns diesen.
Der Servicename als zweiter Parameter ist für unsere Serveranwendung uninteressant, wir kommen beim Erstellen der Clientanwendung auf ihn zurück. Hier übergeben wir den Wert NULL.
Als dritter Parameter Hints ist die Adresse einer initialisierten ADDRINFO-Struktur zu übergeben. Welche Attribute diese Struktur im Detail enthält, folgt in Kürze.
Der letzte Parameter Addressinfo ist ein Outparameter für das Ergebnis. Es ist die Adresse eines Zeigers auf eine ADDRINFO-Instanz zu übergeben.
Listing 2: C++ - Ermitteln der Adressinformationen
… DWORD lenOfHostname = MAX_COMPUTERNAME_LEN; CHAR hostname[MAX_COMPUTERNAME_LEN + 1]; GetComputerName( hostname, &lenOfHostname ); addrinfo* addrinfo = nullptr; addrinfo addrinfoHints = {}; if ( !getaddrinfo( hostname, NULL, &addrinfoHints, &addrinfo ) ) { … } …
Listing 2 zeigt den Aufruf von getaddrinfo(). Werfen wir nun einen Blick auf das Ergebnis und damit auf den Inhalt der Struktur addrinfo, siehe Listing 3.
Listing 3: C++ - Definition der Struktur ADDRINFO
typedef struct addrinfo { int ai_flags; int ai_family; int ai_socktype; int ai_protocol; size_t ai_addrlen; char *ai_canonname; struct sockaddr* ai_addr; struct addrinfo* ai_next; } ADDRINFO, *PADDRINFO;
Wir treffen hier mit ai_family oder ai_protocol alte Bekannte wieder, die uns schon bei der Funktion WSASocket() begegnet sind und auch ai_socktype oder ai_addr sind keine Unbekannten. Um an die von uns benötigten Informationen zu gelangen, sind ai_family und ai_addr wichtig. Erinnern wir uns an WSASocket(), dort wird über den Familyparameter festgelegt wird, ob wir den angelegten Socket in einer IPv4- oder IPv6-Umgebung nutzen möchten. Passend hierzu müssen wir an dieser Stelle die gleiche Family auswählen. ai_addr zeigt dann auf die passende Adressinformation. Listing 4 zeigt ein Beispiel, um die Adressinformationen in einer IPv4-Umgebung zu ermitteln. Beachten Sie den Aufruf von freeaddrinfo() nach dem Auswerten der Adressinformationen, um den durch den Aufruf von getaddrinfo() angelegten Speicher wieder freizugeben. Anschließend sollten sie Ihren Zeiger auf nullptr setzen, damit Prüfungen des Zeigers gegen nullptr nicht irrtümlicherweise aussagen, dass der Speicher noch da ist und Ihre Anwendung auf freigegebenen Speicher zugreift; Stichwort: C++ Best Practices.
Listing 4: C++ - Auslesen der Adressinformationen
… sockaddr_in ipv4Address = {}; for( addrinfo* addr=addrinfo; nullptr != addr; addr=addr->ai_next ) { if ( AF_INET == addr->ai_family ) { ipv4Address = *(sockaddr_in*) addr->ai_addr; } } freeaddrinfo( addrinfo ); addrinfo = nullptr; …
Nachdem wir die Adressinformationen unseres Rechners ermitteln haben, benötigen wir noch einen Wert: den Port, an welchem unser Server unter dieser Adresse lauschen soll, also quasi die Hausnummer, wenn wir die Adressinformation als Postleitzahl und Straße betrachten.
Den Port können Sie selbst festlegen, müssen allerdings die Liste der Standardports, welche die IANA herausgibt, beachten, damit Sie nicht einen der Standardports wie zum Beispiel 80 für HTTP oder 25 für SMTP verwenden. Eingetragen wird die Portnummer in das Attribut sin_port der sockaddr_in-Struktur im Netzwerkformat: ipv4Address.sin_port = htons( 1109 ). htons() wandelt einen Integerwert aus dem Hostformat in das Netzwerkformat um.
Jetzt sind wir soweit, unseren Socket an die ermittelte Adresse und den festgelegten Port zu binden, wie in Listing 5 dargestellt.
Listing 5: C++ - Socket binden
… bind( listenSocket, (CONST sockaddr*) &ipv4Address, sizeof( ipv4Address ) ); …
Damit ist sind wir soweit, dass unser Server "lauschen" kann. listen() heißt die Socketfunktion, die unseren Socket in den Zustand versetzt, dass er auf eingehende Nachrichten lauschen kann. listen() bekommt hierzu neben dem Socket als ersten Parameter noch einen Integerwert als zweiten Parameter übergeben. Dieser gibt die Größe der Queue an, wieviele Connections dort maximal gehalten werden. SOMAXCONN wird vom Socketserviceprovider definiert und enthält die jeweils maximal mögliche Anzahl an gehaltenen Verbindungen. Will ein weiterer Client eine Verbindung aufbauen, so erhält er den Rückgabewert WSACONNECTIONREFUSED zurück; der Verbindungsaufbau wird abgelehnt. Sie müssen entsprechend auf der einen Seite überlegen, wieviel Clients gleichzeitig eine Verbindung zu Ihrer Serveranwendung aufbauen werden und auf der anderen Seite, dass Sie eingehende Verbindungen schnell weiterverarbeiten, damit die Queue nicht vollläuft.
C++ Sockets: Eine Verbindung kommt rein
Woran merkt unser Server nun, dass eine Verbindung reinkommt? WSAAccept() heißt die entsprechende Funktion, welche uns ein Socket für ein ankommende Verbindung erstellt. WSAAccept() erwartet als ersten Parameter unseren erstellten Socket und wartet so lange, bis ein Client eine Verbindung zu diesem, sprich zur Adresse und zum Port, an welchen der Socket gebunden wurde, aufbaut.
Als zweiten und dritten Parameter wird eine Instanz von sockaddr und die Größe der Instanz übergeben. In ihr werden die Adressinformationen der Clientverbindung gespeichert, denn an diese Adresse muss unsere Serveranwendung eine Antwort zurückschicken, damit der Sender diese erhält.
Der vierte Parameter ist ein Funktionszeiger. Hier haben wir die Möglichkeit, eine Prüffunktion für die eingehende Verbindung zu hinterlegen. Nehmen wir einmal an, unsere Serveranwendung darf nur von bestimmten Clients aufgerufen werden und wir wollen diese anhand ihrer IP-Adresse filtern, dann ist diese Prüfroutine genau der richtige Platz. Sie wird von WSAAccept() aufgerufen, noch bevor die Verbindung richtig zustande gekommen ist. Als Ergebnis muss diese Funktion CF_ACCEPT oder CF_REJECT zurückliefern, je nachdem, ob die Verbindung erlaubt oder abgelehnt werden soll. Etwas später kehren wir noch einmal zu den Möglichkeiten dieser Prüffunktion zurück.
Der letzte Parameter Callbackdata gehört zur gerade beschriebenen Prüfroutine. Hier kann ein Wert angegeben werden, welcher zusätzlich zu deren normalen Parametern an die Prüfroutine übergeben wird. Das Beispiel in Listing 6 nutzt die Prüfroutine nicht, hier sind entsprechend alle eingehenden Verbindungen erlaubt.
Listing 6: C++ - Eingehende Verbindung annehmen
… sockaddr_in requestAddress = {}; INT lenOfRequestAddress = sizeof( requestAddress ); SOCKET requestSocket = WSAAccept( listenSocket, (sockaddr*) &requestAddress, &lenOfRequestAddress, NULL, 0 ); …
Das einzige, was uns jetzt noch fehlt, ist, die Daten, welche uns die Clientanwendung überträgt, auszulesen und eine Antwort zu senden. WSARecv() und WSASend() sind die beiden Socketfunktionen, welche diese Aufgabe übergeben. Die Parameter beider Funktionen sind identisch, lediglich die drei Parameter für den Datenpuffer unterscheiden sich in ihrer Bedeutung. Während sie bei WSARecv() den Empfangspuffer darstellen, bilden sie bei WSASend() den Puffer für die zu übertragenden Daten. Schauen wir uns die Parameter im Einzelnen an.
Als erstes ist das Socket anzugeben, welches der Verbindung zugeordnet ist; das requestSocket aus Listing 6.
Parameter zwei und drei bilden den Empfangs- bzw. Sendepuffer und die jeweilige Größe. Zu übergeben ist in Parameter zwei die Adresse einer WSABUF-Instanz. Diese Struktur besteht aus zwei Attributen:
Listing 7: C++ - Definition WSABUF
typedef struct _WSABUF { u_long len; CHAR FAR* buf; } WSABUF, *LPWSABUF;
len gibt die Größe des Puffers in buf an, buf nimmt die Adresse des Puffers auf.
In Parameter drei wird die Anzahl der Puffer angegeben, also die Anzahl von WSABUF-Instanzen, die im zweiten Parameter angegeben wurde. Parameter drei ermöglicht es Ihnen entsprechend, ein Array von WSABUF-Instanzen anzulegen, den Beginn des Arrays in Parameter zwei und die Anzahl der Instanzen in Parameter drei zu übergeben.
Der vierte Parameter ist ein Zeiger auf einen DWORD, in welchem die Größe des Sendepuffers bei WSASend() bzw. die Größe des Empfangspuffers bei WSARecv gespeichert wird. Sie geben auf der einen Seite die Anzahl der Bytes an, die Sie an den Client übertragen möchten bzw. erhalten hier die Anzahl der Bytes zurück, welche aus der Clientverbindung gelesen wurden. Wenn Sie wissen, wie groß die Datenmenge sein muss, können Sie WSARecv() entsprechend oft aufrufen oder den Puffer in der richtigen Größe anlegen. Je nachdem, welche Art von Server Sie allerdings schreiben, ist die Anzahl der erwarteten Bytes nicht bekannt. Wir werden aus diesem und aus einem Sicherheitsgrund im weiteren Verlauf noch eine Funktion kennenlernen, mit welcher wir die Anzahl auszulesender Bytes im Vorfeld ermitteln können. Im Moment reicht die aktuelle Information zu diesem Parameter aus.
Die nächsten drei Parameter gehören ebenfalls zusammen und ermöglichen eine asynchrone Verarbeitung; sofern Sie das Socket entsprechend angelegt haben. Bei WSASocket() konnten wir im Parameter Flags den Wert WSA_FLAG_OVERLAPPED angeben. Dieser sorgt dafür, dass das Socket intern für asynchrone Verarbeitung eingerichtet wird. Sowohl bei WSARecv() als auch bei WSASend() können wir im fünften Parameter erneut WSA_FLAG_OVERLAPPED angeben, um eine asynchrone Verarbeitung zu starten. In diesem Fall warten die beiden Funktionen nicht so lange, bis die vollständigen Daten entweder gelesen oder übertragen wurden.
Um bei asynchroner Verarbeitung das vollständige Lesen bzw. Übertragen der Daten zu prüfen, dienen die nächsten beiden Parameter: sie nehmen zum einen die Adresse einer WSAOVERLAPPED-Instanz auf, welche unter anderem ein Handle auf ein Sychronisationsevent enthält. Dieses wird signalisiert, wenn die jeweilige Aktion abgeschlossen ist und kann in den entsprechenden API-Funktionen ausgewertet werden.
Der letzte Parameter schließlich nimmt die sogenannte Completionroutine auf, welche nach Abschluss von WSARecv() bzw. WSASend() aufgerufen wird.
Nachdem wir unsere Nachricht an den Client zurückgesendet haben, schließen wir das requestSocket mit einem Aufruf von closesocket(), um die Ressource wieder freizugeben. Unsere Serveranwendung ist fertig! Wirklich? Führen wir sie aus und führen ein paar Tests durch. Hierzu können wir die allgemeine Anwendung telnet verwenden. Starten Sie telnet mit der IPv4-Adresse des Rechners, auf welchem Sie unseren Socketserver gestartet haben und übergeben Sie als weiteren Parameter den beim Aufruf von bind() angegebenen Port. Geben Sie in ein paar Buchstaben oder Zahlen, diese werden an unsere Serveranwendung übertragen und unsere Antwort kommt zurück und wird ausgegeben. Der Aufruf von closesocket() trennt die Verbindung wieder. Wiederholen wir unseren Test, allerdings geben Sie diesmal bitte keine Daten ein, die an unsere Serveranwendung übertragen werden, sondern lassen Sie uns einen Blick auf den Server werfen. Er steht still, denn unser Socket wurde von Haus aus im sogenannten blocked-Modus angelegt. Dies bedeutet, dass WSAAccept() auf der einen Seite so lange wartet, bis tatsächlich eine Verbindung aufgebaut wurde, was wir durch telnet getan haben. Ein blocked-Socket bedeutet allerdings auch, dass WSARecv() solange wartet, bis vom Client Daten übertragen wurden. Zwar sollten die "normalen" Clients ihre Daten direkt nach Verbindungsaufbau übertragen. Wie das telnet-Beispiel zeigt, kann allerdings jemand mit einem einfachen Aufruf unsere Anwendung blockieren und sie steht keinem Client mehr zur Verfügung. Denken Sie an eine wichtige, eventuell gar unternehmenskritische Anwendung. Unser Socketserver funktioniert zwar, ist aber für den praktischen Einsatz nicht geeignet. Bauen wir ihn also im zweiten Teil des Artikels ein wenig um.