找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2974

积分

0

好友

407

主题
发表于 8 小时前 | 查看: 2| 回复: 0

如果你知道要遵循开闭原则,却不知如何下手,这篇文章就是一份具体的实践指南。

想象一下这个场景:一个名为 QuestionService 的类,足足有3000行代码,它既负责Excel导入导出,又包含复杂的组卷算法,还管理着题目缓存逻辑。当有人指出这违反了单一职责时,一个现实的问题摆在眼前:到底该拆分成几个类?怎么拆?

知道原则的名字很容易,但真正落地,往往令人沉默。今天,我们不谈空泛的理论,就用答题系统中的真实代码场景,把 SOLID 这五个原则翻译成工程师能立刻理解的语言和可以复用的套路。你会发现,SOLID不是锦上添花的最佳实践,而是防止代码腐烂的五个关键检查点。

首先,快速回顾一下 SOLID原则,这些是面向对象设计的基石:

  • 单一职责原则 (SRP)
  • 开闭原则 (OCP)
  • 里氏替换原则 (LSP)
  • 接口隔离原则 (ISP)
  • 依赖倒置原则 (DIP)

01 SOLID不是目标,而是自检清单

很多人把SOLID看作高级编程技巧,这其实是一种误解。它们更像是从无数踩坑经验中提炼出的一套风险预警信号:

  1. 当一个类什么都管时(违反SRP)→ 未来任何需求变动都要改这个类 → 改崩的风险指数级上升。
  2. 当你用 if-else 堆砌逻辑时(违反OCP)→ 每次新增功能都心惊胆战 → 测试用例永远覆盖不全。
  3. 当你继承仅仅是为了复用代码时(违反LSP)→ 父类可能被子类的意外行为搞崩 → 这是深夜收到报警的经典姿势。

一句话概括:SOLID的目标不是让代码变得更“漂亮”,而是让你在半夜被报警电话叫醒的概率大大降低。

02 从代码坏味道到好设计

2.1 单一职责原则 (SRP):别让一个类活得太累

到底是什么:一个类应该只有一个引起它变化的原因。
通俗解释:如果一个需求变更,需要你修改这个类中多处互不相关的代码,那说明这个类正在替别人“打工”。

坏味道场景:需求要求在现有系统中增加一个智能推荐相似题目的功能。

// 坏味道:QuestionService 变成了“全能神”
@Service
public class QuestionService {
    // 职责1:题目CRUD
    public Question getById(Long id) { ... }
    public void saveQuestion(Question question) { ... }

    // 职责2:导入导出
    public void importFromExcel(MultipartFile file) { ... }
    public byte[] exportToExcel() { ... }

    // 职责3:组卷算法(已经跑偏了)
    public List<Question> generateExamPaper(Strategy strategy) { 
        // 复杂算法:过滤、排序、抽样...
    }

    // 职责4:现在要加智能推荐(代码继续膨胀)
    public List<Question> recommendSimilarQuestions(Long questionId) {
        // 计算题目相似度,需要TF-IDF、嵌入向量...
    }
}

问题在哪里?

  1. 变更风险高:修改导入逻辑(比如支持Word格式),可能会意外影响到推荐功能。
  2. 测试困难:想要测试推荐功能,必须先准备好数据库里的题目数据。
  3. 团队协作冲突:前端同事A修改导出逻辑,后端同事B修改推荐算法,在同一个类里提交代码极易产生冲突。

重构方案:按变化原因进行拆分。

// 清晰的分家方案
// 职责1:纯粹的数据访问与基本维护
public interface QuestionRepository extends JpaRepository<Question, Long> {
    // 只有最基础的CRUD和简单查询
}

// 职责2:文件处理单独一个服务
@Service
public class QuestionFileService {
    public void importFromExcel(MultipartFile file) { ... }
    public byte[] exportToExcel() { ... }
    // 未来加Word导入,只改这个类
}

// 职责3:组卷是独立的业务能力
@Service
public class ExamPaperGenerationService {
    private final QuestionRepository questionRepository;

    public List<Question> generate(PaperGenerationStrategy strategy) {
        List<Question> all = questionRepository.findAll();
        return strategy.generate(all); // 依赖策略接口
    }
}

// 职责4:推荐是独立的算法模块
@Service
public class QuestionRecommendationService {
    private final QuestionRepository questionRepository;

