怪怪设计论闲谈篇:职责与解耦的矛盾
为了大家所谓的“低耦高聚”的目标,也为了保持职责的合理性,希望大家考虑一下,当Book没有Save时,我们除了属性赋值是不是就无路可走了?这就得那些没包含在这次讨论中的习惯性做法(比如平时对.NET Framework和ADO.NET的使用方式)包含近来,看看很多同志指出的美女走光问题,除了给美女一个电棍让美女负担起警察打击偷窥者的职责,能不能通过换件裙子来解决。
我们平时用惯了IDataReader给对象赋值,所以很多人说的那种,从外部通过属性赋值的情况就广泛存在,比如CommunityServer。如果换一个方式呢? 把IDataReader直接交给Book, 然后Book自己展开数据是不是好很多呢?于是有人又说了,这跟IDataReader耦合了,不利于移植等等。或者我根本没用ADO,所以没IDataReader。后者可以通过给自己的数据操作层实现IDataReader搞定; 关键是前者,前者的非法性还表现在,把ADO的接口带入了逻辑层;等等等等, 反正很多。那么为什么不能自己做一些类似IDataReader的接口, 然后把ADO.NET包含的概念作为变化封装出去呢?
在保存数据的时候也一样,不是把数据全部读出来去保存,而是让Book准备好需要保存的数据,总而言之归它管的一分不落,然后实现或者返回出一个统一的接口里面全都是要持久的数据。至于如何跑回书架上,或者被贼给偷跑了,那是别人的事。 毕竟某兄弟回复说的,今天加个Cache明天加个Log后天加个Permission最后数据库都不用了的情况也确实有。这些都该Book负责吗? 不是说只添加不修改吗? 难道非得要求必须AOP? 我是Anders的忠实Fans,我不认为AOP解决了什么本质问题。怎么又说回职责问题了,总而言之,现在总不能有人说美女走光了吧。
其实我们所说的方法,往往都有学习的对象,大家可以想想各种Control对于ViewState的使用, 它其实就是这么一个玩意(关于Control如何使用和持久化ViewState的文章,园子里就有)。那么我们也可以这样,数据就是数据,要什么属性,而且IDataReader出来的不就是一砣类似字典的东西吗?正好直接拿字符串和我们自己定义BL层与数据层之间对接的接口对应(与IDataReader不同的是,我们定义的接口是在逻辑层中使用的,除了象一个数据集,不包含任何和数据层相关的内容),觉得不过瘾,把对应关系保存到配置文件里去。无论如何,希望让对象自己负责恢复状态,同时又不希望对象负责存取逻辑的矛盾,并非无法解决,ViewState的方式只是其中一种解决方法。
我们不妨再看看.NET Framework如何让Serializer和你的对象在没有联系的情况下,通过增加一个翟通道,即不介入对象内部逻辑,又负担了对象持久的:首先实现ISerializer这一接口,同时实现固定方法签名的构造函数。保存数据时,他让你自己打包然后从你的接口实现中获得数据对象,恢复对象时,他调用你那个特别的构造函数,把数据字典还给你,然后让你自己填充。拿Book来说,如果增加了需求,难道非得让Book自己重新实现一个Book.Serialize方法吗?鉴于一些朋友可能不清楚Serializer的工作机制,我借用一下MSDN的简单例子,同时有所改动:
public class MyObject : ISerializable
{
public int N1 { get; private set;} //公开取,私有存
public int N2 { get; internal set;} //公开读,内部存
public String Str { get; set;} //这些是VS 2008支持的写法,不用自己定义私有变量了
public MyObject()
{
}
protected MyObject(SerializationInfo info, StreamingContext context)
{
//这个特殊的构造函数会被自动调用,如果是我们自己实现,就某Manager调用
//其实如果没有复杂的构造函数初始化逻辑,比如给readonly变量赋值
//可以将SetObjectData直接实现于接口,由我们负责数据存取的部分来调用
SetObjectData(info);
}
protected virtual void SetObjectData(SerializationInfo info)
{
N1= info.GetInt32("i"); //由对象自己将数据字典展开
N2 = info.GetInt32("j"); //还原对象状态
Str = info.GetString("k"); //这样就可以把跟对象无关的存储逻辑外包出去
}
[SecurityPermissionAttribute(SecurityAction.Demand,SerializationFormatter=true)]
public virtual void GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("i", N1);//info可以理解为Serializer定义的数据字典格式
info.AddValue("j", N2);//这相当于持久化的概念进入了BL层
info.AddValue("k", Str);//所以当我们实现时,应该根据业务逻辑定义自己的数据字典
}
}
Serializer:
BinaryFormatter formatter = new BinaryFormatter();
保存至fs:
formatter.Serialize(fs, a1);
从fs读取:
formatter.Deserialize(fs);
在上面的上下文中,作为Serializer的BinaryFormatter,相当于BookManager这样的角色(也仅在该上下文中,它们的作用有所重叠,因为BookManager不应负责对对象持久的具体实现),但是它更通用,且与任何对象没有耦合关系,毕竟,这样的方法不存在在咱们自己定义的对象上。实际上,由于不需要变成二进制流,我们平时的保存和读取的实现要比Serializer之类简单一些(而且一般来说严格停留在数据层中);同时由于我们的对象和数据往往有着一定的联系,这样我们的实际实现,就可以增加一些由业务层定义的数据字典作接口发挥类似SerializationInfo的作用,依靠于合理的设计,我们的接口一方面把BL层和数据层隔绝开来,另一方面强调业务层的需求的同时根据业务需要特殊化。
不过有人可能要说了,你也类似于ORM,我也不管我这是不是ORM,至少自己实现的灵活性,要比使用现成的ORM高,Book内部如何展开数据,既可以写个通用的,在有必要的地方也可以自己搞;效率想优化一下,也不用看/改别人的代码,有啥不好呢?关键是这样的方式,无论亚历山大同志是我老板还是反对派是我老板,挨骂也能少两句不是。:)
估计大家也看出来了,以上方法对于复杂性不高的项目,完全可以取消BookManager,将数据存取职能归并到一个集中的数据管理器中去(不是指数据层内的那种)。这样我们又有只有一个孤零零的Book玩Solo了。那么产生其它按职责来说不应该由Book来负担,但又具有一定逻辑的操作怎么办?比如Log,做一个Log管理器,Book继承下去实现Log管理器需要的接口,LoggableBook仅返回Log需要的东西。怕产生一个LoggableCachablePermitableBook?Decorator就是干这个地... 不是强类型的?范型来了... 还有什么是可以变化的?比如到底套几个Decorator,写个Factory什么来直接返回套好的Book的..。需要注意,如果这些Log/Permission/Cache,都是对数据存取而不是针对Book的,那么Decorator也好Factory也好,针对的目标就不再是Book,而是BookManager或者数据管理器了。
如果有BookManager而不是集中对象存取逻辑和职责,那么数据来源和原来的做法没有变化;但非数据层的数据管理器怎么知道Book的数据来自于哪儿呢?虽然我们可以通过Provider和配置文件指定类与数据层跑腿的之间的对应关系,但答案也可以是"不知道"。比如,数据管理器.Get(Query q),然后BookQuery去继承Query就好了。这个Query及其子类是用来表示查询逻辑的,而并非是用来拼SQL字串或直接数据存储的那种Query,最终通过BL层到数据层的接口与数据层对接。数据层根据它所提供的信息,自己决定如何动作,最后返回BL层所用的数据集接口。这样我们就可以防止数据层的概念侵入到BL层中去。
这种方式的BookManager(或没有BookManager)与原来的方式区别在哪儿,让它至少想象起来还挺顺风顺水呢?关键在于,BookManager和Book不再像亚历山大同志批评的那样,全方位互操作,它们的接触从一个面变成了一个甚至完全可以消除任何特殊性点,只包含BookManager职责所涉及到的数据部分,至于数据从哪儿来打哪儿去,这不关别人的事。反过来对于Book呢,他的职责除了执行其他当仁不让的业务逻辑,也保留了应该由他自己负责的如何展开和打包数据等天赋人权,从而将数据到对象状态的转化工作,封装在了对象自身内部。
这种方式与Book.Save区别又在哪里呢?很明显的是Book不再包括它不应关心的逻辑;这样就实现了BookManager(或数据管理器以及各种Query子类)的单独替换和伸缩;在把他们分散到不同的包里时,这样的好处是不言而喻的。另外,相对于Book.Method1/Method2/Method3...这样随着需求变化增加下去,Book同时也保证了一定稳定性。实现这些,是通过对Book增加了一个面对外部世界的点做到的,它本质上更倾向于亚历山大同志说的高聚低耦方式(虽然表现形式不同甚至截然相反),却将存取逻辑通过这个点,转移到了外部。
在我看来,以上手段折射到设计和编码的最终产物,其表现形式就是类的大小和数量。密密麻麻的小类我个人也不习惯很难做到,但我仍然觉得,哪怕一个类只有50行,我觉得只要有一点点理由应该这么做那就相当于说必须去做(虚心接受,坚决不改)。在这点上我有点认同Javaeye上前几年鼓吹什么组合子编程的那家伙,只实现很多很多的基础组合子。问题是拆的太散,最后类之间的逻辑对一般人的脑力和项目的成本承受能力就形成了考验。但是没条件实现不是说不应该这么实现:非常多的细棍一齐直冲云霄,只不过有些棍因为需要借助其它棍的支撑力不得不绑上跟绳子,但却尽量保持不接触,防止实际上变成了一根大粗棍。
关于贫血是不是一定就不合理,Book没有Save是不是就一定贫血这两个问题,我有几点看法如下:
1.
如果真的坚持正确的职责,那么该贫血的模型必须贫血;如果充上别人的血,对于其他对象来说这个对象倒是高聚低耦了,但估计如果你不是AOP迷,这个对象本
身就难免成为胶水对象,比如你可以考虑把CommunityServer的Threads类的职责放到Thread类上去是个什么景象。
2. 若Book和BookManager都足够小,那么这种划分不会增加耦合;相反即使是相互操作,只要接口不变,反而将Book本身可能存在的职责和存取职责之间的耦合度降低了。这是CommunityServer和很多人的做法,但是这个做法的种种缺点也确实存在。
我重新编辑了本文把以上观点单独放在另外一个Post中了,见《贫血或职责的讨论》。本文目前只讨论一种不把持久放在Book中,同时又把恢复对象状态保留,封装于Book中的一种做法。关于1,本文中,我们已经把恢复对象状态的逻辑重新还给了对象,不知道Book是否还是完全的贫血?
最后总结一下本文:
3. 密密麻麻的小类,是各种大嘴/大师们都推荐的做法,尤其是如果存在BookManager,那么BookManager的职责到底有哪些需要谨慎一些,否则比起只有Book的做法,就有可能真的增加了耦合。
4. 考虑到Book状态和装载可能分别变化,我们应该拿出一种它们解耦的方法,让Book自己负责数据的展开和打包,同时由其它对象负责数据的存取(对于图书管理员来说让它拿出来和放回去的这个东西已经可以忽略书本的特性了)。本文提出了一种其实大家都见过,且也许能行的办法。
5. 关键问题是,要点接触不要面接触,要接触不要不接触,不接触最后的结果,不是AOP(是不是一颗加林仙人的仙豆,暂时持保留态度),就是成了一个巨型的胶水类。当你解决胶水问题时,还是要使用以上的手段,只不过对象凭空多出了几个方法而已。
最后说点题外话:我在想,其实问题的讨论点如果变一变,就会有建设性。怎么变呢?比如大家群策群力,总结一下现实世界里的例子,哪些可以容忍贫血,哪些贫血的代价在未来会很大;这样久而久之就会形成一个指南,为后人也为咱们自己铺路。比如显然亚历山大同志老兄,吃过充血(哪怕是过度充血)的甜头,那么就把你吃甜头的例子拿出来分析,于是大家知道了,哦,当现实事物有这样这样的特征时,充血有好处。比如反对派吃了充血过度的苦头,也把苦头拿出来晒晒,那样大家就知道,这种情况下得小心不要充血充出问题。每个人都这么做了,逐渐的就可以通过统计得出一些较为有效的分类方式和在不同分类下的大致设计方法。
但是如果是目前的方式呢?你说你的,我说我的,把自己在现实中获得经验往一个共同的例子上套,可大家都没有考虑自己已经因为过往的经验有了成见。每个开发者的经验都是不同的(也正是因为经验不同所以站位的角度不一样),怎样通过网络让每个人都或多或少得到他人的经验在我看来是网络比较重要的作用。
而不加限定的就某一个问题,非要得出大一统的结论,在我看来不但不是网络的优势和对我们的帮助,而且根本没有那个可能。