JS `Deno` `FFI` (Foreign Function Interface):与原生代码的低开销交互

大家好,欢迎来到今天的Deno FFI讲座。咱们今天就来聊聊Deno的FFI,也就是Foreign Function Interface,外函数接口。这玩意儿听起来高大上,其实就是让你用JavaScript(或者说TypeScript,毕竟Deno更喜欢TS)直接调用用C/C++等语言写的原生代码。

想象一下,你用Deno写了一个程序,需要处理一些非常耗时的计算,或者需要访问一些底层硬件资源,又或者需要用到一些现成的C/C++库。如果用纯JavaScript实现,性能可能不够好,或者实现起来非常困难。这时候,FFI就派上用场了。它允许你把这些任务交给C/C++来做,然后Deno负责调用和管理,这样既能发挥JavaScript的灵活性,又能利用C/C++的性能优势。

一、 为什么要用FFI?

在深入细节之前,我们先来搞清楚为什么要用FFI。简单来说,就是为了解决以下几个问题:

  • 性能瓶颈: JavaScript虽然性能一直在提升,但在某些计算密集型任务中,仍然不如C/C++等编译型语言。FFI允许你把这些任务交给C/C++来处理,从而提高程序的整体性能。
  • 访问底层资源: JavaScript作为一种高级语言,对底层硬件资源的访问能力有限。而C/C++可以直接操作内存、设备驱动等,通过FFI,你可以让Deno程序访问这些底层资源。
  • 重用现有代码: 已经有很多成熟的C/C++库,如果能直接在Deno中使用它们,可以大大节省开发时间。FFI就是让你直接使用这些库的桥梁。

二、 FFI的基本原理

FFI的本质就是在不同编程语言之间建立一座桥梁,让它们可以互相调用。具体来说,Deno FFI的工作原理如下:

  1. 定义外部函数接口: 你需要用Deno代码声明你要调用的C/C++函数,包括函数的名称、参数类型、返回值类型等。这就像是告诉Deno:“嘿,我知道有这么一个C/C++函数,它长这个样子。”
  2. 加载动态链接库: C/C++代码通常会被编译成动态链接库(比如Windows上的.dll文件,Linux上的.so文件,macOS上的.dylib文件)。Deno需要加载这个动态链接库,才能找到你要调用的函数。
  3. 调用外部函数: 当你在Deno代码中调用声明好的外部函数时,Deno会负责把参数传递给C/C++函数,执行C/C++代码,然后把返回值传递回Deno。

三、 Deno FFI实战

说了这么多理论,不如来点实际的。咱们来写一个简单的例子,用Deno FFI调用一个C函数,计算两个整数的和。

