65.9K
CodeProject 正在变化。 阅读更多。
Home

HttpHandler 的基本路由

starIconstarIconstarIconstarIconstarIcon

5.00/5 (19投票s)

2012 年 5 月 8 日

CPOL

14分钟阅读

viewsIcon

99991

downloadIcon

1812

将 HttpHandler 请求映射到控制器/操作的简单方法

目录

引言

本文提供了一种简单的方法,可以将 HttpHandler(或根据需要,HttpModule)中的 URL 请求映射到 {controller}/{action} 的形式,这与 MVC 路由类似,但未使用 MVC。   

该解决方案基于一个小型独立类 SimpleRouter,针对性能要求高的应用程序进行了优化。  

背景    

自动 URL 路由机制是 MVC 的主要优点之一,它允许开发人员指定区域、控制器和操作,以最直观的方式反映应用程序的逻辑,不仅在内部,而且同时在协议(URL)级别上。 

一旦开始使用这种方法,就很难放弃。但是,在某些任务中,您可能希望避免使用它,这里我指的不是 URL 路由本身,而是整个 MVC 平台。此类任务可能包括但不限于:

  • 需要兼顾最快响应和最小占用空间的通用低级优化任务;
  • 拦截或将某些请求/网站部分重定向到高性能程序集;  
  • 编写需要极高可伸缩性的自己的低级服务器。 
大多数情况下,这意味着会诉诸 HttpHandlerHttpModule 等接口。还有一个比这更低的协议,即 HttpListener 类,但在该级别上,它没有与 IIS 的自动集成,它只与自托管解决方案配合良好。 

当使用 HttpHandler(不带任何 MVC)等类时,首先会非常缺少的是自动 URL 路由。如果没有这种有用的东西在工作,您的项目在成长过程中,可能会变得难以理解——哪个代码块对应哪个请求,以及它们是如何相互关联的。 

本文旨在解决这个基本问题。如果您不想或不能在 HttpHandler 中使用 MVC 层,但仍希望在代码中保持简洁的控制器/操作架构,则可以使用此解决方案。 

这绝不是与 MVC 路由一对一的,也不是预期的。相反,这个库专注于在使用 HttpHandler 类时重要的优势,例如: 

  • 最小的占用空间 
  • 最快的执行速度 
  • 快速灵活的集成 

规范 

URL 路由可能是一个非常广泛的主题,并提供了近乎无限的可能性来实现。这就是为什么在开始之前,在这里设定明确的目标非常重要。 

以下是我们为版本的 URL 路由设定的确切目标列表。 

目标/要求

  1. 请求仅接受 {controller}/{action}?[parameters] 的形式。 
  2. 控制器、操作和操作参数的处理不区分大小写。 
  3. 控制器可以驻留在任何命名空间和任何程序集中。  
  4. 只有公共类和公共非静态操作才能映射到请求。 
  5. 要引用类名以“Controller”结尾的控制器类,可以在请求中省略后者。  
  6. 请求中的参数按名称(不区分大小写)映射到相应的操作参数,而它们在请求中的顺序无关紧要。 
  7. 操作支持可通过 URL 传递的所有标准参数类型:  
    1. 所有简单类型:字符串、布尔值、所有整数和浮点类型; 
    2. 指定简单类型的数组;  
    3. 未指定/混合简单类型的数组;  
    4. 具有默认值的参数;  
    5. 声明为可空的参数。 
  8. HttpHandler 配置为可重用的应用程序也可以设置路由器以使用可重用控制器。 
  9. 每个控制器自动提供对当前 HTTP 上下文的直接访问。 
  10. 优化且组织良好的实现 
    1. 仅结合泛型使用System.Web; 
    2. 极简 - 仅在一个类中实现; 
    3. 高性能; 
    4. 文档详尽。 

附加规定 

    1. 不支持默认控制器和操作。 
    2. 操作的返回类型无关紧要,将被忽略。 
    3. 不使用前缀段,即我们只使用请求的最后两个段。例如,请求 www.server.com/one/two/three/controller/action 只会使用 controller/action,前缀 one/two/three 不会被使用,但如果需要,则可供 controller/action 使用。 

使用代码

