问题
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