独立线程渲染Razor部分视图为字符串时如何生成链接且不依赖原HttpContext
Razor Pages 离线渲染邮件模板时链接生成失效修复方案
现存问题
- 为降低请求回传的响应延迟,计划通过独立后台线程执行邮件发送逻辑,示例代码为:
Task.Run(async () => { // Send email code } - 最初的视图转字符串实现基于
IHttpContextAccessor构建ActionContext,虽然可以正常生成页面链接,但HttpContext属于请求作用域服务,原请求结束后会被释放,独立线程运行时会触发对象已释放的异常。 - 调整实现:创建独立DI作用域,手动构建全新的
DefaultHttpContext并将RequestServices绑定到当前作用域的服务实例。该方案解决了对象释放报错,但邮件模板中使用Url.PageLink生成链接时会抛出异常:InvalidOperationException: Could not find an IRouter associated with the ActionContext. If your application is using endpoint routing then you can get a IUrlHelperFactory with dependency injection and use it to create a UrlHelper, or use Microsoft.AspNetCore.Routing.LinkGenerator. - 曾尝试为自定义HttpContext手动补充路由配置,但未解决上述报错,当前存在问题的视图渲染完整实现如下:
public class ViewRenderer { private readonly IRazorViewEngine _razorViewEngine; private readonly ITempDataProvider _tempDataProvider; private readonly IModelMetadataProvider _modelMetadataProvider; private readonly IServiceScopeFactory _serviceScopeFactory; public ViewRenderer( IRazorViewEngine razorViewEngine, ITempDataProvider tempDataProvider, IModelMetadataProvider modelMetadataProvider, IServiceScopeFactory serviceScopeFactory) { _razorViewEngine = razorViewEngine; _tempDataProvider = tempDataProvider; _modelMetadataProvider = modelMetadataProvider; _serviceScopeFactory = serviceScopeFactory; } public async Task<string> RenderToStringAsync<T>(string viewName, T model) { using var scope = _serviceScopeFactory.CreateScope(); var httpContext = new DefaultHttpContext() { RequestServices = scope.ServiceProvider }; httpContext.Features.Set<IRoutingFeature>(new RoutingFeature()); httpContext.Request.RouteValues = new RouteValueDictionary(); var actionContext = new ActionContext(httpContext, httpContext.GetRouteData(), new ActionDescriptor()); var viewResult = _razorViewEngine.FindView(actionContext, viewName, false); if (viewResult.View == null) { throw new ArgumentNullException($"{viewName} could not be found"); } var viewDictionary = new ViewDataDictionary<T>(_modelMetadataProvider, new ModelStateDictionary()) { Model = model }; using var sw = new StringWriter(); var viewContext = new ViewContext( actionContext, viewResult.View, viewDictionary, new TempDataDictionary(actionContext.HttpContext, _tempDataProvider), sw, new HtmlHelperOptions() ); await viewResult.View.RenderAsync(viewContext); return sw.ToString(); } }
问题根因
ASP.NET Core 3.0之后默认启用端点路由(Endpoint Routing),替代了老版本的IRouter路由体系:
- 手动初始化的空
RoutingFeature、空RouteValueDictionary没有加载应用启动时注册的全量端点路由集合,Url.PageLink这类链接生成方法底层依赖已初始化完成的端点元数据反向匹配路由节点,空的路由上下文自然无法完成链接生成,因此抛出找不到IRouter的异常。 - 该问题和原请求携带的HttpContext无强绑定,只要将路由系统全局初始化的元数据正确注入到自定义的HttpContext中,即可在完全脱离原请求的场景下正常生成链接。
可落地修复实现
不需要手动拼接空路由配置,直接从DI容器中获取已全局初始化的路由相关服务,补充到构建的DefaultHttpContext上即可:
- 在
ViewRenderer的构造函数中新增注入两个路由相关服务:EndpointDataSource:应用启动时完成初始化的单例服务,存储了所有已注册的端点路由信息(包含所有Razor Pages的页面路由)LinkGenerator:端点路由体系下的核心链接生成服务,用于为UrlHelper提供底层的路由生成能力
- 替换原来手动初始化空RoutingFeature的逻辑,将从DI中解析到的路由数据源赋值给路由特性:
// 原来的问题代码 // httpContext.Features.Set<IRoutingFeature>(new RoutingFeature()); // httpContext.Request.RouteValues = new RouteValueDictionary(); // 替换为以下逻辑 var routingFeature = new RoutingFeature() { // 绑定当前作用域下的全量端点数据源 EndpointDataSource = scope.ServiceProvider.GetRequiredService<EndpointDataSource>(), RouteValues = new RouteValueDictionary() }; httpContext.Features.Set<IRoutingFeature>(routingFeature); - 补充绝对链接生成需要的基础信息:
Url.PageLink会生成带域名的绝对地址,自定义的空HttpContext没有默认的请求协议、主机信息,需要手动传入站点的对外基础地址,才能生成可直接访问的完整链接。
调整后的完整可用实现:
public class ViewRenderer { private readonly IRazorViewEngine _razorViewEngine; private readonly ITempDataProvider _tempDataProvider; private readonly IModelMetadataProvider _modelMetadataProvider; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly EndpointDataSource _endpointDataSource; private readonly LinkGenerator _linkGenerator; public ViewRenderer( IRazorViewEngine razorViewEngine, ITempDataProvider tempDataProvider, IModelMetadataProvider modelMetadataProvider, IServiceScopeFactory serviceScopeFactory, EndpointDataSource endpointDataSource, LinkGenerator linkGenerator) { _razorViewEngine = razorViewEngine; _tempDataProvider = tempDataProvider; _modelMetadataProvider = modelMetadataProvider; _serviceScopeFactory = serviceScopeFactory; _endpointDataSource = endpointDataSource; _linkGenerator = linkGenerator; } public async Task<string> RenderToStringAsync<T>(string viewName, T model, string siteBaseUrl) { using var scope = _serviceScopeFactory.CreateScope(); var httpContext = new DefaultHttpContext() { RequestServices = scope.ServiceProvider }; // 填充路由特性,解决Url.PageLink找不到路由的报错 var routingFeature = new RoutingFeature() { EndpointDataSource = scope.ServiceProvider.GetRequiredService<EndpointDataSource>(), RouteValues = new RouteValueDictionary() }; httpContext.Features.Set<IRoutingFeature>(routingFeature); // 填充请求的基础信息,保证生成的链接是带域名的完整绝对地址 var baseUri = new Uri(siteBaseUrl); httpContext.Request.Scheme = baseUri.Scheme; httpContext.Request.Host = new HostString(baseUri.Host, baseUri.Port); var actionContext = new ActionContext(httpContext, httpContext.GetRouteData(), new ActionDescriptor()); var viewResult = _razorViewEngine.FindView(actionContext, viewName, false); if (viewResult.View == null) { throw new ArgumentNullException($"{viewName} could not be found"); } var viewDictionary = new ViewDataDictionary<T>(_modelMetadataProvider, new ModelStateDictionary()) { Model = model }; using var sw = new StringWriter(); var viewContext = new ViewContext( actionContext, viewResult.View, viewDictionary, new TempDataDictionary(actionContext.HttpContext, _tempDataProvider), sw, new HtmlHelperOptions() ); await viewResult.View.RenderAsync(viewContext); return sw.ToString(); } }
注意事项
- 生产环境不建议直接用无托管的
Task.Run执行邮件发送逻辑,应用回收、重启时会直接丢失未完成的任务。更稳妥的方案是使用.NET内置的后台托管服务结合内存队列执行这类异步任务,该方案和当前的视图渲染逻辑完全兼容,只需要在后台任务的独立DI作用域中调用RenderToStringAsync方法即可。 - 调整后的实现完全不依赖原请求的HttpContext,所有路由元数据均来自全局初始化的路由服务,即使在后台线程、控制台程序等完全脱离请求管道的场景下,只要传入正确的站点基础地址,即可正常生成邮件模板中的所有页面链接。
内容的提问来源于stack exchange,提问作者Redline
相关产品推荐
相关产品推荐