如果您下载了演示项目的源代码,它会非常容易理解。该解决方案包括三个项目:

  • BasicRouter - 核心库,包含实现路由的 SimpleHandler 类。
  • TestServer - HttpHandler 的简单实现,加上几个演示控制器,以展示其工作原理。
  • TestClient - 一个单页 Web 应用程序作为客户端,向 TestServer 发出请求。它旨在简化库的测试,并添加一些有趣的基准测试和统计数据。

添加库

使用此库的推荐方法是将 BasicRouter 项目添加到您的解决方案中,因为该库仅生成一个 12KB 的 DLL。但是,在您自己的程序集中使用它同样效果良好。 

下面是 TestServer 动态库的实现,它展示了如何声明和初始化路由器的使用。 

HttpHandler 类

public class SimpleHandler : IHttpHandler
{
	/// <summary>
	/// Router instance. Making it static
	/// is not required, but recommended;
	/// </summary>
	private static SimpleRouter router = null;

	#region IHttpHandler Members
	public bool IsReusable
	{
		// NOTE: It is recommended to be true.
		get { return true; }
	}

	public void ProcessRequest(HttpContext ctx)
	{
		if (!router.InvokeAction(ctx)) // If failed to map the request to controller/action;
			router.InvokeAction(ctx, "error", "details"); // Forward to our error handler;
	}
	#endregion

	/// <summary>
	/// Default Constructor.
	/// </summary>
	public SimpleHandler()
	{
		if (router == null)
		{
			// Initialize routing according to the handler's re-usability:
			router = new SimpleRouter(IsReusable);

			// Adding namespaces where our controller classes reside:
			// - add null or "", if you have controllers in the root.
			// - also specify which assembly, if it is not this one.
			router.AddNamespace("TestServer.Controllers");

			// OPTIONAL: Setting exception handler for any action call:
			router.OnActionException += new SimpleRouter.ActionExceptionHandler(OnActionException);
		}
	}

	/// <summary>
	/// Handles exceptions thrown by any action method.
	/// </summary>
	/// <example>
	/// /simple/exception?msg=Ops!:)
	/// </example>
	/// <param name="ctx">current http context</param>
	/// <param name="action">fully-qualified action name</param>
	/// <param name="ex">exception that was raised</param>
	private void OnActionException(HttpContext ctx, string action, Exception ex)
	{
		// Here we just write formatted exception details into the response...
		Exception e = ex.InnerException ?? ex;
		StackFrame frame = new StackTrace(e, true).GetFrame(0);

		string source, fileName = frame.GetFileName();
		if(fileName == null)
			source = "Not Available";
		else
			source = String.Format("{0}, <b>Line {1}</b>", fileName, frame.GetFileLineNumber());

		ctx.Response.Write(String.Format("<h3>Exception was raised while calling an action</h3><ul><li><b>Action:</b> {0}</li><li><b>Source:</b> {1}</li><li><b>Message:</b> <span style=\"color:Red;\">{2}</span></li></ul>", action, source, e.Message));
	}
}

首先,它包含一个 SimpleRouter 类型的对象——这个类实现了我们的路由。 

private static SimpleRouter router = null; 

对象在构造函数内部创建和初始化。 

  1. 创建对象,并告知它根据我们的处理程序设置来激活对可重用控制器的访问; 
  2. 注册我们控制器类所在的所有命名空间; 
  3. 可选:注册一个处理程序,用于捕获操作可能抛出的任何异常。 

最有趣的部分是 ProcessRequest 方法。 

public void ProcessRequest(HttpContext ctx)
{
	if (!router.InvokeAction(ctx)) // If failed to map the request to controller/action;
		router.InvokeAction(ctx, "error", "details"); // Forward to our error handler;
}

该方法首先调用 InvokeAction 来查找与请求对应的控制器/操作,如果找到,则调用该操作。如果失败,则调用我们为处理错误而创建的控制器的操作。 

控制器  

我在 Controllers.cs 文件中创建了一些演示控制器,用于测试并展示您在定义操作参数方面具有极大的灵活性。下面是这些示例。 

/// <summary>
/// Simplest controller example.
/// </summary>
public class SimpleController : BaseController
{
	/// <example>
	/// /simple/time
	/// </example>
	public void Time()
	{
		Write(DateTime.Now.ToString("MMM dd, yyyy; HH:mm:ss.fff"));
	}

	/// <example>
	/// /simple/birthday?name=John&age=25
	/// </example>
	public void Birthday(string name, int age)
	{
		Write(String.Format("<h1>Happy {0}, dear {1}! ;)</h1>", age, name));
	}
	
