ASP.net中的HTTP Handlers 和 HTTP Modules(来源Internet)

   作者:Mansoor Ahmed Siddiqui

译注本文主要讲解如何编写自定义的简单的HTTP处理器及HTTP模块,由于我不太讲究页面效果,而且我的系统也是英文的,所以操作中的界面相关的我没有翻译。整体不是翻译得很细致,如果不太喜欢,可以参照原文:  http://www.15seconds.com/issue/020417.htm

由于是初次写一些东西,如果有些什么看法或发现错误,请指出来,大家共同进步。先谢过。

 

简介

       在互联网上, Web服务器供给放置在网络上的资源, 并且提供安全, 日志等其它的服务.  在网络时代刚刚开始的时候, 客户的需求非常有限, htm文件通常已经让人满意了, 但随着时间的流逝, 客户的需求已经超越了htm或静态文本所能提供的功能.
       开发者需要有一种方法来扩展或补充Web服务器的功能. Web服务器的供应商设计了不同的解决方案, 但是他们都遵从了同一种模式: '在Web服务器中插入一些组件". 所有的Web服务器扩展技术都允许开发者创建及安插组件来增强Web服务器的功能. 微软使用 ISAPI (Internet Server API), 网景使用 NSAPI (Netscape Server API), 等等.
       ISAPI 是一个重要的技术, 它可以我们增强兼容ISAPI的Web服务器(IIS是一个ISAPI相容的Web服务器)的处理能力. 以下的组件可以用于实现这个目标:


 * ISAPI Extensions
 * ISAPI Filters


ISAPI Extension使用 Win32 DLL 来实现, 你可以把一个ISAPI extension看作一个普通的程序. ISAPI Extension是HTTP请求的目标. 意思是说,你必须要调用以激活它们. 举个例子, 下面的URL
调用store.dll ISAPI Extension并传给它两个值:

http://www.myownwebsite.com/Store.dll?sitename=15seconds&location=USA

将ISAPI filter就想象成一个筛选器, 它处在Web服务器和客户端之间. 每次客户端发出一个请求到服务器, 它都会经过 Filter. 客户端不需要在请求中明确指向某个筛选器, 相反, 客户端只是简单地发送请求给服务器, 接着, Web服务器将请求传递给相应的筛选器. 筛选器然后修改请求, 执行一些日志操作等等.

      因为它们所包含的复杂性, 实现这些组件是非常困难的, 必须要用C/C++来开发这些组件, 而且对很多人来说, 用C/C++开发是一伯痛苦的事.

      那么, ASP.net提供什么来实现这个功能呢? ASP.net提供了 HttpHandlers 和 HttpModules

      在讨论这些组件的细节之前, 先看一看Http请求经过HTTP modules 及 HTTP handlers的流程.

配置例子应用程序

我创建了下列的C#项目, 演示了应用程序中的不同组件.

* NewHandler (HTTP handler)
* Webapp (Demonstrates HTTP handler)
* SecurityModules (HTTP module)
* Webapp2 (Demonstrated HTTP module)

安装这个应用

* 解压代码
* 创建两个虚拟目录, 名称为webapp和webapp2;  设置目 录为它们在机器上实际的物理目录.
* 将NewHandler.dll文件从NewHandler项目拷到webapp应用程序的bin目录下.
* 将SecurityModules.dll文件从SecurityMOdules项目拷到 webapp2应用程序的bin目录下.

ASP.net 请求处理

       ASP.net 的请求处理是基于一个管线(pipeline)的模型, ASP.net传递http请求给管线中的所有
模块(modules), 每个模块都接收请求且对该请求有完全的掌控. 该模块能够以它认为的任何合适的方式
来处理这个请求. 一旦这个请求经过了所有HTTP模块, 它最终被交给一个HTTP处理器(handler), HTTP
处理器进行了一些处理之后, 结果会再次通过管线中的HTTP模块.

以下的图描述了这个流程

      注意在处理一个http请求的时候, 只有一个HTTP处理器被调用, 而一个以上的HTTP模块可以被调用.

Http处理器

       HTTP处理器是实现了System.Web.IHttpHandler接口的.NET组件.  任何一个实现了IHttpHndler接口的类都可以成为Http请求的目标对象. HTTP处理器有一点象ISAPI Extensions, HTTP处理器和
