开发 PHP 扩展新途径 通过 FrankenPHP 用 Go 语言编写 PHP 扩展

PHPVerse 2025 大会上(JetBrains 为纪念 PHP 语言 30 周年而组织的会议),FrankenPHP 开发者 Kévin Dunglas 做了一个开创性的宣布:通过 FrankenPHP,可以使用 Go 语言创建 PHP 扩展。虽然这个功能从项目诞生之初就存在,但今天要深入介绍这个小小的革命,让它变得更加容易上手。

早期探索

扩展是直接附加到 PHP 解释器的代码段,让你能够实现几乎任何想要的功能。之所以如此强大,主要是因为扩展通常用 C 语言编写,能够提供对机器的高级和低级访问能力。最著名的扩展包括 Parallel(在 PHP 中启用并行代码执行)、GD(图像处理)以及 PHP Redis(与 Redis 缓存服务器通信,还有其替代品如 Snapchat KeyDB)。

最近,也有很多使用 Rust 和 C++ 编写的扩展(虽然 C++ 已经存在很长时间,但不太流行)。如果你想了解更多,Packagist 列出了可以用 PIE 安装的扩展以及它们使用的编程语言。然而,所有这些语言都是非常底层的,学习门槛较高。对于 PHP 扩展开发来说更是如此,通常需要对 PHP 内部机制有相当深入的了解。既然 FrankenPHP 是用更高级的语言 Go 编写的,为什么不借此机会尝试用 Go 来编写 PHP 扩展呢?实际上,FrankenPHP 集成了 Go 运行时,所以完全可以使用这种语言,除了之前没人这么做过之外,没有任何特殊的技术限制。

Kévin Dunglas 团队开始探索这种可能性,并开发了一个概念验证,在其中向 PHP 添加一个新的原生函数来执行 Go 代码。经过几天的研究,结果出炉了:成功从 PHP 启动了一个 goroutine(与主代码并行执行的代码段)!这一切的关键在于 CGO 库,它让 C 代码能够调用 Go 代码,也能够从 Go 调用 C 代码。有了这个基础,就可以开发各种功能了。

FrankenPHP 作为工具库

从这些实验中发现,尽管有了这些可能性,但是仍然需要花费大量时间编写 C 代码:

  • 扩展在 PHP 中的注册必须用 C 完成,因为这需要操作 Zend Engine 的一些内部指针。好消息是这段代码在所有扩展中基本相同;
  • C 和 Go 之间的类型转换。许多变量类型在 C 和 Go 之间兼容,可以直接使用,比如整数、浮点数和布尔值。然而,更复杂的结构如字符串、对象和数组需要转换,不能直接使用。例如,在 PHP 解释器中,字符串由包含数据和字符串长度的结构表示。因此需要深入了解 PHP 的内部机制,而这些机制有时文档不全。LLM 在解释 PHP 引擎的某些复杂机制方面已经表现出色,但这仍然需要底层知识,并且存在内存损坏的风险。
  • 必须编写 C 代码来调用 Go 代码。

FrankenPHP 在这里发挥了关键作用:作为工具库帮助开发者避免这三个问题。当然,你可以手工完成,相关文档解释了每个步骤,让你清楚了解从头开发扩展的各个阶段。但是,你也可以跳过这些步骤。

对于第一个问题,用 Go 编写的扩展将是 Go 模块,具体来说是 Caddy 模块。FrankenPHP 使用 Caddy 作为集成的 Web 服务器,这使得能够通过一行代码集成自定义模块。特别是,自定义模块可以是带有 init() 函数的 Go 文件,该函数在模块被 Caddy 启动时执行。这是注册扩展的理想位置。得益于项目的最新贡献,FrankenPHP 提供了一个 RegisterExtension() 方法,该方法抽象了注册扩展所需的所有 C 代码。不深入所有细节(如果你好奇,文档中有详细说明),在 Go 中注册扩展看起来是这样的:

