设计原则:SOLID、DRY 与 YAGNI
这一课你将能做到
- 用判题平台真实业务讲清 SOLID 五条原则各自解决什么问题
- 识别代码里的"重复"和"过度设计",知道 DRY 与 YAGNI 的边界
- 给一个违反 SOLID 的类做最小重构,让它符合 OCP 和 DIP
为什么要学
如果说整洁代码是"一行代码怎么写",那设计原则就是"类和类怎么协作"。判题平台里 SubmissionService、JudgeEngine、NotificationSender、PracticeRecordRepository 之间的依赖关系、扩展点、复用方式,全都由这些原则决定。
面试几乎必考 SOLID,但光背定义没用——面试官会追问"举一个你项目里的例子"。这一课就给你备好例子。
先建立图像
S 单一职责 一个类只被一个角色驱动修改
O 开闭 对扩展开放,对修改关闭
L 里氏替换 子类必须能替换父类
I 接口隔离 别强迫实现方实现用不到的方法
D 依赖倒置 依赖抽象,不依赖具体
DRY 重复逻辑只写一次
YAGNI 用不到的别提前写
跟着做
步骤 1:违反 SRP 的"上帝类"
// 反例:SubmissionService 干了太多事
public class SubmissionService {
public void submit(String code, long userId, long lessonId) { /* ... */ }
public void judge(long submissionId) { /* ... */ }
public void sendEmail(long userId) { /* ... */ }
public void exportReport(LocalDate from, LocalDate to) { /* ... */ }
public void updateVipExpire(long userId) { /* ... */ }
}
这个类会被"判题工程师、运营、报表开发、会员产品"四种角色修改,任何改动都可能影响别人。
重构:按角色拆成 SubmissionService、JudgeService、NotificationService、ReportService、MembershipService。每个类只对一个角色负责。
步骤 2:用 OCP 设计判题器
新增"Python 判题"时不改老代码:
public interface JudgeEngine {
boolean supports(String language);
JudgeResult judge(Submission submission);
}
@Component
public class JavaJudgeEngine implements JudgeEngine {
public boolean supports(String language) { return "java".equals(language); }
public JudgeResult judge(Submission s) { /* 编译、跑用例、对比输出 */ }
}
@Component
public class PythonJudgeEngine implements JudgeEngine {
public boolean supports(String language) { return "python".equals(language); }
public JudgeResult judge(Submission s) { /* ... */ }
}
@Service
public class JudgeDispatcher {
private final List<JudgeEngine> engines; // Spring 自动注入所有实现
public JudgeDispatcher(List<JudgeEngine> engines) { this.engines = engines; }
public JudgeResult dispatch(Submission submission) {
return engines.stream()
.filter(e -> e.supports(submission.getLanguage()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("no engine for " + submission.getLanguage()))
.judge(submission);
}
}
新增语言 = 新增一个 @Component,老代码一行不动。这就是"对扩展开放,对修改关闭"。
步骤 3:用 DIP 切断对 MySQL 的硬编码
// 反例:业务层直接依赖 MyBatis Mapper
public class PracticeRecordService {
@Autowired private PracticeRecordMapper mapper; // 绑死 MySQL
}
// 正解:依赖抽象接口,由基础设施层实现
public interface PracticeRecordRepository {
Optional<PracticeRecord> findByUserAndLesson(long userId, long lessonId);
void save(PracticeRecord record);
}
@Repository
public class MybatisPracticeRecordRepository implements PracticeRecordRepository {
private final PracticeRecordMapper mapper;
/* 委托给 MyBatis 实现 */
}
业务层不再关心是 MySQL、Redis 还是内存。换存储、写单测都简单了。
步骤 4:DRY 与 YAGNI 的边界
// DRY:两处都需要的逻辑,抽出来
public boolean canAccessLesson(CfUser user, CfLesson lesson) {
return lesson.isFree() || user.isVip();
}
// YAGNI:现在不需要"团队空间",就别提前加 teamId 字段、别预留 teamService
// 等真的有需求再加,否则你维护的是想象出来的复杂度
讲清楚要点
- SRP:一个类只有一个被修改的理由,按"角色"或"参与者"切分。
- OCP:通过抽象 + 多态新增行为,而不是修改老代码加
if-else。 - LSP:子类任何地方能替换父类而不出错。
Square extends Rectangle是经典反例。 - ISP:客户端不该被迫依赖它用不到的方法。
JudgeEngine不该继承NotificationSender。 - DIP:高层业务不依赖低层细节,双方都依赖抽象。Spring DI 就是 DIP 的工业化实现。
- DRY:同一份业务知识只表达一次;重复代码 ≠ DRY 违反,重复"业务规则"才是。
- YAGNI:你当下没有的需求不要提前实现;预留接口、预留字段都是债。
你可能会懵的地方
- OCP 会不会导致类爆炸?
会多一点类,但每个类很小、很聚焦,远比一个 2000 行的
if-else上帝类好维护。Spring 的List<Strategy>注入让 OCP 几乎零成本。 - 里氏替换在 Java 里具体怎么违?
子类抛了父类不会抛的异常、子类加强了前置条件("我只接受正数"而父类接受所有 int)、子类削弱了后置条件。判题平台里
VipCfUser extends CfUser如果重写canAccess反而更严格,就违反 LSP。 - 接口隔离和单一职责什么关系?
SRP 看类,ISP 看接口;都是"别塞没用的东西"。一个
UserRepository不该同时继承EmailSender。 - DRY 是不是不允许任何重复? 不是。两处相似的代码如果将来变化方向不同,就不该合并。"偶然重复"强行抽象,会埋耦合炸弹。
- YAGNI 和开闭原则矛盾吗? 不矛盾。OCP 是给"已知会变"的维度留扩展点(比如判题语言);YAGNI 是"别为猜不到的维度预留"。区别在于"变化是否有明确信号"。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 一个 Service 2000 行 | 多角色修改,互相踩 | 按角色拆 SRP |
if (lang=="java")...else if(lang=="py") | 加语言要改老代码 | 策略接口 + Spring 注入列表 |
业务层直接 @Autowired Mapper | 绑死 MySQL | 抽象 Repository 接口 |
extends ArrayList 复用 | 继承了不该暴露的方法 | 组合:内部持有一个 List |
提前预留 tenantId 字段 | 当前没多租户需求 | YAGNI:需要时再加 |
卡点急救
- 不知道该怎么拆:列出这个类所有的 public 方法,按"谁会让它改"分组,每组拆成一个类。
- 想抽接口但不知道抽哪些方法:问"调用方真正用到哪些?"只把这些放进接口,其他留在实现里。
- 怕被说过度设计:问"这个扩展点在未来 6 个月有明确需求吗?"没有就先
if-else,有就抽策略。
面试常问
- 举一个你项目里 SOLID 的应用?
答:判题平台用策略模式做
JudgeEngine,每个语言一个实现,Spring 注入List<JudgeEngine>,加语言不改老代码(OCP);业务层依赖PracticeRecordRepository接口而不是 MyBatis Mapper(DIP);通知服务独立成NotificationService,不混在 SubmissionService(SRP)。 - LSP 举个反例?
答:
Square extends Rectangle,正方形 setWidth 会改 height,违反"父类出现的地方子类可替换"。判题平台里如果FreeLesson extends CfLesson重写canAccess抛异常"免费课无需判断",也是 LSP 违反。 - DRY 和"复制粘贴"的边界?
答:DRY 重复的是"业务知识"。两个类恰好都写了
user.getVip() == 1是重复业务知识,该抽user.isVip()。两个类都写log.info(...)是巧合重复,不该合并。
自测三问
- 一个类同时被"判题工程师、运营、会员产品"修改,违反了哪条原则?怎么改?
- 为什么"接口里塞太多方法"也是一种设计病?它违反哪条原则?
- 在判题平台中,新增"Rust 判题"应该怎么动代码?
小结一句话
SOLID 让类之间的关系可扩展、可替换、可测试;DRY 让知识只写一次;YAGNI 让你别为想象中的需求付利息。
延伸
- 《敏捷软件开发:原则、模式与实践》Robert C. Martin
- 《设计模式:可复用面向对象软件的基础》GoF,第 1 章
- Spring 官方文档 - IoC 容器
- 下一课:面向对象——继承、组合、多态在判题平台的具体落地。