HTTP Extensions间的一个不同之处在于HTTP处理器可以用它们的文件名在URL中直接调用, 和ISAPI相似.

HTTP处理器实现了下面的方法

ProcessRequest: 这个方法实际上是所有的Http处理器的核心, 调用这个方法来处理http请求.

IsReusable: 调用该属性用来确定这个http处理器的实例是否可以重用来完成其它的同类型的请求.  HTTP处理器会返回true或false来表示它是否可被重用.

      这些类可以通过配置web.config或machine.config文件被映射到http请求, 一旦完成配置, 无论何时, 只要有相应的请求进来, ASP.net就将实例化这个http处理器.  我们将看到怎样在web.config 及/或 machine.config文件中指定这些细节.

      ASP.net还支持通过IHttpHandlerFactory接口的方式来创建http处理器. ASP.net能够导引http请求到一个实现了IHttpHAndlerFactory接口的类的实例.  在这里, ASP.net使用了厂设计模式. 这个模式
提供了一个接口来创建同属一个继承体系的相关对象而不用指定具体的类. 用简单的话来说, 你可以把这样的类想象为根据传递的参数来创建http处理器的一个工厂. 我们不需要指定实例化一个特定的http处理器, http处理器工厂处理所有的细节. 这样做的好处是当未来某个时候, 实现IHttpHandler接口的类的实现发生改变时, 作为使用方的客户端不会受到影响, 只要接口还和从前一样.

下列就是IHttpHandlerFactory的接口

GetHandler: 这个方法负责创建适当的处理器并返回引用给调用代码(ASP.net运行时). 该方法返回的处理器对象应该实现了IHttpHandler接口.

ReleaseHandler: 这个方法负责释放http处理器, 一旦请求处理结束. 实现中要么实际销毁这个实例或者将其返回到池中以处理将来的请求.

在配置文件中注册HTTP处理器和HTTP处理器厂

ASP.net在下列配置文件中维护它的设置信息

* machine.config
* web.config

      machine.config包含的设置应用于计算机中安装的所有Web应用程序。web.config特定于每一个Web应用程序. 每一个应用程序可以有自己的web.config文件. Web应用中的任何一个子目录也可以有自己的web.config文件, 这允许它们覆盖由父目录所施加的设置.

      我们可以用<httpHandlers>和<add>节点来给我们的web应用程序添加HTTP处理器. 事实上处理器就是由<httpHandlers>和</httpHandlers>之间的<add>节点列出的 这里是一个添加一个HTTP处理器的一般的例子..

<httpHandlers>
 <add verb="supported http verbs" path="path" type="namespace.classname, assemblyname" />
<httpHandlers>

在上面的XML中:

* verb属性指定处理器支持的HTTP verb, 如果处理器支持所有的HTTP verbs, 简单地使用"*", 否则用一个逗号分隔的列表列出支持的verbs, 因此, 如果你的处理器仅支持HTTP GET和POST, 那么verb
属性为 "GET, POST".

* path属性指定特定处理器要处理的文件的路径或通配符描述的文件名, 比方说, 假如你想要仅当test.xyz文件被请求的时候调用你的处理器, 那么这个path属性将包含"test.xyz", 类似地, 如果你想要你的处理器处理所有以.xyz为扩展名的文件, path属性将包含"*.xyz".

* type属性指定了处理器或处理器厂的实际类型, 表现为名字空间, 类名和程序集名的形式. ASP.net
运行时首先在应用程序的bin目录下搜索这个DLL程序集, 然后搜索GAC (Global Assembly Cache).

ASP.net运行时对HTTP处理器的使用

       不管你信不信, ASP.net使用HTTP处理器来实现许多自身的功能, ASP.net用处理器来处理aspx,asmx, soap及其它ASP.net文件.

下面是从machine.config文件中节选的一个片断

<httpHandlers>
<add verb="*" path="trace.axd" type="System.Web.Handlers.TraceHandler"/>
<add verb="*" path="*.aspx" type="System.Web.UI.PageHandlerFactory"/>
<add verb="*" path="*.ashx" type="System.Web.UI.SimpleHandlerFactory"/>
<add verb="*" path="*.config" type="System.Web.HttpForbiddenHandler"/>
 <add verb="GET,HEAD" path="*" type="System.Web.StaticFileHandler"/>
 . . . . . .
 . . . . . .
