Статья: как научить программу общаться
Предисловие В этой статье я расскажу о том, как мне пришлось решать достаточно простую, но вместе с тем интересную задачу. Смысл её заключался в том, чтобы обеспечить передачу файла по локальной сети на несколько машин по списку. При этом на всех компьютерах файл может располагаться в различных каталогах и пользователь компьютера, принимающего файл, не должен никоим образом участвовать в процессе. Практически стояла задача обеспечить автоматизированную рассылку обновления софта и антивирусных баз. Решать задачу пришлось в спешке, поэтому был избран самый простой путь - использовать "троянообразный" сервер на машинах пользователей и клиент с простенькой базой данных, который по очереди соединяется с серверами и выполняет необходимые действия. Результатом работы стало реальное клиент-серверное приложение, программа "Админ-рассылка", бета-версию которой Вы можете загрузить на сайте 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. Потом устанавливаем ноль в таблицу и переходим к обработке следующего сервера. Вот, собственно, и вся программа. За скобками оставим подключение к базе при запуске и отключение при выходе, прочие мелочи. Главное, что я хотел проиллюстрировать на этом примере - использовать асинхронные сокеты можно не только для посылки односторонних команд. Достаточно простой приём, использованный мной, можно существенно оптимизировать, выделить и сделать методом класса. Я уже молчу о том, что обрабатывать сервера один за другим совсем не обязательно: стоит только разнести сервера на разных машинах по разным номерам портов - и вот вам одновременная беседа со всеми компьютерами в списке. Есть другие идеи? Великолепно. Я тут как раз планирую писать следующую версию, и у меня много места в списке авторов ;)
Страница сайта http://www.silicontaiga.ru
Оригинал находится по адресу http://www.silicontaiga.ru/home.asp?artId=5015
|