Делфи - Статьи - Как научить программу общаться

Предисловие

В этой статье я расскажу о том, как мне пришлось решать достаточно простую, но вместе с тем интересную задачу. Смысл её заключался в том, чтобы обеспечить передачу файла по локальной сети на несколько машин по списку. При этом на всех компьютерах файл может располагаться в различных каталогах и пользователь компьютера, принимающего файл, не должен никоим образом участвовать в процессе. Практически стояла задача обеспечить автоматизированную рассылку обновления софта и антивирусных баз.
Решать задачу пришлось в спешке, поэтому был избран самый простой путь - использовать \"троянообразный\" сервер на машинах пользователей и клиент с простенькой базой данных, который по очереди соединяется с серверами и выполняет необходимые действия.
Результатом работы стало реальное клиент-серверное приложение, программа \"Админ-рассылка\", бета-версию которой Вы можете загрузить на сайте www.nox.in.ua. В качестве платформы для разработки был использован Borland Delphi 7, база данных - локальная с использованием библиотеки firebird - gds32.dll. В работе я использовал лучшего друга всех программистов - internet, в частности особую помощь мне оказали материалы с сайтов: www.club.shelek.com, www.fido-online.com, www.delphi.vov.ru, www.miterx.users.kemcity.ru и других.

Часть 1: Троян на службе сисадмина.

Как уже отмечалось, пользователь в результате работы программы должен принимать файл, не подозревая об этом. Следовательно, на его машине должно скрытно выполняться приложение-сервер, открывающее определённый порт для приёма. То есть, придется в наших (исключительно мирных) целях использовать трояна.
Описание технологии построения троянского приложения я пропущу, так как это подробно описано на www.miterx.users.kemcity.ru. Отмечу лишь то, что я добавил от себя. В статье, о которой я упоминал, троян тихий и послушный: он всего лишь выполняет команду. Мне же пришлось учить серверную и клиентскую часть приложения полноценному общению.
В своей работе для связи приложений я использовал компоненты ServerSocket и ClientSocket, которые реализуют асинхронный обмен данными через порт. Не слишком углубляясь в теорию протоколов, обрисую этот процесс таким образом. Приложения посылают пакет данных, и продолжают работать, независимо от того, получен их пакет или нет. При этом, даже если пакет был принят благополучно (а в нормально работающей сети это бывает ;)) нельзя быть уверенным, КОГДА его приняли. Такая особенность обмена данными требует подтверждения готовности к приёму сообщений, вроде того, как принято общаться по рации. Но обо всём по порядку.
В серверной части на форму кладём компонент ServerSocket и прописываем ему порт, который он будет слушать. Свойство Active ставим false. Обрабатываем события:

procedure TForm1.ServerSocket1ClientConnect(Sender: TObject; Socket: TCustomWinSocket); begin ServerSocket1.Socket.Connections[0].SendText(version);
old:=false;
end;


(При подключении клиента сервер шлёт ему свою версию и устанавливает глобальную переменную old:=false) Версию нужно знать для того, чтобы можно было организовать обновление серверной части на машинах пользователей. Переменная old в дальнейшем будет использоваться, чтобы решить, как относиться к файлу с тем же именем, если он уже лежит в месте назначения. Если old = false, то файл заменяем, если true, значит дописываем.

procedure TForm1.FormCreate(Sender: TObject);
begin
regwrite (Application.ExeName); // Простенькая процедура работы с реестром моего производства. ServerSocket1.Active:=true;
rez:=\'диалог\';
end;


(При создании формы приложения прописываемся в автозапуске, открываем порт и устанавливаем глобальную переменную rez). Об этой переменной расскажу подробнее. Она определяет режим, в котором работает наш сервер. В зависимости от заранее установленного режима сервер по разному относится к порции принимаемых данных. В режиме \"диалог\" данные - это команды, в режиме \"файл\" - порция данных файла и т.п. Теперь самое главное событие - получение данных от клиента. Рассмотрим эту процедуру по частям в зависимости от режима:

