什么是端口 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 的缓存,我们直接使用 read 和 write 系统调用。
#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.ex 的 open_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}}
总结
- 端口程序是跑在操作系统下的进程,跟 Erlang VM 隔离。
- 通过管道来传输数据。
- 默认使用标准输入输出,可配置管道的文件描述符。
- 可已使用二进制来传输管道数据。
- 通过
Erlang Interface可以直接传输Erlang Term数据结构。
代码仓库地址:gitee.com/fh250250/el…
Authored by <duanpengyuan#qq.com>