eToro Trading
Author Ad

Φτίαχνουμε Multithreaded Web Server σε C#


Η C# είναι μια αρκετά απλή και ευέλικτη γλώσσα προγραμματισμού.  Μαζί με το .NET έρχονται πολλά έτοιμα classes που την κάνει ακόμα πιο απλή.  Τόσο, που μπορείτε να γράψετε έναν απλό multithreaded HTTP server για στατικό περιεχόμενο μέσα σε 15 λεπτά. Θα μπορούσαμε να χρησιμοποιήσουμε την class HttpListener και να τελειώσουμε ακόμα πιο γρήγορα, όμως ο στόχος αυτού του άρθρου είναι να δείτε πως γίνετε κάτι τέτοιο σε C#.

Για αρχή φτιάχνουμε νέο project:

using System;
using System.Collections.Generic;
using System.Text;
 
namespace HTTPServer
{
    class Server
    {
        static void Main(string[] args)
        {
 
        }
    }
}

Στην .NET μπορούμε πολύ εύκολα να φτιάξουμε TCP-server με την βοήθεια του TcpListener class τον οποίο και θα χρησιμοποιήσουμε.

    class Server
    {
        TcpListener Listener; // Object πάνω στο οποίο συνδέονται οι TCP-clients
 
        // Εκκινηση του server
        public Server(int Port)
        {
            //  Φτιάχνουμε listener για το καθορισμένο port
            Listener = new TcpListener(IPAddress.Any, Port);
            Listener.Start(); // Τον τρέχουμε 
 
            // Σε άπειρο loop
            while (true)
            {
                // Δεχόμαστε νέους clients
                Listener.AcceptTcpClient();
            }
        }
 
        // Διακοπή του server
        ~Server()
        {
            // Εαν ο listener έχει δημιουργηθεί
            if (Listener != null)
            {
                // Τον σταματάμε 
                Listener.Stop();
            }
        }
 
        static void Main(string[] args)
        {
            // Φτιάχνουμε νέο server στην port 8080
            new Server(8080);
        }
    }

Εαν τρέξουμε τώρα το πρόγραμμα τότε θα μπορούμε να συνδεθούμε στην port 8080 και αυτό ήταν.
Θα δημιουργηθεί μια μόνιμη σύνδεση που δεν θα κάνει τίποτα, μιας και δεν υπάρχει handler και δεν κλείνει από την μεριά του server.
Ας φτιάξουμε έναν απλό Ηandler:

    // Client-Handler Class
    class Client
    {
        // Class constructor. Θα του στέλνουμε τους clients του TcpListener
        public Client(TcpClient Client)
        {
            //Μια απλή HTML σελίδα
            string Html = "<html><body><h1>It works!</h1></body></html>";
            // Απαραίτητα Headers: server response, τύπος και μήκος περιεχομένου. Μετά από δύο κενές γραμμές, το περιεχόμενο
            string Str = "HTTP/1.1 200 OK\nContent-type: text/html\nContent-Length:" + Html.Length.ToString() + "\n\n" + Html;
            // Θα περάσουμε την γραμμή σε array
            byte[] Buffer = Encoding.ASCII.GetBytes(Str);
            // Θα το στείλουμε στον client.
            Client.GetStream().Write(Buffer,  0, Buffer.Length);
            // Κλείνουμε την σύνδεση
            Client.Close();
        }
    }

Για να του στείλουμε έναν client θα πρέπει να αλλάξουμε μια γραμμή στην Server class.

    // Λαμβάνουμε νέους clients και τους στέλνουμε για επεξεργασία 
    new Client(Listener.AcceptTcpClient());

