Как правильно работать с сокетами

Обновлено: 07.07.2024

Примечание. Сокеты у нас будут работать, как на серверной части, так и на клиентской. На серверной части этим займется стандартный WebSocket, который появился в HTML5, а работу на серверной части, где у нас PHP будет выполнять библиотека phpws. Есть много подобных библиотек, пожалуй, особенно следует отметить Ratchet, который мне показался громоздким для моего маленького проекта и я остановился на phpws.

Нам нужен Composer

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

Мы его скачали, но команды composer не будут выполняться через PATH, поэтому переместим скачанное в /usr/local/bin

Выполняем команду и получаем результат в виде инструкций и команд Composer, что говорит об удачной установке

Для Windows и Mac можно посмотреть инструкцию на офф-сайте.

Примечание. Все зависимости, которые нужно подключать надо указывать в файле composer.json в корне проекта, который скачает, обновит и соберет все зависимости в одну папку vendor, из которого потом можно загружать через автозагрузчик классов. У Composer есть свое хранилище пакетов и библиотек и называется Packagist, который позволяет указывать vendor/package и он будет установлен. Да, можно указывать конкретные адреса svn/git репозиториев в composer.json, но это неудобно. Намного удобнее иметь какой-то центральный пункт, где есть соответствия пакетов с их адресами репозиториев. Это Packagist.

Нам нужна библиотека phpws

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

В данном случае, мы указали, что скачивать прямо с репозитория GitHub без посредничества Packagist.

Выполним данный файл командой

После чего в папке появится подпапка vendor со скачанными библиотеками и нам остается их подключить и использовать.

Нам нужны базовые понимания работы WebSocket с PHP

И так, на минуточку разберемся, что делать со скачанными библиотеками и как их использовать, углубляться не буду, поэтому, если на пальцах, то нам нужно 2 файла:

Для клиентской части:

Для серверной части:

Стандартный пример вывода текущего времени сервера с обновлением до секунды

Для работы данного примера нужно единожды запустить файл server.php через консоль и после выполнения данного скрипта запуститься сокет-сервер со своим PID

Что делает пример? В примере показано, как до долей секунды сокет обновляет информацию времени на сервер и выдает его клиенту

Стандартный пример простого чата

Показан пример простого чата. Визуально он имеет вид, как на картинке

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

Сначала выводим PID процесса запущенного сокет-сервера. Его мы узнаем посмотрев список запущенных сокетов через их порты через команду:

Находя из списка нужный PID убиваем его через команду:

Сокеты (англ. socket — разъём) — название программного интерфейса для обеспечения обмена данными между процессами. Процессы при таком обмене могут исполняться как на одной ЭВМ, так и на различных ЭВМ, связанных между собой сетью. Сокет — абстрактный объект, представляющий конечную точку соединения.

Принципы сокетов¶

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

Каждый сокет имеет свой адрес. ОС семейства UNIX могут поддерживать много типов адресов, но обязательными являются INET-адрес и UNIX-адрес. Если привязать сокет к UNIX-адресу, то будет создан специальный файл (файл сокета) по заданному пути, через который смогут сообщаться любые локальные процессы путём чтения/записи из него (см. Доменный сокет Unix). Сокеты типа INET доступны из сети и требуют выделения номера порта.

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

Основные функции¶

socket()¶

Создаёт конечную точку соединения и возвращает файловый дескриптор. Принимает три аргумента:

domain указывающий семейство протоколов создаваемого сокета

  • AF_INET для сетевого протокола IPv4
  • AF_INET6 для IPv6
  • AF_UNIX для локальных сокетов (используя файл)

type

  • SOCK_STREAM (надёжная потокоориентированная служба (сервис) или потоковый сокет)
  • SOCK_DGRAM (служба датаграмм или датаграммный сокет)
  • SOCK_RAW (Сырой сокет — сырой протокол поверх сетевого уровня).

protocol

Протоколы обозначаются символьными константами с префиксом IPPROTO_* (например, IPPROTO_TCP или IPPROTO_UDP). Допускается значение protocol=0 (протокол не указан), в этом случае используется значение по умолчанию для данного вида соединений.

