PHP FFI 完整指南

什么是 FFI,能用它做什么?

FFI(Foreign Function Interface,外部函数接口)是一种允许程序使用不同语言编写的库的技术。它比 RPC 或 API 快得多,因为不需要通过网络接口,程序将直接与二进制定义进行交互。

换句话说,通过使用 FFI,PHP 程序将能够使用 C、Rust、Golang 或任何其他能够生成 ABI 的语言编写的库。

FFI 允许使用来自编译语言(如 C、Rust 和 Golang)的库。但它不是一个神奇的工具,不能让两个不同的运行时在没有网络的情况下相互通信。

通过在 PHP 中采用 FFI,能够为项目使用任何需要的共享对象:Windows 的 .dll、Linux 的 .so 或 MacOS 的 .dylib

这提供了一个跳出 PHP 虚拟机(Zend VM)的机会,几乎可以使用 PHP 编写任何想要的东西。使用像 raylib 或 libui 这样的 C 库不再需要依赖任何 C 扩展。

FFI 会让代码运行得更快吗?

可能会认为,由于将使用 C 编写的外部代码,它可能比 PHP 更快。这种思路不一定错,但需要记住,语言不会施展魔法:它们只是按照指令去做。

当涉及到 CPU 时间时,使用 FFI 从 PHP 调用外部函数可能会花费在纯 PHP 中执行相同操作所需时间的两倍。这是因为 PHP 的虚拟机已经非常优化,与外部代码交互需要一个翻译过程,这会增加处理成本。

这是正常的,目前所有支持 FFI 的语言在使用 FFI 时性能都会下降。

可以优化内存消耗! 正如《精通 PHP 中的位运算操作》一文中所述,每个 PHP 变量都有一个内部类型 zval,它做了很多事情来让 PHP 的生活更轻松,比如用 INT64 类型表示每个 PHP 整数。所以即使 0x10 也会在 PHP 中存储为 0x0000000000000010(并且 zval 的所有其他成员都分配了它们的指针)。

所以一个好的实践是在使用 PHP 处理事物和使用 FFI 处理内存中的对象之间找到平衡。这样可以优化内存消耗,这可能会也可能不会影响整体 CPU 时间。

FFI 还是 C 扩展,应该使用哪个?

FFI 通常被作为原型设计工具:用它迈出第一步,然后迁移到原生扩展代码。

如果代码不太关心性能(不太可能,但可能发生),使用 FFI 来扩展 PHP 的能力是可以的。不要忘记,PHP 中的 FFI 仍然是实验性的,可能会不时遇到错误或其核心的 API 更改。

C 扩展通常应该用 C 代码编写,这对许多 PHP 工程师来说是一个可怕的障碍。但它们集成到 PHP 的虚拟机中,所以扩展会比 FFI 快得多,因为它们直接从 C 调用 C 代码(不需要翻译),并且只映射将与最终用户交互的代码。

扩展是针对特定 PHP 版本编译的,这会产生一个恼人的依赖关系,可能会拖慢你升级 PHP 版本的速度。如果你准备自己升级扩展并遵循其社区提出的集成过程,那就更好了,但仍然会花费你几天时间。

FFI 将始终开箱即用,不会阻止你升级 PHP 版本,因为 FFI 扩展是 PHP 核心的一部分。

FFI 入门:构建一个 raylib 窗口

PHP 本身绝对不能做的一件事是操作操作系统上的原生窗口。有一些扩展可以做到这一点,比如之前介绍的 PHP-GTK 和 raylib 扩展,另一个选择是使用 FFI。

这里选择 Raylib 作为示例,因为它的接口非常简化且易于使用。

安装 raylib 的共享对象

对于 Mac 用户来说,这将像通过 HomeBrew 安装 raylib 一样简单:

bash
$ brew install raylib

其他系统有完整的安装指南。这里你可以找到在 Windows 上安装在 Linux 上安装的指南。

安装完所有内容后,你的系统中应该有一个可用的共享对象。在 MacOS 上,你可以在 /usr/local/Cellar/raylib/<version>/lib 下看到 libraylib.dylib 文件:

bash
$ ls -la /usr/local/Cellar/raylib/3.5.0/lib
cmake           libraylib.351.dylib libraylib.dylib
libraylib.3.5.0.dylib   libraylib.a     pkgconfig

在 Windows 上你关心的是 .dll 文件,在 GNU Linux 上你关心的是 .so 文件。

首先在 C 中原型设计

了解它在 PHP 中使用 FFI 是否能良好工作的最简单方法是首先了解它在 C 中应该如何表现,对吧?

所以我们要做的第一件事是使用 raylib 在 C 中构建一个简单的程序,该程序将构建我们的窗口。让我们创建一个 hello_raylib.c 文件,内容如下:

c
#include "raylib.h"

