Разработка на софтуер с функционалност на клиент-сървърни утилити за Windows, част 02

Продължавайки започнатия цикъл от статии, посветени на прcustom реализации на конзолни утилити за Windows, не можем да пропуснем TFTP (Trivial File Transfer Protocol) — прост протокол за предаване на файлове.

Както и преди, ще прегледаме теорията, ще видим кода, реализиращ функционалност, подобна на изискуемата, и ще я анализираме. Повече подробности - по-долу.

Няма да копи-пействам справочна информация, на която традиционно може да се намерят линкове в края на статията, само ще кажа, че по своята същност TFTP е опростена версия на протокола FTP, от който е премахната настройката за контрол на достъпа, и в същността си тук няма нищо освен команди за получаване и предаване на файл. Въпреки това, за да направим нашата реализация малко по-елегантна и адаптирана към настоящите принципи на програмиране, синтаксисът е малко променен - принципите на работа не се променят, но интерфейсът, ИМХО, става малко по-логичен и съчетава положителните страни на FTP и TFTP.

По-конкретно, при стартиране клиентът запитва IP адреса на сървъра и порта, на който е отворен кастомният TFTP (заради несъвместимостта със стандартния протокол сметнах за уместно да оставя възможност за избор на порт на потребителя), след което извършва свързването, в резултат на което клиентът може да изпрати една от командите - get или put, за получаване или изпращане на файл на сървъра. Всички файлове се изпращат в бинарен режим - с цел опростяване на логиката.

За реализацията на протокола използвах традиционно 4 класа:

  • TFTPClient
  • TFTPServer
  • TFTPClientTester
  • TFTPServerTester

Поради факта, че класовете за тестване съществуват само за отстраняване на грешки в основните, няма да ги разглеждам, но кодът ще бъде наличен в репозитория, с линк към него в края на статията. Основните класове сега ще разгледам.

TFTPClient

Целта на този клас е да се свърже с отдалечен сървър чрез неговия IP и номер на порта, да прочете от входния поток (в този случай - клавиатурата) команда, да я парсира, да я предаде на сървъра, и в зависимост от това дали е необходимо предаване или получаване на файл, да го предаде или получи.

Кодът за стартиране на клиента за свързване със сървъра и изчакване на команда от входния поток изглежда така. Редица глобални променливи, които тук се използват, се описват извън статията, в целия текст на програмата. Поради тривиалността им не ги представям, за да не претоварвам статията.

 публичен void run(String ip, int port)
    {
        this.ip = ip;
        this.port = port;
        try {
            inicialization();
            Scanner keyboard = new Scanner(System.in);
            докато (isRunning) {
                getAndParseInput(keyboard);
                sendCommand();
                selector();
                }
            }
        catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }

Нека разгледаме методите, извиквани в този блок код:

Тук се извършва изпращането на файл — с помощта на скенера представяме съдържанието на файла като масив от байтове, които последователно записваме в сокета, след което го затваряме и отваряме отново (не е най-очевидното решение, но то гарантира освобождаването на ресурсите), след което извеждаме на екрана съобщение за успешна предаване.

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());
        }
    }

Този фрагмент от кода описва получаването на данни от сървъра. Всичко отново е тривиално, интерес представлява само първият блок от кода. За да разберем колко точно байта трябва да прочетем от сокета, трябва да знаем колко тежи предаваният файл. Размерът на файла на сървъра се представя като дълго цяло число, така че тук се приема 4 байта, които по-късно се конвертират в едно число. Това не е много Джавен подход, по-скоро е подобно на С, но решава задачата си.

Следва всичко да е тривиално — получаваме известно число байтове от сокета и ги записваме във файл, след което извеждаме съобщение за успех.

   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());
       }
    }

Ако в клиентското поле е въведена команда, различна от get или put, ще бъде извикана функцията showErrorMessage, която ще покаже некоректност на входа. Поради тривиалността — не я привеждам. По-интересна е функцията за получаване и разбиване на входната строка. В нея предаваме скенера, от който очакваме да получим строка, разделена с два интервала и съдържаща команда, източников адрес и целеви адрес.

    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("Лош вход");
        }
    }

Изпращане на команда — предаване на въведената със скенера команда към сокета и принудително изпращане на нея.

    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());
        }
    }

Селектор — функция, която определя действията на програмата в зависимост от въведената строка. Тук не е много красиво и се използва не най-добрият подход с принудително излизане от блока с код, но основната причина за това е липсата на някои елементи в Java, като делегати в C#, указатели в C++ или поне ужасния и страшен goto, които позволяват красиво реализиране на това. Ако знаете как да направите кода малко по-елегантен — очаквам критика в коментарите. Смятам, че тук е нужен речник String-delegate, но делегатите ги няма...

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

TFTPServer

Функционалността на сървъра се отличава от функционалността на клиента по същество само по това, че командите към него не идват от клавиатурата, а от сокета. Част от методите изцяло съвпадат, затова няма да ги привеждам, ще се спра единствено на различията.

За стартиране тук се използва методът run, който получава порта и обработва входните данни от сокета в безкраен цикъл.

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

Метод put, който е обвивка на метода writeToFileFromSocket, отваря поток за запис в файл и записва всички байтове от въвеждането със сокета. След приключване на записа, той извежда съобщение за успешно приключване на трансфера.

    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());
        }
    }

Метод get осигурява получаването на файл от сървъра. Както беше споменато в раздела за клиентската страна на програмата, за успешна трансфера на файла е необходимо да се познава размерът му, съхраняван в дълго цяло число. Поради това, аз го разделям на масив от 4 байта и ги предавам по един по един в сокета, а след това, получавайки и събирайки ги отново на клиента в число, предавам всички байтове, съставляващи файла, прочетени от входния поток.


 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());
        }
    };

Метод getAndParseInput е идентичен на аналогичния в клиента, с единствената разлика, че той прочита данни от сокета, а не от клавиатурата. Кодът в репозиторията е същият, както и селекторът.
В този случай инициализацията е отделена в самостоятелен кодов блок, тъй като в рамките на тази реализация след приключване на предаването ресурсите се освобождават и отново се заемат — отново с цел да се осигури защита от изтичане на паметта.

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

Резюме:

Току-що написахме своя вариация по темата за прост протокол за предаване на данни и разбрахме как трябва да работи. В принцип, не открих Америка и не написах нещо особено ново, но подобни статии на Хабра не бяха налични, а в рамките на написването на цикъл статии за cmd инструменти, не можеше да не го засегнем.

Връзки:

Репозиторий с изходен код
Кратко за TFTP
Същото, но на руски

Източник: habr.com

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster