Продължавайки започнатия цикъл от статии, посветени на кастомните реализации на консолни утилити за Windows, не мога да не спомена TFTP (Trivial File Transfer Protocol) — прост протокол за пренос на файлове.
Както и миналия път, бързо ще прегледаме теорията, ще видим код, който реализира функционалност, сходна с желаната, и ще я анализираме. Подробности след кат.
Няма да копирам информация от справочници, линкове към които традиционно можете да намерите в края на статията, но ще кажа, че по същество TFTP е опростена версия на FTP протокола, в която е премахната настройката за контрол на достъпа, а всъщност тук няма нищо освен команди за получаване и предаване на файл. Въпреки това, за да направя нашата реализация малко по-елегантна и адаптирана към настоящите принципи за писане на код, синтаксисът е малко променен — принципите на работа не се променят, но интерфейсът, ИМХО, става малко по-логичен и комбинира положителните страни на FTP и TFTP.
В частност, при стартиране клиентът запитва IP адреса на сървъра и порта, на който е отворен кастомният TFTP (поради несъвместимост със стандартния протокол, счеох за уместно да оставя на потребителя възможността да избира порта), след което се осъществява свързване, в резултат на което клиентът може да изпрати една от командите — get или put, за получаване или изпращане на файл на сървъра. Всички файлове се изпращат в бинарен режим — за опростяване на логиката.
За реализиране на протокола използвах традиционно 4 класа:
- TFTPClient
- TFTPServer
- TFTPClientTester
- TFTPServerTester
Тъй като тестовите класове съществуват само за отстраняване на грешки в основните, няма да ги разглеждам, но кодът ще бъде в репозитория, с линк към него можете да се запознаете в края на статията. А сега ще разгледам основните класове.
TFTPClient
Задачата на този клас е да се свърже с отдалечен сървър по неговия IP и номер на порта, да прочете команда от входния поток (в този случай — клавиатурата), да я парсира, да я предаде на сървъра и, в зависимост от това, дали се изисква предаване или получаване на файл, да го предаде или получи.
Кодът за иницииране на клиента за свързване със сървъра и изчакване на команда от входния поток изглежда така. Няколко глобални променливи, използвани тук, са описани извън статията, в пълния текст на програмата. Поради тривиалността им не ги посочвам, за да не натоварвам статията.
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());
}
}Нека прегледаме методите, извиквани в този блок код:
Тук се изпраща файл — с помощта на скенера представяме съдържанието на файла като масив от байтове, които последователно записваме в сокета, след което го затваряме и отваряме отново (не е най-очевидното решение, но гарантира освобождаване на ресурсите), след което показваме на екрана съобщение за успешно предаване.
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 байта, които впоследствие се конвертират в едно число. Това не е много Java подход, по-скоро прилича на СИ, но решава своята задача.
Нататък всичко е тривиално — получаваме известно число байтове от сокета и ги записваме в файл, след което показваме на екрана съобщение за успех.
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());
}
}Резюмирайки:
Току-що написахме своята версия на простия протокол за предаване на данни и разбрахме как трябва да работи. В принцип, не открих Америка и не написах нещо особено ново, но — аналогични статии на Хабра нямаше, а в рамките на написването на цикъл от статии за командни утилити не можехме да не го засегнем.
Линкове:
Източник: habr.com