</httpHandlers>



      你可以在上面的配置中看到所有对.aspx文件的请求被System.Web.UI.PageHandlerFactory类所处理.类似地, 所有对.config及其它不应该被客户直接访问的文件的请求由 System.Web.HttpForbiddenHandler类处理. 你或许已经猜到, 这个类简单地返回一个错误给客户端, 说这些类型的文件不可以被访问.

实现HTTP处理器

       现在我们来看看怎样实现一个HTTP处理器. 我们的处理器应该做些什么呢? 就象我在前面讲到的,处理器大部分情况下用来给Web服务器添加新的功能, 因此, 我们将创建一个处理器来处理新的文件类型, 扩展名为.15seconds的文件. 一理我们实现了这个处理器并在我们的web应用中的web.config中注册, 所有对.15seconds文件的请求将会被这个新的处理器处理.
       你可能在考虑这样一个处理器的用途, 如果你想要引入一个新的服务器端脚本语言, 或者动态服务器端文件如asp, aspx怎么办? 你可以定一个自己的处理器. 类似地, 如果你想要在IIS上运行java servlets, JSPs 及其它服务器端java组件, 你会怎么办呢? 一个方法是安装一些ISAPI extensions 象 Allaire 和Macromedia Jrun. 或者你可以写一个自己的HTTP处理器. 尽管对于第三方的厂商如 Allaire 及 Macromedia来说, 这是一个艰难的任务, 但它是一个非常有吸引力的选择, 因为处理器将能够访问所有ASP.net公开的新的功能.

实现我们的HTTP处理器的步骤如下:

1, 写一个实现IHttpHandler接口的类
2, 在web.config或machine.config文件注册这个处理器
3, 在Internet Services Mnager中向ASP.net ISAPI extension DLL (aspnet_isapi.dll)映射文件的扩展名.


第一步
       在Visual Studio.NET中创建一个新的C# class library项目, 取名为"MyHandler". Visual Studio.NET将自动在项目中添加一个叫"Class1.cs"的类, 给它更名为"NewHandler". 在代码窗口中打开这个类, 将类名及构造符改名为"NewHandler".

下面是NewHandler类的代码.

using System;
using System.Web;

namespace MyHandler
{
 ///


 /// Summary description for NewHandler.
 ///
 public class NewHandler : IHttpHandler
 {
  public NewHandler()
  {
   //
   // TODO: Add constructor logic here
   //
  }

  #region Implementation of IHttpHandler
  public void ProcessRequest(System.Web.HttpContext context)
  {
   HttpResponse objResponse = context.Response ;
objResponse.Write("Hello 15Seconds   Reader ") ;
   objResponse.Write("") ;
  }
 

  public bool IsReusable
  {
   get
   {
    return true;
   }
  }
  #endregion
 }
}

       可以看到, 在ProcessRequest方法中, HTTP处理器可以通过由参数传递进来的System.Web.HttpContext对象访问所有ASP.net内部的对象. 实现PRocessRequest方法只是简单地将HttpResponse对象从context对象中提取出来, 然后发送一些HTML给客户. 类似地, IsReusable返回true表明这个处理器可以被重新使用来处理其它的HTTP请求.

      让我们来编译一下这个文件并把它放在webapp虚拟目录的bin目录下.

第二步

添加下列的文本到web.config文件中来注册这个处理器.

<httpHandlers>
  <add verb="*" path="*.15seconds" type="MyHandler.NewHandler,MyHandler"/>
</httpHandlers>

第三步

      因为我们正在创建一个处理带有新扩展名文件的处理器, 我们需要告诉IIS这个新的扩展名, 然后映射到ASP.net. 如果我们不做这一步并试图访问这个Hello.15seconds文件, IIS将只简单地返回这个文件, 而不是将它传递给ASP.net运行时, 结果是HTTP处理器不会被调用.

      打开Internet Services Manager工具, 右击Default Web Site, 选择属性, 选择Properties, 选择Home Directory标签, 点击Configuration按钮. 会弹出Application Configuration 对话框. 点击Add 按钮, 在Executable文本框内填入aspnet_isapi.dll文件的路径, 在Extension框内填入.15seconds. 其它选项不做改动, 对话框看起来象下面这样.

点击OK关掉Application Configuration和Default Web Site Properties对话框.

