Vorbemerkung
Tcp-Kommunikation basiert auf Streams. Daher ist Kenntnis der grundlegenden Stream-Konzepte in Dot.Net unabdingbare Vorraussetzung zum Verständnis dieses Tuts.
Alles klar (oder was?)
Wenn man sich die Klassen
Im Server wartet ein
Problem
Leider ist der stream-basierte Daten-Transfer eben nicht so problemlos, wie eingangs gedacht, denn ein
Viele Lese/Schreib-Vorgänge erfordern aber endliche Streams. ZB ein schreibender
Lesen/Entpacken aus endlosem Stream ist sogar noch problematischer: nämlich er füllt sich seinen Puffer immer komplett voll, entpackt aber nur so viel, wie vom schreibenden GZipStream reingemacht wurde. Das bedeutet: Bei aufeinander folgenden Nachrichten verschwindet der Anfang der zweiten für immer im Puffer-Rest des lesenden GZipStreams - wird nie entpackt
(Ich hab das nur mit
StreamContainer
Meine Lösung besteht darin, dass ich einen Stream gebastelt habe, den man zwischen
Mit einem Extra-Befehl,
Also zwischengeschaltet macht
Das war der Knackpunkt an meim Tcp-Server-Client-TestProjekt, alles andere ist im Grunde Standard von Stange.
Aber auch der Standard ist nicht ganz ohne, und vlt. wert, sich mit zu befassen.
Server-Client-Netz
TCP ist ja verbindungsorientiert, und eine Verbindung hat zwei absolut gleiche Kommunikations-Endpunkte, die Daten miteinander austauschen. (Ich habe dafür die Klasse
Beim Server-Client-Netz hat man nun viele Clients, die sich mit einem Server verbinden. Und jeder Client hat seinen Kommunikations-Endpunkt, und der Server hält logischerweise eine Liste davon.
Weiters braucht man ein Übertragungs-Protokoll, denn nur Daten austauschen reicht nicht - man muss auch mitteilen, was die Gegenstelle damit machen soll. Also bei mir besteht ein "Content" immer aus einem
Wie man sieht versteht der Server mehr Commands als der Client. Liegt hier im Sample daran, dass er zT. verschiedene Anfragen mit demselben ClientCommand beantwortet.
Etwa bei
Was da nun im einzelnen (für Unsinn) gemacht wird ist ja egal. Wichtig ist nur das Prinzip des bidirektionalen Befehls-Protokolls zu zeigen, und proof-of-concept der verschiedenen Transfer-Fähigkeiten: Text, Zahlen, Bilder, Files sowie BroadCast, PeerToPeer, Compression, Encryption - letzteres hab ich aber aus Faulheit weggelassen.
Und dass die Kommunikation unverzüglich erfolgt, also eine Anfrage wird direkt beantwortet, ohne dass Teile der Daten erstmal in Puffern hängenbleiben, oder gar für immer darin verschwinden.
Aufbau der Solution
Auch wesentlich ist ein durchdachter Aufbau der Solution, die besteht hier nämlich aus 4 Projekten:
ServerApp, ClientApp, TcpCommon-Bibliothek, Helpers-Bibliothek.
So kann man Code richtig einordnen, je nachdem, wo er gebraucht wird: Helpers werden von allen gebraucht, TcpCommon wird von Server- und Client-App gebraucht, und letztere beiden brauchen nur sich selber. (Die Helpers-Bibliothek ist übrigens ein Winz-Auszug aus meiner sehr großen Helpers-Bibliothek, in der sich aller möglicher Code findet, der nicht nur hier sondern in zig anderen Projekten auch gebraucht wird.)
Also wer sich mit Tcp beschäftigen will, der sollte unbedingt Projekte zu einer Solution zusammenfassen können, und auch Verweise und Imports richtig setzen, damit wiederverwendbarer Code auch wiederverwendet wird.
Siehe dazu: Video: HelperProjekt einbinden
Und zum testen muss man dann 2 Projekte gleichzeitig als Startprojekt angeben, ich zeig mal Bildle, wie das in VS2013 einzustellen ist:
-->
Diese Einstellung ist wichtig, um die beiliegenden Sources problemlos zu testen!! Und muss man bei sich in der geöffneten Solution so einstellen - die Sources selbst transportieren das nicht mit.
TcpEndpoint
Hier mal der wesentliche
So ein
Dann aber werden 2 Bytes in einen kleinen Puffer gelesen(#20), und wenn nix schief geht, werden die beiden Bytes mittels
Im Callback,
Gut - aber falls keine Id zugewiesen wird, wird halt das
Und wenn das Event durch ist, folgt noch der
Hier liegt interssanterweise eine asynchrone Rekursion vor, denn der angegebene Callback ist ja die Aufrufer-Methode selbst! . Also wer's philosophisch mag, kann mal behirnen, warum dieser Selbst-Aufruf ohne Abbruch-Bedingung nicht mit StackOverflow abstürzt .
Datenverarbeitung
Auszug aussm Server, wie der das
zunächstmal wird der sendende Client identifiziert (#2) - das können ja im Server verschiedene sein. Dann den CommandCode auf ein dem Server verständliches
Dann in einem
Und nach der Verarbeitung muss ja schön mit
Und 3 Verarbeitungen sind auch gezeigt, das Prinzip wird glaub recht deutlich: Immer wird der sendende Client übergeben, und vor dem Writen der Daten immer erst das gemeinte
Dann setzt man diverse Reader auf den ReadStream, und Writer auf den WriteStream, liest , (verarbeitet), schreibt und feddich.
In
Die anschliessende eigliche Verarbeitung ist dort nur die kurze Anweisung:
Dasselbe Spiel auch mit der Zahlen-Inversion
Aber auch trivial, oder? Erst den
(
Tcp-Kommunikation basiert auf Streams. Daher ist Kenntnis der grundlegenden Stream-Konzepte in Dot.Net unabdingbare Vorraussetzung zum Verständnis dieses Tuts.
Alles klar (oder was?)
Wenn man sich die Klassen
TcpListener
, TcpClient
und NetworkStream
im ObjectBrowser so anschaut, denkt man eiglich, die Server-Client-Kommunikation per Internet sei geritzt:Im Server wartet ein
TcpListener
auf Verbindungs-Anfragen, die im Client von TcpClient
-Objekten an den Server geschickt werden. Wenn der TcpListener
eine Anfrage akzeptiert, so erstellt er ebenfalls ein TcpClient
-Objekt, bereits verbunden mit der Gegenstelle, und beide können über ihren NetworkStream
problemlos Daten austauschen, denn zum Daten-Transfer sind Streams ja da.Problem
Leider ist der stream-basierte Daten-Transfer eben nicht so problemlos, wie eingangs gedacht, denn ein
NetworkStream
ist ein endloser Stream.Viele Lese/Schreib-Vorgänge erfordern aber endliche Streams. ZB ein schreibender
GZipStream
behält immer einen ungeschriebenen Rest in seinem Eingangs-Puffer, und schreibt den erst weg, wenn weiterer Content kommt und den Puffer voll füllt. So kann man natürlich keine responsive Kommunikation aufbauen, wenn die erste Nachricht erst abgeschlossen wird, wenn die zweite bereits angefangen wurde. Folglich muss man für jeden komprimierten Content einen eigenen GZipStream
einsetzen, und den disposen, denn nur dann schreibt er auch den Rest-Puffer weg.Lesen/Entpacken aus endlosem Stream ist sogar noch problematischer: nämlich er füllt sich seinen Puffer immer komplett voll, entpackt aber nur so viel, wie vom schreibenden GZipStream reingemacht wurde. Das bedeutet: Bei aufeinander folgenden Nachrichten verschwindet der Anfang der zweiten für immer im Puffer-Rest des lesenden GZipStreams - wird nie entpackt
(Ich hab das nur mit
GZipStream
ausgiebig getestet, denke aber, mit anderen Kompressions-Streams, und auch mit Crypto-Streams aller Art wirds nicht anders sein.)StreamContainer
Meine Lösung besteht darin, dass ich einen Stream gebastelt habe, den man zwischen
NetworkStream
und GZipStream
schaltet, und der schreibend das Ende eines Contents markieren kann, und lesend ab der Markierung einfach keine weiteren Daten mehr herausgibt. Ein StreamContainer
tut also so, als sei er ein endlicher Stream. Mit einem Extra-Befehl,
StreamContainer.NextContent()
, markiert man beim Schreiben das Ende eines Contents, und beim Lesen eröffnet man damit auch die Daten des nächsten Contents.Also zwischengeschaltet macht
StreamContainer
aus dem unendlichen NetworkStream
eine Aneinanderreihung endlicher Streams, und das eingangs beschriebene Problem ist ganz allgemein gelöst, für StreamReader, Zip- oder Crypto-Streams, und auch für weitere Lese/Schreib-Vorgänge, deren diesbezügliche Problematik mir nichtmal bekannt ist (Serialisierung?, Laden von XmlDocumenten?, Laden typisierter Datasets?).Das war der Knackpunkt an meim Tcp-Server-Client-TestProjekt, alles andere ist im Grunde Standard von Stange.
Aber auch der Standard ist nicht ganz ohne, und vlt. wert, sich mit zu befassen.
Server-Client-Netz
TCP ist ja verbindungsorientiert, und eine Verbindung hat zwei absolut gleiche Kommunikations-Endpunkte, die Daten miteinander austauschen. (Ich habe dafür die Klasse
TcpEndPoint
geproggt - mehr dazu später).Beim Server-Client-Netz hat man nun viele Clients, die sich mit einem Server verbinden. Und jeder Client hat seinen Kommunikations-Endpunkt, und der Server hält logischerweise eine Liste davon.
Weiters braucht man ein Übertragungs-Protokoll, denn nur Daten austauschen reicht nicht - man muss auch mitteilen, was die Gegenstelle damit machen soll. Also bei mir besteht ein "Content" immer aus einem
CommandCode
(2 Bytes), gefolgt von Daten. Und das Protokoll definiert nicht nur eine Liste gültiger Commands, sondern zwei: nämlich ServerCommands, die der Client dem Server schickt, und ClientCommands, die der Server den Clients. Ich nenne das ein bidirektionales Befehls-Protokoll, und ist als Enum
super-einfach formuliert:Etwa bei
TextBroadcast
verteilt er mit ClientCommand.Text
den Text an alle Clients. Aber auch auf TextInvert
antwortet er ein ClientCommand.Text
, nur invertiert er jetzt den Text, und schickt ihn auch nicht an alle, sondern nur retour an genau den Client, der angefragt hat.Was da nun im einzelnen (für Unsinn) gemacht wird ist ja egal. Wichtig ist nur das Prinzip des bidirektionalen Befehls-Protokolls zu zeigen, und proof-of-concept der verschiedenen Transfer-Fähigkeiten: Text, Zahlen, Bilder, Files sowie BroadCast, PeerToPeer, Compression, Encryption - letzteres hab ich aber aus Faulheit weggelassen.
Und dass die Kommunikation unverzüglich erfolgt, also eine Anfrage wird direkt beantwortet, ohne dass Teile der Daten erstmal in Puffern hängenbleiben, oder gar für immer darin verschwinden.
Aufbau der Solution
Auch wesentlich ist ein durchdachter Aufbau der Solution, die besteht hier nämlich aus 4 Projekten:
ServerApp, ClientApp, TcpCommon-Bibliothek, Helpers-Bibliothek.
So kann man Code richtig einordnen, je nachdem, wo er gebraucht wird: Helpers werden von allen gebraucht, TcpCommon wird von Server- und Client-App gebraucht, und letztere beiden brauchen nur sich selber. (Die Helpers-Bibliothek ist übrigens ein Winz-Auszug aus meiner sehr großen Helpers-Bibliothek, in der sich aller möglicher Code findet, der nicht nur hier sondern in zig anderen Projekten auch gebraucht wird.)
Also wer sich mit Tcp beschäftigen will, der sollte unbedingt Projekte zu einer Solution zusammenfassen können, und auch Verweise und Imports richtig setzen, damit wiederverwendbarer Code auch wiederverwendet wird.
Siehe dazu: Video: HelperProjekt einbinden
Und zum testen muss man dann 2 Projekte gleichzeitig als Startprojekt angeben, ich zeig mal Bildle, wie das in VS2013 einzustellen ist:
-->
Diese Einstellung ist wichtig, um die beiliegenden Sources problemlos zu testen!! Und muss man bei sich in der geöffneten Solution so einstellen - die Sources selbst transportieren das nicht mit.
TcpEndpoint
Hier mal der wesentliche
TcpEndpoint
-Code, es geht dabei um Threading, um Events und um die Umsetzung der Geschichte mit den CommandCodes:VB.NET-Quellcode
- Public Class TcpEndpoint : Implements IDisposable
- Public Const SetIdCommand As UInt16 = 0
- Public ReadOnly TcpClient As TcpClient, ReadStream, WriteStream As StreamContainer
- Private _dlgReadCommandCode As Func(Of UInt16)
- Public Sub New(ByVal TC As TcpClient, Optional id As Integer? = Nothing)
- _ID = id
- TcpClient = TC
- Dim strm = TcpClient.GetStream
- ReadStream = New StreamContainer(strm)
- WriteStream = New StreamContainer(strm)
- _dlgReadCommandCode = AddressOf ReadCommandCode
- _dlgReadCommandCode.BeginInvoke(AddressOf EndReadCommandCode, Nothing)
- End Sub
- ''' <summary> read the command-code from ReadStream. The real command-processing occurs in the EndReadCommandCode-Callback </summary>
- Private Function ReadCommandCode() As UInt16
- Try
- ReadStream.Read(_CmdBuf, 0, 2)
- Catch ex As IOException
- OnStatusMessage("CounterClient shut down")
- Dispose()
- Return 0
- End Try
- Return BitConverter.ToUInt16(_CmdBuf, 0)
- End Function
- ''' <summary> callback of asynchronous ReadCommandCode-Call </summary>
- Private Sub EndReadCommandCode(ByVal ar As IAsyncResult)
- If IsDisposed Then Return
- _CommandCode = _dlgReadCommandCode.EndInvoke(ar)
- If _CommandCode = SetIdCommand Then
- 'the SetIdCommand is special: the server sends it immediately as handshake, and to set an unique Identifier.
- If _ID.HasValue Then Throw New InvalidOperationException("The SetIdCommand-Code can only be sent once.")
- Dim buf(3) As Byte
- ReadStream.Read(buf, 0, 4)
- _ID = BitConverter.ToInt32(buf, 0)
- RaiseEvent Initialized(Me, EventArgs.Empty)
- Else
- RaiseEvent DataReceived(Me, EventArgs.Empty)
- End If
- ReadStream.NextContent()
- _dlgReadCommandCode.BeginInvoke(AddressOf EndReadCommandCode, Nothing) 'asynchronous Recursion
- End Sub
- Public Sub WriteCommandCode(cmd As UInt16)
- WriteStream.Write(BitConverter.GetBytes(cmd), 0, 2)
- End Sub
Sub New()
ist ja trivial, bis auf was mit dem Delegaten gemacht wird. Der wird nämlich asynchron aufgerufen (zeile#14), mit .BeginInvoke()
, unter Angabe eines Callbacks (in dem dann viel mehr passiert als in ReadCommandCode()
selbst).So ein
.BeginInvoke()
Aufruf delegiert die Geschichte an den Threadpool, sodass nichtmal ein Thread damit belegt ist, solange das Readen aus dem ReadStream
blockiert (also solange keine Daten ankommen). Dann aber werden 2 Bytes in einen kleinen Puffer gelesen(#20), und wenn nix schief geht, werden die beiden Bytes mittels
BitConverter
als ein UInt16
ausgelesen returnt (#26), und das wars auch schon - weiter gehts im Callback.Im Callback,
EndReadCommandCode()
wird (falls nicht disposet ist) der _CommandCode
als Klassenvariable gespeichert(#32), und dann erstmalig interpretiert: Nämlich wenn er identisch ist mit der SetIdCommand
-Konstante (siehe def. in #3), dann ist das ein besonderer, reservierter CommandCode, mit welchem der Server diesem Client eine Id zuweist. Das hab ich ausserhalb des bidirektionalen Protokolls codiert, denn die Protokolle sind von Fall zu Fall anders, aber dass ein Client eine Id braucht, das ist glaub in jedem Server-Client-Netz so. Jo, auch die Id wird per BitConverter
ausgelesen - diesmal als Int32
(also 4 Bytes - #37,#38).Gut - aber falls keine Id zugewiesen wird, wird halt das
DataReceived
-Event geraist, und da kann der Besitzer dieser TcpEndpoint
-Instanz - sei er nun Server oder Client - den Command auswerten, den weiteren Content aus dem ReadStream
lesen und agieren. Und wenn das Event durch ist, folgt noch der
ReadStream.NextContent()
-Aufruf(#43), damit der nächste Content gelesen werden kann. Jo, und dann gleich den nächsten CommandCode auslesen, wieder asynchron mit _dlgReadCommandCode.BeginInvoke()
(#44). Hier liegt interssanterweise eine asynchrone Rekursion vor, denn der angegebene Callback ist ja die Aufrufer-Methode selbst! . Also wer's philosophisch mag, kann mal behirnen, warum dieser Selbst-Aufruf ohne Abbruch-Bedingung nicht mit StackOverflow abstürzt .
Datenverarbeitung
Auszug aussm Server, wie der das
TcpEndPoint.DataReceived
-Event verarbeitet:VB.NET-Quellcode
- Private Sub Client_DataReceived(ByVal sender As Object, ByVal e As EventArgs)
- Dim client = DirectCast(sender, TcpEndpoint)
- Dim cmd = DirectCast(client.CommandCode, ServerCommand)
- Dbg(String.Concat("Client-", client.ID, ": ", cmd))
- Select Case cmd ' BroadCast - Commands
- Case ServerCommand.TextBroadcast : TextBroadcast(client)
- Case ServerCommand.ImageBroadcast : ImageBroadcast(client)
- Case Else
- Select Case cmd ' Back-to-Client - Commands
- Case ServerCommand.TextInvert : TextQuery(client)
- Case ServerCommand.NumberInvert : NumberInvert(client)
- Case ServerCommand.ChangeCompressedFile : ChangeCompressedFile(client)
- Case ServerCommand.ImageInvert : ImageQuery(client)
- Case Else : Dbg("unknown ServerCommand")
- End Select
- client.WriteStream.NextContent()
- Return
- End Select
- _Clients.ForEach(Sub(clnt) clnt.WriteStream.NextContent())
- End Sub
- Private Sub ChangeCompressedFile(client As TcpEndpoint)
- client.WriteCommandCode(ClientCommand.File)
- Using unzipper = New GZipStream(client.ReadStream, CompressionMode.Decompress),
- rd = New StreamReader(unzipper),
- zipper = New GZipStream(client.WriteStream, CompressionLevel.Optimal),
- wr = New StreamWriter(zipper)
- wr.Write(rd.ReadToEnd.ToUpper)
- End Using
- End Sub
- Private Sub NumberInvert(client As TcpEndpoint)
- client.WriteCommandCode(ClientCommand.Number)
- Using rd = New BinaryReader(client.ReadStream), wr = New BinaryWriter(client.WriteStream)
- wr.Write(-rd.ReadDecimal)
- End Using
- End Sub
- Private Sub TextBroadcast(client As TcpEndpoint)
- Using rd = New StreamReader(client.ReadStream)
- Dim txt = rd.ReadToEnd
- Dbg(txt)
- For Each cl As TcpEndpoint In _Clients ' an alle versenden
- cl.WriteCommandCode(ClientCommand.Text)
- Using wr = New StreamWriter(cl.WriteStream)
- wr.Write(txt)
- End Using
- Next
- End Using
- End Sub
ServerCommand
casten (#3). Dann in einem
Select Case
an verschiedene Verarbeitungen verzweigen, und den Select Case
hab ich sogar verschachtelt, denn ich unterscheide 2 Arten von Server-Antworten: "Back-to-Client" oder "BroadCast" (d.h: an alle).Und nach der Verarbeitung muss ja schön mit
WriteStream.NextContent()
das Ende der geschriebenen Antwort markiert werden - bei "Back-to-Client" nur für den einen Client (#16), oder bei BroadCast-Commands auch für alle Clients(#19).Und 3 Verarbeitungen sind auch gezeigt, das Prinzip wird glaub recht deutlich: Immer wird der sendende Client übergeben, und vor dem Writen der Daten immer erst das gemeinte
ClientCommand
reinschreiben (#23, #33, #44). Dann setzt man diverse Reader auf den ReadStream, und Writer auf den WriteStream, liest , (verarbeitet), schreibt und feddich.
In
ChangeCompressedFile()
ist die Reader/Writer-Setzerei ein rechter Bandwurm (#24), denn da wird auf den ReadStream
ein GZipStream
gesetzt, und darauf wiederum ein StreamReader
, und mit dem WriteStream
wird spiegelbildlich verfahren. Die anschliessende eigliche Verarbeitung ist dort nur die kurze Anweisung:
wr.Write(rd.ReadToEnd.ToUpper)
- Writer schreib alles was Reader liest - ToUpper, also in Großbuchstaben.Dasselbe Spiel auch mit der Zahlen-Inversion
NumberInvert()
, nur sinds jetzt keine Stream-, sondern Binary-Reader/-Writer (#34), und keine GZips dazwischen.TextBroadcast()
als Beispiel einer BroadCast-Antwort hat bischen andere Struktur, da wird ja von einem Client gelesen, aber geschrieben wird an alle. Aber auch trivial, oder? Erst den
txt
einlesen (#41), dann alle Clients durchschleifen. Jedem ClientCommand.Text
anweisen (#44), und dann natürlich txt
schreiben (#46).(
Dbg()
ist übrigens meine Debug-Ausgabe in die Konsole) Dieser Beitrag wurde bereits 6 mal editiert, zuletzt von „ErfinderDesRades“ ()