Функция возвращает −1 в случае ошибки. Иначе, она возвращает целое число, представляющее присвоенный дескриптор.

Пример на Python

Связывает сокет с конкретным адресом. Когда сокет создается при помощи socket(), он ассоциируется с некоторым семейством адресов, но не с конкретным адресом. До того как сокет сможет принять входящие соединения, он должен быть связан с адресом. bind() принимает три аргумента:

  1. sockfd — дескриптор, представляющий сокет при привязке
  2. serv_addr — указатель на структуру sockaddr, представляющую адрес, к которому привязываем.
  3. addrlen — поле socklen_t, представляющее длину структуры sockaddr.

Возвращает 0 при успехе и −1 при возникновении ошибки.

Пример на Python

Автоматическое получение имени хоста.

listen()¶

Подготавливает привязываемый сокет к принятию входящих соединений. Данная функция применима только к типам сокетов SOCK_STREAM и SOCK_SEQPACKET. Принимает два аргумента:

  1. sockfd — корректный дескриптор сокета.
  2. backlog — целое число, означающее число установленных соединений, которые могут быть обработаны в любой момент времени. Операционная система обычно ставит его равным максимальному значению.

После принятия соединения оно выводится из очереди. В случае успеха возвращается 0, в случае возникновения ошибки возвращается −1.

Пример на Python

accept()¶

Используется для принятия запроса на установление соединения от удаленного хоста. Принимает следующие аргументы:

  1. sockfd — дескриптор слушающего сокета на принятие соединения.
  2. cliaddr — указатель на структуру sockaddr, для принятия информации об адресе клиента.
  3. addrlen — указатель на socklen_t, определяющее размер структуры, содержащей клиентский адрес и переданной в accept(). Когда accept() возвращает некоторое значение, socklen_t указывает сколько байт структуры cliaddr использовано в данный момент.

Функция возвращает дескриптор сокета, связанный с принятым соединением, или −1 в случае возникновения ошибки.

Пример на Python

connect()¶

Устанавливает соединение с сервером.

Некоторые типы сокетов работают без установления соединения, это в основном касается UDP-сокетов. Для них соединение приобретает особое значение: цель по умолчанию для посылки и получения данных присваивается переданному адресу, позволяя использовать такие функции как send() и recv() на сокетах без установления соединения.

Загруженный сервер может отвергнуть попытку соединения, поэтому в некоторых видах программ необходимо предусмотреть повторные попытки соединения.

Возвращает целое число, представляющее код ошибки: 0 означает успешное выполнение, а −1 свидетельствует об ошибке.

Пример на Python

Передача данных¶

Для передачи данных можно пользоваться стандартными функциями чтения/записи файлов read и write, но есть специальные функции для передачи данных через сокеты:

Сокеты представляют собой чрезвычайно удобную, но в то же время плохо понятую технологию взаимодействия между двумя процессами в сети. Эти процессы могут существовать на одной и той же машине, общаясь друг с другом через локальный сокет, предназначенный для взаимодействия между процессами, либо на разных машинах через Internet. Хотя тема сокетов очень обширна, в данной статье представлены основы, которые необходимы для использования расширений РНР, предназначенных для написания собственных серверов и клиентов сокетов.

Кстати!

Для использования сокетов РНР должен быть скомпилирован с опцией ./configure --enable-sockets или же потребуется загружать расширения поддержки сокетов динамически.

�?мейте в виду, что примеры, приведённые далее в этой статье, разработаны для запуска непосредственно из окружения оболочки с использованием версии РНР командной строки. Хотя их можно запустить в Web-браузере, делать это не рекомендуется. В случае сценариев, которые создают серверы сокетов, их применение можно продемонстрировать с помощью любых программ, способных устанавливать сетевое соединение через сокеты, например, telnet (что, собственно, и рекомендуется).

Основы сокетов

Хотя существует множество типов сокетов, все функции сокетов основаны на одном и том же базовом принципе — получении данных программой В от программы А. Эти программы могут работать на одной и той же машине с применением межпроцессного взаимодействия (Interprocess Communication — IPC), либо на удаленных машинах (таких как Web-сервер и браузеры).