int main(void)
{
  Color white = { 255, 255, 255, 255 };
  Color red = { 255, 0, 0, 255 };

  InitWindow(
    800,
    600,
    "Hello raylib from C"
  );

  while (
    !WindowShouldClose()
  ) {
    ClearBackground(white);

    BeginDrawing();
      DrawText(
        "Hello raylib!",
        400,
        300,
        20,
        red
      );
    EndDrawing();
  }

  CloseWindow();
}

上面的代码应该创建一个大小为 800x600 的窗口,标题栏中有"Hello raylib from C"文本。在这个窗口内,应该出现一个红色的文本"Hello raylib!",其原点在屏幕中间。

让我们编译并运行上面的代码:

bash
$ gcc -o hello_raylib \
  hello_raylib.c -lraylib
$ ./hello_raylib

注意:使用适用于你平台的 C 编译器。在我的情况下,我使用了 clang,但它应该或多或少以相同的方式工作。

下面你会看到预期的结果。

一个尺寸为 800x600 的原生窗口,标题为"Hello raylib from C",显示红色文本"Hello raylib!"

现在用 PHP!构建一个头文件

为了让 PHP 与 C(或其他语言)通信,我们必须首先创建一个接口。在 C 中,这样的接口由头文件表示。这正是为什么大多数 .c 文件在代码库中都有相应的 .h 文件:它概述了链接到它的文件可能会发现有用的常见对象和函数签名。

由于我们想引用 libraylib.dylib,头文件的第一行将包含以下 define,专门用于 FFI。所以让我们开始编写将与 PHP 代码交互的 raylib.h 文件:

c
#define FFI_LIB "libraylib.dylib"

注意:引用的文件可能会根据你的操作系统而改变。

Raylib 有很多很多函数,你可以在他们的速查表中查看。但我们不需要导入所有这些。事实上,我建议你只导入程序所需的那些。在我们的例子中,我们只需要 7 个:

c
#define FFI_LIB "libraylib.dylib"

void InitWindow(
  int width,
  int height,
  const char *title
);
bool WindowShouldClose(void);
void ClearBackground(
  Color color
);
void BeginDrawing(void);
void DrawText(
  const char *text,
  int x,
  int y,
  int size,
  Color color
);
void EndDrawing(void);
void CloseWindow(void);

注意,一些函数签名需要由 raylib 构建的非常特定的类型。函数 ClearBackgroundDrawText 需要一个 Color 类型的参数,我们也需要导入它。所以让我们把它添加到我们的头文件中:

c
#define FFI_LIB "libraylib.dylib"

typedef struct Color {
  unsigned char r;
  unsigned char g;
  unsigned char b;
  unsigned char a;
} Color;

void InitWindow(int width, int height, const char *title);
// ...

我们的 raylib.h 文件现在可以被 PHP 使用了。

将此头文件加载到 PHP 中

由于我们有一个头文件,我们可以使用 FFI::load() 函数像这样导入它:

php
<?php

$ffi = FFI::load(
  __DIR__ . '/raylib.h'
);

使用这个 $ffi 对象,我们现在可以模仿以前的 C 代码。让我们构建 Color 类型的 whitered 变量:

php
<?php

$ffi = FFI::load(__DIR__ . '/raylib.h');

$white = $ffi->new('Color');
$white->r = 255;
$white->g = 255;
$white->b = 255;
$white->a = 255;

$red = $ffi->new('Color');
$red->r = 255;
$red->a = 255;

默认情况下,结构体的所有字段都将使用零值初始化。在 unsigned char(范围从 0 到 255)的情况下,零值是整数 0。

现在我们可以轻松地构建我们的窗口并在屏幕上绘制:

php
<?php

$ffi = FFI::load(__DIR__ . '/raylib.h');

// ...

$ffi->InitWindow(
  800,
  600,
  "Hello raylib from PHP"
);

while (
  !$ffi->WindowShouldClose()
) {
  $ffi->ClearBackground(
    $white
  );

  $ffi->BeginDrawing();
    $ffi->DrawText(
      "Hello raylib!",
      400,
      300,
      20,
      $red
    );
  $ffi->EndDrawing();
}

$ffi->CloseWindow();

我们有了使用 PHP 的 raylib 窗口

如你所见,在 raylib.h 中定义的所有 C 函数都可以通过引用我们的 $ffi 对象在 PHP 中使用。C 变量然后映射到 PHP 变量,反之亦然。

我们最终的 PHP 文件及其结果如下所示:

php
<?php

$ffi = FFI::load(__DIR__ . '/raylib.h');

$white = $ffi->new('Color');
$white->r = 255;
$white->g = 255;
$white->b = 255;
$white->a = 255;

$red = $ffi->new('Color');
$red->r = 255;
$red->a = 255;

$ffi->InitWindow(800, 600, "Hello raylib from PHP");
while (!$ffi->WindowShouldClose()) {
  $ffi->ClearBackground($white);

  $ffi->BeginDrawing();
    $ffi->DrawText("Hello raylib!", 400, 300, 20, $red);
  $ffi->EndDrawing();
}

