JavaWeb(一)Servlet

目录

What is Servlet?

Work Pattern Of Servlet

Servlet API

Principle Of Work

Life Cycle

    Source code analysis

    Servlet接口

    GenericServlet类

    HttpServlet类

    ServletConfig

    ServletContext

Servlet details

    Servlet和线程安全

    web.xml文件配置Servlet

补充

     BaseServlet

后话


What is Servlet?

    Servlet(Server Applet),由全称可知,是用Java编写的服务器端程序。Servlet主要处理请求和发送响应的过程,以此来实现交互的动态页面。以Java的角度来看,Servlet无非只是一个接口,在这里需要将它泛化成任意一个实现了Servlet接口的类都是Servlet。在上述所提及的请求和响应的部分,等有空的时候专门写一篇《Http请求和响应》


Work Pattern Of Servlet

    1.    客户端发送请求至服务器

    2.    服务器启动并调用Servlet,Servlet根据请求生成响应的内容并传至服务器

    3.    服务器将响应内容返回客户端


Servlet API

    Servlet API 包含了以下四个包:

        1.javax.servlet 包含了定义Servlet和Servlet容器之间契约类和接口。

        2.javax.servlet.http 包含了定义HTTP Servlet和Servlet容器之间的关系。

        3.javax.servlet.annotation 包含了标注Servlet, Filter, Listener的标注。

        4.javax.servlet.descriptor 包含了提供程序化登录Web应用程序的配置信息的类型。

在这里补充一下,实现Servlet的三种方式:

  • 实现javax.servlet.Servlet接口

  • 继承javax.servlet.GenericServlet类

  • 继承javax.servlet.http.HttpServlet类


Principle Of Work

    在javax.servlet包中定义了Servlet和Servlet容器之间的契约,所谓的容器是什么呢?即承载Servlet类的服务器,例如通常学习时使用的Tomcat。那么所谓的契约又是什么呢?这个契约是:基于面向对象编程思想,Servlet的容器将Servlet类载入内存,创建一个实现Servlet接口的实例类并且调用它具体的方法。

    在这里以Tomcat为例,讲述一下其工作过程以便更好地理解原理。

        1.Tomcat将http请求文本接收并解析,然后将其封装成HttpServletRequest类型的request对象。

        2.Tomcat同时会要响应的信息封装为HttpServletResponse类型的response对象,再将response交给Tomcat,Tomcat就会将其变成响应文本的格式发送给浏览器。

补充:对于每一个应用程序来说,Servlet容器会创建有且仅有一个ServletContext对象,其封装了上下文的环境详情。


Life Cycle

    Servlet的生命周期是指从创建直至毁灭的整个过程。其中,init(ServletConfig)service(ServletRequest, ServletResponse)destroy()是Servlet生命周期的方法。Servlet容器会根据一定的规则来调用这三个方法。

    1.void init(ServletConfig servletconfig)

        当Servlet第一次被请求时,Servlet容器会传入一个ServletConfig对象从而对Servlet对象进行初始化。该方法只有在第一次请求时才会调用,即后续再次请求相同Servlet时,容器不会再调用该方法。

    2.void service(ServletRequest servletrequest, ServletResponse servletresponse)

        每当请求Servlet时,Servlet容器就会调用这个方法来处理来自客户端的请求,并把格式化的响应传回客户端。在这之间会检查HTTP请求类型(GET、POST、PUT、DELETE)并在适当的时候调用相应的方法。因此对service()不需要做任何修改。

    3.void destroy()

        当要销毁Servlet时,Servlet容器就会调用这个方法。该方法只有一次调用。一般在该方法里写一些清理代码。

    接下来编写一些代码来验证上面所述的生命规则:

public class AServlet implements Servlet {

	/*
	 * 		它也是生命周期方法
	 * 		它会在Servlet被销毁之前调用,并且它只会被调用一次
	 * */
	@Override
	public void destroy() {
		System.out.println("destroy()...");
	}

	@Override
	public ServletConfig getServletConfig() {
		System.out.println("getServletConfig()...");
		return null;
	}

	@Override
	public String getServletInfo() {
		System.out.println("getServletInfo()...");
		return "Servlet";
	}

