Клиенты и серверы

Модель клиент-сервер

 

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

 

 

В базовой модели клиент-сервер все процессы в распределенных системах делятся на две возможно перекрывающиеся группы. Процессы, реализующие некоторую службу, например службу файловой системы или базы данных, называются серверами (servers). Процессы, запрашивающие службы у серверов путем посылки запроса и последующего ожидания ответа от сервера, называются клиентами (clients). Взаимодействие клиента и сервера, известное также под названием режим работы запрос-ответ (request-reply behavior), иллюстрирует рис. 1.18.

 

 

Рис. 1.18. Обобщенное взаимодействие между клиентом и сервером

 

Если базовая сеть так же надежна, как локальные сети, взаимодействие между клиентом и сервером может быть реализовано посредством простого протокола, не требующего установления соединения. В этом случае клиент, запрашивая службу, облекает свой запрос в форму сообщения с указанием в нем службы, которой он желает воспользоваться, и необходимых для этого исходных данных. Затем сообщение посылается серверу. Последний, в свою очередь, постоянно ожидает входящего сообщения, получив его, обрабатывает, упаковывает результат обработки в ответное сообщение и отправляет его клиенту. Использование не требующего соединения протокола дает существенный выигрыш в эффективности. До тех пор пока сообщения не начнут пропадать или повреждаться, можно вполне успешно применять протокол типа запрос-ответ. К сожалению, создать протокол, устойчивый к случайным сбоям связи, — нетривиальная задача. Все, что мы можем сделать, — это дать клиенту возможность повторно послать запрос, на который не был получен ответ. Проблема, однако, состоит в том, что клиент не может определить, действительно ли первоначальное сообщение с запросом было потеряно или ошибка произошла при передаче ответа. Если потерялся ответ, повторная посылка запроса может привести к повторному выполнению операции. Если операция представляла собой что-то вроде «снять 10 000 долларов с моего банковского счета», понятно, что было бы гораздо лучше, если бы вместо повторного выполнения операции вас просто уведомили о произошедшей ошибке. С другой стороны, если операция была «сообщите мне, сколько денег у меня осталось», запрос прекрасно можно было бы послать повторно. Нетрудно заметить, что у этой проблемы нет единого решения.

В качестве альтернативы во многих системах клиент-сервер используется надежный протокол с установкой соединения. Хотя это решение в связи с его относительно низкой производительностью не слишком хорошо подходит для локальных сетей, оно великолепно работает в глобальных системах, для которых ненадежность является «врожденным» свойством соединений. Так, практически все прикладные протоколы Интернета основаны на надежных соединениях по протоколу TCP/IP. В этих случаях всякий раз, когда клиент запрашивает службу, до посылки запроса серверу он должен установить с ним соединение. Сервер обычно использует для посылки ответного сообщения то же самое соединение, после чего оно разрывается. Проблема состоит в том, что установка и разрыв соединения в смысле затрачиваемого времени и ресурсов относительно дороги, особенно если сообщения с запросом и ответом невелики.

 

Примеры клиента и сервера

 

Чтобы внести большую ясность в то, как работают клиент и сервер, в этом пункте мы рассмотрим описание клиента и файлового сервера на языке С. И клиент, и сервер должны совместно использовать некоторые определения, которые мы соберем вместе в файл под названием header.h, текст которого приведен в листинге 1.3. Эти определения затем включаются в тексты программ клиента и сервера следующей строкой:

 

#include <header.h>

 

Эта инструкция вызовет активность препроцессора, который посимвольно вставит все содержимое файла header.h в текст программы до того, как начнется ее компиляция.

 

Листинг 1.3.Файл header.h, используемый клиентом и сервером

 

/ * Определения, необходимые и клиентам, и серверам */

#define TRUE 1

/* Максимальная длина имени файла */

#define МАХ_РАТН 255

/* Максимальное количество данных, передаваемое за один раз */

#define BUF_SIZE 1024

/* Сетевой адрес файлового сервера */

#define FILE SERVER 243

/* Определения разрешенных операций */

/* создать новый файл */

#define CREATE 1

/* считать данные из файла и вернуть их */

#define READ 2

/* записать данные в файл '^1

#define WRITE 3

/* удалить существующий файл */

#define DELETE 4

/* Коды ошибок */

/* операция прошла успешно */

#define OK 0

/* запрос неизвестной операции */

#define E_BAD_OPER - 1

/* ошибка в параметре */

#define E_BAD_PARAM - 2

/* ошибка диска или другая ошибка чтения-записи */

#define Е_IO 3

/* Определение формата сообщения */