	/// <example>
	/// /simple/exception?msg=exception message demo
	/// </example>
	public void Exception(string msg)
	{
		throw new Exception(msg);
	}

	/// <example>
	/// /one/two/three/simple/prefix
	/// - prefix will be {"one", "two", "three"}
	/// </example>
	public void Prefix()
	{
		string s = String.Format("{0} segments in the request prefix:<ol>", prefix.Length);
		foreach (string p in prefix)
			s += String.Format("<li>{0}</li>", p);
		Write(s + "</ol>");
	}
}

/// <summary>
/// Demonstrates use of arrays and default parameters.
/// </summary>
public class ListController : BaseController
{
	/// <example>
	/// /list/sum?values=1,2,3,4,5
	/// </example>
	public void Sum(int [] values)
	{
		int total = 0;
		string s = "";
		foreach (int i in values)
		{
			if (!string.IsNullOrEmpty(s))
				s += " + ";
			s += i.ToString();
			total += i;
		}
		s += " = " + total.ToString();
		Write(s);
	}

	/// <summary>
	/// Outputs the sum of all double values, with optional description.
	/// </summary>
	/// <example>
	/// /list/add?values=1.05,2.17,...[&units=dollars]
	/// </example>
	public void Add(double [] values, string units = null)
	{
		double total = 0;
		foreach (double d in values)
			total += d;
		Write(String.Format("Total: {0} {1}", total, units));
	}

	/// <summary>
	/// Spits out the array of passed strings into a paragraph,
	/// optionally changing the color.
	/// </summary>
	/// <example>
	/// /list/text?values=one,two,three,...[&color=Red]
	/// </example>
	public void Text(string[] values, string color = "Green")
	{
		string result = String.Format("<p style=\"color:{0};\">", color);
		foreach(string s in values)
			result += s + "<br/>";
		result += "</p>";
		Write(result);
	}

	/// <summary>
	/// Shows that we can pass an array of mixed types.
	/// </summary>
	/// <example>
	/// /list/any?values=1,two,-3.45
	/// </example>
	public void Any(object[] values, string desc = null)
	{
		string s = (desc ?? "") + "<ol>";
		foreach (object obj in values)
			s += "<li>" + obj.ToString() + "</li>";
		Write(s + "</ol>");
	}
}

/// <summary>
/// Shows how to quickly and efficiently render an image file.
/// </summary>
public class ImageController : BaseController
{
	/// <summary>
	/// Returns a cached image.
	/// </summary>
	public void Diagram()
	{
		if (image == null)
			image = FileToByteArray(ctx.Server.MapPath("~/Routing.jpg"));

		ctx.Response.ContentType = "image/jpeg";
		ctx.Response.BinaryWrite(image);
	}

	/// <summary>
	/// Simplest and quickest way for reading entire file,
	/// and returning its content as array of bytes.
	/// </summary>
	private static byte[] FileToByteArray(string fileName)
	{
		FileStream fs = new FileStream(fileName, FileMode.Open, FileAccess.Read);
		long nBytes = new FileInfo(fileName).Length;
		return new BinaryReader(fs).ReadBytes((int)nBytes);
	}

	private byte[] image = null; // Cached image;
}

/// <summary>
/// Controller for error handling;
/// </summary>
public class ErrorController:BaseController
{
	/// <summary>
	/// Shows formatted details about the request.
	/// </summary>
	public void Details()
	{
		string path = GetQueryPath();
		if (string.IsNullOrEmpty(path))
			path = "<span style=\"color:Red;\">Empty</span>";

		string msg = String.Format("<p>Failed to process request: <b>{0}</b></p>", path);
		msg += "<p>Passed Parameters:";
		if (ctx.Request.QueryString.Count > 0)
		{
			msg += "</p><ol>";
			foreach (string s in ctx.Request.QueryString)
				msg += String.Format("<li>{0} = {1}</li>", s, ctx.Request.QueryString[s]);
			msg += "</ol>";
		}
		else
			msg += " <b>None</b></p>";

		Write(msg);
	}
}

此外,值得一提的是,根据要求 4,路由的实现方式是只使用公共类和公共非静态操作。 

测试代码 

查看代码运行最简单的方法是运行随本文附带的演示应用程序。 

