TCP/IP alapú átvitel esetén minden esetben kell egy szerver és egy kliens program. A szerver és a kliens lehet egy programon belül is. Klasszikusan a kliens és a szerver két külön program szokott lenni, amik egy közös nyelvet beszélnek. A szerver és a kliens egy programon belül Peer to Peer programokban fordul elő, illetve olyan esetekben, amikor szálak vagy futó program példányok között szeretnénk kommunikálni.
A közös nyelv az átvinni kÃvánt adatok/osztályokból áll. Ezeket a könnyű karbantarthatóság miatt egy közös szerelvény fájlba érdemes szervezni. Illetve érdemes figyelembe venni, hogy egy szerverhez több kliens is csatlakozhat. Éppen ezért, ha hálózati kódot Ãrunk, akkor ügyeljünk arra, hogy az minden esetben szálbiztos és több szálon futó legyen.
A TCP/IP programozásra több tÃpusú programozási lehetÅ‘séget is biztosÃt a .NET, de a Task, Task<T> tÃpusok, illetve az async és await kulcsszavak megjelenése óta (C# 5.0) a többi megoldás háttérbe szorult. A C# 7.0-val megjelent ValueTask<T> és a C# 8.0-ban debütált await foreach kulcsszó tovább mélyÃtették a szakadékot a többszálú alkalmazások korábban használt, illetve a Microsoft által is javasolt megoládsok között.
Az alábbi példaprogram egy TCP/IP szervert és egy hozzá tartozó klienst fog bemutatni async await használatával. A szerver egy véletlenszám-kiszolgálóként funkcionál. A válasz a kliens kérésének megfelelő mennyiségű pszeudo-véletlenszámot, illetve az üzenet létrehozásának idejét tartalmazza.
Figyelem: Az alábbi kódrészlet megértéséhez szükséges egy minimális hálózati alapismeret. Ha a hálózatok új téma, akkor a mellékletekben található egy hálózatok gyorstalpaló, aminek az elolvasását ajánljuk.
Mivel a kliens és a szerver ugyanazokkal az adatokkal dolgozik, ezért egy külön szerelvénybe kerültek az üzenetek leÃrásai. A kliens egy RequestMessage objektumot fog küldeni a szervernek, ami megmondja, hogy pontosan hány darab számot fog kérni:
using System;
namespace NetworkCommon
{
[Serializable]
public class RequestMessage
{
public int Darab { get; set; }
}
}
A szerver válaszüzenete erre egy Message tÃpusú objektum lesz, ami egy listában visszaadja a kért mennyiségű véletlen egész számot, valamint a kiszolgálás idejét. Az objektum kapott egy ToString() implementációt is, amit majd a konzolra kiÃratás használ.
using System;
using System.Text;
namespace NetworkCommon
{
[Serializable]
public class Message
{
public DateTime ValaszIdo { get; set; }
public int[] Szamok { get; set; }
public Message()
{
Szamok = Array.Empty<int>();
}
public override string ToString()
{
StringBuilder str = new StringBuilder();
str.AppendLine($"Szerver valaszido: {ValaszIdo}");
str.AppendLine($"Szamok: {string.Join(',', Szamok)}");
return str.ToString();
}
}
}
A szerver kódja:
using NetworkCommon;
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace TCPServer
{
internal static class Program
{
static void Main(string[] args)
{
IPEndPoint localhost = new IPEndPoint(IPAddress.Parse(Constants.LocalhostIpv4), Constants.TCPProgramPort);
TcpListener server = new TcpListener(localhost);
CancellationTokenSource tokenSource = new CancellationTokenSource();
try
{
server.Start();
Task.Run(() => ServerTask(server, tokenSource.Token));
Console.WriteLine("A szerver fut. Nyomj egy gombot a kilépéshez.");
Console.ReadKey();
}
finally
{
tokenSource.Cancel();
server.Stop();
tokenSource.Dispose();
}
}
private static async Task ServerTask(TcpListener server, CancellationToken token)
{
while (true)
{
if (token.IsCancellationRequested)
{
break;
}
using TcpClient client = await server.AcceptTcpClientAsync();
using (var stream = client.GetStream())
{
while (stream.DataAvailable == false)
{
}
RequestMessage? processedRequest = MessageSerializer.SerializeFrom<RequestMessage>(stream);
if (token.IsCancellationRequested)
{
break;
}
Message responseMessage = CreateResponse(processedRequest?.Darab ?? 0);
MessageSerializer.SerializeTo(stream, responseMessage);
}
}
}
private static Message CreateResponse(int darab)
{
Message result = new Message
{
Szamok = new int[darab]
};
Random r = new Random();
for (int i = 0; i < darab; i++)
{
result.Szamok[i] = r.Next(0, 1000);
}
result.ValaszIdo = DateTime.Now;
return result;
}
}
}
TCP szerver célra a TcpListener osztály használható, ami egy adott IPEndPoint által meghatározott IP cÃmen és porton képes bejövÅ‘ kapcsolatokat fogadni, majd azokra válaszolni. A szerverünk egy külön szálon van megvalósÃtva egy Task formájában. Ez a Task egészen addig fut, amÃg a fÅ‘ szálon nem nyomunk meg egy gombot, ami a CancellationTokenSource tÃpusú változón keresztül nem kéri kulturáltan a Task megszakÃtását.
A Task-ban a TcpListener AcceptTcpClientAsync() metódusával várakozunk egy bejövÅ‘ kapcsolatra. Sikeres kapcsolódás esetén az üres while ciklus várakozást valósÃt meg. Ez a várakozás a kliens által küldött kérés megérkezése miatt kell. Itt érdemes lenne az eltelt idÅ‘t is figyelni, mert Ãgy egy szál végtelen várakozásba kerülhet, ha a kliens nem küld adatot.
Az adat megérkezése után a kliens adatfolyamából kiolvassuk az adatot, majd összeállÃtjuk a válasz csomagot és elküldjük a kliensnek. Feltűnhet, hogy a válaszolás elÅ‘tt ismét ellenÅ‘rzésre kerül a CancellationTokenSource által biztosÃtott token állapota.
Ezt minden hosszabb művelet elvégzése elÅ‘tt érdemes ellenÅ‘rizni. Ha csak a kliens fogadás elÅ‘tt ellenÅ‘rizzük, akkor egy fogadott kliens feldolgozása közben nem tudnánk megszakÃtani a szerver futását. Ez jelenleg nem lenne nagyon nagy baj, de grafikus alkalmazások esetén ronthatja a felhasználói élményt, ha az általa kiadott műveletek látszólag sem azonnal történnek meg.
Az adatok byte sorozattá és abból való konvertálásáért a MessageSerializer osztály felelÅ‘s, ami szintén a közös kódban lett megvalósÃtva generikusan:
using System;
using System.IO;
using System.Net.Sockets;
using System.Text.Json;
namespace NetworkCommon
{
public static class MessageSerializer
{
private static JsonSerializerOptions options = new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public static void SerializeTo<T>(NetworkStream target, T data)
{
using (var buffer = new MemoryStream())
{
JsonSerializer.Serialize(buffer, data, options);
buffer.Flush();
long length = buffer.Length;
target.Write(BitConverter.GetBytes(length));
buffer.Seek(0, SeekOrigin.Begin);
buffer.CopyTo(target);
target.Flush();
}
}
public static T? SerializeFrom<T>(NetworkStream source)
{
byte[] legthBytes = new byte[sizeof(long)];
source.Read(legthBytes, 0, sizeof(long));
long length = BitConverter.ToInt64(legthBytes);
long totalRead = 0;
byte[] buffer = new byte[1024];
using (var bufferStream = new MemoryStream())
{
int read = 0;
while (totalRead != length)
{
read = source.Read(buffer, 0, buffer.Length);
bufferStream.Write(buffer, 0, read);
totalRead += read;
}
bufferStream.Seek(0, SeekOrigin.Begin);
return JsonSerializer.Deserialize<T>(bufferStream, options);
}
}
}
}
Serialization célra a kód a JsonSerializer osztályt alkalmazza, némi csavarral. A csavarra azért van szükség, mert az XML és a JSON serializer is tud Stream tÃpussal együttműködni, de ahhoz, hogy működjön a deszerializáció, a használt Stream osztály Length tulajdonságának a Stream hosszát kell visszadnia. Ez a NetworkStream implementációjában kivételt generál, mivel ez a tÃpus egy hálózati adatfolyamot reprezentál, aminél ez nem értelmezhetÅ‘.
Éppen ezért a szerializáció egy MemoryStream tÃpusba történik, aminek a hosszát az adatot megelÅ‘zÅ‘leg elküldjük. Fogadáskor az adat hosszát kiolvassuk, majd ennek megfelelÅ‘en annyi byte adatot másolunk át az adatfolyamból egy MemoryStream-be, mint amennyi az adat hossza. Ezt követÅ‘en a deszerializáció a memóriából történik és nem a hálózati adatfolyamból.
Mind a SerializeTo és a SerializeFrom implementációjában feltűnhet a Seek(0, SeekOrigin.Begin); művelet. Erre azért van szükség, mert Ãrás esetén a másolás a Stream.Position tulajdonsága által mutatott ponttól menne, de mi az elejétÅ‘l vagyunk kÃváncsiak az adatra. Visszaolvasásnál szintén a teljes adatra szükségünk van.
A kliens kódja némiképpen egyszerűbb, mivel nem külön szálon fut. Ha végzett a lekéréssel, akkor egyszerűen kilép. A kapcsolódás itt is aszinkron módon történik a ConnectAsync metódussal, mert például egy lassú hálózati kapcsolat esetén a fő programszál a kapcsolat létrejöttéig blokkolva lenne.
EbbÅ‘l adódóan a Main metódus egy aszinkron Task. A kliens az adatok elküldése után fogadja az adatokat a szervertÅ‘l. Az adatok elküldése után egy fix 100 ms várakozás lett beiktatva a Task.Delay segÃtségével, ami éles kódban egy kifejezetten rossz példa. Ennek ellenére mégis Ãgy lett megvalósÃtva, mert ebbÅ‘l is tanulunk, hogy kulturáltan ezt is hasonló módon kellene megvalósÃtani, mint ahogy a szerver vár a bejövÅ‘ adatokra.
using NetworkCommon;
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
namespace TCPClient
{
internal static class Program
{
static async Task Main(string[] args)
{
TcpClient client = new TcpClient();
try
{
await client.ConnectAsync(IPAddress.Parse(Constants.LocalhostIpv4), Constants.TCPProgramPort);
using (var stream = client.GetStream())
{
RequestMessage request = new RequestMessage
{
Darab = 5
};
MessageSerializer.SerializeTo(stream, request);
await Task.Delay(100);
Message? response = MessageSerializer.SerializeFrom<Message>(stream);
Console.WriteLine(response);
}
}
finally
{
Console.WriteLine("Nyomj egy gombot a kilépéshez.");
Console.ReadKey();
client.Close();
}
}
}
}
A kliens kimenete futó szerver esetén:
Szerver valaszido: 2023. 10. 15. 17:13:37
Szamok: 48,797,834,897,742
Nyomj egy gombot a kilépéshez.
Mindkét kód esetén érdekesség az IPAddress osztály, ami alkalmas IPv6 kommunikáció lebonyolÃtására is. A példában az IPv4 azért került alkalmazásra, mert egyszerűbben olvasható vele a példakód. Szakszerűen egyébként a gép neve alapján a hozzá tartozó IP cÃmekkel kellene inicializálni a szervert, hogy az összes hálózati kártyán, vagy legalább egy valós hálózati cÃmen reagáljon a kérésekre, mert jelen példában szereplÅ‘ kódok jelenleg csak egy gépen belül működÅ‘képesek.
Minden alkalmazásnak kell egy port, amin az adatokat továbbÃtja. Az 1000 alatti portok többsége már foglalt és mondhatni szabványosÃtott.1 Saját alkalmazás fejlesztésekor igyekezzünk 10000 feletti portokat használni.
-
A modern operációs rendszerek ezen felül rendelkeznek egy védelmi mechanizmussal is, hogy csak úgy ne tudjon bárki mondjuk egy szabványosÃtott protokollra (Pl: HTTP, 80-as port) alkalmazást Ãrni, mert ha blokkoljuk ezek közül valamelyiket a saját kis alkalmazásunkkal, akkor az kihathat a rendszer teljes működésére. Éppen ezért, ha 1000 alatti portot használó alkalmazást szeretnénk futtatni, akkor a programunknak Rendszergazdai jogosultságokkal kell futnia.↩