Gniazda sieciowe
xeo545x39
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-us/library/windows/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 |
---|---|
Available | zwraca ilość bajtów, które zostały odebrane i można je odczytać (przydatne przy buforach) |
Client | dostęp do obiektu klasy Socket , na którym oparta jest komunikacja |
Connected | wskazuje czy klient ma połączenie zdalne z serwerem |
Receive/SendTimeout | pozwala ustawić timeouty odpowiednio dla odczytu i zapisu |
Receive/SendBufferSize | pozwala 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 |
Server | zwraca czyste gniazdo serwera |
Stop() | zatrzymuje nasłuchiwanie |
Perfekcyjna robota.
Dziękuję! :) Świetny tutorial :)