烧水器事件簿 [Design, C#]
烧水器事件簿 [Design, C#]
WRITTEN BY ALLEN LEE
0. 目录
- 1. 烧水器事件
- 2. 关于 PROTON
- 3. BOILER
- 4. ENROLLEE
- 5. ENROLLEEQUEUE
- 6. ENROLLEEMANAGER
- 7. 关于设计
1. 烧水器事件
Paul 是某公司某部门的员工,该部门的员工都是入住员工宿舍的。员工宿舍提供开水的地方和他们所住的地方相隔较远,于是他们凑钱买了个烧水器,但最近这个烧水器坏了。Paul 打算动员大家再凑钱买一个新的,可大家爱理不理、得过且过的,搞到这几天大家都在楼下的小卖部买矿泉水喝。Paul 和同宿舍的几个人商量了,打算就这几个人凑钱买一个烧水器,但东西买回来后,其他人肯定会趁机拿来用,不让其他人用是不可能的,于是,Paul 他们打算出台一些规定来维护“股东”们的权益。
以下是他们即将出台的使用烧水器的排队规则:
- 1) Paul、Henry、Chris 和 MC 四个人凑钱买了这个新的烧水器,他们是“股东”;
- 2) 当原队列只有“股东”时,后加入的“股东”将排在队列末尾,而后加入的“非股东”也将排在队列末尾;
- 3) 当原队列只有“非股东”并且第一个“非股东”正在使用烧水器烧水时,后加入的“股东”将插入正在使用烧水器的“非股东”后面、第一个正在等候烧水器的“非股东”前面,而后加入的“非股东”将排在队列末尾;
- 4) 当原队列既有“股东”又有“非股东”时,后加入的“股东”将会插在队列中最后一个“股东”后面、第一个正在等候烧水器的“非股东”前面。
规则里面的条款明显体现了“股东”们的优先使用权。由于大家都有自己的电脑,于是 Paul 他们打算开发一个小型系统管理队列。透过这个系统,大家都能看到队列当前的状况。当上一个员工用完烧水器后,系统会通知下一个正在等候的员工。
2. 关于 PROTON
Proton 是 Paul 他们四个打算开发的用于管理队列的小型系统的开发代号。Proton 包括以下几个核心类:
- 1) BOILER:从管理学的角度来看,可供分配和使用的资源永远是稀缺的、不足的,也正因为这种稀缺和不足,人们才需要通过管理务求使得这些资源能够发挥最大的效益。然而,资源使用后所发挥的效益的获益者是有偏向性的。在这起烧水器事件中,烧水器就是这一稀缺的资源,而它的使用效益是应该偏向于集资的“股东”们。Boiler 就代表了这个烧水器。
- 2) ENROLLEE:Enrollee 代表着与烧水器事件有关的员工,包括“股东”和“非股东”。Proton 的存在意味着资源的收益的分配是不平均的,于是 Enrollee 的设计必须能够体现出“股东”和“非股东”的区别。
- 3) ENROLLEEQUEUE:为了维护“股东”们的权益,Paul 他们订立了一系列的排队规则。EnrolleeQueue 是一个集合类,提供了必要的接口用于队列操作,它的内部逻辑必须体现出 Paul 他们订立的排队规则。
- 4) ENROLLEEMANAGER:EnrolleeManager 是 Proton 的后方逻辑负责人,它负责要使用烧水器(Boiler)的员工(Enrollee)的注册和注销、使用排队规则(EnrolleeQueue)来管理等候队列、处理烧水器(Boiler)的分配等。
本文将只讨论 Proton 的后方逻辑的设计与实现,并且重点放在上面所提到的4个核心类中。
3. BOILER
3.1 开始烧水...
一个普通的烧水器,无论是用煤气还是用电的,都必定具备烧水的功能,否则就只能当作装饰品了。当你打开煤气炉或者通电后,烧水器就会正式开始烧水了。这里,Enrollee 是通过 Boiler 的 Boil 方法让烧水器开始工作。
现实中的烧水器不会知道更不会记录自己所在的位置,但因为 Proton 在通知下一个使用者的时候必须告诉他到什么地方拿烧水器,于是我为烧水器加入一个 Location 属性指示其当前所在之处。
于是,Boiler.Boil 方法和 Boiler.Location 属性的代码可以这样写:
private string m_Location;
public string Location
{
get { return m_Location; }
internal set { m_Location = value; }
}
public void Boil()
{
Console.WriteLine("Boiling at Room {0}...", m_Location);
}
注意:Location 属性的 setter 使用了 internal 访问修饰符,这是因为该属性对于外界(例如 UI)应该为只读的,但当下一个使用者取得烧水器时,使用者应该把该属性的值改为他/她自己的房号。(Enrollee 和 Boiler 位于同一个程序集)
3.2 水开了要通知我哟!
如何知道水开了呢?以前,只要我们看到烧水器“冒烟”(沸水的蒸汽),就知道水开了;现在,很多烧水器都能够在水开的时候发出声音通知我们。无论 Paul 他们所买的烧水器是哪一种,Boiler 总要支持这种通知功能,而在 Proton 中,Boiler 通过事件(event)来支持这种功能:
public event EventHandler Boiled;
protected virtual void OnBoiled(EventArgs e)
{
if (Boiled != null)
{
Boiled(this, e);
}
}
public void Done()
{
OnBoiled(EventArgs.Empty);
}
使用者订阅(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 他们所订立的排队规则来管理队列。
这里,我采用枚举来标示使用者的优先权:
public enum EnrolleePriority
{
Low,
High
}
然后,在 Enrollee 里面加入一个 Priority 属性就行了:
private EnrolleePriority m_Priority;
public EnrolleePriority Priority
{
get { return m_Priority; }
}
说到这里,可能有人会禁不住问:为什么我们不用一个继承体系呢?把 Enrollee 声明为抽象类,然后派生出 LowPriorityEnrollee 和 HighPriorityEnrollee 呢?
毫无疑问,这个使用继承的方案是可行的,但不是必要的。我的想法是,对于 Proton,使用者的类别只有两种——“股东”和“非股东”,而且这个分类是稳定的,这样我们就没有必要使用继承体系,因为继承体系通常用于应对需要灵活改变子类别的分类,使之具有高度的可扩展性,但与此同时也会引入额外的复杂性。衡量之后,我认为这里采用枚举来标示分类是恰当的。
4.2 把开水倒进水壶...
水开了当然要把它倒进水壶,然后把烧水器传给下一个使用者。Paul 他们所买的烧水器在水开时能通过发出刺耳的声音来通知使用者,但 Enrollee 又是怎么得知水开了呢?还记得 Boiler 有一个 Boiled 事件吗?只要我们把倒开水的操作挂接到该事件就可以了。那么,我们又应该在什么时候进行挂接呢?当烧水器从一个使用者传到另一个使用者,实质上发生的是烧水器的使用权的转移,而挂接也应该发生在使用者正式行使该使用权的时候。说到这里,相信你已经知道挂接实际上应该在使用者为烧水器通上电源之时(如果烧水器里面没有水,就把该使用者当作毁坏他人物品处理吧),即使用者开始烧水之时。由于此时烧水器的使用权已经正式发生转移,烧水器的 Location 属性也应该做出相应的更改。于是,Enrollee.Boil 和 Enrollee.Water 两个方法的代码应该这样写:
public void Boil()
{
m_Boiler.Location = m_Location;
m_Boiler.Boiled += new EventHandler(Water);
m_Boiler.Boil();
}
private void Water(object sender, EventArgs e)
{
Console.WriteLine("Watering by {0}...", m_Name);
}
倒完水之后呢?当然是通知下一个使用者过来拿烧水器啦。在 Enrollee 中,这个效果是通过 Watered 事件来实现的。那么,Enrollee 又是如何传递 Boiler 的呢?答案是通过事件参数。于是,我需要为 Enrollee 定义一个委托(delegate)和事件参数,并使该事件参数的实例能携带 Boiler 的实例:
class EnrolleeEventArgs : EventArgs
{
public EnrolleeEventArgs(Boiler boiler)
{
this.Boiler = boiler;
}
public Boiler Boiler;
}
delegate void EnrolleeEventHandler(object sender, EnrolleeEventArgs e);
在这里,this 的出现是为了消除命名歧义。
接下来,我为 Enrollee 设定 Watered 事件:
public event EnrolleeEventHandler Watered;
protected virtual void OnWatered(EnrolleeEventArgs e)
{
if (Watered != null)
{
Watered(this, e);
}
}
public void Done()
{
m_Boiler.Boiled -= new EventHandler(Water);
OnWatered(new EnrolleeEventArgs(m_Boiler));
m_Boiler = null;
}
当使用者倒完水后,就可以调用 Done 来通知 Proton,Proton 会检查队列中是否还有等候的使用者,如果有,就通知他/她,并把烧水器传给他/她。在 Done 里面,我把 Boiler 的实例传给 EnrolleeEventArgs 的构造函数,使得该事件参数的实例携带 Boiler 的实例,以便将来可以透过事件机制把 Boiler 的实例传递给下一个使用者。当然,当烧水器传出去后,这个使用者就不再拥有烧水器了,于是把其 m_Boiler 设为 null。
在这里,this 的出现是为了协助传递当前实例。
除了上述两种情况,this 通常还会用在某构造函数调用另一构造函数时:
class MyClass
{
public MyClass()
: this(String.Empty)
{
}
public MyClass(string something)
{
//
}
}
4.3 不需要 TIMER 的帮忙吗?
或许有人会认为,让使用者手动调用 Boil 和 Done 可能会为系统带来隐患。在实际的情况中,这是有可能的。但请记住,我们已经对使用者套用了“经济人”的人性假设,使用者会充分利用烧水器资源外,但他们是绝不会浪费宝贵的时间资源的,于是,当他们接过烧水器,他们会马上准备并开始烧水。另外,下一个正在等候的使用者也绝不会让正在倒开水的使用者在倒开水这一过程中占用过多的时间。所以,我们不需要 Timer 协助监视正在使用烧水器的使用者,后面等着的那些会主动想办法的了。
4.4 src\Enrollee.cs
在正式的代码中,我们应该把 Enrollee 的构造函数声明为 internal,而它的实例由工厂创建,创建的时候所有使用者的信息将从数据库读取。每一个使用者都有一个唯一的 ID,创建实例的时候把该 ID 作为参数传递给工厂。
Enrollee someone = EnrolleeFactory.GetEnrollee(id);
5. ENROLLEEQUEUE
5.1 分层的队列。
Paul 他们订立的排队规则一共有4条,其中第一条为基础规则,其余三条是工作规则。第一条规则已经在 Enrollee 内部通过 EnrolleePriority 实现了,接下来就要实现其余的三条规则。
虽然这些规则看起来有点繁琐,但细心留意的话,你将发现这条队列有一个很突出的特点——分层装载。想象向一杯清水倒进花生油,你会发觉花生油总会“自觉”“插队到”清水的上面,清水和花生油是明显分开的,而且最先溢出的也是花生油。这其中的道理相信初中生都能说得清楚,然而,这却和我们正在处理的队列问题十分相似。“股东”总会优先的插队到“非股东”前面,而且如果队列混杂“股东”和“非股东”的话,排在前头的肯定是“股东”,即“股东”最先“溢出”,这就会造成队列中的“股东”和“非股东”明显的分层特点,就像花生油和清水那样。
于是,我在 EnrolleeQueue 内部安排两条队列来“分层”装载这两种使用者:
private Queue<Enrollee> m_LowPriorityQueue;
private Queue<Enrollee> m_HighPriorityQueue;
在这里,我使用了泛型集合类 Queue<T>,选用它的原因很简单,因为它本身就是为处理队列问题而存在的,能够很好的支持队列模型特有的操作。但由于它并不持插队,所以我需要对其进行一番包装。
5.2 排队还是插队?
根据排队规则,“股东”有权插队,而“非股东”只能按一般队列规则排队,所以在处理新加入队列的使用者时,就需要区别对待了:
public void Enqueue(Enrollee enrollee)
{
switch (enrollee.Priority)
{
case EnrolleePriority.Low:
if (!m_LowPriorityQueue.Contains(enrollee))
{
m_LowPriorityQueue.Enqueue(enrollee);
}
break;
case EnrolleePriority.High:
if (!m_HighPriorityQueue.Contains(enrollee))
{
m_HighPriorityQueue.Enqueue(enrollee);
}
break;
}
}
根据当前的需求,使用者的分类是稳定的,所以我们无需担心 switch 会为我们带来扩展性问题。记住,不是任何时候都要使用多态性来处理分类问题,使用哪一种应该在衡量具体的情况后再作决定。
补充阅读:
《今天你多态了吗?》,ALLEN LEE
5.3 轮到谁了?
单纯“插队”是不能完整体现出“股东”的优先权的,下一个使用者的筛选算法才是重点。我们知道,如果队列中同时存在“股东”和“非股东”,那么“股东”总是最先“溢出”的,就像花生油那样:
public Enrollee Dequeue()
{
if (m_HighPriorityQueue.Count != 0)
{
return m_HighPriorityQueue.Dequeue();
}
else
{
return m_LowPriorityQueue.Dequeue();
}
}
代码的算法非常简单,只有当队列中不存在“股东”,才考虑从“非股东”子队列中“溢出”使用者,否则,“股东”总是最先“溢出”的。
5.4 有人要中途退出么?
如果某正在排队等候的使用者突然有事要离去呢?这在现实中也是常有的。总不能后面所有的使用者等他回来吧?于是,EnrolleeQueue 提供了 Remove 方法处理这种情况:
public void Remove(Enrollee enrollee)
{
switch (enrollee.Priority)
{
case EnrolleePriority.Low:
((ICollection<Enrollee>)m_LowPriorityQueue).Remove(enrollee);
break;
case EnrolleePriority.High:
((ICollection<Enrollee>)m_HighPriorityQueue).Remove(enrollee);
break;
}
}
我们知道,这种不指明位置的删除操作肯定涉及到对象的比较,而 ICollection<T>.Remove 在进行对象比较时,首先检测对象是否实现了System.IComparable<T>,如果是,就会使用其中的比较算法;否则就会进一步检测对象是否实现了 System.IComparable,如果是,将会使用其中的比较算法;如果这两个接口都没有实现,ICollection<T>.Remove 将会使用 System.Object.Equals 方法来进行比较。所以,我们应该为 Enrollee 实现 System.IComparable<T>:
public class Enrollee : System.IComparable<Enrollee>
{
//
IComparable
}
5.5 src\EnrolleeQueue.cs
注意,我在这里展示的 EnrolleeQueue 只是一个最小可行方案,你会发现它不实现任何的集合接口,而且只提供很少的操作,在正式的代码中,你可以为它扩展,例如加入迭代器功能等。
6. ENROLLEEMANAGER
6.1 我的执法官。
国家所制定的法律有其所适用的群体,并由国家机器强制执行。与此类似,在 Proton 中,Paul 他们所制定的排队规则由 EnrolleeManager 强制执行,而这些规则的使用群体是烧水器的使用者。
无论你打算加入或者退出使用者的行列,你都必须通知 EnrolleeManager:
public void Enroll(Enrollee enrollee)
{
m_Queue.Enqueue(enrollee);
}
public void UnEnroll(Enrollee enrollee)
{
m_Queue.Remove(enrollee);
}
当你告知 EnrolleeManager 你打算加入使用者的行列时,你将会受到排队规则的约束;当你告知 EnrolleeManager 你打算退出使用者的行列时,你将不再受到这些规则的约束。用一些比较奇幻的文字来描述,就是“执法官”在 Paul 所在的部门的员工中形成一个结界,分割出两个空间,处于结界里面的所有人都将受到排队规则的作用,这个结界有一个入口和一个出口,它们分别是 Enroll 和 UnEnroll。
6.2 通知下一个使用者...
当前的使用者用完烧水器后,EnrolleeManager 就要通知下一个正在等候的使用者前去拿烧水器了。在 Proton 中,EnrolleeManager 首先检测 EnrolleeQueue 中是否有正在等候的使用者,有的话就通知他/她,并把烧水器传给他/她。那么,EnrolleeManager 又是如何得知上一个使用者用完了烧水器呢?还记得 Enrollee 有一个 Watered 事件吗?只要把 EnrolleeManager 通知下一个使用者的方法挂接到 Watered 事件就行了。然而,我们又应该在什么时候进行挂接呢?答案当然是员工进入“执法官”所形成的“结界”时。于是,我们要在 Enroll 方法中加入挂接事件的语句。
public void Enroll(Enrollee enrollee)
{
m_Queue.Enqueue(enrollee);
enrollee.Watered += new EnrolleeEventHandler(MoveNext);
}
private void MoveNext(object sender, EnrolleeEventArgs e)
{
if (m_Queue.Count == 0)
{
return;
}
m_Enrollee = m_Queue.Dequeue();
m_Enrollee.Boiler = e.Boiler;
}
6.3 src\EnrolleeManager.cs
员工的状态可以有三种:
- 1) WORKING;
- 2) QUEUING;
- 3) IDLE。
第一种和第二种就是我们所熟悉的“正在烧水”和“正在排队”。而第三种比较特殊,它指示员工当前没有使用烧水器的打算,可能是该员工已经用过了烧水器或者今天不打算使用等等,但他已经登录了 Proton,并且随时有可能改变主意跑去排队。如果某员工已经用过了烧水器,并且目前不打算使用的话,那么他将不受到排队规则的作用,我们可以说他不在“结界”内,那么 MoveNext 应该与该员工的 Watered 脱钩;如果某员工原本在队列中,但因为突然有事离开了队列,但没有退出 Proton,即他随时可能会来重新排队,那么 UnEnroll 也应该与该员工的 Watered 脱钩。
或许有人会觉得这种“脱钩”的做法有点多余,但我们不能排除有人不小心的甚至故意的按下 GUI 上的「完成」按钮,使得 MoveNext 错误的被调用。当然,你可以有更多的办法防止这种事情的发生,例如,你可以为 Enrollee 加入状态标示,仅当使用者处于 Working 状态,「完成」按钮才可用。
7. 关于设计
7.1 基本假设...
因为系统最终是给人使用的,所以难免需要对人的反应有个了解和把握。而人的反应不属于软件开发的范畴,它属于行为学的范畴。为了使得系统更能反映实际的情况,以及能够更好的简化一些不必要的系统特性,我们就需要利用一些行为学的知识了。
在这个 Proton 的设计过程中,我使用了人性假设中的“经济人”假设。这种人性假设被 D.McGergor 在其《在企业中的人性方面》一书中概括为 X 理论。“经济人”假设认为:人是由经济诱因引发工作动机的;人总是被动地在组织的操纵、激励和控制下从事工作;人总是企图用最小投入取得满意的报酬;大多数的人缺乏理性,不能克制自己,很容易受别人影响,组织必须设法控制个人的感情。Taylor 就是“经济人”假设的典型代表,它建议采用“胡萝卜加大棒”的管理方法。
另外,基本假设不能随便使用,仅当实际的情况和基本假设所描述的模型相关,并且基本假设有助于更好的了解现实情况和简化设计时,我们才应该使用它。
如果你对这些知识有兴趣的话,可以参考一些资料研究一下,相信能够对你的系统设计有所帮助的。但请注意,没有必要也不可能过分深入地研究,过分深入地研究不会对你的系统设计带来更多的作用,这是由边际效用递减规律所决定的。
7.2 PROTON 的复用。
我在这里所说的复用并不是指复用 Proton 的任何代码,我指的是从一个更高的层次——概念层次(Conceptual Level)——复用 Proton。
我们知道,Proton 实质上是一个管理系统,而管理系统通常都是用来管理稀缺资源的分配的。概念上,一个管理系统应该包括以下4个必要的部分:
- 1) RESOURCE:待分配的稀缺资源;
- 2) USER:资源的使用者;
- 3) USERQUEUE:使用者的管理规则;
- 4) USERMANAGER:管理规则的执行官。
当然,实际的复用中,直接照搬肯定是不行的,所以我们要坚持具体问题具体分析。
7.3 PROTON 的完善。
我在这里所展示的代码都经过了测试,但它远未能达到正式投入使用的程度。至少,有3大块必需的工作等着我们:
- 1) UI。这是显然的,无论是图形界面还是字符界面(如果用户不介意的话)。
- 2) NETWORKING。这也是显然的,Proton 应该将客户端软件安装在各个用户的电脑上,让用户在自己的电脑上进行操作(只要条件允许的话)。当然,除了 Windows Application,Web Application 也是一个值得考虑的选择。
- 3) MULTITHREATING。这个不是太显然,但为了改善用户体现,多线程处理有时候还是必要的。此时,Timer 的恰当处理就会变得很重要了。
然而,这3块每一块都足以写成一篇独立的文章。为了突出本文的重点和节省篇幅,我已经把与此相关的内容都省略了,而这些内容将留给有兴趣研究的朋友自行研究。