TCP Klient & Server

Inledning

Vi fortsätter vår serie med nätverksprogrammering. Tidigare har vi tittat på UDP och broadcast i artikeln Broadcast och chatt . Denna gång ska vi titta på TCP (transmission control protocol).

Vi kommer att konstruera ett enkelt klient-server program med hjälp av klassen Socket i .NET. Det första målet är att skapa en tjänst som talar om vad klockan är där endast en klient kan ansluta. Därefter kommer vi att bygga vidare och tillåta att flera klienter ansluter sig samtidigt till servern.

TCP

Till skillnad från UDP så upprättar TCP en förbindelse, en så kallad session vilket är ett annat ord för en etablerade TCP socket. Det kan tyckas förvirrande att klassen Socket både hanterar UDP och TCP i .NET.

När TCP används så måste först klienten upprätta en anslutning till servern. Redan här definieras olika roller. Initiativtagaren (den som ansluter) är klient och servern är den host som erbjuder tjänsten och förhoppningsvis kommer att acceptera anslutningen. Protokollet TCP kommer att hjälpa oss med en rad tekniska problem som just upprättandet av anslutningen och möjligheten att flera klienter ansluter till samma tjänst.

Det viktigaste att känna till är att ett packet (meddelande) som skickas via TCP kommer fram. På en lägre nivå sker det till och med flera omsändningar om inte det första försöket lyckades. Skulle det mot förmodan misslyckas så kommer vi att bli informerade om detta. När vi använde UDP så fick vi inga garantier för att meddelandena som skickades kom fram, vi hade heller ingen möjlighet att kontrollera detta.

För mer info rekommenderas videon "Protokoll och standarder - del 3" från itlararen.se samt Wikipedia.

En enkel server

Vi börjar med att skapa en server som väntar på en anslutning via TCP port 100. När anslutningen väl kommer så går servern över till att vänta på kommandon från klienten. Inga andra klienter kan alltså ansluta efter det att servern fått sin första connection.

Single server - del 1

