Поводом для появления этих коротких заметок с сознательно провокационным названием явилось намерение напомнить о том, что иногда для того, чтобы описать в программе нечто, по существу своему являющееся весьма сложным, могут существовать способы выразить это намного проще. И о том, что прежде, чем "барабанить" программный код, разумно потерять некоторое время на размышления о наиболее коротких путях достижения цели. А как результат такого подхода: краткость, простота, и, что гораздо ценнее - надёжность, отсутствие ошибок и устойчивость программ в период эксплуатации и сопровождения.
А именно - мы поговорим об некоторых способах написания прикладных TCP/IP серверов (да и клиентов тоже), крайне редко, к сожалению, используемых в прикладном программировании. С чего начинает программист, когда перед ним ставится задача написания TCP/IP сервера, особенно если это клиент - серверное TCP/IP приложение для него - хронологически первое в биографии? C судорожной проработки техники написания IP коммуникаций, сравнения механизмов BSD socket с механизмами TLI SVR4 и т.д. Например, пользуясь [1,3,4,5,6,8], которые, безусловно, принадлежат к числу лучших изложений предмета на русском языке. Далее - многие часы бдения над инициализацией socket, установлением соединения … и отладка, отладка, отладка. С учётом того обстоятельства, что отладка сетевых приложений на порядок сложнее локальных, даже при самом смелом использовании таких механизмов как "Berkley packet filter" и сетевые сниферы, например tcpdump. Не случайно, один из самых известных программистских анекдотов ставит знак тождества между понятиями "программировать TCP/IP" и "писать музыку для борделя".
И это при том, что очень часто речь идёт о достаточно тривиальных и типовых случаях, когда сервер должен выполнять нечто, не намного превышающее по сложности ретрансляцию входных запросов, или отрабатывать однозначные последовательности "запрос-ответ", как в случаях с SQL сервером. Во многом, сложившаяся "ручная" технология написания сетевых серверов наследуется разработчиком из практики программирования в Windows [3], а все мы, в силу определённых исторических обстоятельств "выросли из Windows".
Но практически в каждом UNIX, а всё последующее изложение я буду вести относительно QNX 6.X (который "UNIX в большей степени, чем многие из урождённых UNIX", имея в виду POSIX совместимость), существует возможность широкого использования арсенала существующих утилит, в частности, начнём мы с суперсервера (super server) inetd. (А как же программисты в Windows? "Я скорблю вместе с вами!"). Демон inetd очень широко используется в системных сервисах UNIX, но, как-то так сложилось, что в целевых пользовательских программах его практически не используют.
Вспомним, что делает inetd [1,7]?
Во-первых, прослушивает сервисы (как TCP, так и UDP, но нас, для однозначности и простоты, будет интересовать далее в основном TCP), прописанные в файле /etc/inetd.conf (собственно, прослушивает он, естественно, не сервисы, а порты, приписанные этим сервисам в файле /etc/services). Порты TCP/UDP подразделяются на "хорошо известные порты" (номера 0 - 1023), "динамические" (из диапазона 1024 - 49151), и "эфемерные" (49152 - 65535) [1,2]. Для прикладных пользовательских приложений предлагается выбирать номер порта именно из числа "эфемерных". При появлении трафика на отслеживаемых портах (запрос со стороны клиента) - inetd запускает эти сервисы (соответствующую сервису программу сервера). Эта особенность inetd в достаточной мере общеизвестна.
Во-вторых, при запуске программы сервера inetd ассоциирует (дублирует) его потоки SYSIN / SYSOUT (и SYSERR, но это представляется нужным крайне редко) в/из открытый им же (inetd) IP сокет (см. "во-первых"). Это - гораздо менее известная его особенность.
Кроме того, inetd обслуживает некоторые сетевые службы (сервисы) самостоятельно (internal services), например: chargen, datime и др. Эти возможности не будут нас интересовать, но могут быть также гибко задействованы в прикладных целях.
Наконец, inetd поддерживает механизмы RPC, TCPMUX и многое другое - для чего используется несколько изменённая форма конфигурационной строки в inetd.conf, … но "это совсем другая история", которая нас в текущем изложении интересовать не будет совершенно.
Давайте напишем такую простую (проще уже не бывает) программу (исходный файл mycopy.cpp, исполнимый - mycopy): void
main( void ) {
char buf[ 80 ];
// установить построчный режим ввода, но и это не обязательно…
setvbuf( 1, NULL, _IOLBF, 0 );
while( true ) {
cin >> buf;
cout << buf << endl;
}
}
Что это? Это - полнофункциональный TCP/IP сетевой эхо-сервер (или UDP/IP - нужное подставить)! Не верите? Делаем:
файл /etc/services (мы решили в качестве порта использовать 50000):
mycopy 50000/tcp
или (здесь можно "и" - дополнительной строкой: и то и другое)...
mycopy 50000/udp
файл /etc/inetd.conf:
mycopy stream tcp nowait root /<путь>/myprog myprog
или
mycopy dgram udp wait root /<путь>/myprog myprog ...
здесь ещё в конце можно дописать до 5 параметров argv[], которые inetd передаст серверу при запуске (кстати, начиная именно с argv[0], argv[0] автоматом сам не заполняется, поэтому я последний параметр "myprog" и прописал).
естественно, запускаем inetd, если он у кого не запущен:
inetd &
или, если он запущен, заставляем его перечитать конфигурационный файл /etc/inetd.conf (это делается посылкой по pid inetd сигнала SIGHUP командой kill).
Всё - можете обращаться TCP/IP клиентом по порту 50000 (для быстрого написания тестовых клиентов, например, очень хорош Perl, на котором клиент может занять ещё 5-6 значащих строк...) - и получать эхо-подтверждения от удалённого хоста, нечто такое:
#!/usr/bin perl -w
use Socket;
$inet_addr = inet_aton( "18.0.0.15" );
$paddr = sockaddr_in( 50000, $inet_addr );
connect( TO_SERVER, $paddr ) or die "couldn't connect\n"
while(<>) {
print TO_SERVER;
print <TO_SERVER>
};
Единственная "странная" строка в исходной "программе" сервера - это setvbuf(...) - она производит установку построчного режима буферизации вывода SYSOUT, т.к. OS не считает сокет терминалом и не устанавливает для него этот режим, но и это не обязательно - можете её выкинуть, это вопрос эффективности интерактивного ответа ... экспериментируйте.
Можно поступить ещё проще (если вам нужен пока тестовый клиент, не включающий каких либо ваших специальных возможностей), конечно же, telnet - вот так:
telnet host mycopy
или, например, так:
telnet localhost 50000
Здесь мы уже затронули ещё один стандартный "строительный" блок из числа множества типовых сетевых средств UNIX (помимо inetd). А что, если попытаться построить 3-х уровневую клиент-серверную систему (это сейчас так "модно"…) используя в качестве транспортного механизма (2-го уровня) любую консольную утилиту UNIX? Тот же telnet, консольный клиент PostgreSQL - psql и т.д.? Для этого придётся "отобрать" стандартные ввод (SYSIN) и вывод (SYSOUT) у консольной утилиты, и "отдать" их обрамляющему "тонкому клиенту" (это может быть развитый GUI интерфейс). Это уже будет посложнее, но … "игра стоит свеч"! Создадим такой класс (мне здесь уже легче излагать в терминологии C++), он определённо избыточен, о чём я скажу ниже (все #include - исключены: это легко восполнить):
class chld {
static const int MAXSTR = 1024;
int fi[ 2 ], fo[ 2 ]; // pipe - для ввода и для вывода
pid_t pid;
char** create( const char *s );
public:
ifstream is;
ofstream os;
char buf[ MAXSTR ];
chld( const char*, int* fdi = NULL, int* fdo = NULL );
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 ];
is.attach( fo[ 0 ] );
os.attach( fi[ 1 ] );
break;
};
};
// оператор записи того, что дочерний процесс ожидает получить с 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;
};
Для проверки работы сделаем такую автономную программу child (простейший ретранслирующий консольный клиент):
int main( int argc, char *argv[] ) {
// здесь мы сообщим, с какими же параметрами нас запускали
cout << "Args. was: ";
for( int i = 0; i < argc; i++ ) cout << argv[ i ] << " ";
cout << endl;
char buf[ 128 ];
// а дальше - простейшая ретрансляция ...
while( true ) {
if( ( cin >> buf ).eof() ) break;
cout << buf << endl;
};
exit( EXIT_SUCCESS );
};
И сделаем вызывающую программу parent (и использующую класс chld), которую будем вызывать, например, такой командой:
#parent 'child p1 p2 p3'
- т.е., мы хотим вызывать нашего (а значит и произвольного) клиента с произвольным набором параметров его вызова. Два слова об избыточности: я "нарисую" 3 варианта использования перехваченных файловых дескрипторов ввода-вывода, конечно, в реальной жизни вам достаточно одного механизма, того, который вам больше нравится. Начало везде будет одинаковое и тривиальное:
static char b[ 1024 ];
void main( int argc, char *argv[] ) {
if( argc < 2 )
cout << "illegal parameters number" << endl,
exit( EXIT_FAILURE );
// вариант 1-й, с использованием перехваченных файловых дескрипторов,
// в манере самого классического C:
int fdi, fdo;
chld *ch = new chld( argv[ 1 ], &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( true ) {
cin >> b; 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;
};
// вариант 2-й, с использованием переопределённых операций << & >>,
// нам при этом даже нет необходимости определять и знать
// дескрипторы, через которые это происходит:
chld *ch = new chld( argv[ 1 ] );
// читаем заголовочное сообщение о параметрах:
*ch >> b; cout << b << flush;
while( true ) {
cin >> b; ( *ch << b ) >> b; cout << ch->buf << flush;
};
// вариант 3-й, он тоже, подобно 2-му, использует iostream, но
// манипулирует с ними более традиционными функциями ...
chld *ch = new chld( argv[ 1 ] );
char c;
ch->is.get( b, sizeof( b ), '\n' ); ch->is.get( c );
cout << b << endl;
while( true ) {
cin >> b; strcat( b, "\n" );
ch->os.write( b, strlen( b ) ); ch->os.flush();
ch->is.get( b, sizeof( b ), '\n' ); ch->is.get( c );
cout << b << endl;
};
Если в приводимых текстах и есть некоторая громоздкость, то она связана с необходимостью своевременного воссоздания перевода строки ('\n'), который потребуется для последующей операции чтения из потока.
К чему это всё описано?
Очень просто таким образом строить маленькие и простенькие (а иногда и "не") программы для сети (тесты, например ...). А иначе - прописывайте всю "кухню" с socket, с целью только получить эхо... Конечно - это скелет: хорошо бы выход сделать из цикла (из задачи) по определённому условию (хотя inetd может сам не только запускать, но и снимать сервисы при отсутствии активности, но я этой возможности не рассматриваю). Да и на выходе просто эхо - это неинтересно. Но здесь может "висеть", например, полновесная консольная система SQL запросов к PostgreSQL, или даже (переадресованный через потоки, pipe ...) просто сам стандартный консольный менеджер PostgreSQL, запущенный как system( "psql" ). Вот уже и макет полновесного SQL-сервера в 10 строк кода!
void main( void ) {
chld *ch = new chld( "psq" );
// несколько последовательных чтений заголовочного вывода...
*ch >> b >> b >> b;
while( true ) {
cin >> b; ( *ch << b ) >> b;
cout << ch->buf << flush;
};
Возвратимся, однако, к использованию inetd, для неограниченной возможности использования которого мы и "городили" весь этот программный код. Как вы понимаете, и SYSIN и SYSOUT и inetd при их использовании в таком качестве: символьно ориентированные, или даже точнее: строчно ориентированные - если учесть умалчиваемые свойства потока cin или операций чтения в Perl (клиенте) по чтению строки до завершающего '\n'. Как это ни может показаться странным, к стандартным дескрипторам потоков в таком использовании может применяться всё множество специфических функций IP-сокетов: getpeerbyname(0,...), getsockopt(1,...), setsockopt(1,...), send(2,...), reqv(0,...), sendto(1,...), reqvfrom(0,...) ..., в общем - весь джентльменский набор.
В абсолютных "плюсах" такого решения - предельная простота отладки для относительно несложных приложений (а большинство реальных серверов часто и не требует большей сложности): все комбинации запрос-ответ могут быть обкатаны в режиме простейшей текстовой консоли, после чего уже готовое изделие "подвешивается" на inetd.
Часто первым высказывается такое возражение: "фи, я люблю многопоточные сервера" (я, кстати, тоже люблю...), но вариациями параметров строки /etc/inetd.conf (wait/nowait) можно научить inetd запускать новый экземпляр сервера по запросу от нового клиента. Только это не многопоточный сервер, а, как бы это сказать... "много-процессный": для каждого нового подсоединяющегося клиента порождается новая копия процесса сервера. Но, заметьте, что классические UNIX-сервера, построенные через fork() - они в точности такие же "много-процессные" (собственно, так происходило до появления аппаратной поддержки архитектурой процессоров механизмов thread, и их отображение в API OS). Это и было традиционным пониманием многопоточности на протяжении предыдущих 10-15-ти лет.
А вот так, достаточно необычно, может выглядеть многопоточный UDP-сервер с использованием inetd (обычно в inetd.conf для UDP серверов ставится "wait", что препятствует дальнейшему прослушиванию порта до завершения текущего запущенного экземпляра сервера), обработка ошибок - опущена:
void main( void ) {
const MAXLEN = 128;
char buf[ MAXLEN ];
// чтение пакета из SYSIN
struct socadr_in adr;
int len = sizeof( adr );
int rc = recvfrom( 0, buf, MAXLEN, (struct socadr*)&adr, &len );
int sc = socket( AF_INET, SOCK_DGRAM, 0 );
connect( sc, (struct socadr*)&adr, sizeof( struct socadr_in ) );
// завершение родительского процесса и замещение его дочерним:
if( fork() != 0 ) exit( EXIT_SUCCESS );
while( true ) {
// обработка запроса из buf, и помещение в buf результата
// . . . . . . . . . . . . . . . . . . . . . . . . . . . .
write( sc, buf, strlen( buf ) );
rc = read( sc, buf, MAXLEN );
}
};
Что здесь произошло? Первый принятый пакет inetd передал серверу через SYSIN (fd = 0), но запущенный inetd экземпляр даже не стал на него отвечать: он создал свою копию в памяти, которая прослушивает и отвечает на пакеты с сокета sc, настроенного по параметрам (адрес:порт), принятым с SYSIN. После чего исходный сервер благополучно завершился. А ответ, даже на первый, принятый родителем, пакет, как и на все последующие - осуществляет порождённая дочерняя копия.
Глядя на возможности, предоставляемые inetd, которые ещё далеко не все раскрыты по различным вариантам их использования, иногда задумаешься (я, по крайней мере) - а так ли часто нужно писать в каждом случае развёрнутый сервер с детализированным использованием сокетов? Кстати, насколько я знаю, большинство традиционных UNIX-серверов для таких служб как: telnet, ftp, rlogon, rsh …(может ещё каких?) выполнены в точности именно в этой технике.
И последнее (это имеет отношение не столько к технике программирования, сколько к технологии создания комплексного программного продукта): описанная техника может использоваться, начиная с самых ранних фаз согласования с заказчиком прикладных протоколов системы. На этом этапе обычно нет ясности с деталями целевых протоколов, и именно с подобными инструментами они могут быть отработаны ранее написания итоговых, возможно сложных и изощрённых, сетевых компонент комплекса, не вызывая необходимости их последующей корректировки, или существенной переработки.
Маленькое лирическое отступление: в программных фрагментах, приводимых выше, я максимально использовал стандартные программы, утилиты и механизмы UNIX, "тасуя" их в различных комбинациях. В этих возможностях проявляется "мелко дисперсионная" философия OS UNIX, которая вся состоит из множества небольших компонент, чётко регламентированных, имеющих единые и универсальные механизмы связи и взаимодействия с подобными соседями-компонентами "слева, справа, снизу, вверху …". Это принципиально отличает её от "интегрированной" Windows, в которой компоненты-монстры (Word, IE, Access …), имеющие по … 3 миллиона возможностей (вы часто пользуетесь всеми ими в Word?), взаимодействуют с соседями-монстрами по одним из только им двоим известным механизмом (DDE, OLE, COM …). Именно эта, вытекающая из философии UNIX, высокая "гранулярность" системы позволяет, вместо создания сложных монолитных программ, использовать "колаж" из стандартных средств и возможностей. Это и позволяет сложное сделать "не простым, а очень простым", что и показывалось только на одном частном примере: TCP/IP. Но та же методология применима и ко множеству иных сфер программирования в UNIX. Экспериментируйте! Не переносите слепо привычки, приобретённые в других (и далеко не лучших) средах программирования, в UNIX!
Благодарности: Настоящие заметки просто не имели бы шансов появиться, по крайней мере, в таком виде, если бы не имели место продолжительные обсуждения предмета на русскоязычном форуме по QNX: qnx.org.ru/forum всеми его, более чем 400-ми, на сегодня, участниками.
Литература: В перечне литературы я привожу явно "расширенный" перечень источников, описывающих рассматриваемые предметы и использовавшиеся при подготовке настоящего текста. Но это ещё, пожалуй, и лучшие русскоязычные источники из всей массы литературы, выпущенной за последние 5 лет (в ряду заметно большей протяжённости списка - "не лучших").
[1] - Йон Снайдерс, "Эффективное программирование TCP/IP. Библиотека программиста" - СПб.: "Питер", 2001. - 320 стр., ISBN 5-318-00453-9
[2] - C.Зотов, "Протоколы INTERNET" - СПб.: BHV, 1998. - 304 стр., ISBN 5-7791-0076-4
[3] - Джамса К., Коуп К., "Программирование для Internet в среде Windows" - СПб.: "Питер", 1996. - 688 стр., ISBN 5-88782-088-8
[4] - Теренс Чан, "Системное программирование на C++ для UNIX" - Киев: BHV, 1997. - 592 стр., ISBN 5-7315-0013-4
[5] - Кейт Хэвиленд, Дайна Грэй, Бен Cалама, "Системное программирование в UNIX" - М.: ДМК Пресс, 2000. - 368 стр., ISBN 5-94074-008-1
[6] - С.Сэтчелл, Х.Клиффорд, "LINUX IP Stacks в комментариях" - К.: "ДиаСофт", 2001. - 288 стр., ISBN 966-7393-83-6
[7] - Олаф Кирх, "LINUX для профессионалов. Руководство администратора сети" - СПб.: "Питер", 2000. - 368 стр., ISBN 5-8046-0047-8
[8] - Робачевский А.М., "Операционная система UNIX" - СПб.: BHV, 1997. - 528 стр., ISBN 5-7791-0057-8
На любые возникшие по материалу вопросы автор с готовностью ответит по электронной почте: Олег Цилюрик, фирма "LOT Ltd.", г. Харьков, Украина, <olej@front.ru>, <olej.tsil@ua.fm>, <olej@lot.kharkov.ua>. |