Журнал LinuxFormat - перейти на главную

LXF96:Java EE

Материал из Linuxformat
Перейти к: навигация, поиск

Содержание

Java Enterprise Edition: Перекличка серверов

ЧАСТЬ 8, Отыскали пару лишних серверов и готовы написать настоящее распределенное приложение? Александр Бабаев подскажет подходящий способ.

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

Для удобства ссылок дадим серверам названия. Пускай наш старый называется Нео, а большой сервер с разной информацией — Матрица.

Прикинем, как все устроить. Для тестов все программы (оба сервера) будут запускаться на одном компьютере. Но, поменяв соответствующие IP-адреса (ниже мы разберемся, какие именно), можно будет запускать Нео и Матрицу на разных компьютерах одной и той же подсети. Правда, если между ними есть всякие-разные устройства вроде маршрутизаторов, шлюзов и мостов, тесты могут и не заработать. Это предупреждение! Реализовать более сложную схему взаимодействия тоже можно, но на это нам, к сожалению, не хватит журнального места, которое вполне конечно.

Итак, получается следующая топология сети:


LXF96-JavaEE 1.png


Как докричаться?

Скачать исходный код примера

Классический способ связи двух разных программ — через файлы: одна пишет, другая читает. Например, можно сделать общий файл для записи команд, куда пишет Нео и откуда читает Матрица, и файл ответов, где запись/чтение происходят наоборот.

Способ простой, но плохой: возникает огромное количество проблем, из которых падение производительности — самая маленькая. Рассматривать его не будем.

Другой способ — связь через так называемый «сокет». Сокет — это такая штука, к которой можно присоединиться [англ. socket — «розетка»]. Сервер «открывает» сокет и «слушает» его. Клиент — подключается к серверному сокету, передает/получает данные, отключается. Затем подключается следующий клиент.

Можно представить себе сокет как телефон. Вы получаете номер абонента (открываете сокет), подключаете телефон (теперь вы слушаете сокет), вам звонят — соединяются с вашим сокетом… Ну, вроде бы понятно? Примерно так же соединяются и компьютеры. А чтобы определить, к какому конкретно сокету присоединяться, нужно знать адрес IP-компьютера и номер порта, на котором открыт сокет. Номер порта необходим, потому что на одном и том же компьютере может быть запущено несколько «слушающих» программ (серверов), и нужно, чтобы они не мешали друг другу. А клиент, с другой стороны, должен точно указать, какой именно сервер ему нужен.

Есть еще и третий способ, «каналы», но о нем мы поговорим несколько позже.

Сокеты

Итак, общая схема работы с сокетами достаточно проста. Сервер создает сокет:

 ServerSocket serverSocket = new ServerSocket();

Дальше нужно открыть сокет для приема соединений. В Java это делается так:

 serverSocket.bind(new InetSocketAddress("localhost", 10000));

Здесь мы говорим, что сервер будет слушать соединения, в адресе назначения которых указано «localhost» и порт 10000. Сразу отметим: чтобы создать сервер на портах с номерами, меньшими 1024, обычно нужны права суперпользователя.

Далее нужно ловить подключения. Обычно это делается в цикле (получили подключение, обработали, ждем следующее). Например, так:

while (true) { 
    Socket socket = serverSocket.accept(); 
    int byteReceived = socket.getInputStream().read(); 
    OutputStream outputStream = socket.getOutputStream(); 
    outputStream.write(byteReceived + 1); 
    socket.close(); 
}

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

Вот так все несложно. А как устроен клиент? Он создает сокет, пишет в него единицу, читает ответ (двойку) и выводит ее на экран. По Jav’овски это выглядит так:

Socket socket = new Socket("localhost", 10000); 
socket.getOutputStream().write(1); 
int result = socket.getInputStream().read(); 
System.out.println("Result is: " + result); 
socket.close();

Здесь тоже все предельно просто, но тем не менее, работает. То есть, если создать два класса и в main первого поставить код сервера, запустить (он будет «висеть», слушая соединения), в main второго — код клиента, и запустить… Клиент выдаст 2.

NIO

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

Еще один минус блокирующих сокетов в том, что они (простите за тафтологию) блокируют поток в ожидании подключения. А если под ключения нет? Значит, поток заблокирован навсегда. И дело не только в подключениях, но и в записи/чтении в/из сокета: случись что во время этих операций — заблокируют навсегда. Вдобавок при большом количестве соединений/потоков скорость работы сервера уменьшается гораздо быстрее, чем увеличивается количество соединений. То есть имеет место типичная ситуация плохой масштабируемости.

И тут на помощь приходит новый способ обработки соединений — NIO (The new I/O API), хотя и более сложный, но гораздо более масштабируемый. Эта библиотека появилась в Java 1.4, и она позволяет создавать неблокирующие соединения. Рассмотрим тот же сервер и клиент в реализации NIO.. Сначала создадим так называемый «канал»:

Selector acceptSelector = SelectorProvider.provider().openSelector(); 
ServerSocketChannel socketChannel = ServerSocketChannel.open(); 
socketChannel.configureBlocking(false);

