Observer观察者模式与OCP开放-封闭原则
在学习Observer观察者模式时发现它符合敏捷开发中的OCP开放-封闭原则, 本文通过一个场景从差的设计开始, 逐步向Observer模式迈进, 最后的代码能体现出OCP原则带来的好处, 最后分享Observer模式在自己的项目中的实现.
场景引入
- 在一户人家中, 小孩在睡觉, 小孩睡醒后需要吃东西.
- 分析上述场景, 小孩在睡觉, 小孩醒来后需要有人给他喂东西.
- 考虑第一种实现, 分别创建小孩类和父亲类, 它们各自通过一条线程执行, 父亲线程不断监听小孩看它有没有醒, 如果醒了就喂食.
public class Observer {
public static void main(String[] args) {
Child c = new Child();
Dad d = new Dad(c);
new Thread(d).start();
new Thread(c).start();
}
}
class Child implements Runnable {
boolean wakenUp = false;//是否醒了的标志, 供父亲线程探测
public void wakeUp(){
wakenUp = true;//醒后设置标志为true
}
@Override
public void run() {
try {
Thread.sleep(3000);//睡3秒后醒来.
wakeUp();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public boolean isWakenUp() {
return wakenUp;
}
}
class Dad implements Runnable{
private Child c;
public Dad(Child c){
this.c = c;
}
public void feed(){
System.out.println("feed child");
}
@Override
public void run() {
while(true){
if(c.isWakenUp()){//每隔一秒看看孩子是否醒了
feed();//醒了就喂饭
break;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
- 本设计的不合理之处: 父亲线程要每隔一秒去查看一次孩子是否醒了没, 如果小孩连睡三个小时, 父亲线程岂不得连着3个小时每隔一秒访问一下, 这样将极大地耗费掉cpu的资源. 父亲线程也不方便去做些其他的事情.
- 这可以说是一个糟糕的设计, 迫使我们对他作出改进.
下面为了能让父亲能正常干活, 我们把逻辑修改为改为小孩醒后通知父亲喂食.
public class Observer {
public static void main(String[] args) {
Dad d = new Dad();
Child c = new Child(d);
new Thread(c).start();
}
}
class Child implements Runnable {
private Dad d;//持有父亲对象引用
public Child(Dad d){
this.d = d;
}
public void wakeUp(){
d.feed();//醒来通知父亲喂饭
}
@Override
public void run() {
try {
Thread.sleep(3000);//假设睡3秒后醒
wakeUp();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class Dad{
public void feed(){
System.out.println("feed child");
}
}
- 以上的版本比起原版在性能上有了提升, 但是小孩醒后只能固定调用父亲的喂食方法, 父亲不知道任何小孩醒来的任何信息, 比如几点钟醒的, 睡了多久. 我们的程序应该具有适当的弹性, 可扩展性, 深入分析下, 小孩醒了是一个事件, 小孩醒来的时间不同, 父亲喂食的食材也可能不同, 那么如何把小孩醒来这一事件的信息告诉父亲呢?
- 如果对上面的代码进行改动的话, 最直接的方法就是给小孩添加睡醒时间字段, 调用父亲的
feed(Child c)
方法时把自己作为参数传递给父亲, 父亲通过小孩对象就能获得小孩醒来时的具体信息. - 但是根据面向对象思想, 醒来的时间不应该是小孩的属性, 而应该是小孩醒来这件事情的属性, 我们应该考虑创建一个事件类.
- 同样是在面向对象对象的原则下, 父亲对小孩进行喂食是父亲的行为, 与小孩无关, 所以小孩应该只负责通知父亲, 具体的行为由父亲决定, 我们还应该考虑舍弃父亲的
feed()
方法, 改成一个更加通用的actionToWakeUpEvent
, 对起床事件作出响应的方法. - 而且小孩醒来后可能不只被喂饭, 还可能被抱抱, 所以父亲对待小孩醒来事件的方法可以定义的更加灵活.
public class Observer {
public static void main(String[] args) {
Dad d = new Dad();
Child c = new Child(d);
new Thread(c).start();
}
}
class Child implements Runnable {
private Dad d;
public Child(Dad d){
this.d = d;
}
public void wakeUp(){//通过醒来事件让父亲作出响应
d.actionToWakeUpEvent(new WakeUpEvent(System.currentTimeMillis(), this));
}
@Override
public void run() {
try {
Thread.sleep(3000);
wakeUp();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class Dad{
public void actionToWakeUpEvent(WakeUpEvent event){
System.out.println("feed child");
}
}
class WakeUpEvent{
private long time;//醒来的事件
private Child source;//发出醒来事件的源
public WakeUpEvent(long time, Child source){
this.time = time;
this.source = source;
}
}
- 显然这个版本的可扩展性高了一些, 我们接着分析. 由于现在对小孩醒来事件的动作已经不止于喂食了, 如果现在加入一个爷爷类的话, 可以让爷爷在小孩醒来的时候作出抱抱小孩的响应.
- 但是引来的问题是, 要让爷爷知道小孩醒了, 必须在小孩类中添加爷爷字段, 假如还要让奶奶知道小孩醒了, 还要添加奶奶字段, 这种不断修改源代码的做法意味着我们的程序还存在改进的地方.
- 在《敏捷软件开发:原则、模式与实践》一书中曾谈到OCP(开发-封闭原则), 里面指出软件类实体(类, 模块, 函数等)应该是可以扩展的, 但是不可修改的. 为了满足OCP原则, 最关键的地方在于抽象, 在本例中, 我们可以把监听小孩醒来事件向上抽象出一个接口, 接口中有唯一的监听醒来事件的方法. 实现该接口的实体类可以根据醒来事件作出各自的动作.
- 小孩发出醒来事件后可以不单止通知父亲一人, 他可以把醒来事件发送给所有在他这注册过的监听者.
- 所以当作出这样的抽象后, 就不单止孩子能发出醒来的事件了, 小狗也能发出醒来的事件, 并被监听.
public class Observer {
public static void main(String[] args) {
Child c = new Child();
c.addWakeUpListener(new Dad());
c.addWakeUpListener(new GrandFather());
c.addWakeUpListener(new Dog());
new Thread(c).start();
}
}
class Child implements Runnable {
private ArrayList<WakeUpListener> list = new ArrayList<>();
public void addWakeUpListener(WakeUpListener l){//对外提供注册监听的方法
list.add(l);
}
public void wakeUp(){
for(WakeUpListener l : list){//通知所有监听者
l.actionToWakeUpEvent(new WakeUpEvent(System.currentTimeMillis(), this));
}
}
@Override
public void run() {
try {
Thread.sleep(3000);
wakeUp();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
interface WakeUpListener{
public void actionToWakeUpEvent(WakeUpEvent event);
}
class Dad implements WakeUpListener{
@Override
public void actionToWakeUpEvent(WakeUpEvent event){
System.out.println("feed child");
}
}
class GrandFather implements WakeUpListener{
@Override
public void actionToWakeUpEvent(WakeUpEvent event) {
System.out.println("hug child");
}
}
class Dog implements WakeUpListener{
@Override
public void actionToWakeUpEvent(WakeUpEvent event) {
System.out.println("wang wang...");
}
}
class WakeUpEvent{
private long time;
private Child source;//事件源
public WakeUpEvent(long time, Child source){
this.time = time;
this.source = source;
}
}
- 通过上面的例子, 我们能清楚地看到整个观察者模式的模型, 当一个对象的发出某个事件后, 会通知所有的依赖对象, 在OCP原则下, 依赖对象响应事件的具体动作和事件发生源是完全解耦的, 我们可以在不修改源码的情况下随时加入新的事件监听者, 作出新的响应.
在联网坦克项目中使用观察者模式
- 之前写了个网络版的坦克小游戏, 这里是项目的GitHub地址
- 在学习观察者模式后进一步考虑游戏中可以改进的地方. 现在子弹打中坦克的逻辑是这样的: 子弹检测到打中坦克后, 首先它会设置自己的生命为
false
, 然后设置坦克的生命也为false
, 最后产生一个爆炸并向服务器发送响应的消息.
public boolean hitTank(Tank t) {//子弹击中坦克的方法
if(this.live && t.isLive() && this.good != t.isGood() && this.getRect().intersects(t.getRect())) {
this.live = false;//子弹死亡
t.setLive(false);//坦克死亡
tc.getExplodes().add(new Explode(x - 20, y - 20, tc));//产生一个爆炸
return true;
}
return false;
}
- 这个设计显然不太符合面向对象思想, 因为子弹打中坦克后, 子弹设置为死亡是子弹的事, 但是坦克死亡则应该是坦克自己的事情.
- 在原本的设计中, 如果我们想给坦克加上血条不希望它被打中一次就死亡, 那么就得在子弹打中坦克的方法中修改, 代码的可维护性降低了.
- 下面将使用Observer观察者模式对这部分代码进行重写, 让坦克自己对被子弹打中作出响应, 并给坦克加入血条, 每被打中一次扣20滴血.
/**
* 坦克被击中事件监听者(由坦克实现)
*/
public interface TankHitListener {
public void actionToTankHitEvent(TankHitEvent tankHitEvent);
}
public class TankHitEvent {
private Missile source;
public TankHitEvent(Missile source){
this.source = source;
}
//省略 get() / set() 方法...
}
/* 坦克类 */
public class Tank implements TankHitListener {
//...
@Override
public void actionToTankHitEvent(TankHitEvent tankHitEvent) {
this.tc.getExplodes().add(new Explode(tankHitEvent.getSource().getX() - 20,
tankHitEvent.getSource().getY() - 20, this.tc));//坦克自身产生一个爆炸
if(this.blood == 20){//坦克每次扣20滴血, 如果只剩下20滴了, 那么就标记为死亡.
this.live = false;
TankDeadMsg msg = new TankDeadMsg(this.id);//向其他客户端转发坦克死亡的消息
this.tc.getNc().send(msg);
this.tc.getNc().sendClientDisconnectMsg();//和服务器断开连接
this.tc.gameOver();
return;
}
this.blood -= 20;//血量减少20并通知其他客户端本坦克血量减少20.
TankReduceBloodMsg msg = new TankReduceBloodMsg(this.id, tankHitEvent.getSource());//创建消息
this.tc.getNc().send(msg);//向服务器发送消息
}
//...
}
/* 子弹类 */
public class Missile {
//...
public boolean hitTank(Tank t) {//子弹击中坦克的方法
if(this.live && t.isLive() && this.good != t.isGood() && this.getRect().intersects(t.getRect())) {
this.live = false;//子弹死亡
t.actionToTankHitEvent(new TankHitEvent(this));//告知观察的坦克被打中了
return true;
}
return false;
}
//...
}
总结
- 观察者模式遵循了OCP原则, 在这种消息广播模型中运用观察者模式能提高我们程序的可扩展性与可维护性.
- 从实战项目我们也可以看到, 如果要运用观察者模式必然要增添一些代码量, 对应的是开发成本的增加, 在坦克项目中我是为使用设计模式而使用设计模式, 其实如果仅仅从简单能用的角度来看, 观察者模式可能不是一种最佳选择.
- 但由于现在处于学习阶段, 我认为不能因为项目小而不追求更合理的设计, 观察者模式实现了消息发布者和观察者之间的解耦, 使得观察者能够独立处理响应, 符合面向对象思想; 同时对观察者进行抽象, 使得我们可以不修改源码, 通过添加的方式加入更多的观察者, 符合OCP原则, 这是我学习观察者模式最大的收获.