Elixir 端口通信

356 阅读5分钟

什么是端口 Port

Port 是 Erlang VM 与外部通信的一种方式。

当 Erlang VM 打开一个端口时,会启动一个操作系统的进程,然后通过管道来与外部的进程通信。

为什么使用端口通信

我们使用 Elixir 语言当然可以完成大部分业务,但是有时不得不使用其他的语言来实现一些逻辑, 比如我们要使用一些第三方的 SDK,但没有 Elixir 的 binding,那么就可以将这部分逻辑用 端口的形式实现。

而且由于端口程序是跟 VM 进程分开的,当端口程序崩溃时,并不会影响我们的 VM。

初级通信

我们先实现一个简单的 C 程序,通过 Elixir 来启动这个端口程序,然后收发消息。

创建项目

mix new port_demo --sup

这里我们加上 --sup 标志,创建一个带监督树的项目。

C 构建工具

手动编译 C 程序比较繁琐,也不能与 Elixir 项目很好的集成,所以这里我们用 elixir_make 这个 包来管理和构建我们的 C 程序。

修改 mix.exs,添加包依赖。

defp deps do
  [{:elixir_make, "~> 0.6", runtime: false}]
end

为项目增加新的编译器。

compilers: [:elixir_make] ++ Mix.compilers,

安装依赖。

mix deps.get

在项目根目录创建 Makefile

all: ensure_dir
	gcc c_src/app.c -o priv/port_demo

ensure_dir:
	mkdir -p priv

创建一个事例 C 程序。

mkdir c_src
touch c_src/app.c
#include "stdio.h"
#include "stdlib.h"

int main(int argc, char* argv[])
{
  printf("Hello from port\n");

  return EXIT_SUCCESS;
}

然后执行

mix compile

编译整个项目,这时会生成 priv/port_demo 可执行文件,这个就是我们的端口程序。

数据传输

然后我们创建一个 PortServer 用于与端口程序通信,这里实现为一个 gen_server

再启动这个 Server 时我们打开端口程序,在收到端口程序的数据时打印出来。

defmodule PortDemo.PortServer do
  use GenServer
  require Logger

  def start_link(args) do
    GenServer.start_link(__MODULE__, args, name: __MODULE__)
  end

  @impl true
  def init(_args) do
    {:ok, %{port: open_port()}}
  end

  @impl true
  def handle_info({port, {:data, data}}, %{port: port} = state) do
    Logger.info("Elixir get data from c --> #{data}")
    {:noreply, state}
  end

  defp open_port do
    exec_path = Application.app_dir(:port_demo, "priv/port_demo")

    Port.open({:spawn_executable, exec_path}, [:binary])
  end

end

PortServer 加入监督树。

