SpringMVC-介绍

1. SpringMVC简介

SpringMVC主要解决了V-C交互的问题,即如何使用控制器接收请求并给予响应的问题。

MVC:Model(数据模型) + View(视图) + Controller(控制器)

2. 关于SpringBoot框架

SpringBoot框架可以简单的当作为一个“加强版的SpringMVC框架”,相比直接创建SpringMVC框架的项目,如果创建的是SpringBoot框架的项目,可以不必添加非常用依赖,也可以省去大量的常规配置!

3. 创建SpringBoot项目

创建项目时,选择Spring Initializr的创建向导:

当创建项目时,在以上界面点击下一步后长时间无响应时,应该在以上界面的右侧选中Custom,并使用 https://start.springboot.io 网址并再次点击下一步。

在接下来的界面中确定当前项目的一些参数,例如Group、Artifact、Java Version等:

在选择依赖的界面,在左侧选择Web,在右侧勾选Spring Web

完成后,项目即创建完成,第1次使用时,开发工具会自动下载大量依赖。

4. 启动SpringBoot项目

创建好的SpringBoot中默认在src/main/java下就已经有一个package了,其名称是创建项目时指定的Group和Artifact决定的:

注意:这个包是SpringBoot默认指定的组件扫描的包,所以,项目中的各组件都必须放在这个包或其子孙包中!

在这个包中,默认已经存在SpringmvcApplication类,类的名称是创建项目时指定的Artifact决定的:

 这个类中有main()方法,执行这个main()方法就可以启动整个项目,所以,这个类也称之为“启动类”。

启动成功后,控制台输出内容例如:

通过启动日志可以看到,启动当前项目时是启动了Tomcat的,并将项目部署在8080端口,这个Tomcat是当前项目内置的!

另外,通过以上启动日志可以看到Tomcat在部署当前项目时配置的Context Path是空值:

所以,后续在访问当前项目时,在URL中端口号的右侧不必再添加项目名称或对应的路径值!

在项目的src/main/resources下默认就存在application.properties文件,这个文件是项目的配置文件:

 SpringBoot框架会自动读取项目中的配置,并且,SpringBoot还约定框架相关配置的属性名称,所以,除非是编写自定义的配置,否则,各配置的属性名称是相对固定的!

如果需要指定当前项目启动时,将Tomcat启动在哪个端口,可以添加配置:

 如果使用的是MacOS或Linux操作系统,可能无法直接将端口号修改为80,因为操作系统将80端口视为敏感端口,需要事先修改操作系统的设置才可以使用。

5. 添加静态页面

在SpringBoot项目中,如果创建项目时就已经勾选了Spring Web依赖,默认情况下在src\main\resources下就已经创建好了static文件夹:

该文件夹是SpringBoot框架默认的静态资源文件夹,项目中的静态资源应该放在这个文件夹之下,例如.html.css.js、图片文件等,例如,在static文件夹下创建index.html

完成后,重启项目,打开浏览器,输入 http://localhost 即可访问这个页面!

SpringBoot框架将index.html设置为默认访问的资源,所以,如果需要访问的是 http://localhost/index.html,在URL中可以不必显式的指定资源名,即使用 http://localhost 即可!

【练习】

设计register.html页面作为“用户注册”页面,在该页面需要1个表单,表单中至少包含用户名、密码、年龄、手机、邮箱对应的5个输入框和1个提交按钮。最终,通过 http://localhost/register.html 可以打开这个页面。

6. SpringMVC中的控制器

在传统Java EE项目中是使用Servlet组件(自定义类继承自HttpServlet)作为控制器的,在SpringMVC框架中可以自定义任何类作为控制器类,推荐使用Controller作为类名的最后一个单词,并且在类的声明之前添加@Controller注解即可将该类配置为控制器类!例如,在cn.tedu.springmvc包中创建UserController类并添加@Controller注解:

