Наши партнеры

UnixForum





Библиотека сайта rus-linux.net

Сеть IP — когда писать программы лень

Олег И.Цилюрик

Ремейк 3.08 от 27.04.2012г. статьи, опубликованной в 2002 году.

История вопроса

Первоначально (в октябре 2002г.) эта публикация была отработана в операционной системе QNX (версия 6.1) и подготовлена в для публикации в журнале «СТА» [1], позже она вошла в книгу [2]. За прошедшие 10 лет ко мне неоднократно обращались с вопросами и за советом. Оказалось, что и операционная система утратила свою актуальность, и многие публикации того времени выглядят не интересными, а вопрос упрощения серверного программирования использованием inetd или xinetd не утратил актуальности. Но, когда возникла возможность заново опубликовать эту статью относительно Linux, оказалось, что в Linux достаточно многие вещи нужно поменять (хоть и по мелочам). Из-за этого предлагается не прежняя версия текста, а новая, переписанная «по мотивам», поэтому и названная «ремейк». Ещё более поменялись примеры иллюстрирующих кодов. Я беру за основу переписывания исходный текст, поэтому он может выглядеть, местами, достаточно несуразно... Но исходной установкой было: осовременить существующий текст.

xinetd — кто он такой?

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

А именно – мы поговорим об некоторых способах создания прикладных TCP/IP серверов, крайне редко, к сожалению, используемых в прикладном программировании, которые почти не требуют написания программного кода. С чего начинает программист, когда перед ним ставится задача написания TCP/IP сервера, особенно если это клиент-серверное TCP/IP приложение для него – хронологически первое в его биографии? C судорожной проработки техники написания IP коммуникаций, сравнения механизмов BSD socket с механизмами TLI SRV4 и т. д. Далее: многие часы бдения над инициализацией socket, установлением соединения … и отладка, отладка, отладка. С учётом того обстоятельства, что отладка сетевых приложений на порядок сложнее локальных, даже при самом смелом использовании таких механизмов как BPF (Berkley Packet Filter) и сетевых сниферов, таких как tcpdump или wireshark. Не случайно, один из самых известных программистских анекдотов объясняет фразу «программировать TCP/IP» как , в более понятной форма, «писать музыку для борделя».

И это при том, что очень часто речь идёт о достаточно тривиальных и типовых случаях, когда сервер должен выполнять нечто, не намного превышающее по сложности ретрансляцию входных запросов (возможно, с минимальными изменениями в них), или отрабатывать однозначные последовательности «запрос-ответ», как в случаях с SQL сервером. Во многом, кроме того, сложившаяся «ручная» практика написания сетевых серверов наследуется разработчиком из практики программирования в Windows - все мы, в силу определённых исторических обстоятельств, выросли из Windows.

Но практически в каждом UNIX, а всё последующее изложение я буду вести относительно только UNIX-подобных систем, существует возможность широкого использования арсенала уже имеющихся под рукой утилит, в частности, начнём мы с суперсервера (super server) inetd. Демон inetd очень широко используется в системных сервисах UNIX, но, как-то так сложилось, что в целевых пользовательских программах его практически не используют. Но в последние годы на смену inetd пришёл xinetd — вот его мы и будем эксплуатировать в наших изысканиях.

Вспоминаем: что делает суперсервер inetd или xinetd? Во-первых, прослушивает сервисы (как TCP, так и UDP, но нас, для однозначности и простоты, будет интересовать далее, в основном, сервисы TCP), которые прописаны файле /etc/inetd.conf (собственно, прослушивает суперсервер, естественно, не сервисы, а порты, однозначно приписанные этим сервисам в файле /etc/services). Порты TCP/UDP подразделяются на «хорошо известные» порты (номера 0 - 1023), «динамические» (из диапазона 1024 - 49151), и «эфемерные» (49152 - 65535). Для прикладных пользовательских приложений предлагается выбирать номер порта именно из числа «эфемерных» (использование портов из числа «хорошо известных» вообще потребует от вас прав root, что достаточно рискованно для экспериментов). При появлении трафика на отслеживаемых портах (запрос со стороны клиента) – inetd/xinetd запускает эти сервисы (соответствующую этому сервису программу сервера). Эта логика суперсерверов в достаточной мере общеизвестна.

Во-вторых, при запуске программы сервера inetd/xinetd ассоциирует (дублирует, перенапраяляет) его потоки SYSIN/SYSOUTSYSERR, но это представляется нужным крайне редко) в/из открытый им же (inetd/xinetd) IP сокет (см. «во-первых»). Это гораздо менее известная его особенность.

