論面向組合子程序設計方法 之 重構

迄今,發現典型的幾種疑問是:
1。組合子的設計要求正交,要求最基本,這是不是太難達到呢?
2。面對一些現實中更復雜的需求,組合子怎樣scale up呢?

其實,這兩者都指向一個答案:重構。


要設計一個完全正交,原子到不可再分的組合子,也許不是總是那麼容易。但是,我們並不需要一開始就設計出來完美的組合子設計。

比如,我前面的logging例子,TimestampLogger負責給在一行的開頭打印當前時間。
然後readonly提出了一個新的需要:打印調用這個logger的那個java文件的類名字和行號。

分析這個需求,可以發現,兩者都要求在一行的開始打印一些東西。似乎有些共性.
這個"在行首打印一些前綴"就成了一個可以抽象出來的共性.於是重構:

interface Factory{
String create();;
}
class PrefixLogger implements Logger{
private final Logger logger;
private final Factory factory;
private boolean freshline = true;

private void prefix(int lvl);{
if(freshline);{
Object r = factory.create();;
if(r!=null);
logger.print(lvl, r);;
freshline = false;
}
}
public void print(int lvl, String s);{
prefix(lvl);;
logger.print(lvl, s);;
}
public void println(int lvl, String s);{
prefix(lvl);;
logger.println(lvl, s);;
freshline = true;
}
public void printException(int lvl, Throwable e);{
prefix(lvl);;
logger.printException(lvl, e);;
freshline = true;
}
}

這裏,Factory接口用來抽象往行首打印的前綴。這個地方之所以不是一個String,是因爲考慮到生成這個前綴可能是比較昂貴的(比如打印行號,這需要創建一個臨時異常對象)

另外,真正的Logger接口,會負責打印所有的原始類型和Object類型,例子中我們簡化了這個接口,爲了演示方便。


然後,先重構timestamp:

class TimestampFactory implements Factory{
private final DateFormat fmt;
public String create();{
return fmt.format(new Date(););;
}
}


這樣,就把timestamp和“行首打印”解耦了出來。


下面添加TraceBackFactory,負責打印當前行號等源代碼相關信息。
interface SourceLocationFormat{
String format(StackTraceElement frame);;
}
class TraceBackFactory implements Factory{
private final SourceLocationFormat fmt;
public String create();{
final StackTraceElement frame = getNearestUserFrame();;
if(frame!=null);
return fmt.format(frame);;
else return null;
}
private StackTraceElement getNearestUserFrame();{
final StackTraceElement[] frames = new Throwable();.getStackTrace();;
foreach(frame: frames);{
if(!frame.getClassName();.startsWith("org.mylogging"););{
//user frame
return frame;
}
}
return null;
}
}


具體的SourceLocationFormat的實現我就不寫了。

注意,到現在爲止,這個重構都是經典的oo的思路,劃分責任,按照責任定義Factory, SourceLocationFormat等等接口,依賴注入等。完全沒有co的影子。

這也說明,在co裏面,我們不是不能採用oo,就象在oo裏面,我們也可以圍繞某個接口按照co來提供一整套的實現一樣,就象在oo裏面,我們也可以在函數內部用po的方法來實現某個具體功能一樣。


下面開始對factory做一些co的勾當:
先是最簡單的:

class ReturnFactory implements Factory{
private final String s;
public String create();{return s;}
}


然後是兩個factory的串聯,

class ConcatFactory implements Factory{
private final Factory[] fs;
public String create();{
StringBuffer buf = new StringBuffer();;
foreach(f: fs);{
buf.append(f.create(););;
}
return buf.toString();;
}
}



最後,我們把這幾個零件組合在一起:

Logger myprefix(Logger l);{
Factory timestamp = new TimestampFactory(some_date_format);;
Factory traceback = new TraceBackFactory(some_location_format);;
Factory both = new ConcatFactory(
timestamp,
new ReturnFactory(" - ");,
traceback,
new ReturnFactory(" : ");
);;
return new PrefixLogger(both, l);;
}


如此,基本上,在行首添加東西的需求就差不多了,我們甚至也可以在行尾添加東西,還可以重用這些factory的組合子。

另一點我想說明的是:這種重構是相當局部的,僅僅影響幾個組合子,而並不影響整個組合子框架。


真正影響組合子框架的,是Logger接口本身的變化。假設,readonly提出了一個非常好的意見:printException應該也接受level,因爲我們應該也可以選擇一個exception的重要程度。


