Archive for April 15th, 2008

Programming Erlang 第12章 接口技术 (完整)

Tuesday, April 15th, 2008

接口技术

译者: gashero

目录

假设我们需要以Erlang接口运行以C/Python/Shell编写的程序。想要实现这些,我们需要在一个单独的操作系统进程中运行这些程序,而不是在Erlang运行时系统中,他们之间以面向字节的通道完成通信。Erlang端通过 Port 控制。创建端口的进程成为端口的连接进程。连接进程拥有特殊意义:让所有来自扩展程序的消息都会以连接进程的PID来标志。所有的扩展程序消息都会发送到连接进程。

我们可以看看连接进程(C)与端口(P)和扩展操作系统进程的关系。

chpt12.00.jpg从程序员的角度,端口就像是Erlang进程。你可以发送消息给它,你可以注册(register)它,等等。如果扩展程序crash了,那么连接程序就会收到退出信号,如果连接进程死掉了,扩展进程也会被kill掉。

你可能会好奇与为什么要这么做。很多编程语言允许其他语言编写的程序连接到可执行文件。而在Erlang,我们为了安全性不允许这么做。如果我们连 接一个扩展程序到Erlang可执行程序,那么扩展程序的错误将会轻易的干掉Erlang。所以,其他语言编写的程序必须以单独的操作系统进程来运行。 Erlang运行时系统和扩展进程通过字节流通信。

1   端口

创建端口使用如下命令:

Port=open_port(PortName,PortSettings)

这会返回端口,而如下消息是会被发送到端口的(这些消息中PidC是连接进程的PID):

Port ! {PidC,{command,Data}} :发送数据到端口

Port ! {PidC,{connect,Pid1}} :改变控制进程的PID,从PidC到Pid1

Port ! {PidC,close} :关闭端口

连接进程会从扩展程序收到如下消息:

receive
    {Port,{data,Data}} ->
        ... 数据处理 ...

下面的节,我们会编写Erlang与C结合的简单例子。C程序尽可能的简单以避开细节,直接谈接口技术。

注意,下面的例子对接口机制和协议做了加亮。编码和解码复杂的数据结构是个困难的问题,这里没有谈及。在本章末尾,我们会指出一些用于其他编程语言的接口库。

2   扩展C程序的接口

我们先从C程序开始:

int twice(int x) {
    return 2*x;
}

int sum(int x, int y) {
    return x+y;
}

我们最终目标是从Erlang调用这些例程,我们希望看到的是这个样子(Erlang中):

X1=example1:twice(23),
Y1=example1:sum(45,32),

与用户的设置有关,example1是一个Erlang模块,所有与C接口的细节都被隐藏在了模块example1中。

我们的接口需要一个主程序,用来解码Erlang程序发来的数据。在我们的例子,我们首先定义端口和扩展C程序的协议。我们使用一个超级简单的协议,并展示如何在Erlang和C中实现。协议定义如下:

  • 所有包都以2字节的长度代码开头,后面跟着这些字节的数据。
  • 想要调用 twice(N) ,Erlang程序必须以特定形式编码函数调用。我们假设编码是2字节序列 [1,N] ;参数1表示调用函数 twice ,后面的N代表一个1字节的参数。
  • 调用 sum(N,M) ,我们编码请求到字节序列 [2,N,M] 。
  • 假设返回值都是单一的字节长度的。

扩展C程序和Erlang程序都必须遵守这个协议。作为例子,我们通过 sum(45,32) 来看看工作流程:

  1. 端口发送字节序列 0,3,2,45,32 到扩展程序。头两个字节0,3,表示包的长度是3,代码2表示调用扩展的 sum 函数,而 45 和 32 表示调用函数的参数。
  2. 扩展程序从标准输入(stdin)读取这5个字节,调用 sum 函数,然后把字节序列 0,2,77 写到标准输出(stdout)。前两个字节表示包长度,后面的77是结果(仍然是1字节长度)。

我们现在写在两端遵守协议的接口,先以C程序开始。

2.1   C程序

扩展的C程序需要编写3个文件:

  • example1.c :这个包含了我们需要调用的原始函数。
  • example1_driver.c :实现了字节流协议和对 example1.c 的调用例程。
  • erl_comm.c :包含读写内存缓冲区的例程。

