controller 里塞了 800 行业务逻辑后,我终于决定把"分层"当成还债来搞
之前写插件图快,controller 里直接怼 SQL、拼 HTML、算积分、发邮件,一个文件干到 800 行。某天需求改"积分规则支持按版块加权",我翻了两小时才找对位置,当场决定还债——把 controller / service / model 拆开。记录一下我的拆分思路,主要是 WordPress 插件场景下的落地姿势。
一、model 层:只干"存取",不碰业务语义
我现在的 model 全是静态方法,一个类对应一张表或一个 meta 命名空间。比如积分流水:
class CreditLogModel {
public static function insert(array $data): int {
global $wpdb;
$wpdb->insert("{$wpdb->prefix}my_credit_log", [
'user_id' => absint($data['user_id']),
'action' => sanitize_key($data['action']),
'amount' => floatval($data['amount']),
'post_id' => absint($data['post_id'] ?? 0),
'created_at' => current_time('mysql'),
]);
return (int) $wpdb->insert_id;
}
public static function get_by_user(int $user_id, int $limit = 20): array {
global $wpdb;
// 纯查询,不带任何"积分是否有效"的判断
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM ... WHERE user_id = %d ORDER BY id DESC LIMIT %d",
$user_id, $limit
), ARRAY_A);
}
}
关键纪律:model 返回原始数据,不封装"用户当前总积分"这种需要聚合计算的字段。曾经我把 get_total_credit 也塞进来,结果后来要加"冻结积分"概念时,model 里到处改,很脏。
二、service 层:业务规则的"垃圾场",但要有边界
service 是我以前最混乱的地方,现在给自己定了两条红线:
- service 可以调多个 model,但不直接输出 HTML、不读
$_POST、不操作全局状态 - 复杂业务拆"领域 service",别搞万能
PluginService
以发帖加积分为例,拆成 CreditRuleService(算分)和 CreditTransactionService(执行):
class CreditRuleService {
public static function calc_post_reward(int $user_id, int $forum_id): float {
$base = apply_filters('my_credit_post_base', 10.0, $forum_id);
$multiplier = get_option("my_credit_forum_{$forum_id}_multiplier", 1.0);
// 日发帖上限检查——这里只算"本次应得",不扣减
$today_count = CreditLogModel::count_today_by_action($user_id, 'post');
if ($today_count >= 5) {
return 0.0;
}
return $base * $multiplier;
}
}
class CreditTransactionService {
public static function grant_for_post(int $user_id, int $post_id, int $forum_id): ?int {
$amount = CreditRuleService::calc_post_reward($user_id, $forum_id);
if ($amount $user_id,
'action' => 'post',
'amount' => $amount,
'post_id' => $post_id,
]);
UserMetaModel::increment($user_id, 'my_credit_total', $amount);
do_action('my_credit_granted', $user_id, $amount, $log_id);
return $log_id;
}
}
grant_for_post 里我故意没做"是否重复奖励"的检查——那是调用方(controller 或 hook 回调)的责任。service 保证"调用一次执行一次",幂等控制上移,避免层与层之间互相猜疑。
三、controller / hook 回调:只负责"接线"和"格式转换"
controller 越来越薄,核心工作就三个:验权、洗数据、调 service、转格式。
class PostHookController {
public static function on_publish(int $post_id, \WP_Post $post): void {
if ($post->post_type !== 'forum_topic') {
return;
}
$user_id = (int) $post->post_author;
$forum_id = (int) get_post_meta($post_id, 'forum_id', true);
// 鉴权/状态检查在 controller 层做
if (! user_can($user_id, 'publish_topics')) {
return;
}
// 防重:用 post_meta 当简易幂等键
if (get_post_meta($post_id, '_credit_granted', true)) {
return;
}
$log_id = CreditTransactionService::grant_for_post($user_id, $post_id, $forum_id);
if ($log_id) {
update_post_meta($post_id, '_credit_granted', $log_id);
}
}
}
// 接线
add_action('publish_forum_topic', [PostHookController::class, 'on_publish'], 10, 2);
AJAX 或 REST 的 controller 同理,只是最后多一步 wp_send_json。我坚决不在 controller 里写 $wpdb->query,哪怕只是一行简单的更新。
四、拆完后的意外收获:测试和 hook 都变得清爽
以前想写单元测试,controller 里一堆 wp_die 和全局函数,根本跑不起来。现在 model 用 Brain\Monkey mock $wpdb,service 纯 PHP 逻辑直接跑,controller 测个"是否调了对的 service 方法"就行。
还有个副作用:do_action 的埋点位置变清晰了。以前业务散落在 controller 各处,hook 名起得随心所欲。现在 service 层固定抛 my_credit_{before|after}_{action},其他开发者接二次开发时不用翻我源码。
五、还没想好的地方
Query 层怎么拆?WordPress 的 WP_Query 和 get_posts 算 model 还是 service?我目前的妥协是:简单查询直接让 controller 调 WP_Query,复杂统计(比如"用户近30天积分趋势")包成 CreditReportService 里的方法,内部再调 model。感觉这个边界还会随着需求变,先记下来。
你们插件里的分层是怎么落地的?有没有被 WordPress 的全局函数和 hook 系统搞到被迫"破层"的时候?

