【问题标题】:Can ASP.NET Routing be used to create "clean" URLs for .ashx (IHttpHander) handlers?ASP.NET Routing 能否用于为 .ashx (IHttpHander) 处理程序创建“干净”的 URL?
【发布时间】:2011-03-22 13:11:09
【问题描述】:

我有一些使用普通旧 IHttpHandlers 的 REST 服务。我想生成更干净的 URL,这样我的路径中就没有 .ashx。有没有办法使用 ASP.NET 路由来创建映射到 ashx 处理程序的路由?我以前见过这些类型的路线:

// Route to an aspx page
RouteTable.Routes.MapPageRoute("route-name",
    "some/path/{arg}",
    "~/Pages/SomePage.aspx");

// Route for a WCF service
RouteTable.Routes.Add(new ServiceRoute("Services/SomeService",
    new WebServiceHostFactory(),
    typeof(SomeService)));

尝试使用RouteTable.Routes.MapPageRoute() 会产生错误(处理程序不是从Page 派生的)。 System.Web.Routing.RouteBase 似乎只有 2 个派生类:ServiceRoute 用于服务,DynamicDataRoute 用于 MVC。我不确定MapPageRoute() 做了什么(Reflector 不显示方法主体,它只显示“跨 NGen 图像边界内联此类方法的性能至关重要”)。

我看到RouteBase没有密封,界面比较简单:

public abstract RouteData GetRouteData(HttpContextBase httpContext);

public abstract VirtualPathData GetVirtualPath(RequestContext requestContext,
    RouteValueDictionary values);

所以也许我可以制作自己的 HttpHandlerRoute。我会试一试,但如果有人知道将路由映射到 IHttpHandlers 的现有或内置方式,那就太好了。

