最近有个刚接触DevExpress XAF的朋友问我:往一个已经跑起来的XAF工程里添加新模块,是不是右键新建一个类库,再引几个引用就行了?我跟他说,如果你把XAF的模块机制理解成普通的类库引用,后面大概率会被各种启动报错、数据库更新失败、模块不生效折腾到怀疑人生。这篇就从一个实际项目的视角,把添加.NET XAF模块的完整流程、关键原理和我在真实工程里踩过的坑都捋一遍,给正在搞XAF模块化拆分的开发者做一个可以直接对照的参考。
XAF(eXpressApp Framework)里,Module不是一个可有可无的概念,而是整个应用架构的基本单元。你可以把XAF应用理解成一台主机,模块就是插在上面的功能板卡:业务对象、控制器、权限规则、自定义编辑器,全都是通过模块组织并注册到应用中的。理解了这句话,后面所有操作都不会跑偏。
1. 先搞懂XAF模块机制:它和普通类库到底差在哪
1.1 模块是应用的“器官”,不是“零件盒”
刚开始用XAF时,最容易犯的思维错误是把模块当成一个普通的.NET类库项目。普通类库解决的是“代码复用”,引用、调用、完事。但XAF的模块承担的是“能力注册”,它里面的业务对象要参与数据库映射,控制器要挂到UI事件链上,菜单项要出现在导航栏里,权限校验要接入安全系统。这一大堆事情,光有代码是没用的,必须让XAF运行时知道你定义了这些东西,而模块就是“告诉运行时”的载体。
模块本质上是继承自ModuleBase的类,在类里通过AdditionalExportedTypes声明业务对象,通过Controllers集合注册控制器,通过CustomizeTypesInfo扩展元数据。XAF启动时,会扫描模块集合并加载这些信息,把它组装进整个应用的元数据模型(Application Model)里。没有模块这层封装,你写的业务对象就是一堆不参与XAF生命周期的“裸类”。
1.2 业务模块和平台模块,两者千万不能混
XAF模块通常有两种:一种是纯业务模块,只包含业务对象、服务逻辑、与平台无关的控制器,例如合同管理模块、客户管理模块;另一种是平台相关模块,针对WinForms、Blazor、Web API等具体平台定制UI和行为。DevExpress在生成解决方案时,会默认给你建MySolution.Module(业务模块)和MySolution.Module.Win、MySolution.Module.Blazor这类平台模块。
我在项目里见过不少同事在往Blazor平台模块里塞业务逻辑,结果另一个平台的客户端什么都用不上。正确做法是:公用的业务对象放在业务模块,平台特殊处理(比如自定义控件、平台特定Action)才放到平台模块。跨平台复用的诉求决定了你必须把“业务”和“平台”拆开,这在动手添加模块之前就要想清楚。
1.3 模块通信和依赖关系是怎么建立的
模块与模块之间不是彼此孤立的,XAF允许模块之间建立依赖。常见的依赖声明方式有几种:在Module.Designer.cs里添加模块引用,或在Module构造器里通过RequiredModuleAttribute声明前置模块。比如你的日志模块依赖于“基础资料”模块,那么在日志模块的模块类上标注依赖,XAF启动时会自动按顺序加载前置模块,顺序错了会直接抛异常。
理解了依赖关系,后面排错就能少走弯路。我曾经遇到过启动时报“模块无法加载,因为它的依赖模块不存在”,最后排查发现是模块引用了另一个版本不匹配的模块,程序集名称完全对不上。
2. 动手前必须敲定的三件事
2.1 模块边界怎么划分才不会越改越乱
模块拆分是XAF项目里最考验经验的一步。拆小了,项目数量爆炸,模块间引用错综复杂;拆大了,模块退化成一个大杂烩,模块化形同虚设。一个可参考的拆分逻辑是“按业务域划分”,比如把客户、订单、发货拆成三个模块,模块内部高内聚,模块之间通过公开接口或事件交互。
我在一个进销存项目里的做法是:核心主数据(商品、分类、单位)放一个模块,销售出库放一个模块,采购入库放一个模块,公共权限放一个模块。销售模块引用主数据模块,但它不引用采购模块,避免横向耦合。这个粒度在后期做功能裁剪时特别方便,客户只要销售线产品,直接把销售模块相关的页面和控制器保留,其他模块从界面里摘掉即可。
2.2 模块目标框架和XAF版本必须对齐
如果你用DevExpress的新版XAF,目标框架通常是.NET 8或更高。添加模块时要注意TargetFramework一定要和主应用一致,否则引用之后可能编译通过但运行时不兼容。另外,DevExpress安装的版本号必须全局统一,NuGet包的版本也要统一,混版本基本是开启灾难模式。
这里有个细节:新版的.csproj文件里写的<TargetFramework>net8.0</TargetFramework>,如果主应用是net8.0-windows(WinForms平台),你的业务模块用net8.0就行,但平台模块一般得跟随平台目标,否则平台特有API引用不到。
2.3 梳理现有模块的依赖图,别等编译报错才回头
在一个已经运行的项目里加新模块,最怕的是新模块引用了旧模块里的对象,却没在依赖关系里声明。比如新模块的业务对象A继承自主数据模块的基类EntityA,但你没给新模块加“依赖主数据模块”的标记,启动时XAF不会自动去扫描主数据模块,就会发现类型无法解析。
动手之前,建议先列出当前解决方案里所有模块的清单,明确谁依赖谁。DevExpress的Module.Designer.cs里能看到RequiredModuleAttribute和模块引用列表,先把这张图理清楚再动手,效率会高很多。
3. 完整操作记录:从创建模块项目到应用真正加载它
3.1 用XAF Solution Wizard添加模块,是最不容易出错的路径
如果你的解决方案是从Project Wizard创建的标准XAF结构,添加新模块最顺手的方式还是回到向导里。在Visual Studio中双击项目文件,或者在DevExpress菜单下找到Project Wizard的“Add Module”功能,选择你需要的模块类型(业务模块、平台模块等),向导会帮你把模块项目、项目引用、平台模块链都处理好。
用向导的优势在于,它不光创建类库,还会正确地在平台项目的ApplicationConfiguration里注册模块。生成的结构里会多一个模块项目,目标框架、DevExpress包版本都是按当前环境自动匹配的,出现版本错乱的概率几乎为零。
3.2 手动创建类库项目:适合已有复杂解决方案的场景
如果你的解决方案已经有很多定制结构,或者用的是非标准目录,向导不一定能完全识别你想要的路径,这时可以手动添加。步骤如下:
- 创建一个**类库(Class Library)**项目,目标框架对齐主应用,建议
.NET 8.0或当前环境的主版本。 - 用NuGet引用DevExpress的XAF相关包。业务模块一般至少需要
DevExpress.ExpressApp、DevExpress.Persistent.Base、DevExpress.Data,具体按业务对象涉及的接口来补。 - 在项目里添加一个继承自
ModuleBase的类,文件名一般叫MeSolutionModule.cs,并定义模块的构造函数、AdditionalExportedTypes等成员。
这个过程看起来简单,实际操作中有两个容易踩的点:第一个是NuGet包的版本必须与主应用引用的一致,我曾因一个包版本差了0.1,导致运行时在自动更新数据库阶段疯狂报错;第二个是如果你引用了DevExpress.Persistent.BaseImpl,包里默认会带来一批基础业务对象(比如BaseObject、Person等),这些对象会被自动注册到模块里,引起数据库多出一堆多余表。为了避免这种“隐式污染”,我一般用AdditionalExportedTypes显式声明要暴露的业务类型,而不是让整个程序集全量注册。
3.3 让主应用“认识”模块这两步缺一不可
模块建好了不代表就生效了。XAF运行时通过两个途径发现模块:一是在模块程序集中通过ModuleBase的派生类标记;二是在宿主应用里显式把模块添加到Application.Modules集合。所以新建模块后,必须确保平台项目(Blazor/WinForms/WebAPI)的模块注册代码里AddModule了自己需要的模块。
放代码你就能看懂:
// 在Program.cs或Startup配置里 builder.UseApplication<MyApplication>(app => { app.Modules.Add(new MyBusinessModule()); app.Modules.Add(new MyPlatformBlazorModule()); app.Modules.Add(new DevExpress.ExpressApp.Validation.ValidationModule()); });平台模块比较特殊,它会自动引用业务模块,并向宿主注册对应模块。如果你的新模块不需要平台层面的特殊处理,只要在业务模块层面注册一次即可,平台会自动继承。但如果你的模块里有平台相关的Controller或自定义控件,就必须在对应的平台项目里添加并注册。
3.4 平台项目引用必须是“引用模块程序集”,不是只复制DLL
很多新人习惯手动把编译好的DLL复制到Bin目录,以为这样就算引用了。实测下来这会带来数据库更新模块识别不到的问题。正确姿势是在平台项目里直接添加项目引用,让MSBuild在编译时自动传递依赖。这样做的另一个好处是:编译时能发现模块里引用的其他程序集缺失,不至于运行时才炸。
4. 模块内部到底要写什么:业务对象、控制器与应用模型
4.1 Module类的核心注册逻辑
一个典型的模块类长这样:
public sealed class MyBusinessModule : ModuleBase { public MyBusinessModule() { // 告诉XAF哪些类型是业务对象,需要纳入数据库映射 AdditionalExportedTypes.Add(typeof(MyBusinessObject)); AdditionalExportedTypes.Add(typeof(Order)); // 设置模块名称和版本 this.Name = "MyBusinessModule"; this.Description = "业务模块,负责产品和订单管理"; } protected override IEnumerable<Type> GetDeclaredControllerTypes() { // 注册控制器,控制器可以拦截列表视图、新建、保存等动作 yield return typeof(MyController); yield return typeof(MyServiceController); } }这里重点说AdditionalExportedTypes。每个模块都有“显式导出”的类型,这些类型会被纳入XAF的元数据模型。如果你忘了在模块里声明业务对象的类型,即使对象类本身写得很完整,数据库更新时也不会自动建表,界面上也找不到对应的ListView。
4.2 业务对象进数据库:Update Database的完整机制
添加了业务对象后,要让数据库响应,XAF是通过“数据库更新器”机制来实现的。应用启动时会检查当前数据库结构与模块声明的业务类型不匹配,然后根据差异生成升级脚本。常见的入口是在主应用启动时选择“Update Database”,或者你主动调用:
// 使用DatabaseUpdateMode,或在启动流程中配置 app.DatabaseUpdateMode = DatabaseUpdateMode.UpdateDatabaseAlways; app.Connect();如果你在模块里添加了一个新对象,启动应用时会在“Create/Upgrade Database”对话框里看到新增的表和字段差异。这里有个经验:别在正式环境直接自动更新数据库,最好让它在开发环境生成一个XafUpdateDatabase.sql的差异脚本,交给DBA审查后再执行。我吃过一次直接把生产库干挂的亏,那次的教训就是“自动更新”四个字在联调环境也绝不轻易点。
4.3 控制器(Controller)和Action如何挂进模块生命周期
模块里不只是数据对象,还有业务逻辑控制器。控制器是XAF里面挂载到视图交互链路的枢纽,比如你希望在“订单列表”界面点某个按钮时执行一段自定义逻辑,就得写一个ViewController,然后把它注册到模块里。
public class OrderListController : ViewController { public OrderListController() { TargetViewType = ViewType.ListView; TargetObjectType = typeof(Order); } protected override void OnActivated() { base.OnActivated(); var createInvoiceAction = new SimpleAction(this, "CreateInvoice", PredefinedCategory.RecordsEdit); createInvoiceAction.Execute += CreateInvoiceAction_Execute; } }控制器挂上之后,XAF会自动在UI上生成按钮和菜单入口。这正是模块化开发优雅的地方:UI、逻辑和数据的组合都在模块里声明式完成,而不是让每个页面自己去维护一份控件代码。
4.4 Application Model(应用模型)是模块交互的“内存数据库”
XAF的界面配置(导航栏、按钮位置、视图属性)默认保存在Application Model中,这个模型在运行时是模块化的信息中枢。添加新模块后,XAF会尝试从模块的Model差异文件(Model.xafml)读取对该模块的界面定制。如果你的模块要调整某个视图的默认字段顺序,或给某个ListView加一个过滤器,别在运行时改代码,直接在模块的Model.xafml里维护。
这个小知识点很有用。很多刚入坑的同仁都是在视图布局里手工拖字段,但XAF模型层的配置比UI层稳定得多——它不随控件版本升级漂移。我的习惯是:模块相关的UI微调全部写进模块自带的Model.xafml,这样模块部署到另一个应用时,界面配置会跟着模块走,不需要手动重做。
5. 实际项目里我踩过的坑,按排查链路讲透
5.1 模块“看似加载了”却不生效的排查链路
这是最高频的问题。表现是:代码里明明app.Modules.Add了,业务对象定义也没问题,但启动后数据库没有新表,菜单也没有对应入口。碰到这种情况,我的排查顺序一定是:
- 先在模块构造函数里打印或打断点,确认模块类确实执行了。
- 检查
AdditionalExportedTypes里是否声明了业务类型。 - 检查Module类的
Description和版本号是否正常,会不会被平台项目的同一程序集版本遮蔽。
有一次排查到最后发现是模块编译输出的DLL被旧版本缓存覆盖了,重启IIS才生效。这个坑在中大型解决方案里特别常见,因为平台项目引用模块项目时,如果模块项目设置了“优先输出路径”,可能把旧DLL覆盖到别处,结果运行时加载的是旧程序集。
5.2 模块依赖顺序导致的“类型无法解析”
我有一个具体案例:销售模块的控制器依赖“库存模块”里定义的一个枚举,但销售模块的Module.cs没有标注[RequiredModule(typeof(库存Module))]。结果应用启动时,销售模块先被加载,控制器尝试解析库存枚举类型,连编译都过了,运行却报“找不到类型,无法加载程序集”。
后来在模块类上加了RequiredModule属性,指定依赖模块,问题就消失了。原因是XAF在加载模块时会先处理带依赖标记的模块,把依赖模块先加载进运行时上下文。这个标记不是可选项,只要模块之间存在类型引用,就必须显式声明。
5.3 DevExpress大版本升级后模块不兼容的修复经验
XAF从.NET Framework迁移到.NET Core/.NET 5/6/8的版本过程中,模块项目的目标框架、包名、命名空间都经历过调整。如果你把一个老工程的模块直接拖进新解决方案控制台,大概率会遇到“找不到DevExpress.ExpressApp”的编译错误。
我会先打开每个模块的csproj,核对目标框架和包引用路径。早期版本安装包路径可能是C:\Program Files\DevExpress\XX\Components\Bin\Framework\,新版则是NuGet包方式。我的处理方式是:把所有模块生产方向调整为统一通过NuGet引用DevExpress包,并锁死同一个版本号。这样不管是迁移到新电脑还是CI环境,构建都不会因为缺少全局程序集而挂掉。
5.4 数据库更新时“对象已存在”或者“字段类型冲突”的处理
模块新增字段后,开发环境执行Update Database时偶尔会碰到“表或字段已存在”的报错。这通常是因为模型里已经有了同名但类型不同的字段定义,或者数据库中残留了过期表。我的做法是:先备份数据库,再用Solution工具的“Create/Upgrade Database”对比模型和实际表结构。如果只是字段类型冲突,一般删掉旧字段重新生成即可;如果涉及索引、约束,一定要确认差异脚本再执行。
有一条底线:任何环境(包括测试环境)都别直接点“自动升级”,手动核对SQL差异是唯一稳妥的选择。
6. 关于模块化实践的一些经验沉淀
6.1 模块拆分粒度:宁多勿少,还是宁少勿多?
我的倾向是:初期不要拆太碎,尽量以“业务域”为粒度,一个域一个模块。比如“合同管理”就是一个模块,“客户资料”是另一个模块。粒度太碎会让模块的引用图变得冗长,而且编译速度明显下降。真正需要进一步拆分的信号是:某个模块包含多个子功能且彼此无依赖,或者多个平台定制逻辑堆积在同一个模块里。
我见过有人按功能页面拆模块,结果一个订单功能拆了四个模块,互相引用,最后为了调整一个按钮位置要同时编译三个项目。这种过度设计比不做模块化更痛苦。
6.2 模块命名的工程约定
模块命名看起来是小事,但团队协作时命名不一致会导致模块引用查找困难。我建议遵循DevExpress社区较通用的约定:业务模块就叫SolutionName.Module,平台模块在后缀缀上平台名,如SolutionName.Module.Win。自定义功能模块也保持SolutionName.销售模块这样的可读命名,主项目里误加模块时一眼能看出来。
同时每个模块里维护好Module.cs里的Name和Description,因为XAF的模块列表界面会直接展示这些信息。下次部署查日志时,你能快速定位当前加载了哪些模块、哪些没加载。
6.3 模块化在团队协作中的实际收益
现在回看,坚持模块化带来的最大收益不是代码复用,而是“隔离”。团队并行开发时,A同事负责的销售模块和B同事负责的财务模块只要不互相引用,彼此改动几乎不会互相影响。接口稳定后,两个模块的独立发布和部署都成为可能。这在我们做多客户定制版本时非常爽——每个客户拿到的是同一套底层平台+选配模块组合,而不是从一套大单体的代码里临时剪裁功能。
当然,模块化不会自动让项目变好,它需要纪律。依赖方向一旦乱了,模块间环状依赖会让编译报错和启动报错变得极其隐晦。所以我的建议是:每个迭代都抽时间检查一次模块引用图,发现逆向依赖或越界依赖立即修,别让技术债滚到项目中期再回头还。
7. 一个小技巧:如何快速验证新模块被正确加载
写到最后,分享一个我常用的验证小技巧。如果你不确定新模块是否被正常加载,最简单的办法是启动应用后进入“关于”窗口,或使用安全系统自带的管理员界面查看已加载模块列表。Blazor平台一般会有/SystemModule相关页面,WinForms平台在帮助菜单或启动日志里也能找到模块加载记录。
如果线上不方便看界面,就检查启动日志里的模块注册条目。我在CI流程里会加一个启动测试,在启动事件里读取Application.Modules列表并断言预期的模块存在,一旦模块没注册上,构建直接失败,而不是等部署后才发现功能缺失。
这个方法几乎零成本,但对长期维护特别有帮助。因为XAF模块列表是运行时动态拼装的,静态代码审查很难发现“某个平台项目的模块没加进去”这种遗漏,自动化断言能第一时间锁住问题。