JavaEE--框架篇(2)Spring MVC

目录

前言

Spring MVC

快速上手Spring MVC

Spring MVC工作原理分析

自定义实现Spring MVC,并验证框架功能

Spring MVC知识梳理

拦截器

***:Filter和HandlerInterceptor同时存在,对同一个路径拦截时,谁先执行?

***:HandlerInterceptor是否类似Filter有链式执行和执行顺序规则?

监听器(初始配置)

***:是否可以配置多个监听器,执行顺序如何?

***:多个监听器中都添加了Interceptor,它们的执行顺序如何?

***:前端请求跨域问题解决

****:增加了自定义拦截器之后,跨域设置无效?

类型转换

全局异常拦截

乱码解决


前言

带着问题学java系列博文之java基础篇。从问题出发,学习java知识。


Spring MVC

本篇博文继续框架篇的学习,梳理经典框架SSM/SSH中的第二个S(Spring MVC)相关知识。

我们前面无框架开发后台服务时,处理一个Servlet根据执行结果选择返回HTML页面或者字串信息时,是利用转发(得找到具体资源路径)来实现返回html页面;用response写回字串信息的。显然这样做很不方便,而且还存在重复编码。另外,我们每实现一个接口也就是一个Servlet类,当有多个接口,比如一个用户信息管理,就有登录、注册、修改密码、查询列表、删除用户等等,也就是要实现如此之多的servlet类,每个Servlet类还要实现HttpServlet接口。可以想象一个稍微复杂一点的后台服务,它的servlet类会呈爆炸式增长,非常不利于阅读。另外,我们都是在Servlet的doPost或者doGet方法中处理业务逻辑,执行参数读取封装、执行数据库操作、执行响应写回,在Servlet中既有数据层代码,又有视图层代码,杂糅在一起,逻辑混乱复杂,不易阅读,也容易出错。

为了解决上述问题,Spring MVC框架对后台服务进行一个架构设计:划分为MVC三层:Model层,主要是数据层,包括操作数据库(bean、dao、service);View层,主要是视图层,包括视图解析、视图映射;Controller层,主要是对Servlet的扩展,将Servlet的处理方法独立出来单独实现。Spring MVC主要功能包括:请求路径映射,请求参数解析封装,视图映射,响应结果封装等等。

快速上手Spring MVC

下面以一个简单的用户管理系统为例,使用Spring、SpringMVC快速开发实现(数据库操作暂不关注,模拟实现)。

后台工程结构图:

主要代码如下:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
    private Integer id;
    private String name;
    private String sex;
    private String address;
}

@Repository
public class UserDao {

    public void save(User user){
        System.out.println("保存成功,name:"+user.getName());
    }

    public void delete(Integer id){
        System.out.println("id为"+id+"的记录已删除");
    }

    public List<User> queryAll(){
        ArrayList<User> list = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            User user = new User(i,"张三"+i,"男","合肥");
            list.add(user);
        }
        return list;
    }
}

@Service
public class UserService {

    @Autowired
    UserDao userDao;

    public String save(User user, Model model) {
        userDao.save(user);
        model.addAttribute("msg","保存成功");
        return "success";
    }

    public ModelAndView delete(Integer id) {
        userDao.delete(id);
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.addObject("msg","删除成功");
        modelAndView.setViewName("success");
        return modelAndView;
    }

    public List<User> getAll() {
        return userDao.queryAll();
    }
}

@Controller
@RequestMapping("/user")
public class UserController {

    @Autowired
    UserService userService;

    @RequestMapping("/save")
    public String save(@RequestBody User user, Model model){
        return userService.save(user,model);
    }

    @GetMapping("/del")
    public ModelAndView delete(Integer id){
        return userService.delete(id);
    }

    @GetMapping("/getAll")
    public @ResponseBody List<User> getAll(){
        return userService.getAll();
    }
}

web.xml配置文件:

<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
  <display-name>Archetype Created Web Application</display-name>
  <!--配置前端控制器-->
  <servlet>
    <servlet-name>dispatcherServlet</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <!--配置全局初始化参数-->
    <init-param>
      <param-name>contextConfigLocation</param-name>
      <param-value>classpath:springmvc.xml</param-value>
    </init-param>
    <!--配置servlet在启动时即创建-->
    <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
    <servlet-name>dispatcherServlet</servlet-name>
    <url-pattern>/</url-pattern>
  </servlet-mapping>

</web-app>

SpringMVC配置文件:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/mvc
        http://www.springframework.org/schema/mvc/spring-mvc.xsd
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context.xsd">

    <!--开启注解扫描-->
    <context:component-scan base-package="com.zst.usermanager"/>

    <!--视图解析器-->
    <bean id="internalResourceViewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <!--前缀路径-->
        <property name="prefix" value="/"/>
        <!--文件后缀名-->
        <property name="suffix" value=".jsp"/>
    </bean>

    <!--开启springmvc注解支持-->
    <mvc:annotation-driven/>
</beans>

success.jsp页面如下:

<%@ page contentType="text/html;charset=UTF-8" language="java" isELIgnored="false" %>
<html>
<head>
    <title>成功页面</title>
</head>
<body>
    <b>${msg}</b>
</body>
</html>

项目依赖的jar主要有:

spring、springmvc相关依赖:                                                              对象与json字串转换相关依赖:         

   

Servlet,jsp相关依赖:                                                                          Bean实体类辅助插件(省略getter、setter,构造器等)

                       

将项目部署到Tomcat中运行,按照Controller中配置的路径,发起请求,可以看到/user/save 和/user/del 返回的是一个成功页面(success.jsp),并且页面的提示信息也动态变化(保存成功/删除成功),而/user/getAll 返回的是一个list集合对象。

