Laden...

ChatProgramm (Anfänger - TCP / Threading)

Erstellt von BlackBirth vor einem Jahr Letzter Beitrag vor einem Jahr 1.097 Views
B
BlackBirth Themenstarter:in
5 Beiträge seit 2022
vor einem Jahr
ChatProgramm (Anfänger - TCP / Threading)

Hallo, ich bin Anfänger im Programmieren. Habe schon ein paar Programme geschrieben und versuche mich mit Hilfe eines Chat-Programmes an der Netzwergprogrammierung und dem Threading. Anfangs habe ich nur eine Nachricht per TcpClient und TcpListener verschickt und das ging auch soweit und möchte das jetzt erweitern.
Es soll einen zentralen Server geben der alle eingehenden Verbindungen annimmt und die Texte in einer Listbox anzeigt. Bei der Annahme soll in einer 2. Box die IP-Adresse angezeigt werden und anschließend auch der einloggende Name später dann auch. In der ComboBox sollen dann die IPs angezeigt und auswählbar sein. Soweit funktioniert das auch.
Der Client loggt sich per Console ein sendet per Stream erst seinen Namen dann den Text und empfängt auch eine Bestätigung der Sendung.
Noch nicht implementiert, dass alle auch alle Nachrichten sehen und das der Server Nachrichten senden kann, da ich erst die anderen Probleme lösen möchte.

Problem 1:
Mit dem Kick-Button soll die Verbindung mit ausgewählten IP-Adresse in der Combobox getrennt werden. Ich weiß nicht wie ich die beenden kann.

Problem 2:
Ich kann den Server nicht beenden, da die while-Schleife in HandleConnection() mit dem interrupt des Threads nicht beendet wird und immer ein Fehler wirft, da AcceptTcpClient() wahrscheinlich noch auf Verbindungen wartet und wenn der Server gestoppt wird den Fehler erzeugt.

Frage 1: Der Thread t läuft ja in einer Schleife und erzeugt pro eingehende Verbindung einen neuen Thread tc. Wie kann ich die Threads auflisten oder unterscheiden bzw. darauf zugreifen? Später soll eine maximale Anzahl an Verbindungen eingebaut werden und Dopplungen von IPs vermieden werden.

Frage 2: Ist die Prozessstruktur so einigermaßen in Ordnung? Oder sollte ich sie grundsätzlich ändern?

Vielen Dank schon mal im Voraus, und bitte seid nachsichtig ich bin blutiger Anfänger!


using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace Chat_Server
{
    public partial class Form1 : Form
    {
        private TcpListener server;
        private Boolean isRunnig = false;
        private Thread t;

        public Form1()
        {
            InitializeComponent();
        }

        public void btn_server_start_Click(object sender, EventArgs e)
        {
            isRunnig = true;
            t = new Thread(HandleConnection);                        
            t.Start();
            LBox_chat.Items.Add("Server gestartet!");
            
            Thread aktualisieren = new Thread(cBox_aktualisieren);
            aktualisieren.Start();
        }

        private void btn_server_close_Click(object sender, EventArgs e)
        {
            isRunnig = false;
            t.Interrupt();
            server.Stop();
            LBox_chat.Items.Add("Server gestoppt!");
            
        }

        private void btn_send_Click(object sender, EventArgs e)
        {
            tBox_eingabe.Text = "";
        }

        private void btn_client_kick_Click(object sender, EventArgs e)
        {
            LBox_chat.Items.Add(" wurde vom Server gekickt!");
        }

        private void HandleConnection()                                                 //Zuweisungen der Ankommenden Verbindungen
        {
            server = new TcpListener(System.Net.IPAddress.Any, 5732);                                         
            server.Start();

                while (isRunnig)
                {
                    TcpClient newClient = server.AcceptTcpClient();                     
                    Thread tc = new Thread(new ParameterizedThreadStart(HandleClient));  
                    tc.Start(newClient);                                                 

                    LBox_clients.Invoke(new Action(() => LBox_clients.Items.Add(newClient.Client.RemoteEndPoint.ToString())));
                }

        }

        public void HandleClient(object obj)                  //Methode der einselnen Clients zum lesen und schreiben
        {
            String name;

            TcpClient client = (TcpClient)obj;
            StreamWriter writer = new StreamWriter(client.GetStream(), Encoding.ASCII);
            StreamReader reader = new StreamReader(client.GetStream(), Encoding.ASCII);

            Boolean bClieantConnectet = true;
            String sData = null;
            name = reader.ReadLine();

            LBox_chat.Invoke(new Action(() => LBox_chat.Items.Add(name + " hat sich eingeloggt")));
            LBox_clients.Invoke(new Action(() => LBox_clients.Items.Remove(client.Client.RemoteEndPoint.ToString())));
            LBox_clients.Invoke(new Action(() => LBox_clients.Items.Add(name+" "+client.Client.RemoteEndPoint.ToString())));
            
            while (bClieantConnectet)
            {
                sData = reader.ReadLine();
                if (sData != null)
                {
                    LBox_chat.Invoke(new Action(() => LBox_chat.Items.Add(name + ": " + sData)));
                    writer.WriteLine("Du hast eine Nachricht erfolgreich gesendet");
                    writer.Flush();

                }
                if (sData == "ende")
                {
                    bClieantConnectet = false;
                    client.Close();
                    LBox_chat.Invoke(new Action(() => LBox_chat.Items.Add(name + " hat sich ausgeloggt")));
                    client.Close();
                    break;
                }
            }
        }

        public void cBox_aktualisieren()
        {
            while (true)
            {
                int counter = LBox_clients.Items.Count;
                if (counter != cBox_cliens.Items.Count)
                {
                    cBox_cliens.Invoke(new Action(() => cBox_cliens.Items.Clear()));
                    if (counter > 0)
                    {
                        for (int i = 0; i < counter; i++)
                        {
                            cBox_cliens.Invoke(new Action(() => cBox_cliens.Items.Add(LBox_clients.Items[i])));
                        }
                    }

                }
             
            }

        }


    }
}

