用 Go 编写 FrankenPHP 原生 PHP 扩展:从生成器到手动实现的完整实战指南
2026/9/15 15:50:13 网站建设 项目流程

用 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、手动管理zvalzend_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 扩展的路径:

  1. 扩展生成器(Extension Generator)——推荐方式。它为你生成绝大多数样板代码,让你专注于 Go 业务逻辑本身;
  2. 手动实现(Manual Implementation)——对扩展结构拥有完全控制权,适合高级场景,也能帮助你理解底层原理。

本文先从生成器路线讲起(最容易上手),再展示手动实现,供需要完全掌控的开发者参考。

路线一:使用扩展生成器

FrankenPHP 内置了一个工具,让你只用 Go 就能创建 PHP 扩展:不需要写 C 代码,也不需要直接操作 CGO。同时,FrankenPHP 提供了一套公开的类型 API(types API),帮你免去在 PHP/C 与 Go 之间手动进行类型转换(type juggling)的烦恼。

[!TIP] 想了解不用生成器、从零手写 Go 版 PHP 扩展的原理,可以直接跳到文末的「手动实现」一节。

需要说明的是,这个生成器并非完整的扩展生成器:它旨在辅助编写简单的 Go 扩展,并不覆盖 PHP 扩展最前沿的特性。如果你需要编写更复杂、更追求极致优化的扩展,可能仍需要编写部分 C 代码或直接使用 CGO。

前置条件

与手动实现一节的要求一致,你需要:

  1. 获取 PHP 源码(用于提供gen_stub.php脚本);
  2. 创建一个新的 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.phpPHP 存根文件,供 IDE 自动补全使用
my_extension_arginfo.hPHP 参数信息(arginfo)
my_extension.hC 头文件
my_extension.cC 实现文件
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 辅助函数类方法支持
intint64--
?int*int64--
floatfloat64--
?float*float64--
boolbool--
?bool*bool--
string/?string*C.zend_stringfrankenphp.GoString()frankenphp.PHPString()
arrayfrankenphp.AssociativeArrayfrankenphp.GoAssociativeArray()frankenphp.PHPAssociativeArray()
arraymap[string]anyfrankenphp.GoMap()frankenphp.PHPMap()
array[]anyfrankenphp.GoPackedArray()frankenphp.PHPPackedArray()
mixedanyGoValue()PHPValue()
callable*C.zval-frankenphp.CallPHPCallable()
objectstruct尚未实现尚未实现

[!NOTE] 该表尚不完整,会随 FrankenPHP 类型 API 的完善而持续补充。 具体到类方法:目前支持基本类型与数组。对象暂时不能用作方法参数或返回类型。

对照前面repeat_this()的代码可以看到:第一个参数和返回值用了辅助函数转换,而第二、三个参数无需转换——因为底层类型在 C 与 Go 中的内存表示一致。

从源码看,这些辅助函数都定义在 types.go 中,并明确标注为EXPERIMENTAL(实验性 API):

  • GoString()(types.go):把zend_string复制为 Go 字符串,底层通过C.GoStringN读取zend_stringvallen字段;
  • PHPString()(types.go):把 Go 字符串转换为zend_string,其第二个布尔参数决定字符串是非持久(请求结束由 ZMM 自动释放)还是持久(由你负责释放内存);
  • GoValue()/PHPValue()(types.go):mixed类型的通用转换,目前支持nullboollongdoublestringarray等 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);
  • 嵌套数组——数组可以嵌套,所有受支持的类型(int64float64stringboolnilAssociativeArraymap[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 map
  • frankenphp.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] 目前类方法存在以下限制:对象尚不能用作参数类型或返回类型;数组在参数与返回类型上完全支持;支持的类型为stringintfloatboolarray,返回类型还支持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三种形态间选择;mixedGoValue/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=1php-config提供的编译/链接参数是必需项。

由此,你可以在 PHP 与 Go 之间自由搭建高性能的原生能力桥梁——从一行 goroutine 的异步日志,到完整的 Go 支撑 PHP 类与常量体系,再到双向 callable 协作,FrankenPHP 都已为你铺平了道路。

【免费下载链接】frankenphp🧟 The modern PHP app server项目地址: https://gitcode.com/GitHub_Trending/fr/frankenphp

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询