整个项目开发下来,可以看到层次分明,代码简洁,而且没有实现多个Servlet,仅通过一个UserController就实现了所有的接口。区别于之前无框架的web项目,这里仅在web.xml中配置了一个Servlet(dispatcherServlet),而且还是spring mvc框架自己实现的Servlet,整个项目没有实现任何一个Servlet;给这个Servlet配置的映射路径是“/”,即由这个Servlet处理所有的请求。另外,还增加了一个SpringMVC.xml,这是spring框架需要的配置文件,主要配置了一个视图解析器,以及开启spring的注解扫描和mvc注解支持。

 

Spring MVC工作原理分析

上面的后台服务能运行,并且合理响应浏览器请求,而且很神奇是会自动返回视图或者对象数据。那到底是如何工作的呢?下面我们跟踪分析一下,先上图:

1.如图,浏览器发起请求,由于后台服务配置了一个DispathcerServlet,映射的路径是“/”。所以不管浏览器发起什么请求,都是这个DispatcherServlet来处理。

2.DispatcherServlet.service()拿到这个请求后,从HandlerMapping中去查找有没有对应请求uri的Controller,找到则调用controller实例的对应方法;在这个过程中,还会先从request中拿到所有参数,并完成参数解析封装,基本类型以及String类型的参数,直接转换为名称为参数名的变量;引用类型则使用fastjson进行json和对象之间的转化,转换成具体的对象。解析封装之后的参数就是调用controller方法的参数。

所以上例中:

    @RequestMapping("/save")
    public String save(@RequestBody User user, Model model){
        return userService.save(user,model);
    }

使用@RequestBody注解,表示需要转化请求参数json字串,转化为User对象。

    @GetMapping("/del")
    public ModelAndView delete(Integer id){
        return userService.delete(id);
    }

这里是一个基本类型的参数,参数名是id,则表示要从request中获取参数(request.getParam("id"));所以此时要求request中必须有名为id的Int型参数。当然如果想请求参数名和方法参数名不一致,也是可以的。这就需要使用@RequestParam("key"),表示要从request中获取名为key的参数。

    @GetMapping("/del")
    public ModelAndView delete(@RequestParam("key")Integer id){
        return userService.delete(id);
    }

3.从HandlerMapping中找到的对应的Controller,调用对应的方法,拿到方法的返回值。注意这里有两种类型,也就是对应我们项目中,返回页面或者返回字串。

    @GetMapping("/getAll")
    public @ResponseBody List<User> getAll(){
        return userService.getAll();
    }  

如上,使用了一个@ResponseBody注解,告知dispatcherServlet,当前返回的是一个字串;没有使用该注解,则dispatcherServlet就默认当前返回的是一个页面,如果controller方法返回的是一个ModelAndView实例,则不做任何处理;如果返回的是String类型字串,则将认为该字串就是页面名称,封装成ModelAndView实例;如果返回的是void类型,则默认使用controller方法名作为页面名称,封装成ModelAndView实例;如果是其他基本类型,实测仅有int型会被当成String类型一样处理,其他的都会报错;引用类型无法转化,也是会导致报错。

4.如果Controller方法返回的是字串,则直接响应回浏览器;如果是ModelAndView实例,则继续调用internalResourceViewResolver的方法,将modelAndView实例作为参数传给它,让它解析处理;(这里的视图解析器internalResourceViewResolver,就是在SpringMVC.xml中配置的一个bean,具体实现是Spring MVC框架)。

5.视图解析器解析modelAndView实例后,返回具体的页面View,响应回浏览器。

 

自定义实现Spring MVC,并验证框架功能

根据上面的原理分析,和步骤跟踪,我们下面尝试自己实现一下Spring MVC。本范例是比较简单的MVC框架,仅支持@Controller和@RequestMapping,默认都是返回视图,默认都是按参数名称对应做参数封装(不支持json和对象的转换)。上代码:

1.首先实现两个自定义注解@Controller和@RequestMapping,并分别支持通过url和pattern设定映射路径。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface Controller {
    String url() default "/";
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface RequestMapping {
    String pattern();
}

2.实现DispatcherServlet,接管所有的请求(这里由于要开放静态资源,页面等请求,为了简单,不配置“/*”,改为配置“/dispatch/*”)

重写DispatcherServlet的init、service方法,主要逻辑如下:

①在init()中实现handlerMapping以及ViewResolver的实例化,指定handlerMapping要扫描的包,以及指定ViewResolver视图映射的前缀和后缀;

②从request中拿到uri,做字串截取处理(要截取掉前缀部分,contextpath以及dispatch)得到映射路径path;

③根据映射路径path从handlerMapping中查找对应的Controller以及具体方法,找到则继续执行,未找到直接响应404;

④如果找到了对应的Controller和具体的方法,则先封装参数(这里仅简单实现,根据反射拿到方法的参数名,从request中取同名参数。所以该mvc框架仅支持方法名与请求参数名一致的情况);拿到参数后,反射调用controller的该方法;

⑤controller方法执行后拿到返回值,根据返回值的类型,选择交给视图解析器处理,或者直接返回500(不支持的返回值类型);框架仅支持String类型以及ModelAndView类型的返回值,且默认都是视图映射,不支持设定返回字串。

⑥视图解析器ViewResolver拿到ModelAndView对象后,进行解析处理,封装ModelAndView中的数据,添加到Request的Attributes中;拿到视图名,添加上前缀和后缀,完成请求转发,定位具体的视图资源。

@WebServlet(urlPatterns = "/dispatch/*")
public class DispatcherServlet extends HttpServlet {

    private HandlerMapping handlerMapping;
    private ViewResolver viewResolver;

    @Override
    public void init() throws ServletException {
        handlerMapping = new HandlerMapping(getServletContext().getRealPath("/WEB-INF/classes/com/zst/mymvc/controller"));
        viewResolver = new ViewResolver("/",".jsp");
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String uri = req.getRequestURI();
        System.out.println("uri:"+uri);
        //1.从handlerMapping中查找有无对应uri的ControllerMethod对象
        //(这里的uri是包含了contextPath以及dispatch的,所以要去掉,这样才能匹配handlerMapping的uri)
        int index = getServletContext().getContextPath().length() + 9;
        String path = uri.substring(index);
        ControllerMethod controllerMethod = handlerMapping.getMapping(path);
        if (null != controllerMethod) {
            //2.解析参数(这里仅仅是以参数名一一对应:要求Controller方法的参数名和请求的参数名一致)
            Parameter[] parameters = controllerMethod.getParameters();
            Object[] params = null;
            if (null != parameters && parameters.length > 0) {
                params = new Object[parameters.length];
                for (int i = 0; i < parameters.length; i++) {
                    params[i] = req.getParameter(parameters[i].getName());
                }
            }
            try {
                //3.执行Controller对应方法,拿到返回值
                Object result = controllerMethod.getMethod().invoke(controllerMethod.getController(), params);
                //4.视图解析器工作
                if (result instanceof ModelAndView){
                    viewResolver.resolve((ModelAndView) result,req,resp);
                } else if (result instanceof String){
                    ModelAndView modelAndView = new ModelAndView();
                    modelAndView.setViewName((String) result);
                    viewResolver.resolve(modelAndView,req,resp);
                } else {
                    resp.setContentType("text/html;charset=utf-8");
                    resp.setStatus(500);
                    resp.getWriter().write("Controller方法返回值类型不支持视图转换");
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        } else {
            //未找到对应的Controller
            resp.setContentType("text/html;charset=utf-8");
            resp.setStatus(404);
            resp.getWriter().write("404,您访问的页面找不到!");
        }
    }
}

3.实现HandlerMapping以及ViewResolver

HandlerMapping主要是扫描指定包下所有的Class文件(仅简单实现扫描当前包同级目录,没有做文件夹逐级扫描),拿到所有的class文件全类名;然后判断这些class是否使用了@Controller注解,有注解则拿到注解的url值,继续扫描该类方法是否有@RequestMapping注解,有则拿到注解的pattern值,通过反射创建controller类实例,拿到method,以及method的参数名,封装成ControllerMethod对象;最后把url+pattern作为路径映射,ControllerMethod实例作为具体对象,完成整个请求路径映射,存入uriControllerMap,便于dispatcherServlet查找使用。

public class HandlerMapping {
    private static final ConcurrentHashMap<String,ControllerMethod> uriControllerMap = new ConcurrentHashMap<>();

    private String packageName;

    public HandlerMapping(String packageName) {
        this.packageName = packageName;
        init();
    }

    public ControllerMethod getMapping(String urlPattern){
        return uriControllerMap.get(urlPattern);
    }

    /**
     * 反射创建Controller实例,拿到uri和controller方法映射
     */
    private void init(){
        List<String> allClass = getAllClass();
        for (String aClass : allClass) {
            try {
                Class<?> clazz = this.getClass().getClassLoader().loadClass(aClass);
                //仅对使用自定义注解Controller的类进行实例化
                if (clazz.isAnnotationPresent(Controller.class)){
                    Controller controller = clazz.getAnnotation(Controller.class);
                    String url = controller.url();
                    Method[] methods = clazz.getMethods();
                    for (Method method : methods) {
                        //对所有使用@RequestMapping注解的方法进行处理
                        if (method.isAnnotationPresent(RequestMapping.class)){
                            RequestMapping requestMapping = method.getAnnotation(RequestMapping.class);
                            String pattern = requestMapping.pattern();
                            Parameter[] parameters = method.getParameters();
                            ControllerMethod controllerMethod = new ControllerMethod(clazz.newInstance(), method,parameters);
                            String urlPattern = url+pattern;
                            uriControllerMap.put(urlPattern,controllerMethod);
                            System.out.println("key:"+urlPattern+",clazz:"+clazz.getName()+",method:"+method.getName());
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 扫描指定的包,获取所有的class全类名
     * @return
     */
    private List<String> getAllClass(){
        List<String> classes = new ArrayList<>();
        List<String> className = new ArrayList<>();
        File filePackage = new File(packageName);
        File[] fileList = filePackage.listFiles();
        for (File file : fileList) {
            String name = file.getName();
            if (name.contains(".class")){
                className.add(name.split("\\.")[0]);
            }
        }
        String s = packageName.replaceAll("\\\\", "\\.");
        String[] split = s.split("classes");
        String packageStr = split[1].substring(1);
        for (String name : className) {
            classes.add(packageStr+"."+name);
        }
        return classes;
    }
}

ViewResolver视图解析器主要是解析传递过来的modelAndView对象,将对象中的数据进行封装,放入request的attributes;然后拿到视图名,拼接上前缀和后缀,实现转发,返回具体的视图资源。

public class ViewResolver {

    private String prefix;
    private String suffix;

    public ViewResolver(String prefix, String suffix) {
        this.prefix = prefix;
        this.suffix = suffix;
    }

    public void resolve(ModelAndView modelAndView, HttpServletRequest req, HttpServletResponse resp){
        try {
            //解析ModelAndView,拿到返回值,写入request
            HashMap<String, Object> attributes = modelAndView.getAttributes();
            for (String key : attributes.keySet()) {
                req.setAttribute(key,attributes.get(key));
            }
            //利用转发实现返回视图
            req.getRequestDispatcher(prefix+modelAndView.getViewName()+suffix).forward(req,resp);
        } catch (ServletException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

4.自定义的ModelAndView实体类以及ControllerAndMethod类

public class ModelAndView {
    private String viewName;
    private HashMap<String,Object> attributes = new HashMap<>();

    public String getViewName() {
        return viewName;
    }

    public void setViewName(String viewName) {
        this.viewName = viewName;
    }

    public void addAttribute(String name,Object value){
        attributes.put(name,value);
    }

    public void removeAttribute(String name){
        attributes.remove(name);
    }

    public HashMap<String, Object> getAttributes() {
        return attributes;
    }
}

public class ControllerMethod {
    private Object controller;
    private Method method;
    private Parameter[] parameters;

    public ControllerMethod(Object controller, Method method, Parameter[] parameters) {
        this.controller = controller;
        this.method = method;
        this.parameters = parameters;
    }

    public Object getController() {
        return controller;
    }

    public void setController(Object controller) {
        this.controller = controller;
    }

    public Method getMethod() {
        return method;
    }

    public void setMethod(Method method) {
        this.method = method;
    }

    public Parameter[] getParameters() {
        return parameters;
    }

    public void setParameters(Parameter[] parameters) {
        this.parameters = parameters;
    }
}

如此一个全注解的mvc框架就实现了,该框架支持@Controller和@RequestMapping两大注解,默认视图映射,指定包扫描,参数封装等。下面让我们来验证一下这个框架:

package com.zst.mymvc.controller;

import com.zst.mymvc.mvc.Controller;
import com.zst.mymvc.mvc.ModelAndView;
import com.zst.mymvc.mvc.RequestMapping;

@Controller(url = "/user")
public class UserController {

    @RequestMapping(pattern = "/save")
    public String save(String name){
        System.out.println("保存成功,name:"+name);
        return "success";
    }

    @RequestMapping(pattern = "/del")
    public ModelAndView delete(Integer id){
        System.out.println("根据Id:"+id+"删除成功");
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.addAttribute("msg","删除成功");
        modelAndView.setViewName("success");
        return modelAndView;
    }

    @RequestMapping(pattern = "/error")
    public void test(){
        System.out.println("返回值类型Void,不支持");
    }
}

success.jsp页面:

<%@ page contentType="text/html;charset=UTF-8" language="java" isELIgnored="false" %>
<html>
<head>
    <title>成功页面</title>
</head>
<body>
    <b>提示信息:${msg}</b>
</body>
</html>

我们在com.zst.mymvc.controller包下,实现了一个Controller类,并写了三个方法;将项目部署到Tomcat运行(注意修改DispatcherServlet的init()指定handlerMapping要扫描的包名是:/WEB-INF/classes/com/zst/mymvc/controller),我们通过浏览器发起请求试试:

发起请求“dispatch/”,请求被框架的DispatcherServlet接管,Tomcat容器实例化dispatcherServlet,并执行了init()方法,然后调用service()方法:

       

       后台日志可以看到,init()执行,handlerMapping完成了包扫描,以及请求路径和controller方法之间的映射。然后service()执行,接收到请求路径是/MyMVC/dispatch/,由于handlerMapping中没有匹配该路径的ControllerMethod,所以dispatcherServlet直接返回“404,您访问的页面找不到!”。

继续发起请求“dispatch/user/save?name=zhangsan”,dispatcherSevlet继续接管请求,并从handlerMapping中找到对应的controllerMethod,封装参数、执行方法,然后交给视图解析器解析返回值:

         

  后台日志可以看到,dispatcherServlet接收到请求(uri: /MyMVC/dispatch/user/save),并成功调用了UserController的save()方法,save()也拿到了请求参数name。不过由于这里仅仅是返回视图名,没有对modelAndView设置数据,所以jsp页面是取不到msg的值,也就是仅显示“提示信息:”。

继续发起请求“dispatch/user/del?id=1”:

               

后台日志可以看到,成功调用了UserController的delete()方法,并且delete()也拿到了请求参数id。delete()方法返回的是modelAndView对象,并且封装了数据msg=“删除成功”。所以ViewResolver视图解析器成功解析数据,返回视图资源,jsp页面也取到了msg的值,显示为“提示信息:删除成功”。

继续发起请求“dispatch/user/error”:

                

后台日志可以看到,成功调用了UserController的test()方法,由于test()返回值类型是Void,视图映射不支持该类型,因此dispatcherServlet直接响应“Controller方法返回值类型不支持视图转换”。

 

***:小贴士

  • 上面4个请求的发起,可以看到Tomcat容器是在servlet第一次被调用时才进行实例化(懒加载),执行且仅执行一次init()初始化方法;
  • 上面的自定义MVC框架反射获取方法参数名是依赖jdk1.8的新特性,要想method.getParameters()发挥作用,parameter.getName()返回的是具体的参数名,而不是arg0,arg1这种泛泛名称。则必须先开启java编译支持,主要如下(博主使用的是intellij idea,以此举例;当然如果大佬直接用的javac 编译代码,没有使用集成环境那就不用管,jdk1.8以上javac 编译直接已经支持了):

1.开启编译设置(-parameters)

2.配置maven编译支持-parameters

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.3</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <compilerArgs>
                        <arg>-parameters</arg>
                    </compilerArgs>
                </configuration>
            </plugin>

 

Spring MVC知识梳理

经过上例自定义实现MVC框架,相信大家对Spring MVC应该有了一个清晰的认知。上例实现的MVC框架是很简陋的,比如它必须要求请求的时候加上前缀dispatch;对请求参数的封装也仅支持String类型,并且要求请求参数名和方法参数名一致,不支持json和对象间的转化;都是默认视图解析,不支持返回字串;指定包名扫描、视图解析的前缀、后缀,都是修改dispatcherServlet的代码,不是读取配置文件等等。而Spring MVC完全完善了这些缺陷,为此也增加了一些注解,比如@RequestParam用于解决请求参数名和方法参数名不一致的情况,@RequestBody用于封装请求参数是json字串,转化为对象,@ResponseBody用于返回字串,不做视图映射等等,还有仿照JavaWeb规范,实现自定义拦截器、监听器等。有这样优秀的框架,当然是学习使用它,也没必要自己去造轮子了(当然,有兴趣的可以继续完善自定义的MVC框架,解决缺陷,增加拦截器、监听器等部分),下面对Spring MVC的知识做一个梳理(依然注解是主流,主要关注注解):

路径映射注解
注解名具体作用使用位置
@RequestMapping映射路径,value=“”;可以通过method指定请求方式,不指定,默认支持所有请求方式;produces = "text/plain;charset=utf-8"指定request的编码类,方法
@GetMapping相当于@RequestMapping指定了请求方式GET方法
@PostMapping相当于@RequestMapping指定了请求方式POST方法
@DeleteMapping相当于@RequestMapping指定了请求方式DELETE方法
@PutMapping相当于@RequestMapping指定了请求方式PUT方法
@PatchMapping相当于@RequestMapping指定了请求方式Patch方法

***:小贴士

Spring MVC设计了这么多种路径映射注解,就是为了让我们使用的更舒服,简便。所以建议在Controller中明确方法的请求方式,使用对应的注解,而不是一味使用@RequestMapping,或者再在@RequestMapping中指定method。这样代码阅读性更加良好,接口方法的目的性也更强,更加吻合restful风格。

获取请求参数
注解名具体作用使用位置
@RequestParam通过value指定参数别名,将方法的参数与请求参数对应起来,支持绑定一组参数,比如@RequestParam("id") Integer[] ids 即绑定请求参数中的多个名为id的参数,封装到ids数组中;配合required = false,表示该参数是否是必须传递的,不配置则默认必须传递。方法参数
@RequestBody表示这是一个需要json转化的对象,框架自动将请求参数(json)转化为参数对象方法参数
@PathVariable从指定的请求路径中获取参数,需要配置路径映射一起使用,比如:@GetMapping("/test/{id}")  @PathVariable("id")方法参数
不使用注解当请求参数名和方法参数名一致时,可以省略注解,框架会自动完成参数对应
Servlet的init和destory注解
注解名具体作用使用位置
@PostConstruct配置该方法在Servlet创建时执行,即dispatcherServlet中的init()方法的扩展方法
@PreDestory配置该方法在Servlet销毁时执行,即dispatcherServlet中的destory()方法的扩展方法

***:小贴士

按照自定义框架的实现,可以看到,整个后台服务中Controller默认是单一实例,在dispatcherServlet实例化时进行实例化。我们自定义框架是在init()方法中,实例化HandlerMapping,由handlerMapping去反射创建Controller实例。当然Spring MVC支持配置Controller的作用范围,使用@Scope配置即可,如无特殊需要,建议就默认单一实例。所以Controller的方法一般不建议使用外部成员变量,即使使用也不进行值设定,避免线程安全问题。

 

拦截器

Spring MVC实现了一个自定义接口:HandlerInterceptor,主要有三个方法:preHandle(),controller方法执行前调用;postHandle(),controller方法执行之后,页面返回之前调用;afterCompletion(),页面返回之后执行。

拦截器大家肯定不陌生,java EE规范中是Filter接口,我们之前实现自定义Servlet容器时就分析了Filter,其实就是由容器自动接管所有实现了Filter接口的类,完成实例化,以及方法调用。我们是在MyTomcat类中进行实现添加的,读取配置文件web.xml,完成拦截Uri与Filter实例之间的映射,然后在Socktask中先执行Filter的具体方法然后才到具体的Servlet方法。如果淡忘了,可以回顾博文《JavaEE--Java Web基础知识》。

根据Servlet容器的实现,可以看到其实Filter接口只是定义一个规范,所有的Servlet容器就会在初始化时识别该规范(需要在web.xml或者注解配置),并完成相关逻辑调用。Spring MVC框架其实也完全按照这个逻辑来实现的,HandlerInterceptor也是一个接口,然后框架在初始化时识别该规范(需要在springmvc.xml或者注解配置),并在dispatcherServlet中完成相关逻辑调用。如果有兴趣完全可以在上面的自定义MVC框架基础上继续添加过滤器部分,实现自定义的过滤器。

下面看下具体使用范例:

/**
 * 自定义拦截器
 */
public class AccountInterceptor implements HandlerInterceptor {


    /**
     * 预处理,controller方法执行前
     * return true 表示放行
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        System.out.println("拦截器执行了前");
        return true;
    }


    /**
     * 后处理方法,controller执行后,success页面执行前
     * @param request
     * @param response
     * @param handler
     * @param modelAndView
     * @throws Exception
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        System.out.println("拦截器执行了后");
    }


    /**
     * success.jsp执行后,执行该方法
     * @param request
     * @param response
     * @param handler
     * @param ex
     * @throws Exception
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println("最后的方法执行了");
    }
}

在配置文件中配置:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/mvc
        http://www.springframework.org/schema/mvc/spring-mvc.xsd
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context.xsd">

    <!--开启注解扫描-->
    <context:component-scan base-package="com.zst"/>

    <!--视图解析器-->
    <bean id="internalResourceViewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <!--前缀路径-->
        <property name="prefix" value="/WEB-INF/pages/"/>
        <!--文件后缀名-->
        <property name="suffix" value=".jsp"/>
    </bean>

    <!--配置放开静态资源的拦截-->
    <mvc:resources location="/css/" mapping="/css/**"/>
    <mvc:resources location="/images/" mapping="/images/**"/>
    <mvc:resources location="/js/" mapping="/js/**"/>


    <!--配置异常处理器-->
    <bean id="sysExceptionResolver" class="com.zst.exception.SysExceptionResolver"></bean>

    <!--配置拦截器-->
    <mvc:interceptors>
        <mvc:interceptor>
            <!--要拦截的具体方法-->
            <mvc:mapping path="/account/*"/>
            <!--不要拦截的方法
            <mvc:exclude-mapping path=""/>
            -->

            <!--配置拦截器对象-->
            <bean class="com.zst.interceptor.AccountInterceptor"></bean>
        </mvc:interceptor>
    </mvc:interceptors>

    <!--开启springmvc注解支持-->
    <mvc:annotation-driven/>
</beans>

如上是使用配置的方式,实现拦截器,拦截/account/*的所有请求,并通过<mvc:resources>标签开放了静态资源的拦截。

需要注意Spring MVC并没有像Java EE规范那样为Filter添加了注解@WebFilter,那注解是框架发展的主流,使用注解又该如何配置拦截器呢?

/**
 * 登录拦截器
 */
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String userId = request.getHeader("userId");
        if (null != userId){
            //未登录,非法访问,拦截
            response.setContentType("text/html;charset=utf-8");
            response.getWriter().write(ApiResponseDto.TIMEOUT_WITH_DATA.toString());
            return false;
        }else {
            //已登录,放行
            return true;
        }
    }
}

/**
* 通过一个配置类实现向Spring框架注册拦截器
*/
@Configuration
public class LoginConfiguration implements WebMvcConfigurer {
    @Bean
    public LoginInterceptor loginInterceptor(){
        return new LoginInterceptor();
    }
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //配置全局拦截,仅放行static静态资源,其他全部接受核查

        registry.addInterceptor(loginInterceptor()).addPathPatterns("/**")
              .excludePathPatterns("/static/**")
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        //静态资源拦截放开
      registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
    }
}

如上使用注解来实现注册拦截器,依赖@Configuration注解,向框架注册拦截器。

***:Filter和HandlerInterceptor同时存在,对同一个路径拦截时,谁先执行?

如果理解了这两个过滤器的实现,就可以很好的回答这个问题。首先Filter是java EE规范定义的,它是由Servlet容器进行初始化、逻辑调用的,它的执行是在Servlet方法调用之前。一个请求到来Servlet容器首先是进行拦截器路径匹配,一旦匹配,先调用拦截器方法,由拦截器考虑是否放行,之后请求才到具体的Servlet。而HandlerInterceptor是Spring MVC框架自定义的拦截器,它是由框架进行初始化、逻辑调用的,而我们知道Spring MVC的框架对所有的请求进入都是依赖dispatcherServlet,所以HandlerInterceptor肯定是在请求到达了dispatcherServlet之后,再由框架进行路径匹配,一旦匹配,则调用拦截器方法,由拦截器考虑是否放行,之后请求才转发到具体的Controller。可以看到Filter和HandlerInterceptor是两个层面的拦截器,一个是在请求到达Servlet之前,由Servlet容器调用;一个是Servlet容器已经放行请求到达了dispatcherServlet之后,由框架调用。所以可以很肯定的回答这个问题:不管路径如何配置,或者是拦截器的名称如何配置,都是Filter先执行,HandlerInterceptor后执行。

***:HandlerInterceptor是否类似Filter有链式执行和执行顺序规则?

Filter有链式执行以及根据配置顺序和名称大小的规则来决定先后执行顺序;HandlerInterceptor也有类似的规则,首先HandlerInterceptor也支持链式执行(类似方法调用);其次HandlerInterceptor也有自己的规则来决定先后执行顺序:当使用xml配置文件配置时,谁配置在前面,谁先执行;当借助@Cofiguration向框架注册拦截器时,则根据注册的顺序,谁先注册谁先执行。

 

监听器(初始配置)

我们在之前的博文《Java WEB基础知识》中,有讲到Listener,是Java EE规范定义的,主要用来监听web服务中常见3大对象的创建、销毁以及属性变化。Spring MVC框架也采用相同的设计思路,自定义了一个接口WebMvcConfigurer,主要用于当Spring MVC框架初始化时,向框架中配置一些信息,比如配置拦截器,配置跨域,配置静态资源拦截开放,配置自定义参数类型转换等等。可以看成设计这个WebMvcConfigurer主要是为了适配注解发展的主流,程序员可以完全通过注解配置所有,不需要xml配置文件。

使用范例可以参考上例的拦截器,就是由Configuration类实现WebMvcConfigurer接口,然后覆写想要配置的方法即可。(WebMvcConfigurer接口很有意思,它的所有方法都加上了default修饰,这其实是jdk1.8新引入的修饰符,仅限于接口类中使用,使用default修饰的方法,表示该方法已有默认实现,需要加上方法体。然后具体的类实现该接口时,就不需要必须实现接口中的所有方法了。有点像抽象类,但是比抽象类更加灵活,因为类继承只能是一个,但是接口实现却可以多个。)

***:是否可以配置多个监听器,执行顺序如何?

Spring MVC框架肯定是支持配置多个监听器的,对于框架而言无论多少个,其实都是一样的处理。那具体执行顺序如何呢?我们以代码运行结果为准:

@Configuration
public class BaseConfiguration implements WebMvcConfigurer {

    static{
        System.out.println("base configuration 初始化");
    }
}

@Configuration
@Slf4j
public class ASecondConfiguration implements WebMvcConfigurer {

    static {
        System.out.println("second configuration 初始化");
    }
}

上面范例代码配置了两个监听器,为了确认两个监听器谁先执行,增加了一段static代码。运行程序可以看到“second configuration 初始化”先打印,“base configuration 初始化”后打印,说明ASecondConfiguration先执行了。修改一下名称,把ASecondConfiguration改为SecondConfiguration,再运行可以看到相反的打印顺序,说明BaseConfiguration先执行了。基本可以肯定,可以同时配置多个监听器,多个监听器的执行顺序是根据监听器类名大小排序的,类名小的先执行(比较类名字串大小)

 

***:多个监听器中都添加了Interceptor,它们的执行顺序如何?

上例验证了可以配置多个监听器,那如果在多个监听器中又配置了过滤器,过滤器的执行顺序又该如何呢?还是以代码为准:

@Configuration
@Slf4j
public class ASecondConfiguration implements WebMvcConfigurer {

    static {
        System.out.println("second configuration 初始化");
    }

    @Bean
    public ThirdInterceptor thirdInterceptor(){
        return new ThirdInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(thirdInterceptor()).addPathPatterns("/test/**");
    }
}

@Configuration
public class BaseConfiguration implements WebMvcConfigurer {

    static{
        System.out.println("base configuration 初始化");
    }

    @Bean
    public LoginInterceptor loginInterceptor(){
        return new LoginInterceptor();
    }

    @Bean
    public SecondInterceptor secondInterceptor(){
        return new SecondInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //Interceptor谁先配置,谁先执行
        registry.addInterceptor(secondInterceptor()).addPathPatterns("/test/**");
        registry.addInterceptor(loginInterceptor()).addPathPatterns("/test/**");
    }
}

运行后可以看到,先执行了ThirdInterceptor,然后再执行了SecondInterceptor,最后执行LoginInterceptor。整个执行顺序,按照监听器的执行顺序,然后监听器中配置过滤器的顺序依次执行。

***:前端请求跨域问题解决

有了监听器,我们常常在其中进行跨域解决,代码如下:

@Configuration
public class ConverterConfiguration implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new DateConverter());
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**") //允许所有请求跨域
                .allowedHeaders("*") //允许所有的头信息
                .allowedMethods("*") //允许所有请求方式
                .allowedOriginPatterns("*") //允许任何OriginPattern
                .allowCredentials(true); //允许携带cookie
    }
}

Spring MVC框架为我们封装好了,只需要覆写一下方法即可。另外Spring MVC还给定义了一个注解@CorsOrigin,可以用于类或者方法上,表示允许跨域。比如:

@RestController
@RequestMapping("/test")
@CrossOrigin(allowCredentials = "true")  //用在Controller类上,表示该Controller的所有接口都允许跨域
@Slf4j
public class TestController {

    @RequestMapping("/1")
    @CrossOrigin //用在controller的具体方法上,表示该接口允许跨域
    public void test(){
      log.info("controller 1 被执行");
    }
}

****:增加了自定义拦截器之后,跨域设置无效?

按照上例添加跨域后,如果没有再配置自定义拦截器,则允许跨域是可以正常生效的。但是一旦添加了拦截器,则上例两种方式配置的跨域就会失效。没遇到过的,不妨用如下代码测试一下:

@Configuration
public class ConverterConfiguration implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**") //允许所有请求跨域
                .allowedHeaders("*") //允许所有的头信息
                .allowedMethods("*") //允许所有请求方式
                .allowedOriginPatterns("*") //允许任何OriginPattern
                .allowCredentials(true); //允许携带cookie
    }

    //当自定义拦截器和跨域开放同时存在时,跨域开放将失效
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor());
    }
}

这是什么原因呢?主要是因为当遇到跨域访问时,浏览器会先发起一个询问,即请求跨域接口时,会先发起一个头信息含“method:options”的请求,询问后台是否允许跨域访问,允许则再具体请求资源。添加自定义拦截器之后,由于拦截器拦截了这个询问请求,浏览器误认为是不允许跨域访问,因此报了跨域限制。那拦截器和跨域开放要同时存在,该怎么办呢?

上面也分析了,Spring MVC框架定义的拦截器Interceptor其实就是模仿Java Web的Filter(过滤器),只不过执行时刻不同而已。Filter的执行由Servlet容器调度,在Servlet被调用之前。所以通过Filter设定跨域开放,可以实现对所有请求的前置处理,是不受Spring MVC框架的拦截器影响的(拦截器的执行顺序是在Filter之后)。整个执行顺序是:

Filter->dispatcherServlet->HandlerInterceptor->Controller

代码范例如下:

@Configuration
public class GlobalCorsConfig {
    @Bean
    public CorsFilter corsFilter(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.addAllowedOrigin("*");
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*");
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);
        source.registerCorsConfiguration("/**",configuration);
        return new CorsFilter(source);
    }
}

