JS Deno FFI (Foreign Function Interface):与 Rust/C/C++ 原生库交互

大家好!欢迎来到今天的Deno FFI讲座。今天咱们要聊的是个挺酷的东西:Deno的FFI,也就是Foreign Function Interface,它可以让你的Deno代码直接跟Rust、C、C++这些“原生”代码亲密接触。

想象一下,Deno就像一个精致的咖啡馆,而Rust、C、C++就像是咖啡豆的产地,拥有着Deno咖啡馆没有的原始风味和力量。FFI就是这座咖啡馆和咖啡豆产地之间的桥梁,让Deno咖啡馆可以利用这些产地出产的顶级咖啡豆,做出独一无二的咖啡。

一、 什么是FFI?为什么我们需要它?

简单来说,FFI就是一种技术,允许你用一种编程语言(比如Deno的JavaScript/TypeScript)去调用另一种编程语言编写的代码(比如Rust/C/C++)。

那么,我们为什么要这么做呢?原因有很多:

  • 性能优化: JavaScript虽然越来越快,但有些计算密集型的任务,原生代码(Rust/C/C++)往往能提供更高的性能。比如图像处理、音视频编解码、复杂的数学运算等等。
  • 利用现有库: 很多成熟的、经过高度优化的库是用C/C++编写的。如果能直接在Deno中使用这些库,可以大大节省开发时间,避免重复造轮子。
  • 访问底层硬件: 有些时候,我们需要直接访问操作系统底层的功能,比如控制硬件设备。JavaScript/TypeScript本身是做不到的,但原生代码可以。

总而言之,FFI可以让我们在Deno的世界里,享受到原生代码的强大力量。

二、 Deno FFI的基本原理

Deno FFI的核心思想是:

  1. 加载动态链接库: 首先,我们需要把包含原生代码的动态链接库(比如.so.dylib.dll文件)加载到Deno进程中。
  2. 定义函数签名: 告诉Deno,动态链接库里有哪些函数可以调用,以及这些函数的参数类型和返回值类型。
  3. 调用函数: 使用Deno的FFI API,根据函数签名,调用动态链接库里的函数。
  4. 数据转换: 在JavaScript/TypeScript和原生代码之间传递数据时,需要进行类型转换。

这个过程听起来有点抽象,咱们用一个简单的例子来具体说明。

三、 一个简单的例子:加法运算

假设我们有一个用C语言编写的动态链接库,它只有一个函数,用来计算两个整数的和。

1. C代码 (add.c):

#include <stdio.h>

int add(int a, int b) {
    return a + b;
}

2. 编译成动态链接库:

gcc -shared -o libadd.so add.c

这条命令会将add.c编译成一个名为libadd.so的动态链接库(在Linux/macOS上)。在Windows上,你需要生成一个.dll文件。

3. Deno代码 (add.ts):

// 定义动态链接库的路径(根据你的操作系统修改)
const libPath = "./libadd.so"; // Linux/macOS
// const libPath = "./add.dll"; // Windows

// 定义FFI接口
const dylib = Deno.dlopen(libPath, {
  "add": {
    parameters: ["i32", "i32"], // 两个i32类型的参数
    result: "i32",            // 返回一个i32类型的值
  },
});

// 调用C函数
const a = 10;
const b = 20;
const result = dylib.symbols.add(a, b);

// 打印结果
console.log(`The sum of ${a} and ${b} is ${result}`);

// 关闭动态链接库
dylib.close();

这个Deno代码做了以下几件事:

  • Deno.dlopen(libPath, { ... }): 加载动态链接库。第一个参数是动态链接库的路径,第二个参数是一个对象,用来描述动态链接库里可以调用的函数。
  • { "add": { parameters: ["i32", "i32"], result: "i32" } }: 定义了名为add的函数的签名。parameters指定了函数的参数类型,result指定了函数的返回值类型。 i32 代表32位整数。
  • dylib.symbols.add(a, b): 调用C函数add,并将结果赋值给result变量。
  • dylib.close(): 关闭动态链接库,释放资源。

4. 运行Deno代码:

deno run --allow-read --allow-ffi add.ts

别忘了加上--allow-read--allow-ffi这两个权限标志,否则Deno会拒绝加载动态链接库。

运行结果应该会打印出:The sum of 10 and 20 is 30

恭喜你,你已经成功地用Deno调用了C代码!

四、 数据类型映射

在Deno FFI中,需要在JavaScript/TypeScript类型和原生代码类型之间进行映射。Deno提供了一些内置的类型,可以用来表示常见的原生数据类型。

