Programowanie w języku C#

Gniazda sieciowe

Wstęp


W tym artykule zapoznasz się z klasami w .NET, które dotyczą gniazd sieciowych (ang. sockets) oraz nauczysz się jak się nimi posługiwać i napisać prosty czat tekstowy w oparciu właśnie o gniazda. Oprócz tego musisz wiedzieć, że podstawowe klasy do obsługi gniazd znajdują się w przestrzeni nazw:

System.Net.Sockets;


Co to są gniazda sieciowe?


Gniazdo to punkt końcowy (ang. endpoint) w komunikacji między urządzeniami sieciowymi. Gniazdo charakteryzują głównie:

  • adres lokalny (ang. local address)
  • adres zdalny (ang. remote address)
  • protokół (np. TCP, UDP, raw IP)
Gniazda można podzielić na najważniejsze typy:

  • Datagram sockets - działają w oparciu o protokół UDP i nie mają połączenia z drugim gniazdem
  • Stream sockets - gniazda strumieniowe, posiadają wzajemne połączenie, działają w oparciu najczęściej o protokół TCP
  • Raw sockets - gniazda, w których sami musimy określić zawartość pakietu, przydatne, gdy chcemy wykorzystać komunikację opartą na własnym protokole
My zajmiemy się gniazdami TCP i UDP, dlatego że są najczęściej stosowanymi gniazdami.

Klasa Socket


Dokładny opis i dokumentacja klasy Socket znajduje się tu: http://msdn.microsoft.com/en-us/library/system.net.sockets.socket.aspx

Większość metod synchronicznych posiada swoje niesynchroniczne odpowiedniki, np. metoda Receive posiada swoich odpowiedników: BeginReceive i EndReceive konieczne do wykonywania operacji asynchronicznych.

Na początku wspomnę również o klasach takich jak: TcpClient, TcpListener, UdpClient. Są to klasy dziedziczone po Socket i ułatwiają pracę z konkretnym protokołem, gotowe metody i właściwości pomagają w pisaniu niezbyt skomplikowanych aplikacji, które nie wymagają super wydajności.

Konstruktory


Klasa posiada 2 konstruktory:
Socket(SocketInformation socketInformation)
Socket(AddressFamily addressFamily, SocketType socketType, ProtocolType protocolType)


Pierwszy jako parametr przyjmuje wynik z metody Socket.DuplicateAndClose(), która tworzy dane gniazda potrzebne do jego duplikacji na podstawie swojej instancji, a następnie zamyka gniazdo.

Drugi wymaga trochę dłuższego opisu. Parametr addresFamily określa schemat adresowania dla gniazda, najczęściej będzie to AddressFamily.InterNetwork, który określa adres dla protokołu IPv4. Parametr socketType określa typ gniazda, mamy do wyboru: Stream, Dgram, Raw, Rdm, Seqpacket, Unknown. Najważniejsze są pierwsze 3. Ostatni parametr określa typ protokołu na jakim działa gniazdo, lista jest dość długa, ale najważniejsze protokoły to oczywiście: IP, IPv4, Tcp, Udp, Raw.

Wyjątki


W przestrzeni System.Net.Sockets jest klasa SocketException, która opisuje wyjątek związany z gniazdem. Większość metod wyrzuca właśnie ten wyjątek, gdy coś pójdzie nie tak np. zostanie utracone połączenie.

Klasa ta posiada właściwość ErrorCode, która zwraca kod błędu w postaci int. Lista i oznaczenia błędów można znaleźć tu: http://msdn.microsoft.com/en-u[...]desktop/ms740668(v=vs.85).aspx

Tworzymy gniazdo


Aby utworzyć najprostsze gniazdo opierające się na IP piszemy:

Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP);


Warto wspomnieć, że niektóre metody nie są dostępne dla różnych typów gniazd, np. dla gniazda typu UDP nie możliwe jest wykonanie metody Listen, ponieważ ten protokół nie opiera się na połączeniach, a więc nie można nasłuchiwać z gniazda.

Połączenie


Aby zaistniało połączenie między gniazdami musi być serwer i klient. Stwórz więc identyczne 2 gniazda, jeden będzie serwerem, a drugi klientem zewnętrznym, dlaczego zewnętrznym? Zaraz to wytłumaczę. Otóż serwer akceptuje oczekującego klienta po wywołaniu metody Accept() i zwraca gniazdo potrzebne do komunikacji właśnie z tym zewnętrznym klientem. Zewnętrzny klient nie musi być w jednej aplikacji, może on być daleko poza serwerem, my tylko przećwiczymy komunikację lokalnie, ale gniazda są przede wszystkim do komunikacji zdalnej. Prócz klienta i serwera, którym zapewnisz utworzenie instancji, powinieneś zadeklarować jeszcze jedno gniazdo, ale tylko zadeklarować. Gniazdo to zostanie przypisanie po zwróceniu wyniku z w/w metody.

Jeden socket jak wspomniałem musi pełnić funkcję serwera, w tym celu musimy przypisać mu adres nasłuchiwania:

