Clean Architecture 中 BackgroundService 应放在哪一层?
结论:当前场景优先放在宿主项目
对于业务逻辑已经放在 Application 层、由当前后端定时调用的场景,建议把 SomeBackgroundService 放在运行它的宿主项目中。如果 Presentation 项目就是 ASP.NET Core 启动项目,可以放在该项目的 HostedServices 目录下。
放在 Infrastructure 层也可以,前提是项目把后台执行器、调度适配等技术实现统一归入该层。Clean Architecture 没有规定所有 BackgroundService 都必须位于同一个命名层级,这里的建议是根据职责作出的分层判断。
职责可以这样划分:
- Application:定义和执行应用用例,处理一次后台任务的业务流程。
- 宿主中的 BackgroundService:控制执行间隔、响应停止信号、创建依赖作用域,并调用应用用例。
- Infrastructure:提供数据库访问、外部接口等技术实现。
为什么注册位置不决定类的位置
问题中的结构已经把后台处理逻辑放在 application service 中,职责划分是合理的。剩下的 BackgroundService 主要负责让宿主按时触发这个用例。
AddHostedService<SomeBackgroundService>() 是宿主的装配代码。它出现在 Presentation 项目的启动入口,说明该宿主负责启动和停止服务,但 SomeBackgroundService 可以定义在其他项目中。
Clean Architecture 更关注依赖方向:外层调用内层定义的应用能力,Application 和 Domain 不应为了运行后台任务而反过来依赖 Web 宿主或具体后台执行器。这个原则可参见 Microsoft 的 Clean Architecture 架构说明。
如果 Presentation 只是一个独立的控制器类库,而启动入口在另一个 Host 项目中,把 BackgroundService 放在 Host 项目会更自然。
引用 Hosting 包不会破坏分层
原问题中的包名少了一个 s,正确名称是 Microsoft.Extensions.Hosting.Abstractions。BackgroundService 属于 Microsoft.Extensions.Hosting 命名空间,其程序集和包信息见 BackgroundService API 文档。
Infrastructure 引用这个包本身不违反 Clean Architecture,因为该层可以承载框架相关的外层实现。应避免让 Application 中的业务用例为了定时运行而继承 BackgroundService。
Presentation 项目能直接使用某个类型,也不表示该类型在架构上必须属于 Presentation。类型是否可用取决于项目 SDK、框架引用和包引用,不能单凭是否需要安装包来决定分层。
实现示例:每次执行创建独立作用域
还要留意通过 ServiceProvider 解析依赖的方式。AddHostedService 注册的托管服务具有 singleton 生命周期,宿主不会自动为每一轮任务创建 scope。如果应用服务或其依赖包含 scoped 服务,例如 DbContext,应在每轮执行时显式创建作用域。Microsoft 的作用域服务指南说明了这一生命周期要求。
下面的示例使用支持 CreateAsyncScope 的 .NET 6+ 环境。接口和类名仅用于展示结构。
Application 层定义一次任务的入口:
using System.Threading;
using System.Threading.Tasks;
public interface IBackgroundJob
{
Task RunAsync(CancellationToken cancellationToken);
}
让现有 application service 实现这个接口,业务逻辑继续保留在那里。
宿主项目中的执行器负责循环调用:
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
public sealed class SomeBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public SomeBackgroundService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
try
{
while (!stoppingToken.IsCancellationRequested)
{
await RunOnceAsync(stoppingToken);
await Task.Delay(
TimeSpan.FromMinutes(15),
stoppingToken);
}
}
catch (OperationCanceledException)
when (stoppingToken.IsCancellationRequested)
{
// 宿主正在停止,正常结束。
}
}
private async Task RunOnceAsync(CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var job = scope.ServiceProvider
.GetRequiredService<IBackgroundJob>();
await job.RunAsync(cancellationToken);
}
}
在宿主启动入口注册。这里的 YourApplicationService 需要替换成实现 IBackgroundJob 的实际应用服务类型:
builder.Services.AddScoped<IBackgroundJob, YourApplicationService>();
builder.Services.AddHostedService<SomeBackgroundService>();
应用服务所需的其他依赖也要完成注册。通过作用域内的 ServiceProvider 解析入口服务即可,无需自行调用 BuildServiceProvider() 创建另一套容器。
执行间隔和部署时需要注意什么
上述代码启动后会立即执行一次,之后每次执行完成再等待 15 分钟。因此,两次开始执行之间的间隔是“任务耗时 + 15 分钟”,并非固定在每个整刻钟执行。示例中的单个循环会等本次任务完成后再进入下一轮。
示例只处理了宿主停止时的取消。普通业务异常仍会向外传播,实际影响取决于宿主版本和异常处理配置。正式使用时,应明确单次失败后是记录日志并继续、重试,还是停止服务,避免直接吞掉异常。
如果后端部署了多个实例,每个实例都会运行自己的后台服务。需要全局只执行一次时,还应设计任务领取、分布式协调或独立调度机制。进程停止期间,这段内存循环也不会自动记录或补偿错过的执行。
这些需求都不改变应用用例的位置。以后改为 .ps1、.bash、crontab、Windows Task Scheduler 或独立 Worker 时,可以更换触发入口,继续复用 Application 层的业务逻辑。
备注:内容仅供参考。
