C# 技巧 :让后台服务变得更加容易

作者:微信公众号:【架构师老卢】
7-4 18:25
25

概述:几个月前,我写了一篇关于 ASP.net 后台服务的文章。几天前,我向一位同事展示了它,他发现我的解决方案存在一些问题,这让我感到惊讶:我从控制器而不是业务层将工作发送到后台服务。我的解决方案不容易进行单元测试。我的解决方案不是通用的。所以我回到了绘图板,想出了更好的东西。更通用的解决方案:第一步是找到一种方法,让设置对象可用于调用多个服务。public class BackgroundTaskSettings {     public BackgroundTaskSettings(string name, JObject settings)     {         Name =

几个月前,我写了一篇关于 ASP.net 后台服务的文章。几天前,我向一位同事展示了它,他发现我的解决方案存在一些问题,这让我感到惊讶:

  1. 我从控制器而不是业务层将工作发送到后台服务。
  2. 我的解决方案不容易进行单元测试。
  3. 我的解决方案不是通用的。

所以我回到了绘图板,想出了更好的东西。

更通用的解决方案:

第一步是找到一种方法,让设置对象可用于调用多个服务。

public class BackgroundTaskSettings  
{  
    public BackgroundTaskSettings(string name, JObject settings)  
    {  
        Name = name;  
        Settings = settings;  
    }  
  
    public string Name { get; set; }  
    public JObject Settings { get; set; }  
}

我选择使用 JObject 传递设置,而不是使用通用 T,因为在 DI 中注册事物更容易,而不会增加很多复杂性。

还有一个容器,它将包含我们需要执行的任务:

public class TasksToRun
{
    private readonly ConcurrentQueue<BackgroundTaskSettings> _tasks = new();

    public TasksToRun() => _tasks = 
                        new ConcurrentQueue<BackgroundTaskSettings>();

    public void Enqueue(BackgroundTaskSettings settings) =>
                                                   _tasks.Enqueue(settings);

    public void Enqueue(List<BackgroundTaskSettings> settingsList)
    {
        foreach (var settings in settingsList)
        {
            _tasks.Enqueue(settings);
        }
    }

    public BackgroundTaskSettings? Dequeue()
    {
        var hasTasks = _tasks.TryDequeue(out var settings);
        return hasTasks ? settings : null;
    }

    public bool IsEmpty => _tasks.IsEmpty;
}

下一步是为所有将执行所有工作的具体服务创建一个接口,以便后台服务可以使用它们

public interface IBackgroundServiceable  
{  
    string Name();  
   
    Task Execute(BackgroundTaskSettings settings);  
}

最后是后台服务本身:

public class GenericBackgroundTaskService : Microsoft.Extensions.Hosting.BackgroundService
{
    private readonly List<IBackgroundServiceable> _backgroundServiceableList;
    private readonly TasksToRun _tasks;

    //TODO - get number of seconds from config
    private readonly TimeSpan _timeSpan = TimeSpan.FromSeconds(1);

    public GenericBackgroundTaskService(TasksToRun tasks,
         IEnumerable<IBackgroundServiceable> backgroundServiceableList)
    {
        _tasks = tasks;
        _backgroundServiceableList = backgroundServiceableList.ToList();
    }
 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using PeriodicTimer timer = new(_timeSpan);
        while (!stoppingToken.IsCancellationRequested &&
               await timer.WaitForNextTickAsync(stoppingToken))
        {
            await ExecuteTasksFromQueue();
        }
    }
 
    //for unitTests, as we cannot run the protected ExecuteAsync method directly
    public async Task ExecuteTasksFromQueue()
    {
        while (!_tasks.IsEmpty)
        {
            var taskToRun = _tasks.Dequeue();
            if (taskToRun == null) return;
 
            //call the relevant service
            var serviceToRun = _backgroundServiceableList
                          .FirstOrDefault(b => b.Name() == taskToRun.Name);
 
            if (serviceToRun != null)
            {
                await serviceToRun.Execute(taskToRun);
            }
        }
    }
}

GenericBackgroundTaskService 类似于我在上一篇文章中的内容,但这里它获取了 IBackgroundServiceable 的实现列表(大多数 DI 可以做到这一点,它可能非常有用)。

每隔几秒钟,我们就会检查队列中的所有任务,而不是像在上一个解决方案中那样调用具体的服务,而是通过查看服务名称来找到需要调用的特定服务。

DI 注册

最后,我创建了一些扩展方法来注册 DI 中的所有内容:

public static class ServicesCollectionExtensions
{
    public static void RegisterBackgroundService(
                          this IServiceCollection serviceCollection)
    {
        serviceCollection.AddSingleton<TasksToRun, TasksToRun>();
        serviceCollection.AddHostedService<GenericBackgroundTaskService>();
    }

    public static void RegisterBackgroundServiceable(
                        this IServiceCollection serviceCollection)
    {
        //find all implementations of this interface
        var (attributeValidatorType, concreteTypes) = FindImplementations();

        foreach (var type in concreteTypes)
        {
            serviceCollection.AddScoped(attributeValidatorType, type);
        }
    }

    public static (Type attributeValidatorType, IEnumerable<Type> concreteTypes) 
            FindImplementations()
    {
        var attributeValidatorType = typeof(IBackgroundServiceable);
        var concreteTypes = AppDomain.CurrentDomain.GetAssemblies()
             .SelectMany(s => s.GetTypes())
             .Where(p => 
                attributeValidatorType.IsAssignableFrom(p) && !p.IsInterface);

        return (attributeValidatorType, concreteTypes);
    }
}

我创建了 2 种单独的方法:
一种是注册 TasksToRun 容器和 GenericBackgroundTaskService 后台服务。还有一种单独的方法来注册 IBackgroundServiceable 的所有实现,因此我们不需要手动注册它们。我已将其拆分为单独的方法,以防有人需要使用某些客户逻辑注册 IBackgroundServiceable 的实现。
我还有一种服务方法,只需找到所有实现,以帮助任何需要注册它们的人。

这一切是如何结合在一起的:

假设我们需要在后台执行 2 个操作,监控和导入。我们可以创建一个常量文件来保留服务名称,以避免任何拼写问题

public static class ServiceNames  
{  
    public const string MonitorService = "monitoring";  
    public const string ImportService = "importing";  
}

监视和导入服务可能如下所示:

public class MonitorService : IBackgroundServiceable  
{  
    public string Name() => ServiceNames.MonitorService;  
  
    public async Task Execute(BackgroundTaskSettings settings)  
    {  
        var monitorSettings = settings.Settings.ToObject<MonitorSettings>();  
        //monitor something  
        ....  
    }  
}  
  
public class ImportService : IBackgroundServiceable  
{  
    public string Name() => ServiceNames.ImportService;  
  
    public async Task Execute(BackgroundTaskSettings settings)  
    {  
        var importSettings = settings.Settings.ToObject<ImportService>();  
        //import something  
        ....  
    }  
}

请注意,我们必须添加一行来将设置 JObject 解析为类。

让我们来看看我们的主要业务服务

public class BusinessService : IBusinessService
{
    private readonly TasksToRun _tasksToRun;
 
    public BusinessService(TasksToRun tasksToRun)
    {
        _tasksToRun = tasksToRun;
    }
 
    public async Task DoSomething(string val)
    {
        _tasksToRun.Enqueue(new BackgroundTaskSettings(
            ServiceNames.MonitorService,
            JObject.FromObject(new MonitorSettings 
            {
             ApiKey = "AA-BB-CC11",
             ExternalValue = val 
            })));
 
        await Task.Delay(1000);
    }
}

在这里,我们从 DI 获取 TasksToRun 容器,只需将任务排队即可运行。(我还使用 ServiceNames.MonitorService 来确保在一个地方定义服务名称。
TasksToRun.Enqueue 也有一个重载,该重载采用 BackgroundTaskSettings 列表,以防我们需要做几件事_。_

单元测试:

有了这个新的和改进的解决方案,为我们的主要业务服务连接单元测试就变得非常容易了

[TestClass]
public class BusinessServiceTests
{
    [TestMethod]
    public async Task DoSomethingTests()
    {
        var tasks = new TasksToRun();
        var service = new BusinessService(tasks);

        var expectedVal = "testing11";
        await service.DoSomething(expectedVal);

        var task = tasks.Dequeue();
        Assert.IsNotNull(task);
        Assert.IsTrue( 
          HasSettingsValue(task.Settings, "ExternalValue", expectedVal));
    }
}

public static bool HasSettingsValue(JObject obj, string key, string val)
{
    var objVal = obj[key];
    return objVal?.ToString() == val;
}
阅读排行