1. C代码(add.c

#include <stdio.h>

int add(int a, int b) {
  printf("C: Adding %d and %dn", a, b);
  return a + b;
}

这个C代码非常简单,定义了一个add函数,接收两个整数作为参数,返回它们的和。

2. 编译C代码

你需要把这个C代码编译成动态链接库。不同的操作系统有不同的编译命令:

  • Linux/macOS:

    gcc -shared -o libadd.so add.c
  • Windows (MinGW):

    gcc -shared -o add.dll add.c

编译完成后,你会得到一个动态链接库文件,比如libadd.soadd.dll

3. Deno代码(main.ts

// @deno-types="./add.d.ts" // 类型定义文件(可选)
import { dlopen, SymbolTable } from "https://deno.land/x/[email protected]/mod.ts";

interface AddLib {
  add: {
    parameters: ["i32", "i32"];
    result: "i32";
  };
}

const lib = dlopen("./libadd.so", { // 或者 "./add.dll"
  add: {
    parameters: ["i32", "i32"],
    result: "i32",
  },
} as AddLib);

const a = 10;
const b = 20;
const sum = lib.symbols.add(a, b);

console.log(`Deno: The sum of ${a} and ${b} is ${sum}`);
lib.close();

让我们来分析一下这段Deno代码:

  • dlopen: 这个函数是Deno FFI的核心,它用于加载动态链接库。第一个参数是动态链接库的路径,第二个参数是一个对象,用于描述你要调用的外部函数。
  • SymbolTable: dlopen 的第二个参数用于描述动态链接库中的函数。 parameters 属性指定函数的参数类型,result 属性指定函数的返回值类型。 "i32" 表示32位整数。可选的其他类型包括:"void", "u64", "f64", "pointer" 等等。
  • lib.symbols.add: 加载动态链接库后,你可以通过 lib.symbols 访问外部函数。这里我们调用了 add 函数,并将结果存储在 sum 变量中。
  • lib.close(): 加载的动态链接库需要手动关闭,释放资源。

4. 运行Deno代码

在运行Deno代码之前,你需要确保已经安装了Deno,并且动态链接库文件(libadd.soadd.dll)和Deno代码(main.ts)在同一个目录下。然后,你可以使用以下命令运行Deno代码:

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

--allow-ffi 选项允许Deno使用FFI,--allow-read 选项允许Deno读取动态链接库文件。

运行结果应该如下所示:

C: Adding 10 and 20
Deno: The sum of 10 and 20 is 30

可以看到,Deno成功调用了C函数,并得到了正确的结果。

四、 深入FFI:数据类型和指针

上面的例子非常简单,只涉及了整数类型。但在实际应用中,你可能需要传递更复杂的数据类型,比如字符串、数组、结构体等。这时候,你就需要了解Deno FFI如何处理这些数据类型。

1. 基本数据类型映射

Deno FFI支持以下基本数据类型:

C/C++类型 Deno FFI类型 说明
void "void" 空类型,表示没有返回值或参数。
int "i32" 32位整数。
unsigned int "u32" 32位无符号整数。
long long "i64" 64位整数。
unsigned long long "u64" 64位无符号整数。
float "f32" 32位浮点数。
double "f64" 64位浮点数。
char* "pointer" 字符指针,通常用于传递字符串。
void* "pointer" 通用指针,可以指向任何类型的数据。

2. 指针

指针是C/C++中非常重要的概念,也是FFI中经常用到的。在Deno FFI中,指针类型用"pointer"表示。你可以使用Deno.UnsafePointer类型来操作指针。

例如,如果你想传递一个字符串给C函数,你需要先把字符串转换成C字符串,然后把C字符串的指针传递给C函数。

例子:传递字符串

C代码(string.c

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

char* greet(const char* name) {
  char* greeting = (char*)malloc(100);
  if (greeting == NULL) {
    return NULL; // Handle memory allocation failure
  }
  sprintf(greeting, "Hello, %s!", name);
  return greeting;
}

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

编译C代码:

gcc -shared -o libstring.so string.c

Deno代码(string.ts

import { dlopen, toCString, fromCString, UnsafePointer } from "https://deno.land/x/[email protected]/mod.ts";

interface StringLib {
  greet: {
    parameters: ["pointer"]; //const char* name
    result: "pointer"; // char*
  };
  free_string: {
    parameters: ["pointer"];
    result: "void";
  }
}

const lib = dlopen("./libstring.so", {
  greet: {
    parameters: ["pointer"],
    result: "pointer",
  },
  free_string: {
    parameters: ["pointer"],
    result: "void",
  }
} as StringLib);

const name = "Deno";
const namePointer = toCString(name);
const greetingPointer = lib.symbols.greet(namePointer);
const greeting = fromCString(greetingPointer);

console.log(greeting); // 输出 "Hello, Deno!"

// 重要:释放C字符串
lib.symbols.free_string(greetingPointer);

在这个例子中,我们使用了toCString函数把Deno字符串转换成C字符串,并获取了C字符串的指针。然后,我们把这个指针传递给C函数greet。C函数返回一个C字符串的指针,我们使用fromCString函数把C字符串转换成Deno字符串。

注意: 在C代码中,我们使用了malloc函数动态分配内存来存储字符串。这意味着我们需要在Deno代码中手动释放这块内存,否则会导致内存泄漏。在上面的例子中,我们定义了一个 free_string 函数来做这件事。这是使用FFI的一个重要注意事项:你需要负责管理C代码分配的内存。

3. 数组和结构体

传递数组和结构体稍微复杂一些,你需要使用Deno.UnsafePointerView来读取和写入内存。

五、 FFI的安全性和注意事项

FFI虽然强大,但也存在一定的安全风险。由于FFI允许你直接调用原生代码,这意味着你的Deno程序可以访问底层资源,甚至可以执行恶意代码。因此,在使用FFI时,你需要格外小心。

  • 权限控制: Deno有严格的权限控制机制,你可以使用--allow-ffi选项来允许Deno使用FFI。你应该只在必要的时候才开启这个权限,并尽量限制FFI的使用范围。
  • 代码审查: 在调用外部函数之前,你应该仔细审查C/C++代码,确保它没有安全漏洞。
  • 内存管理: C/C++代码通常需要手动管理内存。如果内存管理不当,可能会导致内存泄漏、野指针等问题。你需要负责管理C代码分配的内存,并在不再需要的时候及时释放。
  • 类型安全: Deno FFI的类型检查相对较弱,如果你传递了错误的参数类型给C函数,可能会导致程序崩溃。你应该仔细检查参数类型,确保它们与C函数的期望类型一致。

六、 总结

Deno FFI是一个强大的工具,它允许你用JavaScript/TypeScript与原生代码进行低开销的交互。通过FFI,你可以提高程序的性能,访问底层资源,重用现有代码。但是,FFI也存在一定的安全风险,你需要在使用时格外小心,确保程序的安全性和稳定性。

希望今天的讲座对你有所帮助。现在,你可以尝试用Deno FFI来解决一些实际问题了。记住,实践是最好的老师!

祝大家编程愉快!

发表回复

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