Максимально простой в поддержке способ интеграции java-клиента с java-сервером
При решении повседневных задач с интерфейсом настольного приложения, реализованного на JavaFX, приходится в любом случае делать запрос на веб-сервер. После времен J2EE и страшной аббревиатуры RMI многое изменилось, а вызовы на сервер стали более легковесными. Как нельзя кстати для подобной проблемы подходит стандарт веб-сокетов и его обмен простыми текстовыми сообщениями любого содержания. Но проблема корпоративных приложений в том, что разнообразность и количество запросов превращает создание и отслеживание EndPoint-ов при наличии отдельно выделенных бизнес-сервисов в жуткую рутину и добавляет лишних строк кода.
А что если взять за основу строго типизированную стратегию с RMI, где между клиентом и сервером существовал стандартный java interface, описывающий методы, аргументы и возвращаемые типы, где добавлялось пару аннотаций, и волшебным образом клиент даже не замечал, что идет вызов по сети? Что если по сети передавать не просто текст, а сериализованные java-объекты? Что если добавить к этой стратегии легкость веб-сокетов и их преимущества возможности push-вызовов клиента со стороны сервера? Что если асинхронность ответов веб-сокета для клиента обуздать в привычный блокирующий вызов, а для отложенного вызова добавить возможность возвращения Future или даже CompletableFuture? Что если добавить возможность подписки клиента на определенные события от сервера? Что если на сервере иметь сессию и подключение к каждому клиенту? Может получиться неплохая прозрачная связка привычная любому java-программисту, так как за интерфейсом будет скрыта магия, а в тестировании интерфейсы легко подменить. Но вот только это все не для нагруженных приложений, обрабатывающих, например, котировки с фондовой биржи.
В корпоративных приложениях из моей практики скорость выполнения sql-запроса и передачи выбираемых данных из СУБД несоизмеримы с накладными расходами на сериализацию и рефлексивные вызовы. Более того страшная трассировка EJB-вызовов, дополняющая длительность выполнения до 4 — 10 мс даже на самый простенький запрос не является проблемой, так как длительность типичных запросов находится в коридоре от 50 мс до 250 мс.
Начнем с самого простого — воспользуемся паттерном Proxy-объект для реализации магии за методами интерфейса. Предположим, что у меня есть метод получения истории переписки пользователя с его оппонентами:
public interface ServerChat{
Map> getHistory(Date when, String login);
}
Proxy-объект создадим стандартными средствами java, и вызовем на нем нужный метод:
public class ClientProxyUtils {
public static BiFunction defaultFactory = RMIoverWebSocketProxyHandler::new;
public static T create(Class clazz, String jndiName) {
T f = (T) Proxy.newProxyInstance(clazz.getClassLoader(),
new Class[]{clazz},
defaultFactory.apply(jndiName, clazz));
return f;
}
}
//подключение и открытие сокета
//...
ServerChat chat = ClientProxyUtils.create(ServerChat.class, "java:global/test_app/ServerChat");
Map> history = chat.getHistory(new Date(), "tester");
//...
//закрытие сокета и соединения
Если при этом настроить фабрики, а экземпляр proxy-объекта внедрять по интерфейсу через cdi-инъекцию, то получится магия в чистом виде. При этом открывать/закрывать сокет каждый раз совсем не обязательно. Напротив в моих приложениях сокет постоянно открыт и готов к приему и обработке сообщений. Теперь стоит посмотреть, что такого происходит в RMIoverWebSocketProxyHandler:
public class RMIoverWebSocketProxyHandler implements InvocationHandler {
public static final int OVERHEAD = 0x10000;
public static final int CLIENT_INPUT_BUFFER_SIZE = 0x1000000;// 16mb
public static final int SERVER_OUT_BUFFER_SIZE = CLIENT_INPUT_BUFFER_SIZE - OVERHEAD;
String jndiName;
Class interfaze;
public RMIoverWebSocketProxyHandler(String jndiName, Class interfaze) {
this.jndiName = jndiName;
this.interfaze = interfaze;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Request request = new Request();
request.guid = UUID.randomUUID().toString();
request.jndiName = jndiName;
request.methodName = method.getName();
request.args = args;
request.argsType = method.getParameterTypes();
request.interfaze = interfaze;
WaitList.putRequest(request, getRequestRunnable(request));
checkError(request, method);
return request.result;
}
public static Runnable getRequestRunnable(Request request) throws IOException {
final byte[] requestBytes = write(request);
return () -> {
try {
sendByByteBuffer(requestBytes, ClientRMIHandler.clientSession);
} catch (IOException ex) {
WaitList.clean();
ClientRMIHandler.notifyErrorListeners(new RuntimeException(FATAL_ERROR_MESSAGE, ex));
}
};
}
public static byte[] write(Object object) throws IOException {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream ous = new ObjectOutputStream(baos)) {
ous.writeObject(object);
return baos.toByteArray();
}
}
public static void sendByByteBuffer(byte[] responseBytes, Session wsSession) throws IOException {
...
}
public static void checkError(Request request, Method method) throws Throwable {
...
}
@FunctionalInterface
public interface Callback {
V call() throws Throwable;
}
}
А вот собственно сам клиентский EndPoint:
@ClientEndpoint
public class ClientRMIHandler {
public static volatile Session clientSession;
@OnOpen
public void onOpen(Session session) {
clientSession = session;
}
@OnMessage
public void onMessage(ByteBuffer message, Session session) {
try {
final Object readInput = read(message);
if (readInput instanceof Response) {
standartResponse((Response) readInput);
}
} catch (IOException ex) {
WaitList.clean();
notifyErrorListeners(new RuntimeException(FATAL_ERROR_MESSAGE, ex));
}
}
private void standartResponse(final Response response) throws RuntimeException {
if (response.guid == null) {
if (response.error != null) {
notifyErrorListeners(response.error);
return;
}
WaitList.clean();
final RuntimeException runtimeException = new RuntimeException(FATAL_ERROR_MESSAGE);
notifyErrorListeners(runtimeException);
throw runtimeException;
} else {
WaitList.processResponse(response);
}
}
@OnClose
public void onClose(Session session, CloseReason closeReason) {
WaitList.clean();
}
@OnError
public void onError(Session session, Throwable error) {
notifyErrorListeners(error);
}
private static Object read(ByteBuffer message) throws ClassNotFoundException, IOException {
Object readObject;
byte[] b = new byte[message.remaining()]; // don't use message.array() becouse it is optional
message.get(b);
try (ByteArrayInputStream bais = new ByteArrayInputStream(b);
ObjectInputStream ois = new ObjectInputStream(bais)) {
readObject = ois.readObject();
}
return readObject;
}
}
Таким образом, на вызов любого метода proxy-объекта берем открытую сессию сокета, шлем переданные аргументы и реквизиты метода, который необходимо вызвать на сервере, и wait-имся до получения ответа с указанными ранее в запросе гуидом. При получении ответа проверяем на наличие исключения, и, если все хорошо, то кладем в Request результат ответа и нотифицируем поток, ожидающий ответа в WaitList-е. Реализацию такого WaitList-а приводить не буду, так как она тривиальна. Ожидающий поток в лучшем случае продолжит работать после строки WaitList.putRequest (request, getRequestRunnable (request)); . После пробуждения поток проверит наличие задекларированных в секции throws исключений, и выполнит возврат результата через return.
Приведенные примеры кода являются выдержкой из библиотеки, которая пока не готова для выкладки на github. Необходимо проработать вопросы лицензирования. Реализацию серверной стороны имеет смысл смотреть уже в самом исходном коде после его опубликования. Но ничего особенного там нет — выполняется поиск ejb-объекта, который реализует указанный интерфейс, в jndi через InitialContext и делается рефлексивный вызов по переданным реквизитам. Там конечно еще много чего интересного, но ни в одну статью такой объем информации не влезет. В самой библиотеке приведенный сценарий блокирующего вызова был реализован в первую очередь, так как является самым простым. Позже была добавлена поддержка неблокирующих вызовов посредством Future и CompletableFuture<>. Библиотека успешно используется во всех продуктах с настольным java-клиентом. Буду рад, если кто-то поделится опытом открытия исходного кода, который линкуется с gnu gpl 2.0 (tyrus-standalone-client).
В итоге построить иерархию вызова метода стандартными средствами IDE до самой UI-формы, на которой обработчик кнопки дергает удаленные сервисы, не составляет труда. При этом получаем строгую типизацию и слабую связанность слоя интеграции клиента и сервера. Структура исходного кода приложения делится на клиент, сервер и ядро, которое подключается зависимостью и в клиент, и в сервер. Именно в нем и находятся все удаленные интерфейсы и передаваемые объекты. А рутинная задача разработчика, связанная с запросом в БД, требует нового метода в интерфейсе и его реализации на стороне сервера. На мой взгляд, куда уж проще…