Pisanie oprogramowania z funkcjonalnością klient-serwer dla systemu Windows, część 02

Kontynuując cykl artykułów poświęconych niestandardowym realizacjom konsolowych narzędzi Windows, nie sposób nie wspomnieć o TFTP (Trivial File Transfer Protocol) — prostym protokole transferu plików.

Jak i ostatnim razem, krótko przejrzymy teorię, zaprezentujemy kod realizujący funkcjonalność analogiczną do wymaganej i przeanalizujemy go. Szczegóły poniżej.

Nie będę kopiować informacji pomocniczych, których linki tradycyjnie można znaleźć na końcu artykułu, powiem jedynie, że w swojej istocie TFTP to uproszczona wersja protokołu FTP, z której usunięto konfigurację kontroli dostępu, a tak naprawdę nie ma tu nic poza poleceniami przesyłania i odbierania plików. Jednak, aby uczynić naszą realizację nieco bardziej elegancką i dostosowaną do obecnych zasad pisania kodu, składnia została nieco zmieniona — zasady działania się nie zmieniają, ale interfejs, IMHO, staje się nieco bardziej logiczny i łączy w sobie pozytywne strony FTP i TFTP.

W szczególności, podczas uruchamiania klient prosi o adres IP serwera i port, na którym działa niestandardowe TFTP (z powodu niekompatybilności ze standardowym protokołem uznałem za stosowne pozostawić użytkownikowi możliwość wyboru portu), po czym następuje połączenie, w wyniku którego klient może wysłać jedno z poleceń — get lub put, w celu otrzymania lub wysłania pliku na serwer. Wszystkie pliki są przesyłane w trybie binarnym — w celu uproszczenia logiki.

Do realizacji protokołu wykorzystano tradycyjnie 4 klasy:

  • TFTPClient
  • TFTPServer
  • TFTPClientTester
  • TFTPServerTester

Ponieważ klasy testujące istnieją tylko dla debugowania podstawowych, nie będę ich omawiać, ale kod będzie dostępny w repozytorium, z linkiem do którego można zapoznać się na końcu artykułu. A teraz przeanalizuję główne klasy.

TFTPClient

Zadaniem tej klasy jest połączenie z zdalnym serwerem poprzez jego adres IP i numer portu, odczytanie z wejściowego strumienia (w tym przypadku z klawiatury) polecenia, jego parsowanie, przekazanie do serwera, a w zależności od tego, czy wymagana jest transmisja czy odbiór pliku, przekazanie go lub jego odbiór.

Kod uruchamiający klienta do połączenia z serwerem i oczekiwania na polecenie z strumienia wejściowego wygląda tak. Szereg globalnych zmiennych, które są tu używane, opisano poza artykułem, w pełnym tekście programu. Z powodu ich trywialności nie przytaczam ich, aby nie obciążać artykułu.

 public void run(String ip, int port)
    {
        this.ip = ip;
        this.port = port;
        try {
            inicialization();
            Scanner keyboard = new Scanner(System.in);
            while (isRunning) {
                getAndParseInput(keyboard);
                sendCommand();
                selector();
                }
            }
        catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }

Przejdźmy przez metody wywoływane w tym bloku kodu:

Tutaj następuje wysyłka pliku — za pomocą skanera przedstawiamy zawartość pliku jako tablicę bajtów, które kolejno zapisujemy w gnieździe, następnie zamykamy je i otwieramy ponownie (nie jest to najoczywistsze rozwiązanie, ale gwarantuje zwolnienie zasobów), po czym wyświetlamy na ekranie komunikat o udanym przesyłaniu.

private void put(String sourcePath, String destPath)
    {

        File src = new File(sourcePath);
        try {

            InputStream scanner = new FileInputStream(src);
            byte[] bytes = scanner.readAllBytes();
            for (byte b : bytes)
                sout.write(b);
            sout.close();
            inicialization();
            System.out.println("nDonen");
            }

        catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }

Ten fragment kodu opisuje otrzymywanie danych z serwera. Wszystko znowu jest trywialne, interesujący jest jedynie pierwszy blok kodu. Aby zrozumieć, ile dokładnie bajtów należy odczytać z gniazda, trzeba wiedzieć, ile waży przesyłany plik. Rozmiar pliku na serwerze przedstawiany jest jako długie całkowite, dlatego tutaj przyjmujemy 4 bajty, które następnie konwertowane są na jedną liczbę. To nie jest zbyt javanek podejście, bardziej przypomina C, ale spełnia swoją funkcję.

Dalej wszystko jest trywialne — otrzymujemy określoną liczbę bajtów z gniazda i zapisujemy je w pliku, po czym wyświetlamy komunikat o sukcesie.

   private void get(String sourcePath, String destPath){
        long sizeOfFile = 0;
        try {


            byte[] sizeBytes = new byte[Long.SIZE];
           for (int i =0; i < Long.SIZE/Byte.SIZE; i++)
           {
               sizeBytes[i] = (byte)sin.read();
               sizeOfFile*=256;
               sizeOfFile+=sizeBytes[i];
           }

           FileOutputStream writer = new FileOutputStream(new File(destPath));
           for (int i =0; i < sizeOfFile; i++)
           {
               writer.write(sin.read());
           }
           writer.close();
           System.out.println("nDONEn");
       }
       catch (Exception e){
            System.out.println(e.getMessage());
       }
    }

