Грамотная реализация клиент-серверных приложений на PHP. Часть 1

Php

Автор: Eugen

2011.11.01  1274


Доброго времени суток!

В этой статье я расскажу про сокеты в PHP. При этом, будут затронуты не только стандартные функции PHP 4 и PHP 5, но и модуль sockets, доступный во всех версиях PHP.

Оговорюсь по поводу совместимости. Всё описанное здесь будет одинаково работать во всех операционных системах, за исключением случаев, когда для реализации серверной многопоточности используется pcntl_fork(). Хотя, это самый правильный способ реализации многопоточности, в не UNIX системах он работать не будет. Думаю, что комментарии здесь излишни, т.к. это обсуждалось в моей предыдущей статье. Так же, в конце статьи будут приведены реализации stream-функций, достпуных только в PHP 5 с помощью модуля sockets.

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

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

Это был небольшой кусок теории. Теперь перейдём к описанию функций, которое я начну с самых простых - стандартных функций. Собственно, только они будут рассмотрены в первой части статьи, т.к. их достаточно много...

res fsockopen(str address [, int port [, int &errno [, str &errstr [, float timeout]]]]) - функция устанавливает исходящее TCP или UDP соединение.
Думаю, все обратили внимание, что параметр port - не обязательный. Сейчас расскажу, почему это так.
Параметр address понимает следующие варианты использования: "protocol://address:port", "address:port", "protocol://address", "address".
В качестве протокола можно указать tcp или udp, в качестве адреса можно указывать как IP адрес (IPv4 и IPv6, если ОС поддерживает работу с IPv6), так и доменное имя. В случае, если порт не указывается здесь, он должен быть указан во втором параметре.
Если не указан протокол - по умолчанию используется TCP.
Так же, в качестве протокола можно указывать ssl и tls для использования SSL или TLS шифрования данных. Однако, это может не поддерживаться в PHP (для этого нужна библиотека OpenSSL).
Возвращает функция ресурс (или false в случае ошибки), аналогично функции fopen(), которая используется для работы с файлами. Так же, в случае ошибки, функция вызовет Warning. По умолчанию, функция создаёт блокирующий сокет. Что это такое, я расскажу чуть позже.
Пример использования приведу ниже, вместе с другими функциями.
Аналогом функции является pfsockopen(), которая вызывается с теми же параметрами. Единственное отличие, это то, что если соединение не было закрыто принудительно, в pfsockopen() оно не закрывается после завершения скрипта.

str fread(res fd, int len) - функция читает из сокета (или файла) заданное количество байт.
При работе в блокирующем режиме, функция читает указанное количество байт из сокета или файла, за исключением случаев, когда пакет данных разбит на несколько пакетов или если доступно меньшее количество байт (например, конец файла или соединение было разорвано).
В неблокирующем режиме, функция может прочитать только те данные, которые уже поступили и не будет ждать остальных данных.
В любом случае, необходимо проверять длину принятых данных и дочитывать оставшиеся данные при необходимости.

str fgets(res fd [, int len]) - фунция аналогична предыдущей, однако, останавливает чтение данных, когда доходит до символа конца строки. Иными словами, функция читает строку из файла или сокета, максимальной длины len.

str fgetc(res fd) - функция читает 1 символ (1 байт) из файла или сокета. В остальном, поведение функции аналогично fread().

str fgetss(res fd [, int len [, str allowed_tags]]) - функция аналогична fgets(), только вырезает HTML тэги при чтении данных. Аналог - fgets() и strip_tags(). От себя скажу, что крайне не люблю и не советую использовать подобные вещи. Параметр len был обязателен до PHP 5.0.0.

arr fgetcsv(res fd [, int len [, str delimiter [, str enclosure [, str escape]]]]) - функция аналогична fgets(), только парсит строку как данные в формате CSV. Последний параметр был добавлен в PHP 5.3.0. Примеров на fgetss() и fgetcsv() я приводить не буду, т.к. очень не люблю и не советую использовать такие функции.

int fpassthru(res fd) - функция читает и выводит всё оставшееся содержимое. Возвращает количество прочитанных байт.

int fwrite(res fd, str string [, int bytes]) - функция записывает данные в сокет/файл, возвращает количество записанных байт. Здесь я сделаю несколько комментариев.
Последний параметр указывает максимальное количество записываемых байт. Если это число меньше, чем длина строки, то будет записано именно столько байт.
Далее, это то, что функция не всегда полностью записывает данные в сокет. Желательно проверять количество записанных байт и, если оно меньше реальной длины данных, дописывать оставшиеся данные. Реально же, мне никогда не приходилось сталкиваться с этим.