现在我们可以开始了, 打开IE, 键入下面的UR:
http://localhost/webapp/hello.15seconds

你应该看到下面的页面


HTTP处理器中的会话状态 
      维护会话状态是Web应用程序最普通的任务之一, HTTP处理器也需要访问会话状态. 但是会话状态在默认情况下并没有打开. 为了能够读写会话数据, HTTP处理器要求实现下列的接口之一:

* IRequiresSessionState
* IReadOnlySessionState

      一个HTTP处理器应该实现IRequiresSessionState接口, 如果它要求对会话数据有读写权限, 如果一个处理器仅需要对会话数据有读权限, 那么它应该只实现IReadOnlySessionState接口.

      这两个接口都只是marker interface, 并没有包含任何方法. 因此如果我们想要为我们的NewHandler处理器打开会话状态, 那么象下面这样声明NewHandler类:

public class NewHandler : IHttpHandler, IRequiresSessionState

HTTP模块 (modules)

      HTTP模块是实现了System.Web.IHttpModule接口的.NET组件. 这些组件通过注册一些特定的事件将它们自己插入到ASP.net的请求处理管线中. 不管何时事件发生, ASP.net调用相关的HTTP模块以使它们和请求交互.

一个HTTP模块被假定实现了下列的IHttpModule接口方法:

Init:  这个方法允许一个HTTP模块向HttpApplication对象的事件注册它自己的事件处理方法.

Dispose: 这个方法给HTTP模块提供一个机会在对象被垃圾回收前执行一些清理工作.

 一个HTTP模块可以注册下列System.Web.HttpApplication对象公开的事件:

AcquireRequestState: 这个事件在ASP.net运行时准备获取当前HTTP请求的会话状态时激发.

AuthenticateRequest: 这个事件在ASP.net运行时准备验证用户的标识时引发.

AuthorizeRequest: 这信事件在ASP.net运行时准备授权用户试图访问的资源时引发.

BeginRequest:  这个事件在ASP.net收到一个新的HTTP请求时引发.

Disposed: 这个事件在当ASP.net结束了HTTP请求的处理时引发.

EndRequest: 这个事件在将响应内容返回给客户之前引发。

Error: 这个事件在处理HTTP请求过程中发生未处理异常时引发。

PostRequestHandlerExecute: 这个事件在HTTP处理器刚刚执行结束后引发。

PreRequestHandlerExecute: 这个事件在ASP.net为HTTP请求执行处理器之前引发。在这个事件之后,ASP.net将会把请求交给适当的HTTP处理器。

PreSendRequestContent: 这个事件在ASP.net发送响应内容给客户之前引发。  这个事件允许我们在它传递给客户端之前修改它的内容。我们可以用这个事件来向页面输出添加对所有页面共用的内容,比如说,一个共用菜单,页头或页脚。 

PreSendRequestHeaders: 这个事件在ASP.net发送HTTP响应的头部给客户之前引发。这个事件允许我们在它们传递给客户之前改变头部的内容。  我们可用这个事件来添加cookies及自定义的数据在头字段中。

ReleaseRequestState: 这个事件在ASP.net结束执行所有的请求处理器后发生。

ResolveRequestCache: 这个事件被引发以决定这个请求是否可通过从输出缓存返回内容来满足。这个取决于输出缓存(Output Caching)对于你的应用是怎样设置的。

UpdateRequestCache:  这个事件在ASP.net已经完成了对当前请求的处理并准备把输出内容加入到输出缓存的时候引发,  这个取决于输出缓存对你的应用是怎样设置的。

      除了这些事件以外,  还有四个事件我们可以使用。 我们可以通过实现在我们应用程序中的global.asax文件的方法来捕捉这些事件。

*  Application_OnStart
 这个事件在第一个请求到达Web应用时引发。
*  Application_OnEnd
 这个事件在应用将要终止之前引发。
*  Session_OnStart
 这个事件在第一次请求用户会话时引发。
*  Session_OnEnd
 这个事件在会话会取消或超期时引发。