那麼,如果需要做這個變化,很不幸的是,所有的實現這個接口的類都要改變。

這是不是co的一個缺陷呢?


我說不是。
即使是oo,如果你需要改動接口,所有的實現類也都要改動。co對這種情況,其實還是做了很大的貢獻來避免的:
只有原子組合子需要實現這個接口,而派生的組合子和客戶代碼,根本就不會被波及到。
而co相比於oo,同樣面對相同複雜的需求,往往原子組合子的數目遠遠小於實際上要實現的語義數,大量的需求要求的語義,被通過組合基本粒子來實現。也因此會減少直接實現這個接口的類的數目,降低了接口變化的波及範圍。


那麼,這個Logger接口是怎麼來的呢?

它的形成來自兩方面:

1。需求。通過oo的手段分配責任,最後分析出來的一個接口。這個接口不一定是最簡化的,因爲它完全是外部需求驅動的。

2。組合子自身接口簡單性和完備性的需要。有些時候,我們發現,一個組合子裏面如果沒有某個方法,或者某個方法如果沒有某個參數,一些組合就無法成立。這很可能說明我們的接口不是完備的。(比如那個print函數)。
此時,就需要改動接口,並且修改原子組合子的實現。
因爲這個變化完全是基於組合需求的完備性的,所以是co方法本身帶來的問題,而不能推諉於oo設計出來的接口。
也因爲如此,基本組合子個數的儘量精簡就是一個目標。能夠通過基本組合子組合而成的,就可以考慮不要直接實現這個接口。
當然,這裏面仍然有個權衡:
通過組合出來的不如直接實現的直接,可理解性,甚至可調試性,性能都會有所下降。
而如果選擇直接實現接口,那麼就要做好接口一旦變化,就多出一個類要改動這個類的心理準備。

如何抉擇,沒有一定之規。

而因爲1和2的目標並不完全一致,很多時候,我們還需要在1和2之間架一個adapter以避免兩個目標的衝突。

比如說,實際使用中,我可能希望Logger接口提供不要求level的println函數,讓它的缺省值取INFO就好了。

但是,這對組合子的實現來說卻是不利的。這時,我們也許就要把這個實現要求的Logger接口和組合子的Logger接口分離開來。(比如把組合子單獨挪到一個package中)。


Logger這個例子是非常簡單的,它雖然來自於實際項目,但是項目對logging的需求並不是太多,所以一些朋友提出了一些基於實際使用的一些問題,我只能給一個怎麼做的大致輪廓,手邊卻沒有可以運行的程序。


那麼,下面一個例子,我們來看看一個我經過了很多思考比較完善了的ioc容器的設計。這個設計來源於yan container。


先說一下ioc容器的背景知識。

所謂ioc容器,是一種用來組裝用ioc模式(或者叫依賴注射)設計出來的類的工具。
一個用ioc設計出來的類,本身對ioc容器是一無所知的。使用它的時候,可以根據實際情況選擇直接new,直接調用setter等等比較直接的方法,但是,當這樣的組件非常非常多的時候,用一個ioc容器來統一管理這些對象的組裝就可以被考慮。


拿pico作爲例子,對應這樣一個類:

class Boy{
private final Girl girl;
public Boy(Girl g);{
this.girl = g;
}
...
}


我們自然可以new Boy(new Girl());

沒什麼不好的。

但是,如果這種需要組裝的類太多,那麼這個組裝就變成一件累人的活了。

於是,pico container提供了一個統一管理組建的方法:

picocontainer container = new DefaultContainer();;
container.registerComponentImplementation(Boy.class);;
container.registerComponentImplementation(Girl.class);;



這個代碼,很可能不是直接寫在程序裏面,而是先讀取配置文件或者什麼東西,然後動態地調用這段代碼。

最後,使用下面的方法來取得對象:

Object obj = container.getComponentInstance(Boy.class);;


注意,這個container.getXXX,本身是違反ioc的設計模式的,它[b]主動[/b]地去尋找某個組件了。所以,組件本身是忌諱調用這種api的。如果你在組件級別的代碼直接依賴ioc容器的api,那麼,恭喜你,你終於成功地化神奇爲腐朽了。 :lol:


這段代碼,實際上應該出現在系統的最外圍的組裝程序中。

當然,這是題外話。


那麼,我們來評估一下pico先,

1。讓容器自動尋找符合某個類型的組件,叫做auto-wiring。這個功能方便,但是不能scale up。一旦系統複雜起來,就會造成一團亂麻,尤其是有兩個組件都符合這個要求的時候,就會出現二義性。所以,必須提供讓配置者或者程序員顯示指定使用哪個組件的能力。所謂manual-wire。
當然,pico實際上是提供了這個能力的,它允許你使用組件key或者組件類型來顯示地給某個組件的某個參數或者某個property指定它的那個girl。

但是,pico的靈活性就到這裏了,它要求你的這個girl必須被直接登記在這個容器中,佔用一個寶貴的全局key,即使這個girl只是專門爲這個body臨時製造的夏娃。

在java中,遇到這種情況:

void A createA();{
B b = new B();;
return new A(b,b);;
}


我們只需要把b作爲一個局部變量,構造完A,b就扔掉了。然而,pico裏面這不成,b必須被登記在這個容器中。這就相當於你必須要把b定義成一個全局變量一樣。
pico的對應代碼:

container.registerComponent("b" new CachingComponentAdapter(new ConstructorInjectionComponentAdapter(B.class);););;
container.registerComponent("a", new ConstructorInjectionComponentAdapter(A.class););;


這裏,爲了對應上面java代碼中的兩個參數公用一個b的實例的要求,必須把a登記成一個singleton。CachingComponentAdapter負責singleton化某個組件,而ConstructorInjectionComponentAdapter就是一個調用構造函數的組建匹配器。


當然,這樣做其實還是有麻煩的,當container不把a登記成singleton的時候(pico缺省都登記成singleton,但是你可以換缺省不用singleton的container。),麻煩就來了。

大家可以看到,上面的createA()函數如果調用兩次,會創建兩個A對象,兩個B對象,而用這段pico代碼,調用兩次getComponentInstance("a"),會生成兩個A對象,但是卻只有一個B對象!因爲b被被迫登記爲singleton了。


2。pico除了支持constructor injection,也支持setter injection甚至factory method injection。(對最後一點我有點含糊,不過就假設它支持)。所以,跟spring對比,除了沒有一個配置文件,life-cycle不太優雅之外,什麼都有了。

但是,這就夠了嗎?如果我們把上面的那個createA函數稍微變一下:

A createA();{
B b = new B();;
return new A(b, b.createC(x_component););;
}



現在,我們要在b組件上面調用createC()來生成一個C對象。完了,我們要的既不是構造函數,也不是工廠方法,而是在某個臨時組件的基礎上調用一個函數。

缺省提供的幾個ComponentAdapter這時就不夠用了,我們被告知要自己實現ComponentAdapter。

實際上,pico對很多靈活性的要求的回答都是:自己實現ComponentAdapter。


這是可行的。沒什麼是ComponentAdapter幹不了的,如果不計工作量的話。

一個麻煩是:我們要直接調用pico的api來自己解析依賴了。我們要自己知道是調用container.getComponentInstance("x_component")還是container.getComponentInstance(X.class)。
第二個麻煩是:降低了代碼重用。自己實現ComponentAdapter就得自己老老實實地寫,如果自己的component adapter也要動態設置java bean setter的話,甭想直接用SetterInjectionComponentAdapter,好好看java bean的api吧。


其實,我們可以看出,pico的各種ComponentAdapter正是正宗的decorator pattern。什麼CachingComponentAdapter,什麼SynchronizedComponentAdapter,都是decorator。

但是,這也就是decorator而已了。因爲沒有圍繞組合子的思路開展設計,這些decorator顯得非常隨意,沒有什麼章法,沒辦法支撐起整個的ComponentAdapter的架構。

下一章,我們會介紹yan container對上面提出的問題以及很多其他問題的解決方法。

yan container的口號是:只要你直接組裝能夠做到的,容器就能做到。
不管你是不是用構造函數,靜態方法,java bean,構造函數然後再調用某個方法,等等等等。
而且yan container的目標是,你幾乎不用自己實現component adapter,所有的需求,都通過組合各種已經存在的組合子來完成。


對我們前面那個很不厚道地用來刁難pico的例子,yan的解決方法是:


b_component = Components.ctor(B.class);.singleton();;
a_component = Components.ctor(A.class);
.withArgument(0, b_component);
.withArgument(1, b_component.method("createC"););;


b_component不需要登記在容器中,它作爲局部component存在。
是不是非常declarative呢?


下一節,你會發現,用面向組合子的方法,ioc容器這種東西真的不難。我們不需要仔細分析各種需求,精心分配責任。讓我們再次體驗一下吊兒郎當不知不覺間就天下大治的感覺吧。

待續。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章