在传统的Java EE中,如果需要处理客户端提交的请求,需要在Servlet类之前添加注解来配置请求路径,并重写Servlet中的doGet()doPost()方法来处理GET或POST类型的请求,在SpringMVC中:

  • 在方法的声明之前使用@RequestMapping注解来配置请求路径;

  • 应该使用public权限;

  • 暂时使用String作为返回值类型;

  • 方法名称可以自定义;

  • 参数列表暂时为空;

  • 在方法的声明之前暂时添加@ResponseBody注解。

例如:

完成后,重启项目,在浏览器中通过 http://localhost/hello 即可测试访问,在浏览器中将可以看到OK字样,并且在IntelliJ IDEA的控制台可以看到以上输出的内容!

同一个控制器中可以有若干个处理请求的方法,例如此前练习的“用户注册”页面可以将请求路径设计为/register

然后,在UserController中补充处理/register的方法:

完成后,重启项目,在浏览器重新打开 http://localhost/register.html(如果已经打开,则需要刷新),可以不必输入任何内容,直接点击提交按钮,在浏览器将可以看到Register OK字样,并且在IntelliJ IDEA的控制台可以看到以上输出的内容!

7. SpringMVC框架接收请求参数

register.html中,为表单中的各控件配置name属性就可以设置请求参数的名称,例如:

 在SpringMVC中,如果需要接收这些请求参数,直接将各请求参数声明为处理请求的方法的参数即可,例如:

 完成后,即可重启项目,观察运行结果。

【练习】

自行设计login.html页面,页面中需要用户名、密码的输入框和提交按钮,提交请求后,服务器端可以接收到用户名和密码的值。

使用以上做法接收请求参数时,如果客户端提交的请求参数的数量较多,就会导致处理请求的方法的参数列表中需要声明对应的多个参数,为了避免参数的数量过多,可以将客户端提交的多个请求参数进行封装!例如,创建User类:

public class User {
​
    public String username;
    public String password;
    public Integer age;
    public String phone;
    public String email;
 
    // SETTERS & GETTERS
    // toString()
    
}

提示:以上各属性的访问权限并不重要!

然后,将处理请求的方法的参数列表中使用该类型即可:

完成后,再次重启项目,重新提交表单,可以看到仍正常接收请求参数!

所以,在SpringMVC框架中,当需要接收客户端提交的请求参数时,可以将各参数直接声明为处理请求的方法的参数,也可以将这些请求参考封装起来,理论上来说,如果请求参数的数量较少且可控,则直接声明即可,如果请求参数的数量较多或可能发生调整,应该封装,实际情况是应该封装,除非请求参数只有1个。

8. 关于转发

当需要向客户端响应HTML网页时,可以先在pom.xml中添加SpringBoot整合Thymeleaf的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

在SpringBoot中,当创建项目时勾选了Spring Web的依赖后,默认在src/main/resources下就已经创建好了templates文件夹:

这个templates文件夹就是SpringBoot项目中用于存放模版页面的文件夹!

templates下创建hello.html文件:

 然后,在UserController中,将原本的hello()方法上的@ResponseBody删除,并将返回值改为hello

完成后,重启项目,再次访问 http://localhost/hello 即可看到所设计的hello.html网页!

在以上操作中,首先删除了@ResponseBody注解,该注解的作用是“将方法返回的数据直接响应到客户端”,简称“响应正文”!

然后,在处理请求的方法中,返回了"hello"字符串,是因为SpringMVC框架中,当控制器中处理请求的方法的返回值类型是String时,表示“返回视图名称”,即“由哪个视图来负责响应”,当前项目是添加了SpringBoot整合Thymeleaf的依赖,其对“视图名称”的处理方法是根据/templates/与视图名称与.html来确定由哪个文件负责响应,即:

"/template/" + 返回值 + ".html" = "/templates/hello.html"

所以,拼接出来的结果就是视图文件(HTML文件)的位置!所以,当HTML文件直接放在templates下,而并不在其子级文件夹中时,也可以简单的理解为“返回的字符串就是文件名”!

【练习】

templates下创建success.html页面用于表示“成功”时显示的页面,再创建error.html用于表示“失败”时显示的页面,当用户提交登录时,如果用户名和密码分别是root1234,则视为登录成功,否则视为登录失败,无论成功或失败都应该显示对应的页面!

9. 转发时封装数据并显示

