Построение распределенных систем на Java

Использование API java.net

Разбить на страницы
Показывать лекцию целиком

Рабочий каталог расположен в Practice.

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

Архитектура протоколов TCP/IP, известная как набор протоколов TCP/IP, возникла в результате исследований в области протоколов и разработок, выполнявшейся в экспериментальной сети с коммутацией пакетов под названием ARPANET, которая была основана Управлением перспективных исследовательских программ Министерства обороны США ( DARPA )[3.26]. Этот набор протоколов состоит из большого собрания протоколов, изданных Координационным советом по сети Internet (IAB) в качестве стандартов для Internet.

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

Ввиду этого в задаче обмена информацией выделяют пять основных относительно независимых уровней:

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

    Уровень доступа к сети связан с обменом данными между конечной системой (рабочей станцией, сервером и т.д.) и сетью, к которой подсоединена эта система. Узел-отправитель должен передать в сеть адрес узла-адресата, чтобы сеть могла направить данные по месту требования.

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

    Независимо от природы приложений обмен данными должен быть надежным, т.е. хотелось бы иметь уверенность в том, что все данные попали к приложению-адресату и что эти данные получены в том порядке, в котором они были отправлены. Механизмы обеспечения надежности, по сути, независимы от природы приложений, таким образом, имеет смысл выделить такие механизмы в один общий уровень, совместно используемый всеми приложениями. Этот уровень называется транспортным. Чаще всего для него применяется протокол управления передачей ( Transmission Control Protocol - TCP ).

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

    Итак, чтобы обмен информацией был возможен, каждый элемент системы должен иметь уникальный адрес. Фактически, нужно указать два уровня адресации. Каждый узел сети должен обладать своим уникальным глобальным сетевым адресом, это позволит доставить данные соответствующему узлу. Каждый процесс (компонент) на узле должен иметь адрес, который был бы уникальным в пределах этого узла, что даст возможность транспортному протоколу ( TCP ) доставить данные нужному процессу. Этот адрес - адрес процесса - в терминологии протоколов семейства TCP/IP называют портом.

    Для большинства приложений, выполняющихся в рамках архитектуры протокола TCP/IP, протоколом транспортного уровня выступает TCP. Этот протокол обеспечивает надежное соединение для передачи данных от одного приложения к другому. Кроме него существует еще один широко используемый протокол транспортного уровня, входящий в набор протоколов TCP/IP: пользовательский протокол датаграмм ( User Datagram Protocol - UDP ). Протокол UDP предоставляет сервис без установления соединения, предназначенный для процедур на уровне приложения; он не гарантирует доставку, сохранение последовательности при приеме переданных пакетов или защиту от их дублирования. Он позволяет процессу отправлять сообщения другим процессам, с помощью минимального протокольного механизма. Протокол UDP выполняет крайне ограниченный набор функций, так как работает без установления соединения. По сути, он добавляет к протоколу IP некоторые возможности адресации портов.

    Рассмотрим вкратце возможности, которые предоставляет нам java и ее пакет java.net.

    UDP

    Начнем мы с API, использующего протокол UDP. UDP является протоколом, применяющим для передачи датаграммы. Как уже говорилось, этот протокол не является надежным, поскольку сообщения, передаваемые с его помощью, могут теряться или приходить в другой последовательности, отличной от последовательности их отправки. Таким образом, для обеспечения надежной передачи необходимо организовывать надстройку над этим протоколом, обеспечивающую, например, нумерацию пакетов, повторную передачу пакетов при истечении времени ожидания и т.д. Длина одного сообщения (одной датаграммы) при использовании этого протокола ограничена 65536 байтами (причем многие реализации вообще ограничивают размер датаграммы 8К), в случае необходимости пересылки порции данных большего размера они должны быть разбиты на куски отправителем и снова собраны получателем. Передача сообщения - не блокирующая, прием - блокирующий с возможностью прерывания по истечении времени ожидания.

    Для работы с UDP в пакете java.net определены следующие классы.

  • DatagramPacket (датаграмма). Конструктор этого класса принимает массив байт и адрес процесса-получателя ( IP -адрес узла и порт). Класс предназначен для представления единичной дата-граммы (сообщения). Этот класс используется как для создания сообщения с целью последующей его отправки, так и при приеме сообщения (функция приема возвращает экземпляр этого класса).
  • DatagramSocket.Предназначен для посылки/приема UDP -дата-грамм. Один из конструкторов принимает в качестве аргумента порт, с которым связывается сокет, другой конструктор, без аргументов, задействует в качестве порта первый попавшийся свободный порт. Класс имеет методы send и receive, для, соответственно, передачи и приема датаграмм. Метод setSoTimeout устанавливает тайм-аут для операций сокета.
  • Ниже приведены две простые программы, использующие рассмотренные механизмы для организации взаимодействия.

    UDPClient

    Первая программа создает сокет, соединяется с сервером (порт 6789), пересылает ему сообщение и ждет ответа (пример 3.1).

    1  import java.net.*;
    2  import java.io.*;
    3  public class UDPClient{
    4  public static void main(String args[]){
    5    // аргументы - сообщение и адрес сервера
    6    try {
    7       DatagramSocket aSocket = new DatagramSocket(); // create socket
    8       byte [] message = args[0].getBytes();
    9       InetAddress aHost = InetAddress.getByName(args[1]); // DNS lookup
    10      int serverPort = 6789;
    11      DatagramPacket request =
    12      new DatagramPacket(message,   args[0].length(), aHost, serverPort);
    13      aSocket.send(request);
    14      //send message
    15      byte[] buffer = new byte[1000];
    16      DatagramPacket reply = new DatagramPacket(buffer, buffer.length);
    17      aSocket.receive(reply);
    18      //wait for reply
    19      System.out.println("Reply: " + new String(reply.getData()));
    20      aSocket.close();
    21   } catch (SocketException e){ System.out.println("Socket: " + e.getMessage());
    22  // socket creation failed
    23  } catch (IOException e){ System.out.println("IO: " + e.getMessage());
    24  // ошибка при приеме
    25  }
    26  }
    27  }

    Поскольку это первые наши программы, прокомментируем их более подробно.

    В первых строках (строки 1,2) происходит импорт необходимых библиотек (пакетов) классов - в нашем случае это java.net и java.io. Первый пакет содержит необходимые нам классы для работы с UDP, второй определяет необходимые классы ввода/вывода.

    Наш класс (строка 3) называется UDPClient,он содержит единственный статический метод main (строка 4), являющийся точкой входа в программу. Метод принимает массив аргументов командной строки, переданных программе при запуске. В качестве аргументов для нашей программы должны быть переданы: строка сообщения (которое будет оправлено на сервер) и адрес узла, на котором запущена программа, - сервер (порт не передается, поскольку в нашем случае он заранее известен). Далее создается сокет для передачи сообщения (строка 7), затем происходит разрешение переданного в качестве аргумента командной строки имени узла сервера в адрес (строка 9). Затем создается датаграмма (строки 11-12), в конструкторе которой передается массив, составляющий передаваемые данные, адрес узла, на котором выполняется сервер и порт (который нам известен заранее). Пакет передается на сервер (строка 13). Обратите внимание - адрес получателя определяется в пакете, а не в сокете, через который мы его передаем. Т.е. один и тот же сокет может быть использован для передачи пакетов разным узлам, и этот же сокет может применяться для приема пакета от сервера (строка 17). После использования сокет должен быть закрыт (строка 20).

    UDPServer

    Другая программа (сервер) создает сокет и обслуживает запросы клиента (пример 3.2).

    1  import java.net.*;
    2  import java.io.*;
    3  public class UDPServer{
    4    public static void main(String args[]) {
    5      DatagramSocket aSocket = null;
    6      try{
    7         aSocket = new DatagramSocket(6789); // create socket at port
    8         byte[] buffer = new byte[1000];
    9         while(true){
    10           DatagramPacket request = new DatagramPacket(buffer, buffer.length);
    11           aSocket.receive(request);
    12           DatagramPacket reply =
    13           new DatagramPacket(request.getData(),  request.getLength(),
    14           request.getAddress(), request.getPort());
    15           aSocket.send(reply);
    16       }
    17     } catch (SocketException e) {System.out.println("Socket: " + e.getMessage());
    18    // socket creation failed
    19 } catch (IOException e) {System.out.println("IO: " + e.getMessage());
    20 } finally {if(aSocket != null) aSocket.close();}
    21 }
    22 }

    Компиляция

    В том случае, когда при установке пакета jdk в системе были установлены соответствующие пути, для компиляции примера нужно выполнить следующие действия:

  • перейти в каталог, где сохранены файлы UDPClient.java и UDPServer.java ;
  • набрать в командной строке команду компиляции javac *.java.
  • После компиляции в текущем каталоге должны появиться два файла: UDPClient.class и UDPServer.class, которые представляют собой наши классы, откомпилированные в байт-код.

    Запуск приложения

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

    java UDPServer
    (рис 3.1) Вывод сервера(рис 3.2) Вывод клиента

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

    Клиент запускается следующим образом:

    java UDPClient Hello! 127.0.0.1

    Первый параметр определяет строку, которая будет передана серверу, второй - адрес узла, на котором запущен сервер. В данном случае и клиент, и сервер запущены на одной машине, поэтому в качестве адреса используется адрес 127.0.0.1 ( localhost ).

    После запуска клиент отправляет строку на сервер, печатает ее в консоли и завершает работу (рис. 3.2). Сервер же продолжает ожидать очередное подключение следующего клиента (рис. 3.1).

    Использование протокола TCP

    Другим подходом к организации взаимодействия является использование API, основанных на идеологии потоков ввода-вывода. Примером могут служить TCP-Streams. В отличие от UDP, это семейство протоколов обеспечивает гарантированную доставку сообщений в порядке, соответствующем порядку отправки (естественно, это достигается за счет больших накладных расходов, поскольку базовым протоколом является тот же датаграммный протокол). В рамках этого подхода программисту предоставляется абстрактный сервис, основанный на использовании потоков. Для передачи данных программист создает TCP -сокет, извлекает из него связанный с ним поток (с сокетом связано два потока - поток ввода и поток вывода) и пишет (читает) данные в поток (из потока). В отличие от подхода, основанного на использовании датаграмм, в этом случае не накладывается никаких ограничений на размер сообщения. Кроме того, рассматриваемый подход является ориентированным на соединение, опять же в отличие от первого подхода. Реализация протокола основана на повторной передаче данных, в случае если истек тайм-аут по ожиданию от получателя подтверждения о приеме.

    Классы:

  • ServerSocket - сокет на стороне сервера;
  • Socket - класс для работы с соединением (клиент и сервер). Имеет конструктор для создания сокета и соединения с удаленным узлом и портом, методы для работы с входными и выходными потоками.
  • Ниже приведена простая программа-клиент, иллюстрирующая использование рассмотренных классов.

    TCPClient

    1  import java.net.*;
    2  import java.io.*;
    3  public class TCPClient {
    4  public static void main (String args[]) {
    5  Socket s = null;
    6  try {
    7  int serverPort = 7896;
    8  s = new Socket(args[1], serverPort);
    9  DataInputStream in = new DataInputStream( s.getInputStream());
    10  DataOutputStream out =new DataOutputStream( s.getOutputStream());
    11  out.writeUTF(args[0]); // UTF is a string encoding
    12  String data = in.readUTF(); // read a line of data from the stream
    13  System.out.println("Received: "+ data) ;
    14  } catch (UnknownHostException e) {System.out.println("Socket:" + e.getMessage());
    15  } catch (EOFException e) {System.out.println("EOF:" +e.getMessage());
    16  } catch (IOException e) {System.out.println("readline:" +e.getMessage());
      // error in reading the stream
    17  } finally {
    18  if(s!=null) try {s.close();} catch (IOException e) 
      {System.out.println("close:" + e.getMessage());}}
    19  }
    20  }

    В первых строках (строки 1,2) программы (пример 3.3) импортируются пакеты java.net и java.io - соответственно, пакет, содержащий API TCP, и пакет, содержащий классы ввода-вывода. Класс TCPClient имеет единственный метод main,который также является точкой входа в клиентскую программу. Метод принимает аргументы (аргументы командной строки), первый из которых рассматривается как строка, передаваемая клиентом серверу, второй - как имя (адрес) сервера. Поскольку создание соединения и передача по нему данных сопряжена с возможностью ошибок, остальные действия производятся в блоке try - catch.

    Переменная s, объявленная в строке 5, инициализируется в строке 8, где создается соединение с сервером. Для соединения требуются два параметра - имя сервера и порт. Имя сервера передано нам из командной строки, порт нам известен. В строках 9 и 10 создаются потоки ввода и вывода, с помощью которых можно взаимодействовать с сервером - передавать и принимать данные. В данном случае используются не базовые классы InputStream и OutputStream,а их более специализированные потомки DataInputStream и DataOutputStream, которые содержат готовые методы чтения/записи для всех базовых типов данных Java. Затем производится отправка полученной строки на сервер, после чего клиент ожидает получения информации от сервера (строка 12). Метод чтения данных из потока - блокирующий. Прочитав строку, посланную сервером, клиент печатает ее на экране и завершает работу.

    Здесь стоит сделать небольшое отступление. Дело в том, что нам очень повезло: в нашем примере и клиент, и сервер реализованы на одной и той же программной платформе - на java. В реальных ситуациях дело может осложняться тем, что различные компоненты распределенного приложения могут быть реализованы на различных не только программных, но и аппаратных платформах. При передаче данных между такими "разными" компонентами возникает проблема их интерпретации - ведь форматы представления могут быть различными для разных платформ.

    Представление данных в распределенных системах

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

    В предыдущем разделе мы научились передавать пакеты данных между процессами системы. Теперь стоит поподробнее взглянуть на те данные, которые мы передаем. Нужно заметить, что передачу данных между различными компонентами распределенной системы можно представить как "перенос" некой области памяти между процессами (для простоты считаем, что каждый компонент выполнен в виде процесса), которые могут функционировать на различных физических узлах. Передаваемые данные (т.е. переменные, массивы, структуры и т.д.) определяются внутри процесса и существуют в локальной памяти процесса. Если говорить о данных внутри процесса, то они имеют какую-то физическую структуру, определяемую используемыми технологиями программирования (языком, компилятором) и аппаратной средой. Эта физическая структура отображается на логическую путем применения некоторых правил, используемых на данной конкретной платформе. Проблема состоит в том, что не существует универсальных правил - они различны для разных платформ. Так, одна и та же последовательность битов интерпретируется совершенно по-разному в случае если она представляет вещественное число с плавающей точкой и в случае если она представляет целое число. Таким образом, мало научиться просто передавать данные, принимающая сторона должна уметь их должным образом интерпретировать. Причем, даже если принимающей стороне известно (например, по порядку передачи), какого типа данные передавались, может потребоваться их дополнительная обработка, поскольку данные одного и того же примитивного типа ( int, float, char ) могут иметь различное представление на различных платформах. Таким образом, кроме работы собственно по передаче данных, необходимо еще выполнить работу по их правильной интерпретации после приема.

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

    Несмотря на кажущуюся избыточность, второй подход применяется чаще в силу его универсальности. В настоящее время разработано множество таких "внешних" транспортных форматов - SUN Microsystems XDR (eXternal Data Representation), CORBA CDR (Common Data Representation), ASN.1 (OSI layer 6) и др. Обычно эти задачи возлагаются на слой middleware, и соответствующие преобразования для программиста прозрачны. Однако если никакое промежуточное программное обеспечение не используется, разработчик должен реализовать соответствующие механизмы трансляции самостоятельно - в том случае, если предполагается совместная работа компонент, реализованных на разных платформах.

    TCPServer

    Сервер, принимающий данные от клиента и посылающий ему ответ, выполнен следующим образом (пример 3.4).

    1  import java.net.*;
    2  import java.io.*;
    3    public class TCPServer {
    4      public static void main (String args[]) {
    5        try {
    6           int serverPort = 7896; // the server port
    7           ServerSocket listenSocket = new ServerSocket (serverPort); // new server port generated
    8           while(true) {
    9              Socket clientSocket = listenSocket.accept(); // listen for new connection
    10            Connection c = new Connection(clientSocket); // launch new thread
    11         }
    12     } catch(IOException e) { System.out.println("Listen socket:"+e.getMessage());
    13   }
    14  }
    15 }

    Как и клиенту, серверу необходима реализация сетевого ввода/вывода, поэтому он импортирует соответствующие пакеты - строки 1,2. Так же как и клиент, сервер состоит из одного метода - main,выполняющего всю работу и одновременно являющегося точкой входа в программу. Первое, что делает сервер - создает серверный сокет (строка 7). Для создания серверного сокета необходим один параметр - порт, на котором сокет будет "слушать" сеть, ожидая клиентских соединений - именно по этому порту клиенты смогут затем соединиться с сервером, и, соответственно, этот порт должен указываться в конструкторах клиентских сокетов, наряду с адресом сервера. В случае если создание серверного сокета прошло успешно, сервер запускает цикл ожидания соединений от клиентов. Внутри цикла ожидания сервер вызывает метод accept (строка 9) своего серверного сокета. Этот метод является блокирующим, т.е. он возвратит управление только тогда, когда к серверу подсоединится очередной клиент. Можно сказать, что большую часть времени сервер проводит именно в методе accept,ожидая соединения клиентов. Метод accept возвращает в качестве результата своей работы класс Socket,который, наряду с сокетом, созданным на клиенте, представляет собой второй конец соединения. Начиная с этого момента, клиент и сервер могут обмениваться друг с другом данными, используя соответствующие методы потоков, связанных со своими сокетами. Поскольку наш сервер может одновременно взаимодействовать с несколькими клиентами, мы создаем класс Connection,который инкапсулирует в себе всю функциональность обслуживания соответствующего клиента, после чего снова входим в цикл ожидания подсоединения следующего клиента. Следует обратить внимание на тот факт, что использованное API позволяет серверу одновременно обслуживать несколько клиентов, поскольку для каждого клиента после его подсоединения создается собственный канал типа "точка-точка".

    Класс Connection

    1  class Connection extends Thread {
    2  DataInputStream in;
    3  DataOutputStream out;
    4  Socket clientSocket;
    5  public Connection (Socket aClientSocket) {
    6     try {
    7       clientSocket = aClientSocket;
    8       in = new DataInputStream( clientSocket.getInputStream());
    9       out = new DataOutputStream( clientSocket.getOutputStream());
    10     this.start();
    11    } catch(IOException e){System.out.println ("Connection:"+e.getMessage());
    12   }
    13  }
    14    public void run() { // an echo server
    15      try {
    16        String data = in.readUTF(); // read a line of data from the stream
    17        out.writeUTF(data); // write a line to the stream
    18        clientSocket.close();
    19  } catch (EOFException e){System.out.println ("EOF:"+e.getMessage());
    20  } catch (IOException e) {System.out.println ("readline:"+e.getMessage());}
    21  }
    22  }

    Класс Connection (пример 3.5) служит для обслуживания отдельного клиента. Мы расположили его в том же исходном файле, что и класс TCPServer,поэтому он не импортирует библиотеки java.net и java.io. Этот класс является потомком класса Thread,т.е. является классом-потоком. В конструктор класса передается сокет, который вернул метод accept серверного сокета, - с его помощью этот класс будет взаимодействовать с клиентом. В конструкторе создаются соответствующие потоки ввода-вывода и запускается поток (поскольку класс сам является потоком, он запускает себя). Все дальнейшие действия по обслуживанию клиента будут производиться в методе run (строки 14-22). Метод run,как правило, представляет собой вечный цикл, выход из которого осуществляется либо по ошибке сетевого ввода-вывода, либо по специальной команде, передаваемой клиентом и извещающей сервер об окончании работы. В данном случае класс выполняет только два действия - чтение одной строки с клиента и обратную передачу этой же строки. После выполнения этих действий сокет закрывается, поскольку никаких данных передавать по нему более не предполагается и метод run завершается, что означает завершение потока обслуживания клиента.

    Компиляция

    Для компиляции примера нужно выполнить следующие действия:

  • перейти в каталог, где сохранены файлы с исходным кодом;
  • набрать в командной строке команду компиляции javac *.java.
  • После компиляции в текущем каталоге должны появиться файлы с расширением . class, компиляция должна завершиться без ошибок.

    Запуск приложения

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

    java TCPServer

    После запуска сервер переходит в режим ожидания сообщений от клиента.

    (рис 3.3) Вывод сервера

    Клиент запускается следующим образом:

    java TCPClient   TCPHello! 127.0.0.1

    где первый параметр определяет строку, которая будет передана серверу, второй - адрес узла на котором запущен сервер. В данном случае и клиент, и сервер запущены на одной машине, поэтому в качестве адреса используется адрес 127.0.0.1 ( localhost ).

    После запуска клиент отправляет строку на сервер, печатает ее в консоли и завершает работу (рис. 3.4). Сервер продолжает ожидание очередного подключения следующего клиента (рис. 3.3).

    (рис 3.4) Вывод клиента

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

    Страницы:

    Рабочий каталог расположен в Practice.

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

    Архитектура протоколов TCP/IP, известная как набор протоколов TCP/IP, возникла в результате исследований в области протоколов и разработок, выполнявшейся в экспериментальной сети с коммутацией пакетов под названием ARPANET, которая была основана Управлением перспективных исследовательских программ Министерства обороны США ( DARPA )[3.26]. Этот набор протоколов состоит из большого собрания протоколов, изданных Координационным советом по сети Internet (IAB) в качестве стандартов для Internet.

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

    Ввиду этого в задаче обмена информацией выделяют пять основных относительно независимых уровней:

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

    Уровень доступа к сети связан с обменом данными между конечной системой (рабочей станцией, сервером и т.д.) и сетью, к которой подсоединена эта система. Узел-отправитель должен передать в сеть адрес узла-адресата, чтобы сеть могла направить данные по месту требования.

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

    Независимо от природы приложений обмен данными должен быть надежным, т.е. хотелось бы иметь уверенность в том, что все данные попали к приложению-адресату и что эти данные получены в том порядке, в котором они были отправлены. Механизмы обеспечения надежности, по сути, независимы от природы приложений, таким образом, имеет смысл выделить такие механизмы в один общий уровень, совместно используемый всеми приложениями. Этот уровень называется транспортным. Чаще всего для него применяется протокол управления передачей ( Transmission Control Protocol - TCP ).

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

    Итак, чтобы обмен информацией был возможен, каждый элемент системы должен иметь уникальный адрес. Фактически, нужно указать два уровня адресации. Каждый узел сети должен обладать своим уникальным глобальным сетевым адресом, это позволит доставить данные соответствующему узлу. Каждый процесс (компонент) на узле должен иметь адрес, который был бы уникальным в пределах этого узла, что даст возможность транспортному протоколу ( TCP ) доставить данные нужному процессу. Этот адрес - адрес процесса - в терминологии протоколов семейства TCP/IP называют портом.

    Для большинства приложений, выполняющихся в рамках архитектуры протокола TCP/IP, протоколом транспортного уровня выступает TCP. Этот протокол обеспечивает надежное соединение для передачи данных от одного приложения к другому. Кроме него существует еще один широко используемый протокол транспортного уровня, входящий в набор протоколов TCP/IP: пользовательский протокол датаграмм ( User Datagram Protocol - UDP ). Протокол UDP предоставляет сервис без установления соединения, предназначенный для процедур на уровне приложения; он не гарантирует доставку, сохранение последовательности при приеме переданных пакетов или защиту от их дублирования. Он позволяет процессу отправлять сообщения другим процессам, с помощью минимального протокольного механизма. Протокол UDP выполняет крайне ограниченный набор функций, так как работает без установления соединения. По сути, он добавляет к протоколу IP некоторые возможности адресации портов.

    Рассмотрим вкратце возможности, которые предоставляет нам java и ее пакет java.net.

    UDP

    Начнем мы с API, использующего протокол UDP. UDP является протоколом, применяющим для передачи датаграммы. Как уже говорилось, этот протокол не является надежным, поскольку сообщения, передаваемые с его помощью, могут теряться или приходить в другой последовательности, отличной от последовательности их отправки. Таким образом, для обеспечения надежной передачи необходимо организовывать надстройку над этим протоколом, обеспечивающую, например, нумерацию пакетов, повторную передачу пакетов при истечении времени ожидания и т.д. Длина одного сообщения (одной датаграммы) при использовании этого протокола ограничена 65536 байтами (причем многие реализации вообще ограничивают размер датаграммы 8К), в случае необходимости пересылки порции данных большего размера они должны быть разбиты на куски отправителем и снова собраны получателем. Передача сообщения - не блокирующая, прием - блокирующий с возможностью прерывания по истечении времени ожидания.

    Для работы с UDP в пакете java.net определены следующие классы.

  • DatagramPacket (датаграмма). Конструктор этого класса принимает массив байт и адрес процесса-получателя ( IP -адрес узла и порт). Класс предназначен для представления единичной дата-граммы (сообщения). Этот класс используется как для создания сообщения с целью последующей его отправки, так и при приеме сообщения (функция приема возвращает экземпляр этого класса).
  • DatagramSocket.Предназначен для посылки/приема UDP -дата-грамм. Один из конструкторов принимает в качестве аргумента порт, с которым связывается сокет, другой конструктор, без аргументов, задействует в качестве порта первый попавшийся свободный порт. Класс имеет методы send и receive, для, соответственно, передачи и приема датаграмм. Метод setSoTimeout устанавливает тайм-аут для операций сокета.
  • Ниже приведены две простые программы, использующие рассмотренные механизмы для организации взаимодействия.

    UDPClient

    Первая программа создает сокет, соединяется с сервером (порт 6789), пересылает ему сообщение и ждет ответа (пример 3.1).

    1  import java.net.*;
    2  import java.io.*;
    3  public class UDPClient{
    4  public static void main(String args[]){
    5    // аргументы - сообщение и адрес сервера
    6    try {
    7       DatagramSocket aSocket = new DatagramSocket(); // create socket
    8       byte [] message = args[0].getBytes();
    9       InetAddress aHost = InetAddress.getByName(args[1]); // DNS lookup
    10      int serverPort = 6789;
    11      DatagramPacket request =
    12      new DatagramPacket(message,   args[0].length(), aHost, serverPort);
    13      aSocket.send(request);
    14      //send message
    15      byte[] buffer = new byte[1000];
    16      DatagramPacket reply = new DatagramPacket(buffer, buffer.length);
    17      aSocket.receive(reply);
    18      //wait for reply
    19      System.out.println("Reply: " + new String(reply.getData()));
    20      aSocket.close();
    21   } catch (SocketException e){ System.out.println("Socket: " + e.getMessage());
    22  // socket creation failed
    23  } catch (IOException e){ System.out.println("IO: " + e.getMessage());
    24  // ошибка при приеме
    25  }
    26  }
    27  }

    Поскольку это первые наши программы, прокомментируем их более подробно.

    В первых строках (строки 1,2) происходит импорт необходимых библиотек (пакетов) классов - в нашем случае это java.net и java.io. Первый пакет содержит необходимые нам классы для работы с UDP, второй определяет необходимые классы ввода/вывода.

    Наш класс (строка 3) называется UDPClient,он содержит единственный статический метод main (строка 4), являющийся точкой входа в программу. Метод принимает массив аргументов командной строки, переданных программе при запуске. В качестве аргументов для нашей программы должны быть переданы: строка сообщения (которое будет оправлено на сервер) и адрес узла, на котором запущена программа, - сервер (порт не передается, поскольку в нашем случае он заранее известен). Далее создается сокет для передачи сообщения (строка 7), затем происходит разрешение переданного в качестве аргумента командной строки имени узла сервера в адрес (строка 9). Затем создается датаграмма (строки 11-12), в конструкторе которой передается массив, составляющий передаваемые данные, адрес узла, на котором выполняется сервер и порт (который нам известен заранее). Пакет передается на сервер (строка 13). Обратите внимание - адрес получателя определяется в пакете, а не в сокете, через который мы его передаем. Т.е. один и тот же сокет может быть использован для передачи пакетов разным узлам, и этот же сокет может применяться для приема пакета от сервера (строка 17). После использования сокет должен быть закрыт (строка 20).

    UDPServer

    Другая программа (сервер) создает сокет и обслуживает запросы клиента (пример 3.2).

    1  import java.net.*;
    2  import java.io.*;
    3  public class UDPServer{
    4    public static void main(String args[]) {
    5      DatagramSocket aSocket = null;
    6      try{
    7         aSocket = new DatagramSocket(6789); // create socket at port
    8         byte[] buffer = new byte[1000];
    9         while(true){
    10           DatagramPacket request = new DatagramPacket(buffer, buffer.length);
    11           aSocket.receive(request);
    12           DatagramPacket reply =
    13           new DatagramPacket(request.getData(),  request.getLength(),
    14           request.getAddress(), request.getPort());
    15           aSocket.send(reply);
    16       }
    17     } catch (SocketException e) {System.out.println("Socket: " + e.getMessage());
    18    // socket creation failed
    19 } catch (IOException e) {System.out.println("IO: " + e.getMessage());
    20 } finally {if(aSocket != null) aSocket.close();}
    21 }
    22 }

    Компиляция

    В том случае, когда при установке пакета jdk в системе были установлены соответствующие пути, для компиляции примера нужно выполнить следующие действия:

  • перейти в каталог, где сохранены файлы UDPClient.java и UDPServer.java ;
  • набрать в командной строке команду компиляции javac *.java.
  • После компиляции в текущем каталоге должны появиться два файла: UDPClient.class и UDPServer.class, которые представляют собой наши классы, откомпилированные в байт-код.

    Запуск приложения

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

    java UDPServer
    (рис 3.1) Вывод сервера(рис 3.2) Вывод клиента

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

    Клиент запускается следующим образом:

    java UDPClient Hello! 127.0.0.1

    Первый параметр определяет строку, которая будет передана серверу, второй - адрес узла, на котором запущен сервер. В данном случае и клиент, и сервер запущены на одной машине, поэтому в качестве адреса используется адрес 127.0.0.1 ( localhost ).

    После запуска клиент отправляет строку на сервер, печатает ее в консоли и завершает работу (рис. 3.2). Сервер же продолжает ожидать очередное подключение следующего клиента (рис. 3.1).

    Использование протокола TCP

    Другим подходом к организации взаимодействия является использование API, основанных на идеологии потоков ввода-вывода. Примером могут служить TCP-Streams. В отличие от UDP, это семейство протоколов обеспечивает гарантированную доставку сообщений в порядке, соответствующем порядку отправки (естественно, это достигается за счет больших накладных расходов, поскольку базовым протоколом является тот же датаграммный протокол). В рамках этого подхода программисту предоставляется абстрактный сервис, основанный на использовании потоков. Для передачи данных программист создает TCP -сокет, извлекает из него связанный с ним поток (с сокетом связано два потока - поток ввода и поток вывода) и пишет (читает) данные в поток (из потока). В отличие от подхода, основанного на использовании датаграмм, в этом случае не накладывается никаких ограничений на размер сообщения. Кроме того, рассматриваемый подход является ориентированным на соединение, опять же в отличие от первого подхода. Реализация протокола основана на повторной передаче данных, в случае если истек тайм-аут по ожиданию от получателя подтверждения о приеме.

    Классы:

  • ServerSocket - сокет на стороне сервера;
  • Socket - класс для работы с соединением (клиент и сервер). Имеет конструктор для создания сокета и соединения с удаленным узлом и портом, методы для работы с входными и выходными потоками.
  • Ниже приведена простая программа-клиент, иллюстрирующая использование рассмотренных классов.

    TCPClient

    1  import java.net.*;
    2  import java.io.*;
    3  public class TCPClient {
    4  public static void main (String args[]) {
    5  Socket s = null;
    6  try {
    7  int serverPort = 7896;
    8  s = new Socket(args[1], serverPort);
    9  DataInputStream in = new DataInputStream( s.getInputStream());
    10  DataOutputStream out =new DataOutputStream( s.getOutputStream());
    11  out.writeUTF(args[0]); // UTF is a string encoding
    12  String data = in.readUTF(); // read a line of data from the stream
    13  System.out.println("Received: "+ data) ;
    14  } catch (UnknownHostException e) {System.out.println("Socket:" + e.getMessage());
    15  } catch (EOFException e) {System.out.println("EOF:" +e.getMessage());
    16  } catch (IOException e) {System.out.println("readline:" +e.getMessage());
      // error in reading the stream
    17  } finally {
    18  if(s!=null) try {s.close();} catch (IOException e) 
      {System.out.println("close:" + e.getMessage());}}
    19  }
    20  }

    В первых строках (строки 1,2) программы (пример 3.3) импортируются пакеты java.net и java.io - соответственно, пакет, содержащий API TCP, и пакет, содержащий классы ввода-вывода. Класс TCPClient имеет единственный метод main,который также является точкой входа в клиентскую программу. Метод принимает аргументы (аргументы командной строки), первый из которых рассматривается как строка, передаваемая клиентом серверу, второй - как имя (адрес) сервера. Поскольку создание соединения и передача по нему данных сопряжена с возможностью ошибок, остальные действия производятся в блоке try - catch.

    Переменная s, объявленная в строке 5, инициализируется в строке 8, где создается соединение с сервером. Для соединения требуются два параметра - имя сервера и порт. Имя сервера передано нам из командной строки, порт нам известен. В строках 9 и 10 создаются потоки ввода и вывода, с помощью которых можно взаимодействовать с сервером - передавать и принимать данные. В данном случае используются не базовые классы InputStream и OutputStream,а их более специализированные потомки DataInputStream и DataOutputStream, которые содержат готовые методы чтения/записи для всех базовых типов данных Java. Затем производится отправка полученной строки на сервер, после чего клиент ожидает получения информации от сервера (строка 12). Метод чтения данных из потока - блокирующий. Прочитав строку, посланную сервером, клиент печатает ее на экране и завершает работу.

    Здесь стоит сделать небольшое отступление. Дело в том, что нам очень повезло: в нашем примере и клиент, и сервер реализованы на одной и той же программной платформе - на java. В реальных ситуациях дело может осложняться тем, что различные компоненты распределенного приложения могут быть реализованы на различных не только программных, но и аппаратных платформах. При передаче данных между такими "разными" компонентами возникает проблема их интерпретации - ведь форматы представления могут быть различными для разных платформ.

    Представление данных в распределенных системах

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

    В предыдущем разделе мы научились передавать пакеты данных между процессами системы. Теперь стоит поподробнее взглянуть на те данные, которые мы передаем. Нужно заметить, что передачу данных между различными компонентами распределенной системы можно представить как "перенос" некой области памяти между процессами (для простоты считаем, что каждый компонент выполнен в виде процесса), которые могут функционировать на различных физических узлах. Передаваемые данные (т.е. переменные, массивы, структуры и т.д.) определяются внутри процесса и существуют в локальной памяти процесса. Если говорить о данных внутри процесса, то они имеют какую-то физическую структуру, определяемую используемыми технологиями программирования (языком, компилятором) и аппаратной средой. Эта физическая структура отображается на логическую путем применения некоторых правил, используемых на данной конкретной платформе. Проблема состоит в том, что не существует универсальных правил - они различны для разных платформ. Так, одна и та же последовательность битов интерпретируется совершенно по-разному в случае если она представляет вещественное число с плавающей точкой и в случае если она представляет целое число. Таким образом, мало научиться просто передавать данные, принимающая сторона должна уметь их должным образом интерпретировать. Причем, даже если принимающей стороне известно (например, по порядку передачи), какого типа данные передавались, может потребоваться их дополнительная обработка, поскольку данные одного и того же примитивного типа ( int, float, char ) могут иметь различное представление на различных платформах. Таким образом, кроме работы собственно по передаче данных, необходимо еще выполнить работу по их правильной интерпретации после приема.

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

    Несмотря на кажущуюся избыточность, второй подход применяется чаще в силу его универсальности. В настоящее время разработано множество таких "внешних" транспортных форматов - SUN Microsystems XDR (eXternal Data Representation), CORBA CDR (Common Data Representation), ASN.1 (OSI layer 6) и др. Обычно эти задачи возлагаются на слой middleware, и соответствующие преобразования для программиста прозрачны. Однако если никакое промежуточное программное обеспечение не используется, разработчик должен реализовать соответствующие механизмы трансляции самостоятельно - в том случае, если предполагается совместная работа компонент, реализованных на разных платформах.

    TCPServer

    Сервер, принимающий данные от клиента и посылающий ему ответ, выполнен следующим образом (пример 3.4).

    1  import java.net.*;
    2  import java.io.*;
    3    public class TCPServer {
    4      public static void main (String args[]) {
    5        try {
    6           int serverPort = 7896; // the server port
    7           ServerSocket listenSocket = new ServerSocket (serverPort); // new server port generated
    8           while(true) {
    9              Socket clientSocket = listenSocket.accept(); // listen for new connection
    10            Connection c = new Connection(clientSocket); // launch new thread
    11         }
    12     } catch(IOException e) { System.out.println("Listen socket:"+e.getMessage());
    13   }
    14  }
    15 }

    Как и клиенту, серверу необходима реализация сетевого ввода/вывода, поэтому он импортирует соответствующие пакеты - строки 1,2. Так же как и клиент, сервер состоит из одного метода - main,выполняющего всю работу и одновременно являющегося точкой входа в программу. Первое, что делает сервер - создает серверный сокет (строка 7). Для создания серверного сокета необходим один параметр - порт, на котором сокет будет "слушать" сеть, ожидая клиентских соединений - именно по этому порту клиенты смогут затем соединиться с сервером, и, соответственно, этот порт должен указываться в конструкторах клиентских сокетов, наряду с адресом сервера. В случае если создание серверного сокета прошло успешно, сервер запускает цикл ожидания соединений от клиентов. Внутри цикла ожидания сервер вызывает метод accept (строка 9) своего серверного сокета. Этот метод является блокирующим, т.е. он возвратит управление только тогда, когда к серверу подсоединится очередной клиент. Можно сказать, что большую часть времени сервер проводит именно в методе accept,ожидая соединения клиентов. Метод accept возвращает в качестве результата своей работы класс Socket,который, наряду с сокетом, созданным на клиенте, представляет собой второй конец соединения. Начиная с этого момента, клиент и сервер могут обмениваться друг с другом данными, используя соответствующие методы потоков, связанных со своими сокетами. Поскольку наш сервер может одновременно взаимодействовать с несколькими клиентами, мы создаем класс Connection,который инкапсулирует в себе всю функциональность обслуживания соответствующего клиента, после чего снова входим в цикл ожидания подсоединения следующего клиента. Следует обратить внимание на тот факт, что использованное API позволяет серверу одновременно обслуживать несколько клиентов, поскольку для каждого клиента после его подсоединения создается собственный канал типа "точка-точка".

    Класс Connection

    1  class Connection extends Thread {
    2  DataInputStream in;
    3  DataOutputStream out;
    4  Socket clientSocket;
    5  public Connection (Socket aClientSocket) {
    6     try {
    7       clientSocket = aClientSocket;
    8       in = new DataInputStream( clientSocket.getInputStream());
    9       out = new DataOutputStream( clientSocket.getOutputStream());
    10     this.start();
    11    } catch(IOException e){System.out.println ("Connection:"+e.getMessage());
    12   }
    13  }
    14    public void run() { // an echo server
    15      try {
    16        String data = in.readUTF(); // read a line of data from the stream
    17        out.writeUTF(data); // write a line to the stream
    18        clientSocket.close();
    19  } catch (EOFException e){System.out.println ("EOF:"+e.getMessage());
    20  } catch (IOException e) {System.out.println ("readline:"+e.getMessage());}
    21  }
    22  }

    Класс Connection (пример 3.5) служит для обслуживания отдельного клиента. Мы расположили его в том же исходном файле, что и класс TCPServer,поэтому он не импортирует библиотеки java.net и java.io. Этот класс является потомком класса Thread,т.е. является классом-потоком. В конструктор класса передается сокет, который вернул метод accept серверного сокета, - с его помощью этот класс будет взаимодействовать с клиентом. В конструкторе создаются соответствующие потоки ввода-вывода и запускается поток (поскольку класс сам является потоком, он запускает себя). Все дальнейшие действия по обслуживанию клиента будут производиться в методе run (строки 14-22). Метод run,как правило, представляет собой вечный цикл, выход из которого осуществляется либо по ошибке сетевого ввода-вывода, либо по специальной команде, передаваемой клиентом и извещающей сервер об окончании работы. В данном случае класс выполняет только два действия - чтение одной строки с клиента и обратную передачу этой же строки. После выполнения этих действий сокет закрывается, поскольку никаких данных передавать по нему более не предполагается и метод run завершается, что означает завершение потока обслуживания клиента.

    Компиляция

    Для компиляции примера нужно выполнить следующие действия:

  • перейти в каталог, где сохранены файлы с исходным кодом;
  • набрать в командной строке команду компиляции javac *.java.
  • После компиляции в текущем каталоге должны появиться файлы с расширением . class, компиляция должна завершиться без ошибок.

    Запуск приложения

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

    java TCPServer

    После запуска сервер переходит в режим ожидания сообщений от клиента.

    (рис 3.3) Вывод сервера

    Клиент запускается следующим образом:

    java TCPClient   TCPHello! 127.0.0.1

    где первый параметр определяет строку, которая будет передана серверу, второй - адрес узла на котором запущен сервер. В данном случае и клиент, и сервер запущены на одной машине, поэтому в качестве адреса используется адрес 127.0.0.1 ( localhost ).

    После запуска клиент отправляет строку на сервер, печатает ее в консоли и завершает работу (рис. 3.4). Сервер продолжает ожидание очередного подключения следующего клиента (рис. 3.3).

    (рис 3.4) Вывод клиента

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

    Вернуться к учебному плану