15、MVC中的依赖注入:解耦的艺术

依赖注入(Dependency Injection,简称DI),说白了就是「把组件需要的依赖,从外部塞给它,而不是让它自己 new 出来」。这个概念在 MVC 里特别重要,因为它直接决定了你的代码是「一坨屎山」还是「可维护的乐高积木」。

我刚开始写 MVC 项目时,也踩过这个坑。Controller 里直接 new Service,Service 里直接 new Repository,结果项目一大了,改一个地方要牵连七八个文件。嗯,后来我才明白——依赖注入就是用来解决这个问题的。

为什么 MVC 需要依赖注入?

先看一个反面教材。假设我们有一个用户注册功能:

// 没有依赖注入的写法
class UserController {
    private $userService;

    public function __construct() {
        // 直接在构造函数里 new 依赖
        $this->userService = new UserService();
    }

    public function register($data) {
        return $this->userService->createUser($data);
    }
}

class UserService {
    private $userRepo;
    private $emailService;

    public function __construct() {
        $this->userRepo = new UserRepository();
        $this->emailService = new EmailService();
    }

    public function createUser($data) {
        $user = $this->userRepo->save($data);
        $this->emailService->sendWelcome($user);
        return $user;
    }
}

这段代码有什么问题?问题大了去了。

  • 耦合太紧:UserController 和 UserService 绑死了,没法替换实现。
  • 测试困难:你想单元测试 UserController,结果它连数据库、发邮件都一起跑了。
  • 扩展性差:想换个 UserService 实现?得改 Controller 的构造函数。

我在项目中遇到过类似的情况。有一次要加个「用户注册后发送短信」的功能,结果发现 EmailService 被硬编码在十几个地方。改起来简直想骂人。从那以后,我强制团队所有新代码必须用依赖注入。

依赖注入的核心思想

依赖注入其实就三个角色:

角色 说明 类比
服务(Service) 被依赖的组件,比如数据库、邮件服务 螺丝刀
客户端(Client) 使用依赖的组件,比如 Controller、Service 修理工
注入器(Injector) 负责创建并传递依赖的容器 工具箱

你想想看,修理工(客户端)不需要自己造螺丝刀(服务),工具箱(注入器)会给他配好。这就是依赖注入的朴素思想。

三种注入方式

依赖注入有三种常见方式,我按推荐程度排个序:

1. 构造函数注入(最推荐)

class UserController {
    private $userService;

    // 依赖通过构造函数传入
    public function __construct(UserService $userService) {
        $this->userService = $userService;
    }

    public function register($data) {
        return $this->userService->createUser($data);
    }
}

这种方式的好处是:依赖关系一目了然,对象创建时就必须提供所有依赖,不会出现「用到一半发现没初始化」的尴尬。

我的习惯:能用构造函数注入就别用别的。它让依赖关系显式化,代码可读性最好。

2. Setter 注入(可选依赖用)

class UserController {
    private $logger;

    // 日志不是必须的,所以用 setter
    public function setLogger(Logger $logger) {
        $this->logger = $logger;
    }

    public function register($data) {
        // 有日志就记录,没有就算了
        if ($this->logger) {
            $this->logger->info('用户注册');
        }
        // ... 业务逻辑
    }
}

适合那些「有最好,没有也能跑」的依赖。比如日志、缓存、监控等。

3. 接口注入(框架层面用)

interface LoggerAwareInterface {
    public function setLogger(Logger $logger);
}

class UserController implements LoggerAwareInterface {
    private $logger;

    public function setLogger(Logger $logger) {
        $this->logger = $logger;
    }
}

这种方式在框架层面用得比较多,比如 Symfony 的组件就大量使用接口注入。普通业务代码不太需要。

依赖注入容器(DIC)

手动注入依赖太累了。一个项目几十个 Service,每个都要 new 一遍?所以我们需要依赖注入容器。

容器就是一个「中央工厂」,它知道如何创建所有对象,并且自动把依赖传递进去。

// 一个简单的容器示例
class Container {
    private $bindings = [];
    private $instances = [];

    // 注册一个服务
    public function set($id, $factory) {
        $this->bindings[$id] = $factory;
    }

    // 获取一个服务
    public function get($id) {
        if (!isset($this->instances[$id])) {
            $this->instances[$id] = call_user_func($this->bindings[$id], $this);
        }
        return $this->instances[$id];
    }
}

