How can I do a virtual route/redirect based on the path given in ASP.NET VNEXT MVC6?

不打扰是莪最后的温柔 提交于 2019-12-07 13:57:33

问题


I have a website that exposes several APIs at different paths, each handled by a controller specific to a section of the application, like example.com/Api/Controller/Action?param1=stuff, where Controller changes, but Actions remain fairly consistent.

I have several integrated devices that call these APIs. The issue is that these integrated devices cannot be changed easily, and the specific controller I want them to point to will need to change in the future.

My plan is to use something like a virtual redirect, where all the devices would call a fixed URL like example.com/Api/VRedirect/{deviceId}/MethodName?param1=test

Depending on the value of deviceId, the actual Controller that is used would change (based on some database lookup logic).

So for example, if the deviceId 1234 gets looked up and returns "Example", calling example.com/Api/VRedirect/1234/Test?param1=test would be the equivalent of calling example.com/Api/Example/Test?param1=test directly.

So far I have found no way of implementing this properly, the only way I have come close is by using custom routing:

app.UseMvc(routes => {
    routes.MapRoute(
                    name: "RedirectRoute",
                    template: "Api/VRedirect/{deviceId}/{*subAction}",
                    defaults: new { controller = "BaseApi", action = "VRedirect"});
);

with a redirect action:

public IActionResult VRedirect(string deviceId, string subAction) {
        string controllerName = "Example"; // Database lookup based off deviceId
        return Redirect(string.Format("/Api/{0}/{1}", controllerName, subAction));
    }

This partially works for GET requests, but doesn't work at all for POST because it discards any and all POST data.

Is there any way to implement something like this? I suspect I might have to write a custom router but I'm not sure where to start.

Update: I have managed to accomplish the desired behaviour using the default router by simply adding a route for each device in a loop:

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 });
        }
    }
}

However this has a few obvious limitations, such as the routes only being updated upon application startup. Performance degradation for a huge number of routes may be an issue, however I've tested 10,000 routes didn't notice any perceivable slowdown.


回答1:


First, if these constraints are static and don't change, or don't change often, I would not look them up on each request but rather on application startup, and then cache the data in HttpContext.Cache or Redis or some other caching mechanism that would allow you to bypass this look on each request. If they can be periodically updated, setup a time limit and on cache eviction reload the new set of entries.

Remember, the more routes you have, the more database lookups you'll have in the worst case situation. So even if you need to do a lookup each request, a better solution would be to update the cache on each request.

However, if you absolutely have to do this on each request in each constraint, then you can simply do this:

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;
    }
}

So this is a rather simple way to provide an injected repository to your manually created RouteConstraints.

There is, however, a small issue in that the DbContext must live for the life of the application and that is not really how DbContexts are intended to live. DbContexts have no facility to clean up after themselves other than disposal of the context itself, so it will basically grow and grow its memory usage over time.. although that will likely be limited in this case if you are always querying the same sets of data.

This is because your Route Constraints are created at app startup, and live for the life of the application, and your contexts have to be injected when the constraints are created (although there are some ways around that as well, they may not be the best solution either... for instance You could do an optimization which would inject a factory that creates your context instead, but now you're bypassing the containers lifetime management. You could also use service location, which sometimes you don't have much choice in.. but I leave that for a last resort).

This is why it's much better to query the database at startup and cache the data than to do these kinds of queries on each request.

However, if you're fine with the context (there will be only one) living for the life of the app, then this is a very easy solution.

Also, you really should use the Interface Segregation Principle as well to reduce dependencies. This still creates a dependency on the actual VRouterDbContext, so it can't be easily mocked and tested... so add an interface instead.




回答2:


For this to work for POST requests, you have to use something like HttpClient and create a post request to the resource you want. In fact you can use HttpClient for GET requests too and they will be working 100% of the time.

But doing it this way is beneficial if you want to call an external API. If you just want to call internal resources, it is better to use some other pattern. For example, have you thought of doing away with all of your controllers except the BaseApiController? After you have received the request from the device and you want to delegate the processing to some other class, it does not have to be a controller class. You can simply create instance of the required POCO class by using Activator.CreateInstance (or better yet, use a DI container to instantiate the class) and invoke it's required method.




回答3:


Upon reflecting on it further the following might also work for you:

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"
    }
}

As you can see, your database table would contain at least three columns, DeviceId, ControllerShortName and FullyQualifiedName. So, for example, if you wanted /1234/About to be processed by /Home/About, you would specify "Home" as ControllerShortName and YourProject.Controllers.HomeController as fully qualified name. Please note that you will have to add assembly name if the controllers are not in the currently executing assembly.

After doing the above, you just have to register in Global.asax:

ControllerBuilder.Current.SetControllerFactory(typeof(CustomControllerFactory));



回答4:


So today I had an epiphany of sorts and realised that this can actually be accomplished rather trivially by using route constraints.

A single route for each of the controllers to be used is registered:

routes.MapRoute(
    name: "VRoute_" + "Example",
    template: "Api/VRouter/{deviceId}/{action}",
    defaults: new { controller = "Example"},
    constraints: new { deviceId = new VRouterConstraint("Example") }
);

The above code is repeated once for each Controller, via a for loop or other method (in this case only the ExampleController is registered)

Notice the route constraint specified for deviceId. In order for the route to trigger, VRouterConstraint must register a match on the deviceId parameter.

VRouterConstraint looks like:

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;
    }
}

So, when a device goes to the address Api/VRouter/ABC123/Test, ABC123 is parsed as the deviceId, and the Match() method within VRouterConstraint is called against it. The Match() method does a lookup on the database to see if the device 123ABC is registered to the Controller that the route links to (in this case Example), and if so, returns True.



来源:https://stackoverflow.com/questions/28037695/how-can-i-do-a-virtual-route-redirect-based-on-the-path-given-in-asp-net-vnext-m

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!