SpringMVC个人理解(downpour 的SpringMVC深度探险的个人整理)

从servlet开始讲起,之后以DispatcherServlet为主线展开。

 

SpringMVC是建立在Servlet基础上的,SpringMVC自始至终都没有突破传统Servlet编程模型的限制。

但传统servlet有一些问题:

问题1:传统servlet在项目规模扩大之后,请求-响应的映射关系全部定义在web.xml中,将造成web.xml的不断膨胀而变得难以维护。

为解决这个问题,SpringMVC提出的方案就是:提炼一个核心的Servlet覆盖对所有Http请求的处理

这一被提炼出来的Servlet,通常被我们称之为:核心分发器。在SpringMVC中,核心分发器就是org.springframework.web.servlet.DispatcherServlet

 

有了DispatcherServlet,我们只相当于迈出了第一步,因为对核心Servlet的提炼不仅仅是将所有的Servlet集中在一起那么简单,我们还将面临两大问题: 

问题2核心Servlet应该能够根据一定的规则对不同的Http请求分发到不同的Servlet对象上去进行处理

问题3核心Servlet应该能够建立起一整套完整的对所有Http请求进行规范化处理的流程。

而这两大问题的解决,涉及到了DispatcherServlet的设计核心。我们也不得不引入另外一个重要的编程元素,那就是:组件。 

我们可以发现这两个问题实际上都涉及到了DispatcherServlet的处理过程,这一处理过程首先必须是一剂万能药,能够处理所有的Http请求;同时,DispatcherServlet还需要完成不同协议之间的转化工作(从Http协议到Java世界的转化)。 

对此,SpringMVC所提出的方案是:将整个处理流程规范化,并把每一个处理步骤分派到不同的组件中进行处理。 

这个方案实际上涉及到两个方面: 

  • 处理流程规范化 —— 将处理流程划分为若干个步骤(任务),并使用一条明确的逻辑主线将所有的步骤串联起来
  • 处理流程组件化 —— 将处理流程中的每一个步骤(任务)都定义为接口,并为每个接口赋予不同的实现模式

在SpringMVC的设计中,这两个方面的内容总是在一个不断交叉、互为补充的过程中逐步完善的。

处理流程规范化是目的,对于处理过程的步骤划分和流程定义则是手段。因而处理流程规范化的首要内容就是考虑一个通用的Servlet响应程序大致应该包含的逻辑步骤: 

  • 步骤1 —— 对Http请求进行初步处理,查找与之对应的Controller处理类(方法)
  • 步骤2 —— 调用相应的Controller处理类(方法)完成业务逻辑
  • 步骤3 —— 对Controller处理类(方法)调用时可能发生的异常进行处理
  • 步骤4 —— 根据Controller处理类(方法)的调用结果,进行Http响应处理

所谓的程序化,实际上也就是使用编程语言将这些逻辑语义表达出来。在Java语言中,最适合表达逻辑处理语义的语法结构是接口,因此上述的四个流程也就被定义为了四个不同接口,它们分别是: 

  • 步骤1 —— HandlerMapping
  • 步骤2 —— HandlerAdapter
  • 步骤3 —— HandlerExceptionResolver
  • 步骤4 —— ViewResolver

结合之前我们对流程组件化的解释,这些接口的定义不正是处理流程组件化的步骤嘛?这些接口,就是组件。 

除了上述组件之外,SpringMVC所定义的组件几乎涵盖了每一个处理过程中的重要节点。我们在这里引用Spring官方reference中对于最基本的组件的一些说明: 

222446_Rgp8_2885163.png

 

这些组件一旦被定义,自然而然也就引出了下一个问题:这些组件是如何串联在一起的?

这个过程其实是在DispatcherServlet中完成的。

有关这一点,我们可以从两个不同的角度加以证明。

1. 从DispatcherServlet自身数据结构的角度 

222611_JwtN_2885163.png

如图中所示,DispatcherServlet中包含了众多SpringMVC的组件,这些组件是实现DispatcherServlet核心逻辑的基础。 

 

2. 从DispatcherServlet的核心源码的角度 

Java代码  

