烧水器事件簿 [Design, C#]
WRITTEN BY ALLEN LEE
0. 目录
1. 烧水器事件
Paul 是某公司某部门的员工,该部门的员工都是入住员工宿舍的。员工宿舍提供开水的地方和他们所住的地方相隔较远,于是他们凑钱买了个烧水器,但最近这个烧水器坏了。Paul 打算动员大家再凑钱买一个新的,可大家爱理不理、得过且过的,搞到这几天大家都在楼下的小卖部买矿泉水喝。Paul 和同宿舍的几个人商量了,打算就这几个人凑钱买一个烧水器,但东西买回来后,其他人肯定会趁机拿来用,不让其他人用是不可能的,于是,Paul 他们打算出台一些规定来维护“股东”们的权益。
以下是他们即将出台的使用烧水器的排队规则:
规则里面的条款明显体现了“股东”们的优先使用权。由于大家都有自己的电脑,于是 Paul 他们打算开发一个小型系统管理队列。透过这个系统,大家都能看到队列当前的状况。当上一个员工用完烧水器后,系统会通知下一个正在等候的员工。
2. 关于 PROTON
Proton 是 Paul 他们四个打算开发的用于管理队列的小型系统的开发代号。Proton 包括以下几个核心类:
本文将只讨论 Proton 的后方逻辑的设计与实现,并且重点放在上面所提到的4个核心类中。
3. BOILER
3.1 开始烧水...
一个普通的烧水器,无论是用煤气还是用电的,都必定具备烧水的功能,否则就只能当作装饰品了。当你打开煤气炉或者通电后,烧水器就会正式开始烧水了。这里,Enrollee 是通过 Boiler 的 Boil 方法让烧水器开始工作。
现实中的烧水器不会知道更不会记录自己所在的位置,但因为 Proton 在通知下一个使用者的时候必须告诉他到什么地方拿烧水器,于是我为烧水器加入一个 Location 属性指示其当前所在之处。
于是,Boiler.Boil 方法和 Boiler.Location 属性的代码可以这样写:
注意:Location 属性的 setter 使用了 internal 访问修饰符,这是因为该属性对于外界(例如 UI)应该为只读的,但当下一个使用者取得烧水器时,使用者应该把该属性的值改为他/她自己的房号。(Enrollee 和 Boiler 位于同一个程序集)
3.2 水开了要通知我哟!
如何知道水开了呢?以前,只要我们看到烧水器“冒烟”(沸水的蒸汽),就知道水开了;现在,很多烧水器都能够在水开的时候发出声音通知我们。无论 Paul 他们所买的烧水器是哪一种,Boiler 总要支持这种通知功能,而在 Proton 中,Boiler 通过事件(event)来支持这种功能:
使用者订阅(subscribe)Boiled,当水开了,调用 Done 方法,Boiler 就会通知使用者关掉煤气或者断掉电源。那么,谁又来调用 Done 呢?如果某烧水器足够先进,带有支持蓝牙技术的沸水传感器,并且厂商提供了供程序员使用的 SDK 就最好了。这样,Done 就可以关联到该传感器的相应事件中。可是,Paul 他们买的烧水器并没有这么先进,它只会在水开的时候发出一些刺耳的声音,让你不得不马上跑去拔开电源。那么,Paul 他们打算如何处理呢?一个可选的办法就是由使用者在水开了的时候手动调用 Done,但这样将会为系统带来很大的漏洞。你能猜到是什么样的漏洞吗?
3.3 时间真的是能解决一切问题的良方吗?
如果让使用者手动调用 Done,某些“非股东”可能会趁机。想象一下,水开了但不调用 Done 不就可以继续用下去了么?当然,一旦发现这样的“非股东”,将会在部门的网页上通报批评,剥夺烧水器使用权“终身”。
Paul 他们做了一个小实验,发现装满水的烧水器把水烧开最多只需30分钟。现在,他们打算运用“博弈论”的一些知识跟那些“非股东”来一场“博弈”,他们假设所有的“非股东”都足够聪明和理智,懂得最大限度利用这个烧水器,即所有的“非股东”都会把这30分钟用光。(Paul 他们使用了“博弈论”中的一个基本假设——人是经济人。)
那么,Paul 他们如何把这个假设用到 Boiler 的视线中呢?我相信你已经猜到了—— Timer!我在 Boiler 内部设定一个 System.Timers.Timer,把它的 Interval 属性设为30分钟,当使用者调用 Boil 时开始计时,时间一到 Timer 将调用 Done 通知使用者继而通知 Proton。
补充阅读:
《COMPARING THE TIMER CLASSES IN THE .NET FRAMEWORK CLASS LIBRARY》,ALEX CALVO
3.4 src\Boiler.cs
在 Boiler 的构造函数里,我把 m_Location 初始化为 String.Empty,更接近实际的做法是,每天大家用完烧水器后,把烧水器最后所在的地点持久化到文件或者数据库中,下次启动 Proton 时将从该文件读取数据,并通知第一个使用者到该地点拿烧水器。这里我假设每天大家用完烧水器后,最后一个使用者会自觉地把烧水器拿到 Paul 他们的房间,于是把 m_Location 的初始化为 String.Empty 就没问题了。
另外,这里把 m_Location 初始化为 System.Empty 或者 "" 都不会有什么明显区别。但在一些要同时把很多字符串变量初始化为空串的系统里,初始化为 String.Empty 比 "" 在性能上会更优,因为每次初始化为 "" 都会有一个新的字符串对象被创建,但 String.Empty 是一个静态只读字段,只会在 String 的静态构造函数中被初始化一次。(如果你希望知道它们之间的差距有多大,你可以做一些测量,但这已不在本文的讨论范围了。)
4. ENROLLEE
4.1 把“股东”和“非股东”区分开来。
Proton 的存在明显是为了保障“股东”们的权益,于是我们需要某些标示来协助区分“股东”和“非股东”,这样 Proton 才能应用 Paul 他们所订立的排队规则来管理队列。
这里,我采用枚举来标示使用者的优先权:
然后,在 Enrollee 里面加入一个 Priority 属性就行了:
说到这里,可能有人会禁不住问:为什么我们不用一个继承体系呢?把 Enrollee 声明为抽象类,然后派生出 LowPriorityEnrollee 和 HighPriorityEnrollee 呢?
毫无疑问,这个使用继承的方案是可行的,但不是必要的。我的想法是,对于 Proton,使用者的类别只有两种——“股东”和“非股东”,而且这个分类是稳定的,这样我们就没有必要使用继承体系,因为继承体系通常用于应对需要灵活改变子类别的分类,使之具有高度的可扩展性,但与此同时也会引入额外的复杂性。衡量之后,我认为这里采用枚举来标示分类是恰当的。
4.2 把开水倒进水壶...
水开了当然要把它倒进水壶,然后把烧水器传给下一个使用者。Paul 他们所买的烧水器在水开时能通过发出刺耳的声音来通知使用者,但 Enrollee 又是怎么得知水开了呢?还记得 Boiler 有一个 Boiled 事件吗?只要我们把倒开水的操作挂接到该事件就可以了。那么,我们又应该在什么时候进行挂接呢?当烧水器从一个使用者传到另一个使用者,实质上发生的是烧水器的使用权的转移,而挂接也应该发生在使用者正式行使该使用权的时候。说到这里,相信你已经知道挂接实际上应该在使用者为烧水器通上电源之时(如果烧水器里面没有水,就把该使用者当作毁坏他人物品处理吧),即使用者开始烧水之时。由于此时烧水器的使用权已经正式发生转移,烧水器的 Location 属性也应该做出相应的更改。于是,Enrollee.Boil 和 Enrollee.Water 两个方法的代码应该这样写:
倒完水之后呢?当然是通知下一个使用者过来拿烧水器啦。在 Enrollee 中,这个效果是通过 Watered 事件来实现的。那么,Enrollee 又是如何传递 Boiler 的呢?答案是通过事件参数。于是,我需要为 Enrollee 定义一个委托(delegate)和事件参数,并使该事件参数的实例能携带 Boiler 的实例:
在这里,this 的出现是为了消除命名歧义。
接下来,我为 Enrollee 设定 Watered 事件:
当使用者倒完水后,就可以调用 Done 来通知 Proton,Proton 会检查队列中是否还有等候的使用者,如果有,就通知他/她,并把烧水器传给他/她。在 Done 里面,我把 Boiler 的实例传给 EnrolleeEventArgs 的构造函数,使得该事件参数的实例携带 Boiler 的实例,以便将来可以透过事件机制把 Boiler 的实例传递给下一个使用者。当然,当烧水器传出去后,这个使用者就不再拥有烧水器了,于是把其 m_Boiler 设为 null。
在这里,this 的出现是为了协助传递当前实例。
除了上述两种情况,this 通常还会用在某构造函数调用另一构造函数时:
4.3 不需要 TIMER 的帮忙吗?
或许有人会认为,让使用者手动调用 Boil 和 Done 可能会为系统带来隐患。在实际的情况中,这是有可能的。但请记住,我们已经对使用者套用了“经济人”的人性假设,使用者会充分利用烧水器资源外,但他们是绝不会浪费宝贵的时间资源的,于是,当他们接过烧水器,他们会马上准备并开始烧水。另外,下一个正在等候的使用者也绝不会让正在倒开水的使用者在倒开水这一过程中占用过多的时间。所以,我们不需要 Timer 协助监视正在使用烧水器的使用者,后面等着的那些会主动想办法的了。
4.4 src\Enrollee.cs
评论