    public List<Question> recommendSimilar(Long questionId) {
        Question target = questionRepository.findById(questionId);
        List<Question> all = questionRepository.findAll();
        // 专门的相似度计算逻辑
        return calculateSimilarity(target, all);
    }

    // 私有方法,复杂的算法逻辑封装在这里
    private List<Question> calculateSimilarity(Question target, List<Question> candidates) {
        // 用TF-IDF或嵌入向量计算
    }
}

小技巧

  1. 先按动词分组:把类的方法按“做什么”来分组(管理题目、处理文件、生成试卷…)。
  2. 自我提问:如果需求说要修改导出格式,我需要动组卷的代码吗?如果不需要,它们就应该分开。
  3. 团队共识:在项目Wiki里用一句话记录每个服务的职责,例如:QuestionFileService 只处理题目数据的导入导出,不关心业务逻辑。

2.2 开闭原则 (OCP):装个插排,别改墙里的电线

到底是什么:对扩展开放,对修改关闭。
通俗解释:增加新功能时,应该通过新增代码来实现,而不是修改现有的、已经工作正常的代码。

坏味道场景:批阅系统一开始只支持人工批阅,现在产品要求增加AI自动批阅功能。

// 坏味道:用 if-else 堆砌出来的“可扩展”
@Service
public class ReviewService {
    public ReviewResult review(AnswerRecord answer, String reviewType) {
        if ("MANUAL".equals(reviewType)) {
            // 人工批阅逻辑:调管理员页面,等待提交...
            return manualReview(answer);
        } else if ("AI".equals(reviewType)) {
            // AI批阅逻辑:调用机器学习模型...
            return aiReview(answer);
        } else if ("DOUBLE_CHECK".equals(reviewType)) {
            // 双人复核逻辑:找两个管理员...
            return doubleCheckReview(answer);
        }
        throw new IllegalArgumentException("不支持的批阅类型");
    }
    // 每加一种新类型,就要:1)加 else if;2)可能修改其他分支逻辑
}

问题在哪里?

  1. 修改风险:每次添加新类型,都可能意外影响已有的老逻辑。
  2. 测试负担:每增加一个if分支,所有分支的测试用例理论上都需要重新跑一遍以保证安全。
  3. 代码臭味:这个review方法会随着时间无限膨胀,最终难以维护。

重构方案:策略模式 (Strategy Pattern) + 工厂模式 (Factory Pattern)。

// 第一步:定义策略接口(这就是“插座”)
public interface ReviewStrategy {
    boolean supports(String reviewType); // 这个策略支持什么类型
    ReviewResult review(AnswerRecord answer);
}

// 第二步:实现各种策略(这就是“电器”)
@Component
public class ManualReviewStrategy implements ReviewStrategy {
    @Override
    public boolean supports(String reviewType) {
        return "MANUAL".equals(reviewType);
    }

    @Override
    public ReviewResult review(AnswerRecord answer) {
        // 纯粹的人工批阅逻辑
        // 不会看到任何AI相关代码
    }
}

@Component
public class AIReviewStrategy implements ReviewStrategy {
    @Override
    public boolean supports(String reviewType) {
        return "AI".equals(reviewType);
    }

    @Override
    public ReviewResult review(AnswerRecord answer) {
        // 纯粹的AI批阅逻辑
        // 调用机器学习模型,可能还有模型版本管理
    }
}

// 第三步:工厂类(这就是“插排”)
@Service
public class ReviewStrategyFactory {
    // Spring会自动注入所有实现ReviewStrategy的Bean
    private final List<ReviewStrategy> strategies;

    public ReviewStrategy getStrategy(String reviewType) {
        return strategies.stream()
            .filter(s -> s.supports(reviewType))
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException("不支持的批阅类型"));
    }
}

// 第四步:使用方(变得极其简洁)
@Service
public class ReviewService {
    private final ReviewStrategyFactory factory;

    public ReviewResult review(AnswerRecord answer, String reviewType) {
        ReviewStrategy strategy = factory.getStrategy(reviewType);
        return strategy.review(answer); // 多态调用
    }
    // 未来加“三人复核”,只需要:
    // 1. 新建 TripleCheckReviewStrategy 实现类
    // 2. 实现自己的业务逻辑
    // 3. 完事!ReviewService 一行代码都不用改。
}