try {  
    // 这里省略了部分代码  
  
    // 获取HandlerMapping组件返回的执行链  
    mappedHandler = getHandler(processedRequest, false);  
    if (mappedHandler == null || mappedHandler.getHandler() == null) {  
        noHandlerFound(processedRequest, response);  
        return;  
    }  
  
    // 获取HandlerAdapter组件  
    HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());  
  
    // 这里省略了部分源码  
      
    // 调用HandlerAdapter组件  
    mv = ha.handle(processedRequest, response, mappedHandler.getHandler());  
  
    // 这里省略了部分源码  
  
}catch (ModelAndViewDefiningException ex) {  
    logger.debug("ModelAndViewDefiningException encountered", ex);  
    mv = ex.getModelAndView();  
}catch (Exception ex) {  
    Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);  
    // 调用HandlerExceptionResolver进行异常处理  
    mv = processHandlerException(processedRequest, response, handler, ex);  
    errorView = (mv != null);  
}  

从上面的代码片段中,我们可以看到DispatcherServlet的核心逻辑不过是对组件的获取和调用。 

 

除此之外,SpringMVC对处理流程的规范化和组件化所引出的另外一个问题就是如何针对所有的组件进行管理?

 

先说说管理。其实管理这些组件对于SpringMVC来说完全不是问题,因为SpringMVC作为Spring Framework的一部分,其自身的运行环境就是Spring所定义的容器之中。我们知道,Spring Framework的核心作用之一就是对整个应用程序的组件进行管理。所以SpringMVC对于这些已定义组件的管理,只不过是借用了Spring自身已经提供的容器功能而已。 

 

注:SpringMVC在进行组件管理时,会单独为SpringMVC相关的组件构建一个容器环境,这一容器环境可以独立于应用程序自身所创建的Spring容器。

 

而SpringMVC对这些组件的管理载体,正式核心配置文件。我们可以看到,核心配置文件在整个SpringMVC的构成要素中占有一席之地的重要原因就是在于:我们必须借助一个有效的手段对整个SpringMVC的组件进行定义,而这一点正是通过核心配置文件来完成的。 


接下来接着看核心分发器DispatcherServlet

我们再看一下DispatcherServlet的继承结构: 

224510_60JD_2885163.png

在这个继承结构中,我们可以看到DispatcherServlet在其继承树中包含了2个Spring的支持类:HttpServletBeanFrameworkServlet。我们分别来讨论一下这两个Spring的支持类在这里所起到的作用。 

HttpServletBean是Spring对于Servlet最低层次的抽象。在这一层抽象中,Spring会将这个Servlet视作是一个Spring的bean,并将init-param中的值作为bean的属性注入进来: 

Java代码  

public final void init() throws ServletException {  
    if (logger.isDebugEnabled()) {  
        logger.debug("Initializing servlet '" + getServletName() + "'");  
    }  
  
    // Set bean properties from init parameters.  
    try {  
        PropertyValues pvs = new ServletConfigPropertyValues(getServletConfig(), this.requiredProperties);  
        BeanWrapper bw = PropertyAccessorFactory.forBeanPropertyAccess(this);  
        ResourceLoader resourceLoader = new ServletContextResourceLoader(getServletContext());  
        bw.registerCustomEditor(Resource.class, new ResourceEditor(resourceLoader, this.environment));  
        initBeanWrapper(bw);  
        bw.setPropertyValues(pvs, true);  
    }  
    catch (BeansException ex) {  
        logger.error("Failed to set bean properties on servlet '" + getServletName() + "'", ex);  
        throw ex;  
    }  
  
    // Let subclasses do whatever initialization they like.  
    initServletBean();  
  
    if (logger.isDebugEnabled()) {  
        logger.debug("Servlet '" + getServletName() + "' configured successfully");  
    }  
}  

从源码中,我们可以看到HttpServletBean利用了Servlet的init方法的执行特性,将一个普通的Servlet与Spring的容器联系在了一起。在这其中起到核心作用的代码是:BeanWrapper bw = PropertyAccessorFactory.forBeanPropertyAccess(this);将当前的这个Servlet类转化为一个BeanWrapper,从而能够以Spring的方式来对init-param的值进行注入。BeanWrapper的相关知识属于Spring Framework的内容,我们在这里不做详细展开,读者可以具体参考HttpServletBean的注释获得更多的信息。 

 

