有些工作需要一个真正的浏览器,却不需要有人坐在它前面。例如,服务器将夜间报表渲染为 PDF、截取仪表板的屏幕截图,或者从没有 API 的门户网站读取数据。在 Windows 上,这类工作通常以 Windows 服务的形式运行:它随计算机启动,在无人登录时也照常运行。
Microsoft 不支持在这种场景下使用 WebView2。其文档排除了服务默认获得的帐户,其工程师则将其他配置描述为一项限制或有意为之。下文会引用这两处原话。
本文将构建一个 .NET Worker Service,这是一种面向长时间运行的后台应用的项目类型。它以无头方式运行 DotNetBrowser(一个基于 Chromium 的 .NET WebView):没有窗口,也没有桌面。它每分钟运行一次,将 HTML 页面保存为 PDF 和 PNG 文件,并将页面文本保存为 JSON。
我们使用的是 DotNetBrowser 4.3.4。我们在 Windows 11 和 Windows Server 2025 上以 .NET 10 运行了该服务,并在 Windows 11 上以 .NET 8 运行。
准备工作
要跟着操作,您需要:
- Windows 10 或更高版本,或 Windows Server 2016 或更高版本。
- .NET 10 SDK。如需使用 .NET 8,请将
DnbReportService.csproj中的TargetFramework改为net8.0-windows。 - 一个 DotNetBrowser 许可证密钥。评估密钥可免费使用 30 天。
- 管理员权限,用于安装服务。
代码作为服务运行时有何不同
与桌面应用或在控制台中运行相比,有三点不同。
服务运行在会话 0 中。每个登录的用户都会获得一个会话:一个拥有自己窗口的桌面。服务运行在一个单独的会话(会话 0)中,该会话没有交互式桌面。没有人能看到在那里创建的窗口,也没有人能点击对话框。Chromium 在那里仍然可以使用显卡。在我们的运行中,Windows 11 上确实如此;而在一台没有 GPU 的 Windows Server 虚拟机上,它切换到了软件渲染。
服务在自己的帐户下运行,例如 LocalSystem、LocalService、NetworkService 或普通用户帐户。它也可以是虚拟帐户:Windows 为单个服务创建的低权限帐户,没有密码。该帐户拥有自己的用户文件夹、临时文件夹和环境变量,与您的帐户相互独立。对于 LocalService,临时文件夹是 C:\Windows\ServiceProfiles\LocalService\AppData\Local\Temp。
服务在 C:\Windows\System32 中启动,而不是在自己的文件夹中,因此相对文件路径会以 System32 为基准解析。

