controller 里塞了 200 行 SQL、service 沦为"传话筒":我把插件重构三次后才摸到的分层底线
第一次拆插件的时候,我信心满满:搞个 controller/、service/、model/ 三个文件夹,名字一贴,专业感拉满。结果三个月后翻代码,controller 里躺着 180 行的复杂查询,service 只是 return $this->model->get_xxx(),model 倒是干净——因为所有脏活都被 controller 截胡了。
那次重构让我意识到:分层不是"文件物理分开",是"职责边界画清楚"。后来我又拆了两次,才慢慢稳住一套能跑的生产结构。这篇不聊框架,只聊 WordPress 插件里怎么让这三层不互相"越境"。
一、controller 的"天花板":只负责"接请求、调服务、吐响应"
我最开始的典型反例:
class Order_Controller {
public function export_csv() {
global $wpdb;
$orders = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}orders WHERE status = 'paid' AND created_at > '2024-01-01'" );
// 还要处理权限、格式转换、header 输出...
header('Content-Type: text/csv');
// 80 行后...
}
}
controller 干了四件事:鉴权、查库、业务过滤、输出格式。后来我的硬规矩:
- controller 可以
current_user_can、check_ajax_referer、wp_send_json - controller 不可以出现
$wpdb->、不可以出现业务计算、不可以出现"如果 A 状态且 B 条件则 C"的复合判断
改完后的骨架:
public function export_csv() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( '权限不足', 403 );
}
$filters = $this->sanitize_filters( $_GET ); // 只干"洗干净输入"
try {
$csv = $this->order_service->generate_csv( $filters );
wp_send_json_success( [ 'download_url' => $csv->get_url() ] );
} catch ( \Exception $e ) {
wp_send_json_error( $e->getMessage(), 500 );
}
}
controller 变"薄"之后,单元测试好写了——mock service 就行,不用搭数据库。
二、service 的"护城河":业务规则的"唯一真相源"
我踩过的第二个坑:两个 controller 都要"计算积分",一个写在 User_Controller 里,一个写在 Task_Controller 里,公式改了要两头找。
现在 service 层是我强制要求的"业务规则集中营"。比如积分计算:
class Credit_Service {
public function calculate_earn( $action, $context ) {
$base = $this->get_action_base( $action );
$multiplier = $this->get_context_multiplier( $context );
$daily_cap = $this->get_user_daily_cap( $context['user_id'] );
$raw = $base * $multiplier;
return min( $raw, $daily_cap - $this->get_today_earned( $context['user_id'] ) );
}
}
关键原则:任何"如果用户是 VIP 则双倍"这种会变的需求,必须落在 service,不能散落在 controller 或 model。model 只回答"数据是什么",service 回答"业务上这意味着什么"。
三、model 的"自闭"原则:只跟数据库说话,不跟业务逻辑说话
我现在的 model 层基本只做三件事:
- 把 service 传来的参数转成 SQL/ORM 操作
- 处理 WordPress 特有的表前缀、多站点切换、时区转换
- 返回原始数据或简单聚合,不做"业务含义解释"
举个对比。以前我的 model:
public function get_orders( $user_id ) {
// 还要判断是不是管理员、要不要看子订单...
}
现在:
public function find_by_user( int $user_id, array $status_in = [], int $limit = 20 ) {
// 参数进来是什么,就按什么查
// 权限判断?那是 service 决定"能不能查"之后的事
}
model 的方法名我也改了风格:从 get_user_valid_orders_for_export 变成 find_by_user_with_status。前者带业务意图,后者只描述数据行为。业务意图往上浮,数据行为往下沉。
四、WordPress 特有毒点:钩子注册放哪层?
这我纠结最久。现在我的做法:
- 钩子入口:plugin 主文件或 bootstrap 类,只做
add_action/add_filter的"接线" - 钩子回调:通常是 controller 的 public 方法,或者专门的
Hook_Handler做薄转发 - 绝不:在 service 或 model 里直接
add_action
为什么?因为 WordPress 钩子是"全局副作用",service 如果被钩子在半路上触发,上下文不可控。把钩子收敛到入口层,才能一眼看清"这个插件到底在哪些时机介入了系统"。
五、一个能跑的小目录,供拍砖
my-plugin/
├── my-plugin.php // 唯一入口:注册钩子、初始化容器
├── bootstrap.php // 自动加载、常量、容器绑定
├── src/
│ ├── Controller/
│ │ ├── Admin/ // wp-admin 下的页面请求
│ │ ├── Ajax/ // admin-ajax.php 端点
│ │ └── Rest/ // REST API 端点
│ ├── Service/ // 业务规则、流程编排
│ ├── Model/ // 数据访问、WordPress 封装
│ ├── Domain/ // 纯实体、值对象(可选,复杂插件才上)
│ └── Infrastructure/ // 缓存、日志、第三方 SDK 包装
└── assets/
没有 includes/ 这种"什么都往里扔"的筐,也没有 functions/ 这种过程式遗产。每个文件进去,从命名就知道该找哪层。
六、还没想清楚的:service 之间的调用链
现在我的痛点是 service A 需要 service B 的能力,直接 new 还是走容器?我目前用构造函数注入,但偶尔遇到循环依赖(Credit_Service 要 Notification_Service,后者积分变动时又要回调查前者)。暂时用事件派发解耦,但 WordPress 没有内置事件总线,自己包了一层 do_action 的面向对象壳。
这块还没跑稳定,有实践过的可以聊聊——你们 service 层互相调用是怎么防"毛线球"的?