谈谈.net模块依赖关系及程序结构
技术为解决问题而生。
上面这个命题并非本文重点,我将来有空再谈这个。本文也并非什么了不起的技术创新,只是分享一下我对.net模块依赖关系及程序结构方面的一些看法。先看一个最最简单的hello world网站的模块结构如何:
就一个Website,没有任何层次划分,因为简单嘛。但很快,你就发现,还是把网站和业务逻辑处理层分开比较好,于是变成:
箭头从BLL指向Website,表明Website依赖于BLL。随着BLL的内容的不断增多,你发现需要再细分一下,于是把BLL划分为处理人事业务的HR和办公自动化的Office(假如你在做一个企业应用):
随着HR和Office的内容的增多,你发现HR和Office中有些公共的东西可以提取出来,比如部门信息及客户信息等,于是你创建了一个公共资料模块PubMaterial,HR及Office都会用到它:
接着你很快又发现还是有些公共代码得提取出来,否则会导致大量重复代码,比如统一的日志记录、异常类型、数据库连接、加解密……等,于是你又弄了一个Common模块,其它模块都要用到它:
你很满意,是的,到目前为止,都没有碰到太大的问题,但接下来,问题来了,Office要新加一个功能,这个功能需要获取员工的具体信息,而员工具体信息HR中有实现,让Office直接调用HR模块吗?这样的话本来HR和Office之间的并列关系就被破坏了,但程序还是可以运行,技术上来说还是没问题的:
上面只是破坏了原本企图保持的业务逻辑模块之间的平行关系,技术上问题不大,但接下来真的是问题了,有一天老板需要在HR中新增一个功能,需要能直接看到员工的工作简报,工作简报本来应该是Office的功能,现在需要在HR中进行一些额外的处理,这样HR需要依赖于Office,很明显,这是一个相互依赖,这是不可行的:
解决方法大致有以下三种:
- 将Office的一些“公共功能”抽出成为新的模块,供HR使用,且抽出的新模块不依赖于HR
- 直接在HR中实现一部分Office的功能,使之不依赖Office
- 对各个业务逻辑模块进行“接口抽象”,详情后面会说
很明显,方案2会导致重复代码,肯定不可取;而方案1也存在很大的问题,因为接下来老板要求新增计算和查看统计数据的Report模块和处理公司审批流程的Flow模块,它们与HR存在相互依赖的关系,这次看看你如何招架:
然后,再要加呢?“Oh my god!”你喊道:“快给我讲方案3吧。”OK,现在我来摆出方案3,这不一定是最好的办法,但却是我所要使用的办法:
图看起来有些眼花缭乱,但再仔细一看便发觉其实只是给原先的每个模块增加了一个“接口层”而已,比如Office模块,增加了IOffice,现在就变成Office是对IOffice的实现,而原先需要依赖于Office的模块,现在转去依赖IOffice,而“接口层”仅仅变成了一个描述,它只包括数据及方法的声明,不包括任何实现,所以它可以不依赖于任何其它模块,它只被别的模块所依赖,包括实现它的模块。相信到此大家也都理解了。那么上图那个虚框和“Config”模块是什么意思呢?
试着这样想:假如现在Website要调用Office模块,通过IOffice,但是实现IOffice的却是Office模块,那是不是得先实例化一个Office?谁来负责实例化?如果是Website负责这个实例化工作的话,那势必Website会直接依赖与Office模块,这样就是走老路了,会出问题,而且Office本身又依赖于IHR、IPubMaterial和ICommon,那对应的HR、PubMaterial和Common又谁来实例化?看来如果照着老思路下去的话,还是会掉进“依赖关系地狱”中去的。不行,我们得借助一些工具,当然,重复造轮子也是不可取的,所以我们这次要使用一个叫StructureMap的工具。
使用过Microsoft Enterprise Library的朋友也许都不会对Unity陌生,这是一个IoC(Inversion of Control)工具,后来IoC又有了一个更贴切的名称,叫DI(Dependency Injection),IoC和DI其实都是为了解决程序模块之间的依赖关系而提出来的设计模式,它们本身并不是一种具体的技术,只是一些设计套路,旨在给程序模块松耦(Loose Coupling)。StructureMap也是一个和Unity类似的DI工具,个人感觉很不错。
我目前从NuGet上获取到的StructureMap的版本是2.6.4.1,需要说明的是StructureMap官方网站上提供的文档有些落后,跟不上形势了。所幸的是我们并不需要用它的所有功能,大多数软件不都这样么?80%的功能是留给20%的人用的。
有了这个StructureMap,我们要使用ICommon,直接告诉StructureMap就是,具体用什么去实例化,怎么实例化,不用我们操心,StructureMap自动帮我们做,这就是解决的思路。现在剩下的问题就只是告诉StrutureMap “ICommon”和“Common”的关系即可,可以直接用代码来指明关系,也可以用配置文件,配置文件看起来更具“扩展性”,但这样也就没有了编译期的出错检查,反正都是得写,我这次就用代码直接指明它们之间的对应关系吧。上图中的Config,就是这么一个用来指明接口与实现之间的对应关系的配置。(除了硬编码和写配置文件之外,StructureMap还可以使用反射来自动建立接口和实现体之间的对应关系,具体可以参考它的文档,这里略过不表)
在开始一个真正的例子之前,我还是得说明一些东西,那就是前面所提到的“模块”其实在不同的场合就有着不同的意义,这是一个抽象的概念,有时候是一个代码文件,有时候是一个类,有时候后是一个类库,有时候是一个程序集,大家不要纠结于它究竟是什么,而是要重点看看在自己的使用当中如何给它们松耦。
OK,都了解完之后,我们就开始一个小小的例子。简单起见,我删掉了许多模块,程序结构图变成了:
IHR的内容有:
public class Employee { public string EmpNo { get; set; } public string ChineseName { get; set; } } public class EmployeeDetail:Employee { public string Descriptions { get; set; } public int TaskAmount { get; set; } } public interface IHr { IEnumerable<Employee> GetAllEmployees(); EmployeeDetail GetEmployeeByNo(string strEmpNo); int GetEmployeeAmount(); void AddEmployee(Employee emp); string GetEmpNameByNo(string strEmpNo); }
IOffice的功能有:
public class Task { public int Id { get; set; } public string EmpNo { get; set; } public string Descriptions { get; set; } } public class TaskDetail : Task { public string EmpName { get; set; } } public interface IOffice { IEnumerable<Task> GetAllTasks(); TaskDetail GetTask(int id); void AddTask(string strEmpNo, string strDesc); void DeleteTask(int id); int GetTaskAmountOfEmployee(string strEmpNo); }
ICommon的功能有:
public enum LogType { Information, Warning, Error } public interface ICommon { void Log(LogType logType, string moduleName, string content, params object[] values); }
很明显,这些都是“接口”,不包含任何的实现,它们只会被别的模块依赖,而不会依赖别的模块。
功能相信一看名字就知道怎么回事,不需要太多解释。值得说一下的是HR模块的“GetEmployeeByNo”方法返回的“EmployeeDetail”中包括了一个“TaskAmount”,也就是这个员工的“任务数”,这是Office模块的功能,这意味着HR模块需要调用Office模块的方法;接着看Office模块中的“GetTask”方法,会返回一个“TaskDetail”,“TaskDetail”中含有“EmpName”,即员工姓名,这个需要从HR模块中获取。这是一个“相互依赖”!但这次我向你保证没有问题,因为我们采用了新的设计模式工具——StructureMap,现在,我们来告诉StructureMap如何来帮助我们生成这些接口的实例,看代码:
public static class ContainerBootstrapper { public static void BootstrapStructureMap() { ObjectFactory.Initialize(x => { x.For<IHr>().Singleton().Use<HrManager>(); x.For<IOffice>().Singleton().Use<OfficeManager>(); x.For<ICommon>().Singleton().Use<CommonManager>().Ctor<string>("logPath").Is(AppDomain.CurrentDomain.BaseDirectory+"log"); }); } }
一个静态配置类,一个静态方法,只需要在程序入口函数处调用一下即可。上面的“Singleton”方法是告诉StructureMap我们的HR、Office和Common模块都是“单实例”的,自始自终只有一个实例,如果把“.Singleton()”去掉,那么每次请求实例的时候都会创建一个新的实例出来,另外还有“HttpContextScoped”和“HybridHttpOrThreadLocalScoped”模式,前者表示每个HttpContext使用一个实例,这得在Web程序中才有效,否则按默认处理,而后者则会判断当前程序类型,如果是Web程序,那么和前者一样,如果不是Web程序,那么就每个线程使用一个实例, 究竟怎么选择,这个要看你自身的需要,在我的这个小小的demo中,Singleton即可。另外,对ICommon的实现的CommonManager的构造函数是带参数的,需要指定,否则就会出现运行时错误,上面的代码的意思是logPath这个参数是个string,把“AppDomain.CurrentDomain.BaseDirectory+"log"”作为它的值初始化。
接下来看看Main函数代码片段,简单起见,我写了个控制台程序来“冒充”Website:
static void Main(string[] args) { //初始化StructureMap ContainerBootstrapper.BootstrapStructureMap(); //获取HR实例 IHr hr = ObjectFactory.GetInstance<IHr>(); //用之 //... //获取Office实例 IOffice office = ObjectFactory.GetInstance<IOffice>(); //用之 //... }
非常好!这样一来,模块项目依赖的关系就解决了。大家还可以看看“log”目录,你会发现一次运行中,尽管多次请求获取HR和Office的实例(HR中会请求Office,而Office也会请求HR),但它们都只会被初始化了一次,这是由于配置中指明了它们是Singleton。现在,我们来看看这个Solution的结构:
在这个Solution中,我是把IHr、IOffice和ICommon合并到了Interface这个Project中去,这样的话项目引用会少一点,但如果你要把它们分开的话也是完全没有问题的。
花了这么大的力气来处理模块相互依赖的问题其实还有一个目的,那就是可以做单元测试,如果时间允许,我将会另写一篇文章来讲述如何测试。
At Last,当然少不了完整代码(Visual Studio 2010):structuremap_demo.7z