服务和 Chromium 运行在会话 0 中。
为什么 Windows 服务中不支持 WebView2
WebView2 是 Microsoft 面向 Windows 应用程序的 WebView,基于 Microsoft Edge 构建。开发者曾尝试在服务中使用它来将页面打印为 PDF 或截取屏幕截图。在 GitHub 上 WebView2 问题跟踪器的许多报告中,WebView2 在创建浏览器时挂起,没有任何错误。在另一些报告中,它以错误代码失败。截至 2026 年 10 月 23 日,Microsoft 的说法如下:
- LocalSystem 出于安全原因被有意禁止。 WebView2 安全文档写道:“WebView2 不能以系统用户身份运行。” LocalSystem 是
sc.exe和New-Service这两个创建服务的工具的默认帐户。 - 管理员帐户也行不通。 在管理员帐户下运行的服务拥有完整的管理员权限,即以提升权限(elevated)运行。WebView2 不会以提升的权限运行自己的进程。在提升权限的应用中,它会请求 Windows 资源管理器(Explorer)以较低权限启动这些进程,而会话 0 中没有资源管理器。在自 2023 年 5 月起一直处于打开状态的 issue #3458 中,Microsoft WebView2 团队的一名成员称这是“WebView2 的一个限制,没有变通方法”。这位工程师转而建议使用另一种帐户:“使用非管理员帐户运行服务是唯一的变通方法。”
- 在无人登录时,挂起很可能是有意为之。 #4259 这个 issue 涉及的是一个计划任务。2024 年 1 月,WebView2 团队的一名成员在那里回复:“我认为这目前属于有意设计(By Design),与试图以未登录用户身份运行有关。”这位工程师询问该任务是否以 LocalSystem 运行;报告者表示它是以提升权限的用户运行的。此后 Microsoft 再未回复,该 issue 仍处于打开状态。
非管理员帐户对部分开发者有效,例如 #3458 和 #3786 的报告者,但并非对所有人都有效。社区库 Westwind.WebView 可以在没有可见窗口的情况下运行 WebView2。一位开发者报告这种方式在生产环境中可用(#4198),另一些开发者则报告在非交互式会话中出现挂起(#4259、#4961)。这些都是社区的变通方法,而不是受支持的配置。
Issue #4198 同时也是一项针对无头 WebView2 的功能请求:一种不需要窗口和桌面的 WebView2。2024 年 1 月,一位 Microsoft 工程师“创建了一个内部工作项来跟踪此事”。2025 年 4 月,那里的一条回复称它“目前尚未被列为优先事项”。
类似的报告,例如 #3177 和 #2756,仍处于打开状态。另一些,例如 #3026 和 #2434,在 Microsoft 未提供修复的情况下被关闭。
DotNetBrowser 不需要资源管理器,也不需要已登录的用户。在我们的测试中,它在会话 0 中以 LocalSystem 和管理员帐户运行,并且在重启后无人登录的情况下也能运行。渲染是离屏进行的:页面被绘制到内存中,服务不需要窗口。有关其他差异,请参阅选择 DotNetBrowser 还是 WebView2。
创建 Worker Service
完整示例位于 GitHub 仓库中。克隆它以便跟着操作:
git clone https://github.com/TeamDev-IP/DotNetBrowser-Examples.git
示例位于 csharp/windows-service 文件夹中。我们使用以下命令创建了该项目:
dotnet new worker -o DnbReportService
cd DnbReportService
dotnet add package DotNetBrowser.Win-x64
dotnet add package DotNetBrowser.Chromium.Win-x64
dotnet add package Microsoft.Extensions.Hosting.WindowsServices
dotnet add package System.Drawing.Common
两个 DotNetBrowser 包分别是类库和面向 64 位 Windows 的 Chromium 二进制文件。项目不引用任何 WPF 或 WinForms 包,因为服务不显示 UI。System.Drawing.Common 用于保存屏幕截图。它只能在 Windows 上工作,因此项目的目标框架是 net10.0-windows,而不是模板中的 net10.0。
Program.cs 设置 .NET 宿主,由宿主负责启动和停止应用的各个部分:
// A service starts in C:\Windows\System32, so resolve
// appsettings.json and relative paths against the app folder.
HostApplicationBuilderSettings settings =
new HostApplicationBuilderSettings
{
Args = args,
ContentRootPath = AppContext.BaseDirectory
};
HostApplicationBuilder builder =
Host.CreateApplicationBuilder(settings);
// Talks to the Service Control Manager when started by it.
// In a console it does nothing, so the same exe runs there too.
builder.Services.AddWindowsService(
o => o.ServiceName = ServiceName);
// ...
// Hosted services start in order and stop in reverse order:
// the engine starts first and is disposed last.
builder.Services.AddSingleton<BrowserEngineHost>();
builder.Services.AddHostedService(
sp => sp.GetRequiredService<BrowserEngineHost>());
builder.Services.AddHostedService<ReportWorker>();
builder.Build().Run();
托管服务是宿主所管理的部分之一:一个由宿主启动和停止的类。它并不是一个单独的 Windows 服务。
AddWindowsService() 之于 Host.CreateApplicationBuilder(),正如 UseWindowsService() 之于较旧的 Host.CreateDefaultBuilder()。当 exe 作为服务运行时,AddWindowsService() 还会将日志发送到 Windows 事件日志。示例还额外添加了一个文本日志文件。
设置位于 appsettings.json 中:要渲染的页面、时间间隔,以及 exe 旁 Data 文件夹中的三个文件夹:Chromium、UserData 和 Output。示例渲染两个页面:随项目附带的本地报表 report.html,以及 https://example.com/。
以离屏模式启动 Engine
Engine 是一个正在运行的 Chromium 实例,拥有自己的进程。一个 Engine 可以承载多个 Browser,就像标签页一样。BrowserEngineHost 在服务启动时创建一个 Engine:
EngineOptions engineOptions = new EngineOptions.Builder
{
// No window and no desktop: render into memory.
RenderingMode = RenderingMode.OffScreen,
// The default is a new temporary folder for every engine.
UserDataDirectory = options.UserDataDirectory,
// The default is the temp folder of the service account.
ChromiumDirectory = options.ChromiumDirectory
}.Build();
// ...
engine = await EngineFactory.CreateAsync(engineOptions);
在服务中,其中两个选项最为重要:
UserDataDirectory是用户数据文件夹,Chromium 称之为配置文件(profile):Cookie、缓存和本地存储。如果不设置它,每个 Engine 都会得到一个新的临时文件夹,因此重启之间不会保留任何数据。ChromiumDirectory是 DotNetBrowser 提取 Chromium 二进制文件(约 500 MB)的位置。默认情况下,它使用当前帐户的临时文件夹,因此每个服务帐户都会得到自己的一份副本。使用一个固定的文件夹,Chromium 只需提取一次,而不是每个帐户各提取一次。
服务帐户必须能够写入这些文件夹。示例将它们保存在 exe 旁的 Data 文件夹中。下面的安装步骤会把服务放到 Program Files 中(只有管理员才能更改那里的文件),并授予服务帐户对 Data 的访问权限。
服务停止时,StopAsync() 设置 stopping 标志并处置 Engine,这会结束它的 Chromium 进程。
长时间运行的服务还必须能在 Chromium 崩溃后继续工作。在处理每个页面之前,下一节介绍的 ReportWorker 类会向 BrowserEngineHost 请求 Engine。如果 Chromium 已退出,BrowserEngineHost 会创建一个新的 Engine:
public async Task<IEngine> GetEngineAsync()
{
if (!stopping && (engine == null || engine.IsDisposed))
{
logger.LogWarning("The engine is gone; creating a new one.");
await CreateEngineAsync();
}
return engine;
}
加载页面并打印为 PDF
ReportWorker 是一个 BackgroundService,即 .NET 中用于运行循环的托管服务的基类。它在启动时渲染设置中的每个页面,之后每分钟渲染一次;代码将每个页面称为一个 job(作业)。对于每个页面,它创建一个 Browser,加载 URL,然后等待页面就绪:
IEngine engine = await engineHost.GetEngineAsync();
using (IBrowser browser = engine.CreateBrowser())
// On stop, closing the browser ends a load that is still running.
using (stoppingToken.Register(browser.Dispose))
{
// Off-screen browsers have no window, so set the viewport.
browser.Size = ViewportSize;
NavigationResult result =
await browser.Navigation.LoadUrl(url, LoadTimeout);
// ...
bool ready = await WaitUntilReadyAsync(browser, job.ReadyScript);
// ...
}
页面加载完成并不意味着内容已经就绪:示例报表使用 JavaScript 绘制表格和图表。因此,设置中的每个页面都有一个就绪检查,即一段在页面完成时返回 true 的简短脚本。WaitUntilReadyAsync() 会在 worker 保存页面之前运行它。
示例的 PdfExport 类随后使用内置的 PDF 打印机打印页面:
// Skip the print preview and print right away.
browser.RequestPrintHandler =
new Handler<RequestPrintParameters, RequestPrintResponse>(
p => RequestPrintResponse.Print());
// Send HTML pages to the PDF printer instead of a real one.
browser.PrintHtmlContentHandler =
new Handler<PrintHtmlContentParameters,
PrintHtmlContentResponse>(p =>
{
PdfPrinter<PdfPrinter.IHtmlSettings> pdf =
p.Printers.Pdf;
pdf.PrintJob.Settings.PdfFilePath = pdfPath;
pdf.PrintJob.Settings.PrintingHeaderFooterEnabled = false;
pdf.PrintJob.PrintCompleted += (s, e) =>
{
// ...
};
return PrintHtmlContentResponse.Print(pdf);
});
browser.MainFrame.Print();
服务没有 BrowserView,即在 WPF 或 WinForms 中显示 Browser 的控件。没有它,Browser 会取消打印请求,因此第一个处理程序告诉它立即打印,不显示打印预览。第二个处理程序将页面发送到 PDF 打印机,因此不涉及任何系统打印机。
打印是异步的:worker 会等待 PrintCompleted,然后才关闭 Browser。打印指南列出了其他打印设置,例如纸张尺寸和页边距。
截取屏幕截图并读取 DOM
除了 PDF 之外,worker 还会保存一张屏幕截图和一个元素的文本,后者会写入 JSON 文件:
Bitmap image = browser.TakeImage();
using (System.Drawing.Bitmap png =
BitmapConverter.ToBitmap(image))
{
png.Save(baseName + ".png", ImageFormat.Png);
}
// ...
IElement element = browser.MainFrame.Document
.GetElementByCssSelector(job.Selector);
text = element?.InnerText;
TakeImage() 以 DotNetBrowser 的 Bitmap 而非 System.Drawing.Bitmap 的形式返回离屏页面的像素。示例中的 BitmapConverter.ToBitmap() 将像素复制到一个 System.Drawing.Bitmap 中,再由该位图的 Save() 写入 PNG 文件。要可靠地截取屏幕截图,请参阅网页截图指南。有关读取页面的其他方式,请参阅 DOM 指南。