struct message {

long sourcelong; /*идентификатор источника */

long dest; /* идентификатор приемника *1

long opcode; /* запрашиваемая операция */

long count; /* число передаваемых байт */

long offset; /* позиция в файле, с которой начинается

ввод-вывод */

long result; /* результат операции */

char name[MAX_PATH]; /* имя файла, с которым производятся

операции */

char data[BUF SIZE]; /* данные, которые будут считаны или

записаны */

};

 

Итак, перед нами текст файла header.h. Он начинается с определения двух

констант, МАХ_РАТН и BUF_SIZE, которые определяют размер двух массивов, используемых в сообщении. Первая задает число символов, которое может содержаться в имени файла (то есть в строке с путем типа /usr/ast/books/opsys/chapter1.t).

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

Каждый ответ содержит код результата. Если операция завершена успешно, код результата обычно содержит полезную информацию (например, реальное число считанных байтов). Если нет необходимости возвращать значение (например, при создании файла), используется значение ОК. Если операция по каким-либо причинам окончилась неудачей, код результата (E_BAD_OPER, E_BAD_PARAM и др.) сообщает нам, почему это произошло. Наконец, мы добрались до наиболее важной части файла header.h — определения формата сообщения. В нашем примере это структура с 8 полями. Этот формат используется во всех запросах клиентов к серверу и ответах сервера. В реальных системах, вероятно, фиксированного формата у сообщений не будет (поскольку не во всех случаях нужны все поля), но здесь это упростит объяснение. Поля source и dest определяют, соответственно, отправителя и получателя. Поле opcode — это одна из ранее определенных операций, то есть create, read, write или delete. Поля count и offset требуются для передачи параметров. Поле result в запросах от клиента к серверу не используется, а при ответах сервера клиенту содержит значение результата. В конце структуры имеются два массива. Первый из них, name, содержит имя файла, к которому мы хотим получить доступ. Во втором, data, находятся возвращаемые сервером при чтении или передаваемые на сервер при записи данные. Давайте теперь рассмотрим код в листингах 1.4 и 1.5. Листинг 1.4 — это программа сервера, листинг 1.5 — программа клиента. Программа сервера достаточно элементарна. Основной цикл начинается вызовом receive в ответ на сообщение с запросом. Первый параметр определяет отправителя запроса, поскольку в нем указан его адрес, а второй указывает на буфер сообщений, идентифицируя, где должно быть сохранено пришедшее сообщение. Процедура receive блокирует сервер, пока не будет получено сообщение. Когда оно наконец приходит, сервер продолжает свою работу и определяет тип кода операции. Для каждого кода операции вызывается своя процедура. Входящее сообщение и буфер для исходящих сообщений заданы в параметрах. Процедура проверяет входящее сообщение в параметре ml и строит исходящее в параметре т2. Она также возвращает значение функции, которое передается через поле result. После посылки ответа сервер возвращается к началу цикла, выполняет вызов receive и ожидает следующего сообщения. В листинге 1.5 находится процедура, копирующая файлы с использованием сервера. Тело процедуры содержит цикл чтения блока из исходного файла и записи его в файл-приемник. Цикл повторяется до тех пор, пока исходный файл не будет полностью скопирован, что определяется по коду возврата операции чтения — должен быть ноль или отрицательное число. Первая часть цикла состоит из создания сообщения для операции чтения и пересылки его на сервер. После получения ответа запускается вторая часть цикла, в ходе выполнения которой полученные данные посылаются обратно на сервер для записи в файл-приемник. Программы из листингов 1.4 и 1.5 — это только набросок кода. В них опущено множество деталей. Так, например, не приводятся процедуры do_xxx (которые на самом деле выполняют работу), отсутствует также обработка ошибок. Однако общая идея взаимодействия клиента и сервера вполне понятна. В следующих пунктах мы ближе рассмотрим некоторые дополнительные аспекты модели клргент-сервер.

 

Листинг 1.4.Пример сервера

#inclucie <header.h>

void main(void) {

struct message m1. m2; /* входящее и исходящее сообщения */

int r; /* код результата */

whlle(TRUE) { /* сервер работает непрерывно */

receive(FILE_SERVER,&m1); /* блок ожидания сообщения */

switch(mi.opcode) { /* в зависимости от типа запроса*/

case CREATE: r= do_create(&m1,. &m2); break;

case READ: г= do_delete(&m1, &m2); break;

case WRITE: г= do_read(&m1, &m2); break;

case DELETE: г= do_write(&m1, &m2); break;

default: г= E_BAD_OPER;

}

m2.result = r; /* вернуть результат клиенту */

send(m1.source, &m2); /* послать ответ */

 

Листинг 1.5. Клиент, использующий сервер из листинга 1.4 для копирования файла