	/*
	 * 		它是生命周期方法
	 * 		它会在Servlet对象创建之后马上执行,并只执行一次!
	 * */
	@Override
	public void init(ServletConfig servletConfig) throws ServletException {
		System.out.println("init()...");
		//获取初始化参数
		System.out.println(servletConfig.getInitParameter("p1"));
		System.out.println(servletConfig.getInitParameter("p2"));
		
		Enumeration e = servletConfig.getInitParameterNames();
		while (e.hasMoreElements()) {
			System.out.println(e.nextElement());
			
		}
	}

	@Override
	public void service(ServletRequest request, ServletResponse response) throws ServletException, IOException {
		System.out.println("service()...");
	}

}

   web.xml配置情况如下(web.xml详情)

  <servlet>
    <servlet-name>AServlet</servlet-name>
    <servlet-class>home.web.servlet01.AServlet</servlet-class>
    <init-param>
      <param-name>p1</param-name>
      <param-value>v1</param-value>
    </init-param>
    <init-param>
      <param-name>p2</param-name>
      <param-value>v2</param-value>
    </init-param>
  </servlet>
  <servlet-mapping>
    <servlet-name>AServlet</servlet-name>
    <url-pattern>/AServlet</url-pattern>
  </servlet-mapping>
  <servlet>

    在web.xml设置好相关的Servlet属性后,执行结果:

     当停止了tomcat后,即Servlet容器将会调用destroy()方法来做清理处理,其结果如下:

    在这里必须声明一下:Servlet容器调用destroy()方法并不是真正的销毁了Servlet,而是给编程人员用于做自己想做的事。Servlet是通过JVM的垃圾回收器回收的。因此手动执行也不会清理该Servlet。测试代码可自行编写。

    接下来剖析一下源码。

    Source code analysis

        理论上来说,编程人员要创建一个Servlet,就得要实现Servlet接口。而根据上面所述,Servlet中的许多方法是不需要编程人员去实现接口定义中的生命周期方法。实际上,通常都以继承HttpServlet类去间接实现Servlet接口,以此来简化servlet的编写。

//HttpServlet继承关系
        public abstract class HttpServlet extends GenericServlet

//GenericServlet继承关系
        public abstract class GenericServlet implements Servlet, ServletConfig, ...