Сокеты могут быть надежными, выполняющими все необходимое для обеспечения передачи данных из точки А в точку В (TCP), либо ненадежными, когда данные передаются без гарантии доставки (UDP).

Сокеты также бывают "блокирующими" и "неблокирующими". Блокирующие сокеты заставляют ваше приложение ожидать до тех пор, пока данные станут доступны, в то время как неблокирующие сокеты этого не делают. Хотя, как будет показано далее, все сокеты двунаправлены, все же существует разница между сокетами клиента и сервера.

Мы с вами рассмотрим ТСР-сокеты Internet, поскольку они наиболее широко используются на сегодняшний день. Тем не менее, концепции и примеры кода, приведенные здесь, применимы к большинству операций с сокетами.

Создание нового сокета

Независимо от типа создаваемого сокета (клиентский или серверный), все они инициализируются одинаковым способом — с помощью функции socket_create(). Синтаксис этой функции выглядит следующим образом:

socket_create($domain, $type, $protocol);

$domain - тип создаваемого сокета и должен принимать одно из значений, перечисленных в таблице констант доменов для сокеткых соединений

$type - тип взаимодействия, которое будет осуществляться через сокет; допустимые значения приведены в таблице констант типов сокетов

$protocol - протокол, используемый данным сокетом. Этот параметр может быть любым допустимым номером протокола (см. функцию getprotobyname() ) или константой SOL_UDP или SOL_TCP для соединений TCP/UDP.

В результате выполнения эта функция либо возвращает ресурс, представляющий созданный сокет, либо булевское значение false в случае ошибки.

Функция socket_create() - это первый вызов при любом взаимодействии сокетов, который инициализирует ресурс сокета, используемый в последующих операциях. �?так, сокеты могут использоваться как локально - для IPC, так и удаленно — в стиле клиент/сервер. Контекст конкретного применения сокета называется его доменом. Доступные в РНР домены, передаваемые функции socket_create() в параметре $domain, задаются константами из таблицы:

Константы доменов для сокеткых соединений

Константа Описание
AF_INET Протокол Internet IPv4
AF_INET6 Протокол Internet IPv6
AF_UNIX Локальное межпроцессное взаимодействие

После того, как домен установлен, нужно определить Т�?П подключения для создаваемого сокета. Эти типы перечислены в следующей таблице:

Константы типов сокетов

Как видите, существует множество опций при выборе типа создаваемых сокетов. Вообще большинство сокетных соединений устанавливается с помощью сокетов SОСК_STREAM или SOCK_DGRAM. Учитывая очевидную полноту SOCK_STREAM (большая часть Internet работает по этому типу сокетов через TCP), может быть не совсем понятно, зачем нужны сокеты типа SOCK_DGRAM (используемые с протоколом UDP).

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

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

Мы будем испольэовать сокеты Internet IPv4 типа SOCK_STREAM, работающие через соединения SOL_TCP (TCP), После того, как ресурс сокета создан, он может быть уничтожен с помощью функции socket_close(), имеющей следующий синтаксис:

socket_close($socket);

Здесь $socket - это сокет, подлежащий уничтожению.

Ошибки сокетов

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

socket_last_error($socket);

Где $socket - это сокет, информацию об ошибке которого необходимо извлечь. Как следует из имени функции, она используется для того, чтобы вернуть последнюю ошибку, произошедшую в данном сокете. Эта ошибка представлена в виде целого числа. Чтобы транслировать его в понятную человеку форму, в API-интерфейсе сокетов предусмотрена дополнительная функция socket_strerror():

socket_strerror($error_code);

Где $error_code - это значение, которое получено из функции socket_last_error(). Эта функция вернет строку, описывающую ошибку, возвращенную функцией socket_last_error().

Создание клиентских сокетов.

Создание сокета, готового для подключения к другому сокету в Internet, выполняется с помощью функции socket_connect ():

socket_connect( $socket, $address [, $port] );

$socket - это сокет, участвующий в соединении;