在配置文件中注册HTTP模块

      一旦一个HTTP模块被建立并被复制到Web应用的bin目录或复制到GAC中,  接下来我们要在web.config或machine.config中注册它。

      我们可以使用<httpModules>和<add>结点来给我们的应用程序添加HTTP模块。 事实上模块就是被<httpModules>和</httpMOdules>间的<add>结点列出的。

      因为配置是可继承的, 子目录继承了你目录的配置。  结果,子目录可能继承了一些作为父目录配置一部分的它并不想要的HTTP模块,  因此,我们需要一个方法来移除那些不想要的模块。  我们可以用<remove>结点来办到。

      如果我们想要移除所有从我们的应用中继承过来的HTTP模块,  我们可以使用 节点。

以下是一个添加HTTP模块的一般的例子:


<httpModules>
 <add type="classname, assemblyname" name="modulename"  />
<httpModules>

下面是一个移除HTTP模块的一般的例子:

<httpModules>
 <remove name="modulename"  />
<httpModules>

 在上面的XML中:
*  type属性指定了HTTP模块的实现的类型,  以类名和程序集名的形式。
*  name属性指定了这这个HTTP模块的一个友好的名字。  这个名字将可以被其它应用用来标识这个模块。

ASP.net运行时对HTTP模块的使用

      ASP.net运行时使用HTTP模块来实现一些特殊的特性。  下面的machine.config文件的片断展示了由ASP.net安装的HTTP模块。

<httpModules>
 <add name="OutputCache" type="System.Web.Caching.OutputCacheModule"/>
 <add name="Session" type="System.Web.SessionState.SessionStateModule"/>
 <add name="WindowsAuthentication"
   type="System.Web.Security.WindowsAuthenticationModule"/>
 <add name="FormsAuthentication"
   type="System.Web.Security.FormsAuthenticationModule"/>
 <add name="PassportAuthentication"
   type="System.Web.Security.PassportAuthenticationModule"/>
 <add name="UrlAuthorization"
   type="System.Web.Security.UrlAuthorizationModule"/>
 <add name="FileAuthorization"
   type="System.Web.Security.FileAuthorizationModule"/>
</httpModules>
   

      所有这些HTTP模块被ASP.net用来提供象验证,授权,会话管理及输出缓存这样的服务。  因为这些模块
已经在machine.config文件中注册了, 所以它们在所有Web应用中自动成为可用的。

实现一个提供安全服务的HTTP模块

      现在我们将实现一个HTTP模块来为我们的Web应用提供安全服务。  我们的HTTP模块将提供一个基本的自定义验证服务。  它将在HTTP请求中接收一个验证证书(authentication credentials)并决定这些证书是否合法,  如果是, 这些用户与什么角色相关联?   通过User Identity对象,  它将把可以访问我们的Web用页面的角色和用户的标识关联起来。

下面是我们的HTTP模块的代码:

using System;
using System.Web;
using System.Security.Principal;

namespace SecurityModules
{
 ///


 /// Summary description for Class1.
 /// 

 public class CustomAuthenticationModule : IHttpModule
 {
  public CustomAuthenticationModule()
  {
  }
  public void Init(HttpApplication r_objApplication)
  {
   // Register our event handler with Application object.
   r_objApplication.AuthenticateRequest +=
               new EventHandler(this.AuthenticateRequest) ;
  }

  public void Dispose()
  {
   // Left blank because we dont have to do anything.
  }

  private void AuthenticateRequest(object r_objSender,
                                   EventArgs r_objEventArgs)
  {
   // Authenticate user credentials, and find out user roles.
   1. HttpApplication objApp = (HttpApplication) r_objSender ;
   2. HttpContext objContext = (HttpContext) objApp.Context ;
   3. if ( (objApp.Request["userid"] == null) ||
   4.                     (objApp.Request["password"] == null) )
   5. {
   6.  objContext.Response.Write(" Credentials not provided") ;
   7.  objContext.Response.End() ;
   8. }
 

   9. string userid = "" ;
   10. userid = objApp.Request["userid"].ToString() ;
   11. string password = "" ;
   12. password = objApp.Request["password"].ToString() ;
   
   13. string[] strRoles ;
   14. strRoles = AuthenticateAndGetRoles(userid, password) ;
   15. if ((strRoles == null) || (strRoles.GetLength(0) == 0))
   16. {
   17. objContext.Response.Write(" We are sorry but we could not find this user id and password in our database") ;
   18. objApp.CompleteRequest() ;
   19. }
 

   20. GenericIdentity objIdentity = new GenericIdentity(userid,
                                             "CustomAuthentication") ;
   21. objContext.User = new GenericPrincipal(objIdentity, strRoles) ;
  }

