缘起
每次有新技术发布时,我们总能感受到两种截然不同的情绪:一种是恐惧和抵抗,伴随着这种情绪的还有诸如"C# 2.0用的挺好的,为什么要在C# 3.0搞到那么复杂?"或者"我还在使用C# 1.0呢?"等言辞;另一种则是兴奋和拥抱,伴随着这种情绪的还有诸如"原来这个问题在C# 3.0里可以这么简单!"等言辞。
最近我在公司内部做一个LINQ的系列讲座,在我为其中C# 3.0新特性这一讲准备演示文稿时,突然萌生了写下这篇文章的念头。语言的特性乃至其本身并没有对错之分,是否接受在很大程度上是一个感性问题,即你是否喜欢这样的做事方式,我并没有打算说服任何人接受C# 3.0和LINQ,写这篇文章也只是想和大家分享一下我自己的感受。
有一次我观看一个关于Expression Blend的培训视频,里面说了一句让我印象非常深刻的话:
I know how it works because I know why it works.
细细品味这句话,你会感受到它所要传达的信息:理解为何需要这个功能可以帮助你更好地理解如何使用这个功能,而这也正是我要在这篇文章里采用的表达方式。
你是如何创建属性的?
如果你长期使用C#,相信你不会对属性这个东西感到陌生。一般地,属性是对私有字段的一个简单包装,就像这样:
代码 1
使用属性而不是直接公开私有字段的一个好处就是在属性的获取访问器或设置访问器里加入额外的逻辑并不会为客户端代码带来麻烦,例如你想在设置标题的时候做一些额外的检查。但如果你只是简单地包装一下,像上面的代码那样,就会发现你其实多写了不少可以省略的代码。既然Title属性和m_Title私有字段对应,获取访问器就肯定是返回m_Title的值,而设置访问器也肯定是把值设到m_Title。再者,如果你只通过Title属性来访问这个数据,那么m_Title私有字段就会变得无足轻重,这样的话,为什么不交给编译器代劳呢?这个时候,C# 3.0的自动属性就可以派上用场了:
代码 2
编译器会为你创建一个私有字段,并让获取访问器和设置访问器指向这个私有字段。当然,如果有需要,例如要在获取访问器或设置访问器里加入额外的逻辑时,你随时可以对获取访问器和设置访问器进行展开。
你是如何初始化对象的?
现在,假设我们有这样一个类:
代码 3
你会怎样初始化它?一种做法是用Book的默认构造函数创建对象实例,然后分别为每个属性赋值:
代码 4
另一种做法是使用C# 3.0对象初始化器:
代码 5
乍看一下,C# 3.0的做法似乎没有让人感到任何优越感,现在,请你仔细观察一下,这两份代码分别包含多少个";"?代码4有5个";",意味着它用了5个语句进行初始化;而代码5只有1个";",意味着它只用了1个语句进行初始化。从词法的角度来看,如果此刻我只能接受一个表达式,那么代码4的做法就帮不上忙了。一个变通的方法是为Book类提供带参的构造函数,但这种方法也有弊端,用户可能只想在初始化时为部分属性提供数据,而我们又无法确切预知用户会提供哪些属性的组合,于是,我们可能要为用户提供足够多的构造函数重载,嗯,有点无聊,也有点多余。另一个变通的方法是提供接受最多参数的构造函数,如果用户为某个参数传递null,那么就忽略与之对应的属性,这个方法比较接近代码5的做法,不同的是,如果你的属性很多,而用户关心的只是很少一部分,就可能不得不输入很多null了。
现在,假设你要实例化一组Book对象,并把它们储存在一个集合里,你会怎么做?下面是通常的做法:
代码 6
如果结合使用C# 3.0的对象初始化器和集合初始化器,你就可以把代码简化为:
代码 7
集合里的每个元素通过","分割,结合对象初始化器使用,整个集合的结构显得比较明晰。字典的初始化也可以同样简单:
代码 8
说到这里,我相信你也能感觉到,C#似乎正在表达式化,以前需要很多条语句才能做到的事情,现在却可以用单个表达式描述出来,而这种理念也***在整个C# 3.0的氛围里。
你是如何把运算逻辑外包出去的?
假设我现在得到了一组Book的实例对象,你要对它们进行排序,那么你如何告诉它你要按价格来排序呢?
代码 9
在C# 1.0里,我们需要特意为它提供一个独立的方法:
代码 10
然后向Sort()方法传入所需委托的实例:
代码 11
这在C# 2.0里可以进一步简化为:
代码 12
如果使用C# 2.0的匿名方法,我们可以省去很多不必要的代码:
代码 13
此外,使用匿名方法,Sort()方法和你希望它用来比较两个Book实例对象的逻辑可以放在同一个地方;而使用独立的命名方法,包含这个逻辑的方法可能会由于整理代码而被挪到别的地方。这样,当你看到代码12时,为了了解它内部的实现,就不得不花一些精力去寻找Compare()方法了。当然,你可以争辩说,我们可以制定一个编码规范,使得Compare()方法必须紧贴在Sort()方法的下方。是的,你可以,但如果这个逻辑并不需要重用,那么使用匿名方法还是具有明显的优势的。如果这个逻辑需要重用,那么匿名方法就无能为力了。
现在,让我们来考察一下代码13,有没有发现匿名方法的表达方式还不够简练?我们知道,books集合里面只有Book的实例对象,所以Sort()方法传给我们两个参数的类型必定是Book,而Sort()方法期待的结果正是x.Price.CompareTo(y.Price)这个表达式的运算结果,至于delegate和return这样的字眼可以说在这里完全是多余的,那么为什么我们不直接这样表达呢:
代码 14
这就是C# 3.0引入的Lambda表达式语法。我见过一些人,他们通常强调尽可能简单,但若事情突然变得比他们预期的还要简单很多,他们就开始感到不适,甚至拒绝接受这种简单,其实即使事物的发展方向和你的前进方向相一致,但如果发展速度大大超越了你,仍然有可能引发你内心对失控的恐惧。我希望Lambda表达式语法不会让你感到太大的不适,当然我更希望你会喜欢上它。
Lambda表达式的理解其实可以很简单,就是"=>"左边的参数参与右边的表达式运算,而运算结果将会返回,这有点像化合反应,即两种或两种以上的物质(左边的参数)生成一种新物质(右边的表达式的运算结果),不同的是,Lambda可以不接收任何参数,也可以不返回任何结果。
"=>"右边除了可以放表达式之外,还可以放语句,像这样:
代码 15
我们把它称为Lambda语句(Lambda Statement),或许你已经发现,它和匿名方法相比只是不需要写delegate关键字和参数类型。
你是如何为对象扩展与之相关的功能的?
我一直在想,为什么String类没有提供一个Reverse()方法,把字符串翻转呢?我猜可能是因为这种操作没有什么现实意思,除非你要做一个文字游戏。实现Reverse()方法并不难,下面是其中一种做法:
代码 16
使用方法也非常简单:
代码 17
你甚至可以把Reverse()方法放到某个静态类里,例如Utils,这样,代码17就可以变成:
代码 18
在C# 3.0之前,你最多只能走到这里,而到了C# 3.0,你还可以使用扩展方法对它做进一步调整,使代码18变成:
代码 19
怎么样,看上去就像Reverse()方法是属于String的,而你所需要做的仅仅是在Reverse()方法的target参数前面加上"this"关键字:
代码 20
我们知道,计算机的底层世界并不知道什么是面向对象,而我们在对象里定义的实例方法都包含一个隐藏参数,这个参数就是指向当前对象实例的指针,C# 3.0的扩展方法在形式上模仿了这种做法,但由于扩展方法本质上并不属于与之相关的类,所以你无法在扩展方法里访问类内部的私有成员。
就上面的讨论来说,你可能认为,和代码18相比,代码19并没有太大的优势,那么为什么需要扩展方法呢?假设我们手头上有一堆书,我想找到最便宜的LINQ的书,使用标准查询运算符的话可以这样写:
代码 21
我们知道,Where()、OrderBy()和First()等都是扩展方法,如果C# 3.0不支持扩展方法,那么代码21就不得不写成这样了:
代码 22
代码21的可读性明显比代码22的高,也显得更自然,而此时我们只是使用了3个标准查询运算符,你可以想象一下,在没有扩展方法的支持下要表达更复杂的查询会是怎样一番情景?
你是如何表达你想要的东西的?
现在,假设我想找到最便宜的LINQ的书,使用C# 2.0的语法,我可能需要这样:
代码 23
虽然我已经使用了Array.IndexOf()方法、List<T>.Sort()方法和匿名函数来简化代码,但仍然无法掩盖一个事实,那就是我在讲述如何获取我想要的东西,而这也正是命令式编程(Imperative Programming)的核心思想。
如果使用C# 3.0的语法,情况将会大不一样:
代码 24
在这里,你表达了你想要的东西,而不是获取这些东西的具体步骤,这是声明式编程(Declarative Programming)的核心思想,这样做的好处是明显的,你的需求可以被重新解析并执行,必要时还可以对底层的实现进行优化,但由于你并不关心和牵扯到具体的实现上,所以那些优化并不会导致你修改代码。
命令式编程就像过程管理,你深入执行的细节,继而对整个过程的执行实施控制;而声明式编程则像目标管理(MBO),你制定目标,并把任务分配下去执行。代码23给人的感觉就是整个执行过程都非常的清楚,你可以对任何一个步骤进行修改或者调优;而代码24给人的感觉就是你除了说出你想要什么,你什么也不能做,这对于那些过程管理拥戴者来说可能是不可接受的,他们感到对事物失去了控制,无法建立安全感,因而产生了焦虑。曾经有人向我抱怨:如果你使用了LINQ,你就只能迫使自己相信它的实现是很好的。想想看,如果你的公司把饭堂业务承包给一个餐饮公司,你的公司可以插手别人如何招聘厨师、如何采购食物、如何烧菜烧饭吗?选择LINQ意味着你愿意把执行细节交给别人去处理,从而脱离这些细节,如果你根本无法放下对这些细节的控制,那么LINQ可能并不适合你。
很难说这两种编程方式孰优孰劣,因为在某些场合下,善于过程管理的管理者确实更能让事态朝正确的方向发展;而在另一些场合下,目标管理为实现者提供足够的自由度,更能激励他们积极地进行思考。管理界对于过程管理和目标管理孰优孰劣之争论似乎从来没有停过,更何况编程界对于命令式编程和声明式编程孰优孰劣之争论,我个人倒是更倾向于把这看成是找出更适合你自己的风格,而不是盲目听信别人的说法。语言到底是发挥积极作用还是消极作用在很大程度上是取决于使用者的,我们应该使用语言有利的一面来协助我们的工作,而不是使用其有害的一面来伤害自己和别人。
回到代码24,它把满足条件的书的所有信息都返回给我,如果我只需要书名和作者名字呢?我们知道,在面向对象的世界里,信息储存在对象里,于是我们不得不走到一个尴尬的境地,那就是我们要为此创建一个临时类:
代码 25
噩梦正式开始了,如果我需要书名和价格呢?如果我需要书名、作者和价格呢?……(读者可以自行补全这个列表)这个时候就轮到C# 3.0的匿名类型和隐式类型化变量出场了:
代码 26
因为匿名类型是由编译器自动生成的,而在你写代码的时候它还没有名字,所以你无法用这个类型来声明这个变量,此时"var"关键字就派上用场了。这个是"var"关键字的最初目的,但得益于类型推断系统,我们还可以使用"var"关键字声明任何本地变量,只要我们在声明的同时给予它初始化,否则编译器无法进行推断。曾经有人问我:如果我想返回代码26里的wanted7怎么办?我们知道,方法的返回值需要明确给出类型,而在我们写下代码26时,编译器还没有给查询表达式里的匿名类型取名。如果你真的要把它返回,你只能把方法的返回值类型定为IEnumerable<object>,因为我们只能确定匿名类型是object的后代,但这样一来,客户端代码的日子就不太好过了,因为除了通过反射来访问你的对象,它别无他选。如果你真的要把它返回,那就意味着你和客户端代码有共享这个对象的需求,此时恰当的做法应该是使用命名类型。另外,代码26里构建匿名类型时的"book.Title"是"Title = book.Title"的简写,当你省略"Title ="时,编译器会假定你希望匿名类型的这个属性的名字和Book.Title的一样。
匿名类型还有一个有趣的地方,它曾经是可变的(mutable),后来却变成不可变的(immutable),
Sree在
《Immutable is, the new Anonymous Type》一文中给出了这个转变的解释。我们知道,在面向对象的世界里,对象封装并维护自身的状态,我们通过调用对象的方法所产生的副作用来影响对象的状态,而不可变则是函数式编程(Functional Programming)的核心特征,或许你已经感受到了,C# 3.0引入了大量函数式编程的东西,而函数式编程语言似乎也要风生水起,这究竟意味着什么呢?
前路在何方?
无论你是否承认,C# 3.0在表达上比它之前的版本要来的简单,但要获得这种简单,你必须先用很多东西武装自己的脑袋,这使我想起曾经在一本书里看到的一句话:
简单是由复杂来支撑的。
不同语言之间的相互***已经不再是什么新奇之事了,引入其它语言的功能有时候甚至可以看作是在战略上***对手的市场,这在某种程度上有点像金融业的混业经营。下一个版本的C#将会是怎样的呢?或许这个问题令你兴奋不已,你甚至希望现在就让C# Team看看你的创造力;或许这个问题令你痛心不已,你害怕自己无法适应下一波的变革,因为变革可能导致动荡,动荡可能带来失控,失控可能引发焦虑。不管怎样,该来的是无法回避的,或许现在先让我们看看Matthew Podwysocki的
《What Is the Future of C# Anyways?》是否有一些启示……
附:如果你有兴趣看看我的演示文稿,可以点击
这里下载。
今天突然发现园子里多了几篇MVP获奖感言的文章,所以也来凑个热闹。连续三次获得MVP称号是个什么样的感觉呢?很难描述,里面夹杂了激动和兴奋,又带上几分高处不胜寒,除了真心地说声谢谢之外,也不知道说什么好了,还是多些点文章比较实际,嘿嘿~
很多人有这么一种感觉:我的文章发布的很慢。其实,对于我来说,一篇发得出手的文章,从构思到最终发布,包含写Demo、打草稿、排版和审校等等,平均需要1~2周时间。我不知道用这么长的时间写一篇文章对大家来说意味着什么,只是如果东西还不能让我自己感到满意的话,我就不愿意拿出来。而在写文章的时候,我也希望尽可能写得详细一点,希望文章不仅仅停留在看完就算的程度,希望为大家打开一个思考的通道,希望把研究技术时活跃而发散的思维过程呈现给大家,希望把技术留给我的那份激动传递给大家……
加入博客园已经有三个多年头了,一路来也目睹博客园不断成长和壮大,而这里面 dudu的坚持是功不可没的,也是这份坚持让博客园熬过一个又一个的难关。现在博客园正向团队经营模式发展,我也衷心祝愿博客园越来越好。
p.s. 最近感冒流行,我已中招了,大家要小心身体啊~
很多人有这么一种感觉:我的文章发布的很慢。其实,对于我来说,一篇发得出手的文章,从构思到最终发布,包含写Demo、打草稿、排版和审校等等,平均需要1~2周时间。我不知道用这么长的时间写一篇文章对大家来说意味着什么,只是如果东西还不能让我自己感到满意的话,我就不愿意拿出来。而在写文章的时候,我也希望尽可能写得详细一点,希望文章不仅仅停留在看完就算的程度,希望为大家打开一个思考的通道,希望把研究技术时活跃而发散的思维过程呈现给大家,希望把技术留给我的那份激动传递给大家……
加入博客园已经有三个多年头了,一路来也目睹博客园不断成长和壮大,而这里面 dudu的坚持是功不可没的,也是这份坚持让博客园熬过一个又一个的难关。现在博客园正向团队经营模式发展,我也衷心祝愿博客园越来越好。
p.s. 最近感冒流行,我已中招了,大家要小心身体啊~
烧水器事件簿 II
Written by Allen Lee
缘起
两年前研究.NET事件机制时我写了一篇
《烧水器事件簿》,然而,那篇文章存在一个很大的问题,就是Proton的逻辑并不直观。后来,Microsoft发布了Windows Workflow Foundation,我就在想,如果用Windows Workflow Foundation重写Proton,情况会否有所改善?如果有,能有多大的改善?在重写的过程中,原有的代码中哪些可以重用?哪些需要做出修改?两年后的今天,我终于有机会提笔探个究竟了。
在这篇文章里,我将会探讨下列几个问题:
- 状态机工作流的开发;
- 工作流实例和外界的通信;
- 工作流实例的承载(hosting);
- 工作流实例的持久化与状态管理。
烧水器状态机工作流
毫无疑问,无论是烧水器事件,抑或是应运而生的Proton,都是以烧水器为中心的,于是,烧水器状态便顺理成章地成为Proton的重中之重了。
烧水器的状态不外乎就两个:空闲状态和工作状态。刚买回来的时候,它的状态是空闲的,自第一个用户开始,它就不断地在两个状态之间切换:工作状态、空闲状态、工作状态、空闲状态……直到它因为老化或者损坏而不能工作为止。
下面我们来看看如何用Visual Studio 2008为烧水器创建状态机工作流描绘这个逻辑。首先,创建一个State Machine Workflow Library项目:
图 1 - 创建烧水器工作流库
接着,在工作流设计器上添加烧水器的两个状态:BoilerIdleState和BoilerWorkingState,为这两个状态各添加一个EventDrivenActivity,并分别命名为StartBoiling和CompleteBoiling,然后设置状态的转换:
图 2 - 设计烧水器工作流
其中,BoilerIdleState被设为初始状态(Initial State)。或许你已经发现了,这个状态机没有完成状态(Completed State),正如烧水器自买回来的那一刻起就不会有所谓的"用完",一般所说的"用完"只是"空闲"的一个别称,只要烧水器还能使用,我们就会一直用下去,同样的道理,烧水器状态机模拟了这个过程,这就是为什么它没有完成状态。
当烧水器处于空闲状态时,它实际上是在等别人来用,用行话说,就是监听外部的请求事件,于是,我们需要在StartBoiling里添加一个HandleExternalEventActivity,并命名为HandleBoilerRequest:
图 3 - 设计BoilerIdleState里的工作流
而当烧水器接到某个用户的请求时,它就会开始工作,根据
《烧水器事件簿》里Paul他们的实验结果,烧水器工作的时间最多为30分钟,30分钟之后它将会通告工作完成,并进入空闲状态。要模拟烧水器的30分钟工作过程,DelayActivity是最合适不过了,而30分钟后的工作完成的通告就非CallExternalMetodActivity莫属了。在BoilerWorkingState里添加DelayActivity和CallExternalMethodActivity,并分别命名为Boiling和NotifyBoilerIdle。
图 4 - 设计BoilerWorkingState里的工作流
至此,我们已经勾画出Proton的核心逻辑了,然而,或者你已经注意到了,HandleBoilerRequest和NotifyBoilerIdle的右上角都有一个红色的叹号,它的出现意味着这些活动缺少一些必要的设置,而这正是下一节要处理的问题。
烧水器服务
回顾图2,我们在烧水器工作流里定义了两个状态:空闲状态和工作状态。当烧水器接到用户的使用请求时,它会从空闲状态转到工作状态;而当烧水器从工作状态转到空闲状态时,它会向用户发出通知。在这两个状态转换的过程里,烧水器工作流和用户发生了交互,那么,这些交互是如何做到的呢?
答案是Windows Workflow Foundation提供的本地通讯服务(Local Communication Services),它使得工作流实例和宿主应用程序之间的交互成为可能。之前我们分别在StartBoiling和CompleteBoiling里放置的HandleBoilerRequest和NotifyBoilerIdle就是为此而做的准备。然而,要使这两个世界连接起来,我们还需要定义一个充当"通讯标准"的接口,这个接口必须打上ExternalDataExchangeAttribute,里面定义的方法可以关联到CallExternalMetodActivity,而事件则可以关联到HandleExternalEventActivity,值得提醒的是,充当事件数据的类必须继承自ExternalDataEventArgs类,并打上SerializableAttribute。下面是IBoilerService接口的定义:
代码 1 - 烧水器服务接口
不难看出,NotifyBoilerIdle方法将会关联到NotifyBoilerIdle;而BoilerRequest事件则会关联到HandleBoilerRequest。选中StartBoiling里的HandleBoilerRequest,并在属性窗口里设置InterfaceType和EventName这两个属性:
图 5 - 设置HandleBoilerRequest
接着选中CompleteBoiling里的NotifyBoilerIdle,并在属性窗口里设置InterfaceType和MethodName这两个属性:
图 6 - 设置NotifyBoilerIdle
还记得HandleBoilerRequest和NotifyBoilerIdle的右上角都有一个红色的叹号吗?当你做好关联后,这个叹号就会消失了。
假如我们要在多处使用HandleBoilerRequest和NotifyBoilerIdle,但有不希望每次都重复乏味的手动关联,那么可以考虑使用wca.exe生成Communication Activity。wca.exe会自动搜寻指定的程序集里打上ExternalDataExchangeAttribute的接口,它会为接口里的每个事件创建一个HandleExternalEventActivity的派生类,为接口里的每个方法创建一个CallExternalMetodActivity的派生类。
图 7 - 使用wca.exe生成Communication Activity
接着,我们来实现烧水器服务:
代码 2 - 烧水器服务实现
需要说明的是,BoilerService必须打上SerializableAttribute,里面定义的NotifyBoilerIdle方法和BoilerRequest事件是给烧水器工作流使用的,而BoilerIdle事件和NotifyBoilerRequest方法则是给宿主应用程序使用的。
至此,我们已经为烧水器工作流,以及它和宿主应用程序之间的交互提供了基础条件,是时候让它们动起来了。
搭建程序主体
首先,创建一个Windows 应用程序:
图 8 - 创建宿主应用程序
并添加Windows Workflow Foundation和BoilerWorkflowLibrary的引用:
图 9 - 添加引用
接着,布置一下Proton的主界面:
图 10 - 简陋的用户界面
Proton的用户界面简陋非常,皆因本文的侧重点并非丰富的用户交互,故一切从简,以免喧宾夺主。
Proton启动的时候需要初始化用户队列、烧水器工作流和用户界面上面的一些元素。我打算重用
《烧水器事件簿》里的Enrollee类、EnrolleeQueue类和EnrolleePriority枚举,其中Enrollee类需要稍稍修改,去掉不再需要的代码:
代码 3 - Enrollee类
下面来看看初始化烧水器工作流的代码:
代码 4 - 初始化烧水器工作流
宿主应用程序和工作流实例之间的通讯是通过本地通讯服务来实现的,在这里就是我们之前创建的烧水器服务,但我们不能直接把它关联到工作流运行时,而是通过ExternalDataExchangeService这个中介来关联。换句话说,我们把ExternalDataExchangeService关联到工作流引擎,然后把BoilerService关联到ExternalDataExchangeService。一切就绪后就启动工作流运行时,然后创建并启动烧水器工作流。
当用户点击Boil按钮时,Proton将会禁用这个按钮,防止重复点击多次,从用户队列里抽取第一个用户,设置用户界面上的相关元素,然后就调用烧水器服务发送使用请求:
代码 5 - 开始烧水
当烧水器使用完毕后(水开了),Proton将会通过消息框发出通知,并更新用户界面上的相关元素:
代码 6 - 订阅烧水器空闲通知
最后,你可以抓住窗口关闭的机会释放工作流运行时所占用的资源:
代码 7 - 资源清理
至此,Proton已经可以运行起来了,但每当烧水器进入工作状态时,由于DelayActivity的缘故,烧水器工作流的实例就要盘踞于内存等待30分钟之久,这对于本地Windows应用程序似乎没什么大不了,但如果我想用 .NET Remoting/WCF或者ASP.NET把它开发成网络上的应用并部署在服务器上,内存的浪费就会变得不容忽视了,而工作流持久化服务正是为了解决这类问题而来的。
配置工作流持久化服务
Windows Workflow Foundation自带了一个工作流持久化服务的实现,它可以把工作流实例持久化到SQL Server里。在开始之前,请检查你的装备是否齐全:
- SQL Server 2005 Express Edition
- SQL Server Management Studio Express
- SqlPersistenceService_Logic.sql
- SqlPersistenceService_Schema.sql
打开SQL Server Management Studio Express并连接数据库引擎:
图 11 - 连接数据库引擎
在SQL Server Management Studio Express里创建一个数据库,并命名为SqlPersistenceStore:
图 12 - 创建新的数据库
依次执行装备列表里的SqlPersistenceService_Logic.sql和SqlPersistenceService_Schema.sql,它们位于Windows\Microsoft.NET\Framework\v3.0\Windows Workflow Foundation\SQL\EN文件夹里,执行的时候请确认选中SqlPersistenceStore数据库:
图 13 - 确认目标数据库是SqlPersistenceStore
这两个文件分别在SqlPersistenceStore数据库里创建工作流持久化服务所需的表和存储过程:
图 14 - 工作流持久化服务所需的表和存储过程
配置好数据库后就可以向工作流运行时添加工作流持久化服务了:
代码 8 - 向工作流运行时添加工作流持久化服务
它应该在工作流运行时的创建和启动之间执行:
代码 9 - AttachPersistenceService的位置
这样,当烧水器工作流没有展开实质性的工作时,工作流持久化服务就会让它在数据库里休息一会。
衍生问题
从长期看,任何一个烧水器都难逃"退休"的命运,我们没有理由要求所买的烧水器可以代代相传,因此,当烧水器"退休"时,工作流持久化服务在数据库里储存的数据也应该删除。工作流持久化服务会在两种情况下删除这些数据,第一种情况是工作流顺利完成所有工作,但我们之前定义的烧水器工作流是一个没有完成状态的状态机,只要宿主没有退出,或者使用了工作流持久化服务,它就有可能永远运行下去,那么我们是否要为此添加一个BoilerRetiredState,并把它设为烧水器工作流的完成状态呢?如果是的话,我们是否要在用户界面上添加一个Retire按钮来触发向这个状态的转换呢?另一种情况是工作流遇到未处理异常不得不中止,或者用户在工作流运行的中途发出中止的命令,烧水器工作流在BoilerWorkingState里不可能遇到异常,由于DelayActivity的缘故,它正在数据库里面休息,然而,现实的烧水器却有可能在工作中突然报废,此时用户就应该通知Proton中止烧水器工作流,并舍弃储存在数据库里的状态信息,这次,Terminate按钮可能更易接受。
在正式的系统里,用户可能不会满足于知道队列里有多少个用户在等候,他们可能希望知道排队的人是谁、自己排在哪里、前面有什么人等等,而管理员则希望拥有把队列里的某个(些)人踢出去的权力、在工作流还在运行的时候应用新出台的管理规则等等,而这一切将有可能会导致一个复杂度远大于现在使用的EnrolleeQueue类的队列管理系统出现。
作为资源管理系统,Proton的责任就是管理烧水器的使用,然而,所有问题最终都会回归到人的问题,有时这些系统能够提高协作的效率,有时则会令事情变得更加复杂,究竟是哪种情况,最终还是取决于使用系统的人。
参考书目
探索 Word 2007 开发 II(二):引用 Amazon 图书信息
Written by Allen Lee
引经据典
写文章的时候,我喜欢引经据典,只要有需要,我就会引用曾经看过的图书。然而,和上一回提到的问题类似,每当我要引用一本图书时,我得首先打开浏览器上网搜索一下这本书,接着把这本书的一些基本信息,例如书名和作者,复制到文章里,然后插入这本书的超链接。有没有办法让这个过程变得更加简单呢?如果我没记错的话,Amazon.com提供了搜索图书服务,那么为什么不考虑把它集成进来呢?
引入一个解决方案的同时会把与之相随的问题也引入来,要成功把Amazon.com的搜索图书服务集成到Word 2007里,我们就得回答下面这些问题:
- 访问Amazon.com的搜索图书服务有哪些条件/要求?
- 如何向这个服务发送请求?又如何解析服务返回的数据?哪些数据能用到这里来?
- 有用的数据如何组织和显示?
寻找经典
Amazon Web Service提供了两种接口,一种是基于SOAP的,另一种是基于REST的,我将选用后者来完成本回的插件。本回面临的第一个难点就是构建满足需求的REST请求,要做到这点,就得先清楚需求是什么:
- 我希望按书名的部分或者全部进行搜索;
- 我希望搜索的结果里面包括图书的书名、图书的作者、图书的封面和图书的超链接。
第二课堂 |
Introduction to AWS for C# Developers by Mark Blomsma |
Amazon.com提供了
Amazon E-Commerce Service Developer Guide,根据这份文档,我构建了如下所示的REST请求:
Figure 1 - REST Request
它告诉Amazon Web Service:
- 我要请求的服务是AWSECommerceService,通过它我可以访问Amazon的产品数据库;
- 我的访问密钥是什么,它可以通过在Amazon.com上注册帐号获得;
- 我要请求的操作是ItemSearch,通过它我可以搜索Amazon的产品数据库;
- 我要搜索书名包含"VSTO"字眼的图书。
在浏览器里执行这个请求将的到如下所示的搜索结果:
Figure 2 - REST Response
搜索结果所包含的信息量可以通过ResponseGroup参数来控制,ItemSearch操作使用Small作为该参数的默认值。通过把该参数的值该为Medium可以使返回的搜索结果包含图书的封面:
Figure 3 - REST Response with Image URLs
有了上面这些准备知识,我就可以着手实现搜索图书这部分功能了。首先,构建REST请求,并通过XElement.Load方法执行之:
Code 1 - Execute REST Request
需要注意的是,由于REST请求是一个URL,而书名可以包含URL不允许的字符,于是在把书名加到REST请求之前要先对其进行编码。
接着,用LINQ to XML对搜索结果进行处理:
Code 2 - Process REST Response
其中用到的辅助处理方法有:
Code 3 - REST Response Process Helpers
在命令行程序里使用BookSearch.Search方法搜索书名包含"VSTO"字眼的图书:
Code 4 - Test BookSearch.Search Method
却抛出了NullReferenceException:
Figure 4 - Oops! NullReferenceException is Raised
原来,搜索结果里面有一本书没有图片。由于并非每本书都有图片的,所以图片数据的辅助处理方法应先检查对应的XML元素是否存在,存在则进一步解析并返回图片的地址,否则返回null:
Code 5 - New Version of GetImageUrl Method
再次运行Code 4,可以看到正确的结果了:
Figure 5 - Process Results
列举经典
不知不觉又到设计UI的时候了,由于UI的设计与用户的操作息息相关,于是我们得先看看用户希望怎么操作这个插件:
- 点击Ribbon上的Amazon Book按钮打开搜索图书的窗口;
- 输入搜索关键字,并点击搜索;
- 在搜索结果中选择想要引用的图书;
- 插入选中图书的超链接。
此外,我们还需要进一步细化搜索结果的那些数据将显示在搜索图书窗口里:
- 图书的封面;
- 图书的完整标题;
- 图书的作者列表。
有了上面这些准备,我们就可以设计搜索图书窗口了。首先,在项目里添加如下所示的窗口:
Figure 6 - Search Book Window
这个窗口是使用
Component Factory的Krypton Toolkit控件包开发的,里面包含:
- 给用户输入书名的编辑框;
- 执行搜索的按钮(Go按钮);
- 显示搜索结果的ListView控件;
- 显示选定图书的书名的Label控件;
- 显示选定图书的作者的Label控件;
- 插入选定图书的超链接的按钮(Link按钮);
- 关闭窗口的按钮(Close按钮)。
一开始,用户没有在编辑框输入任何东西,而ListView控件也没有显示任何结果,Go按钮和Link按钮应该是屏蔽状态(disable)的。当用户在编辑框了输入了东西,Go按钮会被激活:
Code 6 - Enable/Disable Go Button
同理,Link按钮也会在用户选定某本图书后激活:
Code 7 - Enable/Disable Link Button
当用户点击Go按钮时,将执行下列操作:
- 通过BookSearch.Search方法获取搜索结果;
- 构建用于ListView显示图书封面的ImageList对象;
- 向ListView填充搜索结果。
对于第二步,我们需要ImageManager的帮忙:
Code 8 - ImageManager
有了它,Go按钮就可以完成它的任务了:
Code 9 - EventHandler for Click Event of Go Button
ListView是一个不错的东西,但它不能单独为每个项指定图标,这是我最讨厌的。另外,你也可以对图片进行本地缓存,并让ImageManager在获取图片的时候先查看本地缓存,没有的话再去Amazon.com那里要。
当用户选定某本图书时,我们需要把它的书名和作者显示在ListView下面的两个Label上,于是我把ListView的SelectedIndexChanged事件委托修改了一下:
Code 10 - EventHandler for SelectedIndexChanged Event of ListView
回到Ribbon的设计上,我们需要一个按钮来打开搜索图书窗口,为此,我在Ribbon上添加了一个SplitButton,之所以选择它而不是普通的Button,乃因我脑海里一个突如其来的想法,稍后将会为你剖析这个想法的来龙去脉。添加了SplitButton后的Ribbon如下图所示:
Figure 7 - Amazon Book SplitButton on Ribbon
然后实现SplitButton的Click事件委托:
Code 11 - EventHandler for Click Event of Amazon Book SplitButton
好了,运行插件看看效果:
Figure 8 - Oops! The Value of ImageUrl is null
噢,抛了一个ArgumentNullException异常!还记得我是怎么处理ImageUrl吗?在Code 5里,当Amazon Web Service返回的搜索结果里面某本图书没有图片时,BookSearch.GetImageUrl方法返回null,类似地,ImageManager.GetImage方法也应该在遇到这种情况时返回一个"占位图片":
Code 12 - New Version of GetImage Method
再次运行插件,可以看到预期的效果了:
Figure 9 - Search Results
连接经典
终于轮到本回的主角出场了,当用户点击搜索图书窗口的Link按钮时将在当前文档的光标处插入选定图书的超链接:
Code 13 - EventHandler for Click Event of Link Button
虽然我现在可以通过搜索图书窗口插入图书的超链接,然而在大多数时候我更愿意遵循以下步骤插入图书的超链接:
- 在文档的某个位置输入要插入超链接的图书的书名;
- 选中这个书名,使之处于高亮(highlight)状态;
- 点击Ribbon上的Amazon Book按钮打开搜索图书的窗口,此时,这个书名显示在编辑框里,而搜索结果则显示在ListView里,并且第一本书处于选中状态;
- 如果选定的图书不是我要找的,则在搜索结果里重新选择;
- 点击Link按钮插入选定的图书的超链接。
为此,我需要为搜索窗口添加Load事件委托:
Code 14 - EventHandler for Load Event of BookSearchView
由于搜索窗口的Load事件委托和Go按钮的Click事件委托都需要执行搜索操作和向ListView填充搜索结果,所以我把这部分代码提取出来以便重用,这样Go按钮的Click事件委托就可以简化为:
Code 15 - New Version of Click EventHandler for Go Button
此时,当我点击Link按钮,原先选中的那个书名将被替换成从Amazon.com获取的完成的书名以及作者的名字,但我希望在原先选中的那个书名的基础上插入超链接并追加作者的名字,于是,我把Link按钮的Click事件委托里为textToDisplay变量赋值的代码改成下面这样:
Code 16 - Assignment to textToDisplay Variable
幸运经典
还记得在设计Ribbon的时候把Amazon Book按钮设为SplitButton而非普通的Button吗?之所以这样做,是因为我希望为这个插件添加一个比较特别的功能,类似于Google的"手气不错"(I'm Feeling Lucky)。我为Amazon Book按钮的下拉菜单添加了两个(普通)按钮,一个是Amazon Book按钮,另一个是Lucky Book按钮,运行起来像这样:
Figure 10 - Lucky Book
其中,下拉菜单里的Amazon Book按钮的效果和Amazon Book主按钮一样,而Lucky Book按钮的效果则和Google的"手气不错"相似。当我在文档的某个位置输入要插入超链接的图书的书名,选中这个书名并点击Lucky Book按钮时,它将会执行基于这个书名的搜索,并插入搜索结果中第一本书的超链接。如果用户在没有选中任何文字的情况下点击Lucky Book按钮或者搜不到指定的图书,则告知用户相关信息。
Code 17 - Insert Lucky Book
至此,Amazon Book插件的开发要告一段落了,虽然我还想实现更加完善的图片管理功能,虽然我还想提供更加丰富的搜索方式,虽然我还想通过配置让插件变得更加灵活,虽然……
探索 Word 2007 开发 II(一):引用 .NET Framework Class Library 在线文档
Written by Allen Lee
再临之时
每当我在文章中提到.NET Framework的某个类、属性或者方法时,我会为它添加一个超链接,引用.NET Framework Class Library的在线文档。首先,我用浏览器打开MSDN Library的页面,接着,在左边的导航目录树中定位到对应的节点,然后,把该节点的超链接复制下来,最后回到Word 2007中插入这个超链接。然而,我已经对这个繁琐的过程感到厌倦了,是否有办法把.NET Framework Class Library的在线文档集成到Word 2007中,从而让这个过程变得更加简单呢?在思考这个问题的同时,我的脑海也萌生了把其它有用的链接服务集成到Word 2007的想法,继而催生了《探索 Word 2007 开发 II》系列文章的创作,以便和大家分享我在探索中的思考和心得。
需求 X 需求
下图是用Paint.NET弄出来的示意图:
Figure 1
我希望把MSDN Library上.NET Framework Class Library那部分导航目录树以侧边栏的方式集成到Word 2007里,这样,当我右击某个节点,点击Insert菜单项时,将在当前文档的光标处插入该节点对应的连接。
作为用户,我提出了上面这些需求,那么,作为开发人员,为了实现这项功能,我又该提出哪些"需求"呢?
- 首先,我需要MSDN提供访问这些数据的服务,没有这个服务,实现这项功能就非常困难,甚至变得不可能。幸亏,MSDN的确提供了这样一个服务,它叫MTPS(MSDN/TechNet Publishing System)Content Service(以下简称"MPTS")。这样,实现这项功能的首要任务就是学习使用这个服务了。这里将会用到Craig Andera在其文章上所提到的知识和技巧(见下表)。
- 其次,我需要知道如何为Word 2007创建侧边栏以及为这个插件在Ribbon上添加一个启动按钮。这里将会用到《探索 Word 2007 开发(一):我的博客》、《探索 Word 2007 开发(三):管理侧栏》和《探索 Word 2007 开发(二):扩展 Ribbon》三篇文章的研究成果。
- 再次,我需要知道TreeView这个控件的使用方法和技巧。这里将会用到《TreeView 四技》这篇文章的研究成果。
- 最后,我需要知道如何在Word 2007中插入超链接。
第二课堂 |
Announcing the MTPS Content Service,Craig Andera |
Consuming MSDN Web Services,Craig Andera |
多重影分身术
多重影分身术是一种实体复制忍术,如果你看过《火影忍者》,应该能够理解它的意思。现在我要做的和这个差不多,为MSDN Library的.NET Framework Class Library导航目录树产生出多个实体分身,并把它们嵌入Word 2007里,不同的是,多重影分身术是由本体实施,而这里的实体复制是由身为第三方的我来实施。
本节的焦点是用MTPS返回的数据填充侧边栏里的TreeView控件。下面是这个插件的需求列表:
- 一开始,TreeView里面只有一个顶节点——.NET Framework Class Library,且该节点是折叠的。
- 使用延迟填充技术,每个可展开节点会且只会在第一次展开时访问MTPS获取数据,并填充子节点。
这两个需求看起来很简单,然而,一旦和MTPS 结合使用就比较复杂了,你需要区分MPTS所使用的两套标识系统,一套是用于标识节点的导航的,另一套是用于标识节点所指向的内容的。如果你还没接触过MTPS,那么我强烈建议你先阅读Craig Andera的两篇文章,否则你有可能无法理解后面的内容。这里我做一个简单的介绍,MTPS有两种节点:导航节点和内容节点,导航节点与MSDN Library/TechNet Library左侧的导航目录树中的节点一一对应;内容节点则是与该导航节点对应的用于显示在右侧的内容。这两种节点使用了同一种标识模型,每一个标识包含了三个部分:标识符、区域信息和版本信息。其中标识符又分五种类型:short ID、content alias、content GUID、content URL和asset ID。并非所有节点都同时具备这五种标识符,也并非所有情况下MTPS都返回/接受这五种标识符,于是,如果你需要在不同的标识符之间切换,你可能需要再次访问MTPS。有鉴于此,上述两个需求的复杂性主要体现在处理不同种类的节点以及用于标识这些节点的不同种类的标识符上。
下面,我们来看看插件的实现,首先,参考
《探索 Word 2007 开发(一):我的博客》这篇文章里提到的方法在Visual Studio 2008 Beta 2中创建一个项目骨架,里面包含一个空白的Ribbon(取名ReflinksRibbon)和一个空白的User Control(取名TocView),并在TocView上放置一个TreeView控件:
Figure 2
我不打算用普通的TreeNode来填充这个TreeView,而是使用根据MTPS的节点模型创建的自定义节点类来填充,当然,这个自定义节点类继承自TreeNode类:
Code 1
TOC全称Table of Content,TocNode与MTPS的TOC导航节点模型向对应:
- TocNode.Text:节点标签(Label),对应于toc:Title;
- TocNode.Target:与该导航节点对应的内容节点的标识符,对应于toc:Target;
- TocNode.Locale:与该导航节点对应的内容节点的区域信息,例如zh-CN,对应于toc:TargetLocale;
- TocNode.Version:与该导航节点对应的内容节点的版本信息,例如VS.90,对应于toc:TargetVersion;
- TocNode.SubTree:如果该导航节点包含子节点,则该属性为子节点树片断的标识符,对应于toc:SubTree。
我希望使用
《TreeView 四技》这篇文章里提到的延迟填充技巧,并让节点自行负责子节点的填充,如果某个节点拥有子节点,它也必须负责通知TreeView对其做出适当的渲染。下面是通过MTPS获取当前节点的子节点(注意,MTPS仅返回下一级的子节点):
Code 2
我们可以通过检查SubTree是否为null知道当前节点有否子节点,然而,我们还是无从得知子节点的装载是否已经执行过。重复装载无疑导致不必要的网络访问,于是,我为TocNode添加了一个类型为bool的m_Loaded字段。这样,仅当SubTree不为null以及m_Loaded不为false时,我们才装载子节点。所有这些操作都是在用户点击节点前面那个+号时才执行的,但由于子节点还没填充,TreeView是不会为该节点渲染+ 号的,于是,我们需要为该节点添加一个"占位子节点",以便TreeView能够正确渲染。添加占位子节点的最佳时机是当我们给SubTree属性赋值时,所以我把SubTree属性修改如下:
Code 3
有了这些准备,我们就可以实现Load()方法来装载子节点了:
Code 4
这个方法将会在TreeView的BeforeExpand事件委托里调用:
Code 5
值得提醒的是,当子节点填充完毕后,别忘了删除之前加入的占位子节点,并把m_Loaded的值设为true。另外,这里使用了GetAttribute()辅助方法来获取XAttribute的值:
Code 6
这样,TreeView的填充就变成简单地添加一个根节点了,这将在TocView的Load事件委托里完成:
Code 7
好了,现在来看看效果:
Figure 3
噢,TreeView的位置有问题!这个问题可以通过设置TreeView的Anchor属性以及TocView的Dock属性解决。我们知道,控件的Anchor属性的默认值为Top和Left的组合,然而,我们并不希望TreeView的右边和下边留下这么多空白,这样就可以把它的值设为Top、Left、Right和Bottom的组合。或者你认为把TreeView的Dock属性的值设为Fill更省事,但我还是喜欢四周留有一些"余地",至于留空多少,你可以通过Margin属性指定。而TocView的Dock属性可以在添加侧边栏时设置:
Code 8
现在,重新编译并运行,看看效果如何:
Figure 4
嗯,这次就好多了。
插入类库超链接
MTPS是用来返回内容的,所以我们无法从返回的数据中得知节点所对应的超链接。那么,我们如何获取节点所对应的超链接呢?如果无法获得,那么我们之前所做的就白费了。Google一下"MSDN MPTS"关键字,发现Tim Ewald曾经在某期MSDN杂志上介绍过MSDN的超链接的设计思路,并从中得知如何推断出节点所对应的超链接。
第二课堂 |
Designing URLs for MSDN2,Tim Ewald |
推断节点所对应的超链接需要我们提供该节点的short ID,然而,除了我们手动赋之予short ID的根节点外,其下任一节点的Target属性所包含的标识符都是asset ID,这就意味着我们需要做一次转换了:
Code 9
getContentResponse.contentId公共字段并非指代内容节点的ID,而是指代short ID,无论该节点是导航节点还是内容节点,这是历史遗留问题,如果你读了第二课堂栏推荐的文章,你会发现这点微小的混乱。
接着,我为TocNode提供一个TargetUrl属性,用于返回当前节点所指向的内容的超链接:
Code 10
值得注意的是,我在这里使用了m_TargetUrl对超链接进行缓存,以保证该方法对于每个节点仅产生一次网络访问。
接下来就是本回的主角了,我希望右击TreeView上的节点时弹出一个菜单,里面包含一个Insert菜单项,当我点击这个菜单项时,在当前文档的光标处插入这个节点对应的连接。下面是Insert菜单项的Click事件委托:
Code 11
最后,要使Insert菜单项生效,我们必须把菜单挂接到节点上,这可以通过处理TreeView的NodeMouseClick事件做到:
Code 12
故技重施
虽然插入类库连接的功能已经可用,然而TocView也遇到了"我的博客"侧边栏曾经遇到的问题:
- 随Word启动,而不是由用户根据需要启动;
- 当打开多个Word窗口时,仅显示在第一个打开的窗口里。
对于这两个问题,我将会使用
《探索 Word 2007 开发(二):扩展 Ribbon》和
《探索 Word 2007 开发(三):管理侧栏》这两篇文章里提到的方法来处理。首先,创建一个Ribbon:
Figure 5
然后,就是移植MyBlogsPaneManager过来管理TocView了。我当然可以直接把MyBlogsPaneManager里面关于"我的博客"侧边栏的东西换成与TocView对应的,然而,我希望让MyBlogsPaneManager更通用,以便将来在遇到关于侧边栏管理问题可以直接使用。MyBlogsPaneManager的核心部分要数GetMyBlogsPane()方法了,要让它变得通用,必须把里面和"我的博客"侧边栏相关的东西泛化,下面是该方法的改版代码:
Code 13
需要说明的是,m_CustomTaskPanePool来自Globals.ThisAddIn.CustomTaskPanes,而后者里只存放当前插件添加进去的侧边栏,再加上同一个Word窗口里相同类型的侧边栏只会出现一次,于是,在上述代码里,我通过侧边栏的父窗口和所含控件的类型来进行判等是合理的。另外,由于添加侧边栏需要提供标题,而标题和控件的类型又是一一对应的,于是,我使用了一个Dictionary<Type, string>来存放这些对应信息。要注册该信息,可以使用下面这个方法:
Code 14
那么,谁应该注册该信息,以及在哪里注册该信息呢?我认为注册该信息的责任应该落在控件的头上,再者,该信息是与控件的类型而不是实例相关的,于是,我把注册该信息的代码放在控件的静态构造函数里:
Code 15
剩下的就是挂接相关的事件委托和处理状态的同步了,这些都和"我的博客"侧边栏的做法大同小异,这里就不重复了。
更多选择
还差点什么呢?语言!留意下面这幅截图:
Figure 6
Class、Members、Constructor、Methods以及节点所指向的内容均为英文,我希望可以选择用于显示的语言。于是,我为插件创建了一个选项窗口:
Figure 7
并在它的Load事件委托里处理当前语言下拉列表的初始化,并读取配置文件中的语言信息:
Code 16
我们确实可以避免在这里硬编码,然而,就目前来说,提供简体中文、繁体中文和英文已经足够了,除非你打算把它变成产品,并进军全球市场。当用户选好语言并点击OK按钮时,语言信息将会保存到配置文件里:
Code 17
Class Library Options窗口是通过侧边栏右下角新添加的Options按钮启动的,当语言信息改变了,TreeView也应该刷新根节点:
Code 18
需要说明的是,我不再指定Version的值,这将告诉MTPS获取最新的发布版本 (非预发行版,包括Beta版和CTP版),而Locale则与配置文件里的语言信息相对应,这意味着插入的超链接所指向的页面将与配置文件里德语言信息相对应。下面是运行效果的截图:
Figure 8
至此,Class Library侧边栏的开发要告一段落了,虽然此时我的脑海里又冒出了新的需求,或者你也对它有了新的想法。最后,给大家介绍两个别人写的MTPS应用:
第二课堂 |
Package This is a GUI tool written in C# for creating help files (.chm and .hxs) from the content obtained from the MSDN Library or the TechNet Library via the MSDN Content Service. |
msdnman is a command-line viewer for documentation stored in the MSDN/TechNet Publishing System (MTPS). |
转载于:https://blog.51cto.com/51xingfu/83727