Мастера DELPHI, Delphi programming community Рейтинг@Mail.ru Титульная страница Поиск, карта сайта Написать письмо 
| Новости |
Новости сайта
Поиск |
Поиск по лучшим сайтам о Delphi
FAQ |
Огромная база часто задаваемых вопросов и, конечно же, ответы к ним ;)
Статьи |
Подборка статей на самые разные темы. Все о DELPHI
Книги |
Новинки книжного рынка
Новости VCL
Обзор свежих компонент со всего мира, по-русски!
|
| Форумы
Здесь вы можете задать свой вопрос и наверняка получите ответ
| ЧАТ |
Место для общения :)
Орешник |
Коллекция курьезных вопросов из форумов
KOL и MCK |
KOL и MCK - Компактные программы на Delphi
 
http://horn.hk/ отзывы о авточехлы.

Что такое Named Pipes и как с ними бороться.

Игорь Шевченко ©

  Именованные каналы (Named Pipes) - это объекты ядра, являющиеся средством межпроцессной коммуникации между сервером канала и одним или несколькими клиентами канала.

  Сервером канала называется процесс, создающий именованный канал.

  Клиентом канала называется процесс, подключающийся к созданному именованному каналу.

  От других аналогичных объектов именованные каналы отличает гарантированная доставка сообщений, возможность асинхронного ввода/вывода, возможность коммуникации между процессами на разных компьютерах в локальной вычислительной сети и относительная простота использования.

  По своему назначению они похожи на каналы операционной системы UNIX.

  При создании именованного канала ему назначается уникальное имя, определяется максимальное количество одновременных соединений с клиентами канала и режим работы канала (должен ли канал быть односторонним или двусторонним (дуплексным), ведется ли передача пакетами или потоком байтов. При передаче пакетами данные одной операции записи отделяются в буфере канала от данных другой операции записи).

  Базовым объектом для реализации именованных каналов служит объект "файл", поэтому для посылки и приема сообщений по именованным каналам используются те же самые функции Windows API, что и при работы с файлами (ReadFile, WriteFile).

  Для каждого процесса-клиента канала создается свой экземпляр канала, с собственными буферами и дескрипторами (handles) и с собственным механизмом передачи данных, не влияющим на остальные экземпляры.

  Экземпляры одного канала имеют общее имя, указанное при создании, сервер назначает имя канала в соответствии с универсальными правилами именования (Universal Naming Convention, UNC), которые обеспечивают независимый от протоколов способ идентификации каналов в Windows-сетях [1].

  Именованные каналы, также как и файлы, наследуют стандартную защиту объектов Windows, что позволяет разграничить участников коммуникации и обеспечить запрет несанкционированного доступа к каналу.

  Создание именованных каналов возможно только в NT-системах, подключение к созданному каналу возможно как в NT-системах, так и в Win9x. Кроме того, API работы с каналами в Win9x не поддерживает асинхронных операций ввода/вывода.

  Именованные каналы широко используются внутри самой системы. Например, взаимодействие менеджера сервисов с самими сервисами осуществляется через несколько именованных каналов. Для связи с сервисами RunAs, с планировщиком событий и с сервером локальной аутентификации также используются именованные каналы.

  Именованные каналы являются наиболее простым способом организации связи между сервисами и пользовательскими приложениями, нуждающимися в такой связи.

  Одним из полезных (и довольно уникальных) свойств именованного канала является возможность сервера заменять права своей учетной записи правами учетной записи клиента, соединившегося с каналом. Эта возможность служит преимущественно для ограничения прав сервера при выполнении операций доступа к различным объектам системы.

  Для работы с именованными каналами Windows API предоставляет следующие функции:

CreateNamedPipe Создание именованного канала или нового экземпляра канала. Функция доступна только серверу.
ConnectNamedPipe или CreateFile Подключение к экземпляру именованного канала со стороны клиента. Функция доступна только клиенту.
WaitNamedPipe Ожидание клиентом появления свободного экземпляра именованного канала для подключения к нему.
ConnectNamedPipe Ожидание сервером подключения клиента к экземпляру именованного канала.
ReadFile, ReadFileEx Чтение данных из именованного канала. Функция доступна как клиенту, так и серверу.
WriteFile, WriteFileEx Запись данных в именованный канал. Функция доступна как клиенту, так и серверу.
PeekNamedPipe Чтение данных из именованного канала без удаления прочитанных данных из буфера канала. Функция доступна как клиенту, так и серверу.
TransactNamedPipe Запись и чтение из именованного канала одной операцией. Функция доступна как клиенту, так и серверу.
DisconnectNamedPipe Отсоединение сервера от экземпляра именованного канала.
GetNamedPipeInfo Получение информации об именованном канале.
GetNamedPipeHandleStateПолучение текущего режима работы именованного канала и количества созданных экземпляров канала.
SetNamedPipeHandleState Установка текущего режима работы именованного канала.
CloseHandle Закрытие дескриптора экземпляра именованного канала, освобождение связанных с объектом ресурсов.
FlushFileBuffers Сброс данных из кэша в буфер канала.

  Рассмотрим алгоритм работы сервера двустороннего именованного канала, обслуживающего одновременно нескольких клиентов с использованием асинхронного ввода-вывода. Работа данного сервера заключается в пересылке сообщений, полученных от каждого клиента всем клиентам, подключенным к каналу (некий аналог чата).

  После инициализации, процесс сервера запускает поток, обслуживающий клиентское соединение. Поток создает именованный канал, используя функцию CreateNamedPipe, указывает максимальное количество экземпляров канала (равное максимально возможному количеству одновременно обслуживаемых клиентов) и режимы работы канала, одновременно с созданием канала создается первый его экземпляр.

  Затем поток сервера, обслуживающий этот экземпляр именованного канала, вызывает функцию ConnectNamedPipe для ожидания клиентского подключения.

  После того, как клиент подключился к экземпляру именованного канала, сервер инициализирует структуры для обмена данными с клиентом, читает из канала имя подсоединившегося клиента, регистрирует его и запускает копию этого же потока для обслуживания следующего клиентского подключения. Далее, поток обслуживания экземпляра канала входит в цикл чтения канала и рассылки прочитанных сообщений всем подключенным клиентам до тех пор, пока соединение с клиентом не разорвется.

  После разрыва соединения поток отключается от экземпляра канала, чтобы освободить ресурсы, выделенные системой под обслуживание этого экземпляра, и завершает свою работу.

  Последующие потоки при вызове функции CreateNamedPipe создают дополнительные экземпляры канала, при этом параметры, задающие режимы работы канала функцией игнорируются.

  Алгоритм работы клиента следующий: После запуска клиент запрашивает у пользователя имя сервера и свое имя для идентификации, подключается к экземпляру канала вызовом функции CreateFile с указанием имени канала, регистрируется на сервере с помощью посылки в канал своего имени и запускает поток асинхронного чтения сообщений из канала. Основной поток принимает сообщения пользователя и передает их в канал серверу. Поток чтения работает до тех пор, пока не разорвется соединение с сервером, или пока пользователь не завершит клиентский процесс.

  Рассмотрим реализацию потока сервера, обслуживающего экземпляр именованного канала:

const
  MAX_PIPE_INSTANCES = 100;
  NAME_SIZE = 25;
  LINE_LEN = 80;

{ Описание клиента, подключенного к каналу }
type
  WRTHANDLE = packed record
    hPipe: THANDLE;
    hEvent: THANDLE;
    overLap: OVERLAPPED;
    Live: LongBool;
    Name: array[0..NAME_SIZE] of WideChar;
  end;

var
  ClientCount: Integer = 0;
  Clients: array[1..MAX_PIPE_INSTANCES] of WRTHANDLE;
  Wnd: HWND;

procedure ServerProc (Param: Pointer); stdcall;
type
  PHWND = ^HWND;
const
  IN_BUF_SIZE = 1000;
  OUT_BUF_SIZE = 1000;
  TIME_OUT = 0;
  MAX_READ = 1000*Sizeof(WideChar);
var
  WindowHandle: HWND;
  Dummy: ULONG;
  hPipe: THANDLE;
  inBuf: array[0..IN_BUF_SIZE] of WideChar;  // Буфер чтения.
  bytesRead: DWORD;
  bytesTransRd: DWORD;
  rc: Boolean;
  ClientIndex: Integer;
  LastError: DWORD;
  ExitLoop: Boolean;

  OverLapWrt: OVERLAPPED;
  hEventWrt: THANDLE;
  OverLapRd: OVERLAPPED;
  hEventRd: THANDLE;
  pSD: PSECURITY_DESCRIPTOR;
  sa: SECURITY_ATTRIBUTES;
