任务队列中调用PDF生成时调试中断问题求助
任务队列中PDF生成导致调试会话中断问题排查
问题描述
我是一名仅有数月开发经验的初级开发者,正在为慈善机构开发公益捐赠网站项目。用户完成纪念捐赠后,系统需要生成致敬PDF并发送给指定收件人。
针对PdfService.GenerateMemorialPdf(...)的单元测试运行正常,但通过任务队列测试事件处理器时,执行到PDF生成环节(document.GeneratePdf())调试会话会突然中断,且无法捕获任何异常。若直接调用ExecuteAsync而非事件处理器的Handle()方法,测试就能正常完成,因此判断问题与任务队列的测试策略有关。
相关代码
事件处理器基类(EnqueueingEventHandler)
namespace CharityName.Application.Events; public abstract class EnqueueingEventHandler<TEvent>(IBackgroundTaskQueue taskQueue) : IEventHandler<TEvent> where TEvent : IEvent { public Task Handle(TEvent eventRequest, CancellationToken cancellationToken) { taskQueue.QueueBackgroundWorkItemAsync((CancellationToken) => ExecuteAsync(eventRequest, CancellationToken)); return Task.CompletedTask; } protected abstract ValueTask ExecuteAsync(TEvent eventRequest, CancellationToken cancellationToken); }
捐赠事件处理器实现(DonationCreatedEventHandler)
namespace CharityName.Application.Events.DonationCreatedEvent; public class DonationCreatedEventHandler( ILogger<DonationCreatedEventHandler> logger, IPdfService pdfService, IEmailService emailService, IBackgroundTaskQueue taskQueue) : EnqueueingEventHandler<DonationCreatedEvent>(taskQueue) { protected override async ValueTask ExecuteAsync(DonationCreatedEvent eventRequest, CancellationToken cancellationToken) { logger.LogInformation("Event {event} is being dequeued from the taskqueue", typeof(DonationCreatedEvent)); string? pdf = null; if (eventRequest.DonationDto.DonationType == Domain.Enums.DonationType.Memorial) { pdf = pdfService.GenerateMemorialPdf(eventRequest.LetterDto.FullName, eventRequest.LetterDto.Message, PdfImage.heart_filled); } var res = await emailService.SendRecieptAsync( EmailTemplate.reciept, eventRequest.DonorDto.Email, eventRequest.DonorDto.FirstName, pdf, "Temporary"); return; } }
PDF服务实现(PdfService)
namespace CharityName.Infrastructure.Services.PdfService; public class PdfService: IPdfService { private const string BaseImagePath = "Services/PdfService/Resources/"; public string GenerateMemorialPdf(string honoreeName, string message, PdfImage image) { // Temporary code, this does not throw var imagePath = BaseImagePath + image + ".png"; if (!File.Exists(imagePath)) { throw new FileNotFoundException($"Image file not found at path: {imagePath}"); } var documentModel = new MemorialDocumentModel() { HonoreeName = honoreeName, Message = message, Image = new Image() { Location = BaseImagePath + image + ".png"} }; var document = new MemorialDocument(documentModel); var pdf = document.GeneratePdf(); // <--- debug session ends here return Convert.ToBase64String(pdf); } }
测试代码
namespace CharityName.Tests.IntegrationTests { public class DonationCreatedEventHandlerTests { private readonly EmailService _emailService; private readonly PdfService _pdfService; private readonly BackgroundTaskQueue _taskQeueue; private readonly QueuedHostedService _queuedHostedService; private readonly IConfiguration _configuration; public DonationCreatedEventHandlerTests() { _configuration = new ConfigurationBuilder() .AddUserSecrets<DonationCreatedEventHandlerTests>() .Build(); var brevoHttpClient = BrevoClientTestHelper.Create(_configuration); _emailService = new EmailService(brevoHttpClient, new Mock<ILogger<EmailService>>().Object); _pdfService = new PdfService(); _taskQeueue = new BackgroundTaskQueue(100); _queuedHostedService = new QueuedHostedService(_taskQeueue, new Mock<ILogger<QueuedHostedService>>().Object); QuestPDF.Settings.License = LicenseType.Community; } [Fact] public async Task Test1() { // Arrange await _queuedHostedService.StartAsync(CancellationToken.None); var handler = new DonationCreatedEventHandler( new Mock<ILogger<DonationCreatedEventHandler>>().Object, _pdfService, _emailService, _taskQeueue); var donationDto = new DonationDto() { Amount = 5000, DonationType = Domain.Enums.DonationType.Memorial, Token = Guid.NewGuid() }; var donorDto = new DonorDto() { FirstName = "John", LastName = "Doe", Email = "John.Doe@example.com" }; var letterDto = new LetterDto() { LetterType = Domain.Enums.LetterType.Celebratory, DeliveryMethod = Domain.Enums.DeliveryMethod.email, LatestsDeliveryDate = DateTime.Now, FullName = "John Doe", Email = "John.Doe@example.com", Message = "Rest in Peace" }; var @event = new DonationCreatedEvent(donationDto, donorDto, letterDto); // Act await handler.Handle(@event, CancellationToken.None); // Assert Assert.True(true); // to be changed } } }
任务队列与托管服务补充代码
using System.Threading.Channels; using CharityName.Application.Abstractions.Interfaces; public class BackgroundTaskQueue : IBackgroundTaskQueue { private readonly Channel<Func<CancellationToken, ValueTask>> _queue; public BackgroundTaskQueue(int capacity) { var options = new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.Wait }; _queue = Channel.CreateBounded<Func<CancellationToken, ValueTask>>(options); } public async ValueTask QueueBackgroundWorkItemAsync( Func<CancellationToken, ValueTask> workItem) { if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); } await _queue.Writer.WriteAsync(workItem); } public async ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync( CancellationToken cancellationToken) { var workItem = await _queue.Reader.ReadAsync(cancellationToken); return workItem; } }
using CharityName.Application.Abstractions.Interfaces; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; public class QueuedHostedService(IBackgroundTaskQueue taskQueue, ILogger<QueuedHostedService> logger) : BackgroundService { public IBackgroundTaskQueue TaskQueue { get; } = taskQueue; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("Queued Hosted Service is running."); await BackgroundProcessing(stoppingToken); } private async Task BackgroundProcessing(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { var workItem = await TaskQueue.DequeueAsync(stoppingToken); try { await workItem(stoppingToken); } catch (Exception ex) { logger.LogError(ex, "Error occurred executing {WorkItem}.", nameof(workItem)); } } } public override async Task StopAsync(CancellationToken stoppingToken) { logger.LogInformation("Queued Hosted Service is stopping."); await base.StopAsync(stoppingToken); } }
问题分析
- 测试生命周期不匹配:测试方法调用
Handle后立即结束,没有等待后台任务队列中的PDF生成任务执行完成。测试框架会终止测试进程,导致调试会话中断,同时后台任务的异常无法被捕获。 - 后台线程异常处理盲区:虽然
QueuedHostedService有异常捕获逻辑,但如果GeneratePdf()抛出未被CLR捕获的致命异常(如内存访问错误),会直接终止进程,调试会话也会随之结束。 - 工作目录差异:后台线程的工作目录可能与测试主线程不同,导致图片路径解析异常,但代码中
File.Exists检查未触发,暂时排除此可能。
解决方案建议
1. 等待后台任务执行完成
修改测试代码,确保在断言前等待队列中的任务处理完毕。可以通过以下方式实现:
方式A:使用信号量监控任务完成
创建测试专用的事件处理器,在ExecuteAsync完成后触发信号:
// 测试类中新增内部类 private class TestDonationCreatedEventHandler : DonationCreatedEventHandler { public ManualResetEventSlim CompletionEvent { get; } = new ManualResetEventSlim(false); public TestDonationCreatedEventHandler(ILogger<DonationCreatedEventHandler> logger, IPdfService pdfService, IEmailService emailService, IBackgroundTaskQueue taskQueue) : base(logger, pdfService, emailService, taskQueue) { } protected override async ValueTask ExecuteAsync(DonationCreatedEvent eventRequest, CancellationToken cancellationToken) { await base.ExecuteAsync(eventRequest, cancellationToken); CompletionEvent.Set(); } } // 修改测试方法 [Fact] public async Task Test1() { // Arrange await _queuedHostedService.StartAsync(CancellationToken.None); var testHandler = new TestDonationCreatedEventHandler( new Mock<ILogger<DonationCreatedEventHandler>>().Object, _pdfService, _emailService, _taskQeueue); // ... 构造事件代码不变 // Act await testHandler.Handle(@event, CancellationToken.None); testHandler.CompletionEvent.Wait(TimeSpan.FromSeconds(5)); // 等待任务完成,超时时间可调整 // Assert // 添加实际断言,比如验证邮件发送状态或PDF生成结果 Assert.True(testHandler.CompletionEvent.IsSet); // Cleanup await _queuedHostedService.StopAsync(CancellationToken.None); }
方式B:检查队列状态(需修改队列暴露状态)
在BackgroundTaskQueue中添加队列数量检查的方法:
public bool IsEmpty => _queue.Reader.Count == 0;
然后在测试中循环等待队列清空:
// Act之后添加 var waitTime = DateTime.Now.AddSeconds(5); while (!_taskQeueue.IsEmpty && DateTime.Now < waitTime) { await Task.Delay(100); }
2. 完善异常捕获与日志
确保QueuedHostedService的日志能输出详细异常信息,便于排查:
- 替换Mock日志为真实的测试日志(如
XunitLogger),或者在Mock中设置回调捕获日志:
var loggerMock = new Mock<ILogger<QueuedHostedService>>(); loggerMock.Setup(l => l.Log( LogLevel.Error, It.IsAny<EventId>(), It.IsAny<It.IsAnyType>(), It.IsAny<Exception>(), (Func<It.IsAnyType, Exception, string>)It.IsAny<object>())) .Callback((LogLevel level, EventId id, object state, Exception ex, Func<object, Exception, string> formatter) => { Console.WriteLine($"Error: {ex.Message}\nStack Trace: {ex.StackTrace}"); }); _queuedHostedService = new QueuedHostedService(_taskQeueue, loggerMock.Object);
3. 验证PDF生成的线程兼容性
确认QuestPDF的GeneratePdf()方法是否支持在后台线程执行,部分PDF库可能对线程上下文有要求。可以尝试在测试主线程外单独启动线程调用该方法,验证是否会出现相同的中断问题。
内容的提问来源于stack exchange,提问作者Pontus
相关产品推荐
相关产品推荐