go
package ext

/*
#include "ext.h"
*/
import "C"
import "github.com/dunglas/frankenphp"

func init() {
    frankenphp.RegisterExtension(unsafe.Pointer(&C.ext_module_entry))
}

这里没有写一行 C 代码,扩展就成功注册了!

对于类型转换问题,FrankenPHP 再次提供了公开的功能来抽象内部类型机制。正如之前看到的,一些标量类型不需要转换。但其他类型需要,比如字符串。这就是为什么 FrankenPHP 提供了诸如 frankenphp.GoString()(从 C 字符串获取 Go 字符串)和 frankenphp.PHPString()(将 Go 字符串转换为 PHP 可用的字符串)等方法,这些方法自动处理转换。随着时间的推移,会为其他数据类型添加更多工具方法。数组已经得到支持,可调用对象正在开发中。

再次强调,这篇文章只是触及了这些新功能的表面,完整的文档已经在线,其中有详细的解释。

如你所见,FrankenPHP 在促进 PHP 扩展创建方面发挥着关键作用,提供了大大简化扩展编码的工具。然而,仍有最后一个问题需要解决:编写调用 Go 代码所需的 C 代码。接下来看看如何解决这个问题。

扩展生成器

意识到需要编写数十行甚至数百行 C 代码来实现 C 和 Go 之间的"桥接",开发团队思考了下一步。是否可能创建一个 PHP 扩展生成器,它接收一个简单的 Go 文件作为输入(可能带有一些特殊语法),然后完成其余工作?答案是肯定的!经过几周的开发,一个新工具已被集成到 FrankenPHP 中作为子命令:扩展生成器。主要目标很明确:开发者必须能够编译和集成 PHP 扩展,而无需编写一行 C 代码。

生成器定义了特定的 Go 指令。把 Go 指令想象成 PHP 中的注解或属性。这是一个与扩展生成器兼容的 Go 文件示例:

go
package main

// export_php:function multiply(int $a, int $b): int
func multiply(a int64, b int64) int64 {
    return a * b
}

// export_php:function is_even(int $a): bool
func is_even(a int64) bool {
    return a%2 == 0
}

// export_php:function float_div(float $a, float $b): float
func float_div(a float64, b float64) float64 {
    return a / b
}

通过 // export_php:function 指令后跟函数签名,生成器将负责定义所有中间 C 代码。剩下的就是将此文件传递给生成器:

bash
alex@alex-macos frankenphp % frankenphp extension-init ext-dir/ext.go
2025/06/20 09:49:09.273 INFO    PHP 扩展 "ext" "ext-dir/build" 中初始化成功

alex@alex-macos frankenphp % ls -la ext-dir/build
total 48
drwxr-xr-x@ 8 alex  staff   256 Jun 20 11:49 .
drwxr-xr-x@ 8 alex  staff   256 Jun 20 11:49 ..
-rw-r--r--@ 1 alex  staff   418 Jun 20 11:49 README.md
-rw-r--r--@ 1 alex  staff  1673 Jun 20 11:49 ext.c
-rw-r--r--@ 1 alex  staff   396 Jun 20 11:49 ext.go
-rw-r--r--@ 1 alex  staff   226 Jun 20 11:49 ext.h
-rw-r--r--@ 1 alex  staff   168 Jun 20 11:49 ext.stub.php
-rw-r--r--@ 1 alex  staff   865 Jun 20 11:49 ext_arginfo.h

FrankenPHP 创建了 PHP 扩展所需的所有文件:

  • 包含导出元素说明的 README.md 文件;
  • 包含想要避免编写的中间代码的 C 文件;
  • Go 文件,与原始文件非常相似,但稍作修改以与 C 代码正确协作;
  • 包含某些函数定义的头文件,使 C 和 Go 代码能够良好协作;
  • 带有 PHP 函数签名定义的存根文件;
  • 包含向 PHP 的 Zend Engine 注册新函数所需的所有指令的 arginfo 文件。