当控制器向模版页面转发时需要将数据交给模版页面去显示时,需要在处理请求的方法的参数列表中添加ModelMap类型的参数,并在处理过程中,调用该参数对象的addAttribute()方法封装数据:

 后续,在页面中需要显示数据时,通过Thymeleaf表达式来显示数据即可:

 完成后,重启项目,在登录时分别使用错误的用户名和错误的密码即可看到执行效果!

10. 重定向

在SpringMVC中,当控制器处理请求的方法的返回值类型是String且没有添加@ResponseBody时,返回值使用redirect:目录路径即可实现重定向,例如:

1. 关于Session

通常,会使用Session来保存:

  • 用户身份的唯一标识,例如用户的ID、用户名、手机号码等具有“唯一”特点的数据;

  • 高使用频率的数据,例如用户名、用户的头像、用户的等级等经常需要使用的数据;

  • 其它不便于使用其它技术处理存取的应用场景。

另外,Session在以下情况会消失/不可用:

  • 超时;

  • 浏览器关闭;

  • 服务器关闭。

在SpringMVC框架中,当需要使用Session时,只需要在处理请求的方法的参数列表中添加HttpSession类型的参数即可,例如:

 

当向Session中存入数据后,后续的访问中就可以从Session中取出此前存入的数据,例如:

 

包括在Thymeleaf的模版页面中也可以显示Session中的数据,例如:

 

2. SpringMVC拦截器(Interceptor)的基本使用

在一个项目中,需要登录以后才可以进行的访问的种类可能非常多,如果在各个处理请求的方法中都添加判断语句进行判断,则这段代码将大量的出现在项目中,操作麻烦且不利于管理和维护!使用拦截器就可以很方便的解决这个问题。

要使用拦截器,必须先自定义类,实现HandlerInterceptor接口,并添加接口中定义的3个方法:

package cn.tedu.springmvc;
​
public class LoginInterceptor implements HandlerInterceptor {
​
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        System.out.println("LoginInterceptor.preHandle");
        return false;
    }
​
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        System.out.println("LoginInterceptor.postHandle");
    }
​
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println("LoginInterceptor.afterCompletion");
    }
​
}

仅仅创建了拦截器是不够,在SpringMVC中拦截器还需要进行配置后才可以使用,则需要创建配置类,例如创建SpringMvcConfigurer实现WebMvcConfigurer接口,重写接口中的addInterceptors()方法进行配置:

@Configuration
public class SpringMvcConfigurer implements WebMvcConfigurer {
​
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 创建自定义的拦截器对象
        LoginInterceptor loginInterceptor = new LoginInterceptor();
        // 添加注册拦截器
        registry.addInterceptor(loginInterceptor)
                // 添加要拦截的路径
                .addPathPatterns("/", "/index.html");
    }
​
}

完成后,重启项目,在浏览器中访问主页,结果在浏览器中显示一片空白,并且,在IntelliJ IDEA的控制台可以看到:

 

 

在拦截器中的preHandle()方法的返回值决定了“阻止”或是“放行”,例如,将返回值改为true

 

 

再次执行,页面是可以正常打开的,并且,在IntelliJ IDEA的控制台可以看到:

 

 

通过以上运行效果和输出结果可以看到,拦截器中的preHandle()方法是在控制器之前执行的,是真正起到“拦截”作用的方法,而postHandle()afterCompletion()是在控制器之后执行的,并不能真正的“拦截”控制器执行!所以,可以在preHandle()方法中添加一些判断标准,以决定当前请求是否允许执行,例如:

 

 

3. SpringMVC拦截器的配置

在配置拦截器时,当添加了拦截器对象后,可以调用返回结果的addPathPatterns()方法来配置需要“拦截”的请求路径,例如:

 

 

注意:在拦截器中配置的所有路径都必须使用/作为第1个字符!

以上addPathPatterns()方法的源代码为:

可以看到,addPathPatterns()方法被重载了2次,可以使用List<String>String...作为参数,无论使用哪种参数格式,都可以配置多个路径被“拦截”,并且,在配置路径时,可以使用*作为通配符!