int fputs(res fd, str string [, int bytes]) - уже очень давно функция является аналогом fwrite(). Однако, у меня сохранилась привычка для текстовых протоколов использовать fputs(), а для бинарных - fwrite(). Хотя, стараюсь уже всюду применять fwrite(), т.к. это правильнее и лучше читается.

int fputcsv(res fd, arr fields [, str delimiter [, str enclosure]]) - функция для записи CSV данных. Больше ничего сказать не могу, т.к. не использую такие функции. Вручную это делать нужно.

bool fflush(res fd) - функция немедленно записывает все буфферизированные данные в файл или сокет. Реально же, эта функция полезна только при работе с файлами. Поэтому, я её не буду здесь описывать.

bool feof(res fd) - функция проверяет, находимся ли мы в конце файла, при работе с файлами. При работе с сокетами, функция проверяет:
1) Что нет непрочитанных данных
2) Что соединение не разорвано
И только при выполнении этих двух условий возвращает true.

bool fclose(res df) - функция принудительно закрывает файл или соединение.

arr stream_get_meta_data(res fd) - функция возвращает ассоциативный массив с информацией о потоке данных.
Параметры такие:
bool timed_out - параметр установлен в TRUE, если вызов fread (и др.) завершился из-за таймаута
bool blocked - параметр установлен в TRUE, если поток работает в блокирующем режиме
bool eof - параметр установлен в TRUE, если указатель находится в конце файла или соединение было прервано. В отличии от feof(), в случае работы с сетью, данный параметр устанавливается в TRUE, даже если остались непрочитанные данные.
int unread_bytes - количество байт данных в буффере PHP (принятые/прочитанные данные, не обработанные функцией fread и подобными)
str stream_type - тип потока (сокет, файл)
str wrapper_type - протокол (например, при использовании fopen для открытия не локальных файлов)
mix wrapper_data - спец. данные потока
arr filters - нумерованный массив-список фильтров потока
str mode - тип доступа к потоку (чтение/запись)
bool seekable - параметр установлен в TRUE, если в данном потоке возможно использовать поиск (фактически, только для файлов)
str uri - URI или имя файла, связанное с потоком
Аналогом функции является socket_get_status(), начиная с PHP 4.3.0.
Сразу оговорюсь, что всё, касаемое фильтров, обработчиков и прочей подобной гадости, я не буду описывать в этой статье. Возможно, это будет полезно тем, кто не умеет пользоваться сокетами и стандартными функциями для работы со строками...

bool stream_set_blocking(res fd, int mode) - функция включает/выключает блокирующий режим потока данных.
Блокирующий режим подразумевает, что при вызове fread и подобных функций будет произведено чтение данных заданной максимальной длины.
В неблокирующем режиме, функция fread (и подобные) не будут ждать данные в потоке, а будут возвращать только данные, которые удалось получить сразу. В блокирующем режиме такие функции будут ждать данные (до определённого таймаута).
Чуть не забыл описать параметр mode...
0 - неблокирующий режим
1 - блокирующий режим
Алиас функции - socket_set_blocking()

bool stream_set_timeout(res fd, int seconds [, int microseconds]) - функция устанавливает таймаут для чтения данных из потока (в секундах и микросекундах). По истечению таймаута вызов fread() и подобных функций прерывается. Алиас функции - socket_set_timeout()

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

bool checkdnsrr(str host [, str type]) - функция проверяет наличие ДНС записи данного типа для данного домена. По умолчанию, тип равен MX. Допустимые типы: A, MX, NS, SOA, PTR, CNAME, AAAA (доступен с PHP 5.0.0), A6, SRV, NAPTR, TXT (доступен с PHP 5.2.4) или ANY. Так же, следует отметить, что данная функция может использоваться только в UNIX системах. В Windows она доступна только начиная с PHP 5.3.0. Алиас - checkdnsrr (доступен только с PHP 5.0.0)

str gethostbyaddr(str ip_address) - функция возвращает домен, привязанный к указанному IP адресу, если может его получить. В противном случае, функция возвращает тот же самый IP адрес.

