В этой статье мы рассмотрим, как установить бесшовную связь между Elixir и Java с помощью мощной библиотеки Jinterface. Создав узел Elixir и узел Java в контейнере Docker, мы можем продемонстрировать практический сценарий, в котором узел Java запрашивает определенные метрики у узла Elixir, а узел Elixir отвечает запрошенными данными. Это межъязыковое общение позволяет нам использовать сильные стороны как Elixir, так и Java.

Мы постараемся не усложнять и не использовать какие-либо фреймворки, высокоуровневые абстракции, такие как GenServer, или любые другие библиотеки, кроме Jinterface. Мы также не будем использовать какие-либо инструменты сборки, такие как Maven или Gradle. Мы скомпилируем и запустим наш код из командной строки. Но вы можете использовать то, что лучше всего подходит для вас!

Зачем мне использовать Elixir с Java?

Вот некоторые из причин, по которым мы хотели бы использовать Java, когда у вас уже есть приложение Elixir:

  • У вас есть существующее Java-приложение, которое вы хотите интегрировать с вашим приложением Elixir.
  • Вы хотите использовать библиотеку Java, которой нет в Elixir.
  • Выполнение некоторых типов задач в Java может быть более эффективным, чем в Elixir.

Что мне нужно, чтобы начать?

  • Докер установлен на вашем хост-компьютере
  • Базовый текстовый редактор

Когда все настроено и готово, давайте погрузимся и построим этот мост между Elixir и Java!

Как мы собираемся все это делать?

Давайте запустим терминал и создадим новый каталог проекта с именем bridge и перейдем к нему.

mkdir bridge
cd bridge

Мы собираемся создать несколько каталогов, один для нашего приложения Elixir и один для Java.

mkdir -p java/src/java_node
mkdir elixir

Эликсирная часть

Начнем с нашего приложения Elixir, перейдя в каталог elixir и создав новый файл с именем main.ex.

cd elixir
touch main.ex

Затем мы пишем простой сервер, который ожидает входящие сообщения и отвечает на них асинхронно.
Чтобы быть более конкретным, мы будем прослушивать входящее сообщение, которое либо запрашивает общее количество атомов, либо общее количество процессов, запущенных в экземпляре BEAM.
Как только сообщение получено, мы получим запрошенные данные из нашего локального экземпляра и отправить его обратно в процесс, который его запросил. Когда это сделано, мы просто увеличиваем наши процессы и атомы и ждем следующего сообщения, рекурсивно вызывая функцию цикла.

мост/эликсир/main.ex

defmodule Main do
  # The main API function to start the server and register the process
  # with the name `handler`.
  def start() do
    Process.register(Process.spawn(__MODULE__, :loop, [], []), :handler)
  end

  # The loop function which waits for incoming messages, handles them
  # and recursivly calls itself again.
  def loop() do
    {msg, from} = receive do
      {:total_atoms, from} ->
        {{:atoms, :erlang.system_info(:atom_count)}, from};
      {:total_processes, from} ->
        {%{total_processes: :erlang.system_info(:process_count)}, from}
    end

    send_data(msg, from)
    increment()
    loop()
  end

  # Function to increment the atom count by spawning a new process.
  # this will be used for demonstration purposes to show how the data is updated.
  def increment() do
    Process.spawn(__MODULE__, :loop, [], [])
    String.to_atom(Integer.to_string(Enum.random(1..100000)))
  end

  # Respond to the client with the data.
  defp send_data(data, pid) do
    Process.send(pid, data, [])
  end
end

# Start the server.
Main.start()

Это было на стороне Эликсира, теперь давайте перейдем к стороне кода Java!

Java-часть

Перейдите в каталог java_node и создайте новый файл с именем Main.java.

cd ../
cd java/src/java_node
touch Main.java

Этот файл будет нашей точкой входа в приложение Java. Мы создадим новый класс с именем Main и добавим в него метод main.
Метод main будет отвечать за создание новый экземпляр класса Main, который, в свою очередь, инициирует экземпляры Collectable
и вызывает для них метод start, запускающий отдельные потоки для каждого Экземпляр Collectable.
Пока не беспокойтесь о классе Collectable, мы доберемся до него через минуту.

мост/java/src/java_node/Main.java

package java_node;

import com.ericsson.otp.erlang.*;
import java.io.IOException;

public class Main {
    private final Collectable collectables[];

    private Main() throws IOException {
        OtpNode localNode = new OtpNode("java_node", "cookie");

        collectables = new Collectable[] {
            new TotalAtomsCollectable(localNode),
            new TotalProcessesCollectable(localNode)
        };
    }

