Другим подходом к организации взаимодействия является использование API, основанных на идеологии потоков ввода-вывода, например, TCP-Streams. В отличие от UDP, это семейство протоколов обеспечивает гарантированную доставку сообщений в порядке, соответствующем порядку отправки (базовым протоколом является тот же датаграммный протокол). Программисту предоставляется абстрактный сервис, основанный на использовании потоков. Для передачи данных программист создает TCP-сокет, извлекает из него связанный с ним поток (с сокетом связано два потока - поток ввода и поток вывода) и пишет (читает) данные в поток (из потока). В отличие от подхода, основанного на использовании датаграмм, в этом случае не накладывается никаких ограничений на размер сообщения. Кроме того, рассматриваемый подход является ориентированным на соединение, в отличие от первого подхода. Реализация протокола основана на повторной передаче данных, в случае если истек тайм-аут по ожиданию от получателя подтверждения о приеме. Для работы с TCP в пакете java.net определены следующие классы:
- ServerSocket - сокет на стороне сервера.
- Socket - класс для работы с соединением (клиент и сервер). Имеет конструктор для создания сокета и соединения с удаленным узлом и портом, методы для работы с входными и выходными потоками.
Ниже приведена простая программа-клиент, иллюстрирующая использование рассмотренных классов (листинг 3).
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 = 7777;
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 }
Листинг 3. Клиент TCP
В первых строках (строки 1, 2) программы импортируются пакеты 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, и соответствующие преобразования для программиста прозрачны. Однако если никакое промежуточное программное обеспечение не используется, разработчик должен реализовать соответствующие механизмы трансляции самостоятельно - в том случае, если предполагается совместная работа компонентов, реализованных на разных платформах [2].
Сервер, принимающий данные от клиента и посылающий ему ответ, выполнен следующим образом.
1. import java.net.*;
2. import java.io.*;
3. public class TCPServer
4. {
5. public static void main (String args[])
6. {
7. try
{
8. int serverPort = 7777; // the server port
9. ServerSocket listenSocket = new ServerSocket (serverPort);
i. // new server port generated
10. while(true)
{
11. Socket clientSocket = listenSocket.accept(); // listen for new connection
12. Connect c = new Connect(clientSocket);
// launch new thread
}
13. }
14. catch(IOException e) { Sytem.out.println("Listen socket:"+e.getMessage());}
15. }
}
Листинг 4. Сервер TCP
Как и клиенту, серверу необходима реализация сетевого ввода/вывода, поэтому он импортирует соответствующие пакеты - строки 1,2. Так же, как и клиент, сервер состоит из одного метода main, выполняющего всю работу и одновременно являющегося точкой входа в программу. Первое, что делает сервер - создает серверный сокет (строка 7). Для создания серверного сокета необходим один параметр - порт, на котором сокет будет "слушать" сеть, ожидая клиентских соединений. Именно по этому порту клиенты смогут затем соединиться с сервером, и, соответственно, этот порт должен указываться в конструкторах клиентских сокетов, наряду с адресом сервера. В случае если создание серверного сокета прошло успешно, сервер запускает цикл ожидания соединений от клиентов. Внутри цикла ожидания сервер вызывает метод accept (строка 9) своего серверного сокета. Этот метод является блокирующим, т.е. он возвратит управление только тогда, когда к серверу подсоединится очередной клиент. Можно сказать, что большую часть времени сервер проводит именно в методе accept, ожидая соединения клиентов. Метод accept возвращает в качестве результата своей работы объект класса Socket, который, наряду с сокетом, созданным на клиенте, представляет собой второй конец соединения. Начиная с этого момента, клиент и сервер могут обмениваться друг с другом данными, используя соответствующие методы потоков, связанных со своими сокетами.
Поскольку наш сервер может одновременно взаимодействовать с несколькими клиентами, мы создаем класс Connection, который инкапсулирует в себе всю функциональность обслуживания соответствующего клиента, после чего снова входим в цикл ожидания подсоединения следующего клиента. Следует обратить внимание на тот факт, что использованное API позволяет серверу одновременно обслуживать несколько клиентов, поскольку для каждого клиента после его подсоединения создается собственный канал типа "точка-точка".
1. class Connect extends Thread {
2. DataInputStream in;
3. DataOutputStream out;
4. Socket clientSocket;
5. public Connect (Socket aClientSocket) {
6. try
7. {
8. clientSocket = aClientSocket;
9. in = new DataInputStream(clientSocket.getInputStream());
10. out = new DataOutputStream( clientSocket.getOutputStream());
11. this.start();}
12. catch(IOException e){System.out.println ("Connection:"+e.getMessage());}
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. }
Листинг 5. Класс Connect
Класс Connect (Листинг 5) служит для обслуживания отдельного клиента, его описание можно поместить в том же исходном файле, что и класс TCPServer. В этом случае нет необходимости импортировать библиотеки java.net и java.io. Этот класс является потомком класса Thread, т.е. является классом-потоком. В конструктор класса передается сокет, который вернул метод accept серверного сокета, с его помощью этот класс будет взаимодействовать с клиентом. В конструкторе создаются соответствующие потоки ввода-вывода и запускается поток (поскольку класс сам является потоком, он запускает себя). Все дальнейшие действия по обслуживанию клиента будут производиться в методе run (строки 14-22). Метод run, как правило, представляет собой вечный цикл, выход из которого осуществляется либо по ошибке сетевого ввода-вывода, либо по специальной команде, передаваемой клиентом и извещающей сервер об окончании работы. В данном случае класс выполняет только два действия - чтение одной строки с клиента и обратную передачу этой же строки. После выполнения этих действий сокет закрывается, поскольку никаких данных передавать по нему более не предполагается и метод run завершается, что означает завершение потока обслуживания клиента.