16.807 Beiträge seit 2008
vor einem Jahr

Ist das eine Lernaufgabe, um Threading zu verstehen, oder soll ein nutzbares Tool werden?

Wenn Zweiteres:
Man würde heutzutage für soche Dinge a) keine Threads nutzen sondern Tasks und asyncrone Programmierung und b) nicht mit Sockets arbeiten sondern zB mit WebSockets; schon allein aus Kompatibilitätsgründen und weil das viel Arbeit abnimmt (Security, Encryption, Connection Handling..).
Bei Sockets musst Du das alles selbst machen.

Zu den Problemen:

ausgewählten IP-Adresse in der Combobox getrennt werden. Ich weiß nicht wie ich die beenden kann.

Du musst Dir die Client-Instanz merken und dann erst den Stream closen und dann den client (auch disposen nicht vergessen).
Kannst aber sicher der Doku entnehmen, sowas ist normalerweise sehr gut dokumentiert, vor allem der TcpClient.

Ich kann den Server nicht beenden, da die while-Schleife in HandleConnection() mit dem interrupt des Threads nicht beendet wird und immer ein Fehler wirft, da AcceptTcpClient() wahrscheinlich noch auf Verbindungen wartet und wenn der Server gestoppt wird den Fehler erzeugt.

Das ist ein allgemeines Problem, wenn man mit Threads arbeitet und blockenden Methoden.
Threads können im Gegensatz zu Tasks nicht gecancelt sondern nur abgebrochen (eher abgeschossen) werden. Wenn eine Methode blockiert, dann bleibt Dir nur das abschießen.
Auch mit asynchroner Programmierung und dessen Variante AcceptTcpClientAsync gäbe es das nicht, weil hier cancellation durch den Token soft unterstützt wird.

Wie kann ich die Threads auflisten oder unterscheiden bzw. darauf zugreifen?

Musst alles selbst veralten, das heisst zB in einer Liste speichern. Da gibts keinen Automatismus oder sowas.
Gilt aber auch für Tasks; wobei sich hier Tasks auch hier erneut einfacher verwalten lassen.
Fertige Frameworks wie SignalR nehmen einem das ab; hier gibts die "Hubs", die sowas automatisch verwalten inkl. Verbindungsaufbau, -abbau, -reconnect etc.

Ist die Prozessstruktur so einigermaßen in Ordnung? Oder sollte ich sie grundsätzlich ändern?