serverSocket.Bind(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 1024)); // przypisuje adres nasłuchiwania jako 127.0.0.1 na porcie 1024


Klasa IPEndPoint i IPAddress znajdują się w przestrzeni nazw:

System.Net;


Teraz ustawimy stan serwera na nasłuchiwanie i połączymy się z nim.

serverSocket.Listen(1); // parametr to maksymalna ilość połączeń oczekujących
clientSocket.Connect("127.0.0.1", 1024); // próba połączenia
internalClient = serverSocket.Accept(); // zaakceptowanie klienta


Metoda Socket.Accept() jest synchroniczna i będzie blokować dalsze działanie obecnego wątku aż do zaakceptowania czyli połączenia się jakiegoś klienta.
Metoda Socket.Connect(...), którą wywołujemy u klienta, może powodować wyjątek np. w przypadku błędu połączenia z powodu nieistnienia serwera o podanym adresie.

Wysyłanie i odbieranie danych


W celu odebrania danych musimy je najpierw wysłać. Użyjemy do tego metody klienta Socket.Send(...), która za parametr przyjmuje tablicę byte, czyli bufor danych do wysłania.

Wymyślmy sobie teraz dane do wysłania, niech to będzie jakiś napis np. standardowe "Hello world!". Jak wspomniałem metoda przyjmuje bufor bajtów, czyli musimy wyciągnąć jakoś naszego stringa w postaci tablicy bajtów. Posłuży nam metoda GetBytes(...) z danego kodowania znaków. Przyjmijmy, że nasz napis jest kodowany jako ASCII, więc aby pobrać bajty naszego napisu piszemy:

ASCIIEncoding.ASCII.GetBytes(napis); // napis jest zmienną typu string


Teraz niestety metoda zwraca wynik "w kosmos", ale wstawmy ją jako parametr metody Socket.Send(...):

clientSocket.Send(ASCIIEncoding.ASCII.GetBytes("Hello world!")); // gdy nie połączyliśmy się z serwerem, metoda wyrzuci wyjątek


Chcemy teraz odebrać to co wysłaliśmy, zatem wywołajmy metodę Socket.Receive(...), która za parametr przyjmuje znów bufor, ale tym razem bufor odbioru, czyli tam gdzie zapiszą się odebrane bajty.

byte[] recBuffer = new byte[256];
internalClient.Receive(recBuffer); // metoda prócz tego zwraca ilość odebranych bajtów


Nasz bufor zawiera teraz odebrane dane, przydałoby się tablicę bajtów zamienić znów na stringa i wyświetlić to co odebraliśmy:

Console.WriteLine(ASCIIEncoding.ASCII.GetString(recBuffer));


To na razie wszystko odnośnie wysyłania i odbierania. Pełny kod:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net.Sockets;
using System.Net;
 
namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP);
            Socket clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP);
            Socket internalSocket;
            byte[] recBuffer = new byte[256];
 
            serverSocket.Bind(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 1024));
            serverSocket.Listen(1);
            clientSocket.Connect("127.0.0.1", 1024);
            internalSocket = serverSocket.Accept();
            clientSocket.Send(ASCIIEncoding.ASCII.GetBytes("Hello world!"));
            internalSocket.Receive(recBuffer);
            Console.WriteLine(ASCIIEncoding.ASCII.GetString(recBuffer));
 
            Console.Read();
        }
    }
}


Oczywiście przykład był w jednej aplikacji, ale clientSocket powinna znajdować się w oddzielnej aplikacji klienckiej.

Rozłączanie


Aby rozłączyć się z klientem należy wywołać metodę Close() obiektu klienta. Czy to zrobimy po stronie klienta czy po serwerze na rzecz obiektu internalSocket połączenie zostanie zerwane.

Klasa TcpClient i TcpListener


Te klasy są stworzone, aby maksymalnie ułatwić pracę z gniazdami opartymi na protokole TCP/IP. TcpClient odpowiada za gniazdo klienckie, a TcpListener to "nasłuchiwacz" pełniący rolę serwera.

Konstruktory


TcpClient:
TcpClient client = new TcpClient(); // nowy klient
TcpClient client = new TcpClient("localhost", 1024); // nowy klient + próba połączenia
 
TcpListener listener = new TcpListener(IPAddress.Parse("127.0.0.1"), 1024); // nowy serwer + ustawienie parametrów nasłuchiwania


Ponadto jest jeden konstruktor klasy TcpListener, który posiada jeden parametr - port. Konstruktor jest jednak przestarzały i powinno się używać tego, który przyjmuje 2 parametry: lokalny adres nasłuchiwania, lokalny port nasłuchiwania lub cała klasa przechowująca te informacje, mianowicie IPEndPoint czyli punkt końcowy.

Nasłuchiwanie, próba połączenia i akceptacja


