前言
盯盘软件的核心循环是什么?每隔几秒,自动刷新行情数据。
用 Timer 行不行?行。但 Timer 有几个问题:
定时不精准,两个 Timer 同时触发可能竞争同一个资源
不支持 cron 表达式("每周一到周五9:30到15:00每10秒刷新"这种复杂调度做不了)
异常直接炸进程,没有重试机制
Quartz.NET 是一个成熟的企业级定时任务调度库,支持持久化任务、Job 链、集群模式。本项目用最简单的模式:内存调度,Job 注入 Autofac Service,每2秒刷新一次。
这章把 Quartz 集成、三层异常处理、日志架构全部讲透。
一、Quartz.NET 基础概念
| Scheduler | |
| IJob | Execute(IJobExecutionContext) 是入口 |
| IJobDetail | |
| ITrigger | |
| JobDataMap |
Scheduler.Start()│├──→ Trigger 触发时机到了│ ││ └──→ Scheduler 查找对应的 JobDetail│ ││ └──→ JobExecutionContext 包装执行环境│ ││ └──→ IJob.Execute(context)│ ││ └──→ 执行业务逻辑│└──→ 定时器持续运行,直到 Scheduler.Shutdown()
二、QuartzHelper:调度器封装
// Common/QuartzHelper.cspublic static class QuartzHelper{private static IScheduler _scheduler;private static readonly object LockObj = new object();publicstatic Task InitQuartzAsync(){lock (LockObj){if (_scheduler != null) return Task.CompletedTask;return InitSchedulerInternalAsync();}}// 兼容旧同步版本(内部调用异步实现)[Obsolete("Use InitQuartzAsync instead.")]publicstaticasyncvoidInitQuartz(){try{await InitQuartzAsync().ConfigureAwait(false);}catch (SchedulerException ex){Logger.LogError(ex, "Quartz initialization failed");}catch (Exception ex){Logger.LogCritical(ex, "Quartz unexpected error");}}privatestaticasync Task InitSchedulerInternalAsync(){StdSchedulerFactory factory = new StdSchedulerFactory();_scheduler = await factory.GetScheduler().ConfigureAwait(false);// 注册两个 Job:自选股刷新 + 大盘指数刷新IJobDetail stockJob = JobBuilder.Create<TaskGetStockDataJob>().WithIdentity("StockJob", "StockGroup").Build();IJobDetail stockIndexJob = JobBuilder.Create<TaskGetStockIndexJob>().WithIdentity("StockIndexJob", "StockGroup2").Build();// 每2秒触发一次,立即开始,永不停止(RepeatForever)ITrigger stockTrigger = TriggerBuilder.Create().WithIdentity("StockTrigger", "StockGroup").StartNow().WithSimpleSchedule(x => x.WithIntervalInSeconds(2).RepeatForever()).Build();ITrigger stockIndexTrigger = TriggerBuilder.Create().WithIdentity("StockTrigger2", "StockGroup2").StartNow().WithSimpleSchedule(x => x.WithIntervalInSeconds(2).RepeatForever()).Build();// 先注册指数(确保指数先刷新,再刷新个股)await _scheduler.ScheduleJob(stockIndexJob, stockIndexTrigger).ConfigureAwait(false);await _scheduler.ScheduleJob(stockJob, stockTrigger).ConfigureAwait(false);await _scheduler.Start().ConfigureAwait(false);Logger.LogInformation("Quartz scheduler started (interval: 2s)");}publicstaticasync Task ShutdownQuartzAsync(){lock (LockObj){if (_scheduler == null) return;}try{Logger.LogInformation("Shutting down Quartz...");await _scheduler.Shutdown(waitForJobsToComplete: true).ConfigureAwait(false);Logger.LogInformation("Quartz shut down cleanly");}catch (Exception ex){Logger.LogError(ex, "Error shutting down Quartz");}}}
设计要点:
双重锁单例:
_scheduler只初始化一次,lock防止并发初始化,Task.CompletedTask短路已有实例waitForJobsToComplete: true:关闭时等当前正在运行的 Job 执行完毕再退出,不会截断正在更新的数据两个分组(StockGroup / StockGroup2):让指数和个股分开管理,后续可以单独暂停/恢复某一组
三、两个 Job 实现
3.1 TaskGetStockDataJob:自选股刷新
// SchedulerJob/TaskGetStockDataJob.cs[DisallowConcurrentExecution] // 禁止并发:上一轮没跑完,不启动下一轮public class TaskGetStockDataJob : IJob{private static readonly ILogger Logger = LoggerHelper.CreateLogger<TaskGetStockDataJob>();private ViewModelLocator _locator;public Task Execute(IJobExecutionContext context){try{// 懒加载 ViewModelLocator(App 启动时已注册到 Resources)if (_locator == null)_locator = Application.Current?.Resources["Locator"] as ViewModelLocator;if (_locator?.Main == null){Logger.LogWarning("Locator not ready, skipping");return Task.CompletedTask;}var list = _locator.Main.StockList;if (list == null || list.Count == 0) return Task.CompletedTask;var stockService = StockServiceFactory.CreateFactory("qq");bool hiddenName = _locator.Main.HiddenName;// ⚠️ 关键:Dispatcher.BeginInvoke,把后台线程的操作投递到 UI 线程return Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Background,new Action(() => stockService.GetStocksDetail(list, hiddenName))).Task;}catch (Exception ex){Logger.LogError(ex, "TaskGetStockDataJob unexpected error");return Task.CompletedTask; // 吞掉异常,不让 Job 彻底崩溃}}}
3.2 TaskGetStockIndexJob:大盘指数刷新
// SchedulerJob/TaskGetStockIndexJob.cs[DisallowConcurrentExecution]public class TaskGetStockIndexJob : IJob{private static readonly ILogger Logger = LoggerHelper.CreateLogger<TaskGetStockIndexJob>();private ViewModelLocator _locator;public Task Execute(IJobExecutionContext context){try{if (_locator == null)_locator = Application.Current?.Resources["Locator"] as ViewModelLocator;if (_locator?.Main == null){Logger.LogWarning("Index Locator not ready, skipping");return Task.CompletedTask;}var list = _locator.Main.StockIndexList;if (list == null || list.Count == 0) return Task.CompletedTask;var stockService = StockServiceFactory.CreateFactory("qq");bool hiddenName = _locator.Main.HiddenName;return Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Background,new Action(() =>{stockService.GetStockIndexDetail(list, hiddenName);UpdateStockIndexString(_locator.Main, list);})).Task;}catch (Exception ex){Logger.LogError(ex, "TaskGetStockIndexJob unexpected error");return Task.CompletedTask;}}// 将指数列表拼成一行字符串,显示在底部状态栏privatestaticvoidUpdateStockIndexString(MainViewModel vm,ObservableCollection<StockViewMode> list){var sb = new StringBuilder();foreach (var item in list){sb.Append(item.Name); sb.Append(": ");sb.Append(item.CurrentPrice); sb.Append(" ");sb.Append(item.ChangePrice.ToString("f2")); sb.Append(" ");sb.Append(item.Percent.ToString("P2")); sb.Append(" ");}vm.StockIndexString = sb.ToString();}}
四、三层异常处理架构
这是整个应用稳定性的核心。
┌─────────────────────────────────────────────────────┐│ App.xaml.cs OnStartup ││ ┌──────────────────────────────────────────────┐ ││ │ AppDomain.UnhandledException │ ││ │ (任何线程未捕获的异常,包括 Quartz Job) │ ││ │ → Logger.LogCritical → 写日志 → 决定是否退出│ ││ └──────────────────────────────────────────────┘ ││ ┌──────────────────────────────────────────────┐ ││ │ DispatcherUnhandledException │ ││ │ (UI 线程绑定错误、空指针等) │ ││ │ → Logger.LogCritical → args.Handled = true │ ││ └──────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────┐│ QuartzHelper ││ ┌──────────────────────────────────────────────┐ ││ │ InitQuartz try/catch │ ││ │ → Logger.LogError / LogCritical │ ││ └──────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────┐│ TaskGetStockDataJob.Execute ││ ┌──────────────────────────────────────────────┐ ││ │ try/catch (内层) │ ││ │ → Logger.LogError │ ││ │ → return Task.CompletedTask; ← 吞异常 │ ││ └──────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────┘
4.1 异常处理详细代码
// App.xaml.csprotectedoverridevoidOnStartup(StartupEventArgs e){AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;DispatcherUnhandledException += (_, args) =>{Logger.LogCritical(args.Exception, "Dispatcher unhandled exception");args.Handled = true; // ← 关键:标记已处理,窗口不崩溃};base.OnStartup(e);System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); // GB2312 编码支持QuartzHelper.InitQuartz(); // 旧同步 API,内部已是 async/await}protectedoverridevoidOnExit(ExitEventArgs e){try{Logger.LogInformation("Shutting down...");_ = QuartzHelper.ShutdownQuartzAsync(); // fire and forget,退出不阻塞LoggerHelper.Dispose();}catch (Exception ex){System.Diagnostics.Debug.WriteLine($"OnExit error: {ex.Message}");}base.OnExit(e);}privatestaticvoidOnUnhandledException(object sender, UnhandledExceptionEventArgs args){if (args.ExceptionObject is Exception ex)Logger.LogCritical(ex,"AppDomain unhandled (terminating: {0})", args.IsTerminating);}
五、LoggerHelper:Microsoft.Extensions.Logging 封装
// Common/LoggerHelper.cspublic static class LoggerHelper{private static ILoggerFactory _factory;publicstaticvoidEnsureInitialized(){if (_factory != null) return;_factory = LoggerFactory.Create(builder =>{builder.SetMinimumLevel(LogLevel.Information);builder.AddDebug(); // Debug 输出窗口builder.AddConsole(); // 控制台});}publicstatic ILogger CreateLogger<T>()=> _factory.CreateLogger<T>();publicstatic ILogger CreateLogger(Type type)=> _factory.CreateLogger(type);}
为什么用 Microsoft.Extensions.Logging?
这是 .NET 生态里的标准日志抽象层,主流日志库(NLog、Serilog、log4net)都有 ILogger 的适配器。切换日志实现只需要改 EnsureInitialized 里的配置,不需要动业务代码:
// 切换到 Serilog(示例)_factory = LoggerFactory.Create(builder =>{builder.AddSerilog(new SerilogLoggerSink配置...);});
六、Quartz 集成 Autofac
Autofac.Extras.Quartz 这个 NuGet 包让 Quartz Job 能自动注入 Service:
// Common/AutoFacHelper.cspublicstatic ContainerBuilder InitAutofac(){var builder = new ContainerBuilder();InitDependency(builder);// 关键:注册 Quartz.Autofac 集成模块// Job 从容器解析 Service,Service 又是单例 → 所有 Job 共享同一个 Service 实例builder.RegisterModule(new QuartzAutofacFactoryModule());return builder;}
StockServiceFactory.CreateFactory("qq") 没走注入,但理论上可以改成注入 IStockService):public classTaskGetStockDataJob : IJob{private readonly IStockService _stockService; // Autofac 注入public TaskGetStockDataJob(IStockService stockService){_stockService = stockService;}}
七、实际运行效果
[INFO] 14:29:30.001 Quartz scheduler started successfully (interval: 2s)[INFO] 14:29:30.101 TaskGetStockIndexJob: refreshed 4 indices[INFO] 14:29:30.102 TaskGetStockDataJob: refreshed 5 stocks[INFO] 14:29:32.002 TaskGetStockIndexJob: refreshed 4 indices[INFO] 14:29:32.102 TaskGetStockDataJob: refreshed 5 stocks[INFO] 14:29:34.003 TaskGetStockIndexJob: refreshed 4 indices[INFO] 14:29:34.104 TaskGetStockDataJob: refreshed 5 stocks...[WARN] 14:30:05.001 HTTP GET failed for http://qt.gtimg.cn/ (timeout)[ERROR] 14:30:05.002 TaskGetStockDataJob unexpected error: HttpRequestException
HttpHelper 兜底,返回空字符串,服务层 GetStocksDetail 对空结果直接跳过,不影响整体刷新。八、系列总结
五篇文章覆盖了一个完整 WPF 应用的开发全过程:
OfficeDingPan/├── App.xaml / App.xaml.cs ← DI 启动 + 全局异常 + Quartz 初始化├── MainWindow.xaml / .cs ← 主窗体 + Messenger 订阅├── Controls/ ← DrawingVisual 自定义控件│ ├── StockKline.cs ← 分时图│ └── Fundline.cs ← 资金流├── DataMode/ ← 数据传输对象├── Model/StockViewMode.cs ← 视图模型(ViewModelBase)├── Service/ ← 服务层(核心业务逻辑)│ ├── IStockService.cs│ ├── StockServiceBase.cs│ ├── QqStockService.cs ← 腾讯行情(主力)│ ├── SinaStockService.cs ← 新浪财经(备用)│ └── StockServiceFactory.cs├── Common/ ← 工具层│ ├── AutoFacHelper.cs ← DI 容器 + Quartz 集成│ ├── HttpHelper.cs ← HttpClient 缓存│ ├── JsonHelper.cs ← 序列化封装│ ├── FileHelper.cs ← JSON 文件持久化│ ├── ExcelHepler.cs ← NPOI Excel 读取│ ├── LoggerHelper.cs ← ILogger 封装│ └── QuartzHelper.cs ← 调度器封装├── SchedulerJob/ ← Quartz Job│ ├── TaskGetStockDataJob.cs ← 自选股定时刷新│ └── TaskGetStockIndexJob.cs ← 大盘指数定时刷新├── View/ ← 窗口│ ├── KLineWindow.xaml / .cs ← K 线窗体(异步轮询)│ ├── HistoryWindow.xaml / .cs ← 历史记录│ ├── FundLineWindow.xaml / .cs ← 资金分析│ ├── HiddenWindow.xaml / .cs ← 隐藏盯盘│ └── AboutWindow.xaml / .cs ← 关于├── ViewModel/ ← ViewModel 层│ ├── MainViewModel.cs ← 主窗体逻辑(CRUD + 命令)│ ├── HistoryViewModel.cs ← 历史记录逻辑│ └── ViewModelLocator.cs ← DI 容器解析├── Skins/ ← 样式资源│ ├── MainSkin.xaml│ └── ButtonStyle.xaml├── Interface/IDependency.cs ← DI 标记接口├── Converters/ForeColorConverter.cs ← 涨跌幅颜色转换└── Enums/EnumStockType.cs ← 股票类型枚举
框架 .NET 8.0-windows + WPFMVVM MVVM Light(ViewModelBase / RelayCommand / Messenger)DI Autofac + Autofac.Extras.QuartzHTTP HttpClient(静态字典缓存)+ Referer 伪装K线绘制 DrawingVisual + DrawingContext + MatrixTransform定时任务 Quartz.NET(2s 间隔,DisallowConcurrent)Excel NPOIJSON Newtonsoft.Json日志 Microsoft.Extensions.Logging(Console + Debug)持久化 JSON 文件(系统临时目录)
夜雨聆风