正如刚才看到的,Caddy 允许你添加额外的自定义模块来扩展其功能:只需给 Caddy 构建目录,它就会处理其余工作。扩展完全可用,无需手工编写一行 C 代码!

这个生成器的强大之处在于它对其他重要功能的支持。这是一个更完整的兼容文件示例:

go
package main

// export_php:namespace Go\MyExtension

import (
 "C"
 "github.com/dunglas/frankenphp"
 "strings"
 "unsafe"
)

// export_php:const
const MY_GLOBAL_CONSTANT = "Hello, World!"

// export_php:classconst MySuperClass
const STR_REVERSE = iota

// export_php:classconst MySuperClass
const STR_NORMAL = iota

// export_php:class MySuperClass
type MyClass struct {
 Name     string
}

// export_php:method MySuperClass::setName(string $name): void
func (mc *MyClass) SetName(v *C.zend_string) {
 // 将 C 字符串转换为 Go 字符串
 mc.Name = frankenphp.GoString(unsafe.Pointer(v))
}

// export_php:method MySuperClass::getName(): string
func (mc *MyClass) GetName() unsafe.Pointer {
 // 将 Go 字符串转换为 PHP 字符串
 return frankenphp.PHPString(mc.Name, false)
}

// export_php:method MySuperClass::repeatName(int $count, ?int $mode): void
func (mc *MyClass) RepeatName(count int64, mode *int64) {
 str := mc.Name

 // 重复字符串指定次数
 result := strings.Repeat(str, int(count))
 if mode != nil && *mode == STR_REVERSE {
  // 反转字符串
  runes := []rune(result)
  for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
   runes[i], runes[j] = runes[j], runes[i]
  }
  result = string(runes)
 }

 if mode == nil || *mode == STR_NORMAL {
  // 正常模式,无需操作,只是为了使用常量 :)
 }

 mc.Name = result
}

在这里可以看到全局常量、类、类常量和类方法的声明。另外需要注意字符串类型转换方法的使用。需要说明的是,生成器几乎无法覆盖 PHP 扩展提供的所有可能性。然而,对于没有过度高级功能的扩展项目,它可以提供真正的帮助。推荐你查看文档,其中描述了生成器支持的所有功能。

为什么选择 Go 扩展?

可能有人会问:为 PHP 编写 Go 扩展有什么意义?经过三十年的发展,看到 PHP 继续与更新的技术如此良好地集成确实令人振奋。与 Go 的接口需要 FrankenPHP。自从 PHP 基金会最近宣布正式将 FrankenPHP 纳入其管辖以来,它的使用前景将继续增长,长期发展得到了保障。

Go 扩展背后的理念可以用几个关键词概括:goroutines 和包装器。首先,goroutines 是该语言闻名的高性能并发模型。在 PHP 代码中对可能繁重和/或耗时的操作使用 goroutines 的能力带来了广阔的可能性。其次,现有库的包装器同样带来了许多新的可能性。许多 Go 库以其质量而闻名,但在 PHP 中不可用。一个例子是 etcd 缓存系统,开发者 Kévin 为此创建了一个完整的 Go 扩展供 PHP 使用。你可以在扩展仓库中找到这个示例。

提供扩展生成器是朝着 PHP 扩展创建民主化迈出的重要一步,降低了开发门槛。任何人都可以快速尝试,探索生成的代码以了解其工作原理,甚至开发出 PHP 生态系统中的下一个优秀扩展。

如果你想了解更多关于如何编写自己的扩展,文档将解释如何使用生成器,以及如何在不使用生成器的情况下编写自己的 Go 扩展。期待看到这两种语言之间独特的协作关系将如何被运用。

CatchAdmin
后端开发工程师,前端入门选手,略知相关服务器知识,偏爱❤️ Laravel & Vue
本作品采用《CC 协议》,转载必须注明作者和本文链接