如果您想在本地 IIS 上进行设置,但不确定如何操作,以下步骤将有所帮助。 

  1. 构建演示项目或解压其二进制版本。 
  2. IIS7 中,添加一个新网站或新应用程序,指定项目文件夹的物理路径(Web.config 所在的位置)。同时,将其应用程序池设置为ASP.NET v4.0。 
  3. 选择您的网站,然后从上下文菜单->管理网站->浏览中,查看它是否打开。默认页面应该打开,并显示我们的错误图像,这是符合设计要求的,因为我们不支持未指定的控制器/操作。 
  4. 以管理员模式启动Powershell,并在其中输入 inetsrv/appcmd list wp,以获取当前在IIS中运行的所有应用程序池的 Id。 
  5. VS-2010 中,打开项目并选择菜单调试->附加到进程,确保将显示所有会话中的进程设置为 ON。找到具有您在Powershell中看到的 ID 的进程,然后单击附加。 
  6. 现在,如果您设置一个断点并从浏览器发送请求,您将能够调试代码。 

为了进一步简化代码的测试,我已将其发布到我的一个托管帐户上。虽然我知道这可能不会永久保留,但至少在接下来的几个月里,它可以为一些人节省在线测试的时间。也许将来我会提供更永久的托管,并在此更新链接。 

根据我们源代码中的控制器,请尝试以下示例:  

  1. /simple/time,显示当前日期/时间
  2. /simple/birthday?name=John&age=25,显示格式化结果
  3. /simple/exception?msg=some exception text,操作抛出异常,处理程序捕获它
  4. /one/two/three/four/five/simple/prefix,显示前缀段如何被移除
  5. /list/sum?values=1,2,3,4,5,显示值之和(数组用法)
  6. /list/add?values=1.03,2.17&units=Dollars,显示格式化值(可选参数用法)
  7. /list/text?values=first,second,third,显示字符串数组
  8. /list/any?values=one,2,-3.4&desc=mixed parameters,显示混合类型参数数组
  9. /image/diagram,显示图像
我们的演示控制器还可以识别许多其他方式的请求——请参见源代码。 

自定义类型 

一种支持自定义类型对象的方法是使用 object[] 类型作为操作参数,如以下示例所示: 

/// <summary>
/// Shows that we can pass an array of mixed types.
/// </summary>
/// <example>
/// /list/any?values=1,two,-3.45
/// </example>
public void Any(object[] values, string desc = null)
{
    string s = (desc ?? "") + "<ol>";
    foreach (object obj in values)
        s += "<li>" + obj.ToString() + "</li>";
    Write(s + "</ol>");
}

如果您知道参数 values 代表的自定义类型,那么您可以轻松地初始化其属性。然而,在上面的例子中,我们只是将所有传入的值写入响应。 

这种方法也适用于仅将混合数据类型的数组作为参数传递。 

URL 过滤器

在附加的演示中,我使用了以下客户端配置设置: 

<system.web>
  <httpHandlers>
    <add verb="*" path="data/*/*" type="TestServer.SimpleHandler, TestServer" />
  </httpHandlers>
</system.web>

这是因为客户端是一个 UI 应用程序,它还需要返回 HTML、CSS、JS 和图像等文件。因此,为了避免处理这些文件,我们使用前缀“/data”将其从 controller/action 的请求中过滤掉。然而,这给 UI 演示本身带来了一个限制,因此它只能理解以 /data/controller/action 形式发出的请求,并且无法显示前缀段的用法。 

您可能会问,如果我想在我的 HttpHandler 中处理所有请求,而不仅仅是 controller/action 的请求呢?我的 HttpHandler 要获得对响应的完全控制需要什么? 

答案是——您需要能够处理任何文件请求,然后返回该文件的内容,并避免将其与 controller/action 混合。为了在一个简单的场景中展示这一点,我在 TestServer 项目中包含了一个类 HttpFileCache。所以,让我们对演示应用程序做一些小的改动,看看它是如何工作的。 

首先,我们修改 TestClient 项目中的 Web.config 文件,让我们的 HttpHandler 捕获所有传入的请求,如下所示: 

<system.web>
  <httpHandlers>
    <add verb="*" path="*" type="TestServer.SimpleHandler, TestServer" />
  </httpHandlers>
</system.web>

现在,我们在 HttpHandler 类中添加对处理文件的支持,如下所示: 