Τώρα αν τρέξουμε το πρόγραμμα και ανοίξουμε τον Browser στο 127.0.0.1:8080 θα δούμε ένα μεγάλο “It Works!”.
Πριν γράψουμε τον parser για τα HTTP-Requests θα κάνουμε τον server μας multithreaded. Για αυτο υπαρχουν 2 τροποι:
να φτιάχνουμε χειροκίνητα νέο thread για κάθε client ή να χρησιμοποιήσουμε thread pool.
Και οι 2 τρόποι έχουν τα πλεονεκτήματα και τα μειονεκτήματά τους. Εαν φτιάχνουμε νέο thread για κάθε νέο client, τότε ο server μπορεί να μην αντέξει μεγάλο φορτίο, αλλά θα μπορούμε να δουλεύουμε σχεδόν με απεριόριστο αριθμό clients ταυτόχρονα.
Εαν χρησιμοποιήσουμε το thread pool, τότε ο αριθμός των ταυτόχρονων thread θα είναι περιορισμένος, αλλά δεν θα μπορούμε να φτιάξουμε νέα thread μέχρι να τερματίσουν τα παλιά.
Δεν ξέρω τι θα προτιμήσετε, γιαυτό θα δείξω πως δουλεύουν και τα δυο.

Ο παρακάτω κώδικας θα δημιουργεί νεο instance του client class.

 static void ClientThread(Object StateInfo)
    {
        new Client((TcpClient)StateInfo);
    }

Για να χρησιμοποιήσουμε τον πρώτο τρόπο αρκεί να αλλάξουμε το περιεχόμενο του infinite loop που λαμβάνει τους πελάτες.

    // λαμβάνουμε νέο client
    TcpClient Client = Listener.AcceptTcpClient();
    // Φτιάχνουμε νέο thread
    Thread Thread = new Thread(new ParameterizedThreadStart(ClientThread));
    // Και τρέχουμε αυτό το thread, στέλνοντάς του τον client που λάβαμε
    Thread.Start(Client);

Για τον δεύτερο τρόπο πρέπει να κάνουμε το ίδιο

    // Λαμβάνουμε νέους clients. Αφού ο πελάτης γίνει δεκτός, στέλνετε στο νέο thread  (ClientThread)
    // χρησιμοποιώντας το thread pool
    ThreadPool.QueueUserWorkItem(new WaitCallback(ClientThread), Listener.AcceptTcpClient());

Συν πρέπει να καθορίσουμε τον ελάχιστο και μέγιστο αριθμό threads. Θα το κάνουμε στην Main function

    // Καθορίζουμε τον μέγιστο αριθμό threads
    // Ας είναι 4 για κάθε cpu.
    int MaxThreadsCount = Environment.ProcessorCount * 4;
    // Ορίζουμε τον μέγιστο αριθμό εργαζομένων threads.
    ThreadPool.SetMaxThreads(MaxThreadsCount, MaxThreadsCount);
    // Ορίζουμε τον ελάχιστο αριθμό εργαζομένων threads.
    ThreadPool.SetMinThreads(2, 2);

Ο μέγιστος αριθμός threads δεν πρέπει να είναι λιγότερος από 2, γιατί σε αυτόν τον αριθμό μπαίνει και το κύριο thread. Εαν το βάλουμε 1, τότε η επεξεργασία των clients θα είναι δυνατή μόνο όταν το κύριο thread σταμάτησε την εργασία του (πχ περιμένει νέο client).

Τώρα ας δούμε πάλι το Client class και να αρχίσουμε να επεξεργαζόμαστε τα HTTP requests.
Λαμβάνουμε το κείμενο με το request από τον client:

    // String στο οποίο θα αποθηκευτεί το request του client.
    string Request = "";
    // Buffer για την αποθήκευση τον δεδομένων που λάβαμε από τον client.
    byte[] Buffer = new byte[1024];
    // Variable για την αποθήκευση του αριθμού bytes που λάβαμε από τον client.
    int Count;
    // Διαβάζουμε το stream του client όσο λαμβάνουμε από αυτόν δεδομένα.
    while ((Count = Client.GetStream().Read(Buffer,  0, Buffer.Length)) >  0)
    {
        // Μετατρέπουμε αυτά τα δεδομένα σε string και το προσθέτουμε στην Request variable.
        Request += Encoding.ASCII.GetString(Buffer,  0, Count);
        // Η αίτηση πρέπει να επεξεργάζεται με αυτήν την ακολουθία  \r\n\r\n
        // Αλλιώς σταματάμε την λήψη δεδομένων εάν το μήκος του Request string είναι μεγαλύτερο από 4KB
        // Αυτό επειδή δεν χρειάζεται να λαμβάνουμε δεδομένα από POST Requests. Ενα απλο request 
        // δεν είναι μεγαλύτερο από 4KB
        if (Request.IndexOf("\r\n\r\n") >=  0 || Request.Length > 4096)
        {
            break;
        }
    }

Τώρα κάνουμε parsing των δεδομένων που λάβαμε:

    // String parsing χρησιμοποιώντας regular expressions.
    // Ταυτόχρονα φιλτράρουμε όλα τα GET Requests.
    Match ReqMatch = Regex.Match(Request, @"^\w+\s+([^\s\?]+)[^\s]*\s+HTTP/.*|");
 
    // Εαν το request απέτυχε.
    if (ReqMatch == Match.Empty)
    {
        // στέλνουμε στον client σφάλμα 400
        SendError(Client, 400);
        return;
    }
 
    // Παίρνουμε το request string
    string RequestUri = ReqMatch.Groups[1].Value;
 
    // Την φέρνουμε στην αρχική της μορφή μετατρέποντας του χαρακτήρες. 
    // πχ, "%20" -> " "
    RequestUri = Uri.UnescapeDataString(RequestUri);
 
    // Εαν το string έχει μέσα διπλές τελείας στέλνουμε σφάλμα 400.
    // Αυτό το κάνουμε για να προστατευτούμε από URL όπως http://example.com/../../file.txt
    if (RequestUri.IndexOf("..") >=  0)
    {
        SendError(Client, 400);
        return;
    }
 
    // Εαν το request string τελειώνει με "/" τότε προσθέτουμε index.html 
    if (RequestUri.EndsWith("/"))
    {
        RequestUri += "index.html";
    }

Και τέλος δουλεύουμε με αρχεία: ελέγχουμε αν υπάρχει το αρχείο που χρειάζεται, καθορίζουμε τον τύπο του αρχείου και το στέλνουμε στον client.

    string FilePath = "www/" + RequestUri;
 
    // Εαν στον φάκελο www δεν υπάρχει τέτοιο αρχείο, στέλνουμε σφάλμα 404
    if (!File.Exists(FilePath))
    {
        SendError(Client, 404);
        return;
    }
 
    // Παίρνουμε την επέκταση του αρχείου
    string Extension = RequestUri.Substring(RequestUri.LastIndexOf('.'));
 
    // Τύπος περιεχομένου
    string ContentType = "";
 
    // Προσπαθούμε να καταλάβουμε τον τύπο περιεχόμενου από την επέκταση του αρχείου. 
    switch (Extension)
    {
        case ".htm":
        case ".html":
            ContentType = "text/html";
            break;
        case ".css":
            ContentType = "text/stylesheet";
            break;
        case ".js":
            ContentType = "text/javascript";
            break;
        case ".jpg":
            ContentType = "image/jpeg";
            break;
        case ".jpeg":
        case ".png":
        case ".gif":
            ContentType = "image/" + Extension.Substring(1);
            break;
        default:
            if (Extension.Length > 1)
            {
                ContentType = "application/" + Extension.Substring(1);
            }
            else
            {
                ContentType = "application/unknown";
            }
            break;
    }
 
    // Ανοίγουμε το αρχείο, ελέγχοντας για σφάλματα.
    FileStream FS;
    try
    {
        FS = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
    }
    catch (Exception)
    {
        // Εαν είχαμε σφάλμα στέλνουμε στον client σφάλμα 500
        SendError(Client, 500);
        return;
    }
 
    // Στέλνουμε τα headers
    string Headers = "HTTP/1.1 200 OK\nContent-Type: " + ContentType + "\nContent-Length: " + FS.Length + "\n\n";
    byte[] HeadersBuffer = Encoding.ASCII.GetBytes(Headers);
    Client.GetStream().Write(HeadersBuffer,  0, HeadersBuffer.Length);
 
    // Μέχρι να φτάσουμε στο τέλος του αρχείου
    while (FS.Position < FS.Length)
    {
        // Διαβάζουμε τα δεδομένα του αρχείου
        Count = FS.Read(Buffer,  0, Buffer.Length);
        // Και τα μεταδίδουμε στον client
        Client.GetStream().Write(Buffer,  0, Count);
    }
 
    // Κλείνουμε τ αρχείο και την σύνδεση
    FS.Close();
    Client.Close();

Στον παραπάνω κώδικα χρησιμοποιήσαμε την ανύπαρκτη SendError function. Ας την γράψουμε τώρα

    // Αποστολή της σελίδας με το σφάλμα. 
    private void SendError(TcpClient Client, int Code)
    {
        // Λαμβάνουμε string τύπου "200 OK"
        // Το HttpStatusCode περιέχει μέσα του όλα τα status-codes του HTTP/1.1
        string CodeStr = Code.ToString() + " " + ((HttpStatusCode)Code).ToString();
        // Μια απλή html σελίδα
        string Html = "<html><body><h1>" + CodeStr + "</h1></body></html>";
        // Απαραίτητα Headers: server response, τύπος και μήκος περιεχομένου. Μετά από δύο κενές γραμμές, το περιεχόμενο
        string Str = "HTTP/1.1 " + CodeStr + "\nContent-type: text/html\nContent-Length:" + Html.Length.ToString() + "\n\n" + Html;
        //  Θα περάσουμε την γραμμή σε byte array
        byte[] Buffer = Encoding.ASCII.GetBytes(Str);
        // Το στέλνουμε στον client
        Client.GetStream().Write(Buffer,  0, Buffer.Length);
        // Κλείνουμε την σύνδεση
        Client.Close();
    }

Αυτό ήταν, ο HTTP server είναι έτοιμος. Είναι multithreaded, μπορεί να δίνει static περιεχόμενο, έχει μια απλή προστασία από malicious requests και βγάζει σφάλμα αν κάποιο αρχείο δεν υπάρχει. Επίσης πολύ εύκολα μπορούμε να του προσθέσουμε διάφορα άλλα features όπως: ρυθμίσεις, vhosts, mod_rewrite, μέχρι και υποστήριξη CGI.

Source Code – Thread Pool
Source Code – Threads

Δημήτρης Α.
Γίνετε επεξεργασία, Παρακαλώ περιμένετε...
+ 63.3

Ο Συγγραφέας

Twitter Linkedin
Σπούδασε Τεχνικός Δικτύων στο IT Step, στο Donentsk της Ουκρανίας. Δουλεύει ως προγραμματιστής στην Livevol Inc. Ασχολείται με τους υπολογιστές, το ίντερνετ, με την τεχνολογία και τον προγραμματισμό, και φυσικά το gaming! Λατρεύει το Wordpress και το Centos. Ακούει Hip-Hop και λατρεύει τα strategy games.

6 Σχολια

  1. kostas says:

    katebasa to arxeio kai to etreksa omws to ektelesimo arxeio pws dhmiourgeitai?
    apo compile?

    Epishs ekana copy-paste to programma kai dokimasa na to kanw compile se linux alla mou emfanize errors?

  2. Ναι, κάνεις compile των κώδικα και δημιουργείται το exe αρχείο το οποίο και παραθέτω.
    Σε linux δεν πρόκειται να δουλέψει, είναι γραμμένο σε Visual Studio και στοχεύει αποκλειστικά τα Windows συστήματα.

  3. kostas says:

    Μήπως μπορείτε να βάλετε ολοκληρωμένο τον κώδικα σε ένα αρχείο γιατι προσπάθησα να το κάνω compile σε Visual Studio C# και μου εμφάνιζε errors…

  4. Στο τέλος του άρθρου δίνω τους ολοκληρωμένους κώδικες και για τις 2 μεθόδους. Εκεί που λέει
    Source Code – Thread Pool
    Source Code – Threads

  5. kostas says:

    Το πρόγραμμα τρέχει μόνο αν είναι εγκατεστημένο το Visual Studiο?
    Επειδή το δοκίμασα σε κάποιον υπολογιστή που δεν το είχε και όταν άνοιγα το εκτελεσιμο αρχείο μου εμφάνιζε error…

  6. Το VS δεν χρειάζεται να είναι εγκαταστημένο για να λειτουργήσει. Και το error μάλλον ήταν επειδή κάποιο άλλο service ακούει την ίδια πορτ.

Αφήστε ένα σχόλιο

You must be logged in to post a comment.