kode / framework
Kode Framework —— 以 kode/core 为启动器、kode/runtime 为统一运行时(Swoole/Swow/Fiber/多进程/多线程)的现代 PHP 全栈框架,组合 kode 生态与 Monolog、Symfony Validator 等成熟包。
Requires
- php: >=8.3
- ext-pcntl: *
- ext-posix: *
- ext-sockets: *
- kode/aop: *
- kode/attributes: *
- kode/cache: *
- kode/console: ^4.1
- kode/context: *
- kode/core: *
- kode/database: ^1.23
- kode/di: *
- kode/event: *
- kode/exception: *
- kode/facade: *
- kode/fibers: *
- kode/http: ^3.5.2
- kode/http-client: ^2.5
- kode/jwt: *
- kode/limiting: ^2.0
- kode/messaging: ^3.3
- kode/middleware: *
- kode/parallel: *
- kode/process: ^5.5.1
- kode/queue: ^2.2
- kode/router: *
- kode/runtime: *
- kode/scheduling: ^1.3
- kode/session: *
- monolog/monolog: ^3.0
- nyholm/psr7: ^1.8
- psr/container: ^2.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/http-server-middleware: ^1.0
- psr/log: ^3.0
- symfony/translation: ^7.0
- symfony/validator: ^7.0
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 1.15.0
- 1.14.0
- 1.13.1
- 1.13.0
- 1.12.0
- 1.11.1
- 1.11.0
- 1.10.2
- 1.10.1
- 1.10.0
- 1.9.0
- 1.8.1
- 1.8.0
- 1.7.9
- 1.7.8
- 1.7.7
- 1.7.6
- 1.7.5
- 1.7.4
- 1.7.3
- 1.7.2
- 1.7.1
- 1.7.0
- 1.6.4
- 1.6.3
- 1.6.2
- 1.6.1
- 1.6.0
- v1.4.3
- v1.4.2
- v1.4.1
- v1.4.0
- v1.3.12
- v1.3.11
- v1.3.10
- v1.3.9
- v1.3.8
- v1.3.7
- v1.3.6
- v1.3.5
- v1.3.4
- v1.3.3
- v1.3.2
- v1.3.1
- v1.3.0
- v1.2.6
- v1.2.5
- v1.2.4
- v1.2.3
- v1.2.2
- v1.2.1
- v1.2.0
- v1.1.9
- v1.1.8
- v1.1.7
- v1.1.6
- v1.1.5
- v1.1.4
- v1.1.3
- v1.1.2
- v1.1.1
- v1.1.0
- v1.0.0
- v0.9.0
- v0.8.51
This package is auto-updated.
Last update: 2026-09-25 19:21:14 UTC
README
一个以 kode 生态组件为基座、组合 Monolog / Symfony Validator 等成熟包的现代化 PHP API 框架。最低 PHP 8.3+,开箱即多进程常驻服务,错误默认返回可追踪的结构化 JSON。
设计立场和 webman / Hyperf 一致:薄内核 + 复用 Composer 生态。框架只做「启动、容器、路由、统一响应、异常、中间件、韧性层」等地基,其余能力(JWT、限流、缓存、队列、数据库、事件、HTTP 客户端、消息、国际化、Snowflake、定时任务、多进程……)全部来自 kode 生态包,业务代码不变即可切换运行时(Fiber 协程 / 多进程 / 多线程 / Swoole / 分布式)。
版本自述
本包版本可由类常量核对:Kode\Framework\Application::VERSION,或调用 Application::version()(当前 1.15.0)。composer.json 的 version 是 composer 侧权威值,类常量是它的交叉核对副本——tests/VersionGuardTest.php 在两者不一致时直接失败。
5 分钟跑起来
# 1. 安装:下载骨架 + composer install + 初始化(项目名 myapp 写在包名后) # 骨架仓库 = kode/skeleton;本仓库(kode/framework)自 v1.2.0 起为纯内核,作为依赖被引入 composer create-project kode/skeleton myapp \ --repository='{"type":"vcs","url":"https://github.com/kodephp/skeleton.git"}' \ --stability=dev cd myapp # 2. 启动多进程 HTTP 服务(默认 http://127.0.0.1:9527) php kode start # 3. 验证 curl http://127.0.0.1:9527/health # {"status":"ok","service":"kode-app","version":"1.15.0","php":"8.3.33","env":"local","time":0.52,"uptime":3.4,"components":{"app":"ok"}} # time = health check 方法执行耗时(毫秒);status 随探针(任一 error 即 degraded),HTTP 恒 200
为什么多了
--repository:kode/skeleton与kode/framework目前都未提交到 Packagist, 直接composer create-project kode/skeleton myapp会报Could not find package ... with stability stable。 显式指定 VCS 仓库即可安装;骨架的composer.json已声明 framework 的 VCS 仓库,依赖可正常解析。
安装时
composer create-project会自动执行php kode init,生成.env(含强随机JWT_SECRET,权限 0600)与storage/目录。 若把框架作为依赖引入已有项目:在composer.json声明 framework 的 VCS 仓库后composer require kode/framework,再从 kode/skeleton 复制app/、config/、lang/、database/、kode到项目根,然后php kode init(控制台命令走php kode console ...)。(本仓库不再自带这些骨架目录。)
第一个接口:
// app/http/controllers/HelloController.php namespace app\http\controllers; use Kode\Framework\Http\Controller; final class HelloController extends Controller { public function say(): array { $name = $this->input('name', '世界'); return ['hello' => $name]; // 直接返回数组 → 自动 JSON 化 } }
// app/routes.php use Kode\Http\App; use app\http\controllers\HelloController; return function (App $app): void { $app->get('/hello', fn() => resolve(HelloController::class)->say()); };
curl "http://127.0.0.1:9527/hello?name=Kode" # {"hello":"Kode"}
服务运维命令(对标 workerman)
启动时会打印进程表横幅,一眼看到协议 / 用户 / worker 名 / 监听地址与端口 / 进程数 / 状态:
Kode[kode] start in PRODUCTION mode
--- KODE ---------------------------------------------------------------------
Kode Framework version:1.15.0 PHP version:8.3.33
Runtime:native Event-Loop:event
--- WORKERS ------------------------------------------------------------------
proto user worker listen processes status
http Zhuanz kode-http http://127.0.0.1:9527 8 [OK]
------------------------------------------------------------------------------
项目根目录:/srv/myapp
Press Ctrl+C to stop. Start success.
| 命令 | 作用 |
|---|---|
php kode start |
前台启动(非 production 默认热重载,--no-watch 关闭;serve 为别名) |
php kode start -d |
守护进程模式(脱离终端,写 PID 文件;横幅给出 php kode stop --port <端口>,多实例按端口分片,不带端口会作用到默认实例) |
php kode status |
workerman 风格状态表:GLOBAL STATUS + 逐进程 PROCESS STATUS |
php kode status [--port P] [--pid=N] |
查看服务状态(--port 定位多实例,--pid 看单进程) |
多实例(v1.3.3):不同端口实例的 PID/日志/状态隔离在
storage/runtime/<port>/(如kode start --port 9599), 用kode stop/status --port 9599精确操作该实例;老版本单文件(storage/runtime/kode.pid)自动兼容一次。 |php kode stop [--port P] [-g]| 停止服务(默认优雅停机,-g强杀;--port定位多实例) | |php kode reload [-d]| 全量重载(等价 stop 后 start;默认前台,-d进守护) | |php kode restart| 运行中平滑滚动 worker;未运行按start拉起 |
命令约定(v1.3.3 起):
reload=重载所有(stop+start),restart=只平滑滚动 worker。注意这与 workerman 的命名相反(那边 restart 是全量、reload 是平滑), 为统一记忆:带 e 的 reload 做“全套”(rEload=Everything),短小的 restart 做“滚动”(rolling)。
status 输出示例(connections / total_request / qps 来自各 worker 的 1Hz 心跳):
----------------------------------------------GLOBAL STATUS----------------------------------------------
Kode Framework version:1.15.0 PHP version:8.3.33
start time:2026-08-30 12:36:36 run 0 days 0 hours 1 minutes
master pid:81664 runtime:native event-loop:event load average:0.35, 0.31, 0.28
1 workers 3 processes
worker_name processes status
kode-http 3 [OK]
----------------------------------------------PROCESS STATUS---------------------------------------------
pid memory listening worker_name connections total_request qps status
81667 12.00M http://127.0.0.1:9527 kode-http#0 0 128 3 [idle]
与 workerman 的已知差异(如实标注,不做伪装):不输出 exit_status / exit_count 两列——
workerman 在 master 里收割子进程并记录退出码,而本框架 master 循环位于 kode/process 内部,
业务层观测不到子进程退出码;与其填 0 假装「零退出」误导排障,不如不列。
进程表数据写在 storage/runtime/(可用 config/server.php 的 runtime_path 改),
纯运行时产物,随时可删,下一心跳自动重建。
Ctrl+C 为什么能立刻退出:
kode/process收到停机信号后固定空等一整个graceful_shutdown_timeout(骨架默认 30s)——空闲服务按 Ctrl+C 也要等满 30s。 框架在 v1.2.0 补了「快速排空」看门狗:收到信号后一旦在途请求归零就立即结束事件循环, 空闲退出从「等满宽限」压到 ≤0.5s;真有在途请求时仍走完整宽限,不丢请求。
命令的「选项面」:未知选项、错取值、空格写法(v1.8.0 → v1.10.0)
kode/console 对不认识的名字一律照收(记进 flags()/options())却不执行,
于是 php kode migrate:reset --pretend 会「以为传了 dry-run、实际把全库回滚了」,退出码还是 0。
基类给了自查门禁:
#[AsCommand(name: 'migrate', description: '执行待运行的数据库迁移', usage: 'migrate {--step=} {--pretend}')] final class MigrateCommand extends Command { private const OPTS = ['step', 'pretend']; protected function handle(): int { if (($bad = $this->rejectUnknownOptions(self::OPTS)) !== null) { return $bad; // 未识别的选项 → 打印用法行 + 退出 1 } // 整数用 checkIntOptions,允许小数的秒数用 checkNumOptions if (($bad = $this->checkIntOptions(['step'], 1)) !== null) { return $bad; // --step=abc / 1.5 / 0 / -2 → 报错退出 1 } // ... } }
usage要写成 console 的签名 DSL({--step=}),不是[--step=N]:方括号形式解析不出任何选项,--step 2的空格写法会把2泄成位置参数,kode help migrate也列不出选项。- 名单逐字比对:console 只把短别名归一成长名,
--dry_run与--dry-run是两个键, 命令读不到前者,所以它算未知(替用户「顺手容错」等于把同一类误会再放行一次)。 - 内核自己消费的全局标志(
-q/-v/-vvv/--no-ansi/--ansi)自动放行, 名单取自Kernel::globalFlagNames()(kode/console ≥ 4.1),命令侧不抄表。 --help/-h出帮助页并返回 0:问「怎么写」的词绝不该被执行。走Kode\Console\Kernel时 内核在fire()之前就按flags('help')打了帮助页(命令体根本不运行),基类这条分支 只服务直接调fire()的嵌入方(单测、进程内调用)。- 需要自己判断时用
unknownOptions(self::OPTS),返回形如['--dry-run']的未知项。 - 取值规则要写
'integer',不能只写'numeric':--step=1.5过得了'numeric', 再被(int)截成 1,动的就不是用户说的那几个批次(v1.8.1 修正,实测踩到)。 - 判据是
Input::provided()而不是「取到的值是否为 null」:签名里带了默认值的选项 ({--limit=20})没传也躺在options()里,两种情况混为一谈就等于永不校验。
v1.9.0:这条门禁铺满全部内置命令。 起因是实测 kode route:list --group api 只列出 0 条路由
(--group=api 才对)——根因不是某个命令写漏了,而是 usage 大面积停在惰性的方括号形式:
签名解不出选项 → 守卫无从比对 → 空格写法的值泄成位置参数 → opt('group') 读到布尔 true。
src/Console/Commands/ 下 30 个命令逐个补齐 OPTS + 守卫,usage 全部改写为签名 DSL,
顺带补上一直读却从未声明的 queue:work --timeout。
覆盖方式不是给每个命令写一份用例,而是 tests/CommandLineSurfaceTest.php 用 glob 自动发现命令类,
把四条不变量钉在所有命令上(新加的命令自动进表):
usage里禁止出现[--;OPTS≡ 签名解析出的选项 ≡ 代码里opt()/flag()/provided()/checkXOptions()真正读到的名字;- 每个带值选项的
--x value、--x=value、--flag三种写法都必须落到值上; - 守卫是
handle()的第一条语句,且所有数值校验排在resolve()(命令开始连库/拉起 worker 的分界)之前。
v1.10.0:kode schedule:list 不再是第二条实现。 启动器 kode 脚本里另有一份
KodeScheduleListCommand,run(array $args) 从头到尾没读过 $args —— 于是
kode schedule:list --tenant=abc 退 0、照样列出全部租户,而控制台那份(有门禁、会报错)被同名分支挡在外面。
现在快捷入口一律转发给控制台,那条重复实现删掉了;不变量由 test_the_launcher_does_not_shadow_console_commands()
盯着:启动器分发表里凡是不走 KodeConsoleCommand 的分支,命令名都不许与控制台命令重名。
同一版把每条 usage 的选项都补上了说明:kode help route:list 此前列得出 --group=GROUP 却全是空描述,
等于把「这个选项干什么」推回源码。现在 --compact、--columns=、queue:work 的十个数值项都有口径,
并由 test_every_token_is_documented() 保证不再退化成空白。
自己加命令时照着抄那一段就够了;漏掉的形态由上面四条替你兜住。
migrate / migrate:rollback / migrate:reset 的 --step 现在先校验再动库:没写就是不限步数,
写了就必须是 ≥1 的整数((int) 'abc' 是 0 步,跟字面意思相反;
光秃秃的 --step 由 console 自己报「需要一个值」并退 2)。
queue:work 同理先验完 --tries/--max-jobs/--max-time/--memory/--sleep/--timeout 再进消费循环
(--tries=abc 强转是 0 = 不限制重试,--sleep=abc 强转是 0 = 空转打满 CPU;--memory 允许 -1 表示不限)。
测试基类 Testing\TestCase:independentApp 现在真的重建(v1.10.1)
bootApp() 把「已启动的应用」缓存在 private static $app,tearDown() 负责清空——
所以 protected bool $independentApp = true 的语义是「我要一个按我自己的 configOverrides
重新引导的实例」。v1.10.1 之前它只重建 kode/core 单例,随后撞上实例缓存就直接把陈应用
返回,开关等于没读;更糟的是那份陈应用的地基刚被自己抽掉,下一次 resolve() 直接抛
「服务容器尚未启动」。踩中它的条件是跨类顺序(前一个类覆盖了 tearDown() 却没回父类),
所以症状是随机的、单跑永远绿——消费方项目真实踩过一次,见其
tests/TestCaseHygieneGateTest.php 静态门禁。
现在 independentApp=true 会同时丢掉缓存并重建核心单例,两者不再只做一半;
不变量由 tests/TestingTestCaseIsolationTest.php 盯住:默认复用、置位必换实例、
且重建后的实例发一次真实请求能解析控制器。
下游写法不变:覆盖 setUp()/tearDown() 时必须调用父类实现,配置互斥的测试类照旧置
independentApp = true。用法详见文档站 docs/testing.md。
/health 的 status 现在随探针(v1.10.2)
同一次探测,框架此前给出三个答案:/health/ready 在有依赖 error 时返回 503、
kode console health:check 以退出码 1 结束、/health 的 status 却硬写着 ok——
而它恰是巡检面板与人工 curl 读的那一条。于是一个 DB 已经断线的实例,明细里写着
"db":"error",顶层仍然是一片绿。
现在 status 与 HealthChecker::check()['healthy'] 同源(任一 error 即 degraded)。
HTTP 状态码刻意保持 200:/health 是报表不是闸门——摘流量看 /health/ready,
重启判定看 /health/live;把聚合视图也做成 503,会让「拿 /health 当 livenessProbe」的
应用在一个外部依赖抖动时开始无意义重启,而重启修不好对端的数据库。
消费方需要注意的只有一件事:如果此前有代码 if ($health['status'] === 'ok') 当成「永远成立」
在写(例如启动自检脚本以此判断「端点活着」),改成看 HTTP 码或 /health/live。
不变量由 tests/HealthExposureTest.php 盯住:探针红 → status:degraded 且仍 200,
探针绿 → status:ok(正对照,防止把 degraded 写死)。用法详见文档站 docs/lifecycle.md、
docs/robustness.md。
min: / max: 现在按字段声明的类型判,而不是按值碰巧长什么样(v1.11.0)
Validator 的 min:/max: 此前按 is_numeric($value) 自适应选比较方式:值是纯数字串就走
数值比较。问题是 HTTP 层拿到的 JSON 值常常就是字符串,于是同一份规则会在特定输入下悄悄
换成另一种判据,而且两个方向都错:
'password' => '12'过min:6(12 >= 6)—— 密码长度策略被一枚两位数字绕过;'title' => '20260101'过不了max:128(2.026e7 > 128)—— 用日期当标题直接 422, 提示还是「应小于等于 128」;- 全数字的 64 位 sha256 过不了
min:64|max:64(恰好合规的长度被判成超大数值)。
现在比较方式只由字段自己声明的类型决定:规则里有 integer / numeric,或值本来就是
int / float → 数值比较;其余(含纯数字字符串)一律按字符数。新增 string 标记,
它同时验类型并把 min:/max: 定在字符数上;值不是字符串时只报「类型不对」,不会再叠一条
看不懂的长度错。需要按大小判的字段照旧写 integer|numeric(age => 151 仍被拒)。
不受影响的:length:(本来就是字符数)、in:/regex:/email/url,以及控制台选项的
min:/max:(那是 kode/console 的 Input::validate() 另一套解析器,一直是数值语义)。
消费方唯一要留意的是「以前靠纯数字串走数值分支」的写法:把 min:0 这类规则改成
numeric|min:0 即可拿回原语义。不变量由 tests/ValidatorTest.php 双向盯住(纯数字串按长度 +
声明了数值类型的仍按数值),详见文档站 docs/getting-started.md 的校验篇目。
pruneRunHistory():删失败不再回 0,越界天数不再被夹(v1.11.1)
ScheduleDispatcher::pruneRunHistory($days) 是「按天数删 kode_schedule_runs 行」的破坏性入口
(后台的清理按钮与定时清理任务都落在它上面)。旧实现有两处会把坏消息说成好消息:
catch (\Throwable) { return 0; }:删失败与「没有过期数据」压成同一个0。调用方印的是 「已清理 0 条(超过 N 天)」并记一条 info 日志——于是保留期早已不工作,而所有人都以为在工作。 更糟的是这个catch里还要调logger(),容器未启动的进程(清理任务常在容器之外跑)会先抛 「服务容器尚未启动」,把真正的数据库原因顶掉,排障方向整个错一位。- 天数直接拼进 SQL:
NOW() - INTERVAL '-5 days'就是NOW() + 5 天,也就是一次「把整张执行 历史删光」的无告警批量删除,而返回值看着完全正常。
现在的口径:$days 必须是 1..3650 的整数,越界抛 InvalidArgumentException(消息里带着收到的值),
判定在碰到数据库之前完成;cutoff 仍由数据库算(make_interval(days => ?),绑定参数),
删除失败原样上抛 \RuntimeException 并把数据库异常挂在 previous 上。
消费方注意:以前「拿 0 当成功」的调用要显式接异常——清理失败要么让任务失败去报警,要么自己
catch 后记下真实原因,别再退回「已清理 0 条」。可接受区间写死在 ScheduleDispatcher::MAX_RETENTION_DAYS
(上限)与 1(下限);应用侧若要更严的用户口径(比如最少 7 天)应在自己那层判完再传进来,
本包不会替你夹。
不变量由 tests/SchedulePruneRunsTest.php 盯住:越界值一律在任何摸库之前拒(连接被刻意指向
死地址,摸了就是另一种异常);删除失败必须带着数据库的原始原因抛出,且异常链里不许出现
「服务容器尚未启动」;正向对照在一次性临时库(kode_zz_prune_*,跑完即删)里真建表、真插一行
40 天前的记录,断言「只删超期那一行、返回条数是真的」。
常驻进程:状态终于读得回来了(v1.12.0)
ProcessManager 过去只有写侧没有读侧。它按槽位往 sys_get_temp_dir() 写 pid 文件
(kode-worker-{name}:{slot}.pid),路径由私有的 pidFileFor() 决定,外面没有任何口子能问到「哪几路在跑、pid 是多少」。
于是应用侧自己编了一个路径去判活——/tmp/kode-daemon.pid,而那个文件从来没有人写过。
后果不是「面板不准」而是三件事同时坏:状态恒显示「未运行」;「启动」按钮的防重入守卫因此
永远放行,每点一次叠加一整套守护进程;「停止/重载」对着一个不存在的文件,永远失败。
kode/process 只提供了写侧的 Daemon,读侧本就该由掌握槽位布局的 ProcessManager 负责。
现在三个公开口子:
$pm->residentSlots(); // list<{name, slot, pid_file}> —— 纯计算,不碰磁盘 $pm->slotStates(); // + pid:int|null, alive:bool, started_at:int|null $pm->signalSlots(\Kode\Process\Signal::USR1); // → ['signalled' => ['heartbeat:0'], 'skipped' => [{label, reason}], 'failed' => [{label, reason}]]
四条口径:
- 一次性 worker(
once())不在清单里。 它们启动即退出、从不写 pid 文件,算成常驻槽位 就让面板多一行永远跑不到的记录,并对它报「发信号失败」。 slotStates()与start()用同一份展开(私有的residentSlotPlan())。这里 fork 的 就是面板上显示的那些路;两份清单各写一遍迟早分叉,而分叉的表现是「状态页有一路、 实际没人跑」或反过来。started_at取 pid 文件的 mtime,这是不依赖/proc的跨平台口径。darwin 根本没有/proc;而按/proc/{pid}/stat第 22 字段(clock ticks)算的应用,实测读到的是宿主机的 开机时长——常驻面板把「本机跑了 300 天」当成「这个 worker 跑了 300 天」。- 读路径绝不删失效的 pid 文件。 清理责任属于写侧的退出逻辑(
Daemon优雅退出时自己unlink)。读者删文件会把一次「正在启动、尚未落 pid」的正常拉起抹成「没发生过」。 同理,pid 文件内容不是纯数字时一律判「没在跑」,绝不去猜一个 pid 来发信号。
信号语义必须由调用方明确选对,Signal 常量在 macOS 和 Linux 上数字不同(USR1 分别是 30 和 10),
所以只能传 Kode\Process\Signal::*,不能传裸数字:
| 信号 | Daemon 的行为 |
|---|---|
Signal::TERM / INT |
优雅停止:停掉全部 worker、回收子进程、删 pid 文件 |
Signal::USR1 |
平滑重载该路的全部 worker(旧 worker 处理完当前任务再退) |
Signal::HUP |
没有安装处理器 → 系统默认处置 = 直接终止进程 |
Signal::USR2 |
把运行时状态打进日志,供排障 |
最后一行是这轮修掉的真缺陷:后台的「重载」按钮发的是 1,也就是 SIGHUP。它看起来什么都不发生
(面板照旧、日志照旧),因为那个守护进程已经没了;再点「停止」时它已经不在,于是回「已停止」。
重载请传 Signal::USR1。
signalSlots() 的返回值分三档(signalled / skipped / failed)而不是布尔,理由是
「一路在跑、一路早就没了」是常态:只回 true 会让面板把部分成功报成全成功。标识串与
slotStates() 的 name . ':' . slot 同源,所以「刚才发给了谁」一定能和状态表逐行对上。
另外 PID 会被操作系统复用,「文件在 + 进程在」理论上是别人的进程;这里不做二次归属判定
(没有跨平台手段),对生产做停机操作前请核对 pid。写侧的归属判定在 kode/process >= 5.5.0:
Daemon 只认领「不存在 / 空 / 非数字 / 已死 / 是自己」的 pid 文件,且退出时只删写着自己 pid
的那一份,所以别人占着的文件不会被覆盖成下一代的。>= 5.5.1 起「判归属 + 落盘」还跑在同一把
非阻塞 flock 里(锁加在 pid 文件本身),并发启动会被点名「并发启动」而拒绝。
回归见 tests/ProcessSlotStateTest.php:槽位枚举(含 once() 被排除、多实例逐槽位独立文件)、
活 pid(用测试自己的 pid 验,alive 为真且 started_at 等于文件 mtime)、缺文件、
内容不是数字、pid 已失效(断言 alive 为假且文件原地不动)、signalSlots() 只命中存活槽位、
空注册表是 no-op、以及「状态表的标识 == 发信号结果的标识」这条对账。
start() 的改动只有一处结构性的(改用 residentSlotPlan()),语义不变:所有 once() worker
仍在任何 fork 之前同步执行完。
重复 start() 在派发前就被拒(v1.13.0)
v1.12.0 把状态做成了可读回来的,但「已在跑还再点一次启动」这一条只写在面板的按钮文案里,
ProcessManager::start() 自己照样会把第二套守护进程 fork 出来。理由并不充分:pid 文件的互斥
确实存在,可它是 kode/process 的 Daemon::run() 在每个守护进程自己的子进程里判的
(v5.5.0 起会抛异常拒绝启动)。而 start() 对多槽位的编排是「fork 完就 wait()」——
子进程抛的异常没有任何人接,父进程只是等它退出,命令行照样回「启动成功」。
于是真实的坏法是:一次 kode start 之后系统里躺着两套互相看不见的守护进程,
第一套的 pid 文件被第二套覆盖(v5.5.0 之前的行为),而状态表只认得后写的那一份。
现在的口径:start() 在派发之前先问自家 slotStates(),有存活槽位就抛
\RuntimeException 并在消息里逐路点名(name:slot(pid N)):
$pm->start(); // RuntimeException: 常驻进程已在运行,拒绝重复启动:heartbeat:0(pid 4812)、 // queue:0(pid 4813)、queue:1(pid 4814)。 // 需要换 worker 注册表请先 stop 再 start;只想重载代码请发 USR1(reload)。
三条口径:
- 拦在
once()worker 之前。 一次性 worker 是start()里唯一同步执行的部分, 放在它后面就等于「拒绝启动」之前先把定时任务真跑了一遍——有副作用的拒绝不是拒绝。 - 判据只有一份,用
slotStates()。 这里不许再出现第二份「读 pid 文件 +posix_kill」, 那是kode/process与slotStates()已经各写一遍的东西;第三份的意思只会和状态表分叉, 而分叉的表现是「状态页说在跑、启动说没在跑」。这条由源码门禁盯着。 - 失效的 pid 文件不拦启动。 「文件在」和「在跑」是两件事:上一代被
kill -9之后 文件原地留着,此时slotStates()回alive: false,启动照走。把残留文件当成「在跑」 等于把机器永久锁死在一次失败启动上,而唯一出路是让人手工去/tmp删文件。
kode/process 那一侧的互斥不是这道预检的后备,反过来也一样,两道各治一种坏法:
本包的这道管「同一个注册表被重复 start()」(面板连点、脚本重跑);kode/process >= 5.5.1 的
flock 管「同一个 pid 文件被两个进程同时判+写」(两个不同的 ProcessManager 实例并发启动、
两条 kode process:start 同时敲)。注意本包的预检挡不住并发:两个进程可以同时在
slotStates() 里看到「全没跑」,然后各自派发 —— 那时真正拦下来的是 process 侧那把锁。
所以这道预检的价值是「在 fork 之前就给出一条能被 catch 到、能回 400 的拒绝」,
而不是「最后一道互斥」(process 侧的异常在已 detach 的子进程里抛出,命令行只看到退出码)。
v5.5.0 之前连 process 侧也挡不住并发(判定与落盘两步之间没有锁),
实测同时敲两次 php kode process:start 会真起出两套守护进程 —— 那是 v5.5.1 修的。
两道判据的分工不同,所以要一起留着。
回归见 tests/ProcessStartGuardTest.php。start() 与 Daemon::run() 都会阻塞,
所以用例一律 fork 子进程 + 有界轮询(200×10ms)拿判决,再 SIGTERM + wait() 收尸:
「被拦时 once() worker 没被执行」(flag 文件不存在)、什么都没跑时照走、
残留 pid 文件不拦(正向证据 = once() worker 的 flag 文件真出现了)、
以及「不得在框架里重写一份 pid 判定」的源码门禁。
testStalePidFileDoesNotBlockStart 是这轮补的第二条腿:只有前两条时,
「无视 alive 一律拦」的变异体能全绿——因为「全部不跑」的用例只注册了一个 once() worker,
slotStates() 天生是空的,那个循环根本没执行。
v1.13.1:更正上面那句「最后一道」
v1.13.0 发版当天,这个分工被活体复现推翻:kode/process v5.5.0 的互斥只罩住了「判」,
没罩住「写」——同时敲两次 php kode process:start 会真起出两套互相看不见的守护进程
(两边都在对方落盘前读到「没人占用」)。process 侧已在 v5.5.1 用 flock 补上,
本包随之做两件事:依赖下限抬到 kode/process ^5.5.1(代码零改动),
把上面「kode/process 那一侧是最后一道」改成如实的分工描述。
本包这道预检的定位不变:它是唯一能把拒绝变成一次可 catch 的异常、从而回 400 的判定,
而 process 侧那把锁在已 detach 的子进程里抛异常,命令行只看得见退出码。
ScheduleDispatcher::stats():读不到不再回「完美」,以及一处从未跑通的调用(v1.14.0)
stats() 是「任务健康度」的唯一数据源(后台 GET /api/schedules/health、schedule:list 都读它)。
这一轮改掉了两件事,第二件才是第一件的真因。
① 有一处调用从来就没跑通过。 单任务那条腿写的是 Db::selectOne(...),而 kode/database
的 facade 从来没有那个方法:Db::__callStatic 把它当成 Model 的静态方法转发,实测每次调用
都抛 BadMethodCallException: 请创建 Model 类后使用静态方法调用。因为外面正好套着 ② 那个
catch (\Throwable),这个 \Error 被吞成一条合成行,于是**「单任务执行统计」从来没工作过**,
而页面上一直是绿的。现在取首行走 select()($rows === [] ? null : $rows[0])。
这类病的通用形态是:facade 方法名写错 → 运行时 \Error → 被 catch (\Throwable) 吞掉 →
「API 用错」在页面上长成「这台系统没有这项数据」。class_exists 型门禁全抓不住(类名是对的)。
所以本包新增 tests/DbFacadeCallGateTest.php:扫 src/ 里对 Db 的全部静态调用
(Db::x( 与 $db::x( 两种写法都要认,后者是本包 $db = Db::class 的既有风格),
逐个 method_exists;先剥注释(文档里那句「旧写法长什么样」不是调用点);
带一条「把幻影方法名喂给同一个抽取器,它必须点名」的正对照,以及「扫到的调用点数不得少于 10」
的防空转断言。
② 读不到不再压成「没有历史、而且完美」。 旧写法:
} catch (\Throwable $e) { logger()->warning('查询调度统计失败:' . $e->getMessage()); return $taskName !== null ? ['total' => 0, …, 'success_rate' => 100.0] : []; }
三处坏法叠在一起:断链 / 无权限 / 列缺失全被压成「这台系统没有任务跑过」;catch 里先调
logger(),未引导的进程(CLI、定时清理任务)会自己抛「服务容器尚未启动」,把真正的数据库原因
顶掉;而回的那条合成行里 success_rate = 100.0 —— 于是「一座库没读到」在页面上是一个绿色的
完美数字,比空表更容易被照着做决策。consecutiveFailures() 同族:catch → return 0
把「读不到」写成「没有连续失败」,而那正是「这条任务健康」的判据之一。
现在的口径:
- 读不到一律抛
\RuntimeException,数据库原始异常挂在previous上(异常链里不许出现 「服务容器尚未启动」,这条是被断言的)。 - 唯一的例外是历史表还没建(全新环境从没跑过任务,那是事实):只认 pgsql
42P01与 sqliteno such table,回空汇总。列缺失(42703)、无权限(42501)、断链(08xxx/HY000) 一律不在豁免内 —— 用例里真ALTER TABLE … DROP COLUMN duration_ms来钉住这条判断没被放宽。 total === 0时success_rate是null,不是100.0:没有分母的比率不存在。 消费方(后台卡片)按null渲染—。
schedule:list 的另一半:stats() 现在会抛,命令不能因此打断整张表,也不能顺手回 0
($stats['total'] > 0 一判就变成「没有记录」)。那一列现在有三种显示,且互相分得开 ——
— = 没有执行记录,! = 这一行读不出来,其余 = 真状态;表尾另出一行
「其中 N 条任务的执行统计读不到」。
消费方注意(破坏性):以前把 stats() 当「恒不抛」的调用要显式接异常,别把它又吞成空数组。
success_rate 的键恒在,但值可能是 null。
回归见 tests/ScheduleStatsFailureTest.php(汇总腿与单任务腿各自验「必须抛且原因在链里」、
缺表是唯一不抛的失败、42703 仍必须抛、total=0 时 success_rate 是 null,
以及正向对照:一次性临时库 kode_zz_stats_* 里插 1 成功 + 1 失败,断言比率真的是 50.0
且全任务汇总里有这一行)+ tests/DbFacadeCallGateTest.php。
执行历史那两条读腿同病同治(v1.15.0)
v1.14.0 只收了汇总腿。runHistory() 与 tenantRunHistory() 是同一张 kode_schedule_runs 上
另外两条读腿(后台 GET /api/schedules/{name}/history 与「执行历史」抽屉的数据源),
catch 长得一模一样:
} catch (\Throwable $e) { logger()->warning("查询调度执行历史失败({$taskName}):" . $e->getMessage()); return []; }
这里的后果比 stats() 更直接:抽屉是有人来查故障时才点开的那个界面,而断链 / 缺列 /
无权限被压成一个空数组后,页面渲染的是「暂无数据」—— 一张干净的空白表格,看不出任何事发生过。
(error_message 也在它的 SELECT 列表里,所以一次没跑完的迁移同样落在这条腿上。)
现在的口径与 stats() 逐字一致(同一张表、同一个豁免、同一种抛法):
- 只有历史表还没建算「没有历史」,回
[];其余读失败抛\RuntimeException, 数据库原始异常挂在previous上,且这条腿也不调logger()(未引导的进程里它自己就抛, 会把真因顶掉)。 - 消息里带任务名 / 租户号(
读取调度执行历史失败({$taskName}):…),因为这两条腿是按归属键 收窄的,定位故障时那串键就是坐标。
消费方注意(破坏性,同上一条):以前把这两个方法当「恒不抛」的调用要显式接异常。
管理端的正确接法不是再吞一次,而是把「读不到」如实说出去(HTTP 仍 200 +
available:false + 结论性 reason),否则这条修就在下一层又被抹平了。
三条读腿现在共用一份夹具(tests/Support/ScheduleRunDbFixture.php):伪造「这座库读不到」
在本仓库有一个不小的坑 —— 只把默认连接换成拒连配置是不够的,Db::select() 落到连接池
当前那个名字上,而 addConnection() 会把池子的驱动名改成最后注册的那个;所以恢复默认连接
必须排在所有 add/remove 之后。回归见 tests/ScheduleHistoryFailureTest.php
(两条腿各验「必须抛且原因在链里」、缺表是唯一不抛的失败、真 DROP COLUMN error_message
仍必须抛、插进去的行真读得回来且 limit 真的落在 SQL 上)。
为什么选它
| 痛点 | 本框架的做法 |
|---|---|
| 错误排查难 | 异常默认返回结构化 JSON,含 location(出错文件/行/方法)与 chain(完整调用链),开发期直接定位源码 |
| 重复造轮子 | 能力全部委托 kode 生态包,框架只做薄适配;包升级即能力升级 |
| 性能 / 常驻 | kode/process 多进程常驻内存(零扩展依赖,不锁 Swoole/Workerman) |
| 多运行时 | 一套业务代码,Fiber / 多进程 / 多线程 / Swoole / 分布式通吃 |
| 约定清晰 | 路由双模型(属性 + 闭包)、app/routes/*.php 即插即用、插件自动发现 |
内置能力一览
| 能力 | 怎么用 | 底层包 |
|---|---|---|
| 路由 | 属性 #[Get] / 闭包 app/routes.php / app/routes/*.php |
kode/router + kode/attributes |
| 请求 / 响应 | 控制器短方法 input/query/post/param;Resp::json/error |
kode/http (PSR-7) |
| 参数校验 | $this->validate($data, $rules) |
Symfony Validator |
| 异常处理 | 全局结构化 JSON(location/chain/trace_id) | kode/exception |
| 鉴权 / JWT | jwt()->issue()、AuthMiddleware |
kode/jwt |
| 限流 | #[RateLimit] 声明式 + 全局默认,分布式用 Redis |
kode/limiting |
| 熔断 | breaker()->run($name, $task, $fallback) |
kode/fibers (CircuitBreaker) |
| HTTP 熔断中间件 | CircuitBreakerMiddleware(边缘保护下游,5xx/传输异常计入,OPEN 短路 503) |
框架内置(PSR-15 薄壳层,复用 Breaker 注册表) |
| 重试 | retry($op, attempts: 3) + BackoffStrategy |
框架内置(固定/指数/去相关抖动,零依赖) |
| 超时 | timeout($op, seconds: 2.0) + fallback |
框架内置(fiber 真实抢占 / pcntl / sync 退化,零依赖) |
| HTTP 重试中间件 | RetryMiddleware(安全方法 502/503/504 自动重试,复用 retry 段退避) |
框架内置(PSR-15 薄壳层,复用 Retry) |
| 定时任务 | 约定式 #[Cron] 自动发现 + 命令式 PluginManager::addCron()(插件 boot() 内登记,支持 [类, 方法] / '类::方法' / 闭包);kode cron、schedule:list 统一支持 schedule.discover_plugins 发现插件任务(来源标记 plugin:<name>);运行时生命周期 setEnabled() / setEnabledBySource() / unregister() / unregisterBySource() 供插件暂停/卸载即时下线其任务 |
kode/scheduling |
| 多进程服务 | kode start(--watch 热重载) |
kode/process |
| 缓存 / 队列 / 数据库 / 事件 / HTTP 客户端 / 消息 | cache()/queue()/db()/event()/http()/messaging() |
kode/cache · queue · database · event · http-client · messaging |
| 国际化 | lang() / LocaleMiddleware |
Symfony Translation |
| 分布式 ID | snowflake() |
kode/process |
| 配置 / 日志 / 门面 / DI | config() / logger() / 门面 / resolve() |
kode/core · Monolog · kode/di |
| 可观测性 | /metrics(Prometheus) + W3C 链路追踪 + Metrics 门面;span 导出离请求路径(worker 周期 tick / shutdown / 停机钩子),OTLP/HTTP JSON 与文件导出器,Collector 不可达时指数退避 + 告警去重 |
kode/context + 框架本地薄实现 |
| 运维与生命周期 | /health /health/ready /ping 探针 + 启动/停机事件 |
kode/event + 框架本地薄实现 |
| 安全与合规 | 安全响应头(CSP/COOP/CORP) + 审计日志(脱敏/业务事件/取证) + CSRF 防护(按需挂载·csrf.failed 安全事件·csrf_token_rotate 会话固定防护) + API 版本化 | 框架本地薄实现 |
| API 文档自动化 | /docs/openapi.json + Swagger UI + #[OpenApi] |
框架本地薄实现 |
文档导航
| 文档 | 看什么 |
|---|---|
| 入门指南 | 环境、安装、第一个接口、请求/响应、校验、错误、运行与排错 |
| 开发文档总览 | 路由全解、中间件编写、鉴权、限流、熔断、定时任务、多进程、缓存/队列/数据库/事件/HTTP、配置、日志、门面与助手、控制台、DI 与服务提供者、AOP、插件、部署、测试(docs/ 文档地图) |
建议顺序:先照「入门指南」把第一个接口跑通,再按需查阅「进阶用法」。
版本
- 当前版本:v1.15.0
- 包名:
kode/framework(Composer) - 仓库:https://github.com/kodephp/framework
许可证
MIT(兼容 kode/jwt 的 Apache-2.0;重新分发请保留其第三方许可说明)。