ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java设计模式六大原则:从概念到实战的系统解析

Java设计模式六大原则:从概念到实战的系统解析 如果你是一个Java开发无论工作了几年肯定都绕不开“设计模式”这四个字。而设计模式的骨架就是那六条被反复提及的规则——单一职责、开闭、里氏替换、接口隔离、依赖倒置和最少知识法则。很多人把这六条当成面试前的八股文背得滚瓜烂熟但真到写代码的时候还是控制不住在一个类里塞上一千行逻辑。问题不在记不住而在于没有想清楚这些原则到底在约束什么。这篇内容适合两类人一类是准备Java开发面试的需要把六条原则讲出深度而不只是念概念因为他们常常在反问环节暴露出死记硬背的问题另一类是在实际项目里已经感觉到代码越写越乱、改一个需求牵连一片的开发者。全文不会只列定义我会结合真实的业务场景和可运行的Java代码把每条原则的边界、拆解思路和最容易踩的坑说透。1. 六大原则到底在管什么六大原则不是凭空发明的口号它们都在回答同一个问题当需求变化时究竟应该让哪部分代码跟着变哪部分代码应该纹丝不动。设计模式之所以有二十多种本质上是把这种回答固化成了常见的结构套路。1.1 先分清原则和模式的层级关系很多初学者容易把原则和模式搞混觉得学了策略模式就能应对变化了学了工厂模式就算懂设计了。其实两者根本不在一层原则是判断标准它告诉你什么样的设计是好的方向比如“当新增功能时尽量不要修改已有代码”模式是具体方案它告诉你某个典型场景下该怎么组织类才能达成这个方向比如用策略模式消除if-else原则是纲领模式是打法。同一个模式在不同项目里落地时代码差异会很大因为约束你的原则可能不一样。这也是为什么面试官最后总会问一句“你用过哪些设计模式”。他们真正想知道的往往不是你会背几个类图而是你有没有用过模式去满足某条原则。1.2 六条原则之间的隐含关系表面看六条原则各自独立实际可以分为三组分组原则约束核心类的职责边界单一职责原则SRP一个类只有一个变化原因扩展与继承开闭原则OCP、里氏替换原则LSP面对变化以扩展为主继承不能破坏子类替换接口与依赖接口隔离原则ISP、依赖倒置原则DIP、最少知识法则LoD依赖的方向、范围、粒度都要受到控制单一职责是最基础的判断单位但它和开闭原则并不冲突。一个类如果只承担一个维度的职责那它对某个方向的扩展就更容易做到只加代码、不改旧逻辑。里氏替换是对继承体系的约束依赖倒置则是把具体依赖反转成抽象依赖。接口隔离和最少知识法则更像两个缩小“接触面”的手段——前者把接口拆小后者把对象之间的交互范围控制住。1.3 原则管不住代码除非你有“违规嗅觉”说实话我刚开始工作时把六大原则背得很顺但代码照样写得很乱。真正让我转变的不是某一次培训而是接手一个遗留项目——改一个简单的折扣逻辑需要把人会员、仓储、营销三个模块都捋一遍。那时我才意识到原则类的东西不会在你写代码时自动生效它需要你形成一种“违规嗅觉”看到一个类超过两百行条件判断嵌套超过三层心里就开始怀疑是不是有原则被破坏了。后面几节我会把每条原则单独拆开讲但请你记住一个总前提这六条原则不是用来评判别人代码好坏的而是用来帮你自己在写代码的过程中做取舍的。2. 单一职责和开闭原则第一道防线这两条原则经常被放在一起讨论因为它们一个管“内聚”一个管“扩展”。如果一开始的边界就划错了后面所有模式都救不回来。2.1 单一职责判断“职责”的分界线到底在哪单一职责原则的经典表述是一个类应该只有一个引起它变化的原因。但实际写代码时“一个职责”太模糊了——一个方法里做两件事算不算违反一个类里有两个public方法但处理的是同一个业务算不算违反我常用的一个判断办法是给当前这个类找一个“负责人”。如果两个功能的变更来源可能是不同角色或不同业务方那它们就是不同职责。举个例子public class UserService { public void saveUser(User user) { // 校验参数 validate(user); // 写入数据库 userDao.insert(user); // 发送欢迎邮件 sendWelcomeMail(user.getEmail()); // 记录操作日志 logService.record(user.save, user.getId()); } }这个UserService表面上只是在“保存用户”但仔细拆一下它至少承担了四个职责参数校验、数据库操作、通知发送、日志记录。这些职责的变化原因完全不同——数据库表结构要改会影响它邮件模板要改也会影响它。正确的做法是把它们按变化维度拆开public class UserApplicationService { private final UserValidator validator; private final UserRepository userRepository; private final MailService mailService; private final OperationLogService logService; public void saveUser(User user) { validator.validate(user); Long userId userRepository.insert(user); mailService.sendWelcomeMail(user.getEmail()); logService.record(user.save, userId); } }这不仅仅是把一个类拆成几个类而是把变化的责任拆开了。提示单一职责不是让类里的代码变少而是让每个类都能被“独立地解释”。如果你需要同时讲三句话才能说清楚某个类是做什么的多半说明职责没有分开。2.2 开闭原则用扩展应对变化而不是反复修改开闭原则说的是“对扩展开放对修改关闭”。但这里的“关闭修改”指的不是一句代码都不能改而是你的核心业务逻辑不应该因为新功能的加入而被反复推翻重写。最典型的反例就是一堆else if不断堆积public DiscountResult calculateDiscount(Order order) { if (order.getUserType() UserType.NORMAL) { return new DiscountResult(order.getAmount(), 0); } else if (order.getUserType() UserType.VIP) { return new DiscountResult(order.getAmount(), 0.1); } else if (order.getUserType() UserType.SVIP) { return new DiscountResult(order.getAmount(), 0.2); } // 每加一种会员类型这里就要改一次 }每新增一个用户等级这段代码就要改一次而且改的时候很可能影响老用户逻辑。符合开闭原则的做法是用策略结构把扩展点稳定下来public interface DiscountStrategy { boolean supports(UserType userType); double calcDiscount(Order order); } Component public class VipDiscountStrategy implements DiscountStrategy { Override public boolean supports(UserType userType) { return userType UserType.VIP; } Override public double calcDiscount(Order order) { return order.getAmount() * 0.1; } }新增一种会员等级时只需要新增一个实现类不用动已有的策略。这可能让项目里多出几个类但换来的结构稳定性非常值。2.3 一个订单系统的实战例子把两个原则放在一个场景里看会更直观。假设我们要做订单价格汇总传统写法可能是public BigDecimal calculateTotalPrice(Order order) { BigDecimal price order.getProductPrice(); // 满减 if (order.getTotalAmount().compareTo(new BigDecimal(200)) 0) { price price.subtract(new BigDecimal(20)); } // 会员折扣 if (order.getUserId() ! null) { price price.multiply(new BigDecimal(0.9)); } // 运费 if (price.compareTo(new BigDecimal(99)) 0) { price price.add(new BigDecimal(10)); } return price; }这类代码的毛病是促销规则一变calculateTotalPrice函数就跟着变里面可能还会出现“这是临时接口绕过活动判断”之类的特判。我重构时会先把每条促销规则变成一个独立类并用一个清单列表来收集所有规则public interface PriceModifier { boolean match(Order order); BigDecimal modify(Order order, BigDecimal currentPrice); } public class FullReductionModifier implements PriceModifier { private static final BigDecimal THRESHOLD new BigDecimal(200); private static final BigDecimal REDUCTION new BigDecimal(20); Override public boolean match(Order order) { return order.getProductPrice().compareTo(THRESHOLD) 0; } Override public BigDecimal modify(Order order, BigDecimal price) { return price.subtract(REDUCTION); } }这样订单计算主逻辑就变成遍历规则、依次生效代码的稳定性和可读性都提高了而且每一条新的价格规则都有一个独立的落点。你可以视为这是后面策略模式的基础但支撑这个结构的底层逻辑其实就是开闭原则。3. 里氏替换和依赖倒置继承与抽象的底线很多Java开发从第一天起就被告知要面向接口编程。但“接口”该怎么设计、继承体系该怎样才安全其实是里氏替换和依赖倒置在管。3.1 里氏替换继承复用不是你想的那样里氏替换原则的经典表述是所有引用父类的地方都必须能透明地使用其子类对象。如果子类不能完整替换父类的能力那就说明继承体系有问题。最经典的反例就是“正方形继承长方形”public class Rectangle { protected int width; protected int height; public void setWidth(int width) { this.width width; } public void setHeight(int height) { this.height height; } public int getWidth() { return width; } public int getHeight() { return height; } } public class Square extends Rectangle { Override public void setWidth(int width) { this.width width; this.height width; } Override public void setHeight(int height) { this.width height; this.height height; } }凡是持有Rectangle对象的代码都会默认“宽度和高度是独立变化的”。当传入Square时这个假设直接被打破。比如一个方法要设置width 5; height 6;然后断言宽不等于高普通长方形完全没问题Square就炸了。解决问题的关键在于找出真正的抽象。正方形和长方形确实都是四边形但业务上它们不具备相同的“可变宽高”能力。如果业务真需要统一处理形状那应该抽出一个只代表“面积”这样的概念public interface Shape { double area(); } public class Rectangle implements Shape { private double width; private double height; public Rectangle(double width, double height) { this.width width; this.height height; } Override public double area() { return width * height; } } public class Square implements Shape { private double side; public Square(double side) { this.side side; } Override public double area() { return side * side; } }这样父类接口只承诺了“能算面积”子类都能完整兑现。很多人在面试时答里氏替换只知道“正方形不是长方形”但真正的考察点是你有没有能力为继承关系找到一个稳定的、可以被所有子类可靠满足的契约。注意里氏替换讲究“is-a”关系必须是完整的。如果你用继承只是为了省几个方法那大概率会捞到不合适的父类能力不如改用组合和接口来得干净。3.2 依赖倒置面向接口编程的真正含义依赖倒置原则的字面意思是高层模块不应该依赖低层模块两者都应该依赖抽象。抽象不应该依赖细节细节应该依赖抽象。翻译成人话就一条你的核心业务代码不要直接去new一个具体的实现类而是面向接口操作。看一个支付场景。一开始你有微信支付public class WechatPayService { public void pay(BigDecimal amount) { // 调微信接口 } } public class OrderService { private final WechatPayService wechatPayService new WechatPayService(); public void payOrder(Order order) { wechatPayService.pay(order.getPayAmount()); } }过了两周你还需要支持支付宝于是把方法改成public class OrderService { public void payOrder(Order order, String payType) { if (wechat.equals(payType)) { wechatPayService.pay(order.getPayAmount()); } else if (alipay.equals(payType)) { alipayPayService.pay(order.getPayAmount()); } } }看起来功能做了拓展但每加一个支付渠道OrderService就要跟着改。依赖倒置的做法是让上层定义一个抽象接口把具体渠道交给外部注入public interface PaymentClient { void pay(PayRequest request); } public class WechatPayClient implements PaymentClient { Override public void pay(PayRequest request) { // 微信支付逻辑 } } public class AlipayPayClient implements PaymentClient { Override public void pay(PayRequest request) { // 支付宝逻辑 } }OrderService只需要依赖PaymentClient至于容器注入的是微信还是支付宝它根本不关心。这样高层的支付订单逻辑稳定底层渠道也可以独立更换。依赖倒置和后面要说的依赖注入不是一回事但依赖注入是落地依赖倒置最常用的手段之一。3.3 中间还要防一手抽象腐烂依赖倒置原则经常被滥用表现为“不分场景、全部抽接口”。看看下面这种情况public interface UserDao { User findById(Long id); void save(User user); void update(User user); }只有一个实现类而且短期内看不出第二个实现的需求。这时候强行抽接口不能说错但会让代码多一层没有意义的间接性。依赖倒置的重点是让“容易变化的细节”和“稳定业务抽象”解耦而不是要求所有地方都套一层接口。当一个接口没有任何变化预判支撑时它更像负担不像资产。我在实际项目里的经验是至少出现两个不同的实现需求或者具备很强的测试隔离要求再抽取接口。4. 接口隔离与最少知识法则把接触面做小如果说前四层原则把控的是类与类之间关系的大方向接口隔离和最少知识法则更像是两个“做减法”的技巧。它们控制的是交互细节的颗粒度。4.1 接口隔离胖接口必须拆开接口隔离原则的经典表述客户端不应该依赖它不需要的接口方法。大白话就是一个接口不要堆满各种各样的方法不要让调用方为了用其中两个方法被迫知道其余八个方法的存在。最常见的胖接口出现在一些“全功能服务”里面public interface UserOperationService { void register(User user); void login(String username, String password); void updateProfile(User user); void changePassword(String oldPwd, String newPwd); void deleteUser(Long userId); ListUserPermission queryPermissions(Long userId); void bindPhone(Long userId, String phone); }假设注册页面只需要register和bindPhone前台登录只需要login和queryPermissions。如果都实现同一个接口那所有实现类都要被迫写一堆不相关的方法体要么throw new UnsupportedOperationException()要么空转。按接口隔离原则拆开后可以拆成一个注册接口、一个登录认证接口、一个用户管理接口。可能你会觉得这样类数量变多了但在调用端每个消费者依赖的都是一个和自己需求完全匹配的窄接口改动影响范围会明显缩小。接口隔离与单一职责的差别在于单一职责关注的是实现类内部为什么变化接口隔离关注的是外部调用者的视角——你只暴露别人实际要用的东西。4.2 迪米特法则少和陌生人说话迪米特法则也叫最少知识法则核心是一个对象应该对其他对象保持最少的了解。更具体的实现指导是在方法里不要通过链条调用去拿到一个远方的对象然后疯狂调用它。最典型的坏味道是这种order.getUser().getAddress().getCity();每一层调用都把内部细节暴露给了外部。一旦中间某一层的结构变化所有调用链都会受影响。更严重的是每个拿到中间结果的地方都可以顺手修改数据出了问题很难定位。一个符合迪米特法则的调整方式是让直接对象给你提供结果// 让 order 自己知道收货城市 order.getShippingCity(); // 或者通过专门的对象查询 fulfillmentService.getShippingCity(order);原有的链式调用说白了是“为了拿到需要的数据被迫穿越一长串陌生人”。迪米特法则不是说绝对不能调用下一个对象的方法而是尽量少和这些“间接对象”发生对话。换句话说让方法调用尽量停留在你直接依赖的那一层。4.3 高内聚低耦合的落地判断接口隔离和最少知识法则最终指向的就是高内聚低耦合。但这句话太抽象落地时我用两个可量化的判断标准耦合度打开一个类如果想改它的行为你需要同时修改几个其他类。数量越多耦合越重。内聚度一个类里的方法是否有大量操作共用了同一个成员变量或者同一个业务概念。如果各做各的内聚就差。我曾经接手过一个工具类里面静态方法超过二十个有处理日期的、有生成订单号的、有解析Excel的、还有做Base64编解码的。调用方各自拿来即用但想优化任何一组方法都要在整个类里翻找。把它按“日期处理”“订单号生成”“文件解析”“编码工具”拆成四个类之后连带测试也好写了。接口隔离在这里的运用不只是拆接口还包括拆工具类、拆模块核心是让每个粒度的东西服务面足够清晰。5. 一个优惠券系统的重构六条原则的联动与妥协单独讲原则永远容易“纸上谈兵”所以我用一个我自己实际重构过的优惠券系统把六条原则放到同一个场景里看它们如何协同也让读者明白原则之间有时候需要妥协不是每一条都必须执行到底。5.1 初始代码暴露出的典型问题优惠券最初版本是这样的public class CouponService { public BigDecimal calcDiscount(Order order, String couponCode) { Coupon coupon findCoupon(couponCode); BigDecimal discount BigDecimal.ZERO; if (coupon.getType() 1) { // 满减券 if (order.getAmount().compareTo(coupon.getThreshold()) 0) { discount coupon.getDiscountAmount(); } } else if (coupon.getType() 2) { // 折扣券 discount order.getAmount().multiply(BigDecimal.ONE.subtract(coupon.getRate())); } else if (coupon.getType() 3) { // 立减券 discount coupon.getDiscountAmount(); } return discount; } }这个类表面只是“计算优惠”但仔细看会发现问题很多优惠券类型一变就要改这个类满减、立减的逻辑混在一起算错了不好定位如果后续要加使用门槛、叠加限制这个calcDiscount会越来越长。用六大原则来审视单一职责被破坏满减、折扣、立减三种策略混在一个方法里各自的变化原因不一致开闭原则被破坏加一种优惠券类型必须修改现有方法依赖倒置没有体现所有逻辑直接依赖具体类型判断接口隔离也谈不上调用方要做优惠计算被迫看到与已无关的券类型内部判断迪米特法则CouponService需要了解Coupon内部的各种类型分支。5.2 按原则重构后的结构我把优惠计算抽成一个策略接口每种券类型对应一个实现public interface CouponCalculator { boolean supports(Coupon coupon); BigDecimal calc(Order order, Coupon coupon); } Component public class FullReductionCalculator implements CouponCalculator { Override public boolean supports(Coupon coupon) { return coupon.getType() 1; } Override public BigDecimal calc(Order order, Coupon coupon) { if (order.getAmount().compareTo(coupon.getThreshold()) 0) { return BigDecimal.ZERO; } return coupon.getDiscountAmount(); } } Component public class RateDiscountCalculator implements CouponCalculator { Override public boolean supports(Coupon coupon) { return coupon.getType() 2; } Override public BigDecimal calc(Order order, Coupon coupon) { return order.getAmount().multiply(BigDecimal.ONE.subtract(coupon.getRate())); } }主服务里维护一组CouponCalculator动态选择Service public class CouponService { private final ListCouponCalculator calculators; public CouponService(ListCouponCalculator calculators) { this.calculators calculators; } public BigDecimal calcDiscount(Order order, String couponCode) { Coupon coupon findCoupon(couponCode); return calculators.stream() .filter(c - c.supports(coupon)) .findFirst() .orElseThrow(() - new UnsupportedCouponException(不支持的优惠券类型)) .calc(order, coupon); } }这样改完新加一种优惠券类型只需要新增一个CouponCalculator实现类主流程完全不用动。这个结构既是策略模式也是模板方法模式的一种变体而支撑它的正是开闭原则和依赖倒置。每一种策略类的内部只处理一类券的算法单一职责也有了落脚点。5.3 要避免的过度设计重构后的代码结构确实更“正”了但如果你的业务只有一种优惠券永远不会有第二种那我不建议引入策略模式。六大原则的价值是面对真实的变化和复杂的业务协作产生加分项如果业务本身非常稳定套上全套设计反而会让项目里出现大量“装饰性类”——只为了满足原则而存在却带来了理解和维护成本。这个优惠券案例也说明了一个核心问题原则之间不是孤立的。单一职责让策略类职责清晰依赖倒置让主服务依赖抽象开闭原则让扩展以新增类的方式完成接口隔离让策略接口只暴露supports和calc两个方法。六条原则拧在一起时才真正形成了一张约束代码结构的网。6. 面试和实践中容易被问倒的细节既然站上Java技术分享的语境就免不了聊聊面试。面试官考察设计模式六大原则时容易被问倒的往往不是定义而是细节和场景判断。6.1 面试时怎么把这六条讲出层次我建议不要按顺序背诵而是抓一条主线“面对需求变化代码应当怎样组织才稳定”。话术可以这样首先说单一职责它是所有原则的基础一个类只有一个变化原因然后说开闭原则它定义了应对变化的总方向尽量扩展、少改旧代码继承和多态场景下里氏替换决定了什么样的继承关系是安全的不能因为子类破坏父类的行为契约依赖倒置告诉你高层模块不能直接依赖低层模块要面向抽象接口隔离是接口设计时的约束别把接口做成万能容器最少知识法则约束对象之间的交互范围减少牵一发而动全身的风险。面试官一旦追问“你项目中哪里用了”最好能说一个具体的重构案例。不要空谈概念哪怕是优惠券系统这种简单的例子也比背定义强。6.2 现实中常见的三个认知偏差第一个偏差是把“修改”理解成不能改代码。开闭原则要关的是“核心流程”的修改不是要求你永不改动。项目经过迭代后某些抽象的边界可能本身就不合理这时候重构接口、修改抽象层是完全正常的。第二个偏差是把单一职责等同于“一个类只能有一个public方法”。这是一种极端误解。一个类有多个public方法完全没问题只要这些方法服务的是同一个业务目标。比如订单服务里有createOrder、cancelOrder、payOrder它们都围绕“订单”这一个聚合根转职责就是一致的。第三个偏差是把接口隔离和单一职责画等号。单一职责是关于实现类的接口隔离是关于调用方视角的。一个类可以只做一件事但如果它的接口方法超过两个业务方需要仍然需要按调用方拆接口。6.3 我在实际代码评审里用的笨办法做了几年代码评审我形成了一套很朴素的审查流程。看到一个新类先问三个问题这个类能不能用一句话说清楚它干什么说不清楚往往就是职责不够单一如果要给这个类加一个新功能你需要修改它吗需要修改的代码量越大开闭原则遵守得越差调用这个类时调用方需要知道多少内部细节知道得越多最少知识法则就执行得越差。这三个问题不涉及高深理论但每次评审都能发现真实问题。有时候我不要求同事立刻重构因为技术债总是有的但至少要知道这里欠了债别在错误的地基上继续盖楼。7. 最后聊一点个人体会这套六大原则我第一次学的时候觉得它们只是面试里的加分项后来在工作中吃过亏才发现它们其实是我这种普通Java开发最容易上手的设计判断工具。我见过很多复杂的架构设计拆到底层就是这几个原则反复使用。反过来很多被说成架构混乱的项目用六条原则一对照问题都一目了然。如果让我给出一个实操建议那就是与其追求把所有代码都设计得“绝对正确”不如在每次改动代码时先问自己一句——“我这次改动是在扩展还是在把逻辑缝得更死”长期积累下来代码质量会比死记硬背原则时好得多。毕竟设计模式不是目的能持续响应变化、让团队维护代码的人不暴躁才是这些原则真正的价值。
返回列表