【问题讨论】:

    标签: asp.net ihttphandler asp.net-routing


    【解决方案1】:

    好的,自从我最初提出这个问题以来,我就一直在解决这个问题,我终于有了一个可以满足我需求的解决方案。但是,需要进行一些预先解释。 IHttpHandler 是一个很基础的接口:

    bool IsReusable { get; }
    void ProcessRequest(HttpContext context)
    

    没有用于访问路由数据的内置属性,并且在上下文或请求中也找不到路由数据。 System.Web.UI.Page 对象具有 RouteData 属性,ServiceRoutes 负责解释 UriTemplate 并将值传递给内部正确方法的所有工作,并且 ASP.NET MVC 提供了自己的访问路由数据的方式。即使您有一个RouteBase,它 (a) 确定传入的 url 是否与您的路由匹配,并且 (b) 解析 url 以从 IHttpHandler 中提取要使用的所有单个值,也没有简单的方法可以将该路由数据传递给您的 IHttpHandler。如果你想让你的 IHttpHandler 保持“纯”,可以这么说,它负责处理 url,以及如何从中提取任何值。本例中的 RouteBase 实现仅用于确定是否应该使用您的 IHttpHandler。

    然而,一个问题仍然存在。一旦 RouteBase 确定传入的 url 与您的路由匹配,它就会传递给 IRouteHandler,它会创建您要处理请求的 IHttpHandler 的实例。但是,一旦您在 IHttpHandler 中,context.Request.CurrentExecutionFilePath 的值就会产生误导。它是来自客户端的 url,减去查询字符串。所以它不是你的 .ashx 文件的路径。而且,路由中任何不变的部分(例如方法的名称)都将成为该执行文件路径值的一部分。如果您在 IHttpHandler 中使用 UriTemplates 来确定 IHttpHandler 中的哪个特定方法应该处理请求,这可能会出现问题。

    示例:如果您在 /myApp/services/myHelloWorldHandler.ashx 有一个 .ashx 处理程序 你有这个映射到处理程序的路由:“services/hello/{name}” 你导航到这个 url,试图调用你的处理程序的 SayHello(string name) 方法: http://localhost/myApp/services/hello/SayHello/Sam

    那么您的CurrentExecutionFilePath 将是:/myApp/services/hello/Sam。它包含部分路由 url,这是一个问题。您希望执行文件路径与您的路由 url 匹配。以下RouteBaseIRouteHandler 的实现处理了这个问题。

    在我粘贴这 2 个类之前,这里有一个非常简单的用法示例。请注意,RouteBase 和 IRouteHandler 的这些实现实际上适用于甚至没有 .ashx 文件的 IHttpHandler,这非常方便。

    // A "headless" IHttpHandler route (no .ashx file required)
    RouteTable.Routes.Add(new GenericHandlerRoute<HeadlessService>("services/headless"));
    

    这将导致所有与“服务/无头”路由匹配的传入 url 被移交给HeadlessService IHttpHandler 的新实例(HeadlessService 只是本例中的一个示例。它可以是您想要的任何 IHttpHandler 实现传递给)。

    好的,下面是路由类实现、cmets 和所有内容:

    /// <summary>
    /// For info on subclassing RouteBase, check Pro Asp.NET MVC Framework, page 252.
    /// Google books link: http://books.google.com/books?id=tD3FfFcnJxYC&pg=PA251&lpg=PA251&dq=.net+RouteBase&source=bl&ots=IQhFwmGOVw&sig=0TgcFFgWyFRVpXgfGY1dIUc0VX4&hl=en&ei=z61UTMKwF4aWsgPHs7XbAg&sa=X&oi=book_result&ct=result&resnum=6&ved=0CC4Q6AEwBQ#v=onepage&q=.net%20RouteBase&f=false
    /// 
    /// It explains how the asp.net runtime will call GetRouteData() for every route in the route table.
    /// GetRouteData() is used for inbound url matching, and should return null for a negative match (the current requests url doesn't match the route).
    /// If it does match, it returns a RouteData object describing the handler that should be used for that request, along with any data values (stored in RouteData.Values) that
    /// that handler might be interested in.
    /// 
    /// The book also explains that GetVirtualPath() (used for outbound url generation) is called for each route in the route table, but that is not my experience,
    /// as mine used to simply throw a NotImplementedException, and that never caused a problem for me.  In my case, I don't need to do outbound url generation,
    /// so I don't have to worry about it in any case.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    public class GenericHandlerRoute<T> : RouteBase where T : IHttpHandler, new()
    {
        public string RouteUrl { get; set; }
    
    
        public GenericHandlerRoute(string routeUrl)
        {
            RouteUrl = routeUrl;
        }
    
    
        public override RouteData GetRouteData(HttpContextBase httpContext)
        {
            // See if the current request matches this route's url
            string baseUrl = httpContext.Request.CurrentExecutionFilePath;
            int ix = baseUrl.IndexOf(RouteUrl);
            if (ix == -1)
                // Doesn't match this route.  Returning null indicates to the asp.net runtime that this route doesn't apply for the current request.
                return null;
    
            baseUrl = baseUrl.Substring(0, ix + RouteUrl.Length);
    
            // This is kind of a hack.  There's no way to access the route data (or even the route url) from an IHttpHandler (which has a very basic interface).
            // We need to store the "base" url somewhere, including parts of the route url that are constant, like maybe the name of a method, etc.
            // For instance, if the route url "myService/myMethod/{myArg}", and the request url were "http://localhost/myApp/myService/myMethod/argValue",
            // the "current execution path" would include the "myServer/myMethod" as part of the url, which is incorrect (and it will prevent your UriTemplates from matching).
            // Since at this point in the exectuion, we know the route url, we can calculate the true base url (excluding all parts of the route url).
            // This means that any IHttpHandlers that use this routing mechanism will have to look for the "__baseUrl" item in the HttpContext.Current.Items bag.
            // TODO: Another way to solve this would be to create a subclass of IHttpHandler that has a BaseUrl property that can be set, and only let this route handler
            // work with instances of the subclass.  Perhaps I can just have RestHttpHandler have that property.  My reticence is that it would be nice to have a generic
            // route handler that works for any "plain ol" IHttpHandler (even though in this case, you have to use the "global" base url that's stored in HttpContext.Current.Items...)
            // Oh well.  At least this works for now.
            httpContext.Items["__baseUrl"] = baseUrl;
    
            GenericHandlerRouteHandler<T> routeHandler = new GenericHandlerRouteHandler<T>();
            RouteData rdata = new RouteData(this, routeHandler);
    
            return rdata;
        }
    
    
        public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
        {
            // This route entry doesn't generate outbound Urls.
            return null;
        }
    }
    
    
    
    public class GenericHandlerRouteHandler<T> : IRouteHandler where T : IHttpHandler, new()
    {
        public IHttpHandler GetHttpHandler(RequestContext requestContext)
        {
            return new T();
        }
    }
    

    我知道这个答案已经很啰嗦了,但这不是一个容易解决的问题。核心逻辑很简单,诀窍是让您的 IHttpHandler 知道“基本 url”,以便它可以正确确定 url 的哪些部分属于路由,以及哪些部分是服务调用的实际参数。

    这些类将在我即将推出的 C# REST 库RestCake 中使用。我希望我的路由兔子洞之路将帮助其他决定使用 RouteBase 并使用 IHttpHandlers 做一些很酷的事情的人。

    【讨论】:

      【解决方案2】:

      我实际上更喜欢 Joel 的解决方案,因为它不需要您在尝试设置路线时知道处理程序的类型。我会投赞成票,但可惜我没有所需的声誉。

      我实际上找到了一个我觉得比上面提到的更好的解决方案。我从中导出示例的原始源代码可以在这里找到链接http://weblogs.asp.net/leftslipper/archive/2009/10/07/introducing-smartyroute-a-smarty-ier-way-to-do-routing-in-asp-net-applications.aspx

      这是更少的代码,与类型无关,而且速度很快。

      public class HttpHandlerRoute : IRouteHandler {
      
        private String _VirtualPath = null;
      
        public HttpHandlerRoute(String virtualPath) {
          _VirtualPath = virtualPath;
        }
      
        public IHttpHandler GetHttpHandler(RequestContext requestContext) {
          IHttpHandler httpHandler = (IHttpHandler)BuildManager.CreateInstanceFromVirtualPath(_VirtualPath, typeof(IHttpHandler));
          return httpHandler;
        }
      }
      

      以及一个粗略的使用示例

      String handlerPath = "~/UploadHandler.ashx";
      RouteTable.Routes.Add(new Route("files/upload", new HttpHandlerRoute(handlerPath)));
      

      【讨论】:

      • 虽然您可能不必知道 IHttpHandler 实现的类型,但您必须知道 ashx 文件的路径。我的解决方案根本不需要 ashx 文件,它是编译时检查。如果设置错误,您的解决方案将导致运行时错误。引用处理程序的类型就像引用 ashx 的路径一样容易。也更安全。不过,它更容易理解,这很好。简单就好。我会坚持我的,因为我喜欢不必拥有 ashx 文件。我只是创建了一个实现 IHttpHandler 的普通类。
      【解决方案3】:

      编辑:我刚刚编辑了这段代码,因为我对旧代码有一些问题。如果您使用的是旧版本,请更新。

      这个线程有点老了,但我只是在这里重新编写了一些代码来做同样的事情,但以一种更优雅的方式,使用扩展方法。

      我在 ASP.net Webforms 上使用它,我喜欢将 ashx 文件放在一个文件夹中,并且能够使用路由或普通请求来调用它们。

      所以我几乎抓住了 shellscape 的代码并制作了一个可以解决问题的扩展方法。最后我觉得我也应该支持传递IHttpHandler对象而不是它的Url,所以我为此编写并重载了MapHttpHandlerRoute方法。

      namespace System.Web.Routing
      {
       public class HttpHandlerRoute<T> : IRouteHandler where T: IHttpHandler
       {
        private String _virtualPath = null;
      
        public HttpHandlerRoute(String virtualPath)
        {
         _virtualPath = virtualPath;
        }
      
        public HttpHandlerRoute() { }
      
        public IHttpHandler GetHttpHandler(RequestContext requestContext)
        {
         return Activator.CreateInstance<T>();
        }
       }
      
       public class HttpHandlerRoute : IRouteHandler
       {
        private String _virtualPath = null;
      
        public HttpHandlerRoute(String virtualPath)
        {
         _virtualPath = virtualPath;
        }
      
        public IHttpHandler GetHttpHandler(RequestContext requestContext)
        {
         if (!string.IsNullOrEmpty(_virtualPath))
         {
          return (IHttpHandler)System.Web.Compilation.BuildManager.CreateInstanceFromVirtualPath(_virtualPath, typeof(IHttpHandler));
         }
         else
         {
          throw new InvalidOperationException("HttpHandlerRoute threw an error because the virtual path to the HttpHandler is null or empty.");
         }
        }
       }
      
       public static class RoutingExtension
       {
        public static void MapHttpHandlerRoute(this RouteCollection routes, string routeName, string routeUrl, string physicalFile, RouteValueDictionary defaults = null, RouteValueDictionary constraints = null)
        {
         var route = new Route(routeUrl, defaults, constraints, new HttpHandlerRoute(physicalFile));
         routes.Add(routeName, route);
        }
      
        public static void MapHttpHandlerRoute<T>(this RouteCollection routes, string routeName, string routeUrl, RouteValueDictionary defaults = null, RouteValueDictionary constraints = null) where T : IHttpHandler
        {
         var route = new Route(routeUrl, defaults, constraints, new HttpHandlerRoute<T>());
         routes.Add(routeName, route);
        }
       }
      }
      

      我将它放在所有本机路由对象的同一个命名空间中,因此它将自动可用。

      所以要使用它,您只需调用:

      // using the handler url
      routes.MapHttpHandlerRoute("DoSomething", "Handlers/DoSomething", "~/DoSomething.ashx");
      

      或者

      // using the type of the handler
      routes.MapHttpHandlerRoute<MyHttpHanler>("DoSomething", "Handlers/DoSomething");
      

      享受, 亚历克斯

      【讨论】:

      • 谢谢亚历克斯,我正在使用你的方法,它们看起来很干净,对我很有效。
      • 嗨,伙计,感谢您的反馈。由于缓存请求的问题,我刚刚更新了此代码。请使用这个新版本。如果您想跟踪,请查看我的博客:instanceofanobject.com 谢谢!
      【解决方案4】:

      所有这些答案都很好。我喜欢 Meacham 先生的 GenericHandlerRouteHandler&lt;T&gt; 课程的简单性。如果您知道特定的 HttpHandler 类,那么消除对虚拟路径的不必要引用是一个好主意。但是,不需要 GenericHandlerRoute&lt;T&gt; 类。现有的从RouteBase 派生的Route 类已经处理了路由匹配、参数等的所有复杂性,因此我们可以将它与GenericHandlerRouteHandler&lt;T&gt; 一起使用。

      下面是一个包含路由参数的真实使用示例的组合版本。

      首先是路由处理程序。这里包括了两个——都具有相同的类名,但一个是通用的并使用类型信息来创建特定 HttpHandler 的实例,如 Meacham 先生的用法,另一个使用虚拟路径和 @ 987654328@ 创建适当的HttpHandler 的实例,如shellscape 的用法。好消息是 .NET 允许两者并存,所以我们可以随意使用任何我们想要的,并且可以根据需要在它们之间切换。

      using System.Web;
      using System.Web.Compilation;
      using System.Web.Routing;
      
      public class HttpHandlerRouteHandler<T> : IRouteHandler where T : IHttpHandler, new() {
      
        public HttpHandlerRouteHandler() { }
      
        public IHttpHandler GetHttpHandler(RequestContext requestContext) {
          return new T();
        }
      }
      
      public class HttpHandlerRouteHandler : IRouteHandler {
      
        private string _VirtualPath;
      
        public HttpHandlerRouteHandler(string virtualPath) {
          this._VirtualPath = virtualPath;
        }
      
        public IHttpHandler GetHttpHandler(RequestContext requestContext) {
          return (IHttpHandler) BuildManager.CreateInstanceFromVirtualPath(this._VirtualPath, typeof(IHttpHandler));
        }
      
      }
      

      假设我们创建了一个HttpHandler,它将文档从我们的虚拟文件夹之外的资源(甚至可能来自数据库)流式传输给用户,并且我们想要欺骗用户的浏览器,让他们相信我们正在直接提供特定文件而不是简单地提供下载(即,允许浏览器的插件处理文件,而不是强迫用户保存文件)。 HttpHandler 可能需要一个文档 id 来定位要提供的文档,并且可能需要一个文件名来提供给浏览器——一个可能与服务器上使用的文件名不同的文件名。

      以下显示了使用DocumentHandler HttpHandler 完成此操作的路由的注册:

      routes.Add("Document", new Route("document/{documentId}/{*fileName}", new HttpHandlerRouteHandler<DocumentHandler>()));
      

      我使用{*fileName} 而不仅仅是{fileName} 来允许fileName 参数充当可选的包罗万象的参数。

      要为此HttpHandler 提供的文件创建URL,我们可以将以下静态方法添加到适合此类方法的类中,例如HttpHandler 类本身:

      public static string GetFileUrl(int documentId, string fileName) {
        string mimeType = null;
        try { mimeType = MimeMap.GetMimeType(Path.GetExtension(fileName)); }
        catch { }
        RouteValueDictionary documentRouteParameters = new RouteValueDictionary {   { "documentId", documentId.ToString(CultureInfo.InvariantCulture) }
                                                                                  , { "fileName",   DocumentHandler.IsPassThruMimeType(mimeType) ? fileName : string.Empty } };
        return RouteTable.Routes.GetVirtualPath(null, "Document", documentRouteParameters).VirtualPath;
      }
      

      我省略了MimeMapIsPassThruMimeType 的定义,以保持这个示例简单。但这些旨在确定特定文件类型是否应直接在 URL 中提供其文件名,或者更确切地说是在 Content-Disposition HTTP 标头中提供它们的文件名。某些文件扩展名可能会被 IIS 或 URL 扫描阻止,或者可能导致代码执行,这可能会给用户带来问题——尤其是当文件的来源是另一个恶意用户时。您可以用其他一些过滤逻辑替换此逻辑,或者如果您没有面临此类风险,则完全省略此类逻辑。

      由于在这个特定的示例中,URL 中可能会省略文件名,因此很明显,我们必须从某个地方检索文件名。在此特定示例中,可以通过使用文档 ID 执行查找来检索文件名,并且在 URL 中包含文件名仅旨在改善用户体验。因此,DocumentHandlerHttpHandler 可以确定 URL 中是否提供了文件名,如果没有,则可以简单地在响应中添加 Content-Disposition HTTP 标头。

      继续话题,上面代码块的重要部分是使用RouteTable.Routes.GetVirtualPath()和路由参数从我们在路由注册过程中创建的Route对象生成一个URL过程。

      这是 DocumentHandler HttpHandler 类的简化版本(为了清楚起见,省略了很多)。可以看到,这个类尽可能使用路由参数来获取文档id和文件名;否则,它将尝试从查询字符串参数中检索文档 ID(即,假设未使用路由)。

      public void ProcessRequest(HttpContext context) {
      
        try {
      
          context.Response.Clear();
      
          // Get the requested document ID from routing data, if routed.  Otherwise, use the query string.
          bool    isRouted    = false;
          int?    documentId  = null;
          string  fileName    = null;
          RequestContext requestContext = context.Request.RequestContext;
          if (requestContext != null && requestContext.RouteData != null) {
            documentId  = Utility.ParseInt32(requestContext.RouteData.Values["documentId"] as string);
            fileName    = Utility.Trim(requestContext.RouteData.Values["fileName"] as string);
            isRouted    = documentId.HasValue;
          }
      
          // Try the query string if no documentId obtained from route parameters.
          if (!isRouted) {
            documentId  = Utility.ParseInt32(context.Request.QueryString["id"]);
            fileName    = null;
          }
          if (!documentId.HasValue) { // Bad request
            // Response logic for bad request omitted for sake of simplicity
            return;
          }
      
          DocumentDetails documentInfo = ... // Details of loading this information omitted
      
          if (context.Response.IsClientConnected) {
      
            string fileExtension = string.Empty;
            try { fileExtension = Path.GetExtension(fileName ?? documentInfo.FileName); } // Use file name provided in URL, if provided, to get the extension.
            catch { }
      
            // Transmit the file to the client.
            FileInfo file = new FileInfo(documentInfo.StoragePath);
            using (FileStream fileStream = file.OpenRead()) {
      
              // If the file size exceeds the threshold specified in the system settings, then we will send the file to the client in chunks.
              bool mustChunk = fileStream.Length > Math.Max(SystemSettings.Default.MaxBufferedDownloadSize * 1024, DocumentHandler.SecondaryBufferSize);
      
              // WARNING! Do not ever set the following property to false!
              //          Doing so causes each chunk sent by IIS to be of the same size,
              //          even if a chunk you are writing, such as the final chunk, may
              //          be shorter than the rest, causing extra bytes to be written to
              //          the stream.
              context.Response.BufferOutput   = true;
      
              context.Response.ContentType = MimeMap.GetMimeType(fileExtension);
              context.Response.AddHeader("Content-Length", fileStream.Length.ToString(CultureInfo.InvariantCulture));
              if (   !isRouted
                  || string.IsNullOrWhiteSpace(fileName)
                  || string.IsNullOrWhiteSpace(fileExtension)) {  // If routed and a file name was provided in the route, then the URL will appear to point directly to a file, and no file name header is needed; otherwise, add the header.
                context.Response.AddHeader("Content-Disposition", string.Format("attachment; filename={0}", HttpUtility.UrlEncode(documentInfo.FileName)));
              }
      
              int     bufferSize      = DocumentHandler.SecondaryBufferSize;
              byte[]  buffer          = new byte[bufferSize];
              int     bytesRead       = 0;
      
              while ((bytesRead = fileStream.Read(buffer, 0, bufferSize)) > 0 && context.Response.IsClientConnected) {
                context.Response.OutputStream.Write(buffer, 0, bytesRead);
                if (mustChunk) {
                  context.Response.Flush();
                }
              }
            }
      
          }
      
        }
        catch (Exception e) {
          // Error handling omitted from this example.
        }
      }
      

      此示例使用一些额外的自定义类,例如 Utility 类来简化一些琐碎的任务。但希望你能摆脱它。当然,关于当前主题,本课程中唯一真正重要的部分是从context.Request.RequestContext.RouteData 检索路由参数。但我在其他地方看到了几篇帖子,询问如何使用HttpHandler 流式传输大文件而不占用服务器内存,因此结合示例似乎是个好主意。

      【讨论】:

        【解决方案5】:

        是的,我也注意到了。也许有一个内置的 ASP.NET 方法可以做到这一点,但对我来说,诀窍是创建一个从 IRouteHandler 派生的新类:

        using System;
        using System.IO;
        using System.Reflection;
        using System.Text.RegularExpressions;
        using System.Web;
        using System.Web.Routing;
        
        namespace MyNamespace
        {
            class GenericHandlerRouteHandler : IRouteHandler
            {
                private string _virtualPath;
                private Type _handlerType;
                private static object s_lock = new object();
        
                public GenericHandlerRouteHandler(string virtualPath)
                {
                    _virtualPath = virtualPath;
                }
        
                #region IRouteHandler Members
        
                public System.Web.IHttpHandler GetHttpHandler(RequestContext requestContext)
                {
                    ResolveHandler();
        
                    IHttpHandler handler = (IHttpHandler)Activator.CreateInstance(_handlerType);
                    return handler;
                }
        
                #endregion
        
                private void ResolveHandler()
                {
                    if (_handlerType != null)
                        return;
        
                    lock (s_lock)
                    {
                        // determine physical path of ashx
                        string path = _virtualPath.Replace("~/", HttpRuntime.AppDomainAppPath);
        
                        if (!File.Exists(path))
                            throw new FileNotFoundException("Generic handler " + _virtualPath + " could not be found.");
        
                        // parse the class name out of the .ashx file
                        // unescaped reg-ex: (?<=Class=")[a-zA-Z\.]*
                        string className;
                        Regex regex = new Regex("(?<=Class=\")[a-zA-Z\\.]*");
                        using (var sr = new StreamReader(path))
                        {
                            string str = sr.ReadToEnd();
        
                            Match match = regex.Match(str);
                            if (match == null)
                                throw new InvalidDataException("Could not determine class name for generic handler " + _virtualPath);
        
                            className = match.Value;
                        }
        
                        // get the class type from the name
                        Assembly[] asms = AppDomain.CurrentDomain.GetAssemblies();
                        foreach (Assembly asm in asms)
                        {
                            _handlerType = asm.GetType(className);
                            if (_handlerType != null)
                                break;
                        }
        
                        if (_handlerType == null)
                            throw new InvalidDataException("Could not find type " + className + " in any loaded assemblies.");
                    }
                }
            }
        }
        

        为 .ashx 创建路由:

        IRouteHandler routeHandler = new GenericHandlerRouteHandler("~/somehandler.ashx");
        Route route = new Route("myroute", null, null, null, routeHandler);
        RouteTable.Routes.Add(route);
        

        上面的代码可能需要增强以使用您的路由参数,但它是起点。欢迎评论。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2014-06-28
          • 1970-01-01
          • 2014-02-09
          • 1970-01-01
          • 2011-11-16
          • 1970-01-01
          • 2016-09-03
          • 1970-01-01
          相关资源
          最近更新 更多