例如,将/user/*配置为拦截路径时,如果客户端提交的请求路径是/user/info/user/password都会被拦截!需要注意,在SpringMVC拦截器的路径中,1个星号只能通配当前层级的资源名,不可以通配多个层级,例如配置为/user/*时就不可以匹配到/user/info/change这样的路径!如果需要通配多个层级,必须使用2个连续的星号,例如配置为/user/**可以匹配到/user/info/user/info/change/user/list/2020/10等……

如果大量使用通配符,就可能导致匹配的范围多大!例如配置为/user/*/user/**时,可以匹配/user/info/user/password,也可以匹配到/user/register/user/login……其实,在项目中,必然存在某些请求路径的前半部分相同,后半部分不同的多个路径中,有些是需要拦截的,而有些并不需要拦截!对于这种情况,可以调用excludePathPatterns()方法来添加“例如”,也就是不拦截的请求路径!例如配置为:

 

 以上excludePathPatterns()方法也被重载了2次,同样支持List<String>String...类型的参数!

注意:当同时配置拦截路径和例如时,必须先配置拦截路径,后配置例外!

另外,同一个项目中是可以同时创建并使用多个拦截器的,当某个请求路径会被多个拦截器拦截时,必须每个拦截器都放行,才可以执行到控制器中,且各拦截器的执行先后顺序取决于“注册”时的顺序!

注意:使用拦截器的目的并不一定是全部要检查是否能够继续向后执行,只要项目中有若干个请求路径都执行相同的任务,就可以使用拦截器!

4. 关于@RequestMapping注解

在SpringMVC框架中,在处理请求的方法之前添加@RequestMapping,可以用于配置请求路径与处理请求的方法之间的绑定关系!

除了在方法的声明之前添加该注解以外,还可以将该注解添加到类的声明之前,

 当在类的声明之前添加了该注解后,这个类中配置的各路径都需要添加以上配置的前缀,例如原本是通过 http://localhost/index.html 来访问主页,现在就需要改为 http://localhost/user/index.html

在实际项目开发中,推荐为每一个控制器类的声明之前配置@RequestMapping注解,用于配置当前类中处理请求的方法的URL的前缀!

在配置@RequestMapping中的URL时,URL值的两端多余的/是可以忽略的,例如:

序号类的声明之前的配置方法的声明之前的配置
1/user/list
2/userlist
3/user//list
4/user/list
5user/list
6userlist
7user//list
8user/list

以上8种配置的组合是完全等效的!推荐使用以上第1种!

关于@RequestMapping注解的源代码片段:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
public @interface RequestMapping {
    // 其它代码
}

可以看到在@RequestMapping的声明之前还添加了@Target@Retention等注解,表示当前@RequestMapping同时具有以上4个注解配置的特性!

在源代码中还有:

/**
 * Assign a name to this mapping.
 * <p><b>Supported at the type level as well as at the method level!</b>
 * When used on both levels, a combined name is derived by concatenation
 * with "#" as separator.
 * @see org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder
 * @see org.springframework.web.servlet.handler.HandlerMethodMappingNamingStrategy
 */
String name() default "";

以上name()是注解中的属性,所以,在使用@RequestMapping时,可以配置名为name的属性,且该属性的值是String类型的,例如,可以配置为:

@RequestMapping(name = "Test Name")

以上源代码中的default ""表示该属性的默认值是""(空字符串)。

另外,还可以通过该属性的声明之前的注释来尝试理解该属性的作用,例如name属性的作用就是:Assign a name to this mapping.所以,这条属性没有任何实质的作用。

在源代码中还有:

/**
 * The primary mapping expressed by this annotation.
 * <p>This is an alias for {@link #path}. For example,
 * {@code @RequestMapping("/foo")} is equivalent to
 * {@code @RequestMapping(path="/foo")}.
 * <p><b>Supported at the type level as well as at the method level!</b>
 * When used at the type level, all method-level mappings inherit
 * this primary mapping, narrowing it for a specific handler method.
 * <p><strong>NOTE</strong>: A handler method that is not mapped to any path
 * explicitly is effectively mapped to an empty path.
 */
@AliasFor("path")
String[] value() default {};