example1_driver.c

#include <stdio.h>
typedef unsigned char byte;

int read_cmd(byte* buff);
int write_cmd(byte* buff, int len);

int main() {
    int fn, arg1, arg2, result;
    byte buff[100];

    while(read_cmd(buff)>0) {
        fn=buff[0];
        if (fn==1) {
            arg1=buff[1];
            result=twice(arg1);
        } else if (fn==2) {
            arg1=buff[1],
            arg2=buff[2],
            //如果希望调试,可以写到stderr
            //fprintf(stderr,"call sum %i %i\n",arg1,arg2);
            result=sum(arg1,arg2);
        }
        buff[0]=result;
        write_cmd(buff,1);
    }
}

这段代码运行一个无限循环,从标准输入stdin读取调用命令,并且将结果写入到标准输出。

如果希望调试,可以写到stderr,上面的例子里面就有相关的输出语句。

erl_comm.c 是对stdin和stdout读写带有2字节包头的数据包的代码。这么写是允许有分片的。

#include <unistd.h>
typedef unsigned char byte;

int read_cmd(byte* buf);
int write_cmd(byte* buf, int len);
int read_exact(byte* buf, int len);
int write_exact(byte* buf, int len);

int read_cmd(byte* buf) {
    int len;
    if (read_exact(buf,2)!=2)
        return(-1);
    len=(buf[0]<<8) | buf[1];
    return read_exact(buf,len);
}

int write_cmd(byte* buf, int len) {
    byte li;
    li=(len>>8) & 0xff;
    write_exact(&li,1);
    li=len & 0xff
    write_exact(&li,1);
    return write_exact(buf,len);
}

