您好,登录后才能下订单哦!
这篇文章主要介绍“怎么用ASP.NET写服务框架”,在日常操作中,相信很多人在怎么用ASP.NET写服务框架问题上存在疑惑,小编查阅了各式资料,整理出简单好用的操作方法,希望对大家解答”怎么用ASP.NET写服务框架”的疑惑有所帮助!接下来,请跟着小编一起来学习吧!
首先我要谈的话题是ASP.NET的请求处理【管线】,我认为这是ASP.NET中最重要的内容了,所有到达ASP.NET的请求都要经过管线来处理,不管是WebForms, MVC, WebService, WCF(ASP.NET的承载方式),还是其它微软的采用HTTP协议的框架。为什么这些框架都选择要ASP.NET做为它们的运行平台呢?
我们可以考虑一下:如果让您从无到有设计一个服务框架,有哪些事件是必须要处理的?
我想有三个最根本的事件要做:1. 监听请求端口,2. 为每个传入的连接请求分配线程来执行具体的响应操作,3. 要把请求的数据读出来,并负责将处理后的响应数据发送给调用者。
这其实是个比较复杂也很枯燥的过程,但每个服务器端程序都需要这些基本功能。幸好IIS和ASP.NET可以为我们做好这些事情,所以那些框架选择ASP.NET平台就可以省去这些复杂的任务。使用ASP.NET平台不仅可以简化设计,它还有着良好的扩展性以满足更多的框架在这个平台上面继续开发,而这个良好扩展性是离不开它的请求处理管线的。
ASP.NET是一个功能完善的平台框架,它既提供一些高层次的框架供我们使用,比如:WebForms, MVC, WebService,也提供一些低层次的机制让我们使用,以便于让我们开发有特殊要求的新框架,新解决方案。这个低层次的机制就是请求处理管线,使用这个管线的有二类对象:HttpHandler, HttpModule,控制这条管线工作的对象是:HttpApplication 。通常情况下,我们并不需要直接使用HttpApplication对象,因此本文的主题将主要介绍HttpHandler, HttpModule这二类对象的功能以及如何使用它们。
理解ASP.NET管线
管线(Pipeline)这个词也是很有点意思,这个词也形象地说明了每个ASP.NET请求的处理过程:请求是在一个管道中,要经过一系列的过程点,这些过程点连接起来也就形成一条线。以上是我对于这个词的理解,如果有误,恳请给予指正。这些一系列的过程点,其实就是由HttpApplication引发的一系列事件,通常可以由HttpModule来订阅,也可以在Global.asax中订阅,这一系列的事件也就构成了一次请求的生命周期。
事件模式,也就是观察者模式。根据【C# 3.0 设计模式】一书中的定义:“观察者模式定义了对象之间的一种联系,使得当一个对象改变状态时,所有其它的对象都可以相应地被通知到。" ASP.NET的管线设计正是采用了这种方式,在这个设计模式中,观察者就是许多HttpModule对象,被观察的对象就是每个”请求“,它的状态是由HttpApplication控制,用于描述当前请求的处理阶段,HttpApplication会根据一个特定的顺序修改这个状态,并在每个状态改变后引发相应的事件。 ASP.NET会为每个请求分配一个HttpApplication对象来引发这些事件,因此可以让一大批观察者了解每个请求的状态,每个观察者也可以在感兴趣的时候修改请求的一些数据。这些与请求相关的数据的也就是我上篇博客中提到的HttpRequest, HttpResponse。正是由于引入了事件机制,ASP.NET框架也有了极强的扩展能力。再来看看管线处理请求的过程,我将直接引用MSDN中的原文【IIS 5.0 和 6.0 的 ASP.NET 应用程序生命周期概述】中的片段。
在处理该请求时将由 HttpApplication 类执行以下事件。 希望扩展 HttpApplication 类的开发人员尤其需要注意这些事件。
1. 对请求进行验证,将检查浏览器发送的信息,并确定其是否包含潜在恶意标记。 有关更多信息,请参见 ValidateRequest 和脚本侵入概述。
2. 如果已在 Web.config 文件的 UrlMappingsSection 节中配置了任何 URL,则执行 URL 映射。
3. 引发 BeginRequest 事件。
4. 引发 AuthenticateRequest 事件。
5. 引发 PostAuthenticateRequest 事件。
6. 引发 AuthorizeRequest 事件。
7. 引发 PostAuthorizeRequest 事件。
8. 引发 ResolveRequestCache 事件。
9. 引发 PostResolveRequestCache 事件。
10. 根据所请求资源的文件扩展名(在应用程序的配置文件中映射),选择实现 IHttpHandler 的类,对请求进行处理。 如果该请求针对从 Page 类派生的对象(页),并且需要对该页进行编译,则 ASP.NET 会在创建该页的实例之前对其进行编译。
11. 引发 PostMapRequestHandler 事件。
12. 引发 AcquireRequestState 事件。
13. 引发 PostAcquireRequestState 事件。
14. 引发 PreRequestHandlerExecute 事件。
15. 为该请求调用合适的 IHttpHandler 类的 ProcessRequest 方法(或异步版 IHttpAsyncHandler.BeginProcessRequest)。 例如,如果该请求针对某页,则当前的页实例将处理该请求。
16. 引发 PostRequestHandlerExecute 事件。
17. 引发 ReleaseRequestState 事件。
18. 引发 PostReleaseRequestState 事件。
19. 如果定义了 Filter 属性,则执行响应筛选。
20. 引发 UpdateRequestCache 事件。
21. 引发 PostUpdateRequestCache 事件。
22. 引发 EndRequest 事件。
23. 引发 PreSendRequestHeaders 事件。
24. 引发 PreSendRequestContent 事件。
如果是IIS7,第10个事件也就是MapRequestHandler事件,而且在EndRequest 事件前,还增加了另二个事件:LogRequest 和 PostLogRequest 事件。
只有当应用程序在 IIS 7.0 集成模式下运行,并且与 .NET Framework 3.0 或更高版本一起运行时,才会支持 MapRequestHandler、LogRequest 和 PostLogRequest 事件。
这里要补充一下:从BeginRequest开始的事件,并不是每个事件都会被触发,因为在整个处理过程中,随时可以调用Response.End()或者有未处理的异常发生而提前结束整个过程。在那些"知名"的事件中,也只有EndRequest事件是肯定会触发的,(部分Module的)BeginRequest有可能也不会被触发。
对于这些管线事件,我只想提醒2个非常重要的地方:
1. 每个请求都将会映射到一个HttpHandler,通常也是处理请求的主要对象。
2. HttpModule可以任意订阅这些事件,在事件处理器中也可以参与修改请求的操作。
这2点也决定了HttpHandler和HttpModule的工作方式。
我找了二张【老图片】,希望能更直观的说明ASP.NET管线的处理过程。结合我前面讲述的内容,再品味一下老图片吧。
HttpHandler
HttpHandler通常是处理请求的核心对象。绝大多数的的请求都在【第10步】被映射到一个HttpHandler,然后在【第15步】中执行处理过程,因此也常把这类对象称为处理器或者处理程序。我们熟知的Page就是一个处理器,一个ashx文件也是一个处理器,不过ashx显示得更原始,我们还是来看一下ashx通常是个什么样子:
<%@ WebHandler Language="C#" Class="Login" %> using System; using System.Web; public class Login : IHttpHandler { public void ProcessRequest (HttpContext context) { context.Response.ContentType = "text/plain"; string username = context.Request.Form["name"]; string password = context.Request.Form["password"]; if( password == "aaaa" ) { System.Web.Security.FormsAuthentication.SetAuthCookie(username, false); context.Response.Write("OK"); } else { context.Response.Write("用户名或密码不正确。"); } } public bool IsReusable { get { return false; } } }
可以看到它仅仅是实现一个IHttpHandler接口而已,IHttpHandler接口也很简单:
// 定义 ASP.NET 为使用自定义 HTTP 处理程序同步处理 HTTP Web 请求而实现的协定。 public interface IHttpHandler { // 获取一个值,该值指示其他请求是否可以使用 System.Web.IHttpHandler 实例。 // // 返回结果: // 如果 System.Web.IHttpHandler 实例可再次使用,则为 true;否则为 false。 bool IsReusable { get;} // 通过实现 System.Web.IHttpHandler 接口的自定义 HttpHandler 启用 HTTP Web 请求的处理。 void ProcessRequest(HttpContext context); }
IsReusable属性上面有注释,我就不说了。接口中最重要的部分就是方法 void ProcessRequest(HttpContext context);这个方法简单地不能再简单,只有一个参数,但这个参数的能量可不小,有了它几乎就有了一切,这就是我对它的评价。关于HttpContext的更多详细介绍请参考我的博客【我心目中的ASP.NET核心对象】。
在Login.ashx中,我做了三简单的事:
1. 读取输入数据: 从Request.Form中。
2. 执行特定的业务逻辑: 一个简单的判断。
3. 返回结果给客户端: 调用Response.Write()
是的,就是这三个简单的操作,但也是绝大多数ashx文件的常规写法,它的确可以完成一次请求的处理过程。
记住:事实上任何HttpHandler都是这样处理请求的,只是有时会借助一些框架的包装而变了味道而已。
我认为:HttpHandler的强大离不开HttpContext,HttpHandler的重要性是因为管线会将每个请求都映射到一个HttpHandler。
通常,我们需要新的HttpHandler,创建一个ashx文件就可以了。但也可以创建自己的HttpHandler,或者要将一类【特殊的路径/扩展名】交给某个处理器来处理,那么就需要我们在web.config中注册那个处理器。
注意:如果是【特殊的扩展名】可能还需要在IIS中注册,原因很简单:IIS不将请求交给ASP.NET,我们的代码根本没机会运行!
我们可以采用以下方式在web.config中注册一个自定义的处理器:
<httpHandlers> <add path="/MyService.axd" verb="*" validate="false" type="MySimpleServiceFramework.MyServiceHandler"/> </httpHandlers>
或者:(为了排版,我将一些代码做了换行处理)
<httpHandlers> <remove verb="*" path="*.cs"/> <add verb="*" path="*.cs" validate="false" type="FishWebLib.Ajax.AjaxMethodV2Handler, FishWebLib, Version=3.0.0.0, Culture=neutral, PublicKeyToken=04db02423b9ebbb2"/>FishWebLib, Version=3.0.0.0, Culture=neutral, PublicKeyToken=04db02423b9ebbb2"/> <remove verb="*" path="*.ascx"/> <add verb="*" path="*.ascx" validate="false" type="FishWebLib.Ajax.UserControlHandler, FishWebLib, Version=3.0.0.0, Culture=neutral, PublicKeyToken=04db02423b9ebbb2"/>FishWebLib, Version=3.0.0.0, Culture=neutral, PublicKeyToken=04db02423b9ebbb2"/> </httpHandlers>
HttpModule
前面我已经提到过HttpModule的工作方式:订阅管线事件,并在事件处理器中执行所需的相关操作。
这个描述看起来很平淡,但是,它的工作方式给了它无限强大的处理能力。
它的无限强大的处理能力来源于可以订阅管线事件,因此,它有能力可以在许多阶段修改请求,这些修改最终可能会影响请求的处理。
前面我说过:“HttpHandler是处理请求的主要对象”,但HttpModule却可以随意指定将某个请求交给某个处理器来执行!
甚至,HttpModule也可以直接处理请求,完全不给HttpHandler工作的机会!
我们来看一下HttpModule的实现方式:
/// <summary> /// 能支持双向GZIP压缩的Module,它会根据客户端是否启用GZIP来自动处理。 /// 对于服务来说,不用关心GZIP处理,服务只要处理输入输出就可以了。 /// </summary> internal class DuplexGzipModule : IHttpModule { public void Init(HttpApplication app) { app.BeginRequest += new EventHandler(app_BeginRequest); } void app_BeginRequest(object sender, EventArgs e) { HttpApplication app = (HttpApplication)sender; // 注意:这里不能使用"Accept-Encoding"这个头,二者的意义完全不同。 if( app.Request.Headers["Content-Encoding"] == "gzip" ) { app.Request.Filter = new GZipStream(app.Request.Filter, CompressionMode.Decompress); app.Response.Filter = new GZipStream(app.Response.Filter, CompressionMode.Compress); app.Response.AppendHeader("Content-Encoding", "gzip"); } } public void Dispose() { } }
每个HttpModule只需要实现IHttpModule接口就可以了。IHttpModule也是个简单的接口:
public interface IHttpModule { void Dispose(); void Init(HttpApplication app); }
在这二个方法中,***个方法通常可以保持为空。最重要的方法就是Init,它给了HttpModule能订阅管线事件的机会,然后在相应的事件处理中,我们就可以执行它的具体操作了。
还记得我在博客【我心目中的ASP.NET核心对象】***给出一个示例吗?在QueryOrderService.ashx中,为了支持gzip,需要直接调用GZipStream类,对于一二个ashx来说,或许不是问题,如果这样的处理器变多了,每个处理器都那样写,您能受得了吗?反正我是受不了的,因此今天我把它改成使用Module来实现,代码简单了许多。在本文末尾可以下载。
对于ASP.NET项目来说:当您发现有很多处理输入输出的操作非常类似时,那正是HttpModule可以发挥的舞台,请把这些重复的操作交给它吧。
让HttpModule工作也需要在web.config中注册:
<httpModules> <add name="DuplexGzipModule" type="MySimpleServiceFramework.DuplexGzipModule"/> </httpModules>
通常,我会把一些HttpModule放在类库中实现,然后在需要使用的项目的web.config中注册。
这也体现它的高重用性:写一次,许多项目就可以直接使用。
HttpModule的加载方式:前面我说过“ASP.NET会为每个请求分配一个HttpApplication对象”,在每个HttpApplication对象的初始化操作中,它会加载所有在web.config中注册的HttpModule。由于ASP.NET并不是只创建一个HttpApplication对象,而是多个HttpApplication对象,因此每个HttpModule的Init事件是有可能被多次调用的。许多人喜欢在这里做各类初始化的操作,那么请注意在这里修改静态变量成员时的线程安全问题。特别地,如果要执行程序初始化的操作,那么还是把代码放在Global.asax的Application_Start中去处理吧, HttpModule的Init事件并不合适。
为HttpModule选择订阅合适的管线事件:这是非常重要的,订阅不同的事件,产生的结果也会不一样。原因也很简单,在ASP.NET运行环境中,并不只有一个HttpModule,某个HttpModule的判断可能要依据其它HttpModule的输出结果,而且在某些(晚期的)管线事件中,也不能再修改输出数据。在后面的示例中,DirectProcessRequestMoudle订阅了PostAuthorizeRequest事件,如果订阅BeginRequest事件或许将得到更好的性能,但是,在BeginRequest事件中,身份认证模块还没有工作,因此每个请求在这个事件阶段都属于“未登录”状态。
前面说了一大堆的HttpModule,事实上,在这个示例中,主角是另一个对象:Filter 。上篇博客我就提过它,***为了演示它,把它放在一个HttpHandler里【糟蹋了】,没办法,上篇的主题不是管线呀。今天只好和HttpModule一起出场了。我认为Filter还是应该和HttpModule一起使用才能发挥它的独特价值。Filter的特点还真不合适在HttpHandler中使用,如果您在HttpHandler里使用Filter,我认为有必要考虑一下是不是用错了。
借HttpModule的地盘我们来谈谈Filter。Filter很低调,低调到什么程度:可能很少有人关注过它,因此也少有人用过它。事实也确实如此,一般情况下可以不用它,但用到它,你会发现它非常强大。前面我经常说到【输入输出流】,请求的数据,除了请求头以外,基本上全放在流中,如果您希望对这些数据以流的方式进行处理,特别是希望对于所有请求,或者某类请求,那么使用Filter是非常恰当的。前面的示例就是一个非常合理地使用,好好地品味它,或许您还能发现Filter能做更多的事情。
选 HttpHandler 还是 HttpModule ?
HttpHandler是每个请求的主要处理对象,而HttpModule可以选择请求交给哪个HttpHandler来处理,甚至,它还可以选择它自己来处理请求。
下面我给个示例代码来说明HttpModule也能直接请求:
/// <summary> /// 此Module示范了直接使用Module也能处理客户端的请求。 /// 建议:除非要很好的理由,否则不建议使用这种方法。 /// </summary> internal class DirectProcessRequestMoudle : IHttpModule { public void Init(HttpApplication app) { app.PostAuthorizeRequest += new EventHandler(app_PostAuthorizeRequest); } void app_PostAuthorizeRequest(object sender, EventArgs e) { HttpApplication app = (HttpApplication)sender; ServiceInfo info = GetServiceInfo(app.Context); if( info == null ) return; ServiceExecutor.ProcessRequest(app.Context, info); app.Response.End(); }
为了更好的回答本节的这个问题,我再给段等效的代码,不过,请求是经过HttpHandler来处理
internal class MyServiceHandler : IHttpHandler { internal ServiceInfo ServiceInfo { get;set;} public void ProcessRequest(HttpContext context) { ServiceInfo info = this.ServiceInfo ?? GetServiceInfo(context); ServiceExecutor.ProcessRequest(context, info); }
HttpHandler和HttpModule都能处理请求,我该选哪个??
对于此类情况,我的答案是:视情况而定,正如我在注释中描述的那样,除非要很好的理由,否则不建议使用HttpModule处理请求。用HttpModule在某些时候可能会快点,关键点在于处理完成时要调用Response.End();这会让后面的事件全都短路,其它的HttpModule就没有机会执行。如果您的框架或者项目设计很依赖于管线中的事件处理,那么调用Response.End();无疑会破坏这个规则,也将会导致不能得到正确的结果。选择HttpHandler就不会有这种事情发生。
不过,也没有绝对的事情:在请求处理期间,您可以在任何地方调用Response.End(); 结果也是一样的。
幸好,短路的情况并不经常发生,因此选择HttpHandler会让整个ASP.NET的管线都能发挥作用,因此,我建议优先选择HttpHandler。
尤其是在HttpHandler能很好的完成工作的前提下,就应该选HttpHandler,因为选HttpModule会给其它请求带来不必要的性能损失,具体细节请继续阅读。
其实,我们还可以从另一个角度来看这个问题。
首先,请仔细地阅读前面的示例代码,您是否发现它们在实现方式上非常类似?
现在应该找到答案了吧:把具体的处理操作分离到HttpHandler,HttpModule之外的地方。那么,此时这个问题也就不是问题了,您也可以提供多种方案供使用者选择。比如:我就为【我的服务框架】提供了5种方式让使用者可以轻松地将一个C#方法公开为一个服务方法,该如何选择这个问题,由使用者来决定,这个问题不会让我为难。
我的观点:在没有太多技术难度的前提下,提供多种解决办法应该是对的,您将会避开很多麻烦事情。
看不见的性能问题
前面我介绍了HttpModule的重要优点:高重用性。只要写好一个HttpModule可以放在任何ASP.NET项目中使用,非常方便。
不过,再好的东西也不能滥用。HttpModule也可以对性能产生负面影响。原因也很简单:对于每个ASP.NET请求,每个HttpModule都会在它们所订阅的事件中,去执行一些操作逻辑。这些操作逻辑或许对一些请求是无意义的,但仍会执行。因此,计算机将会白白浪费一些资源去执行一些无意义的代码。
知道了原因,解决办法也就很清楚了:
1. 去掉不需要的HttpModule
2. 在每个HttpModule的事件处理器中,首先要确定是不是自己所需要处理的请求。对一些不相关的请求,应该立即退出。
在我们创建一个ASP.NET项目时,如果不做任何修改,微软已经为我们加载了好多HttpModule 。请看以下代码:
protected void Page_Load(object sender, EventArgs e) { HttpApplication app = HttpContext.Current.ApplicationInstance; StringBuilder sb = new StringBuilder(); foreach( string module in app.Modules.AllKeys ) sb.AppendFormat(module).Append("<br />"); Response.Write(sb.ToString()); }
输出结果如下:
总共有14个。
哎,大多数是我肯定不会用到的,但它们却被加载了,因此,在它们所订阅的事件中,它们的代码将会检查所有的请求,那些无意义的代码将有机会执行。如果您不想视而不见,那么请在web.config中做类似的修改,将不需要的Module移除。
<httpModules> <remove name="Session"/> <remove name="RoleManager"/> </httpModules>
HttpModule的第2个需要注意的地方是:HttpModule对所有的请求有效,如果HttpModule不能处理所有的请求,那么请先判断当前请求是否需要处理,对于不需要处理的请求,应该立即退出。请看以下示例代码:
/// <summary> /// 【演示用】让Aspx页的请求支持gzip压缩输出 /// </summary> public class FishGzipModule : IHttpModule { public void Init(HttpApplication app) { app.BeginRequest += new EventHandler(app_BeginRequest); } void app_BeginRequest(object sender, EventArgs e) { HttpApplication app = (HttpApplication)sender; // 这里做个简单的演示,只处理aspx页面的输出压缩。 // 当然了,IIS也提供压缩功能,这里也仅当演示用,或许可适用于一些特殊场合。 if( app.Request.AppRelativeCurrentExecutionFilePath.EndsWith( "aspx", StringComparison.OrdinalIgnoreCase) == false ) // 注意:先判断是不是要处理的请求,如果不是,直接退出。 // 而不是:先执行了后面的判断,再发现不是aspx时才退出。 return; string flag = app.Request.Headers["Accept-Encoding"]; if( string.IsNullOrEmpty(flag) == false &&flag.ToLower().IndexOf("gzip") >= 0 ) { app.Response.Filter = new GZipStream(app.Response.Filter, CompressionMode.Compress); app.Response.AppendHeader("Content-Encoding", "gzip"); } }
更多实战介绍
本文从这里起,将不再过多的叙述一些理论文字,而是将以实战的形式展示ASP.NET的强大管线功能,这些实战展示了一些很经典的应用场景,其中大部分示例代码将做为【我的服务框架】的关键部分。因此请注意理解这些代码。
实战代码大量使用了上篇博客【我心目中的ASP.NET核心对象】所介绍的绝大多数对象,也算是再次展示那些核心对象的重要性,因此请务必先了解那些核心对象。
上篇博客仅展示了那些强大对象的功能,单独使用它们,也是不现实的,今天,我将演示它们与HttpHandler, HttpModule一起并肩工作所能完成的各种任务。
故事未讲完,传奇在继续。更多精彩即将上演!
实战演示 - 模拟更多的HttpMethod
近几年又有一种被称为RESTful Web服务的概念进入开发人员的视野,它提倡使用HTTP协议提供的GET、POST、PUT和DELETE方法来操作网络资源。不过,目前的浏览器只支持GET、POST这二种方法,因此就有人想到采用HTTP头,表单值,或者查询字符串的形式来模拟这些浏览器不支持的HTTP方法。每种支持RESTful Web服务的框架都有它们自己的实现方式,今天我将使用HttpModule也来模拟这个操作。最终的结果是可以直接访问HttpRequest.HttpMethod获取这些操作的方法名字。
实现原理:订阅管线中的BeginRequest事件,检查当前请求是否需要修改HttpMethod,如果是,则修改HttpMethod属性。
所以选择BeginRequest这个事件,是因为这个事件比较早,可以让请求的后续阶段都能读到新的结果。
/// <summary> /// 【演示用】实现了模拟更多 HttpMethod 的Module /// </summary> internal class XHttpMethodModule : IHttpModule { private FieldInfo _field; public void Init(HttpApplication context) { // 订阅这个较早的事件,可以让请求的后续阶段都能读到新的结果。 context.BeginRequest += new EventHandler(context_BeginRequest); _field = typeof(HttpRequest).GetField("_httpMethod", BindingFlags.Instance | BindingFlags.NonPublic); } void context_BeginRequest(object sender, EventArgs e) { HttpApplication app = (HttpApplication)sender; // 这里仅检查是否为POST操作,如果您的应用中需要使用GET来模拟的,请修改这里。 if( string.Equals(app.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase) ) { // 这里为了简单,我只检查请求头,如果还需要检查表单值或者查询字符串,请修改这里。 string headerOverrideValue = app.Request.Headers["X-HTTP-Method-Override"]; if( string.IsNullOrEmpty(headerOverrideValue) == false ) { if( string.Equals(headerOverrideValue, "GET", StringComparison.OrdinalIgnoreCase) == false && string.Equals(headerOverrideValue, "POST", StringComparison.OrdinalIgnoreCase) == false ) { // HttpRequest.HttpMethod属性其实就是访问_httpMethod这个私有字段,我将直接修改它。 // 这样修改后,最原始的HTTP方法就丢失,通常这或许也是可以接受的。 _field.SetValue(app.Request, headerOverrideValue.ToUpper()); } } } }
我认为采用HttpModule来处理这个问题是个不错的选择。它至少有2个好处:
1. 这个HttpModule能继续给其它的网站项目使用,因此提高了代码的重用性。
2. 我可以随时决定要不要支持模拟,不需要模拟时,从web.config中不加载它就可以了,因此切换很灵活,且不需要修改现有代码。
来看一下页面及调用结果吧
protected void Page_Load(object sender, EventArgs e) { Response.Write(Request.HttpMethod); }
调用结果如下:
实战演示 - URL重写
使用HttpModule来实现URL重写。这个功能应该是HttpModule非常经典的应用了。
通常情况下,这种应用常用的方式是将一个URL: /product/12 重写为 /product.aspx?id=12 ,此时product.aspx应该是一个已经已存在的页面。显然重写后的地址更友好。URL重写的目的就是能让URL更友好。
实现原理:订阅管线的PostAuthorizeRequest事件,检查URL是不是期望修改的模式,如果是,则调用Context.RewritePath()完成URL的重写操作。在管线的后续处理中,最终会使用新的URL来映射到一个合适的HttpHandler。说明:选择的事件只要在【第10个事件】之前就可以了,因为在第10个事件前重写URL,才能保证到将请求映射到合适的处理器来执行。就这么简单,请参考以下代码:
public class MyServiceUrlRewriteModule : IHttpModule { // 为了演示简单,直接写死地址。 // 注意:MyService.axd 必须在web.config中注册,以保证它能成功映射。 public static string RewriteUrlPattern = "/MyService.axd?sc={1}&op={1}"; public void Init(HttpApplication app) { app.PostAuthorizeRequest += new EventHandler(app_PostAuthorizeRequest); } void app_PostAuthorizeRequest(object sender, EventArgs e) { HttpApplication app = (HttpApplication)sender; // 这里将检查URL是否为需要重写的模式,比如: // http://localhost:11647/service/OrderService/QueryOrder NamesPair pair = FrameworkRules.ParseNamesPair(app.Request); if( pair == null ) return; // 开始重写URL,***将会映射到MyServiceHandler int p = app.Request.Path.IndexOf('?'); if( p >0 ) app.Context.RewritePath(string.Format(RewriteUrlPattern, pair.ServiceName, pair.MethodName) + "&" + app.Request.Path.Substring(p + 1) ); else app.Context.RewritePath(string.Format(RewriteUrlPattern, pair.ServiceName, pair.MethodName)); }
重写发生了什么?
对于一个传入请求:http://localhost:11647/service/FormDemoService/ShowUrlInfo
它将被重写为:http://localhost:11647/MyService.axd?sc=FormDemoService&op=ShowUrlInfo
由于在web.config中,对MyService.axd已做过注册,因此ASP.NET会将请求转交给注册的处理器来处理它。
注意:URL重写,会影响某些变量的值。请参考以下代码,我将写个服务方法来检测这个现象:
[MyServiceMethod]
public string ShowUrlInfo(int a)
{
System.Web.HttpRequest request = System.Web.HttpContext.Current.Request;
System.Text.StringBuilder sb = new System.Text.StringBuilder();
sb.AppendFormat("Path: {0} ", request.Path);
sb.AppendFormat("RawUrl: {0} ", request.RawUrl);
sb.AppendFormat("Url.PathAndQuery: {0} ", request.Url.PathAndQuery);
return sb.ToString();
}
输出结果:
实战演示 - URL路由
使用HttpModule来实现URL路由。这个功能随着ASP.NET MVC框架的出现也逐渐流行起来了。
URL路由的目标也是为了使用URL更友好,与URL重写类似。
实现原理:订阅管线的PostResolveRequestCache事件,检查URL是不是期望的路由模式,如果是,则要根据请求中所包含的信息找到一个合适的处理器,并临时保存这个处理器,重写URL到一个ASP.NET能映射处理器的地址。在管线的PostMapRequestHandler中,检查前面有没有临时保存的处理器,如果有,则重新给Context.Handler赋值,并重写URL到原始地址。在管线的后续处理中,最终会使用Context.Handler的HttpHandler。就这么简单,请参考以下代码:
public class MyServiceUrlRoutingModule : IHttpModule
{
private static readonly object s_dataKey = new object();
public void Init(HttpApplication app)
{
app.PostResolveRequestCache += new EventHandler(app_PostResolveRequestCache);
app.PostMapRequestHandler += new EventHandler(app_PostMapRequestHandler);
}
private void app_PostResolveRequestCache(object sender, EventArgs e)
{
HttpApplication app = (HttpApplication)sender;
// 获取合适的处理器,注意这是与URL重写的根本差别。
// 即:根据当前请求【主动】寻找一个处理器,而不是使用RewritePath让ASP.NET替我们去找。
MyServiceHandler handler = GetHandler(app.Context);
if( handler == null )
return;
// 临时保存前面获取到的处理器,这个值将在PostMapRequestHandler事件中再取出来。
app.Context.Items[s_dataKey] = handler;
// 进入正常的MapRequestHandler事件,随便映射到一个处理器就行了。
app.Context.RewritePath("~/MyServiceUrlRoutingModule.axd");
}
private void app_PostMapRequestHandler(object sender, EventArgs e)
{
HttpApplication app = (HttpApplication)sender;
// 取出在PostResolveRequestCache事件中获得的处理器
MyServiceHandler handler = (MyServiceHandler)app.Context.Items[s_dataKey];
if( handler != null ) {
// 还原URL请求地址。注意这里和URL重写的差别。
app.Context.RewritePath(app.Request.RawUrl);
// 还原根据GetHandler(app.Context)调用得到的处理器。
// 因为此时app.Context.Handler是由"~/MyServiceUrlRoutingModule.axd"映射得到的。
app.Context.Handler = handler;
}
}
注意:在MyServiceUrlRoutingModule中,我将请求【路由】到一个MyServiceHandler的实例,而不是让ASP.NET根据URL来替我选择。
在URL重写的演示中,有些URL相关的属性发生了改变,我们再来看一下URL路由是个什么结果:
实现自己的服务框架
本篇博客在开头说过:将在本次博客中改进上次的服务实现,让它成为一个真正能用的服务框架。
前面在讲述ASP.NET管线时,给出了很多示例代码,这些示例代码都可以在博客的结尾处下载到。这些代码来源于【我的服务框架】中的部分源代码,下面我将重点介绍【我的服务框架】。
利用【我的服务框架】将类公开成服务
在【我的服务框架】中,一个类要想公开为服务类,并不需要继承某个类或者实现什么接口,只需要在类上加一个特性就好了,方法也只需加一个特性,示例代码如下:
[MyService]
public class OrderService
{
[MyServiceMethod]
public static string Hello(string name)
{
return "Hello " + name;
}
[MyServiceMethod]
public List<Order>QueryOrder(QueryOrderCondition query)
{
// 模拟查询过程,这里就直接返回一个列表。
List<Order>list = new List<Order>();
for( int i = 0;i <10;i++ )
list.Add(DataFactory.CreateRandomOrder());
return list;
}
public string HiddenMethod(string aa)
{
// 这个方法应该是不能以服务方式被调用到的。
throw new NotImplementedException();
}
}
如果某个方法需要只公开给登录用户或者指定的用户,还可以使用以下方式:
// 这是一个访问受限的服务类,只允许某些用户调用。
[Authorize]
[MyService]
public static class LimitService
{
[Authorize(Users="fish-li, cc")]
[MyServiceMethod]
public static string CalcPassword(string pwd)
{
// 这个方法只能由 fish-li, cc 二个用户来调用
if( pwd == null )
pwd = string.Empty;
byte[] buffer = (new MD5CryptoServiceProvider()).ComputeHash(Encoding.Default.GetBytes(pwd));
return BitConverter.ToString(buffer).Replace("-", "");
}
[MyServiceMethod]
public static string CalcBase64(string str)
{
// 这个方法只能由已登录用户调用。
if( string.IsNullOrEmpty(str) )
return string.Empty;
return Convert.ToBase64String(Encoding.UTF8.GetBytes(str));
}
}
就这么简单,一个类,就可以成为一个服务。
说明:本框架并不要求将服务类在网站项目中实现,完全可以放在类库中实现。
还可以支持Session哦。
[MyService(SessionMode=SessionMode.Support)]
public class SessionDemoService
{
[MyServiceMethod]
public int Add(int a)
{
// 一个累加的方法,检验是否可以访问Session
if( System.Web.HttpContext.Current.Session == null )
throw new InvalidOperationException("Session没有开启。");
object obj = System.Web.HttpContext.Current.Session["counter"];
int counter = (obj == null ? 0 : (int)obj);
counter += a;
System.Web.HttpContext.Current.Session["counter"] = counter;
return counter;
}
}
SessionMode的定义如下:
public enum SessionMode
{
NotSupport,
Support,
ReadOnly
}
【我的服务框架】支持的序列化的种类
在上篇博客中,我演示了使用JSON序列化的做法来实现一个服务响应。本来也是打算让框架仅支持JSON序列化的,因为传输的数据量小嘛。没想到,做到后来,还是认为有必要把XML序列化也加进来,XML序列化快呀。***,居然想到既然是服务框架,Ajax调用也能算是服务吧,总不能不支持吧,后来干脆也能支持部分的Ajax调用了。
在【我的服务框架】中,服务端判断客户端发送的数据序列化方式是通过判断请求头"Serializer-Format"来实现的。序列化的种类还允许继续自定义。只要实现以下接口:
public interface ISerializerProvider
{
object Deserialize(Type destType, HttpRequest request);
void Serializer(object obj, HttpResponse response);
}
然后调用以下方法就可以了:
public static class SerializerProviderFactory
{
public static void RegisterSerializerProvider(string name, Type type)
{
// ...................................
}
判断客户端的序列化方式,由属性FrameworkRules.GetSerializerFormat来决定:
public static class FrameworkRules
{
private static string Internal_GetSerializerFormat(HttpRequest request){
string flag = request.Headers["Serializer-Format"];
return (string.IsNullOrEmpty(flag) ? "form" : flag);
}
private static Func<HttpRequest, string>_serializerFormatRule = Internal_GetSerializerFormat;
/// <summary>
/// 此委托用来判断客户端发起的请求中,数据是以什么方式序列化的。
/// 返回的结果将会交给SerializerProviderFactory.GetSerializerProvider()来获取序列化提供者
/// 默认的实现是检查请求头:"Serializer-Format"
/// </summary>
public static Func<HttpRequest, string>GetSerializerFormat
{
internal get { return _serializerFormatRule; }
set
{
if( value == null )
throw new ArgumentNullException("value");
_serializerFormatRule = value;
}
}
只是一个委托,可以自己重新实现。
目前本框架提供了三个实现了接口ISerializerProvider的类供用户使用:JsonSerializerProvider, XmlSerializerProvider, FormSerializerProvider
这里只展示JsonSerializerProvider的实现:
internal class JsonSerializerProvider : ISerializerProvider
{
private static readonly MethodInfo s_JSSDeserializeMI
= typeof(JavaScriptSerializer).GetMethod("Deserialize");
JavaScriptSerializer jss = new JavaScriptSerializer();
public object Deserialize(Type destType, HttpRequest request)
{
StreamReader sr = new StreamReader(request.InputStream, request.ContentEncoding);
string input = sr.ReadToEnd();
MethodInfo deserialize = s_JSSDeserializeMI.MakeGenericMethod(destType);
return deserialize.Invoke(jss, new object[] { input });
}
public void Serializer(object obj, HttpResponse response)
{
if( obj == null )
return;
response.ContentType = "application/json";
response.Write(jss.Serialize(obj));
}
注意:FormSerializerProvider的实现不够完善,因为再搞下去,就和【我的WEB框架】就重复了。有兴趣的自己去完善吧。
这里再给自己的作品打个广告:
【ASP.NET MVC 框架,我也来山寨一下】, 【晒晒我的Ajax服务端框架】, 【我的Ajax服务端框架 - (1) JS直接调用C#方法】
【我的服务框架】对gzip的支持
对于gzip的支持,我只想说:太简单了。
前面不是已给出DuplexGzipModule的实现代码嘛。是的,就是把它注册到web.config中就可以了。
你说简不简单? 完全不用写多余的代码,要不要gzip支持,也只是个配置问题!
说到这里,我想起前段时间Artech写的一篇博客通过WCF扩展实现消息压缩,正如我在前篇博客的回复中说到的:“本来真没兴趣看的,不过,为了验证我的猜想,还是去看了一下,果然也没让我失望。”。
在此,有必要公开一下我的想法:绝对没有半点看不起Artech的意思,只是我对WCF没有兴趣了。理由也简单:不够简单。
还是接着说,Artech的博客展示了在WCF中压缩消息的方式,当然我相信Artech对于WCF的理解,他的方案或许应该是最简单的解决方案,但是和【我的服务框架】对gzip的支持的易用性根本没法比。
WCF的粉丝们,当您看到这里,请先别忙着喷我。听我说完:WCF的确很强大,我的这个不到700行的框架那也是根本不能和它相比的。
做这个比较仅仅是为了展示ASP.NET是一个强大的平台,ASP.NET有更高水准的扩展性。
利用【我的服务框架】发布服务的5种方式
【我的服务框架】可以提供5种不同的方式,让您将一个类及方法公开成一个服务,供外界调用。
方法1:使用DirectProcessRequestMoudle,只需要配置web.config即可。
<httpModules>
<add name="DirectProcessRequestMoudle" type="MySimpleServiceFramework.DirectProcessRequestMoudle"/>
</httpModules>
客户端调用URL:http://localhost:11647/service/OrderService/QueryOrder
说明:URL模式是可以自由定义的,只要给FrameworkRules.ParseNamesPair赋值即可,它的定义如下:
public static Func<HttpRequest, NamesPair>ParseNamesPair
默认的实现方式:
internal static class UrlPatternHelper
{
// 为了演示简单,我只定义一个URL模式。【因为我认为对于服务来说,一个就够了】
// 如果希望适用性更广,可以从配置文件中读取,并且可支持多组URL模式。
// URL中加了"/service/"只是为了能更好地区分其它请求,如果您的网站没有子目录,删除它也是可以的。
private static readonly string UrlPattern = @"/service/(?<name>[^/]+)/(?<method>[^/]+)[/?]?";
public static NamesPair ParseNamesPair(HttpRequest request)
{
if( request == null )
throw new ArgumentNullException("request");
MatchCollection matchs = Regex.Matches(request.Path, UrlPattern);
if( matchs.Count != 1 )
return null;
Match m = matchs[0];
return new NamesPair {
ServiceName = m.Result("${name}"),
MethodName = m.Result("${method}")
};
}
客户端调用URL: http://localhost:11647/service/OrderService/QueryOrder
方法2:使用MyServiceUrlRoutingModule,只需要配置web.config即可。
<httpModules>
<add name="MyServiceUrlRoutingModule" type="MySimpleServiceFramework.MyServiceUrlRoutingModule"/>
</httpModules>
客户端调用URL: http://localhost:11647/service/OrderService/QueryOrder
说明:只有这种方式才能支持Session
方法3:使用MyServiceUrlRewriteModule,只需要配置web.config即可。
<httpHandlers>
<add path="/MyService.axd" verb="*" validate="false" type="MySimpleServiceFramework.MyServiceHandler"/>
</httpHandlers>
<httpModules>
<add name="MyServiceUrlRewriteModule" type="MySimpleServiceFramework.MyServiceUrlRewriteModule"/>
</httpModules>
客户端调用URL: http://localhost:11647/service/OrderService/QueryOrder
方法4:使用MyServiceHandler,只需要配置web.config即可。
<httpHandlers>
<add path="/MyService.axd" verb="*" validate="false" type="MySimpleServiceFramework.MyServiceHandler"/>
</httpHandlers>
客户端调用URL: http://localhost:11647/MyService.axd?sc=OrderService&op=QueryOrder
方法5:创建一个ashx,不需要任何配置。
<%@ WebHandler Language="C#" Class="MyService" %>
using System;
using System.Web;
using MySimpleServiceFramework;
public class MyService : IHttpHandler {
public void ProcessRequest (HttpContext context) {
NamesPair pair = new NamesPair();
pair.ServiceName = context.Request.QueryString["sc"];
pair.MethodName = context.Request.QueryString["op"];
ServiceExecutor.ProcessRequest(context, pair);
}
public bool IsReusable {
get {
return false;
}
}
}
客户端调用URL: http://localhost:11647/MyService.ashx?sc=OrderService&op=QueryOrder
注意:前三种方法,需要在IIS中做些额外的配置,因为URL中不包含文件扩展名了,IIS不知道把请求交给ASP.NET来处理。
具体配置见下图,此处省略78个字。
我对发布服务的5种方式的建议
虽然,我给出了5种发布方式,但是我还是想说说我个人的想法。
在这些方法中,使用URL重写,URL路由的方法,并不是我想推荐的,写它们是主要是为了展示HttpModule 。不推荐它们是因为它们要判断URL是否符合指定模式,这个判断是有成本的。至于成本有多高,特此,我专了做门的测试。在示例代码压缩包中有个___TestRoutePerformance目录,结果如何,还是您自己去看吧,我也有点累了。
此外,我想问:对于服务来说,URL友好有多大意义?服务的URL会让用户来输入还是让Google的爬虫来访问?
如果以上二个问题都是否定的,那么,这二种方法就是在白白浪费机器的性能了。
当然了,如果您的站点访问量不大,那么这点性能也可以忽略不计了,就当我没说。
使用URL重写URL路由,还有个比较麻烦的事情:如果想通过URL多传递一个参数,那么,是不是又要修改URL模式?
对于使用DirectProcessRequestMoudle这种模式,我以前已经说过了:除非要很好的理由,否则不建议使用这种方法。
至于其它的二种方式,本质上是一样的,只是说:处理器谁来写的差别了。
不过,如果您要是选择手工创建一个处理器,除了不用修改web.config之外,还可以自定义URL参数名,可以选择要不要支持Session
【我的服务框架】的一些核心类
ReflectionHelper类用于根据类名及服务名定位到一个服务类型以及要调用的方法。
因此,它在框架中的作用也是非常关键的。
internal static class ReflectionHelper
{
private static List<TypeAndAttrInfo>s_typeList;
static ReflectionHelper()
{
InitServiceTypes();
}
/// <summary>
/// 加载所有的服务类型,判断方式就是检查类型是否有MyServiceAttribute
/// </summary>
private static void InitServiceTypes()
{
s_typeList = new List<TypeAndAttrInfo>(256);
ICollection assemblies = BuildManager.GetReferencedAssemblies();
foreach( Assembly assembly in assemblies ) {
try {
(from t in assembly.GetExportedTypes()
let a = (MyServiceAttribute[])t.GetCustomAttributes(typeof(MyServiceAttribute), false)
where a.Length >0
select new TypeAndAttrInfo {
ServiceType = t, Attr = a[0], AuthorizeAttr = t.GetClassAuthorizeAttribute() }
).ToList().ForEach(b => s_typeList.Add(b));
}
catch { }
}
}
private static AuthorizeAttribute GetClassAuthorizeAttribute(this Type t)
{
AuthorizeAttribute[] attrs = (AuthorizeAttribute[])t.GetCustomAttributes(typeof(AuthorizeAttribute), false);
return (attrs.Length >0 ? attrs[0] : null);
}
/// <summary>
/// 根据一个名称获取对应的服务类型(从缓存中获取类型)
/// </summary>
/// <param name="typeName"></param>
/// <returns></returns>
private static TypeAndAttrInfo GetServiceType(string typeName)
{
if( string.IsNullOrEmpty(typeName) )
throw new ArgumentNullException("typeName");
// 查找类型的方式:如果有点号,则按全名来查找(包含命名空间),否则只看名字。
// 本框架对于多个匹配条件的类型,将返回***个匹配项。
if( typeName.IndexOf('.') >0 )
return s_typeList.FirstOrDefault(t => string.Compare(t.ServiceType.FullName, typeName, true) == 0);
else
return s_typeList.FirstOrDefault(t => string.Compare(t.ServiceType.Name, typeName, true) == 0);
}
private static Hashtable s_methodTable = Hashtable.Synchronized(
new Hashtable(4096, StringComparer.OrdinalIgnoreCase));
/// <summary>
/// 根据指定的类型以及方法名称,获取对应的方法信息
/// </summary>
/// <param name="type"></param>
/// <param name="methodName"></param>
/// <returns></returns>
private static MethodAndAttrInfo GetServiceMethod(Type type, string methodName)
{
if( type == null )
throw new ArgumentNullException("type");
if( string.IsNullOrEmpty(methodName))
throw new ArgumentNullException("methodName");
// 首先尝试从缓存中读取
string key = methodName + "@" + type.FullName;
MethodAndAttrInfo mi = (MethodAndAttrInfo)s_methodTable[key];
if( mi == null ) {
// 注意:这里不考虑方法的重载。
MethodInfo method = type.GetMethod(methodName,
BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase);
if( method == null )
return null;
MyServiceMethodAttribute[] attrs = (MyServiceMethodAttribute[])
method.GetCustomAttributes(typeof(MyServiceMethodAttribute), false);
if( attrs.Length != 1 )
return null;
// 由于服务方法的参数来源于反序列化,此时只可能包含一个参数。
ParameterInfo[] paraInfos = method.GetParameters();
if( paraInfos.Length != 1 )
throw new ArgumentNullException("指定的方法虽找到,但该方法的参数数量不是1");
AuthorizeAttribute[] auths = (AuthorizeAttribute[])method.GetCustomAttributes(typeof(AuthorizeAttribute), false);
mi = new MethodAndAttrInfo {
MethodInfo = method,
ParamType = paraInfos[0].ParameterType,
Attr = attrs[0],
AuthorizeAttr = (auths.Length >0 ? auths[0] : null)
};
s_methodTable[key] = mi;
}
return mi;
}
/// <summary>
/// 根据类型名称以及方法名称返回要调用的相关信息
/// </summary>
/// <param name="pair">包含类型名称以及方法名称的对象</param>
/// <returns></returns>
public static InvokeInfo GetInvokeInfo(NamesPair pair)
{
if( pair == null )
throw new ArgumentNullException("pair");
InvokeInfo vkInfo = new InvokeInfo();
vkInfo.ServiceTypeInfo = GetServiceType(pair.ServiceName);
if( vkInfo.ServiceTypeInfo == null )
return null;
vkInfo.MethodAttrInfo = GetServiceMethod(vkInfo.ServiceTypeInfo.ServiceType, pair.MethodName);
if( vkInfo.MethodAttrInfo == null )
return null;
if( vkInfo.MethodAttrInfo.MethodInfo.IsStatic == false )
vkInfo.ServiceInstance = Activator.CreateInstance(vkInfo.ServiceTypeInfo.ServiceType);
return vkInfo;
}
}
ServiceExecutor用于调用服务方法,前面所说的5种服务发布方式,最终都要经过这里。
/// <summary>
/// 最终调用服务方法的工具类。
/// </summary>
public static class ServiceExecutor
{
internal static void ProcessRequest(HttpContext context, ServiceInfo info)
{
if( context == null )
throw new ArgumentNullException("context");
if( info == null || info.InvokeInfo == null )
throw new ArgumentNullException("info");
//if( context.Request.InputStream.Length == 0 )
// throw new InvalidDataException("没有调用数据,请将调用数据以请求体的方式传入。");
if( info.InvokeInfo.AuthenticateRequest(context) == false )
ExceptionHelper.Throw403Exception(context);
// 获取客户端的数据序列化格式。
// 默认实现方式:request.Headers["Serializer-Format"];
// 注意:这是我自定义的请求头名称,也可以不指定,默认为:form (表单)
string serializerFormat = FrameworkRules.GetSerializerFormat(context.Request);
ISerializerProvider serializerProvider =
SerializerProviderFactory.GetSerializerProvider(serializerFormat);
// 获取要调用方法的参数类型
Type destType = info.InvokeInfo.MethodAttrInfo.ParamType;
// 获取要调用的参数
context.Request.InputStream.Position = 0;// 防止其它Module读取过,但没有归位。
object param = serializerProvider.Deserialize(destType, context.Request);
// 调用服务方法
object result = info.InvokeInfo.MethodAttrInfo.MethodInfo.Invoke(
info.InvokeInfo.ServiceInstance, new object[] { param });
// 写输出结果
if( result != null )
serializerProvider.Serializer(result, context.Response);
}
/// <summary>
/// 【外部接口】用于根据服务的类名和方法名执行某个请求
/// </summary>
/// <param name="context"></param>
/// <param name="pair"></param>
public static void ProcessRequest(HttpContext context, NamesPair pair)
{
if( pair == null )
throw new ArgumentNullException("pair");
if( string.IsNullOrEmpty(pair.ServiceName) || string.IsNullOrEmpty(pair.MethodName) )
ExceptionHelper.Throw404Exception(context);
InvokeInfo vkInfo = ReflectionHelper.GetInvokeInfo(pair);
if( vkInfo == null )
ExceptionHelper.Throw404Exception(context);
ServiceInfo info = new ServiceInfo(pair, vkInfo);
ProcessRequest(context, info);
}
}
到此,关于“怎么用ASP.NET写服务框架”的学习就结束了,希望能够解决大家的疑惑。理论与实践的搭配能更好的帮助大家学习,快去试试吧!若想继续学习更多相关知识,请继续关注亿速云网站,小编会继续努力为大家带来更多实用的文章!
免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。