public class SimpleHandler : IHttpHandler
{
	// use this to handle file requests:
	HttpFileCache cache = new HttpFileCache();
	
	// ...the rest of the class;
}

然后我们修改 ProcessRequest 方法: 

public void ProcessRequest(HttpContext ctx)
{
	if (cache.ProcessFileRequest(ctx) == ProcessFileResult.Sucess)
		return;

	if (!router.InvokeAction(ctx)) // If failed to map the request to controller/action;
		router.InvokeAction(ctx, "error", "details"); // Forward to our error handler;
}

现在,如果收到文件请求,它将由 HttpFileCache 类处理;如果不是现有文件的请求,那么我们尝试将请求映射到 controller/action。这个解决方案为我们处理请求提供了完全的灵活性,包括在 UI 应用程序或任何需要返回文件并处理 controller/action 请求的 Web 应用程序中使用前缀段。 

需要注意的是,我们主要使用了 HttpFileCache 类,因为返回的文件必须被缓存,否则会严重影响性能。而 HttpFileCache 类为文件缓存提供了一个非常简单的实现,它不允许处理大文件的请求,例如上传文件(参见 HttpFileCache 的声明),即处理大文件需要额外的工作来返回部分内容。 

实现

SimpleRouter 类文档详细,逻辑也很简单,所以我认为在此处完整重发代码是不必要的。我只列出一些我认为最有趣或最具挑战性的实现方面,其余的——请查看 SimpleRouter.cs 文件——都在那里,而且并不多。 

查找类及其方法的方式非常通用,没有什么特别的,只是使用 Type.GetType 方法查找正确的类,以及使用 Type.GetMethod 方法查找操作方法。 

当启用可重用控制器时,我使用了一个非常简单的缓存实现来存储控制器并在需要时再次从中提取。 

也许整个实现中最复杂的部分是准备需要传递给操作的参数数组。这都在下面显示的方法中完成: 

/// <summary>
/// Prepares parameters to be passed to an action method.
/// </summary>
/// <remarks>
/// For each action parameter in 'pi' tries to locate corresponding value in 'nvc',
/// adjusts the type, if needed, pack it all and return as array of objects.
/// </remarks>
/// <param name="pi">Parameters of the target action</param>
/// <param name="nvc">Parameters from the URL request</param>
/// <returns>Parameters for the target action, or null, if failed</returns>
private object[] PrepareActionParameters(ParameterInfo[] pi, NameValueCollection nvc)
{
    List<object> parameters = new List<object>(); // resulting array;
    foreach (ParameterInfo p in pi)
    {
        object obj = nvc.Get(p.Name); // Get URL parameter;
        if (string.IsNullOrEmpty((string)obj))
        {
            if (!p.IsOptional)
                return null; // failed;

            parameters.Add(p.DefaultValue); // Use default value;
            continue;
        }
        if (p.ParameterType != typeof(string))
        {
            // Expected parameter's type isn't just a string;
            // Try to convert the type into the expected one.
            try
            {
                if (p.ParameterType.IsArray)
                {
                    // For parameter-array we try splitting values
                    // using a separator symbol:
                    string[] str = ((string)obj).Split(arraySeparator);
                    Type baseType = p.ParameterType.GetElementType();
                    Array arr = Array.CreateInstance(baseType, str.Length);
                    int idx = 0;
                    foreach (string s in str)
                        arr.SetValue(Convert.ChangeType(s, baseType), idx++);
                    obj = arr;
                }
                else
                {
                    Type t = p.ParameterType;
                    if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>)) // If parameter is nullable;
                        t = Nullable.GetUnderlyingType(t); // Use the underlying type instead;

                    obj = Convert.ChangeType(obj, t); // Convert into expected type;
                }
            }
            catch (Exception)
            {
                // Failed to map passed value(s) to the action parameter,
                // and it is not terribly important why exactly.
                return null; // fail;
            }
        }
        parameters.Add(obj);
    }
    return parameters.ToArray(); // Success, returning the array;
}
这真是一次很好的练习,试图处理需求中列出的所有情况。一点也不简单,要弄清楚如何正确初始化参数值——数组(我看到很多人在这上面卡住了),然后处理可空参数。 

最终,重要的是,这一切都很通用,所有的类型转换都是通过 Convert.ChangeType 方法完成的,也就是说,我没有处理任何特定类型,而且在这方面非常优雅。  