Deno类型 C/C++类型 说明
"void" void 空类型,表示没有返回值或参数
"i8" int8_t 8位有符号整数
"u8" uint8_t 8位无符号整数
"i16" int16_t 16位有符号整数
"u16" uint16_t 16位无符号整数
"i32" int32_t 32位有符号整数
"u32" uint32_t 32位无符号整数
"i64" int64_t 64位有符号整数
"u64" uint64_t 64位无符号整数
"f32" float 32位浮点数
"f64" double 64位浮点数
"pointer" void* 指针类型,用于传递内存地址
"buffer" void* 缓冲区类型,用于传递二进制数据
"usize" size_t 无符号整数类型,大小取决于平台(32位或64位),通常用于表示内存大小
"isize" ssize_t 有符号整数类型,大小取决于平台(32位或64位),通常用于表示内存大小

五、 更复杂的数据类型:指针和结构体

上面的例子只涉及了简单的整数类型。如果我们要传递更复杂的数据类型,比如指针、结构体,该怎么办呢?

1. 指针

指针在C/C++中非常重要,它允许我们直接操作内存。在Deno FFI中,我们可以使用"pointer"类型来表示指针。

例子:字符串反转

假设我们有一个C函数,它接受一个字符串指针,并将字符串反转。

C代码 (reverse.c):

#include <string.h>
#include <stdlib.h>

char* reverse_string(char* str) {
    if (str == NULL) {
        return NULL;
    }

    size_t len = strlen(str);
    char* reversed = (char*)malloc(len + 1); // 分配内存

    if (reversed == NULL) {
        return NULL; // 内存分配失败
    }

    for (size_t i = 0; i < len; i++) {
        reversed[i] = str[len - 1 - i];
    }
    reversed[len] = ''; // 添加字符串结束符

    return reversed;
}

void free_string(char* str) {
    free(str);
}

编译成动态链接库:

gcc -shared -o libreverse.so reverse.c

Deno代码 (reverse.ts):

const libPath = "./libreverse.so"; // Linux/macOS
// const libPath = "./reverse.dll"; // Windows

const dylib = Deno.dlopen(libPath, {
  "reverse_string": {
    parameters: ["pointer"],
    result: "pointer",
  },
  "free_string":{
    parameters:["pointer"],
    result:"void"
  }
});

// JavaScript字符串 -> C字符串 (Uint8Array)
function stringToUint8Array(str: string): Uint8Array {
    return new TextEncoder().encode(str + ""); // 确保以null结尾
}

// C字符串 -> JavaScript字符串
function uint8ArrayToString(uint8Array: Uint8Array): string {
    return new TextDecoder().decode(uint8Array);
}

const str = "hello";
const strBytes = stringToUint8Array(str);

// 将Uint8Array的指针传递给C函数
const strPointer = Deno.UnsafePointer.of(strBytes);
const reversedPointer = dylib.symbols.reverse_string(strPointer);

// 将C字符串指针转换为Uint8Array
const reversedBytes = new Deno.UnsafePointerView(reversedPointer).getArrayBuffer(str.length+1);
const reversedString = uint8ArrayToString(new Uint8Array(reversedBytes));

console.log(`Original string: ${str}`);
console.log(`Reversed string: ${reversedString}`);

// 释放C字符串占用的内存
dylib.symbols.free_string(reversedPointer);
dylib.close();

在这个例子中,我们需要注意以下几点:

  • 字符串转换: JavaScript字符串和C字符串的表示方式不同,需要进行转换。我们可以使用TextEncoder将JavaScript字符串转换为Uint8Array,然后使用TextDecoderUint8Array转换回JavaScript字符串。 C字符串需要以 结尾。
  • Deno.UnsafePointer.of(strBytes): 获取Uint8Array的指针。
  • 内存管理: C代码中分配的内存需要手动释放,否则会造成内存泄漏。在这个例子中,我们定义了一个free_string函数来释放内存。

2. 结构体

结构体是一种复合数据类型,它可以包含多个不同类型的成员。在Deno FFI中,我们需要使用Deno.UnsafePointerView来访问结构体的成员。

例子:矩形

假设我们有一个C结构体,用来表示一个矩形:

C代码 (rectangle.c):

#include <stdio.h>
#include <stdlib.h>

typedef struct {
    int width;
    int height;
} Rectangle;

Rectangle* create_rectangle(int width, int height) {
    Rectangle* rect = (Rectangle*)malloc(sizeof(Rectangle));
    if (rect == NULL) {
        return NULL;
    }
    rect->width = width;
    rect->height = height;
    return rect;
}

int get_area(Rectangle* rect) {
    if (rect == NULL) {
        return 0;
    }
    return rect->width * rect->height;
}

void free_rectangle(Rectangle* rect) {
    free(rect);
}