procedure TForm1.ServerSocket1ClientRead(Sender: TObject; Socket: TCustomWinSocket); .... if rez=\'диалог\' then begin
clTMsg:=ServerSocket1.Socket.Connections[0].ReceiveText;
if not (clTMsg=\'\') then clMsg:=StrToInt(clTMsg);
case clMsg of
1: begin
rez:=\'путь\';
ServerSocket1.Socket.Connections[0].SendText(\'1\');
end;
3: begin
rez:=\'размер\';
ServerSocket1.Socket.Connections[0].SendText(\'3\');
end;
5: begin
rez:=\'файл\';
ServerSocket1.Socket.Connections[0].SendText(\'5\');
end;
end;
end;
end;
....
end;


Эта часть процедуры выполняется тогда, когда сервер работает в режиме диалога. В строковую переменную clTMsg заносится строка, полученная от клиента. Если строка не пустая, она превращается в число (я использовал числовые команды для общения клиента с сервером). Затем сервер переключается в требуемый режим и отвечает клиенту.

....
if rez=\'путь\' then begin
path:=ServerSocket1.Socket.Connections[0].ReceiveText;
ServerSocket1.Socket.Connections[0].SendText(\'2\');
if not DirectoryExists (ExtractFilePath(path)) then ForceDirectories(ExtractFilePath(path));
rez:=\'диалог\';
end;
....


В режиме \"путь\" сервер присваивает полученную от клиента строку переменной path, затем отвечает клиенту, что путь получен успешно. Далее выполняется проверка на наличие каталога, в который нужно будет положить файл, и если каталога нет, он тут же создаётся. И, наконец, сервер переключает себя в режим диалога.

....
if rez=\'размер\' then begin
sz:=StrToInt(ServerSocket1.Socket.Connections[0].ReceiveText);
ServerSocket1.Socket.Connections[0].SendText(\'4\');
rez:=\'диалог\';
end;
....


В режиме \"размер\" полученные данные преобразуются в число и присваиваются переменной sz, которая хранит размер передаваемого файла. Дальше следует ответ серверу и возврат в режим диалога.

....
if rez=\'файл\' then begin
lr:=0;
while (lr < sz) do begin
l:=Socket.ReceiveLength;
GetMem(buf,l+1);
Socket.ReceiveBuf(buf^,l);
try
if (FileExists(path) and old) then begin
src:=TFileStream.Create(path,fmOpenReadWrite);
src.Seek(0,soFromEnd);
end
else begin
src:=TFileStream.Create(path,fmCreate);
old:=true;
end;
src.WriteBuffer(buf^,l);
lr:=lr+l;
except
FreeMem(buf);
src.Free;
sz:=0;
rez:=\'диалог\';
ServerSocket1.Socket.Connections[0].SendText(\'9\');
end;
FreeMem(buf);
src.Free;
end;
sz:=0;
ServerSocket1.Socket.Connections[0].SendText(\'6\');
rez:=\'диалог\';
end;
....

В режиме \"файл\" принимаемые сервером данные рассматриваются как части файла. Они через буфер дописываются к файлу, до тех пор, пока суммарная длина принятых данных не сравняется с ранее установленной длиной файла. Практически это реализовано следующим образом:
- переменной lr, которая хранит размер уже принятых данных, присваивается нулевое значение.
- выполняем цикл до тех пор пока lr меньше размера файла
- занимаем память под буфер, для чего получаем длину текущей порции данных (переменная l)
- читаем порцию данных из сокета в буфер (переменная buf)
Дальше пытаемся выполнить запись:
- если файл уже имеется и получаемый файл не должен его заменять (вот и пригодилась глобальная переменная old) то открываем файл для записи и ставим указатель в конец файла
- если файла ещё нет или его нужно переписать (old=false) создаём файл
- так или иначе, в полученный файловый поток пишем содержимое буфера, прибавляем размер текущего блока данных к общей длине принятого файла и повторяем цикл со следующей порцией данных.
Если запись не удалась (блок except), освобождаем память, переключаемся в режим диалога и отправляем клиенту сообщение \"9\" (\"найн\" то есть ничего не вышло;))
Если всё прошло успешно, говорим клиенту \"6\".
Вот такой общительный троян получился! Я не стал описывать второстепенные вещи, типа обработки выхода, разрыва соединения и т.п. Вместо этого лучше давайте рассмотрим второго участника \"сокетной беседы\" - программу-клиента.

Часть 2: Файловый почтальон
Данные о пользователях, которые должны получить файл я решил хранить в базе данных. База состоит из единственной таблицы USERS с полями, в которых хранится по порядку ip-адрес, имя хоста, версия сервера, локальный путь на машине пользователя, поле \"отправка\" и примечание. О том, как подключать локальную базу к программе писать не буду: это легко найти в других источниках. Базу я создавал с помощью прекрасной утилиты IBExpert, используя библиотеку gds32.dll из инсталляции firebird, для работы с полученной базой использовались компоненты из вкладки interbase. Такая схема не требует установки на компьютере сервера баз данных, что, согласитесь, очень удобно. База данных заполняется перед началом работы программы вручную. В поле \"отправка\" 1 означает, что файл успешно передан, 0 - требуется передать файл.
Первую в работе кнопочку \"Выбрать файл для рассылки\" первой и обрабатываем:

procedure TForm1.SelectFileButtonClick(Sender: TObject);
begin
if OpenDialog1.Execute then begin
filename := OpenDialog1.FileName;
SendButton.Enabled:=true;
StatusBar1.Panels.Items[0].Text:=\'Отправляем файл \'+filename;
end;
end;


Из OpenDialog-а получаем в переменную filename полное имя файла, который нужно отправлять. Включаем (выключенную по умолчанию) кнопку \"Выполнить рассылку\" и обеспечиваем интерфейс с пользователем ;) выводя в StatusBar строку с именем отправляемого файла.
Кнопочки \"Все отправлять\" и \"Все не отправлять\" в принципе обрабатываем одинаково:

procedure TForm1.Oll_0_ButtonClick(Sender: TObject);
begin
IBTable1.First;
while not IBTable1.EOF do begin
IBTable1.Edit;
IBTable1.FieldByName(\'SEND\').AsInteger:=0;
IBTable1.Post;
IBTable1.Next;
end;
end;
Проходим по таблице и в поле \"отправка\" проставляем 0 или 1 в зависимости от нажатой кнопки.
Кнопка \"Установить пути по умолчанию\" тоже не блещет интеллектом: Также происходит проход по всем записям таблицы и редактирование поля с путём размещения файла. Только строка с путём берётся из InputBox-а. Понятно, что если в вашей сети три компьютера, эта кнопка бесполезна. Но если их, как у меня, 140…
Итак, теперь, отбрасывая всё незначительное, мы вплотную приближаемся к \"главной кнопке\".
Её код:

procedure TForm1.SendButtonClick(Sender: TObject);
begin
IBTable1.First;
rez:=\'диалог\';
NextServ();
end;


Становимся в начало таблицы, устанавливаем уже знакомую нам переменную rez, и...

procedure NextServ();
label nx;
begin
srcfile := TFileStream.Create(filename,fmOpenRead);
nx:
if Form1.IBTable1.FieldByName(\'SEND\').AsInteger=0 then begin
Form1.ClientSocket1.Address:=Form1.IBTable1.FieldByName(\'IP\').AsString;
path:=Form1.IBTable1.FieldByName(\'PATH\').AsString;
Form1.StatusBar1.Panels.Items[0].Text:=\'Обрабатываем сервер \' + Form1.ClientSocket1.Address;
Form1.ClientSocket1.Open;
Form1.ClientSocket1.Socket.SendText(\'0\');
rez:=\'версия\';
end
else begin
Form1.IBTable1.Next;
if not Form1.IBTable1.Eof then goto nx;
end;
end;


Да, я знаю, что использовать goto это дурной тон. Но если надо быстро, и вообще…
Итак: читаем файл в поток, затем если в поле \"отправка\" стоит 0, устанавливаем сокету ip из базы, читаем в переменную path путь из базы, пишем в StatusBar, какой сервер в данный момент обрабатываем, подключаемся и шлём нашему дорогому троянчику нолик. Мы уже знаем, что в ответ на наш запрос сервер пошлёт свою версию, поэтому и переходим в соответствующий режим. Если сервер обработан, идём дальше.
Тут я хочу обратить внимание на одну пикантную особенность. В этой процедуре мы только запускаем обмен данными с серверами, а сам обмен будет реализован совсем в другом месте.
Вот в этом:

procedure TForm1.ClientSocket1Read(Sender: TObject; Socket: TCustomWinSocket); begin if rez=\'диалог\' then begin
servMsg:=StrToInt(ClientSocket1.Socket.ReceiveText);
case servMsg of
1: begin
StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+\' готов принимать путь...\';
sleep(800);
ClientSocket1.Socket.SendText(path+ \'\\\' +ExtractFileName(filename));
end;
2: begin
StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+ \' путь получил!\';
sleep(800);
ClientSocket1.Socket.SendText(\'3\');
end;
3: begin
StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+\' готов принимать размер...\';
sleep(800);
ClientSocket1.Socket.SendText(IntToStr(srcfile.Size));
end;
4: begin
StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+ \' размер получил!\';
sleep(800);
ClientSocket1.Socket.SendText(\'5\');
end;
5: begin
StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+ \' готов принимать файл...\';
sleep(800);
ClientSocket1.Socket.SendStream(srcfile);
end;
6: begin
StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+ \' файл получил!\';
sleep(800);
ClientSocket1.Active:=false;
IBTable1.Edit;
IBTable1.FieldByName(\'SEND\').AsInteger:=1;
IBTable1.Post;
IBTable1.Next;
NextServ();
end;
9: begin
StatusBar1.Panels.Items[0].Text:=ClientSocket1.Address+ \' сообщил об ошибке!\';
sleep(800);
ClientSocket1.Active:=false;
IBTable1.Edit;
IBTable1.FieldByName(\'SEND\').AsInteger:=9;
IBTable1.Post;
IBTable1.Next;
NextServ();
end;
end;
end;
....

Это обработка получения сообщения от сервера в случае, если клиент находится в режиме диалога. Как и в случае с трояном, разбираем сообщение через case, пишем в StatusBar перевод сообщения на человеческий язык, и если сообщение означало готовность сервера что-то принять, тут же ему это и отправляем. Если сервер своим сообщением подтверждает получение, мы не даём ему расслабляться, и шлём команду приготовиться (т.е. перейти в соответствующий режим) к приёму следующей информации. Из листинга вполне понятно, что за чем отправляем. А sleep(800) нужно, как вы уже, наверное догадались, для того, чтобы пользователь успевал читать в StatusBar-е.
Особое внимание уделяется двум сообщениям сервера: 6 и 9. При получении шестёрки отключаемся от сервера, пишем в базу единичку (типа файл отправлен), и запускаем уже знакомую нам процедуру NextServ уже для следующего сервера в списке. Почти также реагируем на девятку, только в базе возникнет не благополучная \"1\" а тревожная \"9\", сообщающая о том, что с сервером что-то не то.
Есть у нас ещё один режим. В нём мы принимаем версию сервера и заносим её в базу:

....
if rez=\'версия\' then begin
serVer:=ClientSocket1.Socket.ReceiveText;
StatusBar1.Panels.Items[0].Text:=\'Получен ответ от \'+ClientSocket1.Address+\'; версия сервера \'+ serVer;
IBTable1.Edit;
IBTable1.FieldByName(\'VERSION\').AsString:=serVer;
IBTable1.Post;
sleep(800);
ClientSocket1.Socket.SendText(\'1\');
rez:=\'диалог\';
end;


Тут, как можно без труда догадаться, переменная serVer хранит эту самую версию. После сообщения о версии и записывания её в базе, отсылаем серверу единицу и переходим в режим диалога, который мы уже разобрали выше.
И последнее: ошибка может возникнуть, если сервер вообще недоступен по какой-либо причине. Обрабатываем это так:

procedure TForm1.ClientSocket1Error(Sender: TObject; Socket: TCustomWinSocket;
ErrorEvent: TErrorEvent; var Errorpre: Integer);
begin
Errorpre:= 0; Form1.StatusBar1.Panels.Items[0].Text:=\'Подключиться к \'+Form1.ClientSocket1.Address+\' не удалось!\'; Form1.IBTable1.Edit; Form1.IBTable1.FieldByName(\'SEND\').AsInteger:=0;
Form1.IBTable1.Post;
Form1.IBTable1.Next;
NextServ();
end;


Тут избавляемся от неприятных сообщений об ошибке (Errorpre:= 0) и заменяем их культурной записью в StatusBar. Потом устанавливаем ноль в таблицу и переходим к обработке следующего сервера.
Вот, собственно, и вся программа. За скобками оставим подключение к базе при запуске и отключение при выходе, прочие мелочи. Главное, что я хотел проиллюстрировать на этом примере - использовать асинхронные сокеты можно не только для посылки односторонних команд. Достаточно простой приём, использованный мной, можно существенно оптимизировать, выделить и сделать методом класса. Я уже молчу о том, что обрабатывать сервера один за другим совсем не обязательно: стоит только разнести сервера на разных машинах по разным номерам портов - и вот вам одновременная беседа со всеми компьютерами в списке. Есть другие идеи? Великолепно. Я тут как раз планирую писать следующую версию, и у меня много места в списке авторов ;)

Copyright © 2006-09.