当然也可以修改一下自定义拦截器,开放所有的method是optins的请求,这样原本失效的跨域设置又恢复作用了:

@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if ("OPTINS".equalsIgnoreCase(request.getMethod())) return true;
        //todo 认证拦截逻辑
        return true;
    }
}

 

类型转换

上例自定义实现MVC框架中,有简单的实现了请求参数封装功能,仅支持封装String类型的参数。Spring MVC框架比范例框架就完善多了,为此Spring MVC自定义了非常多的类型转换器,适配各种请求参数类型的转换封装(可以在org.springframework.format包下找到)。但是依然还有可能存在无法转换的情况,所以就需要我们自定义类型转换了。不用担心,Spring MVC框架也给我们开放了可配置的地方,支持非常方便的配置自定义类型转换,具体见下面代码范例:

@Configuration
public class ConverterConfiguration implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new DateConverter());
    }
}

public class DateConverter implements Converter<String, Date> {
    @Override
    public Date convert(String s) {
        if (s != null && s.trim().length() > 0) {
            String dateStr = s.replaceAll("\\.", "/");
            return new Date(dateStr);
        } else {
            return null;
        }
    }
}

如上,自定义类型转换只需要实现Conveter接口,明确要转换的类型是什么,然后实现具体的转换逻辑,最后在配置类中注册一下即可,整体还是非常简单的。添加自定义类型转换之前,如果发起请求时,json字串中birthday:"2020.11.23",则后台会报错:JSON parse error: Cannot deserialize value of type `java.util.Date` from String "2020.11.23"。即无法将“2020.11.23”转换成Date类型。注册了自定义类型转换之后,就不会报错,框架自动调用了DateConverter,完成类型转换。