//Servlet接口中定义的方法
    	public abstract void init(ServletConfig servletconfig);
        public abstract ServletConfig getServletConfig();
	    public abstract void service(ServletRequest servletrequest, 
                          ServletResponse servletresponse);
        public abstract String getServletInfo();
        public abstract void destroy();

        从以上代码可知,HttpServlet继承GenericServlet类GenericServlet实现Servlet接口。

    Servlet接口

        首先从Servlet接口中的方法说起,除去声明了三个生命周期方法以外,还声明了getServletConfig()方法获取ServletConfig对象以及获取Servlet信息的getServletInfo()方法。

        那么ServletConfig里又有什么呢?查看了一下源码,发现它是一个接口,里面声明了获取Servlet相关信息的方法:

        public abstract String getServletName();
        public abstract ServletContext getServletContext();

        其中,ServletContext是一个域对象,其中包含了上下文信息,使用其可以给多个Servlet传递数据。所谓的上下文,不过是个中式代名词,更多的是用其英文或者称其为“环境”。在这里不过多地阐述何为上下文,如果对上下文不太理解的请点击该链接:上下文理解

        必须注意的是,一个项目只有一个ServletContext对象,并且在Servlet容器(Tomcat)启动时就创建,在容器关闭时就销毁。简而言之:“独一无二,与天同寿”

        至此,可以得知上述所有的方法,有一定相关性。GenericServlet实现Servlet接口,而编写一个类继承GenericServlet从而使用上述所提及的Servlet接口中的方法。尽管在Servlet中可以获取到ServletConfig从而获取ServletContext,相比于直接让GenericServlet实现ServletContext接口更加的麻烦。

    GenericServlet类

        接着再来看GenericServlet类的部分构成:

        private transient ServletConfig config;
        public GenericServlet()
        public void init(ServletConfig config)
        public void init() throws ServletException
        public abstract void service(ServletRequest servletrequest, 
                ServletResponse servletresponse) throws ServletException, IOException;
        public void destroy()

       看到构成可以发现,在GenericServlet中定义了两个init方法,一个是带有参数ServletConfig的,一个是无参方法。在这两个方法中又分别做了什么事?

        public void init(ServletConfig config) throws ServletException {
            this.config = config;
            init();
        }

        public void init() throws ServletException { }

        为什么在带参的init方法中会调用一个无参的init方法呢?首先得明确两个点:一是GenericServlet为抽象类,二是创建一个Servlet需要继承该抽象类。那么当我们需要在servlet中做一些初始化操作时,就得重写带参init方法,就会“破坏”原有的抽象类中的带参init方法,导致this.config无法得到赋值,将会一直是null。如果想要得到赋值,就得在重写的带参init方法中调用父类的原有的带参init方法,这样就显得麻烦以及容易出现忘记编写的情况。因此,在GenericServlet类增加一个无参的init方法,以后就可以在需要的时候,重写无参的init方法。

        在GenericServlet类中,生命周期方法中最为重要的service()方法是一个抽象方法,说明要有一个子类去实现该方法。以下就得引出最后一个类。

    HttpServlet类

        先来看HttpServlet类的部分构成:

        //各种请求方式的处理
        protected void doGet(HttpServletRequest req, HttpServletResponse resp)
        protected void doHead(HttpServletRequest req, HttpServletResponse resp)
        protected void doPost(HttpServletRequest req, HttpServletResponse resp)
        protected void doPut(HttpServletRequest req, HttpServletResponse resp)
        protected void doDelete(HttpServletRequest req, HttpServletResponse resp)
        protected void doOptions(HttpServletRequest req, HttpServletResponse resp)
        protected void doTrace(HttpServletRequest req, HttpServletResponse resp)
        
        //重写的service方法以及新的service方法
        protected void service(HttpServletRequest req, HttpServletResponse resp)
        public void service(ServletRequest req, ServletResponse res)

        第一部分是各种请求方式的处理,不予以过多阐述,主要的关注点在第二部分的两个不同带参类型的service()方法。现附上其内部处理的代码:

        public void service(ServletRequest req, ServletResponse res) {
            HttpServletRequest request;
            HttpServletResponse response;
            try {        //类型强转
                request = (HttpServletRequest)req;
                response = (HttpServletResponse)res;
            }
            catch (ClassCastException e) {
                throw new ServletException("non-HTTP request or response");
            }
            service(request, response);
        }

        该方法中最主要做的一件事就是将ServletRequest和ServletResponse两个类的对象分别强转成HttpServletRequest和HTTPServletResponse。

        这里引发了个思考,为什么可以这样转?那么,通过打印输出,将req和res分别打印出来查看其所属类型,其结果如下:

        //request和response实际的类型
        request---org.apache.catalina.connector.RequestFacade
        response---org.apache.catalina.connector.ResponseFacade

        //根据结果查看了一下Tomcat中的源码
        public class RequestFacade implements HttpServletRequest

        //附上HttpServletRequest和ServletRequest的关系
        public interface HttpServletRequest extends ServletRequest

        根据上面所表现的来看,其继承关系:RequestFacade->HttpServletRequest->ServletRequest。实际上的类型是RequestFacade,这样的强转就等同于多态中的向上转型。

        在上述这个方法的最后调用了service(HttpServletRequest req, HttpServletResponse resp),将转换后的请求和响应对象传入这个方法。在这个内部调用的方法里面中包含了对各式各样的请求的处理。通过这样的结构设计,使编程人员只需要根据请求方式写相应的处理方法,如doGet(), doPost()等。

以上就是对Servlet整体的了解以及部分源码剖析。


下面将分别讲述前面提及的两个类。

    ServletConfig

首先来看源码:

        // 获取Servlet的名称,等同于web.xml文件的servlet-name
        public abstract String getServletName(); 
        // 获取ServletContext对象
        public abstract ServletContext getServletContext();
        // 获取在Servlet中初始化的参数的值
        public abstract String getInitParameter(String s);
        // 获取在Servlet中所有初始化参数的名字
        public abstract Enumeration getInitParameterNames();

