При решении повседневных задач с интерфейсом настольного приложения, реализованного на JavaFX, приходится в любом случае делать запрос на веб-сервер. После времен J2EE и страшной аббревиатуры RMI многое изменилось, а вызовы на сервер стали более легковесными. Как нельзя кстати для подобной проблемы подходит стандарт веб-сокетов и его обмен простыми текстовыми сообщениями любого содержания. Но проблема корпоративных приложений в том, что разнообразность и количество запросов превращает создание и отслеживание EndPoint-ов при наличии отдельно выделенных бизнес-сервисов в жуткую рутину и добавляет лишних строк кода.


А что если взять за основу строго типизированную стратегию с RMI, где между клиентом и сервером существовал стандартный java interface, описывающий методы, аргументы и возвращаемые типы, где добавлялось пару аннотаций, и волшебным образом клиент даже не замечал, что идет вызов по сети? Что если по сети передавать не просто текст, а сериализованные java-объекты? Что если добавить к этой стратегии легкость веб-сокетов и их преимущества возможности push-вызовов клиента со стороны сервера? Что если асинхронность ответов веб-сокета для клиента обуздать в привычный блокирующий вызов, а для отложенного вызова добавить возможность возвращения Future или даже CompletableFuture? Что если добавить возможность подписки клиента на определенные события от сервера? Что если на сервере иметь сессию и подключение к каждому клиенту? Может получиться неплохая прозрачная связка привычная любому java-программисту, так как за интерфейсом будет скрыта магия, а в тестировании интерфейсы легко подменить. Но вот только это все не для нагруженных приложений, обрабатывающих, например, котировки с фондовой биржи.


В корпоративных приложениях из моей практики скорость выполнения sql-запроса и передачи выбираемых данных из СУБД несоизмеримы с накладными расходами на сериализацию и рефлексивные вызовы. Более того страшная трассировка EJB-вызовов, дополняющая длительность выполнения до 4 — 10 мс даже на самый простенький запрос не является проблемой, так как длительность типичных запросов находится в коридоре от 50мс до 250мс.


Начнем с самого простого — воспользуемся паттерном Proxy-объект для реализации магии за методами интерфейса. Предположим, что у меня есть метод получения истории переписки пользователя с его оппонентами:


public interface ServerChat{

    Map<String, <List<String>> getHistory(Date when, String login);
}

Proxy-объект создадим стандартными средствами java, и вызовем на нем нужный метод:


public class ClientProxyUtils {

    public static BiFunction<String, Class, RMIoverWebSocketProxyHandler> defaultFactory = RMIoverWebSocketProxyHandler::new;