当我们有特殊的类型转换需求时,可以参照本例实现自定义类型转换。

 

全局异常拦截

后台服务在执行时可能抛出各种异常,如果我们没有显式捕获处理它们,则系统就会抛出异常,直接显示在前端界面上甚至是系统停止运行。所以一个优秀的后台服务都应该有统一的异常处理,捕获系统对外抛出的所有异常,合理处理。使用了Sping MVC框架后,主要会抛出异常的地方是Controller的各个方法,如果程序员没有在controller的方法中进行捕获,系统就会将异常对外抛出,并向前端页面返回具体异常信息。因此,在使用Spring MVC框架后,全局异常的拦截,主要是对Controller的各个方法异常进行捕获处理。

Spring MVC框架主要提供了两种方式:

1.配置阶段,使用xml配置

/**
 * 自定义异常处理类
 * 使用这种方式有个缺陷:无法控制返回数据是视图还是json字串
 */
public class SysExceptionResolver implements HandlerExceptionResolver {

    /**
     * 处理异常的业务逻辑
     * @param request
     * @param response
     * @param o
     * @param e
     * @return
     */
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object o, Exception e) {
        //获取到异常
        SysException exception = null;
        if (e instanceof SysException){
            exception = (SysException) e;
        } else {
            exception = new SysException("系统正在维护");
        }
        //创建mv对象
        ModelAndView mv = new ModelAndView();
        mv.addObject("errorMsg",exception.getMessage());
        mv.setViewName("error");

        return mv;
    }
}

