用 Go 编写 FrankenPHP 原生 PHP 扩展:从生成器到手动实现的完整实战指南
【免费下载链接】frankenphp🧟 The modern PHP app server项目地址: https://gitcode.com/GitHub_Trending/fr/frankenphp
本文基于 FrankenPHP 官方文档(docs/ru/extensions.md)撰写,系统讲解如何在 FrankenPHP 中用 Go 语言编写 PHP 扩展:你可以为 PHP 提供高性能的原生函数,直接复用任意已有的或新建的 Go 库,甚至把 Go 引以为傲的goroutine 并发模型带进 PHP 代码。读完本文,你将掌握两条完整的技术路线:使用内置扩展生成器(
frankenphp extension-init)快速产出全部样板代码,以及完全手写 Go/C 桥接层以获得最大控制力;同时深入理解 FrankenPHP 提供的类型转换 API 与 Zend Engine 的交互原理。
为什么可以用 Go 写 PHP 扩展
传统上,编写 PHP 扩展意味着编写 C 代码:定义zend_module_entry、实现PHP_FUNCTION、手动管理zval与zend_string的内存生命周期。PHP 扩展的价值在于用底层语言扩展 PHP 的功能边界——比如添加原生函数、针对特定运算做深度优化——但这些能力长期与 C 绑定。
FrankenPHP 基于 Caddy 模块体系构建,因此它提供了一个独特的能力:直接用 Go 编写 PHP 扩展并快速集成进 FrankenPHP。由于 FrankenPHP 本身就是一个 Go 进程,扩展可以共享同一套 Go 运行时,PHP 代码得以:
- 调用 Go 生态中任何现成的或新建的库;
- 从 PHP 侧触发 goroutine,享受 Go 的并发模型(例如在后台异步执行任务、写结构化日志等);
- 避免手写大量 C 样板代码,同时保留原生性能。
在仓库源码中可以看到,FrankenPHP 将「扩展注册」做成了公开 API:frankenphp.RegisterExtension()(见 ext.go)会把扩展的zend_module_entry收集起来,在 PHP 引擎初始化时统一注册(registerExtensions(),见 ext.go)。这正是「Go 写扩展」得以成立的地基。
两种实现路线
FrankenPHP 提供两条创建 Go 版 PHP 扩展的路径:
- 扩展生成器(Extension Generator)——推荐方式。它为你生成绝大多数样板代码,让你专注于 Go 业务逻辑本身;
- 手动实现(Manual Implementation)——对扩展结构拥有完全控制权,适合高级场景,也能帮助你理解底层原理。
本文先从生成器路线讲起(最容易上手),再展示手动实现,供需要完全掌控的开发者参考。
路线一:使用扩展生成器
FrankenPHP 内置了一个工具,让你只用 Go 就能创建 PHP 扩展:不需要写 C 代码,也不需要直接操作 CGO。同时,FrankenPHP 提供了一套公开的类型 API(types API),帮你免去在 PHP/C 与 Go 之间手动进行类型转换(type juggling)的烦恼。
[!TIP] 想了解不用生成器、从零手写 Go 版 PHP 扩展的原理,可以直接跳到文末的「手动实现」一节。
需要说明的是,这个生成器并非完整的扩展生成器:它旨在辅助编写简单的 Go 扩展,并不覆盖 PHP 扩展最前沿的特性。如果你需要编写更复杂、更追求极致优化的扩展,可能仍需要编写部分 C 代码或直接使用 CGO。
前置条件
与手动实现一节的要求一致,你需要:
- 获取 PHP 源码(用于提供
gen_stub.php脚本); - 创建一个新的 Go 模块。
创建新模块并获取 PHP 源码
首先初始化一个 Go 模块:
go mod init example.com/example然后下载 PHP 源码并解压到你选择的任意目录,但不要解压进 Go 模块内部:
tar xf php-*编写扩展(生成器路线)
在模块中新建一个文件stringext.go。我们的第一个函数接收三个参数:一个字符串、一个重复次数、一个布尔值(是否翻转字符串),最后返回处理结果:
// stringext.go package example // #include <Zend/zend_types.h> import "C" import ( "strings" "unsafe" "github.com/dunglas/frankenphp" ) //export_php:function repeat_this(string $str, int $count, bool $reverse): string func repeat_this(s *C.zend_string, count int64, reverse bool) unsafe.Pointer { str := frankenphp.GoString(unsafe.Pointer(s)) result := strings.Repeat(str, int(count)) if 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) } return frankenphp.PHPString(result, false) }这个例子中有两个关键点:
- 指令注释
//export_php:function定义了该函数在 PHP 中的签名,生成器据此生成带正确参数与返回类型的 PHP 函数定义; - 函数必须返回
unsafe.Pointer。FrankenPHP 提供了类型 API 帮你完成 C 与 Go 之间的类型转换。
第一点无需多言,第二点可能稍显费解——本文后面的「类型转换(Type Juggling)」小节会深入讲解。
生成扩展
生成器命令如下:
GEN_STUB_SCRIPT=php-src/build/gen_stub.php frankenphp extension-init my_extension.go[!NOTE] 别忘了把环境变量
GEN_STUB_SCRIPT设置为前面下载的 PHP 源码中gen_stub.php的路径。这个脚本与手动实现一节中使用的gen_stub.php是同一个。
extension-init是 FrankenPHP 通过 Caddy 命令框架注册的自定义命令(注册代码见 caddy/extinit.go),它内部调用internal/extgen包(见 internal/extgen)完成整个生成流程。它还支持--verbose/-v标志开启调试日志。
一切顺利的话,你的项目目录中会多出以下文件:
| 文件 | 说明 |
|---|---|
my_extension.go | 你的原始源文件(保持不变) |
my_extension_generated.go | 生成的 CGO 包装文件,负责调用你的函数 |
my_extension.stub.php | PHP 存根文件,供 IDE 自动补全使用 |
my_extension_arginfo.h | PHP 参数信息(arginfo) |
my_extension.h | C 头文件 |
my_extension.c | C 实现文件 |
README.md | 文档 |
[!IMPORTANT]你的源文件(
my_extension.go)永远不会被修改。生成器会创建独立的_generated.go文件,内含调用你原始函数的 CGO 包装。这意味着你可以放心地对源文件做版本控制,不必担心生成的代码污染它。
把生成的扩展集成进 FrankenPHP
扩展已经准备好被编译并集成进 FrankenPHP。编译方式参见 FrankenPHP 编译文档。使用--with标志把模块加入构建:
CGO_ENABLED=1 \ XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \ CGO_CFLAGS=$(php-config --includes) \ CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \ xcaddy build \ --output frankenphp \ --with github.com/my-account/my-module/build注意这里指向的是生成阶段创建的/build子目录。这不是强制要求:你也可以把生成的文件复制到模块目录中,直接指向模块目录本身。
测试生成的扩展
创建一个 PHP 文件来测试生成的函数与类。例如新建index.php:
<?php // 使用全局常量 var_dump(repeat_this('Hello World', 5, STR_REVERSE)); // 使用类常量 $processor = new StringProcessor(); echo $processor->process('Hello World', StringProcessor::MODE_LOWERCASE); // "hello world" echo $processor->process('Hello World', StringProcessor::MODE_UPPERCASE); // "HELLO WORLD"按上一节的方式把扩展集成进 FrankenPHP 后,运行./frankenphp php-server即可看到扩展生效。
类型转换(Type Juggling)
部分变量类型在 C/PHP 与 Go 之间的内存表示完全一致,可以直通;另一些类型则需要额外的转换逻辑。这可能是编写扩展时最困难的部分,因为它要求你理解 Zend Engine 的内部机制,以及变量在 PHP 中到底如何存储。下表总结了你需要知道的一切:
| PHP 类型 | Go 类型 | 直接转换 | C→Go 辅助函数 | Go→C 辅助函数 | 类方法支持 |
|---|---|---|---|---|---|
int | int64 | ✅ | - | - | ✅ |
?int | *int64 | ✅ | - | - | ✅ |
float | float64 | ✅ | - | - | ✅ |
?float | *float64 | ✅ | - | - | ✅ |
bool | bool | ✅ | - | - | ✅ |
?bool | *bool | ✅ | - | - | ✅ |
string/?string | *C.zend_string | ❌ | frankenphp.GoString() | frankenphp.PHPString() | ✅ |
array | frankenphp.AssociativeArray | ❌ | frankenphp.GoAssociativeArray() | frankenphp.PHPAssociativeArray() | ✅ |
array | map[string]any | ❌ | frankenphp.GoMap() | frankenphp.PHPMap() | ✅ |
array | []any | ❌ | frankenphp.GoPackedArray() | frankenphp.PHPPackedArray() | ✅ |
mixed | any | ❌ | GoValue() | PHPValue() | ❌ |
callable | *C.zval | ❌ | - | frankenphp.CallPHPCallable() | ❌ |
object | struct | ❌ | 尚未实现 | 尚未实现 | ❌ |
[!NOTE] 该表尚不完整,会随 FrankenPHP 类型 API 的完善而持续补充。 具体到类方法:目前支持基本类型与数组。对象暂时不能用作方法参数或返回类型。
对照前面repeat_this()的代码可以看到:第一个参数和返回值用了辅助函数转换,而第二、三个参数无需转换——因为底层类型在 C 与 Go 中的内存表示一致。
从源码看,这些辅助函数都定义在 types.go 中,并明确标注为EXPERIMENTAL(实验性 API):
GoString()(types.go):把zend_string复制为 Go 字符串,底层通过C.GoStringN读取zend_string的val与len字段;PHPString()(types.go):把 Go 字符串转换为zend_string,其第二个布尔参数决定字符串是非持久(请求结束由 ZMM 自动释放)还是持久(由你负责释放内存);GoValue()/PHPValue()(types.go):mixed类型的通用转换,目前支持null、bool、long、double、string、array等 zval 类型,遇到不支持的类型会返回错误(Go 侧)或 panic(PHP 侧)。
数组处理
FrankenPHP 通过frankenphp.AssociativeArray或直接转换为 map / slice 来原生支持 PHP 数组。
AssociativeArray本质是一张哈希表,由Map: map[string]any字段和可选的Order: []string字段组成(与 PHP 的「关联数组」不同,Go 的 map 是无序的,所以需要单独的Order字段来保留插入顺序,见 types.go)。
如果不需要顺序或关联性,也可以直接转换为切片[]any或无序 mapmap[string]any。
在 Go 中创建与操作数组:
// 在 PHP 数组与 Go map/slice 之间转换 package example // #include <Zend/zend_types.h> import "C" import ( "unsafe" "github.com/dunglas/frankenphp" ) // export_php:function process_data_ordered(array $input): array func process_data_ordered_map(arr *C.zend_array) unsafe.Pointer { // 把 PHP 关联数组转为 Go,同时保留顺序 associativeArray, err := frankenphp.GoAssociativeArrayany) if err != nil { // 处理错误 } // 按顺序遍历条目 for _, key := range associativeArray.Order { value, _ = associativeArray.Map[key] // 对 key 和 value 做点什么 } // 返回有序数组 // 如果 'Order' 非空,只有 'Order' 中列出的键值对会被保留 return frankenphp.PHPAssociativeArraystring } // export_php:function process_data_unordered(array $input): array func process_data_unordered_map(arr *C.zend_array) unsafe.Pointer { // 把 PHP 关联数组转为 Go map,不保留顺序 // 忽略顺序性能更好 goMap, err := frankenphp.GoMapany) if err != nil { // 处理错误 } // 按任意顺序遍历条目 for key, value := range goMap { // 对 key 和 value 做点什么 } // 返回无序数组 return frankenphp.PHPMap(map[string]string { "key1": "value1", "key2": "value2", }) } // export_php:function process_data_packed(array $input): array func process_data_packed(arr *C.zend_array) unsafe.Pointer { // 把 PHP 打包数组转为 Go goSlice, err := frankenphp.GoPackedArray(unsafe.Pointer(arr)) if err != nil { // 处理错误 } // 按顺序遍历切片 for index, value := range goSlice { // 对 index 和 value 做点什么 } // 返回打包数组 return frankenphp.PHPPackedArray([]string{"value1", "value2", "value3"}) }数组转换的核心特性:
- 有序键值对——可选保留关联数组的顺序;
- 针对多种场景优化——可以舍弃顺序换取更好性能,也可以直接转成切片;
- 自动列表检测——转换为 PHP 时自动判断数组应该是打包列表(packed list)还是哈希表(hashmap);
- 嵌套数组——数组可以嵌套,所有受支持的类型(
int64、float64、string、bool、nil、AssociativeArray、map[string]any、[]any)都会被自动递归转换; - 对象暂不支持——目前数组值只允许标量类型和数组。传入对象会在 PHP 数组中变成
null。
可用方法:packed 与 associative
frankenphp.PHPAssociativeArray(arr frankenphp.AssociativeArray) unsafe.Pointer——转换为带键值对的有序 PHP 数组frankenphp.PHPMap(arr map[string]any) unsafe.Pointer——把 map 转换为带键值对的无序 PHP 数组frankenphp.PHPPackedArray(slice []any) unsafe.Pointer——把切片转换为仅含索引值的 PHP 打包数组frankenphp.GoAssociativeArray(arr unsafe.Pointer, ordered bool) frankenphp.AssociativeArray——把 PHP 数组转换为有序 GoAssociativeArray(带顺序的 map)frankenphp.GoMap(arr unsafe.Pointer) map[string]any——把 PHP 数组转换为无序 Go mapfrankenphp.GoPackedArray(arr unsafe.Pointer) []any——把 PHP 数组转换为 Go 切片frankenphp.IsPacked(zval *C.zend_array) bool——检查 PHP 数组是打包的(仅索引)还是关联的(键值对)
IsPacked的实现直接读取zend_array的哈希表标志位HASH_FLAG_PACKED(见 types.go),与 Zend Engine 内部的判断方式保持一致。
与 callable 协作
FrankenPHP 通过frankenphp.CallPHPCallable辅助函数提供对 PHP 可调用对象(callable)的支持,让你能从 Go 代码调用 PHP 函数或方法。
为了演示,我们实现一个自己的array_map()函数:接收一个 callable 和一个数组,把 callable 应用到数组每个元素上,返回结果组成的新数组:
// 从 Go 定义的扩展函数中调用 PHP callable // export_php:function my_array_map(array $data, callable $callback): array func my_array_map(arr *C.zend_array, callback *C.zval) unsafe.Pointer { goSlice, err := frankenphp.GoPackedArrayany) if err != nil { panic(err) } result := make([]any, len(goSlice)) for index, value := range goSlice { result[index] = frankenphp.CallPHPCallable(unsafe.Pointer(callback), []interface{}{value}) } return frankenphp.PHPPackedArray(result) }注意frankenphp.CallPHPCallable()的用法:它接收一个指向 callable 的指针和一个参数数组,返回 callable 执行的结果。底层实现(types.go)会先通过zend_is_callable校验可调用性,再调用call_user_function并释放临时分配的 zval 内存。在 PHP 侧,你可以使用熟悉的 callable 语法:
<?php $result = my_array_map([1, 2, 3], function($x) { return $x * 2; }); // $result 为 [2, 4, 6] $result = my_array_map(['hello', 'world'], 'strtoupper'); // $result 为 ['HELLO', 'WORLD']声明原生 PHP 类
生成器支持把 Go 结构体声明为不透明类(opaque classes),用来创建 PHP 对象。使用//export_php:class指令注释定义 PHP 类:
// 声明一个由 Go struct 支撑的 PHP 类 package example //export_php:class User type UserStruct struct { Name string Age int }什么是不透明类?
不透明类是指内部结构(属性)对 PHP 代码完全隐藏的类,这意味着:
- 无直接属性访问:你无法从 PHP 直接读写属性(
$user->name不会生效); - 仅通过方法交互——所有交互必须经由你定义的方法;
- 更好的封装——内部数据结构完全由 Go 代码控制;
- 类型安全——PHP 代码无法用错误的类型破坏内部状态;
- 更干净的 API——倒逼你设计出正确的公共接口。
这种设计提供更好的封装,防止 PHP 代码意外破坏 Go 对象的内部状态。对象的一切交互都必须经过你显式定义的方法。
给类添加方法
由于属性不能直接访问,你必须定义方法来与不透明类交互。使用//export_php:method指令定义行为:
// 在 Go 支撑的 PHP 类上定义方法 package example // #include <Zend/zend_types.h> import "C" import ( "unsafe" "github.com/dunglas/frankenphp" ) //export_php:class User type UserStruct struct { Name string Age int } //export_php:method User::getName(): string func (us *UserStruct) GetUserName() unsafe.Pointer { return frankenphp.PHPString(us.Name, false) } //export_php:method User::setAge(int $age): void func (us *UserStruct) SetUserAge(age int64) { us.Age = int(age) } //export_php:method User::getAge(): int func (us *UserStruct) GetUserAge() int64 { return int64(us.Age) } //export_php:method User::setNamePrefix(string $prefix = "User"): void func (us *UserStruct) SetNamePrefix(prefix *C.zend_string) { us.Name = frankenphp.GoString(unsafe.Pointer(prefix)) + ": " + us.Name }可以看到,方法签名中同样支持默认参数(如$prefix = "User")。
可空参数(Nullable Parameters)
生成器支持在 PHP 签名中使用?前缀声明可空参数。当参数可空时,它在你的 Go 函数中会变成指针,从而让你能检查 PHP 侧是否传入了null:
// 在 Go 方法中处理可空的 PHP 参数 package example // #include <Zend/zend_types.h> import "C" import ( "unsafe" "github.com/dunglas/frankenphp" ) //export_php:method User::updateInfo(?string $name, ?int $age, ?bool $active): void func (us *UserStruct) UpdateInfo(name *C.zend_string, age *int64, active *bool) { // 检查 name 是否提供(非 null) if name != nil { us.Name = frankenphp.GoString(unsafe.Pointer(name)) } // 检查 age 是否提供(非 null) if age != nil { us.Age = int(*age) } // 检查 active 是否提供(非 null) if active != nil { us.Active = *active } }可空参数的关键要点:
- 可空基本类型(
?int、?float、?bool)在 Go 中变成指针(*int64、*float64、*bool); - 可空字符串(
?string)仍是*C.zend_string,但可以为nil; - 解引用指针前务必检查
nil; - PHP 的
null即 Go 的nil——PHP 传入null时,你的 Go 函数收到的是nil指针。
[!WARNING] 目前类方法存在以下限制:对象尚不能用作参数类型或返回类型;数组在参数与返回类型上完全支持;支持的类型为
string、int、float、bool、array,返回类型还支持void;可空参数类型对所有标量类型(?string、?int、?float、?bool)完全支持。
生成扩展后,你就能在 PHP 中使用该类及其方法。注意:不能直接访问属性:
<?php $user = new User(); // ✅ 这样可行 —— 使用方法 $user->setAge(25); echo $user->getName(); // 输出:(空,默认值) echo $user->getAge(); // 输出:25 $user->setNamePrefix("Employee"); // ✅ 这样也可行 —— 可空参数 $user->updateInfo("John", 30, true); // 所有参数都提供 $user->updateInfo("Jane", null, false); // age 为 null $user->updateInfo(null, 25, null); // name 与 active 为 null // ❌ 这样不可行 —— 直接访问属性 // echo $user->name; // 错误:无法访问私有属性 // $user->age = 30; // 错误:无法访问私有属性这样的设计确保你的 Go 代码对对象状态的访问与修改拥有完全控制权,换来的是更好的封装与类型安全。
声明常量
生成器支持用两条指令把 Go 常量导出到 PHP://export_php:const用于全局常量,//export_php:classconst用于类常量。这让你能在 Go 与 PHP 之间共享配置值、状态码等常量。
全局常量
使用//export_php:const指令创建全局 PHP 常量:
// 从 Go 导出全局 PHP 常量 package example //export_php:const const MAX_CONNECTIONS = 100 //export_php:const const API_VERSION = "1.2.3" //export_php:const const ( STATUS_OK = iota STATUS_ERROR )[!NOTE] PHP 常量会沿用 Go 常量的名称,因此建议使用大写字母。
类常量
使用//export_php:classconst ClassName指令创建属于某个特定 PHP 类的常量:
// 从 Go 导出 PHP 类常量 package example //export_php:classconst User const STATUS_ACTIVE = 1 //export_php:classconst User const STATUS_INACTIVE = 0 //export_php:classconst User const ROLE_ADMIN = "admin" //export_php:classconst Order const ( STATE_PENDING = iota STATE_PROCESSING STATE_COMPLETED )[!NOTE] 与全局常量一样,类常量会沿用 Go 常量的名称。
类常量在 PHP 中通过类名作用域访问:
<?php // 全局常量 echo MAX_CONNECTIONS; // 100 echo API_VERSION; // "1.2.3" // 类常量 echo User::STATUS_ACTIVE; // 1 echo User::ROLE_ADMIN; // "admin" echo Order::STATE_PENDING; // 0该指令支持多种值类型:字符串、整数、布尔值、浮点数以及iota常量。使用iota时,生成器自动分配连续值(0、1、2……)。全局常量在 PHP 代码中以全局常量形式可见,类常量则限定在各自类的作用域内(公开可见性)。对于整数,二进制、十六进制、八进制等不同记法均受支持,并会原样写入 PHP 存根文件中。
你可以在 Go 代码中像往常一样使用这些常量。例如,把前面声明的repeat_this()函数最后一个参数改成整数模式:
// 在一个扩展中组合函数、类、方法与常量 package example // #include <Zend/zend_types.h> import "C" import ( "strings" "unsafe" "github.com/dunglas/frankenphp" ) //export_php:const const STR_REVERSE = iota //export_php:const const STR_NORMAL = iota //export_php:classconst StringProcessor const MODE_LOWERCASE = 1 //export_php:classconst StringProcessor const MODE_UPPERCASE = 2 //export_php:function repeat_this(string $str, int $count, int $mode): string func repeat_this(s *C.zend_string, count int64, mode int) unsafe.Pointer { str := frankenphp.GoString(unsafe.Pointer(s)) result := strings.Repeat(str, int(count)) if mode == STR_REVERSE { // 翻转字符串 } if mode == STR_NORMAL { // 空操作,仅用于演示常量 } return frankenphp.PHPString(result, false) } //export_php:class StringProcessor type StringProcessorStruct struct { // 内部字段 } //export_php:method StringProcessor::process(string $input, int $mode): string func (sp *StringProcessorStruct) Process(input *C.zend_string, mode int64) unsafe.Pointer { str := frankenphp.GoString(unsafe.Pointer(input)) switch mode { case MODE_LOWERCASE: str = strings.ToLower(str) case MODE_UPPERCASE: str = strings.ToUpper(str) } return frankenphp.PHPString(str, false) }使用命名空间
生成器支持通过//export_php:namespace指令把扩展的函数、类和常量组织到命名空间下,这有助于避免命名冲突,也让扩展 API 结构更清晰。
声明命名空间
在 Go 文件顶部使用//export_php:namespace指令,把所有导出的符号放到指定命名空间:
// 把导出的符号放入 PHP 命名空间 //export_php:namespace My\Extension package example import ( "unsafe" "github.com/dunglas/frankenphp" ) //export_php:function hello(): string func hello() string { return "Hello from My\\Extension namespace!" } //export_php:class User type UserStruct struct { // 内部字段 } //export_php:method User::getName(): string func (u *UserStruct) GetName() unsafe.Pointer { return frankenphp.PHPString("John Doe", false) } //export_php:const const STATUS_ACTIVE = 1在 PHP 中使用带命名空间的扩展
声明命名空间后,所有函数、类和常量都会放到该命名空间下:
<?php echo My\Extension\hello(); // "Hello from My\Extension namespace!" $user = new My\Extension\User(); echo $user->getName(); // "John Doe" echo My\Extension\STATUS_ACTIVE; // 1重要注意事项
- 每个文件只允许一条命名空间指令。若发现多条,生成器会返回错误;
- 命名空间适用于文件中的所有导出符号:函数、类、方法、常量;
- 命名空间名称遵循 PHP 命名空间约定,使用反斜杠(
\)作为分隔符; - 若未声明命名空间,符号照常导出到全局命名空间。
路线二:手动实现
如果你想深入理解扩展的工作原理,或需要完全控制扩展的每个细节,可以手动编写。这种方案给你完整控制权,但需要更多样板代码。
基础函数
我们编写一个简单的 Go 版 PHP 扩展:它定义一个新的原生函数,PHP 调用它后会触发一个 goroutine,向 Caddy 的日志写入一条消息。该函数不接收参数,也不返回任何东西。
定义 Go 函数
在模块中新建一个文件(例如extension.go),写入以下代码:
// extension.go package example // #include "extension.h" import "C" import ( "log/slog" "unsafe" "github.com/dunglas/frankenphp" ) func init() { frankenphp.RegisterExtension(unsafe.Pointer(&C.ext_module_entry)) } //export go_print_something func go_print_something() { go func() { slog.Info("Hello from a goroutine!") }() }frankenphp.RegisterExtension()简化了扩展注册流程,内部处理了 PHP 注册逻辑(实现见 ext.go)。go_print_something函数使用//export指令,借助 CGO 使其对稍后编写的 C 代码可见。
在这个例子中,新函数触发一个 goroutine,向 Caddy 的日志写入消息。
定义 PHP 函数
为了让 PHP 能调用我们的函数,需要定义对应的 PHP 函数。创建一个存根文件(例如extension.stub.php):
<?php // extension.stub.php /** @generate-class-entries */ function go_print(): void {}该文件定义了go_print()函数的签名。@generate-class-entries指令让 PHP 自动为扩展生成函数条目。
这一步不是手工完成的,而是使用 PHP 源码中提供的脚本(请根据你的 PHP 源码位置调整gen_stub.php的路径):
php ../php-src/build/gen_stub.php extension.stub.php该脚本会生成名为extension_arginfo.h的文件,其中包含 PHP 定义与调用该函数所需的全部信息。
编写 Go 与 C 之间的桥接
现在编写 Go 与 C 之间的桥接。先在模块目录中创建extension.h:
// extension.h #ifndef _EXTENSION_H #define _EXTENSION_H #include <php.h> extern zend_module_entry ext_module_entry; #endif接着创建extension.c,它需要完成以下步骤:
- 引入 PHP 头文件;
- 声明新的原生 PHP 函数
go_print(); - 声明扩展元数据。
首先引入所需头文件:
// extension.c #include <php.h> #include "extension.h" #include "extension_arginfo.h" // 包含 Go 导出的符号 #include "_cgo_export.h"然后定义 PHP 函数为原生语言函数:
PHP_FUNCTION(go_print) { ZEND_PARSE_PARAMETERS_NONE(); go_print_something(); } zend_module_entry ext_module_entry = { STANDARD_MODULE_HEADER, "ext_go", ext_functions, /* 函数 */ NULL, /* MINIT */ NULL, /* MSHUTDOWN */ NULL, /* RINIT */ NULL, /* RSHUTDOWN */ NULL, /* MINFO */ "0.1.1", STANDARD_MODULE_PROPERTIES };这里我们的函数不接收参数、不返回值,只是调用前面用//export指令导出的 Go 函数。
最后,在zend_module_entry结构中定义扩展元数据,如名称、版本和属性。这些信息是 PHP 识别并加载扩展所必需的。注意ext_functions是指向我们已定义 PHP 函数的指针数组,它由gen_stub.php脚本自动生成在extension_arginfo.h中。
扩展注册由我们在 Go 代码中调用的 FrankenPHPRegisterExtension()函数自动处理。
进阶用法
现在把例子升级:创建一个接收字符串参数、返回其大写版本的 PHP 函数。
定义 PHP 函数存根
修改extension.stub.php,加入新的函数签名:
<?php // extension.stub.php /** @generate-class-entries */ /** * 把字符串转换为大写。 * * @param string $string 要转换的字符串。 * @return string 字符串的大写版本。 */ function go_upper(string $string): string {}[!TIP] 不要忽视函数文档!你很可能需要把扩展存根分享给其他开发者,用于说明扩展的用法和可用功能。
用gen_stub.php重新生成存根后,extension_arginfo.h应如下所示:
// extension_arginfo.h (generated) ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_go_upper, 0, 1, IS_STRING, 0) ZEND_ARG_TYPE_INFO(0, string, IS_STRING, 0) ZEND_END_ARG_INFO() ZEND_FUNCTION(go_upper); static const zend_function_entry ext_functions[] = { ZEND_FE(go_upper, arginfo_go_upper) ZEND_FE_END };可以看到go_upper函数带一个string类型参数和string类型返回值。
Go 与 PHP/C 之间的类型转换
你的 Go 函数不能直接接收 PHP 字符串作为参数,必须先转换成 Go 字符串。幸运的是,FrankenPHP 提供了辅助函数来处理 PHP 字符串与 Go 字符串之间的转换,与生成器路线中看到的一致。
头文件保持不变:
// extension.h #ifndef _EXTENSION_H #define _EXTENSION_H #include <php.h> extern zend_module_entry ext_module_entry; #endif现在在extension.c中编写 Go 与 C 的桥接,把 PHP 字符串直接传给 Go 函数:
PHP_FUNCTION(go_upper) { zend_string *str; ZEND_PARSE_PARAMETERS_START(1, 1) Z_PARAM_STR(str) ZEND_PARSE_PARAMETERS_END(); zend_string *result = go_upper(str); RETVAL_STR(result); }关于ZEND_PARSE_PARAMETERS_START与参数解析的更多细节,可以参考 PHP Internals Book 的专门章节。这里我们告诉 PHP:函数接收一个string类型的必选参数(以zend_string形式),然后把它直接传给 Go 函数,并用RETVAL_STR返回结果。
剩下最后一步:在 Go 中实现go_upper函数。
实现 Go 函数
Go 函数接收*C.zend_string参数,用 FrankenPHP 的辅助函数把它转换为 Go 字符串,处理后再以新的*C.zend_string返回。辅助函数帮我们处理了全部内存管理与转换的复杂性:
// extension.go package example // #include <Zend/zend_types.h> import "C" import ( "unsafe" "strings" "github.com/dunglas/frankenphp" ) //export go_upper func go_upper(s *C.zend_string) *C.zend_string { str := frankenphp.GoString(unsafe.Pointer(s)) upper := strings.ToUpper(str) return (*C.zend_string)(frankenphp.PHPString(upper, false)) }这种方案比手动内存管理更干净、更安全。FrankenPHP 的辅助函数自动处理 PHPzend_string格式与 Go 字符串之间的转换。PHPString()中的false参数表示创建一个新的非持久字符串(在请求结束时释放)。
[!TIP] 本例没有做任何错误处理,但在实际代码中,你应该始终先检查指针是否为
nil、数据是否有效,再在 Go 函数中使用它们。
把手动扩展集成进 FrankenPHP
扩展准备好编译与集成了。编译方法参见 FrankenPHP 编译文档。使用--with标志把模块加入构建:
CGO_ENABLED=1 \ XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \ CGO_CFLAGS=$(php-config --includes) \ CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \ xcaddy build \ --output frankenphp \ --with github.com/my-account/my-module这样就完成了!你的扩展已集成进 FrankenPHP,可以在 PHP 代码中使用。
测试你的扩展
集成完成后,创建一个index.php文件,包含你实现函数的示例:
<?php // 测试基础函数 go_print(); // 测试进阶函数 echo go_upper("hello world") . "\n";运行./frankenphp php-server启动 FrankenPHP 处理该文件,即可看到扩展生效。
实践建议与小结
综合两条路线,可以总结出以下实践要点:
- 优先选择生成器:对于绝大多数简单扩展(原生函数、不透明类、常量、数组与 callable 处理),
frankenphp extension-init会产出全部样板文件,且绝不改动你的源文件,非常适合版本化维护。其核心解析与生成逻辑位于 internal/extgen,支持函数、方法、类、常量、命名空间等各类//export_php:*指令的解析与校验; - 类型转换记住一条主线:标量类型(
int/float/bool)直通;字符串走GoString/PHPString;数组按「是否保序」在AssociativeArray/map/slice三种形态间选择;mixed走GoValue/PHPValue;callable 用CallPHPCallable反向调用 PHP。全套 API 均可参阅 types.go 与 frankenphp.stub.php(后者展示了 FrankenPHP 自身扩展函数的存根写法,可作为参考模板); - 需要完全控制时再手动实现:手动路线要求你同时维护 Go 函数、PHP 存根(stub)、C 头文件与 C 实现文件,并用
gen_stub.php生成 arginfo,适合理解扩展内部机制或需要自定义zend_module_entry生命周期钩子(MINIT/MSHUTDOWN 等)的高级场景; - 运行前提:无论哪条路线,最终都需要通过
xcaddy build以--with集成模块,并按 docs/compile.md 的说明编译 FrankenPHP 二进制;编译时CGO_ENABLED=1与php-config提供的编译/链接参数是必需项。
由此,你可以在 PHP 与 Go 之间自由搭建高性能的原生能力桥梁——从一行 goroutine 的异步日志,到完整的 Go 支撑 PHP 类与常量体系,再到双向 callable 协作,FrankenPHP 都已为你铺平了道路。
【免费下载链接】frankenphp🧟 The modern PHP app server项目地址: https://gitcode.com/GitHub_Trending/fr/frankenphp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考