编译成动态链接库:

gcc -shared -o librectangle.so rectangle.c

Deno代码 (rectangle.ts):

const libPath = "./librectangle.so"; // Linux/macOS
// const libPath = "./rectangle.dll"; // Windows

const dylib = Deno.dlopen(libPath, {
  "create_rectangle": {
    parameters: ["i32", "i32"],
    result: "pointer",
  },
  "get_area": {
    parameters: ["pointer"],
    result: "i32",
  },
  "free_rectangle":{
    parameters:["pointer"],
    result:"void"
  }
});

const width = 10;
const height = 20;

const rectPointer = dylib.symbols.create_rectangle(width, height);

//使用DataView读取结构体数据
const dataView = new DataView(Deno.UnsafePointerView.getArrayBuffer(rectPointer, 8)); //Rectangle占用8个字节 (两个i32)
const rectWidth = dataView.getInt32(0, true); // 从偏移量0开始读取width (little-endian)
const rectHeight = dataView.getInt32(4, true); // 从偏移量4开始读取height (little-endian)

const area = dylib.symbols.get_area(rectPointer);

console.log(`Rectangle width: ${rectWidth}`);
console.log(`Rectangle height: ${rectHeight}`);
console.log(`Rectangle area: ${area}`);

dylib.symbols.free_rectangle(rectPointer);
dylib.close();

在这个例子中,我们需要注意以下几点:

  • 结构体内存布局: 我们需要知道结构体在内存中的布局,才能正确地访问其成员。 在这个例子中,Rectangle结构体包含两个int类型的成员,每个int占用4个字节,所以整个结构体占用8个字节。
  • Deno.UnsafePointerView: 使用Deno.UnsafePointerView来读取结构体的数据。 Deno.UnsafePointerView.getArrayBuffer(rectPointer, 8) 获取一个包含结构体数据的ArrayBuffer
  • DataView: 使用DataViewArrayBuffer中读取结构体的成员。 dataView.getInt32(0, true) 从偏移量0开始读取一个32位整数。 第二个参数true表示使用little-endian字节序。

六、 Rust FFI的优势

虽然C/C++ FFI已经很强大了,但Rust FFI还有一些额外的优势:

  • 内存安全: Rust的ownership和borrowing机制可以帮助我们避免内存泄漏、空指针引用等常见的C/C++错误。
  • 类型安全: Rust的类型系统比C/C++更严格,可以帮助我们在编译时发现类型错误。
  • 更容易的构建过程: Rust的Cargo工具可以简化构建动态链接库的过程。

七、 Rust FFI 示例

我们用Rust重写上面的加法运算的例子。

1. Rust代码 (src/lib.rs):

#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}
  • #[no_mangle]: 告诉Rust编译器不要修改函数名,以便Deno可以找到这个函数。
  • pub extern "C" fn add(...): 声明一个可以被C代码调用的函数。 extern "C" 指定使用C ABI (Application Binary Interface)。

2. Cargo.toml:

[package]
name = "rust_add"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]
  • crate-type = ["cdylib"]: 指定将Rust代码编译成一个动态链接库。

3. 编译成动态链接库:

cargo build --release

这个命令会在target/release目录下生成一个动态链接库(librust_add.solibrust_add.dylibrust_add.dll)。

4. Deno代码 (rust_add.ts):

const libPath = "./target/release/librust_add.so"; // Linux/macOS
// const libPath = "./target/release/rust_add.dll"; // Windows

const dylib = Deno.dlopen(libPath, {
  "add": {
    parameters: ["i32", "i32"],
    result: "i32",
  },
});

const a = 10;
const b = 20;
const result = dylib.symbols.add(a, b);

console.log(`The sum of ${a} and ${b} is ${result}`);

dylib.close();

这个Deno代码和之前的C代码的例子几乎一样,唯一的区别是动态链接库的路径。

八、 总结与注意事项

Deno FFI是一个强大的工具,可以让我们在Deno的世界里,享受到原生代码的强大力量。但同时,使用FFI也需要格外小心:

  • 安全风险: FFI会引入安全风险,因为原生代码可能存在漏洞。
  • 内存管理: 如果原生代码分配了内存,我们需要手动释放,否则会造成内存泄漏。
  • 类型安全: 需要仔细地定义函数签名,确保JavaScript/TypeScript类型和原生代码类型匹配。
  • 调试难度: FFI的调试难度较高,需要熟悉原生代码的调试工具。
  • 权限控制: 一定要使用 --allow-ffi--allow-read 权限。

在使用Deno FFI时,一定要谨慎,仔细地测试代码,确保程序的安全性和稳定性。

希望今天的讲座能帮助你更好地理解Deno FFI。 谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注