小技巧

  1. 识别变化点:思考系统中哪些地方经常需要增加新类型?(批阅方式、支付渠道、通知方式…)。
  2. 先接口,后实现:哪怕当前只有一种实现,也先定义好策略接口,为未来扩展预留空间。
  3. 利用Spring的List注入:自动收集所有实现类,让工厂类的代码变得极其简洁,这本身就是一种对 设计模式 的优雅应用。

2.3 里氏替换原则 (LSP):说好是猫粮,就别让狗吃了拉肚子

到底是什么:子类必须能够替换其父类,而不影响程序的正确性。
通俗解释:继承关系应该是 “是一个 (is-a)” 的关系,而不仅仅是 “有点像” 的关系。

坏味道场景:题目系统里有基础题目 Question,还有带图片的题目 ImageQuestion

// 坏味道:子类改变了父类的契约
public class Question {
    protected String content;

    public String getDisplayContent() {
        return content;
    }

    public int getEstimatedReadingTime() {
        // 基础估算:每100字1分钟
        return Math.max(1, content.length() / 100);
    }
}

public class ImageQuestion extends Question {
    private String imageUrl;

    @Override
    public String getDisplayContent() {
        // 子类重写:内容+图片标记
        return content + "\n[查看图片]";
    }

    @Override
    public int getEstimatedReadingTime() {
        // 问题在这里!子类重写时改变了约定
        // 父类说:根据文字长度计算
        // 子类做:固定时间,不管文字多长
        return 3; // 固定3分钟
    }
}

// 使用方代码
public class ExamTimer {
    public int calculateTotalTime(List<Question> questions) {
        int total = 0;
        for (Question q : questions) {
            total += q.getEstimatedReadingTime(); // 这里期望的是父类约定
        }
        return total;
    }
}

问题在哪里?

  1. 破坏契约ExamTimer 期望所有题目都按文字长度估算时间,但 ImageQuestion 破坏了这一约定,返回固定值。
  2. 难以排查:为什么试卷总时间计算总是不准?需要深入每个子类查看实现细节才能发现。
  3. 违背业务直觉:从业务上说,带图片的题目通常需要更长的阅读/作答时间,而不是一个固定值。

应该怎么做:要么严格遵守父类约定,要么干脆别用继承。

// 方案A:子类遵守父类约定(加强版)
public class ImageQuestion extends Question {
    private String imageUrl;

    @Override
    public int getEstimatedReadingTime() {
        // 遵守约定:基于文字长度,但加上图片的额外时间
        int baseTime = super.getEstimatedReadingTime(); // 先算基础时间
        return baseTime + 2; // 图片多看2分钟,合情合理
    }
}

// 方案B:更推荐——组合替代继承
public class ImageQuestion {
    private Question question; // 组合一个基础题目
    private String imageUrl;

    // 重新设计接口,明确说明“我是不一样的”
    public int getTotalReadingTime() {
        return question.getEstimatedReadingTime() + 2;
    }

    public String getFullContent() {
        return question.getDisplayContent() + "\n" + imageUrl;
    }
}

小技巧

  1. 问自己ImageQuestion 是一种 Question 吗?还是它 有一个 Question
  2. 编写单元测试:尝试用父类(Question)类型来测试子类(ImageQuestion)的行为,看测试是否能通过,这是验证LSP的好方法。
  3. 慎用继承:优先考虑使用组合,除非两个类之间是清晰的 “is-a” 关系。

2.4 接口隔离原则 (ISP):别逼素食者买牛排套餐

到底是什么:客户端不应该被迫依赖它不使用的方法。
通俗解释:不要设计大而全的“上帝接口”,应该设计小而专的接口。

坏味道场景:考试系统中定义了一个庞大的 UserService 接口。

// 坏味道:“上帝接口”
public interface UserService {
    // 认证相关
    User login(String username, String password);
    void logout(String token);

    // 用户管理
    User createUser(UserDTO dto);
    void updateUser(Long userId, UserDTO dto);
    void deleteUser(Long userId);

    // 学习数据(这个最要命)
    LearningReport generateLearningReport(Long userId);
    List<AnswerRecord> getRecentAnswers(Long userId);
    Map<String, Integer> getKnowledgeWeakness(Long userId);

    // 消息通知
    void sendNotification(Long userId, String message);
}

