设计模式与 UML
这一课你将能做到
- 识别创建型、结构型、行为型三类 GoF 模式的典型场景,写出单例、工厂、策略、模板方法、观察者的 Java 实现
- 看懂并画出简单 UML 类图:类、接口、继承、实现、组合、依赖六种关系
- 解释依赖注入(DI)与控制反转(IoC),说明 Spring 容器如何把模式「内置」进框架
为什么要学
设计模式不是炫技名词,而是「前辈反复踩坑后总结的标准结构」。当代码里出现这些信号:对象创建逻辑到处复制、新增一种判题语言要改五个文件、模块之间互相 new 死对方、状态变化要通知十个子系统——对应的模式已经在敲门了。
判题平台里模式密集:多语言编译执行(工厂 + 策略)、判题流程固定步骤(模板方法)、提交后触发统计与推送(观察者/事件)、配置类全局唯一(单例思想)。而 Spring 本身就是模式的集大成者:Bean 容器、依赖注入、AOP 代理、JdbcTemplate。学会模式,你读框架源码和设计可扩展代码的能力会同时上台阶。
先建立图像
GoF 23 模式三大类(先掌握每类最常用的)
创建型:把「对象怎么来」封装起来
单例 Singleton 全局一个实例(日志/配置)
工厂 Factory 屏蔽具体类型(按语言创建 Java/C++/Go 编译器)
建造者 Builder 分步构造复杂对象(题目:描述+样例+限制)
结构型:把类/对象组合成更大结构
适配器 Adapter 接口转换(新旧判题接口共存)
装饰器 Decorator 不改正文加能力(IO 流、缓存包装)
代理 Proxy 控制访问(Spring AOP、懒加载)
行为型:分配职责与通信
策略 Strategy 一族算法可互换(多语言判题/多种限流算法)
模板方法 Template 父类定骨架,子类填空
观察者 Observer 状态变化自动通知(提交事件→统计+推送)
UML 关系强度(由弱到强)
依赖 ···▶(临时用一下) 关联 ──▶(持有)
聚合 ◇──▶(整体与部分,可分离) 组合 ◆──▶(同生共死)
实现 ──|▶(接口) 泛化 ──▷(继承)
跟着做
步骤 1:工厂 + 策略支撑多语言判题
// 策略接口:各种语言的执行逻辑可互换
interface JudgeStrategy {
String language();
String compile(String sourceFile);
String runCommand();
}
class JavaJudge implements JudgeStrategy {
public String language() { return "java"; }
public String compile(String src) { return "javac " + src; }
public String runCommand() { return "java Main"; }
}
class CppJudge implements JudgeStrategy {
public String language() { return "cpp"; }
public String compile(String src) { return "g++ -o main " + src; }
public String runCommand() { return "./main"; }
}
// 工厂:根据用户选择的语言产出策略,调用方不认识具体类
class JudgeStrategyFactory {
private final Map<String, JudgeStrategy> strategies = new java.util.HashMap<>();
JudgeStrategyFactory(java.util.List<JudgeStrategy> all) {
all.forEach(s -> strategies.put(s.language(), s));
}
JudgeStrategy create(String language) {
JudgeStrategy strategy = strategies.get(language);
if (strategy == null) {
throw new IllegalArgumentException("暂不支持的语言: " + language);
}
return strategy;
}
}
public class JudgeDemo {
public static void main(String[] args) {
JudgeStrategyFactory factory =
new JudgeStrategyFactory(java.util.List.of(new JavaJudge(), new CppJudge()));
JudgeStrategy strategy = factory.create("java");
System.out.println(strategy.compile("Main.java"));
System.out.println(strategy.runCommand());
}
}
扩展新语言(如 GoJudge)只需新增一个类并注册,调用方零修改——这就是对扩展开放、对修改关闭(OCP)。如果不用策略,典型坏味道是一长串 if ("java".equals(lang)) {...} else if (...),每加语言都改主流程。
步骤 2:模板方法固定判题骨架
abstract class AbstractJudgeTemplate {
// 模板:流程步骤固定(final 防止子类改顺序),差异步骤交给子类实现
public final String judge(String source) {
prepareWorkspace();
String compileResult = compile(source);
if (!compileResult.equals("OK")) return "CE"; // 编译失败
String output = execute();
return compare(output, expectedOutput()) ? "AC" : "WA";
}
private void prepareWorkspace() { System.out.println("准备隔离目录"); }
protected abstract String compile(String source);
protected abstract String execute();
private String expectedOutput() { return "42"; }
private boolean compare(String actual, String expected) {
return actual.trim().equals(expected);
}
}
class JavaJudgeTemplate extends AbstractJudgeTemplate {
protected String compile(String source) { System.out.println("javac 编译"); return "OK"; }
protected String execute() { System.out.println("JVM 运行"); return "42"; }
}
public class TemplateDemo {
public static void main(String[] args) {
System.out.println(new JavaJudgeTemplate().judge("class Main{}"));
}
}
步骤 3:观察者(事件机制)解耦提交后的动作
import java.util.ArrayList;
import java.util.List;
interface SubmissionListener {
void onResult(long submissionId, String status);
}
class StatListener implements SubmissionListener {
public void onResult(long id, String status) {
System.out.println("更新统计: " + id + " " + status);
}
}
class PushListener implements SubmissionListener {
public void onResult(long id, String status) {
System.out.println("WebSocket 推送给用户: " + id);
}
}
class SubmissionEventBus {
private final List<SubmissionListener> listeners = new ArrayList<>();
void subscribe(SubmissionListener listener) { listeners.add(listener); }
void publish(long id, String status) {
for (SubmissionListener listener : listeners) {
listener.onResult(id, status);
}
}
}
public class ObserverDemo {
public static void main(String[] args) {
SubmissionEventBus bus = new SubmissionEventBus();
bus.subscribe(new StatListener());
bus.subscribe(new PushListener());
bus.publish(1024, "AC");
// 判题主流程不认识统计和推送,新增通知渠道只需加监听器 —— Spring 的
// ApplicationEvent / @EventListener 就是这个模式的容器化版本
}
}
步骤 4:单例与 UML 类图
// 现代单例写法:枚举天然防反射与反序列化重复创建
enum JudgeConfig {
INSTANCE;
private int timeLimitMs = 1000;
public int getTimeLimitMs() { return timeLimitMs; }
}
public class SingletonDemo {
public static void main(String[] args) {
System.out.println(JudgeConfig.INSTANCE.getTimeLimitMs());
}
}
┌─────────────────────────┐
│ «interface» │
│ JudgeStrategy │
│ +language():String │
└───────────△─────────────┘
│ implements(虚线空心三角)
┌───────────┴────────────┐
│ JavaJudge │
│ +compile() +runCommand()│
└────────────────────────┘
JudgeStrategyFactory ──▶ JudgeStrategy (依赖:虚线箭头)
记法:三角箭头指向父类/接口(继承用实线、实现用虚线);普通箭头表示持有或临时使用;菱形在整体一端(空心可分离=聚合,实心同生死=组合)。
讲清楚要点
- 模式是「问题-上下文-解法」的三元组,脱离场景套模式就是过度设计;判断标准是「变化点在哪」——对象类型常变用工厂,算法常换用策略,步骤稳定但实现可变用模板。
- 优先组合而非继承:装饰器、策略、代理都通过「持有另一个对象」获得灵活性,避免继承层次爆炸;这与 Effective Java 的原则一致。
- Spring 把模式框架化了:IoC 容器负责对象创建与装配(工厂+单例思想),构造器/Setter 注入是依赖注入,AOP 用动态代理实现,
@EventListener 是观察者,JdbcTemplate/RestTemplate 用模板方法统一骨架。
- 依赖注入的价值:对象不再自己 new 依赖(控制反转),依赖从外部传入,替换实现(如把真实判题机换成 Mock)和单元测试都变得容易;配合面向接口编程,模块间只依赖抽象。
- UML 是沟通语言不是施工图纸:工作中画「足够表达设计的简图」即可,不必穷尽所有符号;时序图(对象间消息按时间排列)在梳理接口调用链时和类图一样常用。
你可能会懵的地方
-
单例 Bean 为什么还能处理并发请求?
单例指容器里只有一个实例,多个线程并发调用它的方法;只要方法不使用共享可变成员(无状态),局部变量线程私有,就没有线程安全问题。这也是 Spring Service/Controller 默认为单例的前提。
-
策略模式和工厂模式是不是一回事?
不是,常常配合:策略定义「可互换的行为」,工厂负责「选出/创建正确的策略」。一个解决行为差异,一个解决对象获取。
-
代理、装饰器、适配器长得像,怎么分?
看意图:代理控制访问(不改变接口,加权限/延迟/日志);装饰器增强行为(层层包装,如 BufferedInputStream);适配器转换接口(让不兼容的类协同)。结构相似但目的不同。
-
为什么不推荐继承实现复用?
继承在编译期固定、破坏封装(子类依赖父类实现细节)、层次深了难维护。组合在运行期可替换,更灵活;只有真正的「is-a」关系才用继承。
-
一定要把 23 个模式全背下来吗?
不必。先吃透本课 8 个高频模式并在项目里用出 2-3 个,其余建立「知道它解决什么问题」的索引,需要时再查 Refactoring.Guru 或 GoF 原书。
错法对照
| 错法 | 问题 | 正解 |
|---|
字段注入 @Autowired 到处写 | 依赖隐蔽、无法 final、测试难 | 构造器注入(单构造器可省略注解) |
| 双重检查锁漏写 volatile | 指令重排导致半初始化对象 | 私有静态持有者或枚举单例 |
| 模板方法不加 final | 子类改顺序破坏骨架 | 模板方法声明为 final |
| 为用而用,简单逻辑硬套三层模式 | 代码膨胀、可读性下降 | 先确认变化点真实存在再引入 |
| 监听器里执行慢操作且不隔离异常 | 一个监听器拖垮整个发布 | 异步事件 + 异常隔离(Spring @Async/@EventListener) |
卡点急救
- 新增一种类型要改很多文件:找到散落的类型判断,用工厂 + 策略收敛到「加一个类 + 注册」。
- 循环依赖启动报错:先反思设计(必要时用 @Lazy 或事件解耦),长期是重新划分模块边界,而不是长期靠注解掩盖。
- 模式用了但同事看不懂:类名体现意图(XxxStrategy/XxxFactory),包结构按角色归类,配一张 UML 简图与场景说明。
面试常问
-
讲一个你在项目里用过的设计模式。
推荐 STAR 式回答:判题平台要支持多语言——用 JudgeStrategy 封装编译运行差异、工厂按 language 选择、Spring 自动收集所有实现注册;新增加语言不动主流程,也方便单元测试 Mock。能说出替代方案(长 if-else)的弊端更佳。
-
Spring 的 IoC 和 DI 是什么关系?
IoC 是思想:对象不再自己控制依赖的创建与生命周期,反转给容器;DI 是实现手段:容器通过构造器/Setter/字段把依赖注入。配合面向接口编程,带来松耦合与可测试性。
-
模板方法和策略有什么区别?
模板方法用继承:父类固定算法骨架,子类重写某些步骤(改动受骨架约束);策略用组合:整个算法族可整体互换,更灵活。选择看变化的是「个别步骤」还是「整体算法」。
自测三问
- 给你「新增 Python/Go 判题支持」的需求,从接口到注册完整画出类图与调用链。
- 不看资料,写出观察者模式三件套(事件源、监听接口、发布逻辑)并对应 Spring 注解。
- 组合、聚合、关联在 UML 上怎么画?代码层面如何区分?
小结一句话
设计模式是「封装变化点、靠组合松耦合」的经验结晶,UML 是让结构被团队看见的语言,而 Spring 把其中最常用的模式直接做进了容器——理解模式就是理解现代 Java 框架的设计语法。
延伸