begin
  WindowHandle := PHWND(Param)^;
  inBuf[0] := #0;
  ExitLoop := false;
  lastError := 0;
  // Создать пустой дескриптор безопасности, позволяющий всем писать в канал.
  // Предупреждение: Указание nil в качестве последнего параметра функции
  // CreateNamedPipe() означает, что все клиенты, подсоединившиеся к каналу
  // будут иметь те же атрибуты безопасности, что и пользователь, чья учетная
  // запись использовалась при создании серверной стороны канала.
  pSD := PSECURITY_DESCRIPTOR(LocalAlloc(LPTR, SECURITY_DESCRIPTOR_MIN_LENGTH));
  if not Assigned(pSD) then begin
    MessageBoxW(WindowHandle, 'Error allocation memory for SD',
      'Debug: ServerProc()', MB_OK);
    Exit;
  end;
  if not InitializeSecurityDescriptor (pSD,
     SECURITY_DESCRIPTOR_REVISION) then begin
    ShowLastErrorMessage(WindowHandle,
      'Debug: ServerProc(): InitializeSecurityDescriptor');
    LocalFree(HLOCAL(pSD));
    Exit;
  end;
  // Добавить NULL ACL к дескриптору безопасности
  if not SetSecurityDescriptorDacl(pSD, true, nil, false) then begin
    ShowLastErrorMessage(WindowHandle,
      'Debug: ServerProc():SetSecurityDescriptorDacl');
    LocalFree(HLOCAL(pSD));
    Exit;
  end;
  sa.nLength := sizeof(sa);
  sa.lpSecurityDescriptor := pSD;
  sa.bInheritHandle := true;
  // Создать серверную часть канала на локальной машине
  hPipe := CreateNamedPipeW ('\\.\PIPE\test', // Имя канала = 'test'.
    PIPE_ACCESS_DUPLEX or      // Двусторонний канал
    FILE_FLAG_OVERLAPPED,      // Асинхронный ввод-вывод
    PIPE_WAIT or               // Ожидать сообщений
    PIPE_READMODE_MESSAGE or   // Обмен в канале производится пакетами
    PIPE_TYPE_MESSAGE,
    MAX_PIPE_INSTANCES,        // Максимальное числе экземпляров канала.
    OUT_BUF_SIZE*SizeOf(WideChar), // Размеры буферов чтения/записи.
    IN_BUF_SIZE*SizeOf(WideChar),
    TIME_OUT,                  // Тайм-аут.
    @sa);                      // Атрибуты безопасности.
  if hPipe = INVALID_HANDLE_VALUE then begin
    ShowLastErrorMessage(WindowHandle, 'Debug: ServerProc():CreateNamedPipeW');
    Exit;
  end;
  // Ожидаем подключения клиента.
  ConnectNamedPipe(hPipe, nil);
  // Создаем событие ожидания завершения записи в канал.
  hEventWrt := CreateEventW (nil, true, false, nil);
  FillChar(OverLapWrt, sizeof(OVERLAPPED), 0);
  OverLapWrt.hEvent := hEventWrt;
  // Создаем событие ожидания завершения чтения из канала.
  hEventRd := CreateEventW (nil, true, false, nil);
  FillChar(OverLapRd, sizeof(OVERLAPPED), 0);
  OverLapRd.hEvent := hEventRd;
  // Для подсоединившегося клиента заполним его описание
  Inc(ClientCount);
  ClientIndex := ClientCount;
  Clients[ClientIndex].hPipe := hPipe;
  Clients[ClientIndex].Live := true;
  Clients[ClientIndex].OverLap := OverLapWrt;
  Clients[ClientIndex].hEvent := hEventWrt;
  // первым сообщением от клиента должно быть его имя
  rc := ReadFile (hPipe, inBuf, MAX_READ, bytesRead, @OverLapRd);
  if not rc then
    lastError := GetLastError;
  if lastError = ERROR_IO_PENDING then  // Ожидаем завершения ввода-вывода
    WaitForSingleObject (hEventRd, INFINITE);
  // Запоминаем имя текущего клиента.
  lstrcpyw (Clients[ClientIndex].Name, inBuf);
  // Запускаем новый поток для ожидания нового клиента
  CreateThread (nil, 0, @ServerProc, Param, 0, Dummy); //Поток выполняется сразу
  // Посылка пустой строки вызовет обновление списка клиентов и его перерисовку
  TellAll('');
  // Читаем сообщения от этого клиента и передаем его всем подключенным клиентам
  repeat
    rc := ReadFile (hPipe, inBuf, MAX_READ, bytesRead, @OverLapRd);
    // Проверяем три вида ошибки: IO_PENDING (ждем завершения операции)
    // При BROKEN_PIPE (клиент или сервер умер), выход из цикла и завершение
    // обслуживания клиента.
    // При остальных ошибках выдаем сообщение, помечаем факт смерти клиента
    // и завершаем его обслуживание, выходя из цикла чтения.
    if not rc then begin
      lastError := GetLastError;
      case lastError of
      ERROR_IO_PENDING: // Ожидаем завершения операции
        WaitForSingleObject (hEventRd, INFINITE);
      ERROR_BROKEN_PIPE: // Экземпляр канала сломался, завершаем обслуживание.
        ExitLoop := true;
      else
        // Выдаем сообщение о нештатной ошибке и завершаем обслуживание клиента.
        begin
          ShowLastErrorMessage(WindowHandle, 'Debug: ServerProc():ReadFile');
          ExitLoop := true;
        end;
      end;
    end;
    if not ExitLoop then begin
      GetOverlappedResult (hPipe, OverLapRd, bytesTransRd, false);
      // Пересылаем сообщение всем клиентам
      if bytesTransRd <> 0 then
        TellAll(inBuf)
      else
        TellAll('');
    end;
  until ExitLoop;
  Clients[ClientIndex].Live := false; // При выходе из цикла чтения клиент мертв
  CloseHandle (hPipe);
  CloseHandle (hEventRd);
  CloseHandle (hEventWrt);
  DisconnectNamedPipe (hPipe);  // Разрушаем экземпляр канала
  ExitThread(0);                // Завершаем обслуживающий поток.
end;
и процедуры рассылки сообщения клиентам:
procedure TellAll (const Message: PWideChar);
var
  I: Integer;
  BytesWritten: DWORD;
  rc: Boolean;
  lastError: DWORD;
  MsgLength: DWORD;
begin
  //передать сообщение всем живым клиентам в списке.
  for I:=1 to ClientCount do
    if Clients[I].Live then begin
      MsgLength := lstrlenW(Message) * SizeOf(WideChar);
      rc := WriteFile (Clients[I].hPipe, Message^, MsgLength, bytesWritten,
        @Clients[I].overLap);
      // Проверка на три вида ошибки: IO_PENDING, NO_DATA и остальные.
      // Для случая IO_PENDING ожидать завершения асинхронного ввода-вывода
      // на событии клиента, во всех остальных случаях, кроме NO_DATA
      // считать клиента умершим и отметить факт его смерти в описании клиента.
      if not rc then begin
        lastError := GetLastError;
        if lastError = ERROR_IO_PENDING then //Ждем завершения операции
          WaitForSingleObject (Clients[i].hEvent, INFINITE)
        else begin
          if lastError <> ERROR_NO_DATA then //Клиент умер по причине lastError
            //TODO: Указывать имя покойника
            ShowLastErrorMessage (Wnd, 'TellAll:', lastError);
          //TODO: рассылать широковещательное сообщение об уходе?
          Clients[i].Live := false;

        
        end;
      end;
    end;
  //Обновить окно с клиентами
  InvalidateRect(Wnd, nil, true);
end;
Рассмотрим реализацию клиента, взаимодействующего с сервером именованного канала:
function ClientDlgProc (WindowHandle: HWND; Message: UINT;
  wParam, lParam: Cardinal): UINT; stdcall;

  function TerminateDialog: UINT;
  begin
    CloseHandle (hPipe);
    CloseHandle (hEventWrt);
    EndDialog(WindowHandle, 1);
    Result := 1;
  end;
  