基准测试和结论

此类库只能在本地 PC 上进行基准测试,因为在任何 Web 主机上测试它只会反映托管服务器的性能,而不是库本身的性能。 

我们主要感兴趣的是,从请求到达 ProcessRequest 方法到缓存控制器上的相应操作开始执行之间平均花费的时间。换句话说,将平均 URL 请求路由到缓存控制器需要多长时间。 

为此,我使用了一个库的修改版本,其中包含多个用于报告执行延迟的注入,这些是结果... 

路由和调用的时长稳定在 1*10-7 秒以下,即小于 100 纳秒。我看到的唯一随机减速,我会归因于内部垃圾回收或 PC 上的某些资源分配,在此期间时间可能会短暂跳至 1 毫秒,但这些并不真正重要。   

整个库最终只有 12KB 的 DLL,只引用了以下核心 DLL:   

  1. Microsoft.CSharp
  2. 系统
  3. System.Core
  4. System.Web
这是我们能为程序集达到的最小占用空间,考虑到库的性能,这个结果看起来相当值得。 

关注点

在实现这个演示的过程中,我发现了一些有趣的事情,它们在“实现”章节中有详细介绍。 

历史 

  • 2012 年 5 月 8 日。库的第一个版本,以及文章的初稿。   
  • 2012 年 5 月 8 日。库的第二次修订:在 BaseController 中添加了对前缀段的支持,并改进了其文档。  
  • 2012 年 5 月 9 日。改进了文章格式,添加了目录表,以及更多细节。 
  • 2012 年 5 月 9 日。添加了更多关于支持混合类型数组的细节。 
  • 2012 年 5 月 10 日。代码和文档的小改进。 
  • 2010 年 5 月 11 日。增加了对多个程序集的支持。因此,不得不将库放在自己的程序集中,以简化其重用并实现跨程序集的使用。 
  • 2012 年 5 月 15 日。对演示源代码和文档进行了重大更改,如下所示。 
    1. SimpleHandler.arraySeparator 属性设为公共,并在其声明中添加了许多有用的细节。 
    2. 添加了 SimpleHandler.CleanSegment 方法来准备任何段以供进一步处理。因此,现在允许传递带有空格的请求,这些空格将被自动剥离。但最重要的是,使用混合大小写字母的请求不再会导致为每种大小写字母创建单独的控制器实例。 
    3. 添加了一个全面的演示——一个可以从 Visual Studio 运行的简单 Web 应用程序,展示了所有内容的工作原理。该演示还包括一个简单的基准测试,用于测量通过 SimpleRouter 的 Ajax 请求的速度。 
  • 2012 年 5 月 20 日。对库进行了重大更改。事实上,当我完成更改后,我开始更新文章,才意识到现在需要完全重写,我希望在不久的将来能找到时间。在此期间,我在下面列出了大部分更改,并建议任何想使用的人更多地参考随附的源代码,因为它非常简单且最新。如果您有任何问题,我很乐意在评论区回答。谢谢! 
    1. 增加了对同一控制器名称但在不同命名空间甚至不同程序集中的支持。 
    2. 增加了对自定义命名空间使用的支持,因此可以根据前缀段或其他您喜欢的任何内容覆盖某个命名空间。请参阅 OnValidatePrefix 方法。 
    3. 增加了对前缀段的验证(OnValidatePrefix 方法),因此可以根据前缀内容选择自定义操作,甚至可以根据需要覆盖前缀本身。 
    4. 增加了对显式操作调用的支持,当指定了控制器和操作的名称时。在某些情况下,例如转发调用给已知控制器/操作(如发生错误时),这变得必要。 
    5. 增加了对超时支持,以过期长时间未使用的控制器(SetTimeout 方法)。 
    6. 对演示控制器进行了一系列更改。 
    7. 更改了控制器的缓存逻辑,因此对于它实现的任何操作,只创建一个控制器。 
    8. 在整个库中增加了线程安全。 
  • 2012 年 6 月 10 日。添加了 URL 过滤章节,该章节解释了: 
    1. 如何在演示应用程序中使用过滤器以及为什么使用,以及如何更改它们以处理任何请求。 
    2. 为什么将 HttpFileCache 类包含在 ServerTest 项目中,以及如何以及何时使用它。 
© . All rights reserved.