W przypadku, gdy w oknie klienta wprowadzono polecenie inne niż get lub put, zostanie wywołana funkcja showErrorMessage, która wskaże na błąd w inputcie. Z powodu banalności – nie podaję. Nieco ciekawsza jest funkcja odbioru i podziału wejściowego ciągu. Przekazujemy do niej skaner, od którego oczekujemy, że zwróci ciąg oddzielony dwoma spacjami, zawierający polecenie, ścieżkę źródłową i docelową.

    private void getAndParseInput(Scanner scanner)
    {
        try {

            input = scanner.nextLine().split(" ");
            typeOfCommand = input[0];
            sourcePath = input[1];
            destPath = input[2];
        }
        catch (Exception e) {
            System.out.println("Zły input");
        }
    }

Wysyłanie polecenia – przekazywanie wprowadzonego z skanera polecenia do gniazda i wymuszone jego wysłanie.

    private void sendCommand()
    {
        try {

            for (String str : input) {
                for (char ch : str.toCharArray()) {
                    sout.write(ch);
                }
                sout.write(' ');
            }
            sout.write('n');
        }
        catch (Exception e) {
            System.out.print(e.getMessage());
        }
    }

Selektor – funkcja, która określa działania programu w zależności od wprowadzonego ciągu. Tutaj nie jest to zbyt estetyczne, ponieważ zastosowano nie najlepszą metodę z wymuszonym wyjściem poza blok kodu. Głównym powodem jest brak niektórych elementów w Javie, takich jak delegaty w C#, wskaźniki funkcji z C++ czy choćby straszne i okropne goto, które umożliwiają estetyczne rozwiązanie. Jeśli wiecie, jak zrobić kod nieco bardziej eleganckim – czekam na krytykę w komentarzach. Wydaje mi się, że potrzebny jest słownik String-delegate, ale nie ma delegata...

    private void selector()
    {
        do{
            if (typeOfCommand.equals("get")){
                get(sourcePath, destPath);
                break;
            }
            if (typeOfCommand.equals("put")){
                put(sourcePath, destPath);
                break;
            }
            showErrorMessage();
        }
        while (false);
    }
}

TFTPServer

Funkcjonalność serwera różni się od funkcjonalności klienta przede wszystkim tym, że polecenia przychodzą do niego nie z klawiatury, lecz z gniazda. Część metod wręcz się pokrywa, więc ich nie podam, a jedynie poruszę różnice.

Do uruchomienia tutaj używany jest metod run, który przyjmuje jako argument port i w pętli nieskończonej przetwarza dane wejściowe z gniazda.

    public void run(int port) {
            this.port = port;
            incialization();
            while (true) {
                getAndParseInput();
                selector();
            }
    }

Metoda put, będąca wrapperem dla metody writeToFileFromSocket, otwiera strumień zapisu do pliku i zapisuje wszystkie bajty wejścia z gniazda, po zakończeniu zapisu wyświetla komunikat o pomyślnym zakończeniu transferu.

    private void put(String source, String dest){
            writeToFileFromSocket();
            System.out.print("nDonen");
    };
    private void writeToFileFromSocket()
    {
        try {
            FileOutputStream writer = new FileOutputStream(new File(destPath));
            byte[] bytes = sin.readAllBytes();
            for (byte b : bytes) {
                writer.write(b);
            }
            writer.close();
        }
        catch (Exception e){
            System.out.println(e.getMessage());
        }
    }

Metoda get umożliwia pobieranie pliku z serwera. Jak już wspomniano w sekcji dotyczącej klienta programu, do pomyślnego transferu pliku trzeba znać jego rozmiar, który jest przechowywany jako długo całkowita, dlatego dzielę go na tablicę 4 bajtów, przesyłam je bajt po bajcie do gniazda, a następnie, po otrzymaniu i zebraniu ich na kliencie z powrotem w liczbę, przesyłam wszystkie bajty, które tworzą plik, odczytane ze strumienia wejściowego z pliku.


 private void get(String source, String dest){
        File sending = new File(source);
        try {
            FileInputStream readFromFile = new FileInputStream(sending);
            byte[] arr = readFromFile.readAllBytes();
            byte[] bytes = ByteBuffer.allocate(Long.SIZE / Byte.SIZE).putLong(sending.length()).array();
            for (int i = 0; i<Long.SIZE / Byte.SIZE; i++)
                sout.write(bytes[i]);
            sout.flush();
            for (byte b : arr)
                sout.write(b);
        }
        catch (Exception e){
            System.out.println(e.getMessage());
        }
    };

Metoda getAndParseInput jest zgodna z odpowiednikiem po stronie klienta, z tą różnicą, że odczytuje dane z gniazda, a nie z klawiatury. Kod w repozytorium, jak i selector.
W tym przypadku inicjalizacja została wydzielona do oddzielnego bloku kodu, ponieważ w ramach tej implementacji po zakończeniu transferu zasoby są zwalniane i zajmowane na nowo — ponownie w celu zapewnienia ochrony przed wyciekiem pamięci.

    private void incialization()
    {
        try {
            serverSocket = new ServerSocket(port);
            socket = serverSocket.accept();
            sin = socket.getInputStream();
            sout = socket.getOutputStream();
        }
        catch (Exception e) {
            System.out.print(e.getMessage());
        }
    }

Podsumowując:

Właśnie napisaliśmy swoją wariację na temat prostego protokołu transferu danych i zrozumieliśmy, jak powinien on działać. W zasadzie, nie odkryłem tu Ameryki ani nie napisałem nic szczególnie nowego, ale – podobnych artykułów na Habré nie było, a w ramach pisania cyklu artykułów o narzędziach cmd nie można było go nie poruszyć.

Linki:

Repozytorium z kodem źródłowym
Krótko o TFTP
To samo, ale po rosyjsku

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster