拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于MAF与文件存储的插件化MVP实践:从管道到进程隔离

我先把话说前头如果你跟我一样平时都在 .NET 技术栈里折腾应该多多少少听过 MAF 这个缩写。它全名是 Managed Add-in Framework中文社区也叫它“托管加载项框架”。这个东西至少从 .NET Framework 3.5 时代就在了后来 .NET Core 时代风头被 MEF 和其他插件方案盖过去不少但真要论隔离性、管道设计、跨 AppDomain 甚至跨进程加载插件MAF 依然是一套非常能打的设计。这次的题目是“基于 File-Based App 开发 MVP 项目”也就是用 MAF 搭建一个以文件为核心的 MVP 应用。我把“MVP”先解释清楚这里说的是 Minimum Viable Product最小可行产品不是 Model-View-Presenter 那个 MVP 架构模式。两者英文缩写一样但解决的是不同问题。我们用一个实际能跑的文件处理小程序把 MAF 的管道、适配器、加载项发现机制全部过一遍同时把文件作为配置中心和数据存储不引入数据库做一个真正轻量的 MVP。无论你是刚接触扩展框架的新手还是想给老项目补一套隔离插件方案的开发者这篇都能用得上。1. 为什么用 MAF 起一个 File-Based 的 MVP 项目1.1 MAF 到底是什么它保证的是哪一层安全MAF 的本质是把“宿主程序”和“插件”之间的依赖关系完全切段。很多人一听到插件第一反应是反射加载 DLL然后 new 一个类型、调用方法。这样做不是不行而是耦合太直接插件程序集一旦异常崩溃宿主也跟着倒霉插件引用的第三方库版本和宿主冲突直接给你来一个 FileLoadException再严重点如果插件是外部团队交付的你根本不想让它在你的主进程里随便运行。MAF 的思路是加一条“管道”也就是一堆专门的适配器层。插件不直接和宿主见面数据在中间经过契约层和适配器的转换才被对方消费。这么做有几个直接收益宿主和插件可以存在于不同的 AppDomain甚至可以跨进程运行契约版本独立管理更新插件不影响宿主插件没法直接反射宿主内部类型因为边界上只有 Contract 暴露出来的那几根线。对于 MVP 阶段也许感觉有点重但如果你做的东西未来要接外部团队插件这个隔离价值会非常明显。1.2 File-Based App 和 MAF 为什么是合适的搭配所谓 File-Based App就是不用数据库服务把数据落到文件里。常见形式是 JSON、XML、CSV或者干脆一个 SQLite 文件也算文件形态。MVP 阶段最大的诉求是什么是快速验证需求、快速演示、快速改。你不想为了一个 Demo 去装数据库服务器、配连接字符串、建表脚本、迁移工具。文件方案的好处在于整个项目可以只有一个可执行文件、一个配置文件和几个数据文件拷到任何一台 Windows 机器就能跑配置丢进 Git 能直接 diff谁改了什么一目了然后台上线前迁移数据库的成本为 0。MAF 本身对数据存储没有任何意见它的加载项发现机制依赖的是.addin清单文件这也是一种文件。所以一个基于文件的宿主应用加上一个基于文件的插件管理系统形成了一个天然自洽的组合。你不需要在 MVP 里引入数据库来记录“谁安装了哪些插件”一个 JSON 列表就够了。这也符合我先跑通业务逻辑再考虑规模化的演进思路。1.3 老技术回春MAF 在 AI 时代还能干嘛我注意到搜索热词里有 “Semantic Kernel 与 MAF” 这个组合。很多人以为 Semantic Kernel 会把 MAF 干掉其实两者压根不在一个维度。Semantic Kernel 是 AI 编排层的 SDK它管理的是 Kernel、Skills、Planner、Memory 这些东西让应用能调用大模型、编排插件。MAF 管的是宿主程序与插件之间的隔离边界和生命周期。你可以这样理解Semantic Kernel 负责“大脑怎么思考”MAF 负责“外包怎么隔离”。你可以在 MAF 的加载项里面封装一个 Semantic Kernel 的 Skill也可以让它去调一个文本生成服务。对于 MVP这种组合的意义在于宿主对 AI 能力零依赖插件可以被替换、升级、甚至卸载AI 服务崩了也只是插件进程崩了不会拖垮宿主。后面我会在实操部分专门说一个简单可行的结合模式。2. 管道架构和程序集目录MAF 的骨架必须先弄懂2.1 七层管道每一层管什么MAF 的经典管道设计一共有七个部分从宿主侧往插件侧数分别是 Host、Host View、Host Side Adapter、Contract、Add-in Side Adapter、Add-in View、Add-in。很多人第一次接触会被这一堆 View、Adapter 绕晕我建议你把握一个核心脉络契约是界碑两端视图是“双方心里各自认为的样子”适配器是翻译官。Contract双方都能看到的接口必须继承IContract标注[AddInContract]这是 MAF 跨边界的定海神针。Host View宿主期望看到的抽象类或者接口它定义了宿主最舒服的使用方式。Add-in View插件侧看到的抽象基类通常和 Host View 长得几乎一样所以有人问为什么不直接共用一个程序集。分开的原因是要保证两侧的程序集可以不引用对方实现真正的物理解耦。Host Side Adapter把 Contract 翻译成 Host View 给宿主用。Add-in Side Adapter把实际的 Add-in 适配成 Contract 传给宿主侧。Host调用AddInStore扫描管道目录拿 Token激活插件。Add-in真正的实现标注[AddIn]。这么多层的设计对小型 MVP 来说确实偏重但它不是白加的将来你要把插件放到独立进程执行或者插件需要远程调用你只需要替换适配器层宿主和插件都不动。这个“未来可扩展”的确定性在真实项目里非常宝贵。2.2 目录约定AddInStore 是怎么找到插件的MAF 并不是像一些反射式插件框架那样直接扫描某个 DLL它要求有一套目录结构和清单文件。默认方式下你要在某个根目录一般叫Pipeline下创建子目录Contracts、AddIns、AddInSideAdapters、HostSideAdapters。宿主在启动时调用AddInStore.Update(pipelineRoot)来扫描这些目录生成缓存文件然后调用FindAddIns查找符合条件的插件。打包的时候Contracts目录放契约程序集。AddInSideAdapters放插件侧适配器程序集。HostSideAdapters放宿主侧适配器程序集。AddIns下可以按插件再分子目录每个插件目录里放插件程序集和对应的.addin清单文件。这个目录结构在 Visual Studio 调试时往往不会自动生成因为项目默认都是把 DLL 拷到各自的输出目录不会自动归位。所以要么在项目属性里改输出路径要么写一个构建后事件做文件复制。第一次跑通的人十有八九都卡在这一步等会我会把复制脚本也放出来。2.3 与 MEF、Semantic Kernel 插件的边界怎么划很多人拿 MEF 和 MAF 比较我给一个实用判断MEF 更简单适合处理应用内部的组合、导入导出扩展点多但不涉及强隔离MAF 更强调边界适合做外部插件生态、跨进程隔离、版本可控。MVP 阶段如果只有两三个内部模块用 MEF 是很香的。但如果你想做一个文件处理引擎允许团队外部写处理器并且不允许它们直接碰宿主进程那 MEF 的默认能力就不够了MAF 反而是教科书级的方案。Semantic Kernel 自带的是 Skill/Plugin 机制它和 MAF 也可以共存。最简单的模式是在 Add-in 内部引入 Semantic Kernel把加载项包装成一个文本处理技能契约里只暴露string Process(string input)这种干净接口。这样大模型调用、Prompt 配置、Kernel 生命周期全部留在插件端宿主完全不知道对面是不是用了 AI。这对我来说已经是目前比较优雅的 AI 插件化玩法了。3. 场景设计用一个文本处理 MVP 把全链路串起来3.1 MVP 要做的事读文件、处理、写文件为了不陷入 CRUD 的无聊循环我设计一个文本处理 MVP。宿主程序从配置文件里读取一个输入目录扫描目录里的.txt文件然后逐个把文本内容扔给加载项处理处理完的结果写到输出目录。插件功能不复杂先做两个一个转大写一个统计单词数。这样你既能体验插件的发现与激活又能直观看到一个业务闭环。MVP 阶段的边界我刻意控制住不做界面、不做用户权限、不做任务队列、不做分布式。宿主是一个控制台程序全部结果通过 stdout 输出和输出文件体现。这个范围做出来的东西第一天就能跑但又能把 MAF 和 File-Based App 的关键点全部覆盖到。3.2 文件方案JSON 配置与数据目录我不打算引入数据库。配置用一个appsettings.json插件注册信息可以直接写到宿主目录下的addins.json。为了减少依赖我用System.Text.Json来读写宿主启动时检查文件是否存在不存在就用默认值写一份。输入输出目录用相对路径并且启动时自动创建。这个设计带来的好处很直接你测试的时候只要删掉data目录整个应用就回到初始状态你改路径不需要改代码你把整个项目压缩成 zip 发给同事他能直接跑起来。对 MVP 来说这种“所见即所得”的体验比任何文档都有效。3.3 契约接口从一个“干净”的字符串开始MAF 的跨边界传参有约束复杂对象需要实现IContract不能像普通方法调用那样把任意类型丢过去。MVP 阶段最省事的做法是让契约接口只传字符串。你想处理文本本来也只需要字符串输入输出这样最底层的数据契约就稳住了。契约接口我定义成using System.AddIn.Contract; using System.AddIn.Pipeline; namespace TextProc.Contract { [AddInContract] public interface ITextProcessorContract : IContract { string Process(string input); } }将来如果业务要加参数建议不要直接改这个方法而是定义一个ProcessorOptionsContract之类的接口对象或者用一个 JSON 字符串把所有参数序列化进去。这样能最大限度保持契约稳定否则你升级一次接口所有插件都要跟着重编译。4. 代码实操从零搭一个 7 层 MAF 项目4.1 解决方案结构怎么排我用 .NET Framework 4.8 做演示因为 MAF 在这个环境里最成熟、坑最少。你如果已经迁移到 .NET 6要先用 NuGet 验证System.AddIn兼容包的可用性个别 API 和框架版本有关别想当然。解决方案里我建了七个项目TextProc.Host控制台程序负责扫描和激活。TextProc.Contract类库放契约接口。TextProc.HostView类库宿主视图。TextProc.HostSideAdapter类库宿主侧适配器。TextProc.AddInView类库插件侧视图。TextProc.AddInSideAdapter类库插件侧适配器。TextProc.AddIns.UpperCase插件转大写。TextProc.AddIns.WordCount插件统计单词数。严格说这是 7 加 2 个项目。你可能会说项目数有点多对这就是 MAF 的代价但每一个都有清晰职责别省略。后面我进到代码里你就知道多出来的程序集不是装饰。4.2 契约、视图和适配器代码契约已经给出来了接下来是 Host View。Host View 可以是抽象类我建议用抽象类而不是接口因为 MAF 内部激活机制对抽象类的支持更顺滑。namespace TextProc.HostView { public abstract class TextProcessorHostView { public abstract string Process(string input); } }Add-in View 和 Host View 长得几乎一样using System.AddIn.Pipeline; namespace TextProc.AddInView { [AddInBase] public abstract class TextProcessorAddInView { public abstract string Process(string input); } }Host Side Adapter 的任务是把ITextProcessorContract包装成TextProcessorHostView。注意这里要持有ContractHandle否则跨边界的对象生命周期管不住可能会被 GC 提前回收using System; using System.AddIn.Contract; using System.AddIn.Pipeline; using System.AddIn.Pipeline.Contract; // 按环境确定引用 using TextProc.Contract; using TextProc.HostView; namespace TextProc.HostSideAdapter { [HostAdapter] public class TextProcessorHostSideAdapter : TextProcessorHostView { private readonly ITextProcessorContract _contract; private readonly ContractHandle _keepAlive; public TextProcessorHostSideAdapter(ITextProcessorContract contract) { _contract contract; _keepAlive new ContractHandle(contract); } public override string Process(string input) { return _contract.Process(input); } } }Add-in Side Adapter 刚好反过来它把TextProcessorAddInView转成契约对象using System.AddIn.Pipeline; using TextProc.AddInView; using TextProc.Contract; namespace TextProc.AddInSideAdapter { [AddInAdapter] public class TextProcessorAddInSideAdapter : ContractBase, ITextProcessorContract { private readonly TextProcessorAddInView _view; public TextProcessorAddInSideAdapter(TextProcessorAddInView view) { _view view; } public string Process(string input) { return _view.Process(input); } } }到这里你可能会发现这两段适配器代码几乎都是“转发”。但它不是多余的因为在更复杂的场景里你可以在转发过程中做参数清洗、权限校验、类型转换、日志埋点。MVP 阶段看不到价值等扩展出真实业务你就懂这条“缝”有多重要。4.3 插件本体两个加载项插件类要继承 Add-in View并标记[AddIn]里面的逻辑完全不需要关心 MAF 的存在using System; using System.AddIn.Pipeline; using TextProc.AddInView; namespace TextProc.AddIns.UpperCase { [AddIn(UpperCaseProcessor, Version 1.0.0.0)] public class UpperCaseProcessor : TextProcessorAddInView { public override string Process(string input) { return string.IsNullOrWhiteSpace(input) ? input : input.ToUpperInvariant(); } } }另一个插件统计单词数using System; using System.Linq; using System.Text.RegularExpressions; using System.AddIn.Pipeline; using TextProc.AddInView; namespace TextProc.AddIns.WordCount { [AddIn(WordCountProcessor, Version 1.0.0.0)] public class WordCountProcessor : TextProcessorAddInView { public override string Process(string input) { if (string.IsNullOrWhiteSpace(input)) return 0; var matches Regex.Matches(input, [A-Za-z0-9]); return matches.Count.ToString(); } } }这里我刻意让插件输出很简单的字符串MVP 阶段先验证链路等链路通了再把输出改成 Json 其实很容易。别一上来就搞复杂 DTO那不是 MVP 的心态。4.4 宿主端扫描、激活、调用宿主端是真正接触 MAF API 的地方。第一步是构建管道根目录并调用AddInStore.Update。我建议在开发环境用Rebuild因为程序集经常变在正式环境用Update否则每次启动全量重建会拖慢速度还可能造成版本缓存问题。string pipelineRoot Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Pipeline); AddInStore.Rebuild(pipelineRoot); // 开发时用 Rebuild正式用 Update找到可用的加载项var tokens AddInStore.FindAddIns(typeof(TextProcessorHostView), pipelineRoot);每个AddInToken代表一个可用的加载项它有Name、Version、Publisher等元数据。你可以在宿主里把 Token 列表打出来让用户用名称选择插件。激活并调用var addIn token.ActivateTextProcessorHostView(AddInSecurityLevel.Host); string result addIn.Process(text);AddInSecurityLevel.Host表示按宿主的信任级别执行开发时最不容易踩权限坑。如果插件需要更受限的环境可以换成Internet级别但那种情况要提前测好文件 IO 权限否则插件访问不到输入输出目录你又要排查半天。4.5 管道目录的布置脚本我踩过的坑八成都在这一层。Visual Studio 默认不会把各项目的 DLL 复制到管道根目录下对应子目录。我通常写一个 PowerShell 脚本构建后执行$base $PSScriptRoot\bin\Debug $pipeline $base\Pipeline New-Item -ItemType Directory -Force -Path $pipeline\Contracts | Out-Null New-Item -ItemType Directory -Force -Path $pipeline\AddInSideAdapters | Out-Null New-Item -ItemType Directory -Force -Path $pipeline\HostSideAdapters | Out-Null New-Item -ItemType Directory -Force -Path $pipeline\AddIns\UpperCase | Out-Null New-Item -ItemType Directory -Force -Path $pipeline\AddIns\WordCount | Out-Null Copy-Item $base\TextProc.Contract.dll $pipeline\Contracts\ -Force Copy-Item $base\TextProc.AddInSideAdapter.dll $pipeline\AddInSideAdapters\ -Force Copy-Item $base\TextProc.HostSideAdapter.dll $pipeline\HostSideAdapters\ -Force Copy-Item $base\TextProc.HostView.dll $pipeline\HostSideAdapters\ -Force Copy-Item $base\TextProc.AddIns.UpperCase.dll $pipeline\AddIns\UpperCase\ -Force Copy-Item $base\TextProc.AddIns.UpperCase.dll.addin $pipeline\AddIns\UpperCase\ -Force Copy-Item $base\TextProc.AddIns.WordCount.dll $pipeline\AddIns\WordCount\ -Force Copy-Item $base\TextProc.AddIns.WordCount.dll.addin $pipeline\AddIns\WordCount\ -Force.addin文件是加载项清单必须和插件 DLL 放一起。内容类似这样?xml version1.0? AddIn AssemblyTextProc.AddIns.UpperCase.dll/Assembly FullNameUpperCaseProcessor/FullName AddInId0d1c0f0e-1234-4a5b-9abc-123456789012/AddInId Version1.0.0.0/Version PublisherYourCompany/Publisher DescriptionConvert text to uppercase/Description /AddIn有时你会发现没有.addin文件也能被扫到那是因为AddInStore.Rebuild会根据AddInAttribute自动生成一部分元数据但为了让生产和调试稳定我还是建议显式放清单尤其是要指定版本、发布者、描述信息时。5. File-Based 的工程细节配置、路径、原子写5.1 用 System.Text.Json 做宿主配置宿主启动时我读appsettings.json文件内容类似{ InputDirectory: data/input, OutputDirectory: data/output, DefaultAddIn: UpperCaseProcessor }读取逻辑很简单但要注意目录处理。我习惯统一转成绝对路径using System; using System.IO; using System.Text.Json; var options new JsonDocumentOptions { CommentHandling JsonCommentHandling.Skip, AllowTrailingCommas true }; using var stream File.OpenRead(appsettings.json); using var doc JsonDocument.Parse(stream, options); var root doc.RootElement; string inputDir root.GetProperty(InputDirectory).GetString(); inputDir Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, inputDir)); Directory.CreateDirectory(inputDir);如果你是 .NET 6我更推荐用官方的Microsoft.Extensions.Configuration它天然支持 JSON、环境变量、命令行参数互相覆盖MVP 阶段就能应付很多部署差异。我这边示例用 JsonDocument是为了减少依赖讲清楚“文件就是配置数据库”这个点。5.2 文件读写别让 JSON 写入成为噩梦File-Based App 最常见的翻车点是写入文件时进程崩溃导致文件坏掉。MVP 阶段很多人不在乎但等你在客户机器上跑起来写一半断电这种事迟早会遇到。我的建议很简单写临时文件再原子替换。string tmpPath path .tmp; string json JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented true }); File.WriteAllText(tmpPath, json); File.Copy(tmpPath, path, overwrite: true); File.Delete(tmpPath);严格说File.Copy并不完全是原子操作但比直接File.WriteAllText覆盖已经安全很多。想更稳妥在 Windows 上可以用File.Replace它会在目标文件存在时做替换并且保留备份。不过要注意目标文件不存在时File.Replace会抛异常必须先判断。还有一点System.Text.Json默认不允许写出尾随逗号、注释而很多人的手写配置里会有注释。读的时候要宽容处理写的时候要规范化这样配置文件才能在“人看”和“程序读”之间自由切换。5.3 插件之间的文件共享策略插件多了之后会面临输入和输出目录互相打架的问题。我在设计里让宿主负责读取输入目录然后把文本内容传入插件插件只返回处理结果不直接碰文件。这个策略的好处是插件之间不会因为文件锁冲突宿主也方便统一记录和处理异常。真正需要插件直接读写文件时我会约定插件只能碰自己专属的data/plugin/PluginName目录并且需要宿主启动时创建好。对 MVP 来说这个约定带来的限制很小但为你以后做多租户、多插件并发处理铺平了路。你也不用去发明一套复杂的文件锁机制因为边界已经被你压缩到了一个字符串函数的调用范围内。6. 常见问题与排查记录6.1 扫描不到加载项先查目录再查清单这是我遇到得最多的问题没有之一。现象是FindAddIns返回的 Token 列表为空。排查顺序确认管道根目录下面有没有AddIns子目录大小写不必纠结Windows 不区分但目录名不能写错。确认插件 DLL 是否已经复制到AddIns/插件名目录别只看“生成成功”。确认有没有.addin清单文件如果没有至少加载项类上要标注[AddIn]。打开宿主目录下的Pipeline隐藏缓存文件如果没生成就要先调AddInStore.Rebuild。经验是先跑一次Rebuild然后把.addin文件内容和目录结构截图对比90% 的问题一分钟能定位。6.2 激活时报类型加载失败多半是程序集版本冲突插件引用文本处理类库时如果契约或 Add-in View 程序集没有复制到对应的管道目录激活时会出现FileNotFoundException或TypeLoadException。另一个常见坑是“强名称”。如果任何一层程序集加了强名称其他所有相关层也必须强名称否则激活阶段会直接被拒绝。我在 MVP 里故意不加强名称减少配置成本。如果你要走正式发布请直接把所有管道程序集全做强名称一个都别漏。6.3 进程内还是进程外看你的信任半径MAF 允许你指定AddInEnvironment或AddInProcess让插件跑在独立进程里。好处是插件崩溃不影响宿主坏处是调试体验差、部署多一个进程文件、跨进程调用有一定性能损耗。MVP 阶段我建议先走进程内AddInSecurityLevel.Host先验证业务逻辑。等你确定插件来源不可控再切到进程外代码改动只在宿主激活那一行其余层几乎不用动这就是管道设计带来的红利。6.4 如果想接 Semantic Kernel我的建议如果你确实想在 MAF 项目里接 Semantic Kernel我建议把Microsoft.SemanticKernel的依赖全部关在 Add-in 项目内部。契约接口不要出现Kernel、FunctionResult这些类型全部用字符串或自定义IContract对象传递。插件内部可以有一套独立配置比如sk.json里面放模型名称、部署信息、Prompt 模板。实际流程可以是宿主读文件 - 调用Process(string)- 插件内部拆分文本、构建 Kernel、调用模型、拿回结果 - 结果作为字符串返回。这样宿主完全感知不到 AI 服务的存在后续你要把某个插件从“规则实现”换成“AI 实现”对宿主来说只是换了 Token 而已。反过来如果你想用 Semantic Kernel 做插件调度器那不属于 MAF 的职责范围建议你把 SK 放在宿主层做编排而 MAF 只负责执行具体的原子能力。7. 我的实际体会这套组合我前后搭过两遍第一遍是被七个项目吓住差点中途放弃第二遍把目录脚本和契约设计理顺之后发现真正写业务代码的时间非常少。我个人觉得MAF 最值得学的地方不是那几段 API 调用而是它逼你思考“边界在哪里”。写 MVP 的时候我们很容易图快把插件和宿主揉成一团结果后面拆分时欲哭无泪。用 MAF 起项目相当于一开始就给你划了一条不可逾越的线这条线短期看着碍事长期就是保护。最后分享一个小技巧开发调试时把宿主项目的“启动操作”设置为TextProc.Host.exe并且把管道目录脚本放到构建后事件里这样 F5 一按整个运行时布局就自动完成不用每次手动复制。等目录脚本稳定之后你再把AddInStore.Rebuild换成AddInStore.Update日常开发体验会顺滑很多。如果你也想基于文件做一个插件化 MVPMAF 这条路值得走一次。先把上面的最小文本处理链路跑通再去加业务复杂度你会发现七层管道其实也没那么吓人。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门