第 3 章 · 03 Composer 反射装配与 MEF 自注册 本节摘要:前两节讲了 Handler 接口与双实现,本节收口——这些实现是怎么从 config.json 里的字符串变成内存对象的?答案是 。它基于 .NET 的 MEF(Managed Extensibility Framework),做两件事:① 启动时扫描所有 ,把带 的接口的实现类登记进 ;② 提供 方法,通过反射+类型名匹配把字符串实例化成接口对象。两套 handler 容器( 系统级 + 算法级)都靠 Composer 从 config 装配。这套机制是"依赖反转原则"在 Lean 的落地——引擎主体依赖接口,Composer 负责把字符串映射成实现,引擎对具体实现类一无所知。
本节摘要:前两节讲了 Handler 接口与双实现,本节收口——这些实现是怎么从 config.json 里的字符串变成内存对象的?答案是
Composer。它基于 .NET 的 MEF(Managed Extensibility Framework),做两件事:① 启动时扫描所有QuantConnect.*.dll,把带[InheritedExport]的接口的实现类登记进CompositionContainer;② 提供GetExportedValueByTypeName<T>(typeName)方法,通过反射+类型名匹配把字符串实例化成接口对象。两套 handler 容器(LeanEngineSystemHandlers系统级 +LeanEngineAlgorithmHandlers算法级)都靠 Composer 从 config 装配。这套机制是"依赖反转原则"在 Lean 的落地——引擎主体依赖接口,Composer 负责把字符串映射成实现,引擎对具体实现类一无所知。本节还总结了 Lean 用到的五种设计模式。
内容来源:原项目源码
Common/Util/Composer.cs、Engine/LeanEngineSystemHandlers.cs、Engine/LeanEngineAlgorithmHandlers.cs、Engine/Initializer.cs,精读并套用体系化模板。
⚠️ 注意:本节假设你已读完前两节。
[InheritedExport]的"接口级自注册"语义在第 01 节讲过,五大 Handler 接口的[InheritedExport(typeof(IXxx))]标记是本节的前提。本节重点在 Composer 的两阶段工作流:启动扫描 + 按名实例化,以及它如何支撑"改 config 字符串就换实现"的插件化。
阅读完本节,你应当能够:
[InheritedExport(typeof(IXxx))] 写在接口上的"继承式导出"语义。Composer 构造函数的 DLL 目录定位逻辑。GetExportedValueByTypeName<T>(typeName) 的三级匹配流程。LeanEngineSystemHandlers 与 LeanEngineAlgorithmHandlers 两套容器各装什么。Initializer.GetSystemHandlers/GetAlgorithmHandlers 的装配入口。[InheritedExport] 的语义第 01 节我们看到了所有 Handler 接口都带这个特性:
// Engine/DataFeeds/IDataFeed.cs:28 [InheritedExport(typeof(IDataFeed))] public interface IDataFeed
// Common/Interfaces/IHistoryProvider.cs:27 [InheritedExport(typeof(IHistoryProvider))] public interface IHistoryProvider : IDataProviderEvents
[InheritedExport(typeof(IDataFeed))] 是 MEF 的"继承式导出"特性,语义是:任何实现 IDataFeed 的非抽象类,都会被自动导出为 IDataFeed 契约的实现。注意它写在接口上,不是实现类上。
对比传统写法——每个实现类自己声明:
// 传统写法(Lean 没用) [Export(typeof(IDataFeed))] public class FileSystemDataFeed : IDataFeed { ... }
Lean 的 [InheritedExport] 写在接口上的好处:实现类不用加任何特性。新增一个 public class MyCustomDataFeed : IDataFeed,编译进 DLL 放到程序目录,Composer 启动扫描时自动发现它,不用改注册表。这就是"自注册"——实现类通过继承接口隐式注册,而非显式标注。
💡 钻取要点:
[InheritedExport]让"新增 handler"的成本降到最低——写个类继承接口就行。这也是 Lean 生态能容纳几十种券商(IB/FXCM/Oanda/Alpaca/Binance/...)、几十种数据源的关键——每个券商只要写一个类实现IBrokerage+IBrokerageFactory,扔进 DLL,用户改 config 就能用。
Composer 是单例(Common/Util/Composer.cs:38-410),通过 Composer.Instance 访问。构造函数(L60-97)做两件事:定位 DLL 目录 + 安全加载所有程序集。
62 var dllDirectoryString = Config.Get("composer-dll-directory"); 63 if (string.IsNullOrWhiteSpace(dllDirectoryString)) 64 { 65 // Check our appdomain directory for QC Dll's 66 if (!string.IsNullOrEmpty(AppDomain.CurrentDomain.BaseDirectory) 67 && Directory.EnumerateFiles(AppDomain.CurrentDomain.BaseDirectory, "QuantConnect.*.dll").Any()) 68 { 69 dllDirectoryString = AppDomain.CurrentDomain.BaseDirectory; 70 } 71 else 72 { 73 var currentDirectory = Directory.GetCurrentDirectory(); 74 var parentDirectory = Directory.GetParent(currentDirectory)?.FullName ?? currentDirectory; 76 dllDirectoryString = Directory.EnumerateFiles(parentDirectory, "QuantConnect.*.dll").Any() 77 ? parentDirectory : currentDirectory; 78 } 79 }
三级回退:① config 显式指定 composer-dll-directory → ② AppDomain.BaseDirectory(标准 .NET 程序目录,Launcher 启动时通常命中)→ ③ 父目录或当前工作目录(给 Jupyter 研究环境用的兜底)。判断依据都是"目录里有没有 QuantConnect.*.dll"。
355 Parallel.ForEach(files, file => 356 { 358 try 359 { 361 Assembly assembly; 362 try { assembly = Assembly.Load(AssemblyName.GetAssemblyName(file)); } 363 catch { assembly = Assembly.LoadFrom(file); } 364 373 if (Path.GetFileName(file).StartsWith($"{nameof(QuantConnect)}.")) 374 { 375 foreach (var type in assembly.ExportedTypes 376 .Where(t => !t.IsAbstract && !t.IsInterface && !t.IsEnum)) 377 { 378 exportedTypes.Add(type); 379 } 380 } 389 catch (Exception ex) { /* 跳过坏 DLL,不致命 */ } 396 _exportedTypes = new List<Type>(exportedTypes); 398 _compositionContainer = new CompositionContainer(aggregate);
四个要点:
Parallel.ForEach 并行处理所有 DLL,快。Assembly.Load(标准方式,走 probing path)失败则退到 Assembly.LoadFrom(直接路径)。后者处理"依赖不在 probing path 但 DLL 已加载"的情况。QuantConnect.*.dll 的导出类型(L373):第三方库(如 Newtonsoft.Json)的类型不进 exportedTypes,避免污染。💡 钻取要点:Composer 同时维护两份数据——
_exportedTypes(纯 Type 列表,供反射查询)和_compositionContainer(MEF 容器,供标准导出查询)。GetExportedValueByTypeName优先用_exportedTypes(L220),fallback 到_composableParts(L241)。两份数据服务于同一个目标——按类型名找实现。
这是 Composer 最关键的方法(Common/Util/Composer.cs:207-306),把 config 里的类名字符串变成接口实例。核心是三级匹配 + 反射实例化 + 缓存。
207 public T GetExportedValueByTypeName<T>(string typeName, bool forceTypeNameOnExisting = true) 208 where T : class 209 { 213 // 第一级:缓存命中 214 var instance = GetParts<T>().FirstOrDefault(x => !forceTypeNameOnExisting || x.GetType().MatchesTypeName(typeName)); 215 if (instance != null) { return instance; } 219 var type = typeof(T); 220 // 第二级:从 _exportedTypes 反射查 221 var typeT = _exportedTypes.Where(type1 => 223 type.IsAssignableFrom(type1) && type1.MatchesTypeName(typeName)) 230 .FirstOrDefault(); 233 if (typeT != null) 234 { 235 instance = (T)Activator.CreateInstance(typeT); // 反射 new 236 } 238 if (instance == null) 239 { 240 // 第三级:从 MEF _composableParts 查 241 var selectedPart = _composableParts.Where(...).FirstOrDefault(); 257 if (selectedPart == null) 258 { 259 throw new ArgumentException($"Unable to locate any exports matching the requested typeName: {typeName}..."); 260 } 263 instance = (T)selectedPart.CreatePart().GetExportedValue(exportDefinition); 266 } 269 // 把新实例缓存到各接口的 List 270 var exportedParts = instance.GetType().GetInterfaces() 271 .Where(i => i.GetCustomAttribute<InheritedExportAttribute>() != null); 272 lock (_exportedValuesLockObject) 273 { 274 foreach (var export in exportedParts) { ...缓存... } 291 } 292 }
三级匹配流程:
| 级别 | 数据源 | 方式 | 适用场景 |
|---|---|---|---|
| 第一级 | _exportedValues 缓存 |
已实例化的对象按类型名匹配 | 同一类型多次请求(如多次 GetExportedValueByTypeName<IResultHandler>) |
| 第二级 | _exportedTypes 反射 |
Activator.CreateInstance new 一个 |
标准情况,实现类在 QuantConnect.*.dll 里 |
| 第三级 | MEF _composableParts |
CreatePart().GetExportedValue |
兜底,处理 MEF 特殊导出 |
MatchesTypeName(typeName) 是扩展方法,依次尝试 AssemblyQualifiedName → FullName → Name 三种匹配。所以 config 里写全限定名(QuantConnect.Lean.Engine.DataFeeds.FileSystemDataFeed)或短名(FileSystemDataFeed)都能命中。
💡 钻取要点:
forceTypeNameOnExisting参数(L204)默认 true。当 false 时,如果接口已有一个实例,就直接返回它(不校验类型名)。这对全局单例有用——如IDataAggregator,不管 config 写什么类型名,只要已有一个实例就用它。Engine.cs:228的Composer.Instance.AddPart(historyProvider)正是配合这个——手动塞一个实例进缓存,后续请求优先用它。
Lean 把 Handler 分成两组:LeanEngineSystemHandlers(系统级)与 LeanEngineAlgorithmHandlers(算法级)。
Engine/LeanEngineSystemHandlers.cs:30-139,装四个系统级 handler:
| 字段 | 接口 | 职责 |
|---|---|---|
Api |
IApi |
与云端 API 通信(状态/限流) |
Notify |
IMessagingHandler |
消息推送(结果包/错误) |
JobQueue |
IJobQueueHandler |
任务队列(取 job/ack) |
LeanManager |
ILeanManager |
宿主环境增强 |
装配方法 FromConfiguration(L107-114):
107 public static LeanEngineSystemHandlers FromConfiguration(Composer composer) 108 { 109 return new LeanEngineSystemHandlers( 110 composer.GetExportedValueByTypeName<IJobQueueHandler>(Config.Get("job-queue-handler", "QuantConnect.Queues.JobQueue")), 111 composer.GetExportedValueByTypeName<IApi>(Config.Get("api-handler", "QuantConnect.Api.Api")), 112 composer.GetExportedValueByTypeName<IMessagingHandler>(Config.Get("messaging-handler", "QuantConnect.Messaging.Messaging")), 113 composer.GetExportedValueByTypeName<ILeanManager>(Config.Get("lean-manager-type", "LocalLeanManager"))); 114 }
四行 Config.Get(key, default) 读 config,缺省值是本地实现(JobQueue/Api/Messaging/LocalLeanManager)。云端运行时 config 会覆盖成云端实现。
Engine/LeanEngineAlgorithmHandlers.cs:37-298,装五个核心 Handler + 数据辅助 provider:
| 字段 | 接口 | 职责 |
|---|---|---|
Results |
IResultHandler |
结果输出 |
Setup |
ISetupHandler |
装配算法+券商 |
DataFeed |
IDataFeed |
数据流 |
Transactions |
ITransactionHandler |
订单处理 |
RealTime |
IRealTimeHandler |
定时事件 |
MapFileProvider |
IMapFileProvider |
标的符号映射(代码变更) |
FactorFileProvider |
IFactorFileProvider |
复权因子 |
DataProvider |
IDataProvider |
数据文件获取 |
ObjectStore |
IObjectStore |
持久化存储 |
DataPermissionsManager |
IDataPermissionManager |
数据权限 |
DataMonitor |
IDataMonitor |
数据缺失监控 |
装配方法 FromConfiguration(L235-276)核心段:
238 var setupHandlerTypeName = Config.Get("setup-handler", "ConsoleSetupHandler"); 239 var transactionHandlerTypeName = Config.Get("transaction-handler", "BacktestingTransactionHandler"); 240 var realTimeHandlerTypeName = Config.Get("real-time-handler", "BacktestingRealTimeHandler"); 241 var dataFeedHandlerTypeName = Config.Get("data-feed-handler", "FileSystemDataFeed"); 242 var resultHandlerTypeName = Config.Get("result-handler", "BacktestingResultHandler"); 247 var result = new LeanEngineAlgorithmHandlers( 248 composer.GetExportedValueByTypeName<IResultHandler>(resultHandlerTypeName), 249 composer.GetExportedValueByTypeName<ISetupHandler>(setupHandlerTypeName), 250 composer.GetExportedValueByTypeName<IDataFeed>(dataFeedHandlerTypeName), 251 composer.GetExportedValueByTypeName<ITransactionHandler>(transactionHandlerTypeName), 252 composer.GetExportedValueByTypeName<IRealTimeHandler>(realTimeHandlerTypeName), 253 mapFileProvider, factorFileProvider, dataProvider, 254 composer.GetExportedValueByTypeName<IObjectStore>(objectStoreTypeName), 255 composer.GetExportedValueByTypeName<IDataPermissionManager>(dataPermissionManager), 258 Globals.LiveMode, researchMode, 260 composer.GetExportedValueByTypeName<IDataMonitor>(dataMonitor));
注意 L238-242 的缺省值——全是 Backtesting*/FileSystem*。这是"缺省即回测"的设计:config 不写任何 environment,跑的就是回测。
💡 钻取要点:注意 L258 传了
Globals.LiveMode——它决定ZipDataCacheProvider是否临时缓存(L187isDataEphemeral: liveMode)、是否挂 DataMonitor 钩子(L190-194 只在非 live 非 research 时挂)。这个布尔从哪来?config.json 顶层或 environment 块里的live-mode字段,被Globals静态读取。所以模式开关的源头就是 config 的live-mode+environment两个字段。
Engine/Initializer.cs 是装配流程的入口,Program.cs 调用它:
63 public static LeanEngineSystemHandlers GetSystemHandlers() 64 { 65 var systemHandlers = LeanEngineSystemHandlers.FromConfiguration(Composer.Instance); 68 systemHandlers.Initialize(); // Notify/JobQueue 初始化 70 return systemHandlers; 71 } 76 public static LeanEngineAlgorithmHandlers GetAlgorithmHandlers(bool researchMode = false) 77 { 78 return LeanEngineAlgorithmHandlers.FromConfiguration(Composer.Instance, researchMode); 79 }
两个方法都是"从 Composer + config 装配 → 返回容器"。Program.cs 的典型流程:
Initializer.Start() // 日志/全局 var system = Initializer.GetSystemHandlers() // 系统级 handler var algorithm = Initializer.GetAlgorithmHandlers() // 算法级 handler var engine = new Engine(system, algorithm, liveMode) // new 引擎 engine.Run(job, manager, assemblyPath, workerThread) // 跑
整条链路里,Engine 拿到的是接口容器,不知道具体实现类。Engine.cs:311-319 的 JOB HANDLERS 日志会在运行时打印实际装配的类型,用户能从日志看到"我这次跑用的是 FileSystemDataFeed + BacktestingTransactionHandler"。
传统分层架构是"高层依赖低层"——引擎直接 new 数据源、new 券商。Lean 反过来:高层(引擎)定义接口,低层(实现)依赖接口。配合 Composer,依赖关系变成:
引擎主体 ──依赖──> IDataFeed 接口 <──实现── FileSystemDataFeed <──实现── LiveTradingDataFeed <──实现── (你的自定义数据源)
引擎主体不知道、也不需要知道运行时是哪个实现。Composer 是"依赖注入容器",负责把字符串映射成实例。这就是 DIP 在 Lean 的落地。
| 模式 | 落点 | 作用 |
|---|---|---|
| 依赖注入(字符串+反射) | Composer.GetExportedValueByTypeName |
config 字符串 → 接口实例,解耦配置与代码 |
| MEF 自注册([InheritedExport]) | 各 Handler 接口 | 新增实现零登记,自动发现 |
| 策略模式 | 五大 Handler 接口 | 每个接口可替换实现(回测/实盘/自定义) |
| 门面模式(Facade) | Engine 类 |
编排所有 Handler,对外暴露单一 Run 入口 |
| 模板方法 | AlgorithmManager.Run |
固定主循环步骤(收数据→调 OnData→处理订单),子步骤可覆写 |
💡 钻取要点(收束):这五种模式协同工作——MEF 自注册让实现类零成本接入,Composer 用反射把字符串变实例,策略模式让每接口可换,Engine 作门面编排,AlgorithmManager 作模板方法固定流程。合起来就是"回测实盘统一"的工程全貌:改 config 一个字符串,整套 handler 套件切换,引擎主体与用户代码零修改。这是 Lean 作为"C# 量化引擎典范"最值得学习的设计。
[InheritedExport(typeof(IXxx))]:写在接口上,实现类自动被 MEF 登记为该契约的导出,新增实现零成本。Parallel.ForEach 安全加载所有 QuantConnect.*.dll → 维护 _exportedTypes 与 _compositionContainer 两份数据。GetExportedValueByTypeName<T>(typeName):三级匹配(缓存 → 反射 _exportedTypes → MEF _composableParts),MatchesTypeName 支持 AssemblyQualifiedName/FullName/短名。Backtesting*/FileSystem*,不写 environment 就跑回测。GetSystemHandlers/GetAlgorithmHandlers 各调一次 FromConfiguration(Composer.Instance)。第 3 章结束。你已经理解了"回测实盘统一"的完整设计——七大接口、双实现、三个分叉点、Composer 装配。下一章(第 4 章)钻进用户 API 层
QCAlgorithm:它是被 SetupHandler 反射加载的用户代码基类,封装了所有 Handler 能力的友好 API。