// 使用容器
$container = new Container();

// 注册依赖
$container->set('UserRepository', function($c) {
    return new UserRepository($c->get('Database'));
});

$container->set('UserService', function($c) {
    return new UserService($c->get('UserRepository'));
});

$container->set('UserController', function($c) {
    return new UserController($c->get('UserService'));
});

// 获取 Controller,所有依赖自动注入
$controller = $container->get('UserController');
核心要点:容器只负责「如何创建对象」,不负责「业务逻辑」。它就像一个智能的工厂,你告诉它需要什么,它帮你组装好。

依赖注入在 MVC 中的典型应用

下面这张图展示了依赖注入在 MVC 中的完整流程:

MVC 依赖注入流程 依赖注入容器 注入 Controller 调用 Service 调用 Repository 容器负责创建所有对象 并自动注入依赖 Controller 只关心请求处理 不关心 Service 怎么来的 Service 只关心业务逻辑 不关心 Repository 怎么来的 Repository 只关心数据存取 不关心数据库连接怎么来的

你看,每一层都只关心自己的事。Controller 不需要知道 UserService 是怎么创建的,它只需要「拿到一个 UserService 实例」就行了。这就是解耦。

避坑指南

我曾经犯过的错:过度使用依赖注入。把每个小工具类都注册到容器里,结果容器变得臃肿不堪。后来我总结了一个原则——「只有那些需要被替换、被测试、被复用的组件,才值得注入」。像工具函数、常量类这些,直接静态调用反而更清晰。

另外还有几个常见坑:

  • 循环依赖:A 依赖 B,B 依赖 A。容器会死循环。解决办法是拆出公共接口,或者用 setter 注入打破循环。
  • 过度抽象:每个类都搞个接口,结果接口比实现还多。我建议只对「可能变化」的部分抽象,比如数据库驱动、缓存驱动、第三方服务。
  • 容器滥用:把容器当作 Service Locator 到处传。正确的做法是:只在入口处(比如路由分发)使用容器,业务代码里只通过构造函数接收依赖。

实际项目中的最佳实践

以我最近做的一个电商项目为例,我们是这样组织依赖注入的:

// 1. 定义接口(契约)
interface OrderRepositoryInterface {
    public function findById($id);
    public function save(Order $order);
}

// 2. 实现接口
class MysqlOrderRepository implements OrderRepositoryInterface {
    private $db;
    public function __construct(PDO $db) {
        $this->db = $db;
    }
    // ... 实现方法
}

class RedisOrderRepository implements OrderRepositoryInterface {
    private $redis;
    public function __construct(Redis $redis) {
        $this->redis = $redis;
    }
    // ... 实现方法
}

// 3. 在容器中绑定接口到具体实现
$container->set('OrderRepositoryInterface', function($c) {
    // 根据配置决定用 MySQL 还是 Redis
    if (config('use_redis')) {
        return new RedisOrderRepository($c->get('Redis'));
    }
    return new MysqlOrderRepository($c->get('PDO'));
});

// 4. Service 只依赖接口
class OrderService {
    private $orderRepo;
    public function __construct(OrderRepositoryInterface $orderRepo) {
        $this->orderRepo = $orderRepo;
    }
    // ... 业务逻辑
}

这样做的好处是:

  • 切换数据源只需要改容器配置,业务代码一行不动
  • 单元测试时,可以注入 Mock 对象,不用连真实数据库
  • 团队协作时,每个人负责自己的模块,接口约定好就行
一个小技巧:在测试中,我经常用 PHPUnit 的 getMockBuilder 创建模拟对象,然后通过构造函数注入到被测试类中。这样测试跑得飞快,而且不依赖外部环境。

依赖注入不是银弹,但它确实是 MVC 架构中解耦的利器。说白了,它让我们的代码从「硬编码的钢筋混凝土」变成了「可插拔的乐高积木」。你想想看,当你的项目从几千行膨胀到几十万行时,这种灵活性有多重要。

嗯,关于依赖注入在 MVC 中的应用,今天就聊到这里。记住一个核心原则:依赖从外部来,不要自己造。你的代码会感谢你的。


公众号:蓝海资料掘金营,微信deep3321