服务在会话 0 中截取的屏幕截图。
在控制台中运行示例
在安装服务之前,先在控制台中运行它。将您的许可证密钥放入 DotNetBrowser-Examples 仓库根文件夹中的 dotnetbrowser.license 文件。构建时会将其复制到 exe 旁边。
然后在 csharp/windows-service 文件夹中打开 PowerShell。请使用普通窗口,而不是以管理员身份打开的窗口,然后运行:
dotnet run --project DnbReportService
第一轮运行结束后,控制台会显示 Cycle finished: 2 ok, 0 failed.,文件会出现在 DnbReportService\bin\Debug\net10.0-windows\win-x64\Data\Output 中。按 Ctrl+C 停止它。
为什么要用普通窗口:自 Chromium 138 起,以管理员权限启动的 Chromium 进程会退出,并在没有管理员权限的情况下重新启动自身。Engine 无法在这次重启后继续工作,因此它无法在以管理员身份打开的窗口中启动,也无法在以管理员身份运行的 Visual Studio 中启动。如果您需要在这些环境中使用管理员权限,请参阅故障排除指南。服务不受影响。
安装并启动服务
在 csharp/windows-service 文件夹中,将服务发布为独立应用(self-contained):
dotnet publish DnbReportService -c Release -r win-x64 --self-contained `
-o publish
然后以管理员身份打开 Windows PowerShell。请使用 Windows PowerShell 5.1 而不是 PowerShell 7:*-EventLog 命令只存在于前者中。进入同一个 csharp/windows-service 文件夹,并在同一个窗口中运行以下步骤,因为它们共享变量:
将发布的文件复制到安装文件夹:
$installDir = "$env:ProgramFiles\DnbReportService" New-Item -ItemType Directory -Force $installDir | Out-Null Copy-Item -Recurse -Force publish\* $installDir注册事件日志来源。只有管理员才能创建它:
New-EventLog -LogName Application -Source DnbReportService创建服务并将其切换到虚拟帐户:
New-Service -Name DnbReportService -DisplayName DnbReportService ` -BinaryPathName "`"$installDir\DnbReportService.exe`"" ` -StartupType Automatic sc.exe config DnbReportService obj= "NT SERVICE\DnbReportService"sc.exe要求obj=后面有一个空格。请输入sc.exe而不是sc:在 Windows PowerShell 中,sc表示Set-Content。使用
icacls(用于文件权限的命令行工具)授予该帐户对其文件夹的访问权限。该帐户需要对Data文件夹拥有“修改”(Modify)权限,对安装文件夹的其余部分拥有“读取和执行”(Read & execute)权限:$account = "NT SERVICE\DnbReportService" $dataDir = "$installDir\Data" New-Item -ItemType Directory -Force $dataDir | Out-Null icacls $dataDir /inheritance:r /grant:r "*S-1-5-32-544:(OI)(CI)F" ` "*S-1-5-18:(OI)(CI)F" "${account}:(OI)(CI)M" icacls $installDir /grant "${account}:(OI)(CI)RX"(OI)(CI)使文件和子文件夹继承该权限;F、M和RX分别表示“完全控制”(Full control)、“修改”和“读取和执行”。其他用户无法在
Program Files中创建或更改任何内容,因此他们无法植入或替换服务所运行的 Chromium 二进制文件。/inheritance:r移除了Data本会继承的权限,因此他们也无法读取 Chromium 配置文件或报表。启动服务:
Start-Service DnbReportService Get-Service DnbReportService状态应为
Running。
许可证文件已随 exe 一起发布,因此服务能找到它。请把它留在那里:您的用户环境变量中的许可证密钥不会传递给服务帐户。
选择服务帐户
默认帐户 LocalSystem 在本机上拥有完全权限。加载网页的服务所需的权限远少于此,因此第 3 步将其切换到虚拟帐户。我们在 Windows 11 和 Windows Server 2025 上以下列每个帐户运行了示例。在所有这些帐户下它都能正常工作,无需更改代码或 appsettings.json:
| 帐户 | sc.exe 中 obj= 的值 | icacls 使用的名称 | 在本机上的权限 |
|---|---|---|---|
| 虚拟帐户 | NT SERVICE\DnbReportService | 相同 | 低 |
| LocalService | NT AUTHORITY\LocalService | NT AUTHORITY\LOCAL SERVICE | 低 |
| NetworkService | NT AUTHORITY\NetworkService | NT AUTHORITY\NETWORK SERVICE | 低 |
| 用户帐户,无论是否为管理员 | .\<user>,并加上 password= | <computer>\<user> | 由您授予;需要“作为服务登录”权限 |
| LocalSystem | LocalSystem | 无需设置 | 完全 |
我们建议使用虚拟帐户或 LocalService。
查看日志
服务将其启动、停止、警告和错误写入 Application 事件日志(来源为 DnbReportService),并将每一行日志写入输出文件夹中的 DnbReportService.log。只有管理员才能读取 Data,因此请使用管理员窗口:
Get-EventLog -LogName Application -Source DnbReportService -Newest 20
$output = "$env:ProgramFiles\DnbReportService\Data\Output"
Get-Content "$output\DnbReportService.log" -Tail 20
以下是我们某次运行的日志中的几行:
Account NT SERVICE\DnbReportService, session 0, running as a service: True,
ChromiumDirectory C:\Program Files\DnbReportService\Data\Chromium,
UserDataDirectory C:\Program Files\DnbReportService\Data\UserData.
...
Cycle finished: 2 ok, 0 failed.
第一行显示帐户、会话以及 Chromium 使用的文件夹。当服务的行为与控制台中不同时,请先检查这一行。
停止并移除服务
在以管理员身份运行的 Windows PowerShell 中:
Stop-Service DnbReportService
sc.exe delete DnbReportService
Remove-EventLog -Source DnbReportService
如需同时删除文件,请删除 %ProgramFiles%\DnbReportService。
限制
在阻止调用 Windows 窗口和绘图 API(User32 和 GDI32)的环境中,例如 Azure App Service 和 Azure Functions,DotNetBrowser 无法运行。
总结
最终结果是一个运行 Chromium 的 Windows 服务,它将网页保存为 PDF、PNG 和 JSON 文件,不需要窗口,也不需要已登录的用户。它在低权限帐户下运行,将 Chromium、其用户数据和输出保存在同一个文件夹中,并能在 Chromium 退出后恢复。如需在控制台应用中完成同样的工作,请参阅控制台快速入门。在 Linux 上,请参阅无头 Linux 指南以及我们关于使用 Docker 部署 DotNetBrowser 的文章。
完整源代码可在示例仓库中获取。要用您自己的页面试一试,请获取免费评估许可证:
发送中。。。
您的个人 DotNetBrowser 试用密钥和快速入门指南将在几分钟内发送至您的电子邮箱。