Sowas ist schlecht zu bewerten, vor allem wenn der Code von Dir als selbst ernannter Einsteiger kommt.
Verletzt viele Dinge der Programmierkunst, aber das lernt man halt einfach mit der Zeit. Ging uns bei den ersten Code-Beginnen nicht anders.

Ich würds als den Standard Wegwerf/Lern-Spaghetti-Code bezeichnen, den man halt als Anfänger so macht 😉
Aber ist schon besser und mit mehr Verständnis, als viele andere Anfänger so als erstes produzieren. Viele verzweifeln am zB Invoke-Thema etc.

PS:
Streams sollte man nicht nur closen sondern auch disposen (bzw alles was disposable ist): Implement a Dispose method

Und noch was:
Schreib lieber string als String. Das sieht erst mal änhlich aus aber string ist ein keyword und String ist eine Klasse.
Und leider lassen sich Klassen überschreiben, auch String. Und es gibt tatsächlich Helden, die selber Klassen wie "String" bauen.
Daher: lieber immer Keywords verwenden, wenn Auswirkungen, die durch String als Klasse passieren können, unerwünscht sind.

M
368 Beiträge seit 2006
vor einem Jahr

... ändern?

Zusätzlich: im Endeffekt ist der verwendete C#-Compiler die entscheidende Instanz was formal(*) korrekten Code, Implementierungs-Möglichkeiten oder Magie im Hintergrund betrifft. Verhaltensweisen wie "Clean Code" oder "Aufteilung in Schichten" dienen dem Menschen, erleichtern aber das Ändern oder Erweitern eines Softwareprodukts. Und mit (tendenziell) zunehmender Menge an Code ist der Einsatz einer Versionsverwaltung (Git, (veraltet) Subversion, (veraltet) CVS,... ) anzuraten.

(*)nicht zwingend: logisch

Goalkicker.com // DNC Magazine for .NET Developers // .NET Blogs zum Folgen
Software is like cathedrals: first we build them, then we pray 😉

B
BlackBirth Themenstarter:in
5 Beiträge seit 2022
vor einem Jahr

Danke Abt an die ausführliche Hilfe, ich versuch es mal umzuarbeiten / neu aufzubauen.
Da ist keine Lernaufgabe, sondern will ich einfach nur Lernen zu programmieren, vielleicht mal das ein oder andre Tool schreiben oder vielleicht auch mal nen kleines Kartenspiel oder so. Da will ich mich halt langsam herantasten und hab gedacht, dass das Chatprogram eine gute Übung ist um mit Daten übers Netzwerk zu schicken und diese zu verarbeiten. Auch der Umgang mit TCP-Server/Client wollte ich damit erkunden. Das meine ersten Programme nicht so perfekt sind ist mir klar, aber ich hoffe das wird sich bessern.
Vielen Dank

B
BlackBirth Themenstarter:in
5 Beiträge seit 2022
vor einem Jahr

So, hab mal versucht den Code umzuarbeiten. Bis jetzt funktioniert auch alles sehr gut genau so wie ich es will. Kann jetzt meine Clients auch verwalten. Ich wollt fragen ob der Code jetzt besser ist? Ich habe zwar viele Cods gesehen, die das Problem auch lösen, aber die verstehe ich zum großen Teil noch nicht und ich find es besser mich da selbst heranzutasten und nicht einfach coppy und paste zu machen, denn das bringt mich auch im Lernprozess nicht weiter. Mein nächster schritt wird sein die Streams einzubauen und zu verwalten, aber alles nach und nach ;D


using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Collections;

namespace Chat_Server_V1_1
{
    public partial class Form1 : Form
    {
        TcpListener server;
        List<TcpClient> clientListe = new List<TcpClient>();
        Boolean isRunning = true ;
        CancellationTokenSource cts = new();
        Task mainTask;

        public Form1()
        {
            InitializeComponent();
        }

        private async void btn_server_start_Click(object sender, EventArgs e)
        {
            CancellationToken token = cts.Token;
            
            if (isRunning)
            {
                isRunning = false;
                btn_server_start.Text = "Server stoppen";
                server = new TcpListener(System.Net.IPAddress.Any,5000);
                server.Start();               
                mainTask = Task.Run(() => HandleTcpConnection(token),token);
                LBox_Chat.Items.Add("Starte Server");
            }
            else
            {
                
                btn_server_start.Text = "Server starten";
                LBox_Chat.Items.Add("Server wird gstoppt");
                isRunning = true;
                cts.Cancel();

                await Task.Run(() => server_stoppen());
            }
        }