str gethostbyname(str hostname) - функция прямо противоположна предыдущей - определяет IP адрес по доменному имени. Кстати, при многократных соединениях на один адрес в цикле, советую получить IP и использовать его в fsockopen(), вместо указания домена. Этим можно здорово сэкономить время подключения (ДНС запросы не будут выполняться каждый раз).

arr gethostbynamel(str hostname) - функция аналогична предыдущей, однако возвращает нумерованный массив-список всех IP адресов для данного домена (предыдущая функция возвращает случайный из них).

int getprotobyname(str name) - функция возвращает номер протокола по его имени (или -1, если протокол не найден). Сейчас это не нужно, но будет полезно дальше, при разборе модуля sockets.

str getprotobynumber(int number) - функция обратна предыдущей - определяет имя протокола по номеру.

int getservbyname(str service, str protocol) - функция определяет номер порта по имени сервиса (например, http) и протоколу (например, tcp).

str getservbyport(int port, str protocol) - функция обратная предыдущей - определяет название сервиса по протоколу и номеру порта.

int ip2long(str ip_address) - функция преобразует IP адрес в LONG-число. Однако, следует не забывать про signed и unsigned числа. Поэтому, правильный вызов будет таким: sprintf('%u',ip2long(...)).

str long2ip(int proper_address) - функция обратна предыдущей.

Вот мы и разобрали стандартные функции в PHP для работы с сетью. Теперь я приведу несколько примеров.

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



Результатом выполнения будет нечто подобное (при нормальных условиях):

195.39.214.115
HTTP/1.1 200 OK
Date: Thu, 03 Sep 2009 17:58:18 GMT
Server: Apache
Pragma: no-cache
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Set-Cookie: session-identifier=vkBqa51PXfE6njwIROllk-rBMF4; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Set-Cookie: lang=ua; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Set-Cookie: body-background-color=%230D0D0D; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Set-Cookie: text-color=%23CFCFCF; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Set-Cookie: link-color=%23CFCFCF; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Set-Cookie: link-hover-color=%23FCFCFC; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Set-Cookie: forms-text-color=%23CFCFCF; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Set-Cookie: forms-background-color=%23343434; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Set-Cookie: menu-border-color=%23555555; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Set-Cookie: downmenu-border-color=%23000000; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Set-Cookie: menu-icon-background-color=%23000000; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Set-Cookie: menu-item-background-color=%23383838; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Set-Cookie: menu-active-background-color=%23262626; expires=Sun, 29-Aug-2010 17:58:18 GMT; path=/; domain=.eugen.su
Vary: Accept-Encoding,TE
Connection: close
Content-Type: text/html; charset=utf-8


Или в случае ошибки (обрыв соединения на модеме):
eugen.su
Connection failed. Error #10060: Попытка установить соединение была безуспешной, т.к. от другого компьютера за требуемое время не получен нужный отклик, или было разорвано уже установленное соединение из-за неверного отклика уже подключенного компьютера.

Прошу обратить внимание, что текст и номер ошибки зависит от системы. Данный код запускался в русскоязычной Windows XP Porfessional SP2.

Далее приведу свои функции для подключения через различные прокси (socks4, socks5, http) и алгоритм для автоматического определения типа прокси.
 

function connect_http_proxy($host,$hport,$proxy,$port,$timeout = 5,$user = null,$password = null,$user_agent = 'PHPProxyConnect/1.0 by Eugen')
{
    $sock = fsockopen($proxy,$port,$en,$es,$timeout);
    if(!$sock)
        return false;
    else {
        if($user && $password)
            $au = "Proxy-Authorization: basic ".base64_encode($user.":".$password)."\r\n";
        else
            $au = '';
        fwrite($sock,"CONNECT ".$host.":".$hport." HTTP/1.0\r\nHost: ".$host.":".$hport."\r\nUser-Agent: ".$user_agent."\r\n".$au."\r\n");
        $code = intval(substr(trim(fgets($sock,1024)),9,3));
        if($code != 200) {
            fclose($sock);
            return false;
        }
        while(($a = trim(fgets($sock,1024)) != ''));
        return $sock;
    }
}


Описание функции такое:
res connect_http_proxy(host, hport, proxy, port [, timeout [, user [, password [, user_agent]]]]) - функция подключается к указанному адресу host на порт hport через HTTP прокси (proxy:port). Так же, возможно указать таймаут подключения, данные для авторизации на прокси и описание клиента для подключения к прокси (user-agent). Возвращает обычный ресурс, с которым можно работать функциями fread/fwrite и другими. Для подключения используется метод CONNECT, поэтому эту функцию можно использовать в реализациях любых протоколов, в том числе и с асинхронной передачей данных.
 