Кроме того, inetd/xinetd обслуживает некоторые сетевые службы (сервисы) самостоятельно (internal services), например: chargen, datime и др. Эти возможности не будут нас интересовать, но могут быть также гибко задействованы в прикладных целях.

Наконец, inetd/xinetd поддерживает механизмы RPC, TCPMUX и многое другое - для чего используется несколько изменённая форма конфигурационной записи суперсервера, … но «это совсем другая история», которая нас в текущем изложении интересовать не будет совершенно.

Простейший ретранслятор

Давайте напишем такую простую (проще уже не бывает) программу (исходный файл mycopy.cc, исполнимый - mycopy):

#include "common.cc.h" 
#include "common.c.h" 

int main( void ) { 
   char buf[ 80 ]; 
   // установить построчный режим ввода, но и это не обязательно… 
   setvbuf( stdout, NULL, _IOLBF, 0 ); // или setlinebuf( stdout ); 
   while( true ) { 
      cin >> buf; 
      cout << buf << endl; 
   }
   return EXIT_SUCCESS; 
}

Что это? Это - полнофункциональный TCP/IP сетевой эхо-сервер! Не верите? Для того, чтобы заставить его функционировать в этом качестве делаем следующие шаги (многие из них потребуют прав root):

  • В файл (в конец файла) /etc/services дописываем (мы решили в качестве порта использовать 50000 из числа «эфемерных» портов):
    $ tail -n3 /etc/services 
    mycopy         50000/tcp               #my echo service 
    mycopy         50000/udp               #my echo service 
    
  • Конфигурационный файл суперсервера /etc/xinetd.conf (всё дальнейшее рассмотрение будет ориентировано на xinetd, как более современное решение) отсылает нас в каталог /etc/xinetd.d. Это достаточно обычная практика в конфигурировании нынешнего Linux: все потенциально входящие в /etc/xinetd.conf текстовые секции (то, что было представлено строками в /etc/inetd.conf) теперь представлены отдельными файлами в этом каталоге /etc/xinetd.d, которые обрабатываются последовательно, имя конкретного файла не играет роли, но может влиять на порядок обработки. Для наших целей достаточно создать в /etc/xinetd.d новый файл (имя его особого значения не имеет):
    $ cat /etc/xinetd.d/mycopy 
    service mycopy 
    {
    	disable = no  
    	protocol = tcp 
            wait = no 
            user = olej 
            server = /home/olej/mycopy 
    }
    

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

  • Запускаем, или перезапускаем суперсервер, который выполняется как сервис системы Linux:
    # /etc/init.d/xinetd/xinetd restart 
    Останавливается xinetd:                                    [  OK  ] 
    Запускается xinetd:                                        [  OK  ] 
    # /etc/init.d/xinetd/xinetd status 
    xinetd (pid  1164) выполняется... 
    
  • Если мы редактируем файлы конфигурации (/etc/services, /etc/xinetd.d/*) и хотим заставить xinetd перечитать новые значения, то пошлём ему сигнал:
    # ps -A | grep xinetd 
    11550 ?        00:00:00 xinetd 
    # kill -SIGHUP 11550
    

Всё! Теперь мы готовы к испытанию созданного нами ретранслирующего сервера:

$ telnet 127.0.0.1 50000 
Trying 127.0.0.1... 
Connected to 127.0.0.1. 
Escape character is '^]'. 
123 
123
asdfg
asdfg
^C
^]
telnet> close 
Connection closed. 

В абсолютных «плюсах» таких решения – предельная простота отладки для относительно несложных приложений (а большинство реальных серверов часто и не требует большей сложности): все комбинации запрос-ответ могут быть обкатаны в режиме простейшей текстовой консоли, после чего уже готовое изделие переносится под управление xinetd.

Часто в качестве первого возражения высказывается следующее: «… фи, а я люблю многопоточные сервера» (я, кстати, тоже их люблю). Но вариациями параметров конфигурации (строка wait) можно научить xinetd запускать новый экземпляр сервера по запросу от нового клиента (wait=no, по умолчанию: wait=yes). Только это не многопоточный сервер, как его называют, а ... как бы это лучше назвать по-русски? — многопроцессный: для каждого нового подсоединяющегося клиента порождается новая копия процесса сервера. Но, заметьте, что классические UNIX-сервера, построенные через fork() - они в точности такие же многопроцессные (собственно, это и было генеральным направлением построения серверов на протяжении почти 30 лет, до широкого распространения к концу 90-х годов API pthread_* и техники потоков).

Любая утилита GNU в качестве сервера

Ранее мы затронули пока ещё только один стандартный «строительный блок» из числа множества типовых сетевых средств UNIX (запуск сервера из-под xinetd). А что, если попытаться построить 3-х уровневую клиент-серверную систему (это сейчас так модно…) используя в качестве транспортного механизма нижнего уровня любую консольную утилиту UNIX? Тот же telnet, консольные клиенты PostgreSQL – psql, или MySQL - mysql и т. д.? Для этого придётся только отобрать стандартные ввод (SYSIN) и вывод (SYSOUT) у такой консольной утилиты, и отдать их обрамляющему тонкому клиенту, используемому xinetd в качестве сервера. Это уже будет посложнее, но … «игра стоит свеч»! Создадим такой класс (здесь просто проще и компактнее излагать это в терминологии C++), это может быть что-то типа (файл chld.cc):

#include <signal.h> 
#include <sys/wait.h> 

class chld { 
  static const int MAXSTR = 1024; 
  int fi[ 2 ], fo[ 2 ];			// pipe – для ввода и для вывода 
  pid_t pid; 
  char** create( const char *s ); 
public:
  char buf[ MAXSTR ]; 
  chld( const char*, int* fdi = NULL, int* fdo = NULL ); 
  ~chld( void ); 
  friend chld& operator <<( chld& c, const char *s ); 
  friend chld& operator >>( chld& c, char *s ); 
};
// это внутренняя private функция-член разбора ком. строки 
// и создания списка параметров запуска 
char** chld::create( const char *s ) { 
  char *p = (char*)s, *f; 
  int  n; 
  for( n = 0; ; n++ ) { 
    if( ( p = strpbrk( p, " " ) ) == NULL ) break; 
    while( *p == ' ' ) p++;      
  };
  char **pv = new char*[ n + 2 ]; 
  for( int i = 0; i < n + 2; i++ ) pv[ i ] = NULL; 
  p = (char*)s; 
  f = strpbrk( p, " " );   
  for( n = 0; ; n++ ) { 
    int k = ( f - p ); 
    pv[ n ] = new char[ k + 1 ]; 
    strncpy( pv[ n ], p, k ); 
    pv[ n ][ k ] = '\0'; 
    p = f; 
    while( *p == ' ' ) p++;      
    if( ( f = strpbrk( p, " " ) ) == NULL ) { 
      pv[ n + 1 ] = strdup( p ); 
      break;
    }
  }
  return pv; 
};
// вот главное «действо» класса – конструктор, здесь переназначаются 
// потоки ввода вывода (SYSIN & SYSOUT), клонируется вызывающий 
// процесс, и в нём вызывается новый процесс клиент со своими 
// параметрами: 
chld::chld( const char* pr,  int* fdi, int* fdo ) { 
  if( pipe( fi ) || pipe( fo ) ) perror( "pipe" ), exit( EXIT_FAILURE ); 
  // здесь создаётся список параметров запуска (если нужно) 
  char **pv = create( pr ); 
  pid = fork(); 
  switch( pid ) { 
    case -1: perror( "fork" ), exit( EXIT_FAILURE ); 
    case  0:   // дочерний клон 
      close( fi[ 1 ] ), close( fo[ 0 ] ); 
      if( dup2( fi[ 0 ], STDIN_FILENO ) == -1 || 
          dup2( fo[ 1 ], STDOUT_FILENO ) == -1 ) 
        perror( "dup2" ), exit( EXIT_FAILURE ); 
        close( fi[ 0 ] ), close( fo[ 1 ] ); 
        // запуск консольного клиента 
        if( execvp( pv[ 0 ], pv ) == -1 ) 
           perror( "execl" ), exit( EXIT_FAILURE ); 
        break;
    default:   // родительский процесс 
        for( int i = 0;; i++ ) 
          if( pv[ i ] != NULL ) delete pv[ i ]; else break; 
        delete [] pv; 
        close( fi[ 0 ] ), close( fo[ 1 ] ); 
        if( fdi != NULL ) *fdi = fo[ 0 ]; 
        if( fdo != NULL ) *fdo = fi[ 1 ]; 
        break;
   };
}
chld::~chld( void ) { 
   if( kill( pid, SIGKILL ) != 0 ) 
      perror( "kill" ), exit( EXIT_FAILURE ); 
   if( -1 == waitpid( pid, NULL, WNOHANG ) ) 
      perror( "waitpid" ), exit( EXIT_FAILURE ); 
}
// оператор записи того, что дочерний процесс ожидает получить с SYSIN 
chld& operator <<( chld& c, const char *s ) { 
   if( strlen( s ) < sizeof( c.buf ) - 1 ) strcpy( c.buf, s ); 
   else perror( "write length" ), exit( EXIT_FAILURE ); 
   if( *( c.buf + strlen( c.buf ) - 1 ) != '\n' ) strcat( c.buf, "\n" ); 
   if( write( c.fi[ 1 ], c.buf, strlen( c.buf ) ) == -1 )
   perror( "write pipe" ), exit( EXIT_FAILURE ); 
   return c; 
}
// оператор чтения того, что выводит в SYSOUT дочерний процесс 
chld& operator >>( chld& c, char *s ) { 
   int n = read( c.fo[ 0 ], c.buf, sizeof( c.buf ) - 1 ); 
   if( n == -1 ) perror( "read pipe" ), exit( EXIT_FAILURE ); 
   c.buf[ n ] = '\0'; 
   strcpy( s, c.buf ); 
   return c; 
}

Далее, мы сделаем вызывающую программу (сервер) parent (и использующую показанный класс chld), которую будем вызывать, например, такой командой:

$ parent child p1 p2 p3

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

$ make VARIANT=2 

Итак, код программы сервера (файл parent.cc):

#include <iostream> 
#include <iomanip> 
using namespace std; 

#include <stdlib.h> 
#include <stdio.h> 
#include <string.h> 

#include "chld.cc" 

static int conti = 1; 
static void handler( int signo ) { conti = 0; }; 

static char b[ 1024 ] = ""; 

int main( int argc, char *argv[] ) { 
   cout << "вариант сборки " << VARIANT << endl;
   if( argc < 2 ) 
      cout << "illegal parameters number" << endl, 
      exit( EXIT_FAILURE ); 
   signal( SIGINT, handler ); 
   for( int i = 1; i < argc; i++ )  // строка вызова дочернего процесса 
      sprintf( b + strlen( b ), "%s ", argv[ i ] ); 
   // описания разных вариантов сборки: 
#if VARIANT == 1 
   // вариант 1-й, с использованием перехваченных файловых 
   // дескрипторов, в манере самого классического C: 
   int fdi, fdo; 
   chld *ch = new chld( b, &fdi, &fdo ); 
   int n; 
   // читаем заголовочное сообщение о параметрах: 
   if( ( n = read( fdi, b, sizeof( b ) - 2 ) ) == -1 ) 
      perror( "read pipe" ), exit( EXIT_FAILURE ); 
   b[ n ] = '\0'; 
   cout << b << flush; 
   // а дальше – цикл ввода с консоли, передачи клиенту, 
   // ретрансляции клиентом, и вывод того, что от него получено: 
   while( conti ) { 
      fgets( b, sizeof( b ) - 2, stdin ); 
      if( !conti ) break; 
      strcat( b, "\n" );      
      if( write( fdo, b, strlen( b ) ) == -1 ) 
         perror( "write pipe" ), exit( EXIT_FAILURE ); 
      if( ( n = read( fdi, b, sizeof( b ) - 2 ) ) == -1 ) 
         perror( "read pipe" ), exit( EXIT_FAILURE ); 
      b[ n ] = '\0'; cout << b << flush; 
   };
#elif VARIANT == 2 
   // вариант 2-й, с использованием переопределённых операций 
   // << & >> - нам при этом даже нет необходимости определять 
   // и знать дескрипторы, через которые это происходит: 
   chld *ch = new chld( b ); 
   // читаем заголовочное сообщение о параметрах: 
   *ch >> b; cout << b << flush; 
   while( conti ) { 
      fgets( b, sizeof( b ) - 2, stdin ); 
      if( !conti ) break; 
      strcat( b, "\n" );      
      ( *ch << b ) >> b; cout << ch->buf << flush; 
   };
#else
#error 'недопустимое значение VARIANT (в Makefile)' 
#endif
   delete ch; // остановка дочернего процесса 
   cout << "child process stop!" << endl; 
   exit( EXIT_SUCCESS ); 
}

Если в приводимых текстах и есть некоторая громоздкость, то она связана с необходимостью своевременного воссоздания перевода строки ('\n'), который потребуется для последующей операции чтения из потока … Автономно (без xinetd, для которого всё это готовится) убедиться, что всё это нормально функционирует можно так:

$ ./parent ./child 1 2 3 
вариант сборки 2 
Args. was: ./child 1 2 3  
sdfbsfg 
sdfbsfg
3425
3425
^C
child process stop! 

Дописываем строку в /etc/services:

# cat /etc/services | grep ^parent 
parent          50001/tcp               #parent service 

И добавляем соответствующий новый файл parent в каталог /etc/xinetd.d:

$ ls /etc/xinetd.d/pa* 
/etc/xinetd.d/parent
$ cat /etc/xinetd.d/parent 
service parent 
{
    disable = no 
    protocol = tcp 
    wait = no 
    user = olej 
    server = /home/olej/parent 
    server_args = /home/olej/child p1 p2 p3 
}

Наконец, объясняем xinetd, что ему следует перечитать обновлённые нами конфигурации:

# ps -A | grep xinetd 
 1237 ?        00:00:00 xinetd 
# kill -SIGHUP 1237 

Теперь всё готово для того, чтобы проверить слаженность нашей серверной конструкции:

$ telnet localhost 50001 
Trying 127.0.0.1... 
Connected to localhost. 
Escape character is '^]'. 
вариант сборки 1 
Args. was: /home/olej/child p1 p2 p3  
123
123
asdf 
asdf
^]

telnet> quit 
Connection closed. 

Это в точности то, чего мы добивались: теперь в качестве исполняющей программы агента, путевое имя которой записано в конфигурационном файле /etc/xinetd.d/parent, может быть указана любая консольная утилита Linux! Здесь может быть, например, указан стандартный консольный менеджер PostgreSQL, запущенный как агент psql. Вот уже и макет полновесного клиента к SQL-серверу в 10 строк кода! (Может возникнуть вопрос: а почему нельзя указать в конфигурации непосредственно программу агента, child в нашем иллюстрирующем случае, минуя промежуточный сервер parent? Потому, что на практике жизнь оказывается разнообразнее наших придумок, и реальный агент может требовать передачи ему некоторой массы начальных сообщений вне клиент-серверного потока, например, настройка на определённые наборы данных, с которыми дальше уже будет производиться работа в режиме запрос-ответ.)

Обратим внимание на то обстоятельство, что и SYSIN и SYSOUT в коде любого такого сервера, и их использование в контексте xinetd — это всё символьно ориентированные потоки, или даже, если быть точнее, строчно ориентированные — если учесть умалчиваемые свойства потока cin или операций API чтения с консоли строки до завершающего '\n'. Тем не менее, как это может не показаться странным, к стандартным дескрипторам потоков при таком использовании может применяться всё множество специфических функций IP-сокетов, в виде: getpeerbyname( 0, ... ), getsockopt( 1, ... ), setsockopt( 1, ... ), send( 1, ... ), reqv( 0, ... ), sendto( 1, ... ), reqvfrom( 0, ... ) ... в общем — весь джентльменский набор API применимых к сокетам.

В порядке итогов...

Глядя на возможности, предоставляемые xinetd, которые далеко не все раскрыты по различным вариантам их использования, иногда задумаешься (я, по крайней мере) — а так ли часто реально существует необходимость писать в каждом случае развёрнутый сервер с детализированным использованием сокетов? Кстати, насколько мне помнится, большинство традиционных UNIX-серверов для таких протоколов как: telnet, ftp, rlogon, rsh … выполнены именно технике использования суперсерверов. Программисты-разработчики часто не любят использовать суперсервер в своих проектах... Но это связано с тем, что программисты обычно чувствуют себя гораздо менее уверенными в сфере администрирования сетевой системы, чем в вопросах написания программного кода. Но это нисколько не есть аргумент против ориентации на суперсервера!

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


[1]. Журнал «Современные Технологии Автоматизации», Москва, http://www.cta.ru/online/online_progr-nets.htm

[2]. Д.Алексеев, А.Волков, Е.Горошко, М.Горчак, Р.Жавнис, Д.Сошин, О.Цилюрик, А.Чиликин, «Практика работы с QNX», М.: «КомБук», 2004, 432 стр., ISBN 5-94740-009-X.

[3]. Архив с примерами исходных кодов.