FrameworkServlet则是在HttpServletBean的基础之上的进一步抽象。通过FrameworkServlet真正初始化了一个Spring的容器(WebApplicationContext),并引入到Servlet对象之中: 

Java代码  

protected final void initServletBean() throws ServletException {  
    getServletContext().log("Initializing Spring FrameworkServlet '" + getServletName() + "'");  
    if (this.logger.isInfoEnabled()) {  
        this.logger.info("FrameworkServlet '" + getServletName() + "': initialization started");  
    }  
    long startTime = System.currentTimeMillis();  
  
    try {  
        this.webApplicationContext = initWebApplicationContext();  
        initFrameworkServlet();  
    } catch (ServletException ex) {  
        this.logger.error("Context initialization failed", ex);  
        throw ex;  
    } catch (RuntimeException ex) {  
        this.logger.error("Context initialization failed", ex);  
        throw ex;  
    }  
  
    if (this.logger.isInfoEnabled()) {  
        long elapsedTime = System.currentTimeMillis() - startTime;  
        this.logger.info("FrameworkServlet '" + getServletName() + "': initialization completed in " +  
                elapsedTime + " ms");  
    }  
}  

上面的这段代码就是FrameworkServlet初始化的核心代码。从中我们可以看到这个FrameworkServlet将调用其内部的方法initWebApplicationContext()对Spring的容器(WebApplicationContext)进行初始化。同时,FrameworkServlet还暴露了与之通讯的结构可供子类调用: 

Java代码  

public abstract class FrameworkServlet extends HttpServletBean {  
  
    /** WebApplicationContext for this servlet */  
    private WebApplicationContext webApplicationContext;  
  
        // 这里省略了其他所有的代码  
  
    /** 
     * Return this servlet's WebApplicationContext. 
     */  
    public final WebApplicationContext getWebApplicationContext() {  
        return this.webApplicationContext;  
    }  
}  

在这里可以体会到:FrameworkServlet在其内部初始化了一个Spring的容器(WebApplicationContext)并暴露了相关的操作接口,因而继承自FrameworkServlet的DispatcherServlet,也就直接拥有了与WebApplicationContext进行通信的能力。 

 

通过对DispatcherServlet继承结构的研究,我们可以明确结论 :

DispatcherServlet的继承体系架起了DispatcherServlet与Spring容器进行沟通的桥梁。

 

再看一下DispatcherServlet的:数据结构

 225207_14Tv_2885163.png

我们可以把在上面这张图中所构成DispatcherServlet的数据结构主要分为两类(我们在这里用一根分割线将其分割开来): 

 

  • 配置参数 —— 控制SpringMVC组件的初始化行为方式
  • 核心组件 —— SpringMVC的核心逻辑处理组件

可以看到,这两类数据结构都与SpringMVC中的核心要素组件有关。因此,我们可以得出这样一个结论:  组件是整个DispatcherServlet的灵魂所在:它不仅是初始化主线中的初始化对象,同样也是Http请求处理主线中的逻辑调度载体。

注:我们可以看到被我们划为配置参数的那些变量都是boolean类型的,它们将在DispatcherServlet的初始化主线中起到一定的作用,我们在之后会使用源码进行说明。而这些boolean值可以通过web.xml中的init-param值进行设定覆盖(这是由HttpServletBean的特性带来的)。 

 

DispatcherServlet继承结构和数据结构,实际上表述的是DispatcherServlet与另外两大要素之间的关系: 

 

  • 继承结构 —— DispatcherServlet与Spring容器(WebApplicationContext)之间的关系
  • 数据结构 —— DispatcherServlet与组件之间的关系

这就牵扯出了SpringMVC的整个运行体系。

它就是由DispatcherServlet、组件和容器这三者共同构成的。 

在这个运行体系中,DispatcherServlet是逻辑处理的调度中心,组件则是被调度的操作对象。而容器在这里所起到的作用,是协助DispatcherServlet更好地对组件进行管理。

 

这里引用Spring官方reference中的一幅图,对三者之间的关系进行简单的描述:

225519_PMEX_2885163.png

 