  private string[] AuthenticateAndGetRoles(string r_strUserID,
                                                 string r_strPassword)
  {
   string[] strRoles = null ;
   if ((r_strUserID.Equals("Steve")) &&
                                  (r_strPassword.Equals("15seconds")))
   {
    strRoles = new String[1] ;
    strRoles[0] = "Administrator" ;
   }
   else if ((r_strUserID.Equals("Mansoor")) &&
                                        (r_strPassword.Equals("mas")))
   {
     strRoles = new string[1] ;
     strRoles[0] = "User" ;    
    }
    return strRoles ;
   }
  }
}

让我们分析一下这段代码:

我们从Init方法开始。  这个方法将我们的AuthenticateRequest事件的事件处理方法加入到了Application对象的委托链中。  这将使得Application对象在AuthenticationRequest事件发生的时调用这个事件处理方法。

      一旦我们的HTTP模块初始化完成, 它的AuthenticateRequest方法将被调用来验证客户的请求。AuthenticateRequest方法是这个功能中安全/验证机制的核心。

第1行和第2行提取了HttpApplication和HttpContext对象,  第3行到第7行检查是否没有提供用户名或密码,  如果是这样,  将会显示错误并且请求处理被中止。

第9行到第12行密码从HttpRequest对象中提取了用户id和密码。

第14行调用了一个辅助方法,  叫做AuthenticateAndGetRoles.  这个方法执行了基本的验证并确定用户的角色。  这个被硬编码且只有两个用户,  但是我们可以一般化这个方法并且添加代码来和一些用户数据库进行交互来取出用户角色。

第16行到第19行检查用户是否被分配了相关的角色,  如果没有意味着传给我们的证书不能够被检验,因此这些证书是非法的。那么,一个错误信息被送到客户端且请求结束。

第20行和第21行非常重要,因为这几行实际通知ASP.net  HTTP运行时关于这个登录用户的标识。一旦这几
行代码被成功执行,  我们的.aspx页面将能够使用这个User对象访问这个信息。

现在让我们看看这个验证机制的实际执行。  当前我们仅允许下列两个用户登录到我们的系统:

* User id = Steve, Password = 15seconds, Role = Administrator
* User id = Mansoor, Password = mas, Role = User

注意用户id和密码是大小写敏感的。

第一次试图不用证书登录。  打开http://localhost/webapp2/index.aspx, 你应该看到下面的信息:

现在试着用用户id “Steve"和密码 "15seconds", 打开
http://localhost/webapp2/index.aspx?userid=Steve&password=15seconds,然后你应该看到下面的
欢迎信息:

现在试图用用户id ”Mansoor" 和密码"15seconds". 打开http://localhost/webapp2/index.aspx?userid=Manoor&password=mas,  然后你应该看到下面的欢迎页面:

现在试图用错误的用户id和密码的组合来登录,打开http://localhost/webapp2/index.aspx?userid=Mansoor&password=xyz,  然后你应该会看到下面的错误消息:

      这个演示了我们的安全模块的实际执行情况。 你可以在AuthenticateAndGetRoles方法中使用数据库访问码来一般化这个安全模块。 

      要使这些能够工作,我们要在我们的web.config文件中进行一些改变,  首先,因为我们要使用自己的自定义验证,我们不需要任何其它的验证机制。要指明这一点,修改webapp2应用的web.config文件中 节点,看起来象这样:

<authentication mode="None"/>

       类似地,不要允许匿名用户访问网站,在web.config文件中添加下面的内容:

<authorization>
 <deny users="?"/>
</authorization>

用户至少应该对用来提供证书的文件有匿名访问的权限。  在web.config中使用下列的配置来指定index.aspx为仅有的可以匿名访问的文件:

<location path="index.aspx">
 <system.web>
  <authorization>
   <allow users="*"/>
  </authorization>
 </system.web>
</location>

总结

      就象你可能在HTTP处理器及HTTP模块中了解到的,  ASP.net给开发者提供了强大的功能。添加你自己的组件到ASP.net请求管线中并享受它带来的好处吧。

      这篇文章至少使你初步理解了这些组件,作为一个练习,你可能想要使验证模块更加灵活并且根据你的需要来对它进行调整。

 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值