Мы не просто создали канал, но еще и объявили его неблокирующим (работа с блокирующими каналами ничуть не лучше работы с блокирующими сокетами). Теперь нужно «объяснить» каналу, какие события нам интересны (присоединение клиента, чтение, запись, отсоединение). Займемся подсоединением клиента:

socketChannel.socket().bind(new InetSocketAddress("localhost",  10000)); 
socketChannel.register(acceptSelector, SelectionKey.OP_ACCEPT);

Теперь начинаем волшебный цикл обработки. В цикле проверяем, есть ли интересующие нас события в «канале», и если есть, итерируем по ним, получая клиентский сокет. Дальше с сокетом работаем так же, как было описано выше.

while (acceptSelector.select() > 0) { 
	Set readyKeys = acceptSelector.selectedKeys(); 
	Iterator i = readyKeys.iterator(); 
 
	while (i.hasNext()) { 
		SelectionKey key = (SelectionKey) i.next(); 
		i.remove(); 
		ServerSocketChannel nextReady = (ServerSocketChannel) key.  channel(); 
		Socket socket = nextReady.accept().socket(); 
		outputInputPlusOne(socket); 
	}
}

Функция outputInputPlusOne(socket), как мы делали ранее, читает число, увеличивает его на 1 и записывает результат. При этом поток постоянно крутится в цикле, не блокируясь. Это позволяет использовать достаточно интересные методы обработки подключений: например, создавать только один поток для произволь ного количества подключений, и он один будет их все обрабатывать. Плюс, операции чтения и записи тоже можно сделать неблокирующими, и совсем будет сказка… Если бы не сложность. Переход на неблокирующую обработку подключений — увеличение объема программы в три раза. Неблокирующая запись и чтение — еще больше. В результате получается достаточно громоздкая схема. Увы, только так можно добиться хорошей производительности.

Минное поле

Но есть замечательная библиотека, где все это уже учтено. Внутри она использует NIO, поэтому отлично масштабируется. Зато наружу выходят очень простые и понятные методы, которые можно использовать, получая превосходный результат. Библиотека называется Apache Mina, она достаточно широко распространена (например, Jetty, который мы рассматривали в первой статье цикла, использует именно ее) и неплохо отлажена. Иными словами, ею можно пользоваться в «промышленных» масштабах, не особо беспокоясь о проблемах. Сервер делается так:

SocketAcceptorConfig acceptorConfig = new SocketAcceptorConfig(); 
acceptorConfig.getFilterChain().addLast("Serializator", new ProtocolCodecFilter(new ObjectSerializationCodecFactory())); 
DataServerHandler handler = new DataServerHandler(); 
SocketAcceptor acceptor = new SocketAcceptor(); 
acceptor.bind(new InetSocketAddress("localhost", 10000), handler, acceptorConfig);

В первой строчке создается конфигуратор (через него задаются разные параметры подключения), затем в него вставляется фильтр, через который пройдут все данные (фильтр распаковывает объекты Java из потока). В третьей строке создается обработчик событий (о нем чуть ниже), который обрабатывает события Mina, и дальше — инициализируется сервер (опять тот же bind).

Поскольку Mina использует NIO, сервер получается событийный. При подключении клиента вызывается обработчик (Handler), который обслуживает запрос. При поступлении данных — тоже вызывается Handler (уже другой метод). И так далее. Вот как выглядит обработчик — (Handler) в нашем случае:

public class DataServerHandler extends IoHandlerAdapter { 
  public void messageReceived(IoSession session, Object message) 
  throws Exception { 
     if (message instanceof Integer) { 
         session.write(((Integer) message) + 1); 
         session.close(); 
     } 
  } 
}

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

Предыдущий клиент, правда, не подойдет — нужно создавать другой. Но он не сложнее, чем был. Вот его код:

SocketConnector connector = new SocketConnector(); 
SocketConnectorConfig config = new SocketConnectorConfig(); 
config.getFilterChain().addLast("Serializator", new ProtocolCodecFilter(new ObjectSerializationCodecFactory())); 
ConnectFuture connectFuture = connector.connect(new InetSocketAddress("localhost", 10000), new DataClientHandler(), config); 
connectFuture.join(); 
IoSession session = connectFuture.getSession(); 
session.write(new Integer(1));

Код клиента — практически точное повторение кода сервера. Даже обработчик есть. Только вместо Acceptor’а создаем Connector. connectFuture — это объект, который позволяет, во-первых, дождаться, пока присоединимся (.join()), а во-вторых, от него получается сессия, куда можно писать всякие объекты (в нашем случае — Integer). Обработчик, как и на сервере, простой:

public class DataClientHandler extends IoHandlerAdapter { 
  public void messageReceived(IoSession session, Object message) 
  throws Exception { 
    System.out.println("Result is: " + message.toString()); 
  } 
}

Все. Сложность в данном конкретном случае — четыре класса вместо двух. Но это оправданно. Mina позволяет гибко настраивать обработку данных, бегающих между клиентом и сервером: количество потоков, обрабатывающих каналы, и так далее. Все это позволяет создавать простые серверы буквально за минуты, а сложные — за считанные часы или за несколько дней.