using System;
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace TcpServerSingle
{
    class Program
    {
        private static Socket _serverSocket;
        private const int BufferSize = 2048;
        private const int Port = 100;
        private static readonly byte[] Buffer = new byte[BufferSize];
        private static bool _closing;

        static void Main()
        {
            Console.Title = "Server";

            Console.WriteLine("Setting up server...");
            _serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            _serverSocket.Bind(new IPEndPoint(IPAddress.Any, Port));
            _serverSocket.Listen(5);
            Console.WriteLine("Waiting for connection...");
            var clientSocket = _serverSocket.Accept();
            Console.WriteLine("Client connected, waiting for request...");

            clientSocket.BeginReceive(Buffer, 0, BufferSize, SocketFlags.None, ReceiveCallback, clientSocket);
            // Vänta här!
            Console.ReadLine(); 
            _closing = true;

            clientSocket.Shutdown(SocketShutdown.Both);
            clientSocket.Close();
            _serverSocket.Close();
        }

Första skapas en _serverSocket av typen Ipv4 och Tcp (parameter 2 o 3 rad 21. Därefter binder vi till port 100 samt IPAddress.Any (rad 22). Any är i detta fall IP 0.0.0.0 och kommer att lyssna på trafik från alla nätverkskort på datorn.

När metoden Listen anropas (rad 23) så försätts vår socket i "listening state" och vi kan acceptera anslutningar. Parametern (5) som anges här har inget med maximala antalet uppkopplingar som tillåts utan anger backLog som är en gräns på hur många väntande uppkopplingar man kan ha.

På rad 25 stannar programmet och väntar på en anslutning. Detta kallas för ett blockerande anrop då tråden (vårt program) som anropar "fastnar" här. Jämför t.ex. med ett Console.ReadLine() som fungerar på ungefär samma vis. Får vi en koppling så fångas denna upp i variabeln clientSocket som är av typen Socket.

När en uppkoppling väl kommer så anropas metoden BeginReceive som inte är blockerande. Detta är ett så kallat asynkront anrop. I och med anropet så skapas det en helt ny tråd som bara kommer att ligga och vänta på att något skall skickas till servern. Det skickas med en Buffer samt storleken på denna. Det skickas också med en så kallad callback metod, ReceiveCallback som är en metod vi själva måste skriva. Den anropas asynkront (i den andra tråden som skapades) när någonting har mottagits eller om det blivit något fel. Sist skickas också klientens uppkopplingen med.

Skulle vi trycka enter så stänger vi servern. Vi signalerar avslut med _closing samt kopplar ned vår clientSocket samt _serverSocket. I det ögonblick vi stänger clientSocket så kommer den andra tråden som väntar på meddelanden att drabbas av ett fel och anropa ReceiveCallback.

ReceiveCallback

Nu ska vi se till att servern kan erbjuda något. Vi kommer att svara på tre sätt, totalt två olika kommandon.

  • get time kommer vi att reagera på och skicka tillbaka aktuell tid.
  • exit kommer betyda att klienten vill koppla ned på ett snyggt vis.
  • allt annat kommer vi att svara "Text is an invalid request".
Single server - del 2

	private static void ReceiveCallback(IAsyncResult ar)
	{
		if (_closing)
			return;

		Socket current = (Socket)ar.AsyncState;
		int received;

		try
		{
			received = current.EndReceive(ar);
		}
		catch (SocketException)
		{
			Console.WriteLine("Client forcefully disconnected");
			current.Close();
			return;
		}

		string text = Encoding.UTF8.GetString(Buffer, 0, received);
		Console.WriteLine("Received Text: " + text);

		switch (text.ToLower())
		{
			case "get time":
				Console.WriteLine("Text is a get time request");
				current.Send(Encoding.UTF8.GetBytes(DateTime.Now.ToLongTimeString()));
				Console.WriteLine("Time sent to client");
				break;
			case "exit":
				current.Shutdown(SocketShutdown.Both);
				current.Close();
				Console.WriteLine("Client disconnected");
				return;
				break;
			default:
				Console.WriteLine("Text is an invalid request");
				current.Send(Encoding.UTF8.GetBytes("Invalid request"));
				Console.WriteLine("Warning Sent");
				break;
		}

		current.BeginReceive(Buffer, 0, BufferSize, SocketFlags.None, ReceiveCallback, current);
	}

Vi börjar vår callback med att undersöka om det är som så att servern håller på att stängas ned. I så fall ska vi bara avsluta. Att röra uppkoppligen i detta läge kommer att ge en exception av något slag, t.ex. att uppkopplingen redan är stängd.

Metoden fortsätter med att plocka fram den socket som är upphov till callback'en. Det kommer att vara vår ensamma klient. Nu måste metodne EndReceive anropas för att avsluta mottagandet samt att vi ska få reda på hur många bytes som togs emot. Här kan exception inträffa. Det kan vara som så att klienten har försvunnit genom att uppkopplingen försvunnit. Då är det inte så mycket att göra än att avsluta lyssnartråden med ett return. Servern kommer fortfarande att vänta på sin Console.ReadLine men inget mer kommer att hända.

Om det verkligen skickades ett meddelande så kommer vi att tolka det som UTF8 på rad 20. Därefter kommer vi att undersöka vad som skickades. De tre fall vi nämnde i inledningen kommer att hanteras. I det fall att exit skickades så stänger vi uppkopplingen och avslutar lyssnartråden med return. I de andra två fallen så kommer något att skickas till klienten.

Sist i ReceiveCallback, om vi kommer hit, så anropar vi BeginReceive igen med samma metod, ReceiveCallback, som callback. Alltså skapar vi här ytterligare en tråd! Den gamla tråden som vi befinner oss i har gjort sitt och kommer att avsluta och städas bort automatiskt. Vi kan se en enkel illustration av förloppet i bilden nedan.

bild

Huvudtråden (blå) kommer alltid att finnas. Sedan skapas det en ny tråd (röd) som väntar på meddelanden. Om något skickas till servern så kommer det att skapas en ny tråd (grön) som också lyssnar. Den nu "gamla" röda tråden har gjort sitt jobb och kommer att avslutas. Fortsätter kommunikationen så kommer den gröna tråden att skapa en ny lyssnartråd och sedan själv försvinna.

Vi kan titta på ett litet demo över vår klient & server nu. I Visual Studio kan man starta flera program samtidigt från en solution (kolla noga hur).

Klienten

Ni har vi redan demonstrerat klienten i videon. Klienten kommer att försöka ansluta till 127.0.0.1 en så kallad loopback adress. Denna adress lämnar aldrig din dator. Det är perfekt för att testa applikationer som dessa!

Klienten - kod

using System;
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace TcpClient
{
    class Program
    {
        private static readonly Socket ClientSocket = new Socket
            (AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        private const int Port = 100;

        static void Main()
        {
            Console.Title = "Client";
            ConnectToServer();
            RequestLoop();

            ClientSocket.Shutdown(SocketShutdown.Both);
            ClientSocket.Close();
        }

        private static void ConnectToServer()
        {
            int attempts = 0;

            while (!ClientSocket.Connected)
            {
                try
                {
                    attempts++;
                    Console.WriteLine("Connection attempt " + attempts);
                    ClientSocket.Connect(IPAddress.Loopback, Port);
                }
                catch (SocketException)
                {
                    Console.Clear();
                }
            }
            Console.Clear();
            Console.WriteLine("Connected");
        }

        private static void RequestLoop()
        {
            Console.WriteLine(@"<Type ""exit"" to properly disconnect client>");
            string requestSent = string.Empty;

            try
            {
                while (requestSent.ToLower() != "exit")
                {
                    Console.Write("Send a request: ");
                    requestSent = Console.ReadLine();
                    ClientSocket.Send(Encoding.UTF8.GetBytes(requestSent), SocketFlags.None);
                    ReceiveResponse();
                }
            }
            catch (Exception)
            {
                
                Console.WriteLine("Error! - Lost server.");
                Console.ReadLine();
            }

        }

        private static void ReceiveResponse()
        {
            var buffer = new byte[2048];
            int received = ClientSocket.Receive(buffer, SocketFlags.None);
            if (received == 0) 
                return;
            Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, received));
        }
    }
}

Klienten är relativt enkel trots en del kod. Vi skapar inga trådar här. Vi börjar med metoden ConnectToServer som klienten fastnar i tills dess att den får kontakt. Vi skriver ut hur många försök vi gjort och rensar skärmen däremellan. Det tar ca 1 sekund per anslutningsförsök.

Vår ClientSocket är av typen Ipv4 och Tcp. Vi försöker ansluta till IPAddress.Loopback (127.0.0.1) på rad 34. Vill du testa programmet på ett riktigt nätverk så måste du ändra till en annan IP-adress!

När klienten väl lyckas ansluta så fortsätter programmet och fastnar i nästa loop i metoden RequestLoop. Så länge vi inte skriver just exit så kommer vi att skicka det vi skrev till servern (rad 56) sedan direkt anropa ReceiveResponse där vi anropar den blockerande metoden Receive och väntar på ett svar från servern.

En bättre server

En betydligt bättre server hade varit en som tillät uppkoppling från flera klienter hela tiden. Det som behövs en en samling av alla klienters sockets som ansluter. Vi behöver också en dedikerad tråd som hela tiden är beredd på att ta emot nya anslutningar.

Multi client server - kod

using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;

namespace TcpServer
{
    class Program
    {
        private static Socket _serverSocket;
        private static readonly List<Socket> ClientSockets = new List<Socket>();
        private const int BufferSize = 2048;
        private const int Port = 100;
        private static readonly byte[] Buffer = new byte[BufferSize];
        private static bool _closing;

        static void Main()
        {
            Console.Title = "Server";
            SetupServer();

            //Vänta här!
            Console.ReadLine(); 
            _closing = true;
            CloseAllSockets();
            Thread.Sleep(2000);
        }

        private static void SetupServer()
        {
            Console.WriteLine("Setting up server...");
            _serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            _serverSocket.Bind(new IPEndPoint(IPAddress.Any, Port));
            _serverSocket.Listen(5);
            _serverSocket.BeginAccept(AcceptCallback, null);
            Console.WriteLine("Server setup complete");
        }

        private static void CloseAllSockets()
        {
            foreach (Socket socket in ClientSockets)
            {
                socket.Shutdown(SocketShutdown.Both);
                socket.Close();
            }

            _serverSocket.Close();
        }

        private static void AcceptCallback(IAsyncResult ar)
        {
            if (_closing)
                return;

            Socket socket = _serverSocket.EndAccept(ar);
            ClientSockets.Add(socket);
            socket.BeginReceive(Buffer, 0, BufferSize, SocketFlags.None, ReceiveCallback, socket);
            Console.WriteLine("Client connected, waiting for request...");
            _serverSocket.BeginAccept(AcceptCallback, null);
        }

        private static void ReceiveCallback(IAsyncResult ar)
        {
            if (_closing)
                return;

            Socket current = (Socket)ar.AsyncState;
            int received;

            try
            {
                received = current.EndReceive(ar);
            }
            catch (SocketException)
            {
                Console.WriteLine("Client forcefully disconnected");
                current.Close(); 
                ClientSockets.Remove(current);
                return;
            }

            string text = Encoding.UTF8.GetString(Buffer, 0, received);
            Console.WriteLine("Received Text: " + text);

            switch (text.ToLower())
            {
                case "get time":
                    Console.WriteLine("Text is a get time request");
                    current.Send(Encoding.UTF8.GetBytes(DateTime.Now.ToLongTimeString()));
                    Console.WriteLine("Time sent to client");
                    break;
                case "exit":
                    current.Shutdown(SocketShutdown.Both);
                    current.Close();
                    ClientSockets.Remove(current);
                    Console.WriteLine("Client disconnected");
                    return;
                    break;
                default:
                    Console.WriteLine("Text is an invalid request");
                    current.Send(Encoding.UTF8.GetBytes("Invalid request"));
                    Console.WriteLine("Warning Sent");
                    break;
            }

            current.BeginReceive(Buffer, 0, BufferSize, SocketFlags.None, ReceiveCallback, current);
        }
    }
}

Största skillnaden är att vi nu har en lista med connections som vi mottagit. Vi anropar också den asynkrona metoden BeginAccept istället för den blockerande Accept. När vi får en callback från vår BeginAccept så tar vi hand om anslutningen, accepterar den med EndAccept, lagrar kopplingen i ClientSockets och skapar en lyssnartråd för varje uppkoppling med BeginReceive.

Vi har nu en huvudtråd, en tråd som accepterar nya anslutningar samt x antal trådar som ligger och lyssnar beroende på hur många anslutningar som finns. De flesta delarna i servern fungerar precis som innan.

En liten demonstration över hur det fungerar med många klienter.

Hur många trådar kan det finnas?

I .NET skapas varje ny tråd via ett asynkront anrop från en så kallad thread pool. Systemet tillåter en viss mängd trådar. Standardvärdena är:

  • 1023 trådar i .NET 4.0 (32 bit)
  • 32768 trådar i .NET 4.0 (64 bit)
  • 250 trådar per kärna i .NET 3.5
  • 25 trådar per kärna i .NET 2.0

Avslutning

Hoppas du uppskattade artikeln. Nätverksprogrammering är inte alla gånger så lätt, främst på grund av det faktum att det skapas många trådar i programmet samt att det måste till mycket felhantering för att programmen ska bli robusta.

bild

För att felsöka nätverk så rekommenderar vi programmet Wireshark. Det kan spela in trafik på nätverket. Mer info om hur programmet fungerar hittar du på itlararen.se, följande videos är av intresse:

Nedan finner du koden till projektet med klienten och de två olika servrarna.

Övning 1

Ändra i koden för klienten så att du kan mata in vilket IP-nummer du vill ansluta till.

Övning 2

Anslut med klienten till servern via ett riktigt nätverk. Använd Wireshark för att spela in trafiken som genereras och analysera denna.

Kan du se hur uppkopplingen görs samt hur meddelandena skickas?

Det är tyvärr svårt att spela in loopback-trafik på en Windows-dator, därav förslaget att använda ett riktigt nätverk.

Övning 3

Hitta på två nya kommandon förutom get time som servern kan svara på.

Övning 4

Ändra i servern så att mer info om klienten skrivs ut, t.ex. IP och port för varje uppkoppling som görs och för varje förfrågan som sänds.

Lämna ett svar

Din e-postadress kommer inte publiceras. Obligatoriska fält är märkta *

Scroll to top