以上代码表示了:在注解中可以使用value这个属性,属性值是String[]类型的,默认值是{}

注意:在注解中,value属性是默认的属性名,在配置时,如果该注解中只配置这1个属性,可以不必显式的指定该属性名,例如:

@RequestMapping(value={"a", "b", "c"})
@RequestMapping({"a", "b", "c"})

以上2种配置是完全等效的!

注意:在注解中,配置属性的值时,如果值类型是数组类型的,当需要配置的值只有1个值时,直接使用数组元素类型的值即可!例如:

@RequestMapping({"a"})
@RequestMapping("a")

以上2种配置是完全等效的!

以上源代码中还有@AliasFor("path")表示当前value属性与path属性是完全等效的!所以,还有如下源代码:

/**
 * The path mapping URIs (e.g. {@code "/profile"}).
 * <p>Ant-style path patterns are also supported (e.g. {@code "/profile/**"}).
 * At the method level, relative paths (e.g. {@code "edit"}) are supported
 * within the primary mapping expressed at the type level.
 * Path mapping URIs may contain placeholders (e.g. <code>"/${profile_path}"</code>).
 * <p><b>Supported at the type level as well as at the method level!</b>
 * When used at the type level, all method-level mappings inherit
 * this primary mapping, narrowing it for a specific handler method.
 * <p><strong>NOTE</strong>: A handler method that is not mapped to any path
 * explicitly is effectively mapped to an empty path.
 * @since 4.2
 */
@AliasFor("value")
String[] path() default {};

在源代码中,还有:

/**
 * The HTTP request methods to map to, narrowing the primary mapping:
 * GET, POST, HEAD, OPTIONS, PUT, PATCH, DELETE, TRACE.
 * <p><b>Supported at the type level as well as at the method level!</b>
 * When used at the type level, all method-level mappings inherit this
 * HTTP method restriction.
 */
RequestMethod[] method() default {};

以上源代码表示:在@RequestMapping注解中可以配置名为method的属性,值的类型是RequestMethod数组类型,默认值为空!

以上method属性用于限制请求方式,当没有配置该属性的值时,无论哪种请求方式都是允许的,如果配置为某1种请求方式,则只有这种请求方式是允许的,其它请求方式将导致405错误,例如配置为:

@RequestMapping(path="/login", method=RequestMethod.POST)

则只允许通过POST类型的请求对/login对应的URL进行访问!

5. 关于@RequestMapping的进阶版注解

在SpringMVC框架中还定义了@GetMapping注解,其源代码是:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.GET)
public @interface GetMapping {
​
    /**
     * Alias for {@link RequestMapping#name}.
     */
    @AliasFor(annotation = RequestMapping.class)
    String name() default "";
    
    // ...
    
}

所以,@GetMapping就是一个已经将请求类型限制为GET 类型的@RequestMapping注解!

与之类似的还有@PostMapping@PutMapping@DeleteMapping等。

6. 关于@Controller注解

@Controller是Spring框架定义的注解,在Spring框架的作用范围内,它与@Component@Service@Repository这几个注解是完全等效的!

但是,在SpringMVC中,控制器类必须添加@Controller注解,不可以使用@Component等3个中的某1个。

在实际开发中,还经常会使用到@RestController注解,也是可以添加在控制器类的声明之前的,其源代码为:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
    // ...   
}

可以看到,@RestController注解同时具有@Controller@ResponseBody的效果!

7. 关于@ResponseBody注解

如果在控制器类的处理请求的方法的声明之前添加@ResponseBody注解,表示该方法将“响应正文”,则方法的返回值会响应到客户端去!

如果在控制器类的声明之前添加@ResponseBody注解,表示该类中所有处理请求的方法都将“响应正文”!

目前,服务器端向客户端响应正文是主流的开发模式!

通常,响应到客户端的数据是需要一定的格式的,例如,当需要向客户端响应某个用户的详情时,用户详情中包含用户名、密码、年龄、手机、邮箱等数据,但是,方法的返回值却只有1个,不可能响应为这样的数据:

root12342513800138001root@tedu.cn

