在患者就醫時,醫生會根據病情開具處方單,很多醫院都會存在以下這個流程:劃價人員拿到處方單之後根據藥品名稱和數量計算總價,而藥房工作人員根據藥品名稱和數量準備藥品,如下圖所示。
在軟體開發中,有時候也需要處理像處方單這樣的集合物件結構,在該物件結構中儲存了多個不同型別的物件資訊,而且對同一物件結構中的元素的操作方式並不唯一,可能需要提供多種不同的處理方式。在設計模式中,有一種模式可以滿足上述要求,其模式動機就是以不同的方式操作複雜物件結構,該模式就是訪問者模式。
訪問者模式(Visitor) | 學習難度:★★★★☆ | 使用頻率:★☆☆☆☆ |
一、OA系統員工資料彙總設計
1.1 需求背景
Background:M公司開發部想要為某企業開發一個OA系統,在該OA系統中包含一個員工資訊管理子系統,該企業包括正式員工和臨時工,每週HR部門和財務部等部門需要對員工資料進行彙總,彙總資料包括員工工作時間、員工工資等等。該企業的基本制度如下:
(1)正式員工(Full time Employee)每週工作時間為40小時,不同級別、不同部門的員工每週基本工資不同;如果超過40小時,超出部分按照100元/小時作為加班費;如果少於40小時,所缺時間按照請假處理,請假鎖釦工資以80元/小時計算,直到基本工資扣除到0為止。除了記錄實際工作時間外,HR部需要記錄加班時長或請假時長,作為員工平時表現的一項依據。
(2)臨時員工(Part time Employee)每週工作時間不固定,基本工資按照小時計算,不同崗位的臨時工小時工資不同。HR部只需要記錄實際工作時間。
HR人力資源部和財務部工作人員可以根據各自的需要對員工資料進行彙總處理,HR人力資源部負責彙總每週員工工作時間,而財務部負責計算每週員工工資。
1.2 初始設計
M公司開發人員針對需求,提出了一個初始的解決方案,其核心程式碼如下:
public class EmployeeList { // 員工集合 private IList<Employee> empList = new List<Employee>(); // 增加員工 public void AddEmployee(Employee emp) { this.empList.Add(emp); } // 處理員工資料 public void Handle(string deptName) { if (deptName.Equals("財務部")) { foreach (var emp in empList) { if (emp.GetType().Equals("FullTimeEmployee")) { Console.WriteLine("財務部處理全職員工資料!"); } else { Console.WriteLine("財務部處理兼職員工資料!"); } } } else if (deptName.Equals("人力資源部")) { foreach (var emp in empList) { if (emp.GetType().Equals("FullTimeEmployee")) { Console.WriteLine("人力資源部處理全職員工資料!"); } else { Console.WriteLine("人力資源部處理兼職員工資料!"); } } } } }
不難發現,該解決方案存在以下問題:
(1)EmployeeList類非常龐大,承擔了過多的職責,既不便於程式碼複用,也不便於系統擴充套件,違背了單一職責原則。
(2)包含了大量的if-else語句,測試和維護的難度增大。
(3)如果要新增一個部門來操作員工資料集合,那麼不得不修改EmployeeList類的原始碼,違背了開閉原則。
訪問者模式是一個可以考慮用來解決的方案,它可以在一定程度上解決上述問題(大部分問題)。
二、訪問者模式概述
2.1 訪問者模式簡介
訪問者模式是一種較為複雜的行為型模式,它包含訪問者和被訪問元素兩個主要組成部分,這些被訪問的元素通常具有不同的型別,且不同的訪問者可以對它們進行不同的訪問操作。例如:處方單中的各種藥品資訊就是被訪問的元素,而劃價人員和藥房工作人員就是訪問者。訪問者模式可以使得使用者在不修改現有系統的情況下擴充套件系統的功能,為這些不同型別的元素增加新的操作。
訪問者(Visitor)模式:提供一個作用於某物件結構中的各元素的操作表示,它使得可以在不改變各元素的類的前提下定義作用於這些元素的新操作。訪問者模式是一種物件行為型模式。
2.2 訪問者模式結構
訪問者模式結構圖中包含以下5個角色:
(1)Visitor(抽象訪問者):抽象訪問者為物件結構中每一個具體元素類ConcreteElement宣告一個訪問操作,從這個操作的名稱或引數型別可以清楚知道需要訪問的具體元素的型別,具體訪問者則需要實現這些操作方法,定義對這些元素的訪問操作。
(2)ConcreteVisitor(具體訪問者):具體訪問者實現了抽象訪問者宣告的方法,每一個操作作用於訪問物件結構中一種型別的元素。
(3)Element(抽象元素):一般是一個抽象類或介面,定義一個Accept方法,該方法通常以一個抽象訪問者作為引數。
(4)ConcreteElement(具體元素):具體元素實現了Accept方法,在Accept方法中呼叫訪問者的訪問方法以便完成一個元素的操作。
(4)ObjectStructure(物件結構):物件結構是一個元素的集合,用於存放元素物件,且提供便利其內部元素的方法。
三、重構OA系統員工資料彙總
3.1 重構後的設計結構
在上圖中,FinanceDepartment表示財務部,HRDepartment表示人力資源部,它們充當具體訪問者的角色,其抽象父類Department充當抽象訪問者角色;EmployeeList充當物件結構,用於儲存員工列表;FullTimeEmployee表示全職員工,PartTimeEmployee表示兼職員工,它們充當具體元素角色,而其父類IEmployee(這裡實現形式是interface)充當抽象元素角色。
3.2 具體程式碼實現
(1)抽象元素=>IEmployee
/// <summary> /// 抽象元素類:Employee /// </summary> public interface IEmployee { void Accept(Department handler); }
(2)具體元素=>FullTimeEmployee,PartTimeEmployee
/// <summary> /// 具體元素類:FullTimeEmployee /// </summary> public class FullTimeEmployee : IEmployee { public string Name { get; set; } public double WeeklyWage { get; set; } public int WorkTime { get; set; } public FullTimeEmployee(string name, double weeklyWage, int workTime) { this.Name = name; this.WeeklyWage = weeklyWage; this.WorkTime = workTime; } public void Accept(Department handler) { handler.Visit(this); } } /// <summary> /// 具體元素類:PartTimeEmployee /// </summary> public class PartTimeEmployee : IEmployee { public string Name { get; set; } public double HourWage { get; set; } public int WorkTime { get; set; } public PartTimeEmployee(string name, double hourWage, int workTime) { this.Name = name; this.HourWage = hourWage; this.WorkTime = workTime; } public void Accept(Department handler) { handler.Visit(this); } }
(3)物件結構=>EmployeeList
/// <summary> /// 物件結構類:EmployeeList /// </summary> public class EmployeeList { private IList<IEmployee> empList = new List<IEmployee>(); public void AddEmployee(IEmployee emp) { this.empList.Add(emp); } public void Accept(Department handler) { foreach (var emp in empList) { emp.Accept(handler); } }
(4)抽象訪問者=>Department
/// <summary> /// 抽象訪問者類:Department /// </summary> public abstract class Department { // 宣告一組過載的訪問方法,用於訪問不同型別的具體元素 public abstract void Visit(FullTimeEmployee employee); public abstract void Visit(PartTimeEmployee employee); }
(5)具體訪問者=>FinanceDepartment,HRDepartment
/// <summary> /// 具體訪問者類:FinanceDepartment /// </summary> public class FinanceDepartment : Department { // 實現財務部對兼職員工資料的訪問 public override void Visit(PartTimeEmployee employee) { int workTime = employee.WorkTime; double hourWage = employee.HourWage; Console.WriteLine("臨時工 {0} 實際工資為:{1} 元", employee.Name, workTime * hourWage); } // 實現財務部對全職員工資料的訪問 public override void Visit(FullTimeEmployee employee) { int workTime = employee.WorkTime; double weekWage = employee.WeeklyWage; if (workTime > 40) { weekWage = weekWage + (workTime - 40) * 50; } else if (workTime < 40) { weekWage = weekWage - (40 - workTime) * 80; if (weekWage < 0) { weekWage = 0; } } Console.WriteLine("正式員工 {0} 實際工資為:{1} 元", employee.Name, weekWage); } } /// <summary> /// 具體訪問者類:HRDepartment /// </summary> public class HRDepartment : Department { // 實現人力資源部對兼職員工資料的訪問 public override void Visit(PartTimeEmployee employee) { int workTime = employee.WorkTime; Console.WriteLine("臨時工 {0} 實際工作時間為:{1} 小時", employee.Name, workTime); } // 實現人力資源部對全職員工資料的訪問 public override void Visit(FullTimeEmployee employee) { int workTime = employee.WorkTime; Console.WriteLine("正式員工 {0} 實際工作時間為:{1} 小時", employee.Name, workTime); if (workTime > 40) { Console.WriteLine("正式員工 {0} 加班時間為:{1} 小時", employee.Name, workTime - 40); } else if (workTime < 40) { Console.WriteLine("正式員工 {0} 請假時間為:{1} 小時", employee.Name, 40 - workTime); } } }
(6)客戶端呼叫與測試
public class Program { public static void Main(string[] args) { EmployeeList empList = new EmployeeList(); IEmployee fteA = new FullTimeEmployee("樑思成", 3200.00, 45); IEmployee fteB = new FullTimeEmployee("徐志摩", 2000, 40); IEmployee fteC = new FullTimeEmployee("樑徽因", 2400, 38); IEmployee fteD = new PartTimeEmployee("方鴻漸", 80, 20); IEmployee fteE = new PartTimeEmployee("唐宛如", 60, 18); empList.AddEmployee(fteA); empList.AddEmployee(fteB); empList.AddEmployee(fteC); empList.AddEmployee(fteD); empList.AddEmployee(fteE); Department dept = AppConfigHelper.GetDeptInstance() as Department; if (dept != null) { empList.Accept(dept); } Console.ReadKey(); } }
其中,AppConfigHelper用於從配置檔案中獲得具體訪問者例項,配置檔案如下:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <appSettings> <add key="DeptName" value="Manulife.ChengDu.DesignPattern.Visitor.HRDepartment, Manulife.ChengDu.DesignPattern.Visitor" /> </appSettings> </configuration>
AppConfigHelper的具體程式碼如下:
public class AppConfigHelper { public static string GetDeptName() { string factoryName = null; try { factoryName = System.Configuration.ConfigurationManager.AppSettings["DeptName"]; } catch (Exception ex) { Console.WriteLine(ex.Message); } return factoryName; } public static object GetDeptInstance() { string assemblyName = AppConfigHelper.GetDeptName(); Type type = Type.GetType(assemblyName); var instance = Activator.CreateInstance(type); return instance; } }
編譯執行後的結果如下:
如果需要更換具體訪問者類,無須修改原始碼,只需要修改一下配置檔案。例如這裡將訪問者由人力資源部更改為財務部:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <appSettings> <add key="DeptName" value="Manulife.ChengDu.DesignPattern.Visitor.FinanceDepartment, Manulife.ChengDu.DesignPattern.Visitor" /> </appSettings> </configuration>
此時再次執行則會得到以下結果:
可以看出,如果我們要在系統中新增訪問者,那麼無需修改原始碼,只需新增一個新的具體訪問者類即可,從這一點看,訪問者模式符合開閉原則。
但是,如果我們要在系統中新增具體元素,比如新增一個新的員工型別為“退休人員”,由於原系統並未提供相應的訪問介面,因此必須對原有系統進行修改。所以,從新增新的元素來看,訪問者模式違背了開閉原則。
因此,訪問者模式與抽象工廠模式類似,對於開閉原則的支援具有“傾斜”性,可以方便地新增訪問者,但是新增新的元素較為麻煩。
四、訪問者模式總結
4.1 主要優點
(1)增加新的訪問操作十分方便,不痛不癢 => 符合開閉原則
(2)將有關元素物件的訪問行為集中到一個訪問者物件中,而不是分散在一個個的元素類中,類的職責更加清晰 => 符合單一職責原則
4.2 主要缺點
(1)增加新的元素類很困難,需要在每一個訪問者類中增加相應訪問操作程式碼 => 違背了開閉原則
(2)元素物件有時候必須暴露一些自己的內部操作和狀態,否則無法供訪問者訪問 => 破壞了元素的封裝性
4.3 應用場景
(1)一個物件結構包含多個型別的物件,希望對這些物件實施一些依賴其具體型別的操作。=> 不同的型別可以有不同的訪問操作
(2)物件結構中物件對應的類很少改變 很少改變 很少改變(重要的事情說三遍),但經常需要在此物件結構上定義新的操作。
參考資料
劉偉,《設計模式的藝術—軟體開發人員內功修煉之道》