问题在哪里?

  1. 强迫依赖AdminController 可能只需要用户管理功能,但它却能看到(甚至可能不小心调用)生成学习报告的方法。
  2. 实现类痛苦UserServiceImpl 必须实现所有方法,哪怕有些方法对于当前场景只是抛出 UnsupportedOperationException
  3. 变更影响范围大:修改学习报告相关的接口,会影响到所有依赖 UserService 的客户端,即便它们只用到了登录功能。

重构方案:按照客户端(调用方)的角色来拆分接口。

// 按使用方角色拆分
// 接口1:核心认证(登录注册用)
public interface AuthService {
    User login(String username, String password);
    void logout(String token);
}

// 接口2:用户管理(后台管理用)
public interface UserManageService {
    User createUser(UserDTO dto);
    void updateUser(Long userId, UserDTO dto);
    void deleteUser(Long userId);
}

// 接口3:学习数据(学习分析用)
public interface LearningAnalysisService {
    LearningReport generateLearningReport(Long userId);
    List<AnswerRecord> getRecentAnswers(Long userId);
    Map<String, Integer> getKnowledgeWeakness(Long userId);
}

// 接口4:消息通知(通知模块用)
public interface UserNotificationService {
    void sendNotification(Long userId, String message);
}

// 实现类可以灵活组合
@Service
public class UserServiceImpl implements 
    AuthService, 
    UserManageService,
    LearningAnalysisService {
    // 实现所有方法,但现在是清晰的
}

// 客户端只依赖它真正需要的接口
@RestController
@RequestMapping("/admin")
public class AdminController {
    private final UserManageService userService; // 只看到管理方法,不会误用学习报告方法
}

小技巧

  1. 按调用方分组:观察哪些客户端(如Controller、Service)调用了接口的哪些方法。
  2. 接口命名体现职责:将泛泛的 XxxService 细化为 XxxForAdminServiceXxxForLearningService等。
  3. 利用IDE的“查找用法”功能:查看每个方法被谁调用,经常被同一批客户端调用的方法应该放在同一个接口里。

2.5 依赖倒置原则 (DIP):依赖合同,不依赖具体人

到底是什么:高层模块不应该依赖低层模块,两者都应该依赖抽象。
通俗解释:要依赖接口,不要依赖具体的实现类。

坏味道场景:成绩统计模块直接依赖了具体的数据库和缓存实现。

// 坏味道:高层模块直接依赖具体实现
@Service
public class ScoreStatisticsService {
    // 直接依赖MySQL实现
    private final JdbcScoreRepository jdbcRepository;
    // 直接依赖Redis缓存
    private final RedisCacheManager redisCache;

    public Map<String, Double> getAverageScores(Long examId) {
        // 先查缓存
        String cacheKey = "exam_scores:" + examId;
        Map<String, Double> cached = redisCache.get(cacheKey);
        if (cached != null) {
            return cached;
        }

        // 缓存没有,查数据库
        List<Score> scores = jdbcRepository.findByExamId(examId);
        Map<String, Double> result = calculateAverage(scores);

        // 写回缓存
        redisCache.set(cacheKey, result, 3600);
        return result;
    }
}

问题在哪里?

  1. 紧耦合:未来如果想换用MongoDB来存储成绩,就需要重写整个 ScoreStatisticsService
  2. 难以测试:想要对这个服务进行单元测试,必须启动一个真实的Redis和MySQL实例。
  3. 职责混杂:这个服务既关心核心的统计业务逻辑,又关心具体的缓存策略(Key的格式、TTL设置等)。

重构方案:让高层模块依赖抽象,使底层细节变得可插拔。

// 第一步:定义抽象接口(“合同”)
public interface ScoreRepository {
    List<Score> findByExamId(Long examId);
    // 其他必要的查询方法
}

public interface CacheService {
    <T> T get(String key, Class<T> type);
    void set(String key, Object value, long ttlSeconds);
}

// 第二步:实现具体细节
@Repository
public class JdbcScoreRepositoryImpl implements ScoreRepository {
    // 实现MySQL查询
}

@Component  
public class RedisCacheServiceImpl implements CacheService {
    // 实现Redis缓存
}

// 第三步:高层模块依赖抽象
@Service
public class ScoreStatisticsService {
    // 依赖抽象,不关心具体是MySQL还是MongoDB,是Redis还是Memcached
    private final ScoreRepository scoreRepository;
    private final CacheService cacheService;

    // Spring通过构造函数自动注入具体实现
    public ScoreStatisticsService(ScoreRepository repo, CacheService cache) {
        this.scoreRepository = repo;
        this.cacheService = cache;
    }