RMI

Еще один способ соединения двух Java-программ — RMI (Remote Method Invocation, удаленный вызов метода). Это несколько другой класс соединений, который скрывает сокеты и прочую внутреннюю механику от программиста, но требует понимания совершенно других принципов.

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

После этих магических пассов методы сервера можно вызывать, как будто они находятся прямо в клиенте, в той же Java-машине. А RMI сам понимает, что вызван метод сервера, организует передачу/прием данных, преобразования всего из всего и выдает результат. На Рис. 2 приведена схема работы RMI (исключая registry):


LXF96-JavaEE 2.png

При создании класса, который может быть вызван удаленно (схема, когда обращаются непосредственно к методу, а он скрытыми путями вызывается на удаленной машине, называется удаленным вызовом, Remote Procedure Call), генерируются специальные классы-заглушки (stubs). Они-то и делают все «взмахи волшебными палочками». На самом деле, когда клиент выполняет удаленный вызов, вызывается аналогичный серверному метод заглушки. В этом методе параметры сериализуются и через сокет передаются на сервер, где вновь десериализуются, вызывается сервер, а ответ передается таким же образом (через заглушки) клиенту. Чтобы RMI заработал, нужно сделать интерфейс для сервера и его реализацию, написать клиент, потом сгенерировать заглушки, все скомпилировать и запустить. Интерфейс сервера прост:

public interface IRMIServer extends Remote { 
  public abstract int increment(int aValue) throws RemoteException; 
}

Он должен наследоваться от интерфейса Remote, и все методы, вызываемые удаленно, должны выбрасывать исключение RemoteException. Реализация сервера не менее проста:

public class RMIServer implements IRMIServer { 
  public RMIServer() throws RemoteException { 
    super(); 
  } 
 
  public int increment(int aValue) throws RemoteException { 
    return aValue + 1; 
  } 
}

Реализация должна обязательно иметь конструктор по умолчанию, который выкидывает исключение RemoteException и вызывает super(). Метод реализуется как обычно. Регистрация сервера может быть выполнена, например, в методе main того же сервера:

public static void main(String[] args) throws Exception { 
  System.setSecurityManager(new RMISecurityManager()); 
  RMIServer Server = new RMIServer(); 
  Naming.rebind("RMIServer" , Server); 
}

Здесь устанавливается менеджер безопасности (иначе нам скажут, что нет прав для работы с RMI), создается сервер и регистрируется в registry (rebind). Есть и метод bind, но он выдаст ошибку, если такой сервер уже зарегистрирован. В целях экономии места используем rebind, который проверку на существование такого же сервера не выполняет.

Клиент тоже устанавливает менеджер безопасности, после чего в registry ищет сервер (имя он знает). Как только найдет — просто вызывает метод, как если бы RMI не существовало.

public class RMIClient { 
    public static void main(String[] args) throws MalformedURLException,NotBoundException, RemoteException { 
      System.setSecurityManager(new RMISecurityManager()); 
      String url = "//localhost/RMIServer"; 
      IRMIServer remoteObject = (IRMIServer) Naming.lookup(url); 
      System.out.println("Result is " + remoteObject.increment(1)); 
    } 
}

Запуск сервера и клиента несколько более сложен, чем обычно. До сих пор мы компилировали и запускали классы «просто так». Теперь создадим заглушки и запустим registry. Это делается так:

rmic RMIServer 

создаст заглушку сервера (сначала нужно все скомпилировать, а потом выполнить эту команду с полным именем класса сервера). А команда

rmiregistry & 

запустит RMI Registry с параметрами по умолчанию. Чтобы разрешить менеджеру безопасности делать все что угодно, нужно запустить клиент и сервер с параметром, указывающим на файл политики безопасности. Назовем этот файл open.policy и запишем в него следующее:

grant { 
  permission java.security.AllPermission; 
};

Теперь запустим клиент и сервер:

java -Djava.security.policy=open.policy RMIServer 

и

java -Djava.security.policy=open.policy RMIClient 

Вот и все. Как можно заметить, чем проще и понятнее код клиента и сервера, тем больше телодвижений нужно сделать для запуска разных вспомогательных приложений. Это общая закономерность. Пока приложения простые, зачастую удобнее использовать просто сокеты; в более тяжелых случаях (например, для работы клиент-серверных приложений уровня предприятия, тех самых Enterprise Applications) проще один раз запустить что-то вроде registry, а потом пользоваться «благами цивилизации».

Стоит отметить, что RMI, создаваемый «по умолчанию», в нормальных приложениях использовать нельзя. Слишком много ограничений, слишком много неразрешимых проблем (например, если клиент и сервер находятся в разных подсетях, соединение становится почти невозможным; или — невозможно внести в registry-сервер, который запущен не на той же машине, где и registry). Но реализации, которые предоставляют так называемые контейнеры приложений (GlassFish, Sun WebSphere, JBoss и другие) — это именно то, что используется для связи клиентов с сервером. Там это все работает, и работает отлично.

Персональные инструменты
купить
подписаться
Яндекс.Метрика