Jak wspomniałem klasy te są maksymalnie uproszczone, wystarczy wywołać metodę Start(), aby serwer zaczął nasłuchiwać. Akceptowanie odbywa się po wywołaniu jednej metody, która czeka, aż dołączy się nowy klient, jest to metoda AcceptTcpClient() i zwraca ona obiekt klasy TcpClient. Poprzez dostęp do nowego obiektu klienta, możemy również wysyłać/odbierać dane. Aby można jednak było zaakceptować klienta, musi on zrobić próbę połączenia. Odbywa się to w ten sposób: albo przy konstruktorze albo poprzez metodę Connect(IPAddress, int). Na razie kod wygląda tak:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net.Sockets;
using System.Net;
 
namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            TcpListener listener = new TcpListener(IPAddress.Parse("127.0.0.1"), 1024); // nasz serwer
            TcpClient externalClient = new TcpClient(); // tworzymy zewnętrznego klienta, imituje on aplikację kliencką
 
            listener.Start();
            externalClient.Connect("127.0.0.1", 1024); // próba połączenia
            TcpClient newClient = listener.AcceptTcpClient(); // akceptacja
 
            Console.Read();
        }
    }
}


Wysyłanie i odbieranie danych


Jesteśmy teraz gotowi do przesyłu danych między klientem, a serwerem, ale mamy 2 drogi: albo bawić się znów buforami albo uprościć sobie to (skoro używamy uproszczonych klas to i użyjmy uproszczonego przesyłu) używając klas: BinaryWriter i BinaryReader (leżą one w przestrzeni System.IO). Dzięki nim możemy bardzo łatwo przesyłać dane w jedną i drugą stronę. Klasy te w konstruktorze pobierają strumień, na którym będą operować. Strumień, który podamy to strumień sieciowy, który to znów pobieramy metodą GetStream() obiektu TcpClient. Prześlijmy więc przykładowy łańcuch i wartość naszym strumieniem za pomocą tych klas.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net.Sockets;
using System.Net;
using System.IO;
 
namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            TcpListener listener = new TcpListener(IPAddress.Parse("127.0.0.1"), 1024); // nasz serwer
            TcpClient externalClient = new TcpClient(); // tworzymy zewnętrznego klienta, imituje on aplikację kliencką
 
            listener.Start();
            externalClient.Connect("127.0.0.1", 1024); // próba połączenia
            TcpClient newClient = listener.AcceptTcpClient(); // akceptacja
 
            BinaryWriter writer = new BinaryWriter(externalClient.GetStream()); // przyjmijmy, że writer jest w aplikacji klienckiej, dlatego podajemy "inny" inny strumień
            BinaryReader reader = new BinaryReader(newClient.GetStream()); // a reader jest po stronie serwera
 
            writer.Write("Hello, it's a test of TCP/IP sockets communication");
            writer.Write(12345.15);
 
            Console.WriteLine(reader.ReadString());
            Console.WriteLine(reader.ReadDouble());
 
            Console.Read();
        }
    }
}


Jak widzimy, metoda Write jest przeciążona kilkunastokrotnie dla podstawowych typów, zaś metoda Read... występuje w różnych postaciach, zależnie od tego jaki typ chcemy odebrać. W naszym przykładzie wysyłamy najpierw napis, a potem wartość zmiennoprzecinkową, następnie odbieramy w takiej samej kolejności. Cała logika, że nie musimy bawić się w bufory w klasach BinaryWriter i BinaryReader polega na tym, że writer zapisuje najpierw ile bajtów zapisał do strumienia, dzięki temu reader wie ile ma tych bajtów odczytać. Następnie konwertuje odebrany bufor na nasz chciany typ.

Rozłączanie


Rozłączamy się analogicznie jak przy klasie Socket.

Co siedzi jeszcze w klasach


Prócz metod, których używaliśmy istnieją jeszcze inne metody i właściwości w klasach TcpClient i TcpListener.

Klasa TcpClient:

Metoda/właściwośćOpis
Availablezwraca ilość bajtów, które zostały odebrane i można je odczytać (przydatne przy buforach)
Clientdostęp do obiektu klasy Socket, na którym oparta jest komunikacja
Connectedwskazuje czy klient ma połączenie zdalne z serwerem
Receive/SendTimeoutpozwala ustawić timeouty odpowiednio dla odczytu i zapisu
Receive/SendBufferSizepozwala ustawić rozmiar buforów odczytu i zapisu

Klasa TcpListener:

Metoda/właściwośćOpis
AcceptSocket()pozwala odebrać zwykłe gniazdo zamiast obiektu TcpClient
Pending()pozwala dowiedzieć się czy jest jakieś połączenie oczekujące
Serverzwraca czyste gniazdo serwera
Stop()zatrzymuje nasłuchiwanie

4 komentarze

ziajek444 2016-10-16 11:24

Perfekcyjna robota.

mgmuras 2014-04-17 23:59

Dziękuję! :) Świetny tutorial :)

Brak avatara
danny 2013-11-27 19:59

Genialnie wytłumaczone, polecam!:)

Brak avatara
abc 2013-11-06 20:02

Bardzo dobry tutorial, pierwszy z którego faktycznie coś zrozumiałem. Brakuje tylko rozdzielenia plików na klienta i serwer. Laik może się pogubić.