Monthly Archives: July 2013
При разработке клиент-серверных приложений иногда требуется наладить обратную связь – посылать сообщения от сервера к клиенту. Есть 2 стандартных пути решения этой задачи. Первый, самый простой, заключается в периодическом опросе клиентами сервера на предмет наличия сообщений для них. Он не очень удобен, потому что если выбрать большой интервал между опросами, то данные будут обновляться медленно, а если интервал сделать маленьким, то при большом количестве клиентов сервер будет завален запросами. Плюс сетевой траффик. В общем, не очень. Второй же вариант – push-уведомления – сервер сам посылает клиенту сообщение, когда нужно. И этот путь намного лучше, поскольку он лишён всех недостатков первой схемы. Единственный минус push-уведомлений – проблемы реализации. Не всегда клиент доступен для подключения со стороны сервера (если клиент за NATом, например, и чтобы подключиться к нему, нужно пробрасывать порт). В этом случае приходится либо отказываться от push-уведомлений, либо реализовывать их более хитрым образом. Одну из таких реализаций мы и рассмотрим, на примере транспорта bisocket в библиотеке JBoss Remoting.
JBoss Remoting
Вообще JBoss Remoting – это библиотека, созданная как раз для быстрой реализации таких вещей. Она позволяет использовать единое API для работы со кучей разных транспортов. То есть, грубо говоря, на клиенте можно написать Client.send(message), а на сервере сочинить обработчик для поступающих сообщений. И после этого выбрать подходящий транспорт, коих поддерживается множество: есть HTTP, есть Sockets, есть RMI, есть SSL Sockets. Есть даже какие-то особенные транспорты Servlet и Coyote (непонятно, чем они отличаются от HTTP, наверное, используют какие-то особенности сервлетного API и реализации томката). Фишка в том, что API всегда одно и то же. Меняется только имя транспорта и конфигурация (каждый транспорт поддерживает свой набор параметров). Мы рассмотрим транспорт bisocket, его схему работы и то, как его настраивать и использовать.
Транспорт bisocket
Что такое bisocket ? Это транспорт на базе двух TCP сокетов. Один используется для запросов от клиента к серверу, другой – для обратной связи. Изначально в JBoss Remoting был транспорт Socket, но для реализации push-уведомлений он требовал коннекта от сервера к клиенту. Remoting использовался в JBoss Application Server для реализации JMS, а в сценариях с JMS это было неудобно. Если бы в качестве транспорта был использован Socket, то подписчики JMS должны были бы конфигурироваться так, чтобы принимать входящие соединения. Ребята из RedHat пораскинули мозгами и родили концепцию транспорта на базе сокетов, который не требует подключения сервера к клиенту. Вместо этого клиент открывает дополнительно второе, управляющее, соединение, через которое ждёт сообщений от сервера. Итак, давайте с вами посмотрим на то, как работает bisocket.
Подключение слушателя push-уведомлений
Клиент вызывает Client.addListener() для регистрации обработчика push-уведомлений. Код клиента посылает на сервер сообщение, запрашивающее координаты (хост и порт) вторичного сокета. Сервер передает координаты вторичного сокета, и клиент соединяется с ним, создавая управляющее соединение (control connection). Сервер после вызова accept() на вторичном сокете получает экземпляр Socket, с помощью которого он будет обмениваться сообщениями с клиентом, и сохраняет его у себя в статической Map. После этого клиент передает на сервер сообщение “addListener”, и сервер логически связывает клиента и предварительно сохранённый экземпляр Socket. После всего этого на стороне сервера стартует задача PingTask, которая периодически посылает ping-запросы на клиент и ждет ответа. Если ответа не приходит N раз подряд, управляющее соединение считается разорванным.
Отправка push-уведомлений
Серверная сторона вызывает InvokerCallbackHandler.handleCallback(). Сервер проверяет, есть ли в пуле соединений свободное соединение с этим клиентом. Если такого нет, то по управляющему соединению сервер посылает клиенту сообщение о том, чтобы клиент подключился ко вторичному сокету ещё раз – для создания рабочего соединения. После этого сервер ожидает подключения клиента (появления соответствующего экземпляра Socket в статической Map). Клиент получает это сообщение, и подключается на вторичный сокет, создавая worker thread для обработки этого соединения. В это время сервер после вызова accept() получает экземпляр Socket для этого нового рабочего соединения, сохраняет его в статическую Map, после чего выполнение серверного кода посылки push-уведомления продолжается, и сообщение идёт уже в это новое соединение. После отправки соединение добавляется в пул соединений для дальнейшего использования.
Что происходит, если управляющее соединение рвется
Восстановление происходит на стороне клиента. Клиент уведомляется о том, что ping-сообщения не получались уже долгое время. Клиентский код заново запрашивает сервер о координатах вторичного сокета (вдруг они изменились). Клиент подключается ко вторичному сокету и параллельно с этим посылает на сервер сообщение о том, что текущее управляющее соединение нужно заменить новым. Сервер, получив это сообщение, подождет, когда будет вызван accept() на вторичном сокете, и получив экземпляр свежесозданного Socket, заменит им старый Socket управляющего соединения.
Настройки
Итак, теперь, когда мы разобрали схему работы транспорта Bisocket, можно перечислить настройки, сразу станет ясно, зачем каждая из них.
IS_CALLBACK_SERVER – задаётся на стороне клиента. Если false (значение по умолчанию), то будет использован обычный механизм соединения сервера к клиенту (как в транспорте Socket). Правда не понимаю, зачем было устанавливать это значение по умолчанию в false, если транспорт специально был написан для функциональности, которая становится доступной только если значение установлено в true. Не вижу логики в этом. Нам, правильным пацанам, надо ставить его в true, или использовать обычный Socket.
Все остальные рассматриваемые настройки – серверные.
SECONDARY_BIND_PORT – порт, который будет прослушиваться вторичным сокетом. Если он не задан, будет выбран произвольный порт, и если ваш сервер размещён за брандмауэром, то вам может не повезти, и ваш клиент не сможет подключиться к вторичному сокету. Поэтому рекомендую задавать его явно.
SECONDARY_CONNECT_PORT – порт, который будет передан клиенту при запросе координат вторичного сокета. По умолчанию равен SECONDARY_BIND_PORT, но если ваш сервер размещён за NAT’ом, то при пробросе порта внешний порт и внутренний могут не совпадать. В этом случае в SECONDARY_CONNECT_PORT вы можете установить внешний порт, и клиент будет подключаться на него.
SECONDARY_BIND_PORTS и SECONDARY_CONNECT_PORTS – настройки для multihome серверов, это вы можете попробовать, если у вас стоит балансировщик нагрузки. Не знаю как будет вести себя сервер, наверное он будет принимать подключения на все указанные порты.
SERVER_BIND_ADDRESS – адрес, по которому будут прослушиваться первичный и вторичный сокеты. Обычно устанавливается в ${jboss.bind.address} (если используется вариант конфигурации в xml в jboss). По умолчанию вроде бы подключается на *, но в документации почему-то об этом не упоминается. Может быть, я что-то путаю.
SERVER_BIND_PORT – порт, по которому будет прослушиваться первичный сокет.
Проблема с NAT
Если ваш сервер размещён за NAT’ом, то скорее всего вы столкнетесь с проблемой того, что управляющее соединение не будет создаваться. Это связано с тем, что при создании управляющего соединения сервер передаёт на клиент координаты вторичного сокета, хост и порт. Хост он берёт из настройки SERVER_BIND_ADDRESS, который в случае используемого NAT скорее всего будет указывать на адрес в локальной сети. Соответственно, клиент при получении этого хоста будет пытаться на него подключиться, и не сможет. К сожалению, настройки “использовать для подключения хост, применявшийся для установки первичного соединения” нет. Мы столкнулись с этой проблемой, и я не нашёл иного решения кроме патчинга библиотеки. Нужно скачать svn репозиторий для тега 2.5.4.SP4, пропатчить файл BisocketClientInvoker.java, в строке 355 заменив Object o = invoke(r); на кусок кода
Object o = invoke(r); // DZ: patch to use original server address for creating secondary connection // instead of address retrieved from server (because server will return jboss bind address // that can be local address, inaccessible from client) InvokerLocator patchedLocator = new InvokerLocator( (( InvokerLocator ) o).getOriginalURI().replace( (( InvokerLocator ) o).getHost(), this.getLocator().getHost() ) ); log.debug("secondary locator: " + o); log.debug("patched secondary locator: " + patchedLocator); return patchedLocator; |
После этого нужно пересобрать библиотеку командой ant и всё будет готово. Либо подождать, пока редхатовцы рассмотрят созданный тикет https://issues.jboss.org/browse/JBREM-1322
Пример кода
Примеры доступны в дистрибутиве библиотеки, для bisocket есть отдельный пример, примеры можно запустить с помощью ant, выполнив ant run-bisocket-server и ant run-bisocket-client для запуска сервера и клиента соответственно. К сожалению, конкретно этот пример очень куцый, и он даже не рассчитан на подключение более чем одного клиента, но этим грешат все примеры в этом дистрибутиве. Если будет время, я напишу более хороший пример, во-первых, standalone сервера и клиента (консольных или на Swing’е), а во-вторых, приведу пример конфигурации сервера в рамках JBoss AS (там это делается при помощи xml, что очень удобно).
Проблема
В общий лог выводятся сообщения, которые видеть нежелательно. С другой стороны, полностью отключать логи для этого package не хочется. А в коде поправить возможности нет – библиотека сторонняя, и пишет в логи ошибки, которые по идее ошибками не являются. В моем случае это был jboss remoting, записывающий сообщение об отключении клиента в ERROR при отправке асинхронного push-уведомления.
Решение
Добавить в log4j.xml определение Appender, который будет писать эти логи в отдельный файл, и настроить категории логирования так, чтобы эти логи не шли в общий лог.
Appender:
<appender name="RemotingLogFile" class="org.jboss.logging.appender.DailyRollingFileAppender"> <errorHandler class="org.jboss.logging.util.OnlyOnceErrorHandler"/> <param name="File" value="${jboss.server.log.dir}/remoting.log"/> <param name="Append" value="true"/> <param name="DatePattern" value="'.'yyyy-MM-dd"/> <layout class="org.apache.log4j.PatternLayout"> <param name="ConversionPattern" value="%d %-5p [%c] (%t) %m%n"/> </layout> </appender> |
Определения категорий:
<category name="org.jboss.remoting" additivity="false"> <priority value="DEBUG"/> <appender-ref ref="RemotingLogFile"/> </category> <!-- для следующих двух классов установим loglevel повыше, в INFO --> <category name="org.jboss.remoting.transport.socket.SocketClientInvoker" additivity="false"> <priority value="INFO"/> <appender-ref ref="RemotingLogFile"/> </category> <category name="org.jboss.remoting.transport.bisocket.BisocketClientInvoker" additivity="false"> <priority value="INFO"/> <appender-ref ref="RemotingLogFile"/> </category> |
Важно ! Если не указывать additivity=false, то эти категории будут записываться и в ROOT категорию тоже (правда, только те, которые соответствуют loglevel рутовой категории). И соответственно, будут попадать в общий лог по-прежнему. Чтобы этого избежать, нужно устанавливать additivity в true.
Результат
Основной лог не содержит сообщений из jboss remoting вообще, все логи, связанные с ним, идут в файл remoting.log. LogLevel для этой категории по умолчанию установлен в DEBUG, но для двух классов – INFO, поскольку в режиме DEBUG они чересчур многословны.
Наша система, написанная на Java, должна взаимодействовать с коллцентром. Коллцентр – это такая десктопная приложуха, которая запускается на компьютере отдельно и принимает звонки клиентов. Приложение это предоставляет COM-интерфейсы для взаимодействия с ней, можно получить текущий статус, подключиться к серверу и подписаться на события поступления звонков. Так как взаимодействовать с COM удобнее всего на дотнете, то решили действовать именно так. Но основное-то наше приложение – это десктопный Swing-клиент! Нужно как-то транслировать всю информацию из дотнетовой аппликухи в свинговую. Два года назад такая же проблема была решена путём взаимодействия через сеть. Дотнетовое приложение тогда запускалось отдельно и при некоторых событиях дёргало по HTTP некий урл, который уже обрабатывался Java-программой. Но сейчас мы обнаружили такую замечательную штуку как jni4net. Этот opensource инструмент, написанный энтузиастом, позволяет внутри Java-процесса загружать дотнетовую CRL и работать напрямую с дотнетовыми классами через JNI. Для дотнетовых классов будут сгенерированы удобные Java-прокси классы, которые вы сможете вызывать так, как будто это были бы обычные Java классы. Но при этом все вызовы будут затранслированы в дотнетовский рантайм. То же самое можно проделать и в другом направлении – то есть внутри CLR подгрузить Java Runtime и обращаться к JVM аналогичным образом. Поковырявшись немного, хочу тезисно расписать основные моменты, на которых бы хотелось остановиться:
- Это реально работает!
- Чтобы создать ant-скрипт для сборки такой конструкции, пришлось повозиться, но я подготовил шаблон проекта, с которого можно начать быстро и безболезненно.
- В случае вызова .NET из контекста работающего Java-процесса все происходит примерно следующим образом: jni4net загружает DLL-переходник для CLR соответствующей разрядности, затем происходит загрузка CLR, после этого грузится ваша DLL.
- Дотнетовые поля не маршаллятся (в прокси-классах их просто не будет), используйте свойства.
- Свойства маршаллятся на ура, превращаясь по ходу дела в геттеры и сеттеры (правда, булевые свойства тоже будет доступны через get, а не через is, как многие привыкли видеть).
- Делегаты нужно придумывать свои, поскольку прокси-классы к стандартным скорее всего не будут созданы (для этого наверное нужно более подробно конфигурировать proxygen). То есть просто EventHandler лучше не использовать, можно завести свой public делегат и он прекрасно будет преобразован в интерфейс с методом Invoke(). Соответственно, в Java коде вы создаете свою реализацию этого интерфейса (например, анонимную) – всё как с обычными джавовыми слушателями.
- Насчет исключений я не разбирался, вроде бы тоже должны маршаллиться, но я предпочел использовать паттерн “GetLastError”, чтобы не зависеть от этого.
- Если ваша DLL требует того, чтобы быть сконфигурированной в App.config- у вас не получится этого сделать. В списках рассылки автор предлагает обходить это двумя путями: либо инициализировать всё программно, либо в вашей DLL уже создавать отдельный AppDomain и в него загружать сборки.
- Если вам необходимо работать с 32-разрядными библиотеками (в моём случае это было именно так), то вам придётся позаботиться о том, как настроить запуск вашего java-приложения с 32-битной JVM.
- Если вы хотите засунуть все DLL в отдельную папку, вы должны туда же засунуть и jni4net.j-0.8.6.0.jar. Судя по экспериментам, jni4net ищет свой рантайм рядом с собой, определяя местоположение своего джарника. Но при указании библиотеки, которую вы хотите загрузить, вы должны указать путь к ней. Например, так:
Bridge.LoadAndRegisterAssemblyFrom(new java.io.File("lib\\net-app.j4n.dll"));
Если всё, что связано с jni4net лежит в lib. В этом случае jni4net найдет свой рантайм, а также найдет net-app.j4n.dll в месте, которое вы ему указали. Можно ли держать рантайм jni4net в одной папке, а дотнетовые DLL в другой, пока не ясно.
- Запуск из папки, расположенной на сетевом диске, не работает – Windows не разрешает загружать дотнетовые сборки из shared-папок по умолчанию. На Windows 7 я получил следующий стектрейс:
Can't init BridgeExport: DLLs are marked as unsafe. Open file properties in windows explorer and click unblock. Can't init BridgeExport:An attempt was made to load an assembly from a network location which would have caused the assembly to be sandboxed in previous versions of the .NET Framework. This release of the .NET Framework does not enable CAS policy by default, s o this load may be dangerous. If this load is not intended to sandbox the assembly, please enable the loadFromRemoteSources switch . See http://go.microsoft.com/fwlink/?LinkId=155569 for more information. Can't init BridgeExport:System.NotSupportedException: An attempt was made to load an assembly from a network location which would have caused the assembly to be sandboxed in previous versions of the .NET Framework. This release of the .NET Framework does not e nable CAS policy by default, so this load may be dangerous. If this load is not intended to sandbox the assembly, please enable th e loadFromRemoteSources switch. See http://go.microsoft.com/fwlink/?LinkId=155569 for more information. at System.Reflection.RuntimeAssembly.nLoadFile(String path, Evidence evidence) at System.Reflection.Assembly.LoadFile(String path) at net.sf.jni4net.BridgeExport.initDotNet(IntPtr envi, IntPtr clazz) Can't initialize jni4net Bridge from S:\temp\elwood\Taxi\lib\jni4net.n.w32.v40-0.8.6.0.dll Can't initialize jni4net BridgeCan't initialize jni4net Bridge. Code:-101
На Windows XP у меня в такой же ситуации просто вылетела JVM. Возможно, есть способ это исправить, но я не исследовал.
Ещё раз ссылка на шаблон проекта, который можно взять и запустить:
1