如何根据 ASP.NET VNEXT MVC6 中给出的路径进行虚拟 route/redirect?
How can I do a virtual route/redirect based on the path given in ASP.NET VNEXT MVC6?
我有一个网站在不同的路径上公开了多个 API,每个 API 都由特定于应用程序部分的控制器处理,例如 example.com/Api/Controller/Action?param1=stuff
,其中控制器发生变化,但操作保持相当一致。
我有几个调用这些 API 的集成设备。问题是这些集成设备不能轻易更改,我希望它们指向的具体控制器将来需要更改。
我的计划是使用虚拟重定向之类的东西,所有设备都会调用固定的 URL,例如 example.com/Api/VRedirect/{deviceId}/MethodName?param1=test
根据 deviceId
的值,实际使用的控制器会发生变化(基于某些数据库查找逻辑)。
因此,例如,如果 deviceId
1234 被查找并且 returns "Example",调用 example.com/Api/VRedirect/1234/Test?param1=test
将等同于直接调用 example.com/Api/Example/Test?param1=test
.
到目前为止我还没有找到正确实现它的方法,我接近的唯一方法是使用自定义路由:
app.UseMvc(routes => {
routes.MapRoute(
name: "RedirectRoute",
template: "Api/VRedirect/{deviceId}/{*subAction}",
defaults: new { controller = "BaseApi", action = "VRedirect"});
);
使用重定向操作:
public IActionResult VRedirect(string deviceId, string subAction) {
string controllerName = "Example"; // Database lookup based off deviceId
return Redirect(string.Format("/Api/{0}/{1}", controllerName, subAction));
}
这部分适用于 GET 请求,但对 POST 根本不起作用,因为它会丢弃所有 POST 数据。
有什么办法可以实现这样的功能吗?我怀疑我可能必须编写一个自定义路由器,但我不确定从哪里开始。
更新:
通过简单地为循环中的每个设备添加路由,我已经设法使用默认路由器完成所需的行为:
app.UseMvc(routes => {
Dictionary<string, string> deviceRouteAssignments = new Dictionary<string, string>();
// TODO: Get all these assignments from a database
deviceRouteAssignments.Add("12345", "ExampleControllerName");
foreach (var thisAssignment in deviceRouteAssignments) {
routes.MapRoute(
name: "DeviceRouteAssignment_" + thisAssignment.Key,
template: "Api/VRedirect/" + thisAssignment.Key + "/{action}",
defaults: new { controller = thisAssignment.Value });
}
}
}
然而,这有一些明显的局限性,例如路由仅在应用程序启动时更新。大量路由的性能下降可能是个问题,但我测试了 10,000 条路由,没有发现任何可察觉的减速。
要使其适用于 POST 请求,您必须使用 HttpClient
之类的东西并创建一个 post 请求到您想要的资源。事实上,您也可以将 HttpClient
用于 GET 请求,它们将在 100% 的时间内正常工作。
但是如果你想调用外部API,这样做是有好处的。如果你只想调用内部资源,最好使用其他模式。例如,您是否考虑过取消除 BaseApiController
之外的所有控制器?在您收到来自设备的请求并希望将处理委托给其他人 class 后,它不必是控制器 class。您可以使用 Activator.CreateInstance
(或者更好的是,使用 DI 容器实例化 class)简单地创建所需 POCO class 的实例并调用它所需的方法。
进一步思考后,以下内容可能也对您有用:
public class CustomControllerFactory : DefaultControllerFactory
{
protected override Type GetControllerType(RequestContext requestContext, string controllerName)
{
var controllerToken = requestContext.RouteData.GetRequiredString("controller");
var context = new DbContext();
var mappedRoute = context.RouteMaps.FirstOrDefault(r => r.DeviceId == controllerToken);
if(mappedRoute == null) return base.GetControllerType(requestContext, controllerName);
requestContext.RouteData.Values["controller"] = mappedRoute.ControllerShortName; //Example: "Home";
return Type.GetType(mappedRoute.FullyQualifiedName); //Example: "Web.Controllers.HomeController"
}
}
如您所见,您的数据库 table 将至少包含三列,DeviceId
、ControllerShortName
和 FullyQualifiedName
。因此,例如,如果您希望 /1234/About 由 /Home/About 处理,您可以将 "Home" 指定为 ControllerShortName
并将 YourProject.Controllers.HomeController
指定为完全限定名称。请注意,如果控制器不在当前正在执行的程序集中,您将必须添加程序集名称。
完成以上操作后,您只需在Global.asax
:
注册即可
ControllerBuilder.Current.SetControllerFactory(typeof(CustomControllerFactory));
所以今天我有了某种顿悟,意识到这实际上可以通过使用路由约束来相当简单地完成。
为每个要使用的控制器注册一个路由:
routes.MapRoute(
name: "VRoute_" + "Example",
template: "Api/VRouter/{deviceId}/{action}",
defaults: new { controller = "Example"},
constraints: new { deviceId = new VRouterConstraint("Example") }
);
以上代码通过for循环或其他方法为每个Controller重复一次(在本例中只有ExampleController
被注册)
注意为 deviceId
指定的路由限制。为了触发路由,VRouterConstraint
必须在 deviceId
参数上注册一个匹配项。
VRouterConstraint
看起来像:
public class VRouterConstraint : IRouteConstraint {
public VRouterConstraint (string controllerId) {
this.ControllerId= controllerId;
}
public string ControllerId{ get; set; }
public bool Match(HttpContext httpContext, IRouter route, string routeKey, IDictionary<string, object> values, RouteDirection routeDirection) {
object deviceIdObject;
if (!values.TryGetValue(routeKey, out deviceIdObject)) {
return false;
}
string deviceId = deviceIdObject as string;
if (deviceId == null) {
return false;
}
bool match = false;
using (VRouterDbContext vRouterDb = new VRouterDbContext ()) {
match = vRouterDb.DeviceServiceAssociations
.AsNoTracking()
.Where(o => o.ControllerId == this.ControllerId)
.Any(o => o.AssoicatedDeviceId == deviceId);
}
return match;
}
}
因此,当设备转到地址Api/VRouter/ABC123/Test
时,ABC123
被解析为deviceId
,而VRouterConstraint
中的Match()
方法是反对它。 Match()
方法在数据库中查找设备 123ABC
是否已注册到路由链接到的控制器(在本例中为 Example
),如果是,returns True
.
首先,如果这些约束是静态的并且不改变,或者不经常改变,我不会在每次请求时查找它们,而是在应用程序启动时查找它们,然后将数据缓存在 HttpContext.Cache 或 Redis 或其他一些允许您绕过每个请求的外观的缓存机制。如果可以定期更新它们,请设置时间限制并在缓存逐出时重新加载新的条目集。
请记住,您拥有的路线越多,在最坏的情况下您进行的数据库查询就越多。因此,即使您需要对每个请求进行查找,更好的解决方案是在每个请求上更新缓存。
但是,如果您绝对必须对每个约束中的每个请求执行此操作,那么您可以简单地执行以下操作:
public void ConfigureServices(IServiceCollection services)
{
services.AddEntityFramework(Configuration)
.AddSqlServer()
.AddDbContext<VRouterDbContextt>();
//...
}
// Note: I added the DbContext here (and yes, this does in fact work)...
public void Configure(IApplicationBuilder app, IHostingEnvironment env,
ILoggerFactory loggerfactory, VRouterDbContext context)
{
// ....
app.UseMvc(routes =>
{
routes.MapRoute(
name: "VRoute_" + "Example",
template: "Api/VRouter/{deviceId}/{action}",
defaults: new { controller = "Example"},
constraints: new { deviceId = new VRouterConstraint(context, "Example")}
});
}
public class VRouterConstraint : IRouteConstraint {
public VRouterConstraint (VRouterDbContext context, string controllerId) {
this.DbContext = context;
this.ControllerId = controllerId;
}
private VRouterDbContext DbContext {get; set;}
public string ControllerId{ get; set; }
public bool Match(HttpContext httpContext, IRouter route, string routeKey,
IDictionary<string, object> values, RouteDirection routeDirection) {
object deviceIdObject;
if (!values.TryGetValue(routeKey, out deviceIdObject)) {
return false;
}
string deviceId = deviceIdObject as string;
if (deviceId == null) {
return false;
}
bool match = DbContext.DeviceServiceAssociations
.AsNoTracking()
.Where(o => o.ControllerId == this.ControllerId)
.Any(o => o.AssoicatedDeviceId == deviceId);
return match;
}
}
因此,这是一种为您手动创建的 RouteConstraint 提供注入存储库的相当简单的方法。
但是,有一个小问题,即 DbContext 必须在应用程序的整个生命周期内存在,而这并不是 DbContext 真正打算存在的方式。 DbContexts 除了处理上下文本身外,没有其他工具可以自行清理,因此它基本上会随着时间的推移不断增加其内存使用量。尽管在这种情况下,如果您始终查询相同的数据集,这可能会受到限制.
这是因为您的路由约束是在应用程序启动时创建的,并且在应用程序的整个生命周期内都存在,并且必须在创建约束时注入您的上下文(尽管也有一些方法可以解决这个问题,它们也可能不是最好的解决方案……例如,您可以进行优化,注入一个创建上下文的工厂,但现在您正在绕过容器生命周期管理。您也可以使用服务位置,有时您不这样做'有很多选择.. 但我把它留到最后的手段)。
这就是为什么在启动时查询数据库并缓存数据比在每次请求时都执行此类查询要好得多的原因。
但是,如果您不介意在应用程序的整个生命周期中使用上下文(只有一个),那么这是一个非常简单的解决方案。
此外,您确实也应该使用接口隔离原则来减少依赖性。这仍然会产生对实际 VRouterDbContext 的依赖性,因此无法轻松模拟和测试它...因此请添加一个接口。
我有一个网站在不同的路径上公开了多个 API,每个 API 都由特定于应用程序部分的控制器处理,例如 example.com/Api/Controller/Action?param1=stuff
,其中控制器发生变化,但操作保持相当一致。
我有几个调用这些 API 的集成设备。问题是这些集成设备不能轻易更改,我希望它们指向的具体控制器将来需要更改。
我的计划是使用虚拟重定向之类的东西,所有设备都会调用固定的 URL,例如 example.com/Api/VRedirect/{deviceId}/MethodName?param1=test
根据 deviceId
的值,实际使用的控制器会发生变化(基于某些数据库查找逻辑)。
因此,例如,如果 deviceId
1234 被查找并且 returns "Example",调用 example.com/Api/VRedirect/1234/Test?param1=test
将等同于直接调用 example.com/Api/Example/Test?param1=test
.
到目前为止我还没有找到正确实现它的方法,我接近的唯一方法是使用自定义路由:
app.UseMvc(routes => {
routes.MapRoute(
name: "RedirectRoute",
template: "Api/VRedirect/{deviceId}/{*subAction}",
defaults: new { controller = "BaseApi", action = "VRedirect"});
);
使用重定向操作:
public IActionResult VRedirect(string deviceId, string subAction) {
string controllerName = "Example"; // Database lookup based off deviceId
return Redirect(string.Format("/Api/{0}/{1}", controllerName, subAction));
}
这部分适用于 GET 请求,但对 POST 根本不起作用,因为它会丢弃所有 POST 数据。
有什么办法可以实现这样的功能吗?我怀疑我可能必须编写一个自定义路由器,但我不确定从哪里开始。
更新: 通过简单地为循环中的每个设备添加路由,我已经设法使用默认路由器完成所需的行为:
app.UseMvc(routes => {
Dictionary<string, string> deviceRouteAssignments = new Dictionary<string, string>();
// TODO: Get all these assignments from a database
deviceRouteAssignments.Add("12345", "ExampleControllerName");
foreach (var thisAssignment in deviceRouteAssignments) {
routes.MapRoute(
name: "DeviceRouteAssignment_" + thisAssignment.Key,
template: "Api/VRedirect/" + thisAssignment.Key + "/{action}",
defaults: new { controller = thisAssignment.Value });
}
}
}
然而,这有一些明显的局限性,例如路由仅在应用程序启动时更新。大量路由的性能下降可能是个问题,但我测试了 10,000 条路由,没有发现任何可察觉的减速。
要使其适用于 POST 请求,您必须使用 HttpClient
之类的东西并创建一个 post 请求到您想要的资源。事实上,您也可以将 HttpClient
用于 GET 请求,它们将在 100% 的时间内正常工作。
但是如果你想调用外部API,这样做是有好处的。如果你只想调用内部资源,最好使用其他模式。例如,您是否考虑过取消除 BaseApiController
之外的所有控制器?在您收到来自设备的请求并希望将处理委托给其他人 class 后,它不必是控制器 class。您可以使用 Activator.CreateInstance
(或者更好的是,使用 DI 容器实例化 class)简单地创建所需 POCO class 的实例并调用它所需的方法。
进一步思考后,以下内容可能也对您有用:
public class CustomControllerFactory : DefaultControllerFactory
{
protected override Type GetControllerType(RequestContext requestContext, string controllerName)
{
var controllerToken = requestContext.RouteData.GetRequiredString("controller");
var context = new DbContext();
var mappedRoute = context.RouteMaps.FirstOrDefault(r => r.DeviceId == controllerToken);
if(mappedRoute == null) return base.GetControllerType(requestContext, controllerName);
requestContext.RouteData.Values["controller"] = mappedRoute.ControllerShortName; //Example: "Home";
return Type.GetType(mappedRoute.FullyQualifiedName); //Example: "Web.Controllers.HomeController"
}
}
如您所见,您的数据库 table 将至少包含三列,DeviceId
、ControllerShortName
和 FullyQualifiedName
。因此,例如,如果您希望 /1234/About 由 /Home/About 处理,您可以将 "Home" 指定为 ControllerShortName
并将 YourProject.Controllers.HomeController
指定为完全限定名称。请注意,如果控制器不在当前正在执行的程序集中,您将必须添加程序集名称。
完成以上操作后,您只需在Global.asax
:
ControllerBuilder.Current.SetControllerFactory(typeof(CustomControllerFactory));
所以今天我有了某种顿悟,意识到这实际上可以通过使用路由约束来相当简单地完成。
为每个要使用的控制器注册一个路由:
routes.MapRoute(
name: "VRoute_" + "Example",
template: "Api/VRouter/{deviceId}/{action}",
defaults: new { controller = "Example"},
constraints: new { deviceId = new VRouterConstraint("Example") }
);
以上代码通过for循环或其他方法为每个Controller重复一次(在本例中只有ExampleController
被注册)
注意为 deviceId
指定的路由限制。为了触发路由,VRouterConstraint
必须在 deviceId
参数上注册一个匹配项。
VRouterConstraint
看起来像:
public class VRouterConstraint : IRouteConstraint {
public VRouterConstraint (string controllerId) {
this.ControllerId= controllerId;
}
public string ControllerId{ get; set; }
public bool Match(HttpContext httpContext, IRouter route, string routeKey, IDictionary<string, object> values, RouteDirection routeDirection) {
object deviceIdObject;
if (!values.TryGetValue(routeKey, out deviceIdObject)) {
return false;
}
string deviceId = deviceIdObject as string;
if (deviceId == null) {
return false;
}
bool match = false;
using (VRouterDbContext vRouterDb = new VRouterDbContext ()) {
match = vRouterDb.DeviceServiceAssociations
.AsNoTracking()
.Where(o => o.ControllerId == this.ControllerId)
.Any(o => o.AssoicatedDeviceId == deviceId);
}
return match;
}
}
因此,当设备转到地址Api/VRouter/ABC123/Test
时,ABC123
被解析为deviceId
,而VRouterConstraint
中的Match()
方法是反对它。 Match()
方法在数据库中查找设备 123ABC
是否已注册到路由链接到的控制器(在本例中为 Example
),如果是,returns True
.
首先,如果这些约束是静态的并且不改变,或者不经常改变,我不会在每次请求时查找它们,而是在应用程序启动时查找它们,然后将数据缓存在 HttpContext.Cache 或 Redis 或其他一些允许您绕过每个请求的外观的缓存机制。如果可以定期更新它们,请设置时间限制并在缓存逐出时重新加载新的条目集。
请记住,您拥有的路线越多,在最坏的情况下您进行的数据库查询就越多。因此,即使您需要对每个请求进行查找,更好的解决方案是在每个请求上更新缓存。
但是,如果您绝对必须对每个约束中的每个请求执行此操作,那么您可以简单地执行以下操作:
public void ConfigureServices(IServiceCollection services)
{
services.AddEntityFramework(Configuration)
.AddSqlServer()
.AddDbContext<VRouterDbContextt>();
//...
}
// Note: I added the DbContext here (and yes, this does in fact work)...
public void Configure(IApplicationBuilder app, IHostingEnvironment env,
ILoggerFactory loggerfactory, VRouterDbContext context)
{
// ....
app.UseMvc(routes =>
{
routes.MapRoute(
name: "VRoute_" + "Example",
template: "Api/VRouter/{deviceId}/{action}",
defaults: new { controller = "Example"},
constraints: new { deviceId = new VRouterConstraint(context, "Example")}
});
}
public class VRouterConstraint : IRouteConstraint {
public VRouterConstraint (VRouterDbContext context, string controllerId) {
this.DbContext = context;
this.ControllerId = controllerId;
}
private VRouterDbContext DbContext {get; set;}
public string ControllerId{ get; set; }
public bool Match(HttpContext httpContext, IRouter route, string routeKey,
IDictionary<string, object> values, RouteDirection routeDirection) {
object deviceIdObject;
if (!values.TryGetValue(routeKey, out deviceIdObject)) {
return false;
}
string deviceId = deviceIdObject as string;
if (deviceId == null) {
return false;
}
bool match = DbContext.DeviceServiceAssociations
.AsNoTracking()
.Where(o => o.ControllerId == this.ControllerId)
.Any(o => o.AssoicatedDeviceId == deviceId);
return match;
}
}
因此,这是一种为您手动创建的 RouteConstraint 提供注入存储库的相当简单的方法。
但是,有一个小问题,即 DbContext 必须在应用程序的整个生命周期内存在,而这并不是 DbContext 真正打算存在的方式。 DbContexts 除了处理上下文本身外,没有其他工具可以自行清理,因此它基本上会随着时间的推移不断增加其内存使用量。尽管在这种情况下,如果您始终查询相同的数据集,这可能会受到限制.
这是因为您的路由约束是在应用程序启动时创建的,并且在应用程序的整个生命周期内都存在,并且必须在创建约束时注入您的上下文(尽管也有一些方法可以解决这个问题,它们也可能不是最好的解决方案……例如,您可以进行优化,注入一个创建上下文的工厂,但现在您正在绕过容器生命周期管理。您也可以使用服务位置,有时您不这样做'有很多选择.. 但我把它留到最后的手段)。
这就是为什么在启动时查询数据库并缓存数据比在每次请求时都执行此类查询要好得多的原因。
但是,如果您不介意在应用程序的整个生命周期中使用上下文(只有一个),那么这是一个非常简单的解决方案。
此外,您确实也应该使用接口隔离原则来减少依赖性。这仍然会产生对实际 VRouterDbContext 的依赖性,因此无法轻松模拟和测试它...因此请添加一个接口。