既然是三个元素之间的关系表述,我们必须以两两关系的形式进行归纳: 

 

  • DispatcherServlet - 容器 —— DispatcherServlet对容器进行初始化
  • 容器 - 组件 —— 容器对组件进行全局管理
  • DispatcherServlet - 组件 —— DispatcherServlet对组件进行逻辑调用

 

接下来,我们就再看看DispatcherServlet的初始化主线 

对于DispatcherServlet的初始化主线,我们首先应该明确几个基本观点: 

  • 初始化主线的驱动要素 —— servlet中的init方法
  • 初始化主线的执行次序 —— HttpServletBean -> FrameworkServlet -> DispatcherServlet
  • 初始化主线的操作对象 —— Spring容器(WebApplicationContext)和组件

这三个基本观点,可以说是我们对之前所有讨论的一个小结。明确了这些内容,我们就可以更加深入地看看DispatcherServlet初始化主线的过程: 

225901_sa6I_2885163.png

之前我们讨论了DispatcherServlet对于WebApplicationContext的初始化是在FrameworkServlet中完成的,不过我们并没有细究其中的细节。在默认情况下,这个初始化过程是由web.xml中的入口程序配置所驱动的: 

  1. <!-- Processes application requests -->  
  2. <servlet>  
  3.     <servlet-name>dispatcher</servlet-name>  
  4.     <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>  
  5.     <load-on-startup>1</load-on-startup>  
  6. </servlet>  
  7.           
  8. <servlet-mapping>  
  9.     <servlet-name>dispatcher</servlet-name>  
  10.     <url-pattern>/**</url-pattern>  
  11. </servlet-mapping>  

在默认情况下,web.xml配置节点中<servlet-name>的值就是建立起核心分发器DispatcherServlet与核心配置文件之间联系的桥梁。DispatcherServlet在初始化时会加载位置在/WEB-INF/[servlet-name]-servlet.xml的配置文件作为SpringMVC的核心配置。

SpringMVC在这里采用了一个“命名约定”的方法进行关系映射,这种方法很廉价也很管用。以上面的配置为例,我们就必须在/WEB-INF/目录下,放一个名为dispatcher-servlet.xml的Spring配置文件作为SpringMVC的核心配置用以指定SpringMVC的基本组件声明定义。 

 

这看上去似乎有一点别扭,因为在实际项目中,我们通常喜欢把配置文件放在classpath下,并使用不同的package进行区分。例如,在基于Maven的项目结构中,所有的配置文件应置于src/main/resources目录下,这样才比较符合配置文件统一化管理的最佳实践。 

 

于是,Spring提供了一个初始化的配置选项,通过指定contextConfigLocation选项来自定义SpringMVC核心配置文件的位置: 

  1. <!-- Processes application requests -->  
  2. <servlet>  
  3.     <servlet-name>dispatcher</servlet-name>  
  4.     <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>  
  5.     <init-param>  
  6.         <param-name>contextConfigLocation</param-name>  
  7.         <param-value>classpath:web/applicationContext-dispatcherServlet.xml</param-value>  
  8.     </init-param>  
  9.     <load-on-startup>1</load-on-startup>  
  10. </servlet>  
  11.           
  12. <servlet-mapping>  
  13.     <servlet-name>dispatcher</servlet-name>  
  14.     <url-pattern>/</url-pattern>  
  15. </servlet-mapping>  

这样一来,DispatcherServlet在初始化时,就会自动加载在classpath下,web这个package下名为applicationContext-dispatcherServlet.xml的文件作为其核心配置并用以初始化容器(WebApplicationContext)。 

 

当然,这只是DispatcherServlet在进行WebApplicationContext初始化过程中的配置选项之一。我们可以在Spring的官方reference中找到相应的配置选项,有兴趣的读者可以参照reference的说明进行尝试: 230600_olRK_2885163.png

所有的这些配置选项,实际上都是为了让DispatcherServlet对WebApplicationContext的初始化过程显得更加自然。不过这只是完成了容器(WebApplicationContext)的构建工作,那么容器所管理的那些组件,又是如何进行初始化的呢? 

结论 SpringMVC核心配置文件中所有的bean定义,就是SpringMVC的组件定义,也是DispatcherServlet在初始化容器(WebApplicationContext)时,所要进行初始化的组件。

上一篇文章我们谈到组件的时候就曾经提到,SpringMVC自身对于组件并未实现一套完整的管理机制,而是借用了Spring Framework核心框架中容器的概念,将所有的组件纳入到容器中进行管理。所以,SpringMVC的核心配置文件使用与传统Spring Framework相同的配置形式,而整个管理的体系也是一脉相承的。 

 

我们知道,SpringMVC的组件是一个个的接口定义,当我们在SpringMVC的核心配置文件中定义一个组件时,使用的却是组件的实现类: 

这也就是Spring管理组件的模式:用具体的实现类来指定接口的行为方式。不同的实现类,代表着不同的组件行为模式,它们在Spring容器中是可以共存的: 

所以,Spring的容器就像是一个聚宝盆,它只负责承载对象,管理好对象的生命周期,而并不关心一个组件接口到底有多少种实现类或者行为模式。这也就是我们在上面那幅图中,画了多个HandlerMappings、HandlerAdapters和ViewResolvers的原因:一个组件的多种行为模式可以在容器中共存,容器将负责对这些实现类进行管理。而具体如何使用这些对象,则由应用程序自身来决定。 

 

如此一来,我们可以大致概括一下WebApplicationContext初始化的两个逻辑层次: 

 

  • DispatcherServlet负责对容器(WebApplicationContext)进行初始化。
  • 容器(WebApplicationContext)将读取SpringMVC的核心配置文件进行组件的实例化。

 

 

独立的WebApplicationContext体系

 

独立的WebApplicationContext体系,是SpringMVC初始化主线中的一个非常重要的概念。回顾一下刚才曾经提到过的DispatcherServlet、容器和组件三者之间的关系,我们在引用的那副官方reference的示意图中,实际上已经包含了这一层意思: 

结论 :在DispatcherServlet初始化的过程中所构建的WebApplicationContext独立于Spring自身的所构建的其他WebApplicationContext体系而存在。

稍有一些Spring编程经验的程序员,对于下面的配置应该非常熟悉: 

 

Xml代码  

<context-param>  
    <param-name>contextConfigLocation</param-name>  
    <param-value>classpath:context/applicationContext-*.xml</param-value>  
</context-param>  
      
<listener>  
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>  
</listener>  

在上面的代码中,我们定义了一个Listener,它会在整个Web应用程序启动的时候运行一次,并初始化传统意义上的Spring的容器。这也是一般情况下,当并不使用SpringMVC作为我们的表示层解决方案,却希望在我们的Web应用程序中使用Spring相关功能时所采取的一种配置方式。 

如果我们要在这里引入SpringMVC,整个配置看上去就像这样: 

 

Xml代码  

<context-param>  
    <param-name>contextConfigLocation</param-name>  
    <param-value>classpath:context/applicationContext-*.xml</param-value>  
</context-param>  
      
<listener>  
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>  
</listener>  
      
<!-- Processes application requests -->  
<servlet>  
    <servlet-name>dispatcher</servlet-name>  
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>  
    <init-param>  
        <param-name>contextConfigLocation</param-name>  
        <param-value>classpath:web/applicationContext-dispatcherServlet.xml</param-value>  
    </init-param>  
    <load-on-startup>1</load-on-startup>  
</servlet>  
          
<servlet-mapping>  
    <servlet-name>dispatcher</servlet-name>  
    <url-pattern>/</url-pattern>  
</servlet-mapping>  

在这种情况下,DispatcherServlet和ContextLoaderListener会分别构建不同作用范围的容器(WebApplicationContext)。我们可以引入两个不同的概念来对其进行表述:ContextLoaderListener所初始化的容器,我们称之为Root WebApplicationContext;而DispatcherServlet所初始化的容器,是SpringMVC WebApplicationContext。 

 

SpringMVC WebApplicationContext与Root WebApplicationContext之间的关系:从属关系。因为根据日志的表述(这里并未给出),SpringMVC WebApplicationContext能够感知到Root WebApplicationContext的存在,并且将其作为parent容器。 

Spring正是使用这种Parent-Child的容器关系来对不同的编程层次进行划分。这种我们俗称的父子关系实际上不仅仅是一种从属关系,更是一种引用关系。从刚才的日志分析中,我们可以看出:SpringMVC中所定义的一切组件能够无缝地与Root WebApplicationContext中的组件整合。 

 

DispatcherServlet的初始化主线的执行体系是顺着其继承结构依次进行的,我们在之前曾经讨论过它的执行次序。所以,只有在FrameworkServlet完成了对于WebApplicationContext和组件的初始化之后,执行权才被正式转移到DispatcherServlet中。我们可以来看看DispatcherServlet此时究竟干了哪些事: 

  1. /** 
  2.  * This implementation calls {@link #initStrategies}. 
  3.  */  
  4. @Override  
  5. protected void onRefresh(ApplicationContext context) {  
  6.     initStrategies(context);  
  7. }  
  8.   
  9. /** 
  10.  * Initialize the strategy objects that this servlet uses. 
  11.  * <p>May be overridden in subclasses in order to initialize further strategy objects. 
  12.  */  
  13. protected void initStrategies(ApplicationContext context) {  
  14.     initMultipartResolver(context);  
  15.     initLocaleResolver(context);  
  16.     initThemeResolver(context);  
  17.     initHandlerMappings(context);  
  18.     initHandlerAdapters(context);  
  19.     initHandlerExceptionResolvers(context);  
  20.     initRequestToViewNameTranslator(context);  
  21.     initViewResolvers(context);  
  22.     initFlashMapManager(context);  
  23. }  