$ffi->CloseWindow();

一个尺寸为 800x600 的原生窗口,标题为"Hello raylib from PHP",显示红色文本"Hello raylib!"

FFI 的常见问题及如何解决

在实践中为 PHP 的 Raylib 提供绑定时遇到了一些问题。了解它们以及如何克服这些问题可能对你也有帮助!

最重要的建议是:不要将应用程序代码与 FFI 代码混合,请将绑定提取到独立的库中,并使用 composer 引入它。这不会解决大部分问题,但肯定会隔离它们并使测试更容易。

FFI 可能难以测试

特别是在 Raylib 的情况下,我们无法测试太多。主要是因为它操作原生窗口,而 PHP 没有简单的方法来执行这种断言。

所以请记住,如果你正在编写真正超出 PHP 常规范围的东西,你将需要其他测试工具。还要确保这些工具可以在所有可能的平台上运行。

例如,可以通过使用 xorg 搜索窗口标题来捕获窗口 PID,我知道 Windows API 某处也为你提供了这个功能。如果你想测试,你可能不得不放弃保持你的项目只使用 PHP。

同样值得记住的是,测试并不一定在应用程序的任何地方都增加价值。测试可以作为学习工具,提供一个安全的环境来逐步学习新概念,而不必同时关心不同的依赖关系。不幸的是,大多数 PHP 测试框架在学习 Raylib 时帮助不大。在这种情况下,解决方案是创建不同的 PHP 文件,每个文件做一件事,就像测试用例一样。

难以执行静态分析

目前没有找到一个很好的方法来克服这个问题。像 psalm 这样的静态分析工具对 FFI 代码简直崩溃了!

回到 $white$red 代码片段,让我们看看为什么:

php
$white = $ffi->new('Color');
$white->r = 255;
$white->g = 255;
$white->b = 255;
$white->a = 255;

如果你检查 FFI::new() 签名,你会了解到它返回 FFI\CData 或 null。这个 CData 返回类型是一个对象,应该包含所使用的结构体的所有字段。

据我所知,psalm 无法注释变量 $white 包含四个整数字段 $r$g$b$a。而且 psalm 甚至不知道它们存在,因为,嗯,它们是在别处用 C 编写的!

所以理想情况下,你应该将 FFI 逻辑抽象到某种 Facade 或 Adapter 类中,你将承诺尽可能地用测试覆盖它,并让 psalm 在执行静态分析时忽略这个特定类。

然后,这个 Facade/Adapter 会将 PHP 值(原始类型或对象)正确映射到 CData,并为你处理 C 函数调用。

你或多或少会构建一个 PHP 库,如果你考虑一下,这是理想的!这样你可以防止你的生产代码被 FFI 特定逻辑污染,并且对于应用程序端来说,事情自然变得可测试。

保持库更新

使用 FFI 而不是 PHP 扩展的一大好处是,不必为每个新的 PHP 版本升级 C 代码。但仍然需要管理 C 库版本。

建议学习原始库的版本控制系统,并相应地标记 php 绑定,除了补丁版本。因此,主要版本和次要版本将始终与原始 C 库匹配,而修复错误时,仍然可以自由地提升补丁版本。

这自然会促使 100% 尊重原始 C 库接口。但可以自由地在 C 库和 PHP 代码中拉取和分发安全修复和错误修复。

多平台问题

PHP 是多平台的。它的用户期望所有库也是多平台的。在处理 FFI 代码时,保持这个前提可能很棘手。

回到 raylib 示例,导入该共享文件已经迫使我们按文件名选择:raylib.so(GNU Linux)、libraylib.dylib(MacOS)或 raylib.dll(Windows)。导入错误的文件,你的库就根本无法工作!

你可以编写不同的头文件,特定于平台。这会造成大量重复,但有点帮助。

另一个选择是使用 FFI::cdef() 来加载你的函数签名。它与 FFI::load() 非常相似,但需要一个原始字符串而不是文件路径。在这种情况下,你可以在运行时构建共享对象文件路径。

你可以通过调用 php_uname() 函数来检测运行 php 代码的操作系统。避免使用 PHP_OS 常量:它会显示编译 PHP 二进制文件的计算机的操作系统信息,在某些情况下可能与实际运行代码的操作系统不同。

最后但同样重要的是,请考虑某些库不是多平台的。将它们移植到 PHP 可能会让许多最终用户感到沮丧,如果你决定无论如何都要移植这样的库,请考虑为不支持的操作系统抛出异常:这将立即告诉用户问题是什么。

总结

FFI 是一项令人兴奋的技术,希望这篇文章能帮助你启动 FFI 设置!

INFO

记住,FFI 是实验性的!可能随时会遇到意外的错误!

随着对底层代码的熟悉,FFI 提供了一个很好的机会,可以用 PHP 编程不同的用例(如游戏开发或音频处理)。

本作品采用《CC 协议》,转载必须注明作者和本文链接