    public static void main(String[] args) throws IOException {
        Main main = new Main();

        for (Collectable c : main.collectables) {
            c.start();
        }

        // Wait indefinitely until a termination signal is received.
        synchronized (main) {
            try {
                main.wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

Здесь я хотел бы отметить некоторые важные вещи.

Первый оператор импорта import com.ericsson.otp.erlang.*; используется для импорта библиотеки Jinterface. Без этой библиотеки нам пришлось бы самим реализовывать протокол распространения Erlang, что потребовало бы много работы.

Внутри нашего конструктора первый оператор OtpNode localNode = new OtpNode("java", "cookie"); создает новый узел с именем java_node со значением файла cookie cookie .
Значение файла cookie важно, поскольку оба узла должны иметь одинаковое значение файла cookie, чтобы взаимодействовать друг с другом.

При написании кода мы должны следовать принципу DRY, который означает «Не повторяйся». Для этой цели мы создадим новый абстрактный класс с именем Collectable. Этот класс будет обеспечивать базовые функции, которые должны быть у всех собираемых подклассов. Если в будущем мы решим собрать больше метрик, мы можем просто создать новый подкласс, который расширяет Collectable и наследует базовую функциональность.

touch Collectable.java

bridge/java/src/java_node/Collectable.java

package java_node;

import com.ericsson.otp.erlang.*;
import java.io.IOException;

abstract public class Collectable extends Thread {
    protected static final String erlangProcess = "handler";
    protected static final String beamNode = "beam_node";
    protected OtpNode localNode;
    protected OtpMbox mbox;
    protected String collectableName;

    public Collectable(OtpNode localNode) {
        this.localNode = localNode;
        collectableName = this.getClass().getSimpleName();
        mbox = localNode.createMbox(collectableName);
    }

    abstract protected String messageKey();
    abstract protected void handleMessage(OtpErlangObject message);

    @Override
    public void run() {
        while (true) {
            try {
                sendMessage(mbox);
                receiveMessage();
            } catch (OtpErlangDecodeException | OtpErlangExit | IOException e) {
                e.printStackTrace();
            }

            try {
                Thread.sleep(5000); // Wait 5 seconds.
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    private void sendMessage(OtpMbox mbox) throws IOException {
        OtpErlangObject elements[] = new OtpErlangObject[] {
            new OtpErlangAtom(messageKey()),
            mbox.self()
        };

        mbox.send(erlangProcess, beamNode, new OtpErlangTuple(elements));
    }

    private void receiveMessage() throws OtpErlangDecodeException, OtpErlangExit {
        OtpErlangObject message = mbox.receive(10000);
        handleMessage(message);
    }
}

Это абстрактный класс, в котором происходит большая часть магии Java. Давайте внимательно посмотрим на код и разберем его.

Мы видим знакомый оператор импорта import com.ericsson.otp.erlang.*;, который используется для импорта библиотеки Jinterface.
Затем мы определяем абстрактный класс и расширить класс Thread. Это сделано для того, чтобы мы могли запускать новый поток для каждого экземпляра Collectable
и одновременно собирать метрики.

Внутри конструктора мы создаем новый почтовый ящик для каждого экземпляра Collectable. Почтовый ящик используется для получения сообщений от узла Elixir, а также для отправки сообщений узлу Elixir. Каждый почтовый ящик имеет уникальное имя, совпадающее с именем экземпляра Collectable.

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

Переходя к методу run, мы видим, что у нас есть бесконечный цикл, который будет выполняться до тех пор, пока поток не будет завершен. Внутри цикла мы отправляем сообщение нашему узлу Elixir и запрашиваем соответствующую метрику, затем ждем ответа от узла Elixir и соответствующим образом обрабатываем ответ. После этого ждем 5 секунд и снова повторяем процесс.

Под методом run у нас есть два закрытых метода sendMessage и receiveMessage. Метод sendMessage используется для отправки сообщения узлу Elixir с помощью объекта mailBox, созданного в конструкторе. Мы создаем сообщение, которое хотим отправить, создавая массив общих объектов OTP. Мы заполняем массив двумя элементами, первый элемент — это ключ сообщения, который используется для идентификации сообщения на стороне Elixir. Это будет атом со значением метода messageKey. Второй элемент — это объект почтового ящика PID (идентификатор процесса Erlang). Это используется узлом Elixir для отправки ответа нашему узлу Java.

Переходя к нашему методу receiveMessage, мы видим, что мы ожидаем прибытия сообщения в течение 10 секунд.
Как только сообщение получено, мы вызываем метод handleMessage, который реализуется конкретными классами.

Ух, там много всего происходит, но оставайтесь со мной, мы уже наполовину сделали! :)

Коллекционные реализации

Давайте создадим два новых файла в нашем каталоге java_node.

touch TotalAtomsCollectable.java
touch TotalProcessesCollectable.java

Это будет гораздо более прямолинейно!

bridge/java/src/java_node/TotalAtomsCollectable.java

package java_node;

import com.ericsson.otp.erlang.*;
import java.io.IOException;

public class TotalAtomsCollectable extends Collectable {
    public TotalAtomsCollectable(OtpNode localNode) {
        super(localNode);
    }

    @Override
    protected String messageKey() {
        return "total_atoms";
    }

    @Override
    protected void handleMessage(OtpErlangObject message) {
        OtpErlangTuple tuple = (OtpErlangTuple) message;
        OtpErlangAtom key = (OtpErlangAtom) tuple.elementAt(0);
        OtpErlangLong value = (OtpErlangLong) tuple.elementAt(1);

        System.out.println("Key " + key + " has value " + value);
    }
}

Это будет конкретный класс, отвечающий за сбор метрик об общем количестве атомов. Внутри класса мы просто расширяем Collectable, который вызывает наши функции реализации.
Функция messageKey просто возвращает ключ, который будет использоваться для отображения сообщения в Эликсире. side.
Функция handleMessage разбирает структуру данных Elixir и печатает значения. На самом деле мы могли бы просто напечатать весь OtpErlangObject, но я хотел продемонстрировать, как мы можем деконструировать структуры данных Elixir на стороне Java.

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

bridge/java/src/java_node/TotalProcessesCollectable.java

package java_node;

import com.ericsson.otp.erlang.*;

public class TotalProcessesCollectable extends Collectable {
    public TotalProcessesCollectable(OtpNode localNode) {
        super(localNode);
    }

    @Override
    protected String messageKey() {
        return "total_processes";
    }

    @Override
    protected void handleMessage(OtpErlangObject message) {
        System.out.println("Erlang term in string representation: " + message.toString());
    }
}

Это должно быть понятно сейчас, и мы не собираемся вдаваться в подробности здесь.

Мы хорошо поработали, написав код Java, и можем продолжить настройку и сборку Docker.

Докер часть

Вернемся к корневому каталогу bridge нашего проекта.

cd ../../../

.. и создайте два файла для нашей настройки Docker

touch Dockerfile
touch docker-compose.yml

Начнем с bridge/Dockerfile

FROM openjdk:11-jdk

RUN apt-get update

# Install build tools
RUN apt-get install -y git build-essential libncurses5-dev

# Install Erlang
RUN git clone https://github.com/erlang/otp.git /usr/local/src/otp
WORKDIR /usr/local/src/otp
RUN git checkout maint-25
RUN ./otp_build autoconf
RUN ./configure --prefix=/usr/local/otp
RUN make -j$(nproc)
RUN make install
ENV PATH="/usr/local/otp/bin:${PATH}"

# Install Elixir
RUN git clone https://github.com/elixir-lang/elixir.git /usr/local/src/elixir
WORKDIR /usr/local/src/elixir
RUN git checkout v1.15
RUN make clean compile
ENV PATH="/usr/local/src/elixir/bin:${PATH}"

WORKDIR /app

Короче говоря, этот Dockerfile будет основан на образе openjdk:11 и будет устанавливать Erlang и Elixir из исходного кода.

Мы могли бы настроить два контейнера, один для java, а другой для эликсира, но для простоты я решил использовать один контейнер и скомпилировать Erlang и Elixir из исходников. Таким образом мы получим библиотеку Jinterface без инструментов сборки Java. Официальный образ докера Elixir не включал Jinterface.

Далее у нас есть файл bridge/docker-compose.yml.

version: "3.5"

services:
  bridge:
    build:
      context: .
    volumes:
      - ./:/app
    working_dir: /app
    tty: true

Внутри него мы определяем нашу службу контейнера и монтируем наш код в контейнер.

Пришло время строить :)

Выполните в терминале команду сборки (вы, вероятно, можете выпить кофе, потому что это может занять некоторое время).

docker-compose build

После завершения мы можем запустить наш контейнер, выполнив

docker-compose up -d

Компиляция и запуск системы

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

Первый SSH в контейнер

docker exec -it bridge_bridge_1 bash

Теперь давайте запустим узел Elixir, для этого нам нужно перейти в каталог elixir.

cd elixir
iex --sname beam_node --cookie cookie main.ex

С помощью приведенной выше команды запускается узел Elixir с коротким именемbeam_node и cookie со значением cookie. Эти значения также используются в нашем коде Java.

Теперь откройте новое окно терминала (не закрывайте старое и продолжайте работу узла Elixir)

SSH в контейнер снова в новом окне терминала

docker exec -it bridge_bridge_1 bash

Теперь давайте скомпилируем наш Java-код.

javac java/src/java_node/*.java -d java/out/ -classpath ".:/usr/local/otp/lib/erlang/lib/jinterface-1.13.2/priv/OtpErlang.jar"

Здесь мы видим, что сообщаем компилятору Java, где он может найти файл jar, содержащий Jinterface.

Если вы не можете найти OtpErlang.jar, проверьте, где установлен Erlang, выполнив в оболочке elixir функцию `:code.lib_dir()`, которая указывает на каталог установки Erlang. Там вы должны найти библиотеку Jinterface.

Теперь последняя команда, которая запускает узел Java

java -classpath "java/out:/usr/local/otp/lib/erlang/lib/jinterface-1.13.2/priv/OtpErlang.jar" java_node.Main

Вуаля! Когда все хорошо, мы должны увидеть сообщение, проходящее между двумя узлами каждые 5 секунд.

Key atoms has value 17787
Erlang term in string representation: #{total_processes => 64}
Key atoms has value 17794
Erlang term in string representation: #{total_processes => 66}

Исходный код

Исходный код статьи можно найти на Github https://github.com/alfetahe/bridge-example