简介:这是基于 .NET Framework 4.0 开发的 Windows 服务检测工具,面向需要保障业务系统持续运行的运维或开发人员,解决服务意外停止后无法及时恢复的问题。程序采用 Winform 界面,可添加多个服务名称并设定检测周期,默认 3 秒轮询一次;一旦发现服务停止,会自动重启直至状态变为“运行中”,且等待过程不会阻塞界面;该方式对服务状态变化响应及时,适合无人值守场景。同时支持设定提示信息显示行数、手动停止检测、导出提示信息,并按日期自动生成日志;关闭按钮会最小化到托盘,防止误关闭。压缩包共 5 个文件、约 35KB,包含可直接运行的 exe、参数配置 config、服务清单 xml、调试符号 pdb 及说明文本,结构简洁。已有 452 人学习下载。拿到后可按需修改配置与待检测服务列表,快速部署到目标机器,适合中小型业务系统做轻量级服务守护。
1. 当业务服务挂掉时,为什么你需要一个自动拉起程序
Windows 服务一旦异常退出,业务通常立即中断,而人工介入往往需要几分钟甚至更长。这个服务检测工具就是专门解决这个空窗期的:按设定的周期扫描指定服务,发现状态不是 Running 就尝试启动,直到恢复运行。它运行在 Winform 界面上,可以同时监控多个服务,并且把停止和启动记录落到日期日志里。适合运维值班、现场部署环境,也适合对稳定性要求较高的内部系统。下面我会拆解它的实现方式和关键参数,以及我在实际使用中遇到的坑。
2. Windows 服务状态监控的底层机制与选型逻辑
2.1 轮询与事件通知:为什么轮询更适合这个场景
Windows 服务状态变化可以是瞬时的,也可以持续很久。事件通知方式一般通过 WMI 的Win32_Service相关事件来订阅,比如使用ManagementEventWatcher监听服务状态变化。这种方案响应快,但需要额外处理事件丢失、重连逻辑,而且当服务被禁用或状态持续不变时,事件流可能静默。轮询就简单很多:按固定间隔读取状态,发现不符合预期就处理。这个工具的核心是“停止后自动重启”,对实时性要求不高,几秒的空白期可以接受。
在开发时,我一般会把轮询间隔和业务容忍度挂钩。如果业务要求 10 秒内拉起,轮询间隔就不要超过 5 秒。默认 3 秒是一个比较通用的值。间隔太短会增加 CPU 占用,也会给事件日志增加噪声;间隔太长则服务不可用时间较长。如果要让用户可配置,就在ServiceCheck.exe.config里加一个Interval键。实际项目中,这个工具的配置文件确实有SystemConfig节点,可以放检测周期和显示行数。
2.2 ServiceController 的使用边界与常见异常
ServiceController是 System.ServiceProcess 命名空间下的核心类。在 .NET Framework 4.0 中可以直接使用。它的构造函数接收服务名,或者显示名称。但是注意,如果传入显示名称,执行Start()时未必有效,因为Start()底层是通过服务名来操作。我建议统一用服务名。下面是最基本的检查逻辑:
var sc = new ServiceController("Spooler"); sc.Refresh(); var status = sc.Status; if (status == ServiceControllerStatus.Stopped) { sc.Start(); sc.Refresh(); Console.WriteLine(sc.Status); }说明:Refresh()是必须的,因为ServiceController实例在创建后并不自动保持最新状态,它会缓存第一次读取的结果。Start()是异步请求,立即返回不代表服务已经运行,需要等待状态变为Running。如果要读取远程机器,构造函数需要传机器名,但需要足够权限。
服务状态枚举在重启逻辑中的处理建议如下表:
| 枚举 | 含义 | 本工具有效动作 | | Running | 运行中 | 不处理 | | Stopped | 已停止 | 触发 Start() | | StartPending | 启动中 | 等待或忽略 | | StopPending | 正在停止 | 等待 | | Paused | 已暂停 | 根据业务决定是否 Continue() | | Unknown | 未知状态 | 记录日志并检查服务是否存在 |
补充:ServiceController在服务不存在时,访问Status会抛InvalidOperationException,异常信息 “Cannot open xxx service on computer”。所以代码里要捕获这个异常,而不是让它冒泡到 UI。
2.3 为什么 Winform 做外壳是务实选择
很多运维工具喜欢做成 Windows 服务,常驻后台,但这类检测工具做成 Winform 的好处是:用户可以直观看到哪些服务出过问题,以及重启历史。配合托盘图标,不会阻碍桌面操作。缺点是程序依赖用户会话,如果机器重启后用户没有登录,程序不会自动启动。所以我在部署时通常会用计划任务在登录时启动它,或者直接把快捷方式丢到启动目录。对于服务器环境,如果允许,也可以做成启动时自动运行,但需要隐藏主窗口。
3. 定时检测与自动重启的完整实现细节
3.1 基于 Winform Timer 的检测主循环
Winform 有一个System.Windows.Forms.Timer,它会在 UI 线程上触发Tick事件。使用它最大的好处是可以在事件中直接修改界面控件,不需要跨线程操作。在Form_Load中初始化:
Timer timer = new Timer(); timer.Interval = 3000; timer.Tick += Timer_Tick; timer.Start();这里Interval单位是毫秒。如果想让用户配置,可以从ServiceCheck.exe.config读取。注意 Timer 的数值不要设成低于 500,否则频繁查询可能影响系统性能。
Timer_Tick里要避免重入。如果上一次检测还没执行完,下一次 Tick 就触发了,可以用一个布尔锁跳过:
private bool _checking; private void Timer_Tick(object sender, EventArgs e) { if (_checking) return; _checking = true; try { CheckAllServices(); } finally { _checking = false; } }这样即使某个服务启动卡住,也不会引发多个线程同时操作。实际部署中,我见过因为服务启动过程慢,导致 Timer 重入,连续触发Start()的情况,加了锁之后立刻稳定。
3.2 多服务并行重启时的避让与去重
这个工具支持多个服务,所以CheckAllServices会遍历服务列表。对于每个服务,先查状态,如果停止就启动。但要注意,如果服务正在启动中,Status会是StartPending,此时再次Start()会抛异常。所以要判断状态是否Stopped,并且要记录每个服务的重启时间,避免在启动期间反复触发。
我常用一个字典存储服务上次启动时间:
Dictionary<string, DateTime> lastStartTime = new Dictionary<string, DateTime>(); private void CheckService(string name) { var sc = new ServiceController(name); sc.Refresh(); if (sc.Status == ServiceControllerStatus.Stopped) { if (lastStartTime.TryGetValue(name, out DateTime t)) { if ((DateTime.Now - t).TotalSeconds < 10) return; } sc.Start(); lastStartTime[name] = DateTime.Now; Log($"{name} 服务正在启动"); } }这个“冷却时间”非常关键,否则服务启动失败时,每 3 秒会被强拉一次,产生大量错误日志。在 Windows 事件日志里也会出现一堆Service Control Manager警告,影响正常排错。
3.3 等待服务进入 Running 状态的异步方案
需求中提到“直到服务状态为运行中为止”,并且不能阻塞界面。在 .NET Framework 4.0 中,可以用Task配合后台线程完成。这里展示一个兼容 .NET 4.0 的写法:
private void RestartAndWait(string name) { Task.Factory.StartNew(() => { var sc = new ServiceController(name); sc.Refresh(); if (sc.Status != ServiceControllerStatus.Running) { AppendLog($"[{name}] 停止,准备重启"); sc.Start(); for (int i = 0; i < 40; i++) { Thread.Sleep(500); sc.Refresh(); if (sc.Status == ServiceControllerStatus.Running) { AppendLog($"[{name}] 已恢复运行"); return; } } AppendLog($"[{name}] 等待超时,请手动检查"); } }); }这里用Thread.Sleep在后台线程中等待,不会阻塞 UI。AppendLog需要借助Control.BeginInvoke来更新界面控件,因为跨线程不能直接操作 TextBox。参数说明:循环次数 40 次,每次 sleep 500 毫秒,总共 20 秒。如果你的服务启动时间较长,比如数据库服务,可以把循环次数提到 120,也就是 1 分钟。但要注意,如果服务一直处于Stopped状态,20 秒后会记录超时,但不会再次自动启动,直到下一个检测周期。
4. 配置、日志与提示信息的工程化落地
4.1 ServiceNameList.xml 的设计与容错
服务列表放在 XML 里,是为了不重新编译就能改监控对象。这个工具根目录下的ServiceNameList.xml就是干这个的。文件结构可以这样:
<ServiceItems> <ServiceItem Name="W3SVC" /> <ServiceItem Name="SQLAgent$SQLEXPRESS" /> </ServiceItems>读取时用XDocument.Load,如果找不到文件,可以给个默认列表并提示。如果 XML 格式损坏,程序不能崩溃,要捕获XmlException。我一般还会在程序启动时备份原始 XML,防止用户改坏后无法恢复。
这里有一个坑:服务名不区分大小写,但建议保持一致。读取时用Trim()去掉空白,同时过滤空项。如果某个服务在系统中不存在,后续检测时会反复抛异常,所以最好在加载时做一次存在性校验,把无效服务名过滤掉。
4.2 按日归档日志的写入策略
日志文件名是EverydayLog 2023-03-13.txt这种格式,直接拼接日期。写入用File.AppendAllText很简单,但存在并发问题。多个后台任务同时写日志时,可能出现文件占用冲突。所以最好用锁或队列。这里给出一个简单的加锁写法:
private object logLock = new object(); private void WriteLog(string msg) { lock (logLock) { string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, $"EverydayLog {DateTime.Now:yyyy-MM-dd}.txt"); string line = $"{DateTime.Now:HH:mm:ss} {msg}"; File.AppendAllText(path, line + Environment.NewLine); } }lock保证同一时间只有一个线程写文件。AppendAllText内部会打开文件、写入、关闭,对低频日志没问题。但如果你在 3 秒周期内监控几十个服务,可能会有几十次写入,建议改成简单的内存队列,每隔几秒钟刷一次磁盘。日志文件里至少应该记录:服务名称、发现停止的时间、触发启动的时间、启动成功或失败的信息。
4.3 提示行数控制、导出与单实例保护
界面提示信息达到 100 行自动清空,这是为了美观。用AppendText追加后,判断行数,超出则截取。示例代码如下:
private void AppendMessage(string msg) { string line = $"{DateTime.Now:HH:mm:ss} {msg}"; txtLog.AppendText(line + Environment.NewLine); if (txtLog.Lines.Count > maxLines) { txtLog.Text = string.Join(Environment.NewLine, txtLog.Lines.Skip(txtLog.Lines.Count - maxLines)); txtLog.SelectionStart = txtLog.Text.Length; txtLog.ScrollToCaret(); } }maxLines默认 100,可以放到配置中。导出功能就是把txtLog.Text保存为文本文件,用SaveFileDialog选路径,File.WriteAllText即可。
单实例保护在工具类软件里也值得实现。使用Mutex可以防止多个实例同时操作同一个服务。因为多个进程同时Start()同一个服务,大概率会触发异常。代码在Program.cs的Main方法里写:
bool createdNew; using (Mutex mutex = new Mutex(true, "Global\\ServiceCheck_MUTEX", out createdNew)) { if (!createdNew) { MessageBox.Show("已经有一个服务检测工具在运行"); return; } Application.Run(new MainForm()); }注意Global\前缀可以跨会话,但在权限较低的账号下可能因创建命名对象失败而抛异常。如果只是单机单用户,去掉前缀更稳妥。
5. 托盘退出机制与运维中的避坑技巧
5.1 托盘图标与右键菜单的交互实现
关闭按钮不退出,而是最小化到托盘,这是 Winform 常用套路。核心是NotifyIcon和ContextMenuStrip。关闭事件中隐藏窗口,而不是Application.Exit()。右键菜单包含“退出”项。
private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { e.Cancel = true; this.Hide(); }然后托盘图标的右键菜单绑定退出项:
private void 退出ToolStripMenuItem_Click(object sender, EventArgs e) { trayIcon.Visible = false; Application.Exit(); }托盘图标还需要关联主窗口的恢复事件,双击托盘时调用this.Show(),并设置WindowState = Normal。注意托盘图标要设置Icon,否则空白托盘图标很难看。
5.2 生产环境部署时的三个注意点
第一个注意点是检测周期与服务启动时间的配合。如果服务需要 30 秒才能启动完成,但检测周期只有 3 秒,那么每次检测时服务都在启动中,不会触发重启。但也会反复查询状态,增加无谓开销。我一般把第一次启动后的 30 秒内设为冷却期,不参与轮询。
第二个注意点是服务账号和权限。如果要启动的服务不在当前用户权限范围内,Start()会抛出System.ComponentModel.Win32Exception。工具运行时最好以管理员身份运行,或者在服务控制属性中赋予启动权限。特别是生产环境,如果当前登录账号没有SeServiceLogonRight,即使本地管理员也可能启动失败。
第三个注意点是日志的保留策略。按天生成的文件会越积越多,最好加一个自动清理功能,只保留最近 30 天。简单做法是在程序启动时扫描EverydayLog *.txt,根据文件创建时间删除过期文件。这样可以避免磁盘被日志占满,也是在部署这个服务检测工具时最容易忽略的地方。
本文还有配套的精品资源,点击获取