3、分层原则:依赖倒置原则、接口隔离原则在分层中的应用
说到分层架构,很多人第一反应就是「Controller -> Service -> DAO」这种三层结构。但说实话,光会分层远远不够。真正让分层架构活起来的,是两条设计原则——依赖倒置原则和接口隔离原则。
我刚开始带团队那会儿,就吃过不懂这两条原则的亏。代码写出来,上层依赖下层,下层又依赖更下层,改一个底层接口,上面全得跟着改。那叫一个痛苦。后来我才明白,分层架构的核心不是「分」,而是「如何分」以及「如何让层之间优雅地协作」。
3.1 依赖倒置原则:让高层不再依赖低层
依赖倒置原则(DIP)说的是什么?说白了就是:高层模块不应该依赖低层模块,两者都应该依赖抽象。
你想想看,传统的分层是自上而下的依赖。业务层依赖数据访问层,数据访问层依赖数据库。这看起来没毛病,但一旦数据库换了,或者数据访问方式变了,业务层就得跟着改。这就是典型的「高层被低层绑架」。
我在一个电商项目中遇到过这种情况。一开始用的 MySQL,后来业务量上来要切分库分表,底层 DAO 全得重写。结果呢?业务层也跟着改了一大片。为什么?因为业务层直接 new 了 DAO 的实现类,耦合死了。
正确的做法是什么?让业务层依赖接口,而不是具体实现。来看个例子:
// 不好的做法:业务层直接依赖具体实现
public class OrderService {
private MySqlOrderDao orderDao = new MySqlOrderDao();
public void createOrder(Order order) {
orderDao.save(order);
}
}
// 好的做法:依赖抽象接口
public class OrderService {
private OrderRepository orderRepo;
// 通过构造器注入,具体实现由外部决定
public OrderService(OrderRepository orderRepo) {
this.orderRepo = orderRepo;
}
public void createOrder(Order order) {
orderRepo.save(order);
}
}
// 抽象接口
public interface OrderRepository {
void save(Order order);
Order findById(Long id);
}
// 具体实现
public class MySqlOrderRepository implements OrderRepository {
public void save(Order order) { /* MySQL 实现 */ }
public Order findById(Long id) { /* MySQL 实现 */ }
}
核心要点:依赖倒置让「控制权」反转了。不再是高层决定用哪个低层实现,而是由外部(比如依赖注入容器)来决定。这样一来,高层模块变得稳定,低层模块可以随意替换。
嗯,这里要注意一点。依赖倒置不是说所有地方都要用接口。我个人习惯是:跨层的依赖必须用接口,同层内部的依赖可以灵活处理。比如 Service 调用另一个 Service,如果它们属于同一层,直接用具体类也没问题。
3.2 接口隔离原则:别让调用方承担不必要的负担
接口隔离原则(ISP)听起来有点绕,其实意思很简单:客户端不应该被迫依赖它不需要的接口方法。
我举个例子你就明白了。假设你有一个「用户管理」的接口,里面包含了增删改查、导出报表、发送邮件、权限校验……十几个方法。然后你的「用户查询服务」只需要查数据,却要依赖这个臃肿的接口。这就是典型的接口污染。
我曾经接手过一个遗留系统,一个 Service 接口里塞了 30 多个方法。每次改接口,所有实现类都得跟着改,哪怕只改了一个方法签名。那感觉,就像你只是想去楼下买个早餐,结果被拉着参加了一整天的会议。
接口隔离原则在分层中的应用,主要体现在两个地方:
- 层与层之间的接口要小而专:每个接口只暴露该层对外提供的核心能力
- 不同调用方使用不同接口:比如「读操作」和「写操作」可以拆成两个接口
来看一个对比:
// 不好的做法:一个接口包罗万象
public interface UserService {
User findById(Long id);
List<User> search(String keyword);
void createUser(User user);
void updateUser(User user);
void deleteUser(Long id);
void exportReport(String format);
void sendWelcomeEmail(Long userId);
boolean checkPermission(Long userId, String permission);
}
// 好的做法:按职责拆分成多个小接口
public interface UserQueryService {
User findById(Long id);
List<User> search(String keyword);
}
public interface UserCommandService {
void createUser(User user);
void updateUser(User user);
void deleteUser(Long id);
}
public interface UserReportService {
void exportReport(String format);
}
我的经验:接口拆分的粒度,以「调用方需要的最小集合」为准。如果一个调用方只需要查数据,那就只给它暴露查询接口。别怕接口多,接口多了反而清晰。
3.3 两条原则如何协同工作
依赖倒置和接口隔离,其实是相辅相成的。依赖倒置解决了「依赖方向」的问题,接口隔离解决了「依赖粒度」的问题。
你想想看,如果只有依赖倒置,没有接口隔离,那高层依赖的抽象接口可能还是又大又全的。调用方明明只需要一个方法,却要依赖整个接口,这就不符合接口隔离原则了。
反过来,如果只有接口隔离,没有依赖倒置,那接口拆得再细,高层还是直接依赖低层的具体实现,换实现的时候照样得改代码。
所以我的建议是:在分层架构中,这两条原则要一起用。用依赖倒置确定依赖方向,用接口隔离确定接口粒度。
| 原则 | 解决的问题 | 在分层中的应用 |
|---|---|---|
| 依赖倒置原则 | 高层依赖低层,导致耦合 | 层间依赖使用接口抽象,具体实现由外部注入 |
| 接口隔离原则 | 接口过于臃肿,调用方被迫依赖不需要的方法 | 按职责拆分接口,每个接口只提供最小能力集 |
避坑指南:我曾经见过一个团队,为了「完美」地应用这两条原则,把接口拆到了极致。一个查询操作拆了五六个接口,每个接口只有一个方法。结果呢?代码变得极其碎片化,新人根本看不懂。记住,原则是工具,不是教条。适度拆分,够用就好。
3.4 实战中的常见误区
说了这么多理论,咱们聊聊实战中容易踩的坑。
误区一:所有层都用接口
有些人觉得,既然依赖倒置好,那所有层之间都用接口吧。其实没必要。我个人习惯是:只对「可能变化」的依赖使用接口。比如数据访问层,因为数据库可能换,所以用接口。但工具类、常量类这些,直接用具体类就行。
误区二:接口设计过于通用
「我这个接口要设计得通用一点,以后什么场景都能用。」——这种想法很危险。通用接口往往意味着大而全,反而违反了接口隔离原则。我建议:先满足当前需求,再考虑扩展。不要为了「未来可能的需求」去设计接口。
误区三:忽略依赖注入
依赖倒置原则的实现,离不开依赖注入。如果你还在用 new 关键字创建依赖对象,那依赖倒置就无从谈起。Spring 的 IoC 容器、Guice 这些工具,就是帮你做这件事的。
总结一下:依赖倒置原则让分层架构的依赖方向变得合理,接口隔离原则让接口粒度变得合适。两者结合,才能做出真正灵活、可维护的分层架构。我在项目中反复验证过,这两条原则用好了,后期改需求、换技术栈,成本能降低 50% 以上。
好了,这一章就聊到这里。下一章咱们聊聊分层架构中的「边界」问题——什么时候该拆层,什么时候该合并。这也是个容易踩坑的地方。