int read_exact(byte* buf, int len) {
    int i, got=0;
    do {
        if ((i=read(0,buf+got,len-got)<=0)
            return i;
        got+=i;
    } while (got<len);
    return len;
}

int write_exact(byte* buf, int len) {
    itn i,wrote=0;
    do {
        if ((i=write(1,buf+wrote,len-wrote)<=0)
            return i;
        wrote+=i;
    } while(wrote<len);
    return len;
}

这段代码处理带有2字节长度包头的数据包,所以用于匹配 {packet,2} 选项的端口驱动程序。

2.2   Erlang程序

Erlang端的驱动程序参考如下:

-module(example1).
-export([start/0,stop/0]).
-export([twice/1,sum/2]).

start() ->
    spawn(fun() ->
            register(example1,self()),
            process_flag(trap_exit,true),
            Port=open_port({spawn,"./example1"},[{packet,2}]),
            loop(Port)
        end).

stop() ->
    example1 ! stop.

twice(X) -> call_port({twice,X}).
sum(X,Y) -> call_port({sum,X,Y}).

call_port(Msg) ->
    example1 ! {call,self(),Msg},
    receive
        {example1,Result} ->
            Result
    end.

loop(Port) ->
    receive
        {call,Caller,Msg} ->
            Port ! {self(),{command,encode(Msg)}},
            receive
                {Port,{data,Data}} ->
                    Caller ! {example1,decode(Data)}
            end,
            loop(Port);
        stop ->
            Port ! {self(),close},
            receive
                {Port,closed} ->
                    exit(normal)
            end;
        {'EXIT',Port,Reason} ->
            exit({port_terminated,Reason})
    end.

encode({twice,X}) -> [1,X];
encode({sum,X,Y}) -> [2,X,Y];

decode([Int]) -> Int.

端口通过如下方式打开:

Port=open_port({spawn,"./example1"},[{packet,2}])

选项 {packet,2} 告诉系统自动在发送数据包时添加一个2字节的包头。所以,会在发送消息 {PidC,{command,[2,45,32]}} 自动加上两字节的长度包头,也就是发送 0,3,2,45,32 到扩展程序。

而输入端口会假设输入数据包也有2字节的长度包头,获取指定长度的数据包之后就会去掉长度包头。

完善程序还需要一个makefile来构建。命令 make example1 用于构建 open_port 调用的扩展程序。注意makefile同时也链接了本章稍后会用到的内联驱动。

makefile

.SUFFIXES: .erl .beam .yrl

.erl.beam:
    erlc -W $<

MODS=example1 example1_lid.c

all: ${MODS:%=%.beam} example1 example1_drv.so

example1: example1.c erl_comm.c example1_driver.c
    gcc -o example1 example1.c erl_comm.c example1_driver.c

example1_drv.so: example1_lid.c example.c
    gcc -o example1_drv.so -fpic -shared example1.c example1_lid.c

clean:
    rm example example1_drv.so *.beam

2.3   运行程序

现在运行程序:

1> example1:start().
<0.32.0>
2> example1:sum(45,32).
77
4> example1:twice(10).
20
...

这就完成了我们的第一个例子。

进入下个主题之前,必须注意的是:

  1. 例子程序没有尝试统一Erlang和C程序的整数。这里我们假设整数都是单字节的,并且忽略了精度和符号的问题。在现实的应用中必须注意类型和精度的问题。这是个困难的问题,因为erlang管理着不限制大小的整数,而像C一类的语言却必须管理整数的精度等等。
  2. 我们无法在运行扩展程序之前调用对应的erlang的函数(也就是运行 example1:start() 之前不能调用对应函数)。我们更希望可以自动的启动。这在18.7节会详细讲解。

3   open_port

前一节使用了 open_port 却没有介绍具体的参数。只是使用了增加了 {packet,2} 选项的 open_port 而已。 open_port 包含很多参数。

一些常用参数如下:

@spec open_port(PortName,[Opt]) -> Port

PortName是如下之一:

{spawn,Command}

启动一个扩展程序。Command是扩展程序的名字。Command会在Erlang的工作空间以外工作,除非找到了叫做Command的内联驱动。

{fd,In,Out}

允许Erlang进程存取一个已经打开的文件描述符。文件描述符 “In” 用作stdin,而文件描述符 “Out” 用作stdout。查看例子 http://www.erlang.org/examples/examples-2.0.html

Opt是如下之一:

{packet,N}

包前面加上N(1,2,4)字节长度的长度包头。

stream

消息不是按照包长度发送的。应用自己知道如何处理这些包。

{line,Max}

以行为单位传递消息。如果一行大于Max字节,则会切割为只有Max字节。

{cd,Dir}

仅用于 {spawn,Command} 选项,扩展程序的初始目录为Dir。

{env,Env}

仅用于 {spawn,Command} 选项。指定扩展程序可用的环境变量为Env。Env是一个列表的 {VarName,Value} 对。两个变量都是字符串。

这并不是完整的 open_port 参数列表。我们可以在参考手册的erlang模块里找到详细的描述。

4   内联驱动

有时候我们希望在Erlang运行时系统内运行一个外语程序。在这种情况下,程序以共享库的方式编写并动态链接到Erlang运行时系统。内联驱动对程序员来说就是一个端口程序,而且与端口程序使用相同的协议。

创建内联驱动是最有效率的工作方式,不过却很危险。任何致命错误都会干掉整个Erlang系统,并且影响到正在工作的所有进程。因为这个原因,非常不推荐内联驱动。

通过刚才的例子讲讲内联驱动。你需要三个文件:

  1. example1_lid.erl :这个是erlang的服务器。
  2. example1.c :包含我们需要调用的C函数,跟上一节的一样。
  3. example1_lid.c :调用 example1.c 中好函数的函数。

Erlang管理接口的代码如下:

-module(example1_lid).
-export([start/0,stop/0]).
-export([twice/1,sum/2]).

start() ->
    start("example1_drv").

start(SharedLib) ->
    case erl_ddl1:load_driver(".",SharedLib) of
        ok -> ok;
        {error,already_loaded} -> ok;
        _ -> exit({error,could_not_load_driver})
    end,
    spawn(fun() -> init(SharedLib) end).

init(SharedLib) ->
    register(example1_lid,self()),
    Port=open_port({spawn,SharedLib},[]),
    loop(Port).

stop() ->
    example1_lid ! stop.

twice(X) -> call_port({twice,X}).
sum(X,Y) -> call_port({sum,X,Y}).

call_port(Msg) ->
    example1_lid ! {call,self(),Msg},
    receive
        {example1_lid,Result} ->
            Result
    end.

loop(Port) ->
    receive
        {call,Caller,Msg} ->
            Port ! {self(),{command,encode(Msg)}},
            receive
                {Port,{data,Data}} ->
                    Caller ! {example1_lid,decode(Data)}
            end,
            loop(Port);
        stop ->
            Port ! {self(),close},
            receive
                {Port,closed} ->
                    exit(normal)
            end;
        {'EXIT',Port,Reason} ->
            io:format("~p~n",[Reason]),
            exit(port_terminated)
    end.

encode({twice,X}) -> [1,X];
encode({sum,X,Y}) -> [2,X,Y].

decode([Int]) -> Int.

对比该程序与较早的版本,看起来基本相同。

驱动程序由 driver 结构体组成。命令 make example1_drv.so 用于构建动态链接库。

// example1_lid.c
#include <stdio.h>
#include "erl_driver.h"

typedef struct {
    ErlDrvPort port;
} example_data;

static ErlDrvData example_drv_start(ErlDrvPort port, char* buff) {
    example_data* d=(example_data*)driver_alloc(sizeof(example_data));
    d->port=port;
    return (ErlDrvData)d;
}

static void example_drv_stop(ErlDrvData handle) {
    driver_free((char*) handle);
}

static void example_drv_output(ErlDrvData handle, char* buff, int bufflen) {
    example_data* d=(example_data*)handle;
    char fn=buff[0], arg=buff[1], res;
    if (fn==1) {
        res=twice(arg);
    } else if (fn==2) {
        res=sum(buff[1],buff[2]);
    }
    driver_output(d->port,&res,1);
}

ErlDrvEntry example_driver_entry={
    NULL,                   // F_PTR init, N/A
    example_drv_start,      // L_PTR start, 在端口打开时调用
    example_drv_stop,       // F_PTR stop, 在端口关闭时调用
    example_drv_output,     // F_PTR output, 当erlang发送数据到端口时
    NULL,                   // F_PTR ready_input
    NULL,                   // F_PTR ready_output
    "example1_drv",         // 驱动的名字
    NULL,                   // F_PTR finish, 卸载(unload)时
    NULL,                   // F_PTR control, port_command回调
    NULL,                   // F_PTR timeout, 保留
    NULL                    // F_PTR outputv, 保留
};

DRIVER_INIT(example_drv) {  //必须与driver_entry中的名字匹配
    return &example_driver_entry;
}

这里时运行程序:

1> c(example_lid).
{ok,example1_lid}
2> example1_lid:start().
<0.41.0>
3> example1_lid:twice(50).
100
4> example1_lid:sum(10,20).
30

5   注意

本章研究了如何以端口来调用扩展程序。除了使用端口协议之外,还可以使用一些其他的BIF来操作端口。这些都在erlang模块的手册中。

在这一点上,你可能会关心如何方便的发送复杂的数据结构,如字符串、元组等等到扩展程序?很不幸,答案是还没有比较简单的方法来实现。所有的端口只 是提供了很底层的机制来传输一系列字节而已。同样的问题也发生在Socket编程中。一个Socket提供了数据流与应用交互,对于数据流的解释还需要应 用自己做。

不过一些库提供了简单的交互方法,可以查看:

http://www.erlang.org/doc/pdf/erl_interface.pdf

Erl interface (ei) 是一系列的C例程和宏,用于编码和解码Erlang的扩展格式。在Erlang端,一个程序使用 term_to_binary 来将Erlang术语串行化,而在C端ei的例程可以把binary解包。ei也可以用于构造binary,而在Erlang使用 binary_to_term 来解包。

http://www.erlang.org/doc/pdf/ic.pdf

Erlang IDL Compiler (ic) 。这是一个Erlang的OMG IDL编译器实现。

http://www.erlang.org/doc/pdf/jinterface.pdf

Jinterface 是一系列Java到Erlang的接口工具。它提供了Erlang类型到Java类型的完全映射,编码和解码Erlang术语,连接Erlang进程等等。还有一大堆的扩展功能。