onRefresh是FrameworkServlet中预留的扩展方法,在DispatcherServlet中做了一个基本实现:initStrategies。我们粗略一看,很容易就能明白DispatcherServlet到底在这里干些什么了:初始化组件。 

 

读者或许会问,组件不是已经在WebApplicationContext初始化的时候已经被初始化过了嘛?这里所谓的组件初始化,指的又是什么呢?让我们来看看其中的一个方法的源码: 

  1. /** 
  2.  * Initialize the MultipartResolver used by this class. 
  3.  * <p>If no bean is defined with the given name in the BeanFactory for this namespace, 
  4.  * no multipart handling is provided. 
  5.  */  
  6. private void initMultipartResolver(ApplicationContext context) {  
  7.     try {  
  8.         this.multipartResolver = context.getBean(MULTIPART_RESOLVER_BEAN_NAME, MultipartResolver.class);  
  9.         if (logger.isDebugEnabled()) {  
  10.             logger.debug("Using MultipartResolver [" + this.multipartResolver + "]");  
  11.         }  
  12.     } catch (NoSuchBeanDefinitionException ex) {  
  13.         // Default is no multipart resolver.  
  14.         this.multipartResolver = null;  
  15.         if (logger.isDebugEnabled()) {  
  16.             logger.debug("Unable to locate MultipartResolver with name '" + MULTIPART_RESOLVER_BEAN_NAME +  
  17.                     "': no multipart request handling provided");  
  18.         }  
  19.     }  
  20. }  

 原来,这里的初始化,指的是DispatcherServlet从容器(WebApplicationContext)中读取组件的实现类,并缓存于DispatcherServlet内部的过程。还记得我们之前给出的DispatcherServlet的数据结构吗?这些位于DispatcherServlet内部的组件实际上只是一些来源于容器缓存实例,不过它们同样也是DispatcherServlet进行后续操作的基础。 

如果对上面的代码加以详细分析,我们会发现initMultipartResolver的过程是查找特定MultipartResolver实现类的过程。因为在容器中查找组件的时候,采取的是根据特定名称(MULTIPART_RESOLVER_BEAN_NAME)进行查找的策略。由此,我们可以看到DispatcherServlet进行组件初始化的特点: 

结论: DispatcherServlet中对于组件的初始化过程实际上是应用程序在WebApplicationContext中选择和查找组件实现类的过程,也是指定组件在SpringMVC中的默认行为方式的过程。

除了根据特定名称进行查找的策略以外,我们还对DispatcherServlet中指定SpringMVC默认行为方式的其他的策略进行的总结: 

 

  • 名称查找 —— 根据bean的名字在容器中查找相应的实现类
  • 自动搜索 —— 自动搜索容器中所有某个特定组件(接口)的所有实现类
  • 默认配置 —— 根据一个默认的配置文件指定进行实现类加载

转载于:https://my.oschina.net/zjllovecode/blog/1512450

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值