大家好!欢迎来到今天的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的核心思想是:
- 加载动态链接库: 首先,我们需要把包含原生代码的动态链接库(比如
.so
、.dylib
、.dll
文件)加载到Deno进程中。 - 定义函数签名: 告诉Deno,动态链接库里有哪些函数可以调用,以及这些函数的参数类型和返回值类型。
- 调用函数: 使用Deno的FFI API,根据函数签名,调用动态链接库里的函数。
- 数据转换: 在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
,然后使用TextDecoder
将Uint8Array
转换回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
: 使用DataView
从ArrayBuffer
中读取结构体的成员。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.so
、librust_add.dylib
、rust_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。 谢谢大家!