function connect_socks4_proxy($host,$hport,$proxy,$port,$timeout = 5,$user = null)
{
    $sock = fsockopen($proxy,$port,$en,$es,$timeout);
    if(!$sock)
        return false;
    else {
        $ip = gethostbyname($host);
        if(preg_match('/(\d+)\.(\d+)\.(\d+)\.(\d+)/',$ip,$matches))
            $int = pack('C4',$matches[1],$matches[2],$matches[3],$matches[4]);
        else {
            fclose($sock);
            return false;
        }
        $request = pack('C2',0x04,0x01).pack('n1',$hport).$int.($user)?$user:'0'.pack('C1',0x00);
        fwrite($sock,$request);
        $resp = fread($sock,9);
        $answer = unpack('Cvn/Ccd',substr($resp,0,2));
        if($answer['vn'] != 0x00) {
            fclose($sock);
            return false;
        }
        if($answer['cd'] != 0x5A) {
            fclose($sock);
            return false;
        }
        return $sock;
    }
}


Описание функции:
res connect_socks4_proxy(host, hport, proxy, port [, timeout [, user]]) - функция подключается к указанному адресу host на порт hport через Socks4 прокси (proxy:port). Так же, возможно указать таймаут подключения и данные для авторизации на прокси. В остальном, поведение функции аналогично предыдущей.
 

function connect_socks5_proxy($host,$hport,$proxy,$port,$timeout = 5,$user = null,$password = null)
{
    $sock = fsockopen($proxy,$port,$en,$es,$timeout);
    if(!$sock)
        return false;
    else {
        if($user && $password)
            $request = pack('C4',0x05,0x02,0x00,0x02);
        else
            $request = pack('C3',0x05,0x01,0x00);
        fwrite($sock,$request);
        $resp = fread($sock,3);
        $answer = unpack('Cver/Cmethod',$resp);
        if($answer['method'] == 0x02) {
            $request = pack('C1',0x01).pack('C1',strlen($user)).$user.pack('C1',strlen($password)).$password;
            fwrite($sock,$request);
            $resp = fread($sock,3);
            $answer = unpack('Cvn/Cresult',$resp);
            if($answer['vn'] != 0x01 && $answer['result'] != 0x00) {
                fclose($sock);
                return false;
            }
            $answer['method'] = 0;
        }
        if($answer['method'] == 0x00) {
            $ip = gethostbyname($host);
            if(preg_match('/(\d+)\.(\d+)\.(\d+)\.(\d+)/',$ip,$matches))
                $int = pack('C4',$matches[1],$matches[2],$matches[3],$matches[4]);
            else {
                fclose($sock);
                return false;
            }
            $request = pack('C4',0x05,0x01,0x00,0x01).$int.pack('n',$hport);
            fwrite($sock,$request);
            $resp = fread($sock,11);
            $answer = unpack('Cver/CREP',substr($resp,0,2));
            if($answer['REP'] != 0x00) {
                fclose($sock);
                return false;
            }
            return $sock;
        } else {
            fclose($sock);
            return false;
        }
    }
}


Описание функции:
res connect_socks5_proxy(host, hport, proxy, port [, timeout [, user]]) - функция подключается к указанному адресу host на порт hport через Socks5 прокси (proxy:port). В остальном, поведение функции аналогично предыдущей.

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

Пару слов по поводу протоколов...
HTTP - протокол текстовый. HTTP прокси после успешного выполнения CONNECT запроса переходит в асинхронный режим тунелирования данных. Это значит, что все данные, отправленные прокси серверу будут переданы конечному серверу. Со стороны конечного сервера такое подключение будет выглядеть как подключение обычного клиента. И все данные, которые сервер передаёт клиенту, будут незамедлительно переданы конечному клиенту.
Socks4 и Socks5 - бинарные протоколы. После установки соединения и авторизации (если она требуется), прокси сервер переходит в асинхронный режим тунелирования данных.
На мой взгляд, функции написаны достаточно понятно, так что, как работают эти протоколы я рассказывать не буду.

Собственно, на этом я и закончу первую часть статьи.
В следующей части я опишу stream-функции в PHP (которые появились только в PHP 5), с помощью которых можно реализовывать ещё и серверы.
Всем спасибо за внимание. С вами был Eugen.