var
  retCode: DWORD;
  rc: Boolean;
  errorBuf: array[0..LINE_LEN] of WideChar;
  outBuf: array[0..OUT_BUF_SIZE] of WideChar; 
  sendBuf: array[0..OUT_BUF_SIZE] of WideChar;  
  bytesWritten: DWORD;
  Dummy: DWORD;
  fileName: array[0..LINE_LEN+NAME_SIZE+sizeof(WideChar)*2] of WideChar;
  AFileName: WideString;
  lastError: DWORD;
  APipeName: string;
begin
  hWndClient := WindowHandle;
  errorBuf[0] := #0;
  outBuf[0] := #0;
  sendBuf[0] := #0;

  case Message of
  WM_COMMAND:
    begin
      case LOWORD(wParam) of
        // После нажатия на кнопку Send получить текст для отправки серверу,
        // префиксировать его именем клиента и записать в канал.
      IDB_SEND:
        begin
          GetWindowTextW (GetDlgItem(WindowHandle, IDD_EDITWRITE), outBuf,
            MAX_WRITE);
          lstrcpyw(sendBuf, ClntName);
          lstrcatw(sendBuf, ':');
          lstrcatw(sendBuf, outBuf);
          // Записать сообщение в канал
          rc := WriteFile (hPipe, sendBuf, MAX_WRITE, bytesWritten,
            @OverLapWrt);
          if not rc then begin
            lastError := GetLastError;
            // Если IO_PENDING, ждать завершения асинхронной операции записи
            if lastError = ERROR_IO_PENDING then
              WaitForSingleObject (hEventWrt, INFINITE);
          end;
        end;
      end;
      Result := 0;
    end;
  WM_INITCLIENT:
    // При инициализации создать диалог для получения имен сервера и клиента
    // Имя сервера, равное "." означает, что сервер находится на том же
    // компьютере, что и клиент. Имя канала должно выглядеть как
    // '\\.\PIPE\<pipename>' для соединения с локальным сервером или
    // '\\<machinename>\PIPE\<pipename>' для соединения с удаленным сервером
    // После соединения с каналом, отослать серверу свое имя для идентификации
    // и создать поток для чтения из канала.
    begin
      DialogBoxW (GetModuleHandle(nil), 'InitDialog', WindowHandle,
        @InitDlgProc);
      // Записать имя клиента в заголовок окна
      SetWindowTextW (WindowHandle, ClntName);
      APipeName:= Format('\\%s\PIPE\test', [WideCharToString(ShrName)]);
      AFileName:= StringToWideChar(APipeName, FileName, SizeOf(FileName));
      // Соединиться с сервером
      hPipe := CreateFileW (PWideChar(AFileName),
        GENERIC_WRITE or // Доступ на чтение/запись
        GENERIC_READ,
        FILE_SHARE_READ or // Разделенный доступ
        FILE_SHARE_WRITE,
        nil,
        OPEN_EXISTING,   // Канал должен существовать
        FILE_FLAG_OVERLAPPED, // Использовать асинхронный ввод/вывод
        0);
      if hPipe = INVALID_HANDLE_VALUE then begin
        retCode := GetLastError;
        // Проверить попытку подключения к несуществующему каналу
        if (retCode = ERROR_SEEK_ON_DEVICE) or
           (retCode = ERROR_FILE_NOT_FOUND) then
          MessageBoxW (WindowHandle,
            'CANNOT FIND PIPE: Assure Server32 is started, check share name.',
            '', MB_OK)
        else begin
          // Не удалось подключиться по другой причине
          MessageBoxW(WindowHandle, StringToWideChar(SysErrorMessage(retCode),
            errorBuf, SizeOf(errorBuf)),
            'Debug Window:CreateFileW', MB_OK or MB_ICONINFORMATION
              or MB_APPLMODAL);
        end;
        EndDialog (WindowHandle, 0); // Умереть, если не удалось соединиться
        Result := 0;
        Exit;
      end;
      hEventWrt := CreateEvent (nil, true, false, nil);
      OverLapWrt.hEvent := hEventWrt;
      // Сообщить серверу свое имя
      rc := WriteFile (hPipe, ClntName, MAX_WRITE, bytesWritten,
        @OverLapWrt);
      if not rc then // Если IO_PENDING, ожидать звершения операции
        if GetLastError = ERROR_IO_PENDING then
          WaitForSingleObject (hEventWrt, INFINITE);
      // Создать поток чтения из канала.
      CreateThread (nil, 0, @ReadPipe, @hPipe, 0, Dummy);
      Result := 0;
    end;
  WM_INITDIALOG:
    // Послать сообщение в очередь, чтобы успел создаться диалог
    begin
      PostMessageW (WindowHandle, WM_INITCLIENT, 0, 0);
      Result := 0;
    end;
  WM_GO_AWAY: // Завершение работы клиентской части из-за разрыва соединения
              // с сервером.
    Result := TerminateDialog;

  WM_SYSCOMMAND:
    if (wParam and $FFF0) = SC_CLOSE then // Если диалог закрывается
                                          // пользователем.
      Result := TerminateDialog
    else
      Result := 0;
  else
    Result := 0;
  end;