defmodule PortDemo.Application do
  use Application

  def start(_type, _args) do
    children = [
      PortDemo.PortServer
    ]

    opts = [strategy: :one_for_one, name: PortDemo.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

启动项目 iex -S mix,会看到我们的 Elixir 接收到了 C 程序的数据。

17:18:23.959 [info]  Elixir get data from c --> Hello from port

我们再修改 app.c 让 C 程序也可以接收 Elixir 的数据。 这里我们不用标准库的输入输出函数 printf scanf,因为会有 IO 的缓存,我们直接使用 readwrite 系统调用。

#include "stdio.h"
#include "stdlib.h"
#include "unistd.h"

int main(int argc, char* argv[])
{
  int  n = 0;
  char buffer[128] = {0};

  while (1)
  {
    n = read(0, buffer, sizeof buffer);

    if (n <= 0) break;

    write(1, buffer, n);
  }

  return EXIT_SUCCESS;
}

修改 port_server.ex,增加 send/1 函数

def send(data) do
  GenServer.call(__MODULE__, {:send, data})
end

@impl true
def handle_call({:send, data}, _from, %{port: port} = state) do
  Port.command(port, data)
  {:reply, :ok, state}
end

运行结果

iex(1)> PortDemo.PortServer.send "sfsd"
09:12:27.649 [info]  Elixir get data from c --> sfsd

高级通信

目前我们已经可以在 C 程序里通过标准输入输出与 Elixir 通信了,但任然存在一些问题,比如 C 程序想要 打印自己的日志,这时由于标准输入输出已经被用于管道通信了,我们不能向控制台输出东西了。

使用非标准输入输出

使用 :nouse_stdio 参数可以让我们不用标准输入输出来作为通信的管道,而是使用文件描述符 3 和 4 来 作为输入和输出管道。

修改 port_server.exopen_port/0 函数

Port.open({:spawn_executable, exec_path}, [:binary, :nouse_stdio])

修改 app.c

#include "stdio.h"
#include "stdlib.h"
#include "unistd.h"

#define PORT_IN 3
#define PORT_OUT 4

int main(int argc, char* argv[])
{
  int  n = 0;
  char buffer[128] = {0};

  while (1)
  {
    n = read(PORT_IN, buffer, sizeof buffer);

    if (n <= 0) break;

    printf("[STDIO] recv elixir data\n");
    write(PORT_OUT, buffer, n);
  }

  return EXIT_SUCCESS;
}

运行结果

iex(1)> PortDemo.PortServer.send "good day"
[STDIO] recv elixir data

09:36:30.886 [info]  Elixir get data from c --> good day

使用二进制传输

目前使用的数据传输格式都是字符串,而且我们不能确定收到的数据大小。 我们可以通过配置 {:packet, N} 来让端口程序二进制的形式传输数据。

N 表示包头的大小,比如 {:packet, 4} 表示我们传输的每个数据的最前面有 4 字节的包头表示这个消息 的大小。因此我们可以先读取 4 字节数据,然后根据读取到的大小在读取剩余数据。

首先修改 port_server.ex

Port.open({:spawn_executable, exec_path}, [:binary, :nouse_stdio, {:packet, 4}])

然后修改 app.c

#include "stdio.h"
#include "stdlib.h"
#include "string.h"
#include "unistd.h"
#include "arpa/inet.h"

#define PORT_IN 3
#define PORT_OUT 4

int main(int argc, char* argv[])
{
  u_int32_t packet_size;
  u_int32_t _packet_size;
  char buffer[1024] = {0};

  while (1)
  {
    if (read(PORT_IN, &_packet_size, 4) != 4) break;

    packet_size = ntohl(_packet_size);
    printf("[STDIO] recv packet_size: %d\n", packet_size);

    if (read(PORT_IN, buffer + 4, packet_size) != packet_size) break;

    memcpy(buffer, &_packet_size, 4);
    write(PORT_OUT, buffer, packet_size + 4);
  }

  return EXIT_SUCCESS;
}

这里有个需要注意的地方,我们在读取和写入包头数据时,需要做一次字节序转换,因为 Erlang VM 的端口程序 在底层传输数据时使用的是网络字节序。

运行结果

iex(4)> PortDemo.PortServer.send "nice man"
[STDIO] recv packet_size: 8
10:22:58.290 [info]  Elixir get data from c --> nice man

使用 ETF 格式直接传输 Erlang Term

External Term Format 是 Erlang Term 的一种外部表示格式,可以用于与外部程序通信。

目前我们已经可以传输任意的二进制数据了,但是如果我们需要复杂的数据结构的话,我们就要自己定义一套二进制数据结构,然后 编码与解析。不过我们可以直接传输 Erlang Term。通过 :erlang.binary_to_term:erlang.term_to_binary 来 序列化与反序列化二进制数据。

首先修改 port_server.ex 传输使用 ETF

def handle_call({:send, data}, _from, %{port: port} = state) do
  Port.command(port, :erlang.term_to_binary(data))
  {:reply, :ok, state}
end

def handle_info({port, {:data, data}}, %{port: port} = state) do
  term =
    :erlang.binary_to_term(data)
    |> Map.update!(:c_port, &:erlang.binary_to_term/1)

  Logger.info("Elixir get data from c --> #{inspect(term)}")
  {:noreply, state}
end

然后我们需要修改一下 Makefile,引入 Erlang Interface 相关的库。

all: ensure_dir
	gcc c_src/app.c -o priv/port_demo -I$(ERL_EI_INCLUDE_DIR) -L$(ERL_EI_LIBDIR) -lei -lpthread

ensure_dir:
	@mkdir -p priv

修改 app.c

#include "stdio.h"
#include "stdlib.h"
#include "string.h"
#include "unistd.h"
#include "arpa/inet.h"
#include "ei.h"

#define PORT_IN 3
#define PORT_OUT 4

int main(int argc, char* argv[])
{
  u_int32_t packet_size;
  char* read_buffer;
  char* send_buffer;
  ei_x_buff ei_buffer;

  ei_init();

  while (1)
  {
    if (read(PORT_IN, &packet_size, 4) != 4) break;

    packet_size = ntohl(packet_size);
    printf("[STDIO] recv packet_size: %d\n", packet_size);

    read_buffer = malloc(packet_size);

    if (read(PORT_IN, read_buffer, packet_size) != packet_size) break;

    ei_x_new_with_version(&ei_buffer);
    ei_x_encode_map_header(&ei_buffer, 1);
    ei_x_encode_atom(&ei_buffer, "c_port");
    ei_x_encode_binary(&ei_buffer, read_buffer, packet_size);

    send_buffer = malloc(ei_buffer.index + 4);
    packet_size = htonl(ei_buffer.index);
    memcpy(send_buffer, &packet_size, 4);
    memcpy(send_buffer + 4, ei_buffer.buff, ei_buffer.index);

    write(PORT_OUT, send_buffer, ei_buffer.index + 4);

    ei_x_free(&ei_buffer);
    free(read_buffer);
    free(send_buffer);
  }

  return EXIT_SUCCESS;
}

运行结果

iex(2)> PortDemo.PortServer.send 123
[STDIO] recv packet_size: 3
14:20:28.694 [info]  Elixir get data from c --> %{c_port: 123}

iex(4)> PortDemo.PortServer.send {}
[STDIO] recv packet_size: 3
14:22:56.517 [info]  Elixir get data from c --> %{c_port: {}}

iex(5)> PortDemo.PortServer.send {1, 2, :df}
[STDIO] recv packet_size: 12
14:23:03.788 [info]  Elixir get data from c --> %{c_port: {1, 2, :df}}

总结

  1. 端口程序是跑在操作系统下的进程,跟 Erlang VM 隔离。
  2. 通过管道来传输数据。
  3. 默认使用标准输入输出,可配置管道的文件描述符。
  4. 可已使用二进制来传输管道数据。
  5. 通过 Erlang Interface 可以直接传输 Erlang Term 数据结构。

代码仓库地址:gitee.com/fh250250/el…

Authored by <duanpengyuan#qq.com>