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:
Źródło: habr.com