        void HandleTcpConnection(CancellationToken token)
        {         
            LBox_Chat.Invoke(new Action(() => LBox_Chat.Items.Add("Starte Schleife")));
            
            try 
            { 
                while (true)
                {
                    
                    LBox_Chat.Invoke(new Action(() => LBox_Chat.Items.Add("Der Server ist bereit zum verbinden!")));
                    TcpClient neuerCLient  = server.AcceptTcpClient();
                    clientListe.Add(neuerCLient);

                    LBox_Chat.Invoke(new Action(() => LBox_client.Items.Add(neuerCLient.Client.RemoteEndPoint)));

                    token.ThrowIfCancellationRequested();
                }
            }
            catch (Exception)
            {
                LBox_Chat.Invoke(new Action(() => LBox_Chat.Items.Add("Schleife unterbrochen")));
            }
        }

        void server_stoppen()
        {           
            cts.Cancel();
            server.Stop();
            Thread.Sleep(50);
            mainTask.Dispose();
            mainTask = null;                                    
            cts.Dispose();
            cts = new CancellationTokenSource();
            
            LBox_Chat.Invoke(new Action(() => LBox_Chat.Items.Add("Server wurde gestoppt")));
        }

        private void btn_clients_show_Click(object sender, EventArgs e) 
        {
            LBox_client.Items.Clear();
            for (int i = 0; i < clientListe.Count; i++)
            { 
                LBox_client.Items.Add(clientListe[i].Client.RemoteEndPoint);
            } 
        }

        private void button1_Click(object sender, EventArgs e)   // für null dann den Index angeben der gekickt werden soll 
        {
            clientListe[0].Close();
            clientListe[0].Dispose();
            clientListe.RemoveAt(0);
        }
    }
}

B
BlackBirth Themenstarter:in
5 Beiträge seit 2022
vor einem Jahr

Ich würds als den Standard Wegwerf/Lern-Spaghetti-Code bezeichnen, den man halt als Anfänger so macht 😉
Aber ist schon besser und mit mehr Verständnis, als viele andere Anfänger so als erstes produzieren. Viele verzweifeln am zB Invoke-Thema etc.

So jetzt meine 3. Version, die funktioniert jetzt eigentlich sehr gut (bei Version 2 war die Clientverwaltung mist). Hab versucht deine Vorschläge umzusetzten. Hab den Code auch komplett allein geschrieben (von Copy und Paste halt nich nicht viel, weil man es besser versteht wenn man es selbs schreibt). Ist denn mein Code jetzt besser geworden? Gibt noch Verbesserungsvorschläge?

Vielen Dank schon mal für die gute Hilfe


using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Collections;

namespace Chat_Server_V3
{
 public partial class Form1 : Form
 {
#region Deklaration
 	TcpListener server;
 	List<serverClient> clientListe = new List<serverClient>();
 	bool isRunning = false;
 	CancellationTokenSource cts = new();
 	Task mainTask;
 	int aktualisieren = 0;
 	bool wdh = false;
#endregion

 	public Form1()
 	{
 		InitializeComponent();
 	}

#region Buttons
 	private async void btn_server_start_Click(object sender, EventArgs e)
 	{
 		CancellationToken token = cts.Token;
 		if (!isRunning)
 		{
 			isRunning = true;
 			lB_chat.Items.Add("Server wird gestartet!");
 			btn_server_start.Text = "Server stoppen";
 			server = new TcpListener(System.Net.IPAddress.Any, 5000);
 			server.Start();
 			 mainTask = Task.Run(() => HandleTcpConnection(token), token);		//Task für das Annehmen von Tcp-Verbindungen
 			await Task.Run(() => lb_client_aktualisieren());
 		}
 		else
 		{
 			btn_server_start.Text = "Server starten";
			isRunning = false;
 			cts.Cancel();

 			await Task.Run(() => server_stoppen());
 		}
 	}