end;
И клиентского потока асинхронного чтения данных из канала:
procedure ReadPipe (hPipe: PHANDLE); stdcall;
var
  inBuf: array[0..IN_BUF_SIZE] of WideChar;
  bytesRead: DWORD;
  rc: Boolean;
  lastError: DWORD;
  hEventRd: THANDLE;
  OverLapRd: OVERLAPPED;
  bytesTrans: DWORD;
begin
  inBuf[0] := #0;
  hEventRd := CreateEventW (nil, true, false, nil);
  FillChar (OverLapRd, sizeof(OVERLAPPED), 0);
  OverLapRd.hEvent := hEventRd;
  // Бесконечный цикл чтения из канала, до тех пор,пока не разорвется соединение
  // Чтение происходит асинхронно, с ожиданием по событию. После того, как сооб-
  // щение прочитано, оно помещается в элемент редактирования.
  while true do begin
    rc := ReadFile (hPipe^, inBuf, IN_BUF_SIZE*sizeof(WideChar), bytesRead,
      @OverLapRd);
    if not rc then begin
      lastError := GetLastError;
      // Проверка на три вида ошибки:
      // IO_PENDING (ожидать завершения операции), BROKEN_PIPE (выйти из цикла)
      // и остальные (выдать сообщение, выйти из цикла и умереть)
      if lastError = ERROR_IO_PENDING then begin
        WaitForSingleObject (hEventRd, INFINITE);
      end else begin
        if lastError = ERROR_BROKEN_PIPE then
          MessageBoxW (hWndClient,
            'The connection to this client has been broken.', '', MB_OK)
        else
          ShowLastErrorMessage(hWndClient,
            PAnsiChar('Client: Debug():ReadFile'));
        Break;
      end;
    end;
    GetOverlappedResult (hPipe^, OverLapRd, bytesTrans, false);
    inBuf[bytesTrans div SizeOf(WideChar)] := #0; // Завершить полученную строку
    SendMessageW (GetDlgItem (hWndClient, IDD_EDITREAD), EM_REPLACESEL,
      0, LPARAM(@inBuf));
    // Перевести курсор на следующую строку в элементе редактирования :)
    SendMessageW (GetDlgItem (hWndClient, IDD_EDITREAD), EM_REPLACESEL,
      0, LPARAM(PWideChar(CrLf)));
  end;
  // Если соединение с каналом разорвано, завершить программу
  PostMessageW (hWndClient, WM_GO_AWAY, 0,0);
  ExitThread(0);
end;

  Полный текст примера сервера и клиента, использующих именованный канал для коммуникации можно найти в приложении к статье.

  Как уже отмечалось ранее, базовым объектом для реализации именованных каналов является объект «Файл». Это позволяет перечислить созданные в системе именованные каналы программно средствами Native API: открыть корневой каталог файловой системы именованных каналов (\Device\NamedPipe) и перечислить его содержимое. Пример программы перечисления созданных именованных каналов можно найти на сайте http://www.sysinternals.com (PipeList) или в приложении к статье.

  Автор выражает признательность Екатерине Субботиной, Дмитрию Заварзину и Александру Жигалину за конструктивную критику в процессе написания статьи.

Литература:
1. Д. Соломон, М. Руссинович: Внутреннее устройство Windows 2000.
2. MSDN Library (http://msdn.microsoft.com)
   Внимание! Запрещается перепечатка данной статьи или ее части без согласования с автором. Если вы хотите разместить эту статью на своем сайте или издать в печатном виде, свяжитесь с автором.
Автор статьи:  Игорь Шевченко
  

Другие статьи Наверх


  Рейтинг@Mail.ru     Титульная страница Поиск, карта сайта Написать письмо