一般来说,需要通过实现ServletConfig类去重写这些方法后再来使用。在上面说过,GenericServlet除了实现了Servlet接口外,还实现了ServletConfig接口,在GenericServlet已经帮编程人员写好了处理代码。而HttpServlet又继承于GenericServlet,编写的Servlet继承HttpServlet。因此,不需要再去获取ServletConfig去执行以上的方法。在上面举的代码例子附有

        System.out.println(servletConfig.getInitParameter("p1"));

是因为在AServlet中实现的Servlet接口方法,是最原始的实现。如果是继承HttpServlet则可以不需要书写servletConfig。

最后放一个栗子,实现页面访问次数的显示,效果自行测试。

        ServletContext app = this.getServletContext();
        Integer count = (Integer) app.getAttribute("count");
        if(count == null) {
            count = 1;
        } else {
            count++;
        }
        resp.setContentType("text/html;charset=utf-8");
        resp.getWriter().print("<h1>本页面一共被访问" + count + "次 </h1>");
        app.setAttribute("count", count);

    ServletContext

在前面的时候稍微地提了一下这个东西,“独一无二,与天同寿”是其显著的特点。作用就是为了在一个项目中的多个Servlet中传递数据,也就是共享数据。

举个栗子:

        //在AServlet的doGet()方法设置一个名为name,值为zhangsan的属性
        public void doGet(HttpServletRequest req, HttpServletResponse resp) {
            getServletContext().setAttribute("name", "zhangsan");
        }

        //在BServlet的doGet()方法尝试获取这个属性
        public void doGet(HttpServletRequest req, HttpServletResponse resp) {
            getServletContext().getAttribute("name");
        }

代码自行编写测试,而最终的显示结果是不同的Servlet可以获取到存在的属性,即不同Servlet之间可以共享数据

在ServletContext类中声明了许多的方法,在这里抽一些出来讲,也是比较常用的方法。

在ServletConfig类中,有一个getInitParameter(String s)方法,而在ServletContext中也有这样的同名方法。通过ServletContext设置的初始化属性为全局属性,何为全局,即是可以让所有Servlet都能访问的属性,这跟单个Servlet中自行配置的初始化属性不同。广义的来说,是Web项目的初始化属性。

那么要如何才能设置Web项目的初始化属性呢,在web.xml文件中配置好相关属性即可。如下所示:

        <context-param>
            <param-name>context-param</param-name>
            <param-value>context-value</param-value>
        </context-param>

至于实现的代码部分过于简单就不放了。

除了上述两种方法以外,还可以用ServletContext获取Web项目中的资源的路径、内容。

        public abstract String getRealPath(String s);
        public abstract InputStream getResourceAsStream(String s);

以下为测试代码,尽量自行编写测试。

        // realPath的值为a.txt文件的绝对路径
        String realPath = this.getServletContext().getRealPath("/WEB-INF/a.txt");
        System.out.println("a.txt的真实路径为" + "---" + realPath);

        // 获取资源流
        InputStream input = this.getServletContext().getResourceAsStream("/WEB-INF/a.txt");
        String s = IOUtils.toString(input);
        System.out.println(s);

测试结果如下:

需要注意的地方:

     getRealPath(String s)方法中的s必须写资源的相对路径,相对当前.class文件所在目录,细致点说就是相对于Servlet的路径、

     servletContext的getResourceAsStream方法是返回字节流对象的,若文件中带有中文需要转成字符流来读取。

     如果对这两个名词不太熟悉或者对转换方式不太那么的熟悉的话,可以看以下代码:

        InputStream input = this.getServletContext().getResourceAsStream("/WEB-INF/a.txt");
        // 字节流转字符流
        if (input != null) {
            InputStreamReader isr = new InputStreamReader(input);
            BufferedReader br = new BufferedReader(isr);
            String s = null;
            while ((s = br.readLine()) != null) {
                System.out.println(s);
            }
        }

至于演示代码中的IOUtils类,是我自己导入的一个包commons-io-1.4自带的类。

补充(对Java学得比较深的可以看看,没有学得深的也可以看看留个印象):

    其实上述的getRealPath的使用并不是太好,在不同服务器上获得的实现是不同的,大多数都不是你想的路径,有可能还是null。

    那么实际上也有解决方案,先附上代码:

        //先得到ClassLoader,再调用其getResourceAsStream()
        ClassLoader cl = this.getClass().getClassLoader();
        // 相对/classes
        InputStream input = cl.getResourceAsStream("a.txt");    //这里是错的
        //这个才是对的。也就是说需要写好准确的位置 相对/classes
        InputStream input = cl.getResourceAsStream("home/web/servlet01/a.txt");

        //亦或者
        Class c = this.getClass();
        // 相对当前.class文件所在目录
        InputStream input = cl.getResourceAsStream("a.txt");
        
        String s = IOUtils.toString(input);
        System.out.println(s);

基于一个原则:尽量使用J2EE规范中的各层次classloader来获取资源,而不是去找文件的绝对路径。

对于上述的ClassLoader,ClassLoader cl = this.getClass().getClassLoader();System.out.println(cl); 得到下面结果:

context代表了项目名的路径,即Web项目的根路径,repositories在英文中是仓库的意思,在源码中是一个ArrayList,负责存储web应用的所有class文件,在这里显示的是其总路径。

PS:delegate根据源码中的意思是判断是否需要先让parent代理,在这里不过多阐述Tomcat源码,如果有时间的话,再写Tomcat源码的研究吧。


Servlet details

    Servlet和线程安全

    为了保证线程安全,有以下三点规则:

        1.不在Servlet中创建成员,创建局部变量即可。

        2.可以创建无状态成员。

        3.可以创建有状态成员,但状态必须是只读的。

    所谓的“有状态”就是有数据存储功能,具体来说就是有数据成员并且可以写入数据。而“无状态”就是没有存储数据功能,实质为只有方法没有数据成员或者有数据成员但数据成员是只读的

    附上一段代码以理解上面的话:

        // 无状态成员
        public class Example1 {
            public void hello() {
                System.out.println("Hello World!");
            }
        }
        // 有状态成员,状态只读
        public class Example2 {
            private String msg = "Hello World!";
            public String getMsg() {
                return msg;
            }
        }
        // 有状态
        public class Example3 {
            public String msg = "Hello World";
            public String getMsg() {
                return msg;
            }
            public void setMsg(String msg) {
                this.msg = msg;
            }
        }

现在开始讲Servlet和线程安全的关系,总的来说,Servlet并不是线程安全的。

首先得知道一个过程:Servlet容器(Tomcat)接收到客户端的HTTP请求时,会从ThreadPool(线程池)取出一个线程,找到请求对应的Servlet对象并初始化后调用service()方法。

然后再知道一个特点:每一个Servlet对象在Servlet容器中只有一个实例对象,即单例模式。

由这个过程和这个特点做一个假设:假设多个客户端请求同个Servlet,那么这多个请求对应的线程并发调用Servlet的service()方法。同时如果这个Servlet定义了有状态成员(实例变量或者静态变量),则可能会发生线程安全问题。

现用一段代码来演示:

        public class AServlet extends HttpServlet {
            String msg;
            protected void doPost(HttpServetRequest req, HttpServletResponse resp) {
                msg = req.getParameter("msg");
                try {
                    Thread.sleep(4000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                resp.getWriter().print(msg);
            }
        }

分别用两个浏览器打开这个Servlet,会发现输入的参数值和结果显示不匹配。那么如何解决线程问题,有以下几种方法:

    1.使用局部变量代替成员变量

        protected void doPost(...) {
            String msg;
            msg = request.getParameter("msg");
        }

    2.加锁,将成员变量使用synchronized关键字加锁

        String msg;
        protected void doPost(...) {
            synchronized (msg) {
                msg = request.getParameter("msg");
            }
        }

    3.实现SingleThreadMode接口(了解就好,因性能太低被抛弃)

        public class AServlet extends HttpServlet implements SingleThreadMode {}

        性能问题:每个Servlet创建多个对象实例。如果并发高,每个Servlet只支持20个线程的并发访问,其余挂起。

        如果想了解得更多,可以找一下apache.catalina.core.StandardWrapper类翻看一下allocate()方法的源码。

    web.xml文件配置Servlet

想在服务器启动时就创建Servlet而不是请求时创建Servlet,就在web.xml配置Servlet的servlet-class下方加上

        <load-on-startup>1</load-on-startup>	<!--非负整数告知顺序,为0则请求时创建-->

<url-pattern>是<servlet-mapping>的子元素,用来指定Servlet的访问路径。在mapping里可以写多个路径也可以在路径中使用通配符,可以匹配任何URL前缀或后缀。Eg:

        <url-pattern>/servlet/*</url-pattern> ==> /servlet/a

补充

     BaseServlet

BaseServlet的产生主要是为了在一个Servlet中可以有多个请求处理方法,为了能够处理不同的方法就得指定一个参数来表示请求处理的方法。从上面讲述的Servlet生命周期,我们可以知道,最终服务器都会调用Servlet中的service()方法,那么需要处理不同的方法就得在service()方法中写明判断。

那么,这里就产生了一个问题:如何根据名字来确定方法,并且执行?那这里就得说起反射,即得到类的Class对象,然后调用其一个方法通过方法名得到Method对象来完成需求。

代码如下所示:

    public void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 获取想请求的方法的名称
        String methodName = request.getParameter("method");
        if(methodName == null || methodName.trim().isEmpty()) 
            throw new RuntimeException("未传递method参数");
        // 得到方法的名称通过反射来调用
        Class c = this.getClass();
        Method method = null;
        try {
            method = c.getMethod(methodName, HttpServletRequest.class, HttpServletResponse.class);
            method.invoke(this, request, response);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
	
    public void add(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("add");
    }
	
    public void remove(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("remove");		
    }

通过输入BaseServlet?method=add和BaseServlet?method=remove显示结果如下所示:

这个BaseServlet,原则上应该是不在服务器中配置相应的访问路径,应该用于需要请求多个不同方法的Servlet继承这个BaseServlet。以演示转发和重定向为例,对BaseServlet进行扩充。

public class BaseServlet extends HttpServlet {
    public void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 获取想请求的方法的名称
        String methodName = request.getParameter("method");
        if(methodName == null || methodName.trim().isEmpty()) 
            throw new RuntimeException("未传递method参数");
        // 得到方法的名称通过反射来调用
        Class c = this.getClass();
        Method method = null;
        try {
            method = c.getMethod(methodName, HttpServletRequest.class, HttpServletResponse.class);
            String result = (String)method.invoke(this, request, response);
            if(result == null || result.trim().isEmpty())
                return ;
            if(result.contains(":")) {
                // 有冒号,则用冒号分割字符串,得到前后缀
                int index = result.indexOf(":");
                String prefix = result.substring(0, index);		// 截取出前缀
                String path = result.substring(index+1);	// 截取出后缀
                if(prefix.equalsIgnoreCase("r")) {
                    // 前缀如果是r则表示重定向
                    response.sendRedirect(request.getContextPath() + path);
                } else if (prefix.equalsIgnoreCase("f")) {
                    // 前缀如果是f则表示请求转发
                    request.getRequestDispatcher(path).forward(request, response);
                } else {
                    throw new RuntimeException("指定的操作:" + s + "当前版本还不支持");
                }
            } else {
                // 无冒号,则默认表示转发
                request.getRequestDispatcher(result).forward(request, response);
            }
			
        } catch (Exception e) {
            System.out.println("调用的方法" + methodName + "内部抛出了异常");
            throw new RuntimeException(e);
        }
    }
}

写一个AServlet继承BaseServlet,如下所示:

public class AServlet extends BaseServlet {

    public String func1(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("func1");
        return "f:/index.jsp";
    }
	
    public String func2(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("func2");
        return "r:/index.jsp";
    }
	
    public String func3(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("func3");
        return null;
    }
}

显示结果如下所示:

后话

    '''
        原本想在这一章写完Request和Response,
        后来觉得可能要写很多,
        还是决定单独开下一章放这两个重要的玩意。
        在这里更新了一下BaseServlet的内容
    '''

下一章:Request和Response

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值