 	private void btn_kick_Click(object sender, EventArgs e)
 	{
 		if (cB_clients.Items.Count != 0 && cB_clients.SelectedIndex >= 0)
 		{
 			int kick = cB_clients.SelectedIndex;
 			clientListe[kick].tcpClient.Close();
 			lB_chat.Items.Add(clientListe[kick].name + " wurde vom Admin gekickt!");
 		}
	}
#endregion


#region Methoden

 	private void HandleTcpConnection(CancellationToken token)		//Auf Verbindung warten und diese annehmen
 	{
 		try
 		{
 			while (!token.IsCancellationRequested)
 			{
 				if (server.Pending())
 				{
 					serverClient newClient = new serverClient();
 					newClient.tcpClient = server.AcceptTcpClient();
 					clientListe.Add(newClient);
 					newClient.ipAdresse = newClient.tcpClient.Client.RemoteEndPoint;
 					Task.Run(() => StreamStarter(newClient));
 					token.ThrowIfCancellationRequested();
 				}
 			}
 		}
 		catch (Exception)
 		{
 			lB_chat.Invoke(new Action(() => lB_chat.Items.Add("Schleife unterbrochen")));
 		}
 	}

 	void StreamStarter(serverClient verbundenerClient)
 	{
 		String sData = null;
 		verbundenerClient.streamWriter = new StreamWriter(verbundenerClient.tcpClient.GetStream(), Encoding.ASCII);
 		verbundenerClient.streamReader = new StreamReader(verbundenerClient.tcpClient.GetStream(), Encoding.ASCII);
 		verbundenerClient.openStream = true;
 		verbundenerClient.name = verbundenerClient.streamReader.ReadLine();
 		lB_chat.Invoke(new Action(() => lB_chat.Items.Add(verbundenerClient.name + " hat sich eingeloggt")));
 		wdh = true;
 		try
 		{
 			while (verbundenerClient.tcpClient.Connected)
 			{
 				sData = verbundenerClient.streamReader.ReadLine();
 				if (sData != null)              //empfangene Daten in Chat ausgeben
 				{
 					lB_chat.Invoke(new Action(() => lB_chat.Items.Add(verbundenerClient.name + ": " + sData)));
 					verbundenerClient.streamWriter.WriteLine("Du hast eine Nachricht erfolgreich gesendet");
 					verbundenerClient.streamWriter.Flush();
 				}
 				if (sData == "ende")            //ausloggen vom Client
 				{
 					lB_chat.Invoke(new Action(() => lB_chat.Items.Add(verbundenerClient.name + " hat sich ausgeloggt!")));
 					verbundenerClient.tcpClient.Close();
 					verbundenerClient.tcpClient.Dispose();
 					verbundenerClient.streamWriter.Close();
 					verbundenerClient.streamWriter.Dispose();
 					verbundenerClient.streamReader.Close();
 					verbundenerClient.streamReader.Dispose();
 					clientListe.Remove(verbundenerClient);
 					break;
 				}
 			}
 		}
 		catch (Exception)  
 		{
 			lB_chat.Invoke(new Action(() => lB_chat.Items.Add("Die Verbindung zu " + verbundenerClient.name + " wurde unterbrochen.")));
 			verbundenerClient.tcpClient.Close();
 			verbundenerClient.tcpClient.Dispose();
 			verbundenerClient.streamWriter.Close();
 			verbundenerClient.streamWriter.Dispose();
 			verbundenerClient.streamReader.Close();
 			verbundenerClient.streamReader.Dispose();
 			clientListe.Remove(verbundenerClient);   
 		}
	}


 	void server_stoppen()
 	{
 		for (int i = 0; i < clientListe.Count; i++)
 		{
 			clientListe[i].tcpClient.Close();
 			clientListe[i].tcpClient.Dispose();
 			clientListe[i].streamWriter.Close();
 			clientListe[i].streamWriter.Dispose();
 			clientListe[i].streamReader.Close();
 			clientListe[i].streamReader.Dispose();
 			clientListe.RemoveAt(i);
 		}

 		cts.Cancel();
 		server.Stop();
 		Thread.Sleep(5);
 		mainTask.Dispose();
 		mainTask = null;
 		cts.Dispose();
 		cts = new CancellationTokenSource();
 		Thread.Sleep(5);
 		lB_clients.Items.Clear();
 		lB_chat.Invoke(new Action(() => lB_chat.Items.Add("Server wurde gestoppt!")));
 	}