    public Map<String, Double> getAverageScores(Long examId) {
        String cacheKey = "exam_scores:" + examId;
        Map<String, Double> cached = cacheService.get(cacheKey, Map.class);
        if (cached != null) return cached;

        List<Score> scores = scoreRepository.findByExamId(examId);
        Map<String, Double> result = calculateAverage(scores);

        cacheService.set(cacheKey, result, 3600);
        return result;
    }

    // 纯业务逻辑,可以轻松地进行单元测试(通过Mock接口)
    private Map<String, Double> calculateAverage(List<Score> scores) { ... }
}

小技巧

  1. 使用依赖注入:利用Spring的 @Autowired 或更推荐的构造函数注入。
  2. 面向接口编程:在代码中看到 new 关键字实例化具体类时,都思考一下能否改为注入接口。
  3. 让代码对测试友好:重构后,可以轻松地用 Mock 对象来模拟 ScoreRepositoryCacheService,实现对 ScoreStatisticsService 的纯净 单元测试,这是保证代码质量的重要手段。

03 为什么我们会违反SOLID?

很多时候,我们是因为紧急的业务需求而暂时牺牲了代码质量。不是不懂这些原则,而是“没时间”。但一个残酷的真相是:违反SOLID原则通常不会导致系统立刻崩溃,但它像一种慢性毒药。

  1. 第1个月:觉得加个 if-else 最快最省事。
  2. 第3个月:类膨胀到2000行,变成了“谁都不敢动”的泥潭。
  3. 第6个月:修复一个Bug,不小心引入了三个新Bug。
  4. 第12个月:推倒重来的成本,已经超过了继续在屎山上维护的成本。

面对现实,我们可以考虑 2/8原则

  1. 80%的代码,保持基本整洁即可,不必过度设计。
  2. 20%的核心领域代码(如 AnswerRecordReviewService),必须严格运用SOLID原则。
  3. 如何识别核心?问问这几个问题:哪些模块被频繁修改?哪些模块出Bug后果最严重?那些地方就是最需要SOLID防守的阵地。

在日常开发中,可以随时问自己这三个问题来快速自检:

  • 单一职责:如果我改这个功能,会影响这个类里其他不相关的代码吗?(会 → 拆!)
  • 开闭原则:如果明天要加一个类似的功能,我需要修改现有的代码吗?(要 → 抽象!)
  • 依赖倒置:这个类依赖的具体实现,未来有可能更换吗?(可能 → 注入接口!)

04 总结

不知你是否发现,SOLID 并非五个冷冰冰的教条,它们共同构成了一套编写代码时的“防御性驾驶”体系。就像回顾我们项目里曾踩过的那些坑,很多问题其实早就可以预见和避免。

  1. SRP(单一职责) 像个预警雷达:一个类干太多事?它马上报警,提醒你这是未来的隐患。
  2. OCP(开闭原则) 是坚固的护甲:新功能来袭时无需修改老代码,直接扩展,避免牵一发而动全身。
  3. LSP(里氏替换) 是安全协议:保证子类替换父类时,系统行为依然可预期,不会突然“抽风”。
  4. ISP(接口隔离) 像精准制导:只给调用方它真正需要的武器,避免误用和过度耦合。
  5. DIP(依赖倒置) 提供了通用接口:让高层模块不再绑死在具体实现上,更换底层如同更换插件,系统灵活性大增。

说到底,编程高手也并非天生就精通这些。关键是在写下每一行代码时,都带着一份“觉知”:我这样写,会不会给未来的自己或同事挖坑?

目标不是背诵概念,而是让这些原则内化为“肌肉记忆”——在问题发生之前就看到它,而不是等到系统崩盘时才追悔莫及。

希望本文中的具体场景和代码能给你带来启发。关于 设计模式编码规范 的更多深度讨论,欢迎在 云栈社区 与大家交流。下次当你面对重构时,不妨就从这五个防御点入手,你的代码会因此变得越来越有韧性。

打开你最近编写的一个Service类,数数它有多少个public方法,然后诚实地问自己:这些方法真的是因为同一个原因而变化吗?




上一篇:MySQL执行计划深度解读:从EXPLAIN字段到SQL性能调优实战
下一篇:ProtoText 开源笔记工具:用卡片视图革新信息组织与知识管理
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-3 18:03 , Processed in 0.280378 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表