    public static <T> T create(Class<T> 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<String, <List<String>> 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>  {

        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-формы, на которой обработчик кнопки дергает удаленные сервисы, не составляет труда. При этом получаем строгую типизацию и слабую связанность слоя интеграции клиента и сервера. Структура исходного кода приложения делится на клиент, сервер и ядро, которое подключается зависимостью и в клиент, и в сервер. Именно в нем и находятся все удаленные интерфейсы и передаваемые объекты. А рутинная задача разработчика, связанная с запросом в БД, требует нового метода в интерфейсе и его реализации на стороне сервера. На мой взгляд, куда уж проще...

Комментарии (32)


  1. Sabubu
    04.09.2018 03:55

    Не понимаю, зачем здесь вебсокет. Вебсокет придуман для браузеров для интеграции в HTTP. У вас нет нужды в браузере и с обоих сторон Ява — следовательно вебсокет в сравнении с традиционными сокетами добавляет лишь лишний оверхед.

    Также, непонятно, зачем изобретать свой протокол, когда в Ява есть уже стандартный вариант, библиотеки для него, инструменты.

    Хотелось бы понять, конечно.


    1. High_Tower Автор
      04.09.2018 06:16
      -1

      У вебсокета асинхронная двусторонняя связь — push нотификации. Он работает по http — не требуется дополнительной настройки сетевых экранов. Добавляем https и мы не видны для антивирусов — по умолчанию они не сканируют защищенный канал (некоторые блокируют ответ от сервера по незащищенному каналу). Установка клиента на рабочее место заключается в простом копировании ярлыка, который скачивает бинарники и без дополнительных настроек запускает клиента. Ранее использовалась технология RMI, которая работала на сокете по определенному порту и требовался администратор для установки на новое рабочее место. Так как к окружению применялись высокие требования безопасности, то все порты кроме 80 были закрыты по умолчанию.


      1. barker
        04.09.2018 08:51
        +3

        Лично мне ещё больше стало непонятно. Ну сделайте сокет на 80/443, он тоже асинхронный и полнодуплексный. Всё что тут написано делится на «то же самое в обычном сокете» либо «причём тут вообще веб-сокеты» (копирование ярлыка? бинарники?).


        1. High_Tower Автор
          04.09.2018 11:43
          -3

          То есть, вы предлагаете создать ServerSocket на 80 порту в рамках сервера приложений, и ServerSocket на 443 порту клиента. Реализовать дуплексное общение через две пары сокетов, а также взять на себя всю инфраструктуру сессий, обработки исключительных ситуаций, например, обрыв соединения, работу через прокси сервер и многое другое, что уже есть в готовых решениях с реализацией веб-сокетов. Думаю, что тогда бы мой серверный сокет на 80 порту (если бы он вообще запустился) нарушил бы корректную работу сервера приложений, на котором есть и другие приложения, например, с веб-интерфейсом.

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

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

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


    1. Moxa
      04.09.2018 09:56
      +1

      Не читал статью, но хочу сказать, что вебсокеты вполне можно использовать вместо обычных сокетов, оверхед фактически только на подключение, дальше идёт очень простой бинарный протокол вида <размер пакета><данные>, что все равно придется придумывать для обычных


    1. AstarothAst
      04.09.2018 10:41

      Зато если, как водится, внезапно захочется перейти на тонкий клиент, то придется переписывать только клиент.


      1. Sabubu
        04.09.2018 15:35

        Нет. Там передаются сериализованные Java объекты. Придется как-то имитировать эти объекты на стороне JS и тащить сложную сериализацию/десериализацию вместо того же JSON.


        1. High_Tower Автор
          04.09.2018 16:08

          Java-cериализация при необходимости может быть заменена на любой другой способ. Берем тот же Jakson и сериализуем в JSON. На java-сериализацию завязки нет. Просто не было смысла, так как обе стороны java.


        1. AstarothAst
          04.09.2018 16:09

          Тот же jackson объекты просто и понятно сериализует в json и обратно, так что сторона js будет как раз очень рада, поскольку json ей родной и близкий.


          1. High_Tower Автор
            04.09.2018 16:31

            Если не ошибаюсь, есть проблема, когда у тебя Date лежит в Map. Приходится что-то дополнительно прописывать, а то он дату сериализует в число, а обратно, не может понять к чему это число привести.

            Задачи интеграции с js-клиентом и обменом json не ставилось.


            1. AstarothAst
              05.09.2018 11:04

              Если не ошибаюсь, есть проблема, когда у тебя Date лежит в Map. Приходится что-то дополнительно прописывать, а то он дату сериализует в число, а обратно, не может понять к чему это число привести.

              Я в работе, по привычке использую JodaTime, плюс никогда не перевожу в-из json непосредственно в бизнес-сущности, у меня всегда есть промежуточное звено назначение которого как раз в конвертации. Позволяет держать нутро «не осведомленным» о том, что его в какой-то момент времени будут конвертировать и представлять в json, или обратно.


  1. StanislavL
    04.09.2018 11:17

    Делал похожее на Spring + REST. github.com/StanislavLapitsky/SpringSOAProxy

    Объявляются интерфейсы для сервисов. Далее их реализация может быть распределенной. Все интерфейсы доступны. При вызове любого метода ищется сперва локальный бин, а если его нет динамическим прокси дергается тот же метод удаленно.

    Еще надо бы Service Discovery впилить, но руки не доходят пока.

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


    1. High_Tower Автор
      04.09.2018 11:49

      В том то и дело, что не просто двусторонняя связь, нужны и все плюшки контейнера сервера приложений.


  1. Mishiko
    04.09.2018 11:47

    В WildFly/JBoss что то похожее есть «из коробки». Не знаю, используются ли web-сокеты, но рукоблудить с кодом точно не нужно:

    env.put(Context.INITIAL_CONTEXT_FACTORY, "org.jboss.naming.remote.client.InitialContextFactory");
    env.put(Context.PROVIDER_URL, "http-remoting://localhost:8080");
    


    1. High_Tower Автор
      04.09.2018 11:53

      Интересно, что лежит под этой фабрикой в WildFly. В Glassfish/Payara, тоже есть из коробки, правда работает оно по устаревшей RMI-технологии. Увы, но на WildFly переехать в свое время не получилось — слишком много надо было менять в инфраструктуре серверного кода (вот такая вот заменяемость контейнеров в javaee).


      1. Mishiko
        04.09.2018 13:24

        слишком много надо было менять в инфраструктуре серверного кода (вот такая вот заменяемость контейнеров в javaee)

        — сделать приложение не переносимым, это заслуга разработчика/архитектора, технология тут не виновата. Если в кроссплатформенной Java написать:
        File f = new File("C:/myfile.txt");

        она тоже перестанет быть кроссплатформенной, но это не значит что виновата технология. Во всяком случае на JavaEE можно писать приложения с минимальными модификациями при переносе, если об этом побеспокоиться изначально.


        1. High_Tower Автор
          04.09.2018 13:42

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


  1. BigDflz
    04.09.2018 14:24

    использование ws позволяет их использовать как для связи сервера с браузером, так и с десктопным/андроид приложением. по поводу портов — библиотека tomcat работает по 80 порту. github.comту./TooTallNate/Java-WebSocket работает по любому порту.


  1. sved
    04.09.2018 15:01

    Самый простой способ связать клиент и сервер в Java это JAX-WS. Обращаю внимание что это работает начиная с Java 6 и не требует ни одной библиотеки или сервера, только голый SDK

    Фрагмент Кода
    	// server side
            MyService serv = new MyService();
            Endpoint.publish("http://localhost:9000/", serv);
    
    	// client
            QName portQName = new QName("my", "MyServiceService");
            Service service = Service.create(new URL("http://localhost:9090/services/hello?wsdl"), portQName);
            IService client = service.getPort(IService.class);
            System.out.println(client.echo("hello"));
    
    
            @WebService(name = "MyService", targetNamespace = "my")
            public interface IService {
                String echo(String s);
            }
    


    1. High_Tower Автор
      04.09.2018 16:33

      А если передавать не простые типы, а экземпляры своих классов, разве не потребуется дополнительных аннотаций на стороне сервера или клиента?


      1. sved
        04.09.2018 16:50

        Кое-где надо будет. Особенно если что-то хитрое сериализуется а-ля енамы, но в целом всё гуманно. Сервер и клиент могут шарить DTO, а также интерфейс.


        1. High_Tower Автор
          04.09.2018 21:17

          Привык делать так, чтобы не отвлекаться на подобные ситуации. Одно дело, когда только сам используешь для себя, другое дело еще всем коллегам надо помнить о том, что где-то надо что-то подписать. По моему опыту этого никто не помнит, а когда выстреливает, то тратится большое количество времени на решение проблемы.


          1. sved
            05.09.2018 01:21

            Там где требуется сериализация, без аннотаций не обойтись. Как минимум, надо пометить какие поля не надо сериализовывать. А если коллега не может правильно подписать, то это неправильный коллега


    1. BigDflz
      04.09.2018 20:24

      ws — full duplex, и для браузера и для приложения. один для всех. и очень маленький трафик.


  1. Throwable
    04.09.2018 15:11

    Джавовский RMI в его основе — это совсем не про то, как клиенту что-то вызвать на сервере через Java Proxy. RMI — это распределенная сеть, там нет ни сервера, ни клиента, есть только peer-s. Любой Remote-объект может быть передан по сети любому из peer-ов точно также как и обычный, например в качестве параметра к удаленному методу или поля объекта. При реализации истинного RMI вам придется решать такие задачи как call-routig, call-forwarding, service discovery, и distributed garbage collector.


    Может быть то, что вы хотели сделать, уже существует?


    1. High_Tower Автор
      04.09.2018 15:55

      Feign, очень интересная библиотека. Надо будет ее подробнее изучить. Спасибо! На момент написания основы библиотеки (~2015г.) про feign не знал, да и если честно не искал. Почему-то был уверен, что подобного никто не пишет. Сейчас бы, наверное, доработал бы его под свои нужды.


      1. Throwable
        04.09.2018 17:06

        Ну как это никто не пишет? Даже джавовский почти стандартный Jersey умеет уже давно проксировать клиента. Причем один и тот же аннотированный интерфейс можно использовать как для клиента, так и для сервиса.


        1. High_Tower Автор
          04.09.2018 20:59

          JAX-WS и REST не стал использовать, так как в серверном коде требуются дополнительные аннотации на каждый метод, а при сериализации сложных структур еще и на классы передаваемые в качестве аргументов или возвращаемые в качестве результата. Мой подход требует, чтобы ejb (

          @Stateless
          или
          @Singleton
          ) реализовывал интерфейс и имел определенное имя. Этого достаточно, чтобы его можно было вызвать с клиента через proxy.


      1. dph
        04.09.2018 21:07

        Кроме Feign еще можно посмотреть на замечательную github.com/briandilley/jsonrpc4j
        Просто и очень удобно.


        1. High_Tower Автор
          04.09.2018 21:11

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


          1. dph
            04.09.2018 23:10

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


            1. High_Tower Автор
              05.09.2018 06:05

              Многоверсионность никогда поддерживать не приходилось. Обновляю клиента и сервер за раз.