 	void lb_client_aktualisieren()
 	{
 		while (isRunning)
 		{
 			if (aktualisieren != clientListe.Count | wdh)
 			{
 				lB_clients.Invoke(new Action(() => lB_clients.Items.Clear()));
 				cB_clients.Invoke(new Action(() => cB_clients.Items.Clear()));
 				try
 				{
 					for (int i = 0; i < clientListe.Count; i++)
 					{
 						cB_clients.Invoke(new Action(() => cB_clients.Items.Add(clientListe[i].name)));
 						lB_clients.Invoke(new Action(() => lB_clients.Items.Add("Name: " + clientListe[i].name)));
 						lB_clients.Invoke(new Action(() => lB_clients.Items.Add("IP-Adresse: " + clientListe[i].ipAdresse.ToString())));
 						lB_clients.Invoke(new Action(() => lB_clients.Items.Add("Stream: " + clientListe[i].openStream.ToString())));
 						lB_clients.Invoke(new Action(() => lB_clients.Items.Add("-----------------")));
 					}
 				}
 				catch (Exception)
 				{
 					lB_clients.Invoke(new Action(() => lB_clients.Items.Clear()));
 					lB_clients.Invoke(new Action(() => lB_clients.Items.Add("Fehler bei der Auflistung")));
 				}
 				aktualisieren = clientListe.Count;
 				wdh = false;
 			}
 		}
 	}

#endregion

#region Klassen

 	public class serverClient
 	{
 		public string name = "";
 		public EndPoint ipAdresse;
 		public TcpClient tcpClient = new TcpClient();
 		public StreamReader streamReader = null;
 		public StreamWriter streamWriter = null;
 		public bool openStream = false;
 	}

#endregion
 }
}


4.931 Beiträge seit 2008
vor einem Jahr

Als nächsten Schritt solltest du den gesamten Server/Client Code in eine eigene Klasse auslagern, s.a. [Artikel] Drei-Schichten-Architektur (architekturtechnisch gehört dieser Code zum DAL - du kannst die (Business)Logik aber erstmal auslassen).

Statt den Invoke(...)-Aufrufen erzeugst du in dieser Klasse dann ein Ereignis (s. [FAQ] Eigenen Event definieren / Information zu Events (Ereignis/Ereignisse)) und abonnierst es dann vom UI-Code, welcher dann den Invoke() ausführt. Du müßtest dir dazu überlegen, welche Daten du dem Ereignis mitgibst (als erstes kannst du die Texte direkt übergeben, aber besser wäre ein enum-Statuscode o.ä. - denn lokalisierbare Texte gehören auch zum UI-Code).

Und statt einzelner Invoke-Aufrufe kannst/solltest du diese auch bündeln (es ist egal auf welchem UI-Element diese aufgerufen wird, meistens nimmt man daher gleich die Form, d.h. implizit this., z.B.


Invoke(new Action(() =>
    {
         lB_clients.Items.Clear();
         cB_clients.Items.Clear();
    }));

Edit: Und die Close()/Dispose()-Aufrufe der ServerClient-Member solltest du in eine eigene Methode auslagern.

16.807 Beiträge seit 2008
vor einem Jahr

Ist denn mein Code jetzt besser geworden? Gibt noch Verbesserungsvorschläge?

Viele, die Frage ist wo enden 😉
Dir gehts ja primär mal um das Verständnis, da bringts also nichts wenn ich Dir sage: lern die C# Naming Guidelines, was Du tun solltest 🙂

Daher ja:

  • Definitiv an Code Struktur arbeiten
  • teilweise unnötige Logik-Hilfen: isRunning könnte man einsparen, tut aber auch nicht weh
  • Effizienter entwickeln: clientListe in eine Variable auslagern statt dauernd über den Index zuzugreifen
  • Einiges lässt sich über andere Pattern besser lösen, aber Pattern kommen mit der Erfahrung "von allein"
B
BlackBirth Themenstarter:in
5 Beiträge seit 2022
vor einem Jahr

Vielen Dank für die Antworten und Verbesserungsvorschlägen, ich werde mich ihrer annehmen und dann mal weiter und weiter an meinen Code arbeiten ... ich denke auch das sehr viel mit der Zeit kommt, jetzt weiß ich aber in welche Richtung/Thema ich mich zuwenden muss.