在xml配置:
<!--配置异常处理器-->
    <bean id="sysExceptionResolver" class="com.zst.exception.SysExceptionResolver"></bean>

使用xml配置有个缺陷,返回只能是modelandview,无法控制返回字串,并且不支持注解。

2.注解阶段,使用注解

Spring MVC配置阶段提供的方式存在缺陷,另外注解也是发展主流,所以Spring MVC也提供了更加优雅、适配注解的方式。要想实现全局异常拦截,其实无非是对所有的Controller进行动态增强,使用try-catch包裹,捕获到异常后统一处理。是不是发现与数据库事务控制非常像,不就是AOP么。确实这里就是一个典型的aop,只不过切点不再是某些方法,而是controller类。Spring MVC也正是这样实现的:

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(DefineException.class)
    public void handleDefineException(DefineException e){
        log.info("msg:{},e:{}",e.getMsg(),e.getE().getMessage());
    }

    @ExceptionHandler(Exception.class)
    public void handleException(Exception e){
        log.info("message:{}",e.getMessage());
    }
}


@RestController
@RequestMapping("/test")
@Slf4j
public class TestController {

    @RequestMapping("/exception")
    public void error(){
        log.info("手动制造异常");
        int i = 1/0;
    }
    
    @RequestMapping("/define")
    public void define(){
        log.info("抛出自定义异常");
        try {
            int i = 1/0;
        } catch (Exception e){
            throw new DefineException("自定义异常",e);
        }
    }
}

