Caveman 微框架实战:用极简 PHP 快速搭建 JSON API 服务 最近折腾一个新项目的时候我把一个叫Caveman的微型 PHP 框架拖进了代码库。第一反应是这名字真敢起第二反应是我还真就喜欢上了这种粗粝直接的开发体验。这篇文章不聊概念就从头到尾记录一下我是怎么用 Caveman 搭完一个可用的 JSON API 服务以及过程中踩过的那些实实在在的坑。如果你手头有小工具、接口服务需要快速落地又不想为了一个路由把 Laravel 全家桶扛起来这篇东西应该能给你一点参考。1. 一个敢叫 Caveman 的框架到底在解决什么问题1.1 它不是世界末日只是一个 PHP 微框架先给不了解的朋友对齐一下信息。Caveman是一个以“路由绑定函数”为核心思路的极简 PHP 框架你在入口文件里注册 URL 规则把 URL 直接绑到一个闭包或者控制器方法上它帮你完成请求解析、参数提取、响应输出这几件事。没有繁琐的服务容器没有几十个配置文件甚至没有强制性的目录规范。它的定位非常像“穴居人”——只保留原始生存本能。这里补一句市面上其实还有别的同名项目但我的实践基于这个 PHP 版本因此后文统一指它。这样的东西能解决什么问题我举一个真实场景团队要快速做一个内部工具站一个页面提交表单另一个页面处理数据第三个页面展示结果。如果用 Laravel 这类企业级框架你得先想路由文件怎么写、控制器放哪个目录、中间件要不要注册、是不是得建模型和迁移。而用 Caveman我写了三行注册代码然后专注写处理逻辑本身。我从个人经验出发觉得它最合适的是这几类人独立开发者想在一个下午把想法变成可演示的 URL 页面。API 原型验证后端接口先跑通数据结构先对接再决定要不要迁移到重型框架。PHP 学习者想理解“一个请求进来框架到底做了什么”微框架的源码量刚好适合通读。1.2 名字背后那套“极简生存”哲学Caveman 这个名字不是随便起的。它想表达的是当你要在沙地里快速钻木取火你不会去研究怎么建一栋带中央空调的别墅。很多框架把”最佳实践”打包成约定这本身是好事但对小场景而言反而是认知负担。Caveman 刻意砍掉了所有“你以后可能会用到”的功能只留下“你当前绝对要用”的东西。这背后其实是一组很有意思的取舍框架的核心职责是把你从 HTTP 协议的细节里解放出来而不是替你决定业务怎么组织。我在实际使用中最明显的感受是它的目录结构完全可以自己定。你习惯把控制器放app/Controllers/可以你习惯直接写在logic/里也可以框架不拦你。不过要说明的是这同时意味着你自己得承担工程规范的责任。我实际体验下来一旦业务逻辑超过十个路由还是有必要主动做好分层否则会把入口文件写成一团乱麻。后文我给的示例就是兼顾了“框架极简”和“自己约束”的一种折中方案。2. 核心机制拆解路由、请求与分发到底怎么转2.1 前端控制器所有流量先过 index.php 这一个口Caveman 和绝大多数现代 PHP 框架一样采用前端控制器模式。所谓前端控制器就是一个单一的入口脚本index.php所有请求越过 Web 服务器之后统一进这个文件。为什么不用静态页面直接对应文件因为路由规则需要在 PHP 代码里动态匹配否则每个 URL 都得建一个物理文件项目一多就完全没法维护。这里有个关键配置点和踩坑点Web 服务器必须能把请求转发到index.php。如果你用 Nginx典型配置长这样location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass 127.0.0.1:9000; }如果你用 Apache则通过.htaccess配合mod_rewrite实现。我在本地用的是内置服务器最省事的方式是执行php -S localhost:8080 index.php这样做的意义在于不管用户访问/task/list还是/task/delete/12请求都会落到同一个入口脚本。Caveman 拿到$_SERVER[REQUEST_URI]和请求方法之后通过它内部的路由表来决定调用哪一段代码。2.2 路由注册与参数提取闭包是它的灵魂Caveman 最核心的使用姿势就是注册路由。我的理解是它把 HTTP 动词和 URL 规则绑定到一个可调用对象上而这个可调用对象负责返回响应内容。一个最小的例子是这样?php require vendor/autoload.php; use Caveman\Core\App; $app new App(); $app-get(/, function () { return Hello, Caveman!; }); $app-run();开阔一点看这个设计有两个好处。第一入口即文档所有可访问的 URL 都集中写在入口文件中看到路由代码就等于看到了整个应用的 API 面。第二类型强制弱闭包里你爱怎么写怎么写返回字符串也行返回 JSON 也行框架不强制你实现某种 Response 对象。再说参数提取。URL 里的动态部分用大括号占位$app-get(/user/{id}, function ($id) { return 用户编号 . htmlspecialchars($id); });Caveman 内部会做两件事匹配路径模板和填充参数。这里的实现思路是路由规则编译成正则然后把捕获到的分组按顺序传入闭包。因此我可以大胆地说底层原理并不复杂但接口做得足够顺手。2.3 请求方法判断与响应返回我尝试过给同一个 URL 注册 GET 和 POST 两种行为这算是微框架的基本功。Caveman 的写法是$app-get(/task, function () { return 获取任务列表; }); $app-post(/task, function () { return 创建新任务; });值得一提的细节是路由匹配是区分 HTTP 方法的。如果请求方法不匹配框架会直接返回 405。这提醒我在设计接口时要提前规划好同 URL 不同动词对应的语义跟 REST 风格非常契合。响应的返回也走极简路线。直接return字符串框架原样输出return数组或对象时我习惯手动转成 JSON$app-get(/task/list, function () { $data [[id 1, name 写周报]]; header(Content-Type: application/json; charsetutf-8); return json_encode($data, JSON_UNESCAPED_UNICODE); });以后封装一个小的响应辅助函数就能统一 JSON 格式。这也是我在实操第 4 章会做的事情。3. 第一次实操从零搭一个 Caveman 应用3.1 依赖安装与目录结构Caveman 用 Composer 管理依赖安装很直接composer create-project clevermike/caveman-app my-caveman-demo cd my-caveman-demo composer install目录结构我不建议照搬任何人的骨架但一个我实践下来舒服的最小结构是这样的my-caveman-demo/ ├── composer.json ├── vendor/ ├── index.php # 前端控制器所有入口 ├── routes.php # 路由集中注册 ├── app/ │ ├── Controllers/ # 控制器类 │ ├── Middleware/ # 中间件可选 │ └── Views/ # 视图模板 └── storage/ # 日志、缓存等我在index.php里做的事情只有三件加载 Composer 自动加载器、引入路由文件、启动应用。把所有路由塞进一个独立文件比全部堆在入口里清爽得多也方便后面做拆分。3.2 入口文件与 Web 服务器重写规则入口文件我写得非常克制全程没有多余逻辑?php require __DIR__ . /vendor/autoload.php; $app require __DIR__ . /routes.php; // 如果你需要在这里注册全局中间件也可以 // $app-addMiddleware(\App\Middleware\CorsMiddleware::class); $app-run();而routes.php负责创建 App 对象并返回它。注意这里不是启动应用而是把配置好的应用返回出去。把“配置”和“启动”分离有两个好处后续跑单元测试时可以直接拿到$app来模拟请求另外入口文件保持足够薄别人阅读时一眼就能看懂项目怎么跑起来的。服务器重写规则我用的是上文那段 Nginx 配置。实际部署时最容易忽略的一点是如果你的项目放在子目录里比如htdocs/my-caveman-demo/那么try_files里的规则和路由里的基础路径要保持一致。比如访问http://localhost/my-caveman-demo/task/list如果try_files没有正确 rewrite请求会变成 404这属于环境排查的第一步。3.3 第一个路由闭包直接返回 HTML配置好环境后我写了第一个真正意义上的页面路由。先定义一个简单的 HTML 返回$app-get(/hello/{name}, function ($name) { $name htmlspecialchars($name, ENT_QUOTES, UTF-8); return h1你好{$name}/h1p这里是 Caveman。/p; });这里我要强调一个容易被忽略的点闭包返回值如果是字符串框架会原样输出但如果你拼接用户输入一定要先做编码转换。不转义就输出用户参数等于手动开了一个反射型 XSS 的口子。不要让框架的“极简”替你的安全意识背锅。访问http://localhost:8080/hello/张三页面上会显示中文昵称而且不会有乱码。这里还有个小细节源代码里最好显式设置字符集header(Content-Type: text/html; charsetutf-8);我第一次没加这行时中文在部分浏览器里会出现编码猜测问题虽然大多数现代浏览器默认按 UTF-8 处理但加上这条响应头是零成本的习惯。3.4 视图模板与数据传递随着页面内容增多直接在闭包里拼 HTML 会迅速失控。我的做法是让路由返回视图渲染结果。Caveman 本身没有内置强大的模板引擎但原生 PHP 本身就足以充当模板$app-get(/task/{id}, function ($id) { $task [ id (int) $id, name 写 Caveman 使用总结, done false, ]; return render_template(task.php, $task); });然后在app/Views/task.php里直接写!DOCTYPE html html langzh-CN head meta charsetUTF-8 title任务详情/title /head body h1? htmlspecialchars($task[name]) ?/h1 p任务 ID? (int) $task[id] ?/p p状态? $task[done] ? 已完成 : 进行中 ?/p /body /html用一个简单函数render_template做变量隔离避免$task在模板外被意外覆盖function render_template(string $template, array $data []): string { $path __DIR__ . /app/Views/ . $template; ob_start(); extract($data, EXTR_SKIP); require $path; return ob_get_clean(); }为什么用ob_start()因为require默认会直接输出内容我们需要把打印内容“接住”转成字符串返回。这是一种非常朴素的做法但正是 Caveman 这种极简框架的常见搭档。当然如果你项目后面规模变大我建议换成专门的模板引擎比如 Twig但这是后话。4. 实战改造把 Caveman 做成一个 JSON API 服务4.1 需求设计与路由规划静态页面试完我决定做一个更贴近实际的项目一个任务管理 API。需求不高四件事获取任务列表、查看单个任务、创建任务、删除任务。我按 REST 习惯规划路由动词路径行为GET/api/tasks返回任务列表GET/api/tasks/{id}返回单个任务POST/api/tasks创建任务body 传 nameDELETE/api/tasks/{id}删除任务为什么用 API 而不是页面因为 API 更能体现路由、方法区分、数据序列化这几个核心点。而且你开发完接口前端可以用任何框架对接实用性强。4.2 控制器类怎么组织路由闭包适合快速演示但业务逻辑一多就必须控制器化。我建了TaskController?php namespace App\Controllers; class TaskController { private array $tasks []; public function __construct() { $this-tasks [ 1 [id 1, name 完成博文, done true], 2 [id 2, name 跑通部署, done false], ]; } public function index(): string { return $this-json([data array_values($this-tasks)]); } public function show(int $id): string { if (!isset($this-tasks[$id])) { return $this-json([error 任务不存在], 404); } return $this-json([data $this-tasks[$id]]); } public function store(): string { $input json_decode(file_get_contents(php://input), true); if (empty($input[name])) { return $this-json([error name 不能为空], 422); } return $this-json([data [id 3, name $input[name], done false]], 201); } public function destroy(int $id): string { if (!isset($this-tasks[$id])) { return $this-json([error 任务不存在], 404); } unset($this-tasks[$id]); return $this-json([data true]); } private function json(array $data, int $status 200): string { http_response_code($status); header(Content-Type: application/json; charsetutf-8); return json_encode($data, JSON_UNESCAPED_UNICODE); } }4.3 JSON 响应与错误码约定上面这个控制器里我重点想说的是json这个方法。它做了三件事设置 HTTP 状态码成功是 200创建成功是 201参数错误是 422找不到资源是 404。统一响应头让客户端知道返回的是 JSON避免浏览器弹下载框或者直接显示源码。输出规范结构成功的响应包在data字段里错误的响应包在error字段里前端拿到后可以统一处理。错误码约定不是框架强制的但我在实际项目中特别在意这一点。如果每个接口错误结构都不一样联调阶段你会被前端问疯“你们的 error 到底放在哪个字段里”所以从第一天就统一化是小成本、大收益的好习惯。4.4 接口联通与自测路由注册如下?php use Caveman\Core\App; use App\Controllers\TaskController; $app new App(); $app-get(/api/tasks, App\Controllers\TaskControllerindex); $app-get(/api/tasks/{id}, App\Controllers\TaskControllershow); $app-post(/api/tasks, App\Controllers\TaskControllerstore); $app-delete(/api/tasks/{id}, App\Controllers\TaskControllerdestroy); return $app;字符串类型的路由绑定Controllermethod是框架约定它会把控制器实例化后再调用方法同时把路由参数按顺序传给方法。上面代码里show(int $id)的$id就是从 URL 里提取的。本地自测我用 curl 验证了完整流程curl -s http://localhost:8080/api/tasks curl -s http://localhost:8080/api/tasks/1 curl -s -X POST http://localhost:8080/api/tasks \ -H Content-Type: application/json \ -d {name:新建任务} curl -s -X DELETE http://localhost:8080/api/tasks/2从实际返回看结果完全符合预期。需要特别提醒的是POST 请求读取 JSON body 时必须使用php://input我见过不少新手直接$_POST[name]结果发现取不到值因为$_POST只会在application/x-www-form-urlencoded或multipart/form-data这种编码下填充。用php://inputjson_decode才稳妥。5. 踩坑实录与避坑清单5.1 Nginx 重写规则导致的 404我第一次把代码部署到 Nginx 环境时访问/正常但访问/api/tasks直接 404。排查一圈后发现问题不是出在框架里面而是location /的try_files没有落到index.php。当时的配置是try_files $uri $uri/ /index.php;看起来没问题但$query_string没接上导致路由脚本拿不到参数。修正为try_files $uri $uri/ /index.php?$query_string;这个问题我想特别强调遇到 404 先把服务器重写规则排除再去看框架代码。否则容易找错方向浪费大量时间。5.2 闭包引用外部变量失败我在一个路由里想用use导入外层变量$config [app_name Task Service]; $app-get(/info, function () use ($config) { return json_encode($config); });这本身没问题问题是use是在定义闭包时就拷贝了变量值。如果之后我修改了$config闭包里的值不变。这个行为算不上坑但它提醒我配置项最好通过容器或专门的对象传递不要依赖闭包的变量拷贝机制。否则调试的时候你会看到“明明改了配置接口返回还是旧值”的诡异现象。5.3 路由顺序导致的匹配覆盖Caveman 按注册顺序匹配路由所以/api/tasks/{id}这类动态路由如果注册在/api/tasks/new之前new会被当成{id}捕获。我遇到过一次前端要一个“新建任务”的动态接口结果因为顺序问题new被转成了有数字要求的参数直接 404。解决方式很简单把精确路径注册在模糊路径前面。经验上把静态路径优先注册是通用规则任何路由框架都适用。5.4 其他容易踩的小坑下面这几点我单独列个速查表遇到类似症状可以按表排查症状可能原因解决办法中文乱码响应头未指定 charset统一加Content-Type: application/json; charsetutf-8POST 参数取不到请求体是 JSON不能用$_POST改为file_get_contents(php://input)路径带子目录时 404rewrite 规则没考虑基础路径调整try_files或使用相对路由一个路由预期不匹配动态路由写在静态路由前面调整注册顺序精确路径优先控制器方法报未定义参数闭包/控制器方法参数顺序与路由参数顺序不一致检查路径模板与函数签名参数顺序6. 什么时候用 Caveman什么时候绕开它6.1 我推荐用它的情况我个人非常推荐在以下场景选择 Caveman内部工具与服务脚本式管理系统、定时任务面板、个人作品集后端不需要完整 RBAC 权限体系等企业级能力。API 快速原型要在一个小时内给客户或前端提供可调用接口Caveman 的开销几乎为零。学习路由原理因为源码精简断点跟读入口文件、路由分发、参数绑定整个链路是理解现代框架的绝佳路径。极简部署环境有些环境对资源占用敏感动辄几百 MB 的依赖往往不划算Caveman 这类框架能把整体体积压得极低。6.2 我劝你慎重的情况反过来说遇到下面这些情况还是趁早绕开条件为什么不要用 Caveman团队已有 Laravel 规范引入新框架等于引入新的技术债务需要队列、事件、权限内置Caveman 没有你需要自己造团队新手多且无微框架经验自由度过高时反而容易写出不可维护代码项目生命周期预期超过两年那时你的诉求大概率会超过框架能力范围这不是说 Caveman 不好而是每种框架都有自己适配的生态位。它就像一个灵巧的折叠刀户外露营够用但你要开着它去伐木开山那属于工具选型失误。我在实际项目中体会到技术选型本质上是“匹配问题复杂度”的艺术。很多时候我们不是被框架难住了而是被框架带来的复杂度压垮了。Caveman 的存在提醒我一个原始、直接、只讨好核心需求的工具反而能让人把注意力放回真正的项目逻辑上。以后再有快速原型的需求我大概率还会优先想起这个小家伙。也建议你手里永远备着一个这样趁手的小框架关键时刻能帮你省下不少折腾配置的时间。