$address - IP-адрес сервера, к которому нужно подключиться;

$port - необязательный параметр - это порт сервера, к которому необходимо подключиться;

Хотя параметр $port не обязателен в прототипе функции, при подключении доменов AF_INET или AF_INET6 он должен присутствовать. При выполнении эта функция подключается к указанному серверу, используя предоставленный сокет, и возвращает булевское значение, указывающее на то, успешно ли выполнен запрос.

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

socket_write( $socket, $buffer [, $length] );

$socket — это сокет для записи данных;

$buffer - Данные для записи в сокет;

$length - (необязательный) также может быть указан при желании (в противном случае будет записан весь буфер);

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

Для чтения данных из сокета применяется функция socket_read<) со следующим синтаксисом:

socket_read($socket, $length [, $type]);

$socket — это сокет, из которого нужно прочитать максимум $length байт. Необязательный параметр $type принимает значения, описанные в таблице ниже, и указывает способ, по которому данные должны читаться из сокета.

Константы типа для socket_read()

Константа Описание
PHP_BINARY_READ �?нтерпретировать данные как бинарные (поумолчанию).
PHP_NORMAL_READ Читать данные заданной длины, либо пока не встретится символ новой строки (\r или \n).

Рассмотрев этот простой пример сокета-клиента, теперь давайте посмотрим на другую сторону соединения - простой сервер на базе сокетов.

Создание серверных сокетов

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

socket_bind( $socket, $address [,$port] );

Здесь $socket — это сокет, который подлежит привязке к адресу $address. Если сокет существует в домене AF_INET или AF_INET6, необязательный параметр $port должен быть указан. При выполнении эта функция пытается привязать созданный сокет к указанным адресу и порту и возвращает булевское значение, указывающее на успешность операции.

Кстати!

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

Второй шаг: настроить сокет на прослушивание трафика на предмет попыток подключения к нему. Это делается с помощью функции socket_listen():

socket_listen($socket [, $backlog] );

Где $socket - привязанный ранее сокет, который должен быть включен на прослушинание. Необязательный параметр $backlog используется для создания очереди посредством указания максимально допустимого числа входящих подключений, помещаемых в очередь. Если этот параметр не указан, то сокет, пытающийся подключиться, получит отказ в обслуживании, пока серверный сокет недоступен. В результате выполнения эта функция возвращает булевское значение, указывающее на успешность настройки серверного сокета на прослушивание.

Третий и последний шаг в создании серверного сокета — дать команду на прием входящих подключений. Это делается функцией socket_accept ():

socket_accept($socket);

Где $socket — привязанный сокет, включенный на прослушивание, который должен принимать соединения.

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

Кстати

Сокетный ресурс, возвращенный функцией socket_accept(), не может быть повторно использован, поскольку он обслуживает только одно определенное текущее подключение. Сокет, переданный ей в параметре $socket, однако, может быть использован повторно.

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

Создание простого сервера на основе сокета

У меня на компе этот скрипт лежит в папке денвера по пути: C:\WebServers\home\app.loc\www\sockets\test.php
Теперь, если запустить наш скрипт из командной строки таким макаром: C:\WebServers\home\app.loc\www\sockets>php test.php В командной строке мы увидим следующее:

Сервер в ответ пришлёт нам наш же запрос + заголовки:

В командной строке мы увидим новые данные

А так же новое приглашение ввода - свидетельство о том, что процесс отрубился. Это можно так же проверить командой netstat -a и убудиться, что порт 4545 в списке не присутствует.

Кстати!

Чтобы создать сервер, сокеты которого ведут прослушивание на портах с номерами ниже 1000, данный пользователь должен иметь в системе административные права. Также следует отметить, что приведенный выше сценарий не завершит работу до тех пор, пока не будет установлено соединение, что может создать впечатление "зависания".

Одновременная работа с несколькими сокетами

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