可以看到当调用exception时,系统抛出了除0异常;调用define时,系统抛出了自定义异常,这两个异常都被GlobalExceptionHandler捕获,handleException()方法捕获的是Exception异常,所以除0异常被它捕获,handleException()执行,并打印日志;handleDefineException()方法捕获的是自定义的DefineException,所以自定义异常被它捕获,并打印日志。

***:小贴士

很显然第二种方式更加优雅,还支持选择返回字串还是视图(@ControllerAdvice 或者@RestControllerAdvice)。所以全局异常处理,建议使用第二种方式,并且实现自定义异常,在实现Controller时,不管方法是否抛出异常,都使用try-catch包裹方法,捕获顶级Exception,然后在catch方法体中完成异常封装,抛出自定义异常,由全局异常处理器去处理。

 

乱码解决

之前我们解决乱码,主要分三种情况,分别解决了Tomcat后台日志乱码,请求参数乱码,响应信息乱码。其中后两种无非都是对request或者response进行编码设定。如果这样做的话,岂不是所有的Controller的具体方法都要进行设定。显然,这样做很繁琐,而且与controller强耦合。有没有更好的、更简便的方式呢?

我们学习了Filter,知道Filter是Java EE规范,它是由Servlet容器初始化并执行,是执行在Servlet之前的。那我们完全可以写一个Filter,拦截所有的请求,然后在拦截方法中实现request和response的编码设定,再放行这些请求。这样就可以实现,对所有的请求都设置了统一编码,确保不会乱码了。这样实现既简便,又不需要修改controller的代码,去掉了强耦合。我们能想到的方案,Spring MVC框架早就想到了,并且为我们封装好了这个Filter,也就是org.springframework.web.filter.CharacterEncodingFilter。我们只需要在web.xml中配置一下即可。

  <!--配置过滤器-->
  <filter>
    <filter-name>characterEncodingFilter</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <init-param>
      <param-name>encoding</param-name>
      <param-value>UTF-8</param-value>
    </init-param>
  </filter>
  <filter-mapping>
    <filter-name>characterEncodingFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>


以上系个人理解,如果存在错误,欢迎大家指正。原创不易,转载请注明出处!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值