所以,响应到客户端的数据必须是有一定格式的,例如,可以把数据组织为JSON格式的字符串:

{
    "username":"root",
    "password":"1234",
    "age":25,
    "phone":"13800138001",
    "email":"root@tedu.cn"
}

在SpringMVC中,如果要向客户端响应JSON格式的数据,需要将数据的属性封装在某个类中,例如以上JSON数据中就包含username等5个属性,那就需要将这5个属性放在某个类中,然后,将这个类作为处理请求的方法的返回值类型即可:

为了正常演示接下来的效果,先禁用拦截器,将拦截器的配置类SpringMvcConfigurer的声明之前的@Configuration删除即可,并且,务必保证当前处理请求的方法是响应正文的。

 在SpringMVC框架中,当需要响应正文时,SpringMVC框架会通过“转换器”(Converter)将处理请求的方法返回的结果转换为响应到客户端的数据,并且,SpringMVC框架内置了多个不同的转换器,针对不同类型的返回值会自动使用不同的转换器,例如,当返回值类型是String时,就会自动使用StringHttpMessageConverter转换器……当然,肯定存在一些类型是SpringMVC框架的设计者没有预料到的,例如框架的使用者自定义的数据类型,当处理请求的方法的返回值类型是这些种类,且当前项目中添加jackson-databind的依赖时(SpringBoot项目默认已添加),SpringMVC框架就会自动使用jackson-databind中的转换器,而jackson-databind的转换器的工作方式就是将对象转换为JSON格式的字符串,并且,还将响应头(Response Headers)中的Content-Type设置为application/json,使得客户端程序接收到该数据时能够明确数据的类型。

【小结】SpringMVC框架小结

  • 【理解】SpringMVC框架的主要作用:

    • 接收请求,处理响应结果;

  • 【掌握】创建控制器类:

    • 在SpringBoot项目的根包或其子孙包中创建类,在类的声明之前添加@Controller@RestController注解;

  • 【掌握】接收客户端的请求:

    • 在处理请求的方法之前添加@RequestMapping@GetMapping等注解,在注解中配置value/path参数;

  • 【掌握】自定义方法处理客户端提交的请求,关于方法的声明:

    • 【访问权限】应该使用public权限;

    • 【返回值类型】当需要转发或重定向时,应该使用String作为返回值类型,当需要响应JSON结果时,应该使用自定义类作为返回值类型;

    • 【方法名称】自定义;

    • 【参数列表】可以按需添加HttpServletRequestHttpServletResponseHttpSessionModelMap,及客户端提交的请求参数,或封装了客户端提交的请求参数的类型的对象。

  • 【掌握】接收客户端提交的请求参数

    • 将客户端提交的请求参数设计为处理请求的方法的参数;

    • 【推荐】将客户端提交的请求参数封装在自定义类中(各属性需要有匹配的SETTERS、GETTERS方法),使用自定义类作为方法的参数。

  • 【掌握】转发

    • 需要添加Thymeleaf依赖(如果使用其它视图,可能需要添加其它依赖);

    • 使用String作为处理请求的方法的返回值类型,并保证返回的字符串与/templates/.html能拼接出HTML页面的位置;

    • 如果转发时需要封装数据,需要在方法的参数列表中添加ModelMap参数,并在转发之前将数据封装在ModelMap对象中。

  • 【掌握】重定向

    • 使用String作为处理请求的方法的返回值类型,返回redirect:目标路径字符串。

  • 【掌握】响应正文

    • 使用@RestController注解,或使用@ResponseBody注解;

    • 使用自定义类作为返回值类型即可响应JSON格式的字符串。

  • 【掌握】拦截器

    • 自定义类实现HandlerInterceptor接口,主要重写其中的preHandle()方法;

    • 自定义类实现WebMvcConfigurer接口,在类的声明之前添加@Configruation注解,重写addInterceptors()方法以注册并配置拦截器;

    • 通过addPathPatterns()配置拦截路径,通过excludePathPatterns()方法配置例外路径。

  • 【掌握】学会阅读注解的源代码,了解注解参数的配置方式。

 

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Healer_小振

感谢大佬的支持和鼓励!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值