1 IGame游戲公司的故事 1.1 討論會 話說有一個叫IGame的游戲公司,正在開發一款ARPG游戲(動作&角色扮演類游戲,如魔獸世界、夢幻西游這一類的游戲)。一般這類游戲都有一個基本的功能,就是打怪(玩家攻擊怪物,藉此獲得經驗、虛擬貨幣和虛擬裝備),並且根據玩家角色所裝備的武器不同,攻擊效果也 ...
1 IGame游戲公司的故事
1.1 討論會
話說有一個叫IGame的游戲公司,正在開發一款ARPG游戲(動作&角色扮演類游戲,如魔獸世界、夢幻西游這一類的游戲)。一般這類游戲都有一個基本的功能,就是打怪(玩家攻擊怪物,藉此獲得經驗、虛擬貨幣和虛擬裝備),並且根據玩家角色所裝備的武器不同,攻擊效果也不同。這天,IGame公司的開發小組正在開會對打怪功能中的某一個功能點如何實現進行討論,他們面前的大屏幕上是這樣一份需求描述的ppt:
圖1.1 需求描述ppt
各個開發人員,面對這份需求,展開了熱烈的討論,下麵我們看看討論會上都發生了什麼。
1.2 實習生小李的實現方式
在經過一番討論後,項目組長Peter覺得有必要整理一下各方的意見,他首先詢問小李的看法。小李是某學校電腦系大三學生,對游戲開發特別感興趣,目前是IGame公司的一名實習生。
經過短暫的思考,小李闡述了自己的意見:
“我認為,這個需求可以這麼實現。HP當然是怪物的一個屬性成員,而武器是角色的一個屬性成員,類型可以使字元串,用於描述目前角色所裝備的武器。角色類有一個攻擊方法,以被攻擊怪物為參數,當實施一次攻擊時,攻擊方法被調用,而這個方法首先判斷當前角色裝備了什麼武器,然後據此對被攻擊怪物的HP進行操作,以產生不同效果。”
而在闡述完後,小李也飛快的在自己的電腦上寫了一個Demo,來演示他的想法,Demo代碼如下。
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 namespace IGameLi 7 { 8 /// <summary> 9 /// 怪物 10 /// </summary> 11 internal sealed class Monster 12 { 13 /// <summary> 14 /// 怪物的名字 15 /// </summary> 16 public String Name { get; set; } 17 18 /// <summary> 19 /// 怪物的生命值 20 /// </summary> 21 public Int32 HP { get; set; } 22 23 public Monster(String name,Int32 hp) 24 { 25 this.Name = name; 26 this.HP = hp; 27 } 28 } 29 }View Code
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 namespace IGameLi 7 { 8 /// <summary> 9 /// 角色 10 /// </summary> 11 internal sealed class Role 12 { 13 private Random _random = new Random(); 14 15 /// <summary> 16 /// 表示角色目前所持武器的字元串 17 /// </summary> 18 public String WeaponTag { get; set; } 19 20 /// <summary> 21 /// 攻擊怪物 22 /// </summary> 23 /// <param name="monster">被攻擊的怪物</param> 24 public void Attack(Monster monster) 25 { 26 if (monster.HP <= 0) 27 { 28 Console.WriteLine("此怪物已死"); 29 return; 30 } 31 32 if ("WoodSword" == this.WeaponTag) 33 { 34 monster.HP -= 20; 35 if (monster.HP <= 0) 36 { 37 Console.WriteLine("攻擊成功!怪物" + monster.Name + "已死亡"); 38 } 39 else 40 { 41 Console.WriteLine("攻擊成功!怪物" + monster.Name + "損失20HP"); 42 } 43 } 44 else if ("IronSword" == this.WeaponTag) 45 { 46 monster.HP -= 50; 47 if (monster.HP <= 0) 48 { 49 Console.WriteLine("攻擊成功!怪物" + monster.Name + "已死亡"); 50 } 51 else 52 { 53 Console.WriteLine("攻擊成功!怪物" + monster.Name + "損失50HP"); 54 } 55 } 56 else if ("MagicSword" == this.WeaponTag) 57 { 58 Int32 loss = (_random.NextDouble() < 0.5) ? 100 : 200; 59 monster.HP -= loss; 60 if (200 == loss) 61 { 62 Console.WriteLine("出現暴擊!!!"); 63 } 64 65 if (monster.HP <= 0) 66 { 67 Console.WriteLine("攻擊成功!怪物" + monster.Name + "已死亡"); 68 } 69 else 70 { 71 Console.WriteLine("攻擊成功!怪物" + monster.Name + "損失" + loss + "HP"); 72 } 73 } 74 else 75 { 76 Console.WriteLine("角色手裡沒有武器,無法攻擊!"); 77 } 78 } 79 } 80 }View Code
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 namespace IGameLi 7 { 8 class Program 9 { 10 static void Main(string[] args) 11 { 12 //生成怪物 13 Monster monster1 = new Monster("小怪A", 50); 14 Monster monster2 = new Monster("小怪B", 50); 15 Monster monster3 = new Monster("關主", 200); 16 Monster monster4 = new Monster("最終Boss", 1000); 17 18 //生成角色 19 Role role = new Role(); 20 21 //木劍攻擊 22 role.WeaponTag = "WoodSword"; 23 role.Attack(monster1); 24 25 //鐵劍攻擊 26 role.WeaponTag = "IronSword"; 27 role.Attack(monster2); 28 role.Attack(monster3); 29 30 //魔劍攻擊 31 role.WeaponTag = "MagicSword"; 32 role.Attack(monster3); 33 role.Attack(monster4); 34 role.Attack(monster4); 35 role.Attack(monster4); 36 role.Attack(monster4); 37 role.Attack(monster4); 38 39 Console.ReadLine(); 40 } 41 } 42 }View Code
程式運行結果如下:
圖1.2 小李程式的運行結果
1.3 架構師的建議
小李闡述完自己的想法並演示了Demo後,項目組長Peter首先肯定了小李的思考能力、編程能力以及初步的面向對象分析與設計的思想,並承認小李的程式正確完成了需求中的功能。但同時,Peter也指出小李的設計存在一些問題,他請小於講一下自己的看法。
小於是一名有五年軟體架構經驗的架構師,對軟體架構、設計模式和麵向對象思想有較深入的認識。他向Peter點了點頭,發表了自己的看法:
“小李的思考能力是不錯的,有著基本的面向對象分析設計能力,並且程式正確完成了所需要的功能。不過,這裡我想從架構角度,簡要說一下我認為這個設計中存在的問題。
首先,小李設計的Role類的Attack方法很長,並且方法中有一個冗長的if…else結構,且每個分支的代碼的業務邏輯很相似,只是很少的地方不同。
再者,我認為這個設計比較大的一個問題是,違反了OCP原則。在這個設計中,如果以後我們增加一個新的武器,如倚天劍,每次攻擊損失500HP,那麼,我們就要打開Role,修改Attack方法。而我們的代碼應該是對修改關閉的,當有新武器加入的時候,應該使用擴展完成,避免修改已有代碼。
一般來說,當一個方法裡面出現冗長的if…else或switch…case結構,且每個分支代碼業務相似時,往往預示這裡應該引入多態性來解決問題。而這裡,如果把不同武器攻擊看成一個策略,那麼引入策略模式(Strategy Pattern)是明智的選擇。
最後說一個小的問題,被攻擊後,減HP、死亡判斷等都是怪物的職責,這裡放在Role中有些不當。”
Tip:OCP原則,即開放關閉原則,指設計應該對擴展開放,對修改關閉。
Tip:策略模式,英文名Strategy Pattern,指定義演算法族,分別封裝起來,讓他們之間可以相互替換,此模式使得演算法的變化獨立於客戶。
小於邊說,邊畫了一幅UML類圖,用於直觀表示他的思想。
圖1.3 小於的設計
Peter讓小李按照小於的設計重構Demo,小李看了看小於的設計圖,很快完成。相關代碼如下:
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 namespace IGameLiAdv 7 { 8 internal interface IAttackStrategy 9 { 10 void AttackTarget(Monster monster); 11 } 12 }View Code
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 namespace IGameLiAdv 7 { 8 internal sealed class WoodSword : IAttackStrategy 9 { 10 public void AttackTarget(Monster monster) 11 { 12 monster.Notify(20); 13 } 14 } 15 }View Code
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 namespace IGameLiAdv 7 { 8 internal sealed class IronSword : IAttackStrategy 9 { 10 public void AttackTarget(Monster monster) 11 { 12 monster.Notify(50); 13 } 14 } 15 }View Code
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 namespace IGameLiAdv 7 { 8 internal sealed class MagicSword : IAttackStrategy 9 { 10 private Random _random = new Random(); 11 12 public void AttackTarget(Monster monster) 13 { 14 Int32 loss = (_random.NextDouble() < 0.5) ? 100 : 200; 15 if (200 == loss) 16 { 17 Console.WriteLine("出現暴擊!!!"); 18 } 19 monster.Notify(loss); 20 } 21 } 22 }View Code
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 namespace IGameLiAdv 7 { 8 /// <summary> 9 /// 怪物 10 /// </summary> 11 internal sealed class Monster 12 { 13 /// <summary> 14 /// 怪物的名字 15 /// </summary> 16 public String Name { get; set; } 17 18 /// <summary> 19 /// 怪物的生命值 20 /// </summary> 21 private Int32 HP { get; set; } 22 23 public Monster(String name,Int32 hp) 24 { 25 this.Name = name; 26 this.HP = hp; 27 } 28 29 /// <summary> 30 /// 怪物被攻擊時,被調用的方法,用來處理被攻擊後的狀態更改 31 /// </summary> 32 /// <param name="loss">此次攻擊損失的HP</param> 33 public void Notify(Int32 loss) 34 { 35 if (this.HP <= 0) 36 { 37 Console.WriteLine("此怪物已死"); 38 return; 39 } 40 41 this.HP -= loss; 42 if (this.HP <= 0) 43 { 44 Console.WriteLine("怪物" + this.Name + "被打死"); 45 } 46 else 47 { 48 Console.WriteLine("怪物" + this.Name + "損失" + loss + "HP"); 49 } 50 } 51 } 52 }View Code
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 namespace IGameLiAdv 7 { 8 /// <summary> 9 /// 角色 10 /// </summary> 11 internal sealed class Role 12 { 13 /// <summary> 14 /// 表示角色目前所持武器 15 /// </summary> 16 public IAttackStrategy Weapon { get; set; } 17 18 /// <summary> 19 /// 攻擊怪物 20 /// </summary> 21 /// <param name="monster">被攻擊的怪物</param> 22 public void Attack(Monster monster) 23 { 24 this.Weapon.AttackTarget(monster); 25 } 26 } 27 }View Code
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 6 namespace IGameLiAdv 7 { 8 class Program 9 { 10 static void Main(string[] args) 11 { 12 //生成怪物 13 Monster monster1 = new Monster("小怪A", 50); 14 Monster monster2 = new Monster("小怪B", 50); 15 Monster monster3 = new Monster("關主", 200); 16 Monster monster4 = new Monster("最終Boss", 1000); 17 18 //生成角色 19 Role role = new Role(); 20 21 //木劍攻擊 22 role.Weapon = new WoodSword(); 23 role.Attack(monster1); 24 25 //鐵劍攻擊 26 role.Weapon = new IronSword(); 27 role.Attack(monster2); 28 role.Attack(monster3); 29 30 //魔劍攻擊 31 role.Weapon = new MagicSword(); 32 role.Attack(monster3); 33 role.Attack(monster4); 34 role.Attack(monster4); 35 role.Attack(monster4); 36 role.Attack(monster4); 37 role.Attack(monster4); 38 39 Console.ReadLine(); 40 } 41 } 42 }View Code
編譯運行以上代碼,得到的運行結果與上一版本代碼基本一致。
1.4 小李的小結
Peter顯然對改進後的代碼比較滿意,他讓小李對照兩份設計和代碼,進行一個小結。小李簡略思考了一下,並結合小於對一次設計指出的不足,說道:
“我認為,改進後的代碼有如下優點:
第一,雖然類的數量增加了,但是每個類中方法的代碼都非常短,沒有了以前Attack方法那種很長的方法,也沒有了冗長的if…else,代碼結構變得很清晰。
第二,類的職責更明確了。在第一個設計中,Role不但負責攻擊,還負責給怪物減少HP和判斷怪物是否已死。這明顯不應該是Role的職責,改進後的代碼將這兩個職責移入Monster內,使得職責明確,提高了類的內聚性。
第三,引入Strategy模式後,不但消除了重覆性代碼,更重要的是,使得設計符合了OCP。如果以後要加一個新武器,只要新建一個類,實現IAttackStrategy介面,當角色需要裝備這個新武器時,客戶代碼只要實例化一個新武器類,並賦給Role的Weapon成員就可以了,已有的Role和Monster代碼都不用改動。這樣就實現了對擴展開發,對修改關閉。”
Peter和小於聽後都很滿意,認為小李總結的非常出色。
IGame公司的討論會還在進行著,內容是非常精彩,不過我們先聽到這裡,因為,接下來,我們要對其中某些問題進行一點探討。別忘了,本文的主題可是依賴註入,這個主角還沒登場呢!讓主角等太久可不好。
2 探究依賴註入
2.1 故事的啟迪
我們現在靜下心來,再回味一下剛纔的故事。因為,這個故事裡面隱藏著依賴註入的出現原因。我說過不只一次,想真正認清一個事物,不能只看“它是什麼?什麼樣子?”,而應該先弄清楚“它是怎麼來的?是什麼樣的需求和背景促使了它的誕生?它被創造出來是做什麼用的?”。
回想上面的故事。剛開始,主要需求是一個打怪的功能。小李做了一個初步面向對象的設計:抽取領域場景中的實體(怪物、角色等),封裝成類,併為各個類賦予屬性與方法,最後通過類的交互完成打怪功能,這應該算是面向對象設計的初級階段。
在小李的設計基礎上,架構師小於指出了幾點不足,如不符合OCP,職責劃分不明確等等,並根據情況引入策略模式。這是更高層次的面向對象設計。其實就核心來說,小於只做了一件事:利用多態性,隔離變化。它清楚認識到,這個打怪功能中,有些業務邏輯是不變的,如角色攻擊怪物,怪物減少HP,減到0怪物就會死;而變化的僅僅是不同的角色持有不同武器時,每次攻擊的效用不一樣。於是他的架構,本質就是把變化的部分和不變的部分隔離開,使得變化部分發生變化時,不變部分不受影響。
我們再仔細看看小於的設計圖,這樣設計後,有個基本的問題需要解決:現在Role不依賴具體武器,而僅僅依賴一個IAttackStrategy介面,介面是不能實例化的,雖然Role的Weapon成員類型定義為IAttackStrategy,但最終還是會被賦予一個實現了IAttackStrategy介面的具體武器,並且隨著程式進展,一個角色會裝備不同的武器,從而產生不同的效用。賦予武器的職責,在Demo中是放在了測試代碼里。
這裡,測試代碼實例化一個具體的武器,並賦給Role的Weapon成員的過程,就是依賴註入!這裡要清楚,依賴註入其實是一個過程的稱謂!
2.2 正式定義依賴註入
下麵,用稍微正式一點的語言,定義依賴註入產生的背景緣由和依賴註入的含義。在讀的過程中,讀者可以結合上面的例子進行理解。
依賴註入產生的背景:
隨著面向對象分析與設計的發展,一個良好的設計,核心原則之一就是將變化隔離,使得變化部分發生變化時,不變部分不受影響(這也是OCP的目的)。為了做到這一點,要利用面向對象中的多態性,使用多態性後,客戶類不再直接依賴服務類,而是依賴於一個抽象的介面,這樣,客戶類就不能在內部直接實例化具體的服務類。但是,客戶類在運作中又客觀需要具體的服務類提供服務,因為介面是不能實例化去提供服務的。就產生了“客戶類不准實例化具體服務類”和“客戶類需要具體服務類”這樣一對矛盾。為瞭解決這個矛盾,開發人員提出了一種模式:客戶類(如上例中的Role)定義一個註入點(Public成員Weapon),用於服務類(實現IAttackStrategy的具體類,如WoodSword、IronSword和MagicSword,也包括以後加進來的所有實現IAttackStrategy的新類)的註入,而客戶類的客戶類(Program,即測試代碼)負責根據情況,實例化服務類,註入到客戶類中,從而解決了這個矛盾。
依賴註入的正式定義:
依賴註入(Dependency Injection),是這樣一個過程:由於某客戶類只依賴於服務類的一個介面,而不依賴於具體服務類,所以客戶類只定義一個註入點。在程式運行過程中,客戶類不直接實例化具體服務類實例,而是客戶類的運行上下文環境或專門組件負責實例化服務類,然後將其註入到客戶類中,保證客戶類的正常運行。
3 依賴註入那些事兒
上面我們從需求背景的角度,講述了依賴註入的來源和定義。但是,如果依賴註入僅僅就只有這麼點東西,那也沒有什麼值得討論的了。但是,上面討論的僅僅是依賴註入的內涵,其外延還是非常廣泛的,從依賴註入衍生出了很多相關的概念與技術,下麵我們討論一下依賴註入的“那些事兒”。
3.1 依賴註入的類別
依賴註入有很多種方法,上面看到的例子中,只是其中的一種,下麵分別討論不同的依賴註入類型。
3.1.1 Setter註入
第一種依賴註入的方式,就是Setter註入,上面的例子中,將武器註入Role就是Setter註入。正式點說:
Setter註入(Setter Injection)是指在客戶類中,設置一個服務類介面類型的數據成員,並設置一個Set方法作為註入點,這個Set方法接受一個具體的服務類實例為參數,並將它賦給服務類介面類型的數據成員。
圖3.1 Setter註入示意
上圖展示了Setter註入的結構示意圖,客戶類ClientClass設置IServiceClass類型成員_serviceImpl,並設置Set_ServiceImpl方法作為註入點。Context會負責實例化一個具體的ServiceClass,然後註入到ClientClass里。
下麵給出Setter註入的示例代碼。