分层拆到一半发现"伪干净":我是怎么在 controller 里偷偷塞了 SQL,又把业务规则埋进 migration 的
之前跟风搞分层,文件夹倒是挺漂亮:controllers/、services/、models/ 整整齐齐。结果 code review 时被同事怼了一句——"你这 controller 里怎么还有 `get_results`?" 我才意识到,分层不是文件夹游戏,是认知负荷的重新分配。
先说我的"翻车现场"。当时写个会员积分插件,controller 长这样:
// 伪干净的 controller
public function handle_export() {
$user_id = get_current_user_id();
// 这里!我以为只是"组装条件"
$sql = "SELECT * FROM {$this->table} WHERE user_id = %d";
if ( ! current_user_can( 'manage_options' ) ) {
$sql .= " AND status = 'approved'"; // 业务规则逃逸
}
$results = $this->wpdb->get_results( $this->wpdb->prepare( $sql, $user_id ) );
wp_send_json( $results );
}
问题在哪?status = 'approved' 这个过滤条件,表面是查询优化,实际是权限相关的业务规则。它本该在 service 层决定"谁能看见什么",结果因为"就一行 SQL"的侥幸心理,直接埋进了 controller 的字符串拼接里。
更隐蔽的是 migration 文件。我习惯把"初始数据规则"写进 dbDelta 之后的填充逻辑:
// migration v1.0.1.php
$default_rules = [
[ 'type' => 'daily_login', 'points' => 10, 'limit' => '1/day' ],
[ 'type' => 'post_published', 'points' => 50, 'limit' => '5/day' ],
];
// 直接 INSERT ... 规则写死
后来产品说"不同会员等级积分系数不同",我才发现 migration 里的 points 是裸数值,没有关联任何等级体系。migration 成了"不可触碰的历史遗迹",新规则只能叠补丁。
现在我拆三层的底线是"如果改这个文件,需要知道多少上下文":
Controller:只认识 HTTP,不认识数据库
它的工作就三件:收参数、调 service、决定响应格式。我强制自己用 WP_REST_Request 的 schema 做第一道过滤,controller 里不允许出现表名、字段名、SQL 关键字。
public function get_items( $request ) {
// 只干这个:把 HTTP 方言翻译成业务方言
$filters = [
'user_id' => absint( $request['user_id'] ),
'status' => in_array( $request['status'], [ 'all', 'pending', 'approved' ], true )
? $request['status']
: 'approved',
'page' => max( 1, absint( $request['page'] ) ),
];
// 权限检查交给 middleware/hook,不在这里混着
$result = $this->service->list_records( $filters );
return rest_ensure_response( $result );
}
Service:只认识业务规则,不认识 HTTP 和 SQL
关键突破是引入 Query Specification 对象,让 service 描述"要什么",而不是"怎么查"。
// service 层:说人话
public function list_records( array $filters ): array {
// 业务规则:非管理员只能看自己的
if ( ! $this->user_can( 'view_others_records' ) ) {
$filters['ownership'] = 'self';
}
// 业务规则:pending 超过 7 天自动转 approved
$this->auto_approve_stale_records();
// 组装查询规格,扔给 repository
$spec = new RecordQuerySpec( $filters );
$spec->with( 'user' )->order_by( 'created_at', 'desc' );
return $this->repository->match( $spec );
}
注意 auto_approve_stale_records 这个副作用。以前我会把它塞在 cron 或者某个角落,现在明确放在 service 的读操作入口——"读的时候顺便把该修的修了",比 hidden cron 好找得多。
Model/Repository:只认识存储,不认识业务
我放弃了 Active Record 模式($record->save() 太容易藏逻辑),改用纯 Repository。WordPress 里没有 Doctrine,就手搓一个轻量的:
class RecordRepository {
private string $table;
public function match( RecordQuerySpec $spec ): array {
$qb = new QueryBuilder( $this->table );
// 把 spec 翻译成 SQL,这里允许出现字段名
$spec->apply_to( $qb );
return $this->wpdb->get_results( $qb->to_sql(), ARRAY_A );
}
public function add( RecordEntity $entity ): int {
// 只负责映射 entity 到数据库行,不判断"能不能加"
return $this->wpdb->insert( /* ... */ );
}
}
Migration 的"去毒"处理
现在 migration 只建结构,所有可变的业务初始值拆到 DataSeeder,并且 seeder 本身调用 service:
// migration:纯结构
"CREATE TABLE {$table} (
id bigint unsigned NOT NULL auto_increment,
type varchar(20) NOT NULL,
base_points int NOT NULL,
-- 没有默认值,没有规则硬编码
PRIMARY KEY (id)
)";
// seeder:走业务入口
$service->create_rule( [
'type' => 'daily_login',
'base_points' => 10,
'limits' => [ 'frequency' => '1/day' ],
] );
// 如果以后"积分"改成"经验",只改 service 里的映射
一个自检问题清单
我现在合代码前会问:
- 如果明天把 MySQL 换成 MongoDB,哪些文件要改?(理想:只有 repository)
- 如果产品说"导出功能要加 PDF 格式",controller 会不会动?(理想:只加格式判断,不调新服务)
- 如果某个积分规则被判违规,migration 里的历史数据会不会让我不敢删字段?
分层最坑的不是"没拆",是"拆了但拆得自欺欺人"。文件夹改名容易,真正难的是每次想偷懒写 $wpdb-> 的时候,忍住去想想这句话到底属于哪个上下文。
你们有没有类似"看起来分了其实没分"的代码?比如 model 里调 wp_mail、service 里读 $_GET 之类的,欢迎丢出来一起疼。