Здесь $read, $write и $error — переданные по ссылке переменные (точнее, массивы). Эти массивы должны содержать список всех сокетов, за которыми нужно наблюдать на предмет чтения, записи и перехвата ошибок соответственно. Например, помещение активного сокета в массив, передаваемый в параметре $read, заставляет РНР проверять, есть ли в этом сокете данные для чтения. Последние два параметра - $sec и необязательный $usec - это значения тайм-аута, управляющие тем, как долго будет ожидать функция socket_select(), прежде чем вернуть управление РНР.

В результате выполнения функция socket_select() возвращает целое число, указывающее общее количество измененных сокетов ( из переданного списка), и модифицирует массивы $read, $write и $error, удаляя из них те элементы, которые не были изменены. В результате каждый из этих массивов будет содержать только список сокетов, отвечающих следующим требованиям:

Сокеты, перечисленные в массиве $read, содержат данные, подлежащие чтению из них, либо входящие подключения к ним.

Сокеты, перечисленные в массиве $write, содержат данные, подлежащие записи в них.

Сокеты, перечисленные в массиве $error, содержат ошибки, которые нужно обработать.

В случае ошибочного завершения socket_select() возвращает булевское значение false.

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

Этот сокет будет также добавлен в массив $read и будет запущен управляемый бесконечный цикл. Затем с помощью функции socket_select() будет организован мониторинг главного сокета на предмет новых подключений. Когда появляется новое подключение, автоматически вызывается функция socket_accept(), что приводит к созданию нового серверного сокета, используемого для взаимодействия с подключенным клиентом.

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

Создание многосортного сервера на РНР

Кстати!

В листинге выше вскрыты некоторые ограничения сценарного механизма РНР, которые требуют несколько более сложного обходного пути в форме вызова socket_select():

$num_changed = socket_select($read, $NULL, $NULL, 0, 10);

Обратите внимание на применение переменной с именем $NULL. В РНР для функций, принимающих параметры по ссылке (как это и делает socket_select() в первом приближении), NULL является недопустимым значением. Однако передача NULL в качестве одного или более параметров-списков вполне корректна. Поэтому обходной маневр заключается в присвоении переменной $NULL значения NULL:

$NULL = NULL;

�? последующей ее передачи функции socket_select().

Протокол

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

Протокол UDP — низкоуровневый. С его помощью компьютеры могут отправлять и получать информацию в виде отдельных пакетов, не создавая логическое соединение. В отличие от TCP, взаимодействия по протоколу UDP не отличаются надежностью. Это усложняет управление ими в приложениях, в которых при обмене информацией нужны гарантии. Поэтому большинство интернет-приложений используют TCP.


Работа с сокетами в Python

Для создания сокетов в питоне используется модуль socket. В нем так же имеются методы для , установление и закрытие соединения, отправку данных по сети и их получение и другие операции.

ОбщиеСерверныеКлиентские
socket — создать сокетbind — привязать сокет к IP-адресу и порту машиныconnect — установить соединение
send — передать данныеlisten — просигнализировать о готовности принимать соединения
recv — получить данныеaccept — принять запрос на установку соединения
close — закрыть соединение

Работа ТСР протокола

Чтобы понять, как с сокетом работает протокол ТСР, посмотрим на изображение ниже. Пояснение будет в коде программы (для примера мы отправляем клиенту текущее время)


Серверная часть:

Если вы работаете в среде программирования, то разрешите вашему серверу работать в вашей локальной сети:


Клиентская часть

from socket import *

Результат клиентской части (после запуска сервера):


Результат серверной части (после подключения клиента):

Как происходит кодирование/декодирование данных?

Строки, байты, изменяемые строки байтов:

from socket import *
import time

JSON Instant Messaging

Весь скомпилированный JSON-объект должен уложиться в 640 символов.

Аутентификация

Для того, чтобы инициализировать процесс аутентификации, надо создать такой JSON-объект:

Ответы сервера будут содержать поле response, и может быть еще одно (необязательное) поле alert/error с текстом ошибки.

Подключение, отключение, авторизация

В сети/ не в сети

В свою очередь, сервер посылает специальный probe-запрос для проверки доступности клиента:

Алерты и ошибки сервера

Тоже самое, что и отправка обычному пользователю, только в поле to ставится решетка с названием чатрума

Читайте также: