简介:一份基于 .NET 5 与 .NET 6 的 Windows 服务开发完整 Demo,面向需要编写 Windows 服务或 Worker Service 的 .NET 开发人员,可解决跨平台长期运行服务的托管、配置与接口开放等实际问题。工程打包为 zip 格式,约 7.1MB,体积精简,便于下载后直接对照工程结构学习。代码覆盖服务基础配置、log4net 日志集成、配置文件读写、托管服务启动、HTTP 请求监听与对外接口,并集成 Ant Design Pro 管理端方案,有助于读者理解从后端服务到前端展示的完整链路。目前已有 410 人学习下载,该 Demo 提供清晰的模块划分和关键实现思路,适合希望参考现成代码、快速搭建 Windows 服务项目的开发者。
1. 用 .NET5 写 Windows 服务:这套 demo 把门槛压到了最低
做后端的人迟早要碰 Windows 服务,比如定时对账、MQ 队列消费、文件监听落库。我第一次用 .NET Framework 写服务时被坑得很惨:要装 Installer 类、要做安装工程、调试还要跑到服务控制管理器里面去附加进程。后来换 .NET5 写,发现事情简单得多——本质就是 Console 程序套一个BackgroundService,服务化只是宿主多注册一个东西而已。这套 dotnet5-winservice-demo 就是把「能跑的 Worker + 服务注册 + 安装卸载脚本 + 日志落盘」整个闭环给齐了,适合两类人:一是急着在 Windows Server 上落一个常驻后台任务的,二是想弄明白 .NET5/6 的 Worker Service 到底怎么变成系统里那个"服务"的。下面从项目结构开始,一步一步把它拆透。
2. Worker Service 模板与 UseWindowsService:先让程序"能活"
2.1 Worker Service 与 Windows 服务的真实关系
打开 Visual Studio 模板里的 Worker Service,你会发现它就是个带泛型Host的控制台程序。所谓 Windows 服务,本质上也是一个可执行文件,只不过它没有界面,由 SCM(服务控制管理器)去启动它、看护它。.NET5 以前要写服务,必须走ServiceBase那套老代码;.NET5 开始Generic Host里直接集成UseWindowsService,等于把服务生命周期接进了现代 .NET 的依赖注入框架里。
这个 demo 的骨干就是这个结构:
WorkerServiceDemo/ ├── Program.cs // 入口,配置宿主 ├── Workers/ │ └── DemoWorker.cs // 继承 BackgroundService ├── Services/ │ └── JobService.cs // 业务逻辑,从构造器注入 ├── ProjectInstaller.cs // 安装器(给 installutil 用) ├── worker-service-demo.csproj └── appsettings.json我拆这个 demo 时先看的不是代码,是 csproj。因为项目能不能跑成服务,有一半在项目文件里就定了。常见做法是确认两个关键项:
<PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net5.0</TargetFramework> <!-- 是否允许在 Windows 服务中运行的关键开关 --> <UseWindowsForms></UseWindowsForms> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="5.0.0" /> </ItemGroup>Microsoft.Extensions.Hosting.WindowsServices这个包是关键,它是 Windows 服务的宿主支持包。没有它,即使代码里调用UseWindowsService()也编译不过。TargetFramework 如果你是 .NET6,改成net6.0再加对应 6.x 版本的包就行,代码不用动。这包做的事情很实在:把主机的生存周期绑定到 SCM 上,系统关机、服务停止时能触发IHost的优雅停止回调。
2.2 Program.cs:服务的"心脏"在这里切换
Program.cs 是整套模板里最重要的文件,务必逐行看。
public class Program { public static void Main(string[] args) { IHost host = Host.CreateDefaultBuilder(args) .UseWindowsService(options => { // 服务列表中显示的名字,区别于进程名 options.ServiceName = "DemoWorkerService"; }) .ConfigureServices((context, services) => { // 注册业务服务:文件处理、HTTP 请求、仓库等 services.AddSingleton<JobService>(); // 注册后台任务 services.AddHostedService<DemoWorker>(); }) .Build(); host.Run(); } }这里的UseWindowsService是分水岭。代码逻辑和普通 Worker 完全一样,但这行决定了它是否去响应 SCM 的控制指令。运行机制是:当进程是被服务管理器启动的,它就调用服务逻辑;如果是从命令行直接dotnet WorkerServiceDemo.dll跑,它照样以控制台模式跑,方便调试。ServiceName不等同于可执行文件名,它是服务管理器里注册的名称,卸载、启动、停止都要用它。这个 demo 里叫DemoWorkerService,你自己写新项目时记得换掉,否则和你机器上已有的服务重名,注册时直接报错。
2.3 ExecuteAsync 的写法:别把服务写成循环死等
Worker 是任务的心脏,demo 里的 DemoWorker 继承了BackgroundService,核心是ExecuteAsync。
protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // 服务刚启动时最怕构造函数或这里抛异常 _logger.LogInformation("Worker started at: {time}", DateTimeOffset.Now); while (!stoppingToken.IsCancellationRequested) { try { await _jobService.ProcessOnceAsync(stoppingToken); } catch (Exception ex) { // 单次任务失败不能把整个服务拖垮 _logger.LogError(ex, "Task execution failed"); } // 轮询间隔,不要用 Thread.Sleep await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); } }三个地方容易出事。第一,Task.Delay必须把stoppingToken传进去,否则停止服务时,工作线程会一直睡到自然醒,SCM 等不到进程退出就会报"服务停止超时"或者直接杀进程。第二,循环里的任务要捕获异常:单次任务挂掉,服务不应该跟着退出,日志记下异常继续下一轮。第三,ProcessOnceAsync里如果调外部接口,建议自己加超时控制,因为个别 HTTP 客户端默认超时是 100 秒,服务被 SCM 认为无响应时往往就是卡在这种地方。
提示:
stoppingToken在OnStopping触发后进入取消状态,Task.Delay会立刻抛出OperationCanceledException。如果没传给 Delay,系统停止服务时最坏要等满 30 秒。
3. 把项目变成真正服务:installutil、sc.exe 与自启动参数
3.1 ProjectInstaller:给 installutil 走的传统路径
demo 里带了一个ProjectInstaller.cs,这是从 .NET Framework 时代过来的老套路。它本质是用安装器类向系统注册服务名、描述、启动类型、运行账户。编译出来后在项目目录下执行:
installutil.exe WorkerServiceDemo.exeProjectInstaller里的核心逻辑如下:
[RunInstaller(true)] public partial class ProjectInstaller : System.Configuration.Install.Installer { public ProjectInstaller() { ServiceProcessInstaller processInstaller = new ServiceProcessInstaller(); // LocalSystem 权限高,多数内网服务器够用 // 需要域账户或者指定用户时改成 manual processInstaller.Account = ServiceAccount.LocalSystem; ServiceInstaller serviceInstaller = new ServiceInstaller(); serviceInstaller.ServiceName = "DemoWorkerService"; serviceInstaller.DisplayName = "Demo Worker Service"; serviceInstaller.Description = "定时执行 job 的后台服务"; serviceInstaller.StartType = ServiceStartMode.Automatic; Installers.Add(processInstaller); Installers.Add(serviceInstaller); } }Account = LocalSystem是个偷懒但常见的选项。它对文件系统近乎完全权限,很多坑都能避开,但它也是安全评审盯着的地方。如果服务只需访问某个共享目录或数据库,更稳妥的做法是建一个最小权限的本地用户,Account = ServiceAccount.Manual然后加Credentials属性。对于这个 demo,按 LocalSystem 跑起来,先把链路走通。
3.2 sc.exe 快速注册:适合拿去验证
如果不想带 Installer 工程,或者你只是临时部署到测试机,sc.exe是手工路径里最好用的工具。我一般会这样做:
sc.exe create DemoWorkerService binPath= "D:\services\WorkerServiceDemo.exe" start= auto DisplayName= "Demo Worker Service"注意两个细节。binPath=后必须有一个空格,这是 sc.exe 的语法,少一个空格就提示参数错误。路径有空格时整串加引号。路径里不要用相对路径,服务启动时的工作目录不一定是程序集目录,相对路径十次有九次会出问题。这个 demo 给到的 .NET5 发布产物,我习惯放到一个不带空格的盘符目录里,比如D:\svc\,免得路径转义折腾。
sc 除了 create,常用的还有:
sc.exe start DemoWorkerService sc.exe query DemoWorkerService sc.exe stop DemoWorkerService sc.exe delete DemoWorkerServicequery的STATE字段停在RUNNING才算真的起来了。很多时候create成功但start失败,后面避坑章里我会专门讲。
3.3 卸载与升级:先停、再删、后覆盖
Windows 服务有个很烦的特性:exe 正被服务进程占用时,你想覆盖 DLL 会提示"文件正在被另一进程使用"。我拆这个 demo 时专门验证过升级流程,正确姿势是这样:
sc.exe stop DemoWorkerService sc.exe delete DemoWorkerService :: 确认 STATE 变成 STOPPED 之后再做覆盖 xcopy /Y /E publish\* D:\svc\ sc.exe create DemoWorkerService binPath= "D:\svc\WorkerServiceDemo.exe" start= auto sc.exe start DemoWorkerService停服务和删服务之间稍微等一下,SCM 释放句柄有延迟。有人直接sc.exe stop后马上覆盖文件,照样报文件占用,就是这个原因。如果删服务时提示 1072 或者"指定的服务已标记为删除",那说明句柄没释放完,等几秒或者重启一下服务管理器。升级时比较稳的做法是发布目录固定成D:\svc,新版本文件先放大版本号再覆盖,不要直接改文件名。
注意:installutil 卸载用
installutil /u WorkerServiceDemo.exe。如果中途用过sc.exe delete又没有成功删干净,再执行 installutil 会提示服务已存在,需要先在服务管理器里确认那个名字真的消失了。
4. 调式、日志与控制台双模:看见服务内部到底发生什么
4.1 控制台模式跑:能定位 80% 的逻辑问题
Windows 服务调试最烦的是看不到输出。但这个 demo 有个非常实用的特性——直接双击 exe 或者用dotnet run跑,它会进入控制台模式。这意味着程序集加载问题、配置文件找不到、业务代码异常,全部能直接在控制台看到。我排错的第一步永远是"先控制台跑一遍",逻辑跑通再注册成服务。
cd D:\svc WorkerServiceDemo.exe如果是 .NET5 框架依赖发布,可以用:
dotnet WorkerServiceDemo.dll控制台模式下,看到Application started. Press Ctrl+C to shut down.就说明宿主起来了。接下来看有没有业务日志刷出来,确认定时任务真的在跑。如果控制台都报错,就别折腾服务管理器了,问题在代码或者环境。
4.2 附加进程调试:断点必须在服务启动后
控制台模式能看日志,能确认整体逻辑,但断点调试还是得靠 Visual Studio 附加进程。
Debug -> Attach to Process -> 勾选 Show processes from all users -> 找到 WorkerServiceDemo.exe -> Attach注意服务要是以 LocalSystem 跑的,普通权限看不到这个进程,必须勾选 all users,且 Visual Studio 要用管理员身份打开。断点位置有讲究:Program.cs里的host.Run()之前不要打断点,因为 SCM 启动服务时,代码还没跑到那里可能就已经被判定启动超时。正确做法是在ExecuteAsync入口打第一个断点。另外一个实用技巧是服务启动时附加往往赶不上,可以在Main里加一个Thread.Sleep(5000)的延迟(demo 里部分版本加的是if (!Environment.UserInteractive) Thread.Sleep(3000)),给调试器争取附加时间。部署时记得把这个延迟注释掉或设成 0。
4.3 日志:EventLog 和文件日志的选择
demo 里默认用了ILogger,在控制台模式写控制台,注册成服务后控制台输出不可见,所以文件日志是第一个要补的设施。这段代码值得抄:
builder.Logging.AddFile("logs/demo-{Date}.log", options => { options.FormatLogFileName = f => $"demo-{DateTime.Now:yyyyMMdd}.log"; options.FileSizeLimitBytes = 50 * 1024 * 1024; });这是第三方包Serilog.Extensions.Logging.File的写法。如果你不想引第三方包,也可以自己写一个ILoggerProvider,但没必要,Serilog 这套成熟。日志级别默认在appsettings.json里配:
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } } }Microsoft.Hosting.Lifetime级别的日志会输出服务的启停信息,排查"服务有没有起来"特别有用。文件日志最大的价值是事后分析——服务半夜崩了,/var/log 里看时间线;EventLog 的好处是 Windows 的事件查看器里能统一看,但信息量太少,格式也不友好。我的习惯是文件日志记录业务细节,EventLog 只写服务的启动、停止、异常重启三个生命周期事件。
提示:文件日志路径建议配绝对路径,比如
D:\logs\demo-{Date}.log。以 LocalSystem 跑时相对路径在系统目录下,不好找。
5. 避坑手册:服务启动失败、路径错位、权限不足
5.1 错误 1053:服务没有及时响应启动或控制请求
现象:sc.exe start或服务管理器里启动,先是"正在启动",过一会儿弹窗报"错误 1053:服务没有及时响应启动或控制请求"。
原因:SCM 默认给服务 30 秒启动时限。你的Main里如果做了耗时的初始化,比如读大文件、连数据库、调远程接口,没到host.Run()就已经超时。另一个高频原因是程序集没找到——binPath写了 exe,但同一个目录下的 DLL 发布不完整,启动时抛出FileNotFoundException,SCM 直接判定没起来。
解决:先控制台跑一遍排除程序集缺失;再看Main里有没有阻塞初始化。常见做法是把耗时操作移进ExecuteAsync的开头,让服务先向 SCM 报告"已启动",再慢慢干活。如果要在启动时就加载,用IHostApplicationLifetime.ApplicationStarted回调,那个时机 SCM 已确认启动。
5.2 附件路径错误:工作目录不在程序集目录
现象:服务在日志里记的路径全是C:\Windows\System32\...,按相对路径找appsettings.json找不到,整个配置加载失败。
原因:SCM 启动服务时,进程的工作目录(CurrentDirectory)是System32,不是 exe 所在目录。控制台模式没问题,服务模式立刻翻车。这是 Windows 服务最典型的路径坑。
解决:代码里所有路径都基于AppContext.BaseDirectory:
string baseDir = AppContext.BaseDirectory; string configFilePath = Path.Combine(baseDir, "appsettings.json");文件日志、读取配置、写附件全部拼接这个目录。日志文件则直接配绝对路径,别让它依赖工作目录。
5.3 服务显示 RUNNING 但任务没执行:DI 生命周期搞错
现象:服务启动成功,状态 RUNNING,但等了好几个轮询周期,任务一次没跑。日志里什么都没有,或者只有启动一条。
原因:AddSingleton<JobService>注册成单例没问题,怕的是你把DemoWorker也注册成单例,或者业务服务注册成Scoped,导致注入到 Worker 里时作用域错位。在BackgroundService里用 Scoped 服务会抛Cannot resolve scoped service from root provider。更隐蔽的情况是 Worker 注册没问题,但ExecuteAsync一开始就return了,比如while条件误写成了while(true)然后被 finally 吞掉异常,线程静默退出。
解决:Worker 必须AddHostedService<T>注册;Worker 内部的临时服务用IServiceScopeFactory手动建作用域。这是标准解法:
using (var scope = _scopeFactory.CreateScope()) { var job = scope.ServiceProvider.GetRequiredService<JobService>(); await job.ProcessOnceAsync(stoppingToken); }判断"任务真的在跑",在ExecuteAsync里Task.Delay之前加一行日志,确认每轮都进来了。别靠肉眼看,要靠日志时间戳。
5.4 更新覆盖 DLL 报 file in use:停服务后句柄未释放
现象:sc.exe stop完成后,去覆盖D:\svc\WorkerServiceDemo.dll,Windows 提示文件被占用,替换不了。
原因:SCM 通知服务停止是异步的,服务收到CancellationToken后,如果ExecuteAsync里有长任务没退出,进程一直活着。sc.exe stop返回时可能只表示"已发出停止请求",不是"真的停了"。
解决:sc.exe query DemoWorkerService确认STATE为STOPPED再覆盖。如果执行停止后长期停在STOP_PENDING,检查ExecuteAsync的 finally 里有没有没加超时的等待,Task.Delay没有传stoppingToken几乎必然卡死。这个 demo 的写法里,Task.Delay(delay, stoppingToken)是能正常退出的关键。真到了杀不掉的地步,taskkill /f /pid <PID>是最后手段,但那是后悔药,不要靠它维持运维流程。
5.5 服务没问题但访问共享路径 401:LocalSystem 不总是万能
现象:服务跑在 A 机器,任务去读 B 机器的共享目录,日志报Access is denied,或者访问 SQL Server 时报Login failed for user。
原因:LocalSystem 访问网络共享是以机器账户身份过去的,不是当前登录用户。跨域或者对端权限严格时,机器账户没有被授权。
解决:用ServiceAccount.Manual指定一个域账户或本机账号,并给这个账号配共享目录的最小权限:
processInstaller.Account = ServiceAccount.Manual; // 在服务的 Log On 选项卡里填入 user/pwd,或者在 Installer 类里设置demo 是用 LocalSystem 起步的,生产环境我建议尽早切换到指定账户,否则后面每次换密码都要动服务,运维成本很高。
6. 让服务更可靠:自启动、崩溃重启、心跳确认
6.1 恢复选项:别让服务死了就永远死了
服务挂掉之后能不能自动拉起来,由 SCM 的恢复策略控制。这个参数用命令行就可以设置:
sc.exe failure DemoWorkerService reset= 86400 actions= restart/5000/restart/10000/restart/60000意思是:第一次失败 5 秒后重启,第二次 10 秒后,第三次以及以后 60 秒后。reset= 86400表示一天内失败次数清零。这个参数配合start= auto,保障服务在机器重启后能拉起,进程崩了之后能自动恢复。如果是周期任务场景,服务挂了没被拉起,数据就漏了。
6.2 心跳与自检:确认它真的在干活
服务跑起来五分钟,你知道它还在干活吗?靠日志回看有点滞后,我的习惯是给服务加一个心跳文件:
private void WriteHeartbeat() { string heartbeatPath = Path.Combine(_heartbeatDir, "heartbeat.txt"); File.WriteAllText(heartbeatPath, $"alive at {DateTimeOffset.Now:O}"); }每小时写一次。监控脚本只要检查这个文件的修改时间,距离当前时间超过 15 分钟就告警。Windows Server 上不用额外装监控 agent,一个计划任务就够了。心跳文件作用很实际:排查"服务是 RUNNING 但是没干活"的隐蔽问题时,它是第一手证据——如果心跳时间还在更新,进程活着,问题的方向就转到业务代码或外部依赖;如果心跳停了,进程状态就存疑了。
我从一处项目之后养成一个习惯:每次用sc.exe注册完服务,强制走一遍完整流程——sc query看状态、控制台跑一遍、看日志文件有没有滚动、心跳文件是否更新,四步全过才敢合上服务器管理器。Windows 服务它不是一件玄学的东西,多数翻车都集中在路径、权限、宿主生命周期这三件事上。把这个 demo 的项目文件、Program.cs、Worker 写法吃透,你就能在任意一台 Windows Server 上把常驻任务以正式服务跑起来。希望这篇拆解帮到你,落地过程顺利。
本文还有配套的精品资源,点击获取