【万字长文】Spring MVC 层层递进轻松入门 !

SpringMVC 开篇絮叨

(一) 谈一谈 Web 项目开发

Html是“名词”,CSS是“形容词”,JavaScript是“动词”,这三个兄弟凑在一起,就构成了 “静态” 页面,那么如何让他 “动态” 起来呢?这就需要后端相关技术的支持,这也是我们今天想要说的。

那么又怎么去理解 “静态”“动态” 这两个词呢?

这两个词最大的不同就是在于其交互性,静态页面不是指页面不能进行变化,而是指不能与后端进行交互,实现数据的传输与处理,也就是说,静态页面一旦做好后,基本就是这个样子了,更像一个单纯的展示,而动态页面却可以实现根据用户的要求和选择而动态的去改变和响应,浏览器客户端,成为了前后端动态交互的一个桥梁。

而随着现在用户需求的增加,以及数据量的增加,在Web开发中,能够及时、正确地响应用户的请求几乎已经可以说是必须的了

  • ① 用户在前端的页面上,进行一个提交或者说点击 URL,就会向后端服务器发送一个请求
  • ② 后端经过一系列处理后(例如,从数据库中查到需要的数据)把数据响应给前端页面
  • ③ 前端页面获取到响应内容后,对其进行解析以及进行一些处理(例如:回显内容到页面)

今天重点要学习的就是也就是——如何在获取请求后对其解析,然后执行相关的逻辑处理,最终跳转到页面,将数据回馈

(二) 三层架构

上面我提到了,在前后端动态交互中,浏览器客户端,成为了前后端沟通的桥梁,这也就是常见的 B/S 架构方式,也就是 浏览器/服务器,在其中最为常用的就是三层架构的开发模式

大家在 JavaWeb 的学习过程中,基本上已经在用三层架构的模式来进行编程,哪三层呢?

注:以JavaWeb中为例

① 表现层(Web层)

  • 作用:接收客户端请求(一般是HTTP请求),同时向其响应结果
  • 分类:表现层分为,展示层和控制层,控制层 (Servlet) 负责接收请求,展示层 (HTML JSP) 负责结果的展示
  • 在表现层会依赖于业务层,进行业务处理,也就是好比在 Servlet 中调用某个Service
  • 一般使用 MVC 模型开发(仅限此层,详情下面会说)

② 业务层(Service层)

  • 作用:根据项目需求,进行业务逻辑处理
  • 在业务层可能会依赖于持久层,也就是好比在 Service 中调用某个 Dao

③ 持久层 (Dao)

  • 作用:数据持久化
  • 说白了,就是实现和数据库之间的交互,本质都是增删改查,只不过不同的项目复杂程度会有所不同

有两点需要强调一下:

什么是,某某层依赖于某某层?

例如表现层依赖业务层,在 JavaWeb 阶段实际上就是在 Servlet 中 new 了一个 Service ,当然,在Spring的 IOC 下我们只需要在控制层中添加Service的引用就可以了,并不需要再new了,耦合大大降低,我们上面说的依赖主要指两个层之间存在一定的关系

什么是业务逻辑?

针对,一些简单的操作,例如单表数据的增删,实际上几乎没有任何业务,最多例如参数不合法一类的,能加个返回的错误码,但如果面对一些比较复杂的项目,就存在一些业务逻辑需要编写

例如:查询时需要的结果,并不是简单的一张表中,而查询条件也比较复杂,我们就可以通过对查询条件进行拆分,再组合,就可以查询到不同需求的数据。

再例如:以前文章中我常说的转账案例,为了避免在转账的整个过程中发生异常,导致资金发生问题,就需要保证事务的一致性,而这些事务我们就可以放在业务层来做,当然 Spring 的AOP 可以帮助我们更好的处理事务问题

(三) MVC 模型

MVC 也就是 model-view-controller,我们来看看它的每一部分

Model(模型)

  • Model 可以叫做数据模型层,也就是用来封装数据的
  • 例如请求的过程中,用户信息被封装在 User 实体类中,这个实体类就属于 Model 层中

View(视图)

  • 视图层中会选择一个恰当的视图来显示最终的执行结果
  • 例如常见的 HTML JSP 就是用来展示数据的

Controller(控制)

  • 这就是比较直观的用来处理交互的部分,接收用户请求,然后执行业务等流程,以及一些数据的校验,最终反馈结果

做了一张 MVC 模式下的工程结构图,方便大家理解

初识 Spring MVC

实际上,如果是初次接触 Spring MVC 实际上,看个基本概念也就行了,比如下面我提到的,Spring MVC 的优点,Spring MVC 与 Struts 的区别,如果在没有进行过一些基本的使用,以及简单一些流程的简单分析,实际上没啥卵用,这些东西简单看看就行了,经过一定的学习以后,回过头来再看,会有感觉的多

(一) Spring MVC 基础

Spring Web MVC is the original web framework built on the Servlet API and has been included in the Spring Framework from the very beginning. The formal name, “Spring Web MVC,” comes from the name of its source module (spring-webmvc), but it is more commonly known as “Spring MVC”.

—— Spring官网

Spring MVC属于SpringFrameWork的后续产品,已经融合在Spring Web Flow里面。Spring 框架提供了构建 Web 应用程序的全功能 MVC 模块。使用 Spring 可插入的 MVC 架构,从而在使用Spring进行WEB开发时,可以选择使用Spring的Spring MVC框架或集成其他MVC开发框架,如Struts1(现在一般不用),Struts 2(一般老项目使用)等。

—— 百度百科

MVC 在上面我们已经进行了基本的介绍,而Spring MVC 就是一款基于 MVC架构模式的轻量级Web框架,我们所说的 Spring MVC 与 Spring Web MVC 是等价的,只不过人们更习惯前者的叫法,这一款框架,本质上也是基于 Servlet 的,如果你有 Servlet 以及 Spring 的基础,简单的上手这个Web框架是非常快的

(二) Spring MVC 的优点

  • ① Spring MVC 具有 Spring 的优点,例如依赖注入 (IOC) 和切面编程 (AOP)

  • ② 清晰的模块化职能划分,各模块各司其职,清晰明了

    • 控制器 (controller)
    • 验证器 (validator)
    • 命令对象 (command obect)
    • 表单对象 (form object)
    • 模型对象 (model object)
    • Servlet分发器 (DispatcherServlet)
    • 处理器映射 (handler mapping)
    • 试图解析器 (view resoler)
  • ③ 可以非常方便的与其他视图技术 (FreeMarker) 整合,由于Spring MVC 的模型数据往往放在 Map 数据结构中,因此可以很方便的被其他框架引用

  • ④ 可以灵活的实现绑定 (binding) 、验证 (validation)

  • ⑤ 简介的异常处理机制

  • ⑥ 比较强大的 JSP 标签库,简化了JSP的开发

  • ⑦ 支持 RESTful 风格

  • ⑧ 提供了强大的约定大于配置的契约式编程支持,也就是提供一种软件设计范式,减少软件开发人员做决定的次数,开发人员仅需要规定应用中不符合约定的部分

(三) Spring MVC 与 Struts 的区别

Struts 也是一款基于 MVC 这种在开发模式的 JavaEE框架,近些年来,实际上开发者更多的选择使用 SpringMVC 这个框架,那么两者的区别是什么呢?Spring MVC 的过人之处又在哪里呢?

① Spring MVC 基于方法开发,Struts 基于类开发

  • 使用 Spring MVC 开发的时候,会将 URL 请求的路径与 Controller 的某个方法进行绑定,请求参数作为该参数方法的形参
  • 使用 Struts 开始的时候,Action 类中所有方法使用的请求参数都是 Action 类中的成员变量,一旦方法变多,很容易混淆成员变量对应使用的方法

② Spring MVC 支持单例开发模式,而 Struts 不支持

③ Spring MVC 的速度比 Struts 的速度稍微快一些

  • 一是由于 Struts 每次都会创建一个动作类
  • 二是由于 Struts 的标签设计问题

④ Spring MVC 使用更加简洁,同时还支持 JSR303,能够比较方便的处理 ajax

  • JSR 303 – Bean Validation (后台通用参数校验)

⑤ Struts2 的 OGNL 表达式使页面的开发效率相比 Spring MVC 更高一点,但是执行效率对于 JSTL 也没有很明显的提升

浅尝 Spring MVC

(一) 搭建开发环境

(1) 创建项目

① 创建Maven项目 --> ② 选择JDK版本 --> ③ 勾选 create from archetype 即使用骨架创建项目 --> ④ 选择 maven-archetype-webapp 创建出一个web项目

然后指定基本信息,点击下一步

但是,由于创建 maven archetype 的原因,在创建时,会执行 mvn archetype:generate这个命令,这样就需要指定一个 archetype-catalog.xml 文件,命令中参数 -DarchetypeCatalog 的值有三种

  • remote:从Maven远程中央仓库获取 archetypeCatalog(默认的)
  • internal:从 maven-archetype-plugin 内置的 archetypeCatalog 文件获取
  • local:本地的 archetypeCatalog 文件

我们需要做的就是添加这样一组键值对,就可以加快创建项目的速度

  • DarchetypeCatalog
  • internal

这里没什么好说的,基本不需要更改,继续下一步

(2) 修改pom文件

将版本从1.7改为1.8,接着又在 dependencies 中引入我们需要的一些 jar 包

定义 <spring.version>5.0.2.RELEASE</spring.version> 这样一个标签对,在下面就可以引用,这样相比于直接将版本信息写到每一个 dependencie 中,更利于后期的维护,方便更换版本,这种方式叫做锁定版本

<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>cn.ideal</groupId>
  <artifactId>spring_mvc_01_basic</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>war</packaging>

  <name>spring_mvc_01_basic Maven Webapp</name>
  <!-- FIXME change it to the project's website -->
  <url>http://www.example.com</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <spring.version>5.0.2.RELEASE</spring.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-context</artifactId>
      <version>${spring.version}</version>
    </dependency>

    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-web</artifactId>
      <version>${spring.version}</version>
    </dependency>

    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-webmvc</artifactId>
      <version>${spring.version}</version>
    </dependency>

    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>servlet-api</artifactId>
      <version>2.5</version>
      <scope>provided</scope>
    </dependency>

    <dependency>
      <groupId>javax.servlet.jsp</groupId>
      <artifactId>jsp-api</artifactId>
      <version>2.0</version>
      <scope>provided</scope>
    </dependency>

    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>

  </dependencies>


  <build>
    <finalName>spring_mvc_01_basic</finalName>
    <pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
      <plugins>
        <plugin>
          <artifactId>maven-clean-plugin</artifactId>
          <version>3.1.0</version>
        </plugin>
        <!-- see http://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_war_packaging -->
        <plugin>
          <artifactId>maven-resources-plugin</artifactId>
          <version>3.0.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.8.0</version>
        </plugin>
        <plugin>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>2.22.1</version>
        </plugin>
        <plugin>
          <artifactId>maven-war-plugin</artifactId>
          <version>3.2.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-install-plugin</artifactId>
          <version>2.5.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-deploy-plugin</artifactId>
          <version>2.8.2</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>

(3) 目录结构

刚创建好的项目中,main文件夹下是空的,我们需要创建出 java 以及 resources 两个文件夹,并且分别设置为,源代码根目录 以及 资源根目录,设置方式如下图

(二) 编写入门程序

(1) 配置核心控制器

在以前 JavaWeb 阶段中,我们都很清楚,前端发出的请求,都会被映射到 Web.xml 中,然后匹配到对应的 Servlet 中,然后调用对应的 Servlet 类 来处理这个请求

由于现在我们使用了 Spring MVC,所以这些请求,我们就交给 Spring MVC 进行管理,所以需要在工程 webapp-WEB-INF 中找到 web.xml 进,在其中配置核心控制器,也就是 DispatcherServelt

<servlet ></servlet >标签中指定了一个实现类为 DispatcherServelt ,名称为 dispatcherServlet 的 servlet 配置

<servlet-mapping></servlet-mapping>标签中则指定了 dispatcherServlet 拦截请求的范围,使用 / 即代表所有请求都需要经过这里

<init-param></init-param>标签对中放置 DispatcherServelt 所需要的初始化参数,配置的是 contextConfigLocation 上下文参数变量,其加载的配置文件为编译目录下的 springmvc.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>
    <!--配置Servlet初始化参数,读取springmvc的配置文件,创建spring容器-->
    <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>

(2) 创建核心配置文件

在这里,一个是开启扫描,以及开启注解,还有就是配置视图解析器,它的作用就是执行方法后,根据返回的信息,来加载相应的界面,并且绑定反馈数据

<?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">

    <!-- 配置spring创建容器时要扫描的包-->
    <context:component-scan base-package="cn.ideal"></context:component-scan>

    <!-- 配置视图解析器-->
    <bean id="viewResolver"
          class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="prefix" value="/WEB-INF/pages/"></property>
        <property name="suffix" value=".jsp"></property>
    </bean>

    <!-- 配置spring开启注解mvc的支持 -->
    <mvc:annotation-driven></mvc:annotation-driven>
</beans>

特别说明:一般开发我们都需要写上这个标签,即使或许现在还没怎么体现出来

(3) 编写控制类

package cn.ideal.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class ControllerDemo {
    @RequestMapping(path = "/test")
    public String methodTest(){
        System.out.println("这是Controller测试方法");
        return "testSuccess";
    }
}

(4) 编写页面

index.jsp

写一个超链接,去请求test这个路径,也就是指向到了 Controller 下的 methodTest() 方法

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
    <h3>这是主页面</h3>
    <a href="test">访问test试试</a>
</body>
</html>

WEB-INF -> pages

testSuccess.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
    <h3>跳转成功哈</h3>
</body>
</html>

(5) 配置 Tomcat

我这里,配置了本地的tomcat,以及项目名称

(三) Spring MVC 请求流程

前端控制器(DispatcherServlet)

  • 接收用户请求,以及做出响应
  • 它负责调用其他组件处理用户的请求,控制整个流程的执行,想当于一个中央处理器
  • 它降低了组件之间的耦合行,利于组件之间的扩展

处理器映射器(HandlerMapping)

  • 根据用户请求的 URL 路径,通过注解或者 XML 配置,寻找匹配的 Handler 即处理器

处理器适配器(HandlerAdapter)

  • 根据映射器找到的处理器(Handler)信息,按照特定规则执行相关的 Handler (常称为 Controller)

处理器(Hander)

  • 这就是开发中要编写的具体业务逻辑控制器,执行相关的请求处理逻辑,并且返回相应的数据和视图信息,然后封装到 ModeAndView 对象中

视图解析器(View resolver)

  • 通过ModelAndView 对象中的 View 信息将逻辑视图名解析成物理视图名,即具体的页面地址,然后再生成 View 视图对象,最后对 View 进行渲染处理结果通过页面展示给用户

视图(View)

  • 本身是一个接口,实现类支持不同 View 类型 (JSP、FreeMarker、Excel 等)

注:我们开发人员真正需要进行开发的是处理器(Handler)和视图(View)

也就是,处理用户请求的具体逻辑代码,以及展示给用户的界面

(四) 请求映射与参数绑定

(1) RequestMapping

@RequestMaspping 注解是指定控制器可以处理哪些URL请求,这个注解可以放在类或者方法上。

  • 类上:一级访问目录
  • 方法上:二级访问目录
  • ${ pageContext.request.contextPath }可以省略不写,但路径上不能写/

属性:

  • path:指定请求路径的url
  1. value:value属性和path属性是一样的
  2. mthod:指定该方法的请求方式
  3. params:指定限制请求参数的条件
  4. headers:发送的请求中必须包含的请求头

而一般不在 @RequestMaspping 中配置其他属性的时候,可以省去 value 参数名,直接写一个代表 URL 映射信息的字符串就可以了

例如:@RequestMaspping(/test)

(2) 请求参数的绑定

在用户在页面中出发请求的时候,提交表单的数据一般都是 key/value 格式的数据

在传统JavaWeb 中我们所使用的一般是 request.getParameter() 等方法将请求参数获取到

而Spring MVC中可以通过参数绑定,将客户端请求的这个 key/value 格式的数据绑定到 Controller 处理器方法的形参上,支持的数据类型我们可以分为三类

A:基本数据类型和字符串类型

index.jsp

注:只截取了部分

<h3>这是主页面</h3>
<a href="user/testA?username=admin&password=admin888">测试一下</a>

pages --> testSuccess.jsp

<h3>跳转成功哈</h3>

UserController

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

    @RequestMapping("/testA")
    public String testA(String username, String password) {
        System.out.println("获取到的username: " + username);
        System.out.println("获取到的password: " + password);
        return "testSuccess";
    }

}

通过构建一个超链接的方式传递参数,例如 ?username=admin 而在后端中如果方法形参与这个username是一致的,这个提交的数据就会被绑定到参数username中

B:JavaBean 实体类型

参数中使用 JavaBean 类型接收时,在提交表单的时候,就需要将其中的 name 属性中的值与实体类中的成员变量的值是一样的

如果一个JavaBean类中包含其他的引用类型,那么表单的name属性需要编写成:对象.属性例如:account.username

index.jsp

<form action="user/testB" method="post">
	昵称: <input type="text" name="nickname"><br/>
	年龄: <input type="text" name="age"><br/>
    住址: <input type="text" name="address"><br/>
    用户名: <input type="text" name="account.username"><br/>
    密码: <input type="text" name="account.password"><br/>
    <input type="submit" value="提交">
</form>

UserController

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

    @RequestMapping("/testB")
    public String testB(User user) {
        System.out.println(user);
        return "testSuccess";
    }
    
}

实体类 User 和 Account

public class User implements Serializable {
    private String nickname;
    private int age;
    private String address;
    private Account account;
    ......省略 get set toString方法
}
public class Account implements Serializable {
    private String username;
    private String password;
    ......省略 get set toString方法
}

C:集合数据类型

对于集合类型,仍然使用一个表达式的写法

index.jsp

<form action="user/testB" method="post">
    昵称: <input type="text" name="nickname"><br/>
    年龄: <input type="text" name="age"><br/>
    住址: <input type="text" name="address"><br/>
    用户名1: <input type="text" name="list[0].username"><br/>
    密码1: <input type="text" name="list[0].password"><br/>
    用户名2: <input type="text" name="map['First'].username"><br/>
    密码2: <input type="text" name="map['First'].password"><br/>
    <input type="submit" value="提交">
</form>

UserController

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

    @RequestMapping("/testB")
    public String testB(User user) {
        System.out.println(user);
        return "testSuccess";
    }
    
}

实体类 User 和 Account

public class User implements Serializable {
    private String nickname;
    private int age;
    private String address;

    private List<Account> list;
    private Map<String, Account> map;
    ......省略 get set toString方法
}
public class Account implements Serializable {
    private String username;
    private String password;
    ......省略 get set toString方法
}

解决请求参数中文乱码的问题

在 web.xml 中的 <web-app></web-app>标签内配置过滤器类,达到解决请求参数中文乱码的问题

<!--配置解决中文乱码的过滤器-->
<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) RequestParam 注解

  • 作用:把请求中的指定名称的参数传递给控制器中的形参

  • 属性

    • value:请求参数中的名称

    • name:name是value的别名,一个账户

    • required:是否必需,默认为 true,即 请求中必须包含该参数,如果没有包含,将会抛出异常(可选)

@RequestMapping(path="/hello")
public String sayHello(@RequestParam(value="nick",required=false)String nickname) {
	System.out.println(nickname);
	return "success";
}

(2) RequestBody 注解

  • 作用:用于获取请求体的内容(注:get方法不可以)
@RequestMapping("/testC")
public String testC(@RequestBody String body) {
    System.out.println(body);
    return "testSuccess";
}

例如接收到这样的语句

nickname=BWH_Steven&age=666&address=beijing

(3) PathVariable 注解

  • 作用:用于绑定url中的占位符,例如:url中有/test/{id},{id}就是占位符
  • 属性:
    • value:用于指定url中占位符名称

UserController

@RequestMapping(path="/test/{uid}")
public String testD(@PathVariable(value="uid") String id) {
    System.out.println(id);
    return "testSuccess";
}

index.jsp

<a href="user/test/66">访问test试试</a>

(4) RequestHeader 注解 (不常用)

  • 作用:获取指定请求头的值
  • 属性:
    • value:请求头的名称

UserController

@RequestMapping("/testD")
public String testD(@RequestHeader(value="Accept") String header) {
    System.out.println(header);
    return "testSuccess";
}

index.jsp

<a href="user/testD">访问test试试</a>

打印结果:

text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3

(5) CookieValue注解

  • 作用:用于获取指定cookie的名称的值

属性:

  • value:cookie的名称

UserController

@RequestMapping("/testF")
    public String testF(@CookieValue(value="JSESSIONID") String cookieValue) {
        System.out.println(cookieValue);
        return "testSuccess";
    }

index.jsp

<a href="user/testF">访问test试试</a>

打印结果:

FCFDD389AC473F837266FC890E9E6F36

(6) ModelAttribute 注解

作用:

  • 在方法上:表示当前方法会在控制器方法执行前执行

  • 在参数上:获取指定的数据给参数赋值

应用场景:

  • 提交表单的数据不是完整的数据,而没提交的字段,就是用数据库中原来的
  • 例如:用户修改个人信息,但是昵称则不允许修改,只提供修改例如年龄、地址等的表单,如果不进行任何的处理,就会导致,接收到的数据中 nickname 这个值是 null,再存到数据库就会对原来的数据造成损失影响
  • 还有一些情况就例如:账号注册日期这种信息当然也是不能被修改的

index.jsp

只提供修改年龄和地址的表单,同时传一个隐藏域中的id,方便去数据库查询(当然我们这里是模拟的)

<form action="user/testG" method="post">
    <input type="hidden" name="uid" value="1">
    年龄: <input type="text" name="age"><br/>
    住址: <input type="text" name="address"><br/>
    <input type="submit" value="提交">
</form>

UserController

实体类就不给出了,就是三个成员,nickname age address

如果没有下面这个增加了 @ModelAttribute 注解的 findUserByUid方法,当执行 testG 方法后,会获取到一个 nickname = null 的值

而我们下面的做法,在执行 testG 之前会先执行 findUserByUid,然后可以去数据库中根据uid查询,当然我们这里是模拟的,然后将这个user返回

接着执行 testG 方法的时候,就能将用户提交的 age 和 address 获取到,同时将用户没有提交的 nickname 使用数据库中的值

@RequestMapping("/testG")
public String testG(User user) {
    System.out.println("这是testG方法");
    System.out.println("用户修改后的: " + user);
    return "testSuccess";
}

@ModelAttribute
public User findUserByUid(Integer uid) {
    System.out.println("这是findUserByUid方法");
    //模拟查询数据库
    User user = new User();
    user.setNickname("BWH_Steven");
    user.setAge(66);
    user.setAddress("北京");

    System.out.println("数据库中查询到: " + user);

    return user;
}

另一种方式

如果没有返回值的方式就可以这样改写,增加一个 map ,然后存进去

@RequestMapping("/testG")
public String testG(@ModelAttribute("first") User user) {
    System.out.println("这是testG方法");
    System.out.println("用户修改后的: " + user);
    return "testSuccess";
}

@ModelAttribute
public void findUserByUid(Integer uid, Map<String,User> map) {
    System.out.println("这是findUserByUid方法");
    //模拟查询数据库
    User user = new User();
    user.setNickname("BWH_Steven");
    user.setAge(66);
    user.setAddress("北京");

    System.out.println("数据库中查询到: " + user);

    map.put("first",user);

}

(7) SessionAttributes 注解

  • 作用:用于多次执行控制器方法间的参数共享

属性

  • value:指定存入属性的名称

UserController

在存入方法跳转之前,会将数据保存到 nickname age address 中,因为注解@SessionAttribute中有这几个参数

@Controller
@RequestMapping("/user")
@SessionAttributes(value = {"nickname","age","address"})
public class UserController {
	/**
     * 向session中存入值
     * @param model
     * @return
     */
    @RequestMapping("/addUser")
    public String addUser(Model model) {
        model.addAttribute("nickname", "BWH_Steven");
        model.addAttribute("age", 20);
        model.addAttribute("address", "北京");
        return "testSuccess";
    }

    /**
     * 从session中获取值
     * @param modelMap
     * @return
     */
    @RequestMapping("/findUser")
    public String findUser(ModelMap modelMap) {
        String nickname = (String) modelMap.get("nickname");
        Integer age = (Integer)modelMap.get("age");
        String address= (String) modelMap.get("address");
        System.out.println(nickname + " " + age + " " + address);
        return "testSuccess";
    }

    /**
     * 清除值
     * @return
     */
    @RequestMapping("/delete")
    public String delete(SessionStatus status) {
        status.setComplete();
        return "testSuccess";
    }
}

index.jsp

<a href="user/addUser">访问addUser试试</a>
<a href="user/findUser">访问findUser试试</a>
<a href="user/delete">访问delete试试</a>

testSuccess.jsp

注意设置 isELIgnored="false"

<%@ page contentType="text/html;charset=UTF-8" language="java" isELIgnored="false" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
    <h3>跳转成功哈</h3>
    ${nickname}
    ${age}
    ${address}
    ${sessionScope}
</body>
</html>

(六) 响应数据以及结果视图

讲完了请求与参数绑定,以及一些常用的注解,接着就可以说一下响应的一些知识,也就是我们接受到用户的请求,并且进行一定的处理以后,如何进行正确的响应

(1) 返回字符串

其实在前面的讲解中,我们一直用的就是返回字符串的形式,而结果也是很直观的,也就是,进行了同名页面的跳转,例如返回 success 则跳转到 success.jsp 的页面中

这也就是说,Controller 方法返回字符串可以指定逻辑视图的名称,视图解析器会将其解析成物理视图的地址

演示一种常见的使用场景

index.jsp

<a href="user/testString">修改用户信息页面</a>

UserController

注:实体类就不谈了,只有 username 和 password 两个成员

模拟一个数据库查询到数据信息,然后将数据存到 request 域中

@Controller
@RequestMapping("/user")
public class UserController {
    @RequestMapping("/testString")
    public String testString(Model model) {
        //模拟从数据库中查询
        User user = new User();
        user.setUsername("张三");
        user.setPassword("888666");
        model.addAttribute("user", user);
        return "success";
    }
}

success.jsp

注意配置:isELIgnored="false"

当用户点击主页面中进行页面修改,就会跳转到这个用户名以及密码的修改界面,同时将数据进行回显,优化体验

<%@ page contentType="text/html;charset=UTF-8" language="java"  isELIgnored="false" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
    <h3>修改</h3>
    <form action="" method="post">
        用户名:<input type="text" name="username" value="${ user.username }"><br>
        密码:<input type="text" name="password" value="${ user.password }"><br>
        <input type="submit" value="提交">
    </form>
</body>
</html>

A:响应转发或者重定向

<a href="user/testForward">测试一下</a>
@RequestMapping("/testForward")
    public String testForward() throws Exception{
        System.out.println("testForward 被执行了");
          //转发
//        return "forward:/WEB-INF/pages/success.jsp";
        //重定向
        return "redirect:/index.jsp";
    }

(2) 返回 void

如果说直接去掉返回值,以及修改返回类型为void,会报出一个404异常,可以看到地址栏中,去指向了一个 http://localhost:8080/springmvc-response/user/testVoid.jsp 的地址,也就是说它默认去查找了一个jsp页面(也就是 @RequestMapping("/testVoid") 值同名的 jsp),不过没有找到

如果想要在这种情况下,跳转页面可以使用请求转发,或者重定向跳转

@RequestMapping("/testVoid")
public void testVoid(HttpServletRequest request,HttpServletResponse response) throws
Exception {
System.out.println("请求转发或者重定向被执行了");
// 1. 请求转发
// request.getRequestDispatcher("/WEB-INF/pages/test1.jsp").forward(request,
response);
    
// 2. 重定向
// response.sendRedirect(request.getContextPath()+"/test2.jsp");
    
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=UTF-8");
// 3. 直接响应数据
response.getWriter().print("测试被执行了哈");
return;
}

(3) 返回值是ModelAndView对象

这种方式其实和String达到的效果基本是一致的

index.jsp

<a href="user/findAll">测试一下</a>

UserController

@RequestMapping("/findUser")
public ModelAndView findUser() throws Exception{
    ModelAndView modelAndView = new ModelAndView();
    //跳转到jsp
    modelAndView.setViewName("success");

    //模拟从数据库中查询用户信息
    User user = new User();
    user.setUsername("李四");
    user.setPassword("888888");
    
    modelAndView.addObject("user",user);
    return modelAndView;
}

success.jsp

${user.username}
${user.password}

(4) 过滤静态资源(必备)

在 web.xml 中配置的 DispatcherServle(前端控制器),会拦截到所有的资源,在以后的开发中,一个特别显著的问题就是,静态资源 (img、css、js)这样的文件也被拦截了,也就无法使用,我们首先需要了解的就是如何不对静态资源进行拦截

非常简单,在springmvc.xml中配置就可以了

mvc:resources 标签就可以配置不过滤

  • location 表示webapp目录下的包下的所有文件
  • mapping 表示以/xxx开头的所有请求路径,如/xxx/a 或者/xxx/a/b
<!--前端控制器-->
<mvc:resources mapping="/css/**/" location="/css/"/>
<mvc:resources mapping="/images/**/" location="/images/"/>
<mvc:resources mapping="/js/**/" location="/js/"/>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>

    <%--引入jquery--%>
    <script src="js/jquery-2.1.0.min.js"></script>
    <script>
        $(function () {
            $("#btn").click(function () {
                alert("Just for test");
            });
        });
    </script>
</head>
<body>
    <%--<a href="user/testString">修改用户信息页面</a>--%>
    <%--<a href="user/testForward">测试一下</a>--%>
    <button id="btn">发送ajax请求</button>
</body>
</html>

(5) 发送ajax请求-后台获取请求体

index.jsp

在 Javaweb 阶段,大家基本都是有了解过 ajax 的,所以我就直接用了,如果有不熟悉的,可以去查一下api或者找一下教程,格式还是非常好理解的

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>

    <%--引入jquery--%>
    <script src="js/jquery-2.1.0.min.js"></script>
    <script>
        $(function () {
            $("#btn").click(function () {
                //发送ajax请求
                $.ajax({
                    url:"user/testAjax",
                    contentType:"application/json;charset=UTF-8",
                    data:'{"username":"zhangsan","password":"888888"}',
                    dataType:"json",
                    type:"post",
                    success:function (data) {
                        //解析响应数据
                    }
                })
            });
        });
    </script>
</head>
<body>
    <button id="btn">发送ajax请求</button>
</body>
</html>

参数中使用 @RequestBody 这个注解,就可以接收到请求体,然后变成这样一个串的形式

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

    @RequestMapping("/testAjax")
    public void testAjax(@RequestBody String body){
        System.out.println("testAjax 被执行了");
        System.out.println(body);
    }
}

打印结果就是这样的

testAjax 被执行了
{"username":"zhangsan","password":"888888"}

(6) 响应json格式数据

UserControllr

@RequestMapping("/testAjax")
public @ResponseBody User testAjax(@RequestBody User user){
    System.out.println("testAjax 被执行了");
    // 模拟数据库查询
    System.out.println(user);
    user.setUsername("admin");
    user.setPassword("admin888");
    return user;
}

使用 @RequestBody String body 接收到的是一个串,而想要直接将 Json 字符串和 JavaBean 对象相互转换,需要 jackson 的jar包,我们可以增加这样的依赖

<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.9.0</version>
</dependency>
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-core</artifactId>
  <version>2.9.0</version>
</dependency>
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-annotations</artifactId>
  <version>2.9.0</version>
</dependency>

index.jsp

<%--引入jquery--%>
<script src="js/jquery-2.1.0.min.js"></script>
<script>
    $(function () {
        $("#btn").click(function () {
            //发送ajax请求
            $.ajax({
                url:"user/testAjax",
                contentType:"application/json;charset=UTF-8",
                data:'{"username":"zhangsan","password":"888888"}',
                dataType:"json",
                type:"post",
                success:function (data) {
                    //解析响应数据
                    alert(data);
                    alert(data.username);
                    alert(data.password);
                }
            })
        });
     });
</script>

(七) 文件上传

(1) 普通文件上传方式

index.jsp

<h3>文件上传</h3>
<form action="user/fileupload" method="post" enctype="multipart/form-data">
    选择文件:<input type="file" name="upload"/><br/>
    <input type="submit" value="上传文件"/>
</form>
  • form表单的enctype的默认值是:application/x-www-form-urlencoded

    • 表单正文内容一般是:key=value&key=value&key=value
  • 如果想要进行文件上传,就必须要改为 multipart/form-data(),同时method属性取值必须是Post

    • 每一部分都变成MIME类型描述的正文

注意:当form表单的enctype取值不是application/x-www-form-urlencoded后,request.getParameter()方法就不能再使用

注意:想要实现文件上传,可以借助一些组件,需要导入该组件相应的支撑jar 包:Commons-fileupload 和commons-io

commons-io 不属于文件上传组件的开发jar文件,但Commons-fileupload 组件从1.1 版本开始,它使用需要commons-io包的支持

UserController

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

    @RequestMapping("/fileupload")
    public String fileupload(HttpServletRequest request) throws Exception {
        System.out.println("文件上传...");

        // 使用fileupload组件完成文件上传
        // 上传位置
        String path = request.getSession().getServletContext().getRealPath("/uploads/");
        // 判断,该路径是否存在
        File file = new File(path);
        if (!file.exists()) {
            // 不存在则创建文件夹
            file.mkdirs();
        }

        // 解析request对象,获取上传文件项
        DiskFileItemFactory factory = new DiskFileItemFactory();
        ServletFileUpload upload = new ServletFileUpload(factory);
        // 解析request
        List<FileItem> items = upload.parseRequest(request);
        // 遍历
        for (FileItem item : items) {
            // 进行判断,当前item对象是否是上传文件项
            if (item.isFormField()) {
                // 普通表单项
            } else {
                // 上传文件项
                // 上传文件的名称
                String filename = item.getName();
                // 把文件的名称设置唯一值,UUID 防止重复覆盖问题
                String uuid = UUID.randomUUID().toString().replace("-", "");
                filename = uuid + "_" + filename;
                // 完成文件上传
                item.write(new File(path, filename));
                // 删除临时文件
                item.delete();
            }
        }

        return "success";
    }
}

说明

request.getSession().getServletContext() 获取的是Servlet容器对象,就好比tomcat容器

getRealPath("/") 代表获取实际路径,“/”指代项目根目录

所以代码返回的是项目在容器中的实际发布运行的根路径

(2) Spring MVC 上传方式(同服务器)

index.jsp

<h3>文件上传</h3>
<form action="user/fileupload2" method="post" enctype="multipart/form-data">
    选择文件:<input type="file" name="upload"/><br/>
    <input type="submit" value="上传文件"/>
</form>

springmvc.xml

<!--配置文件解析器对象-->
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
    <property name="maxUploadSize" value="10485760" />
</bean>

注意:10485760 = 10x1024x1024

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

    @RequestMapping("/fileupload2")
    public String fileupload2(HttpServletRequest request, MultipartFile upload) throws Exception {
        System.out.println("文件上传...");

        // 使用fileupload组件完成文件上传
        // 上传位置
        String path = request.getSession().getServletContext().getRealPath("/uploads/");
        // 判断,该路径是否存在
        File file = new File(path);
        if (!file.exists()) {
            // 不存在则创建文件夹
            file.mkdirs();
        }

        // 获取上传文件的名称
        String filename = upload.getOriginalFilename();
        // 把文件的名称设置唯一值,uuid
        String uuid = UUID.randomUUID().toString().replace("-", "");
        filename = uuid+"_"+filename;
        // 完成文件上传
        upload.transferTo(new File(path,filename));

        return "success";
    }
}

(3) Spring MVC 上传方式(跨服务器)

很多时候会将整个工程部署到不同的服务器,例如:

应用服务器,数据库服务器,缓存和消息服务器,文件服务器等等,不过入门来说了解一下就可以了

想要测试下面的代码,可以配置两个 Tomcat 给不同端口等配置,模拟一下

index.jsp

<h3>文件上传</h3>
<form action="user/fileupload3" method="post" enctype="multipart/form-data">
    选择文件:<input type="file" name="upload"/><br/>
    <input type="submit" value="上传文件"/>
</form>

增加依赖

<dependency>
  <groupId>com.sun.jersey</groupId>
  <artifactId>jersey-core</artifactId>
  <version>1.18.1</version>
</dependency>
<dependency>
  <groupId>com.sun.jersey</groupId>
  <artifactId>jersey-client</artifactId>
  <version>1.18.1</version>
</dependency>

UserController

@Controller
@RequestMapping("/user")
public class UserController {
    
   @RequestMapping("/fileupload3")
    public String fileupload3(MultipartFile upload) throws Exception {
        System.out.println("SpringMVC跨服务器方式的文件上传...");
        // 定义图片服务器的请求路径
        String path = "http://localhost:9090//springmvc-fileupload/uploads/";
        // 获取到上传文件的名称
        String filename = upload.getOriginalFilename();
        String uuid = UUID.randomUUID().toString().replaceAll("-", "").toUpperCase();
        // 把文件的名称唯一化
        filename = uuid + "_" + filename;
        // 向图片服务器上传文件
        // 创建客户端对象

        Client client = Client.create();
        // 连接图片服务器
        WebResource webResource = client.resource(path + filename);
        // 上传文件
        webResource.put(upload.getBytes());
        return "success";
    }
}

(八) 异常处理

异常处理也算一个老生常谈的问题,在上线项目或者运行项目的时候,总可能会出现一些无法预料的异常信息,对于开发者而言,自然需要看到具体的异常信息,然后进行排除,而对于用户,自然尽可能的出现一些简单,易于理解的语言或者提示

在 Spring MVC 中,提供了一个全局异常处理器,可以对异常进行统一处理

Dao、Service、Controller出现都通过 throws Exception 向上抛出,最后由Spring MVC前端
控制器交由全局异常处理器进行异常处理

(1) 自定义异常类

对于预期的异常,通常定义一个自定义异常类,用来存储异常的信息

首先这个类继承了 Exception 类,用来描述程序能获取的异常,设置了一个成员message,就是用来存放异常信息的

package cn.ideal.exception;

public class SysException extends Exception {
    private String message;

    @Override
    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public SysException(String message) {
        this.message = message;
    }
}

(2) 全局异常处理器

全局异常处理器实现的是 Spring MVC 的 HandlerExceptionResolver 接口

这是接口的源码

public interface HandlerExceptionResolver {
    @Nullable
    ModelAndView resolveException(HttpServletRequest var1, HttpServletResponse var2, @Nullable Object var3, Exception var4);
}
  • Exception ex:就是 Controller 或下层抛出的异常
  • Object handler:处理器适配器要执行的 Handler 对象
  • 返回值类型:ModelAndView 这也就是说,可以通过这个返回值设置异常时显示的页面
public class SysExceptionResolver implements HandlerExceptionResolver {
    /**
     * 跳转到具体的错误页面的方法
     */
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse
            response, Object handler,Exception ex) {
        ex.printStackTrace();
        //解析出异常类型
        SysException sysException = null;
        // 获取到异常对象
        if (ex instanceof SysException) {
            //如果异常类型是系统自定义异常,直接取出异常信息,在错误页面展示
            sysException = (SysException) ex;
        } else {
            //如果异常类型不是系统自定义异常,则构造一个自定义异常类型
            sysException = new SysException("未知错误");
        }
        ModelAndView mv = new ModelAndView();
        // 存入错误的提示信息
        mv.addObject("errorMsg", sysException.getMessage());
        // 跳转的Jsp页面
        mv.setViewName("error");
        return mv;
    }
}

(3) 配置异常处理器

springmvc.xml 中配置

<bean id="sysExceptionResolver" class="cn.ideal.exception.SysExceptionResolver"/>

(4) 测试代码

UserController

@Controller
@RequestMapping("/user")
public class UserController {
    @RequestMapping("testException")
    public String testException() throws SysException {
        System.out.println("testException被执行了");
        try {
            int a = 100 / 0;
        } catch (Exception e) {
            e.printStackTrace();
            throw new SysException("测试这个方法出错了");
        }
        return "success";
    }
}

index.jsp

<h3>主页面</h3>
<a href="user/testException">测试一下</a>

error.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" isELIgnored="false" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
    ${errorMsg}
</body>
</html>

(九) 拦截器

拦截器,用来干嘛呢,就比如你需要检测用户的权限,或者把请求信息记录到日志等等,也就是说需要在用户请求前后执行的一些行为

首先在 Spring MVC 中是有两种机制,第一种就是实现 HandlerInterceptor接口,还有一种就是实现 Spring 的 WebRequestInterceptor 接口,不过我们就简单说一下第一种

自定义一个类简单看一下,实现三个方法

public class MyInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        System.out.println("preHandle方法执行了");
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        System.out.println("postHandle方法执行了");
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println("afterCompletion方法执行了");
    }
}

① preHandle方法:controller 方法执行前拦截的方法

  • 可以使用 request 或者 response 跳转到指定的页面

  • return true放行,执行下一个拦截器,如果没有拦截器,执行 controller 中的方法

  • return false不放行,不会执行 controller 中的方法

② postHandle:controller 方法执行后执行的方法,在 JSP 视图执行前

  • 可以使用 request 或者 response 跳转到指定的页面

  • 如果指定了跳转的页面,那么 controller 方法跳转的页面将不会显示。

③ postHandle方法:在JSP执行后执行

  • request 或者 response 不能再跳转页面了

配置拦截器

注:不要拦截用这个标签<mvc:exclude-mapping path=""/>

注:/user/* 代表所有访问带有 /user/的路径都会被拦截,例如 /user/test

<!--配置拦截器-->
<mvc:interceptors>
    <!--配置拦截器-->
    <mvc:interceptor>
        <!--要拦截的具体的方法-->
        <mvc:mapping path="/user/*"/>
        <!--配置拦截器对象-->
        <bean class="cn.ideal.interceptor.MyInterceptor" />
    </mvc:interceptor>
</mvc:interceptors>

随便写一个方法测试一下

@RequestMapping("/testInterceptor")
public String testInterceptor() {
    System.out.println("testInterceptor被执行了");
    return "success";
}

执行结果

preHandle方法执行了
testInterceptor被执行了
postHandle方法执行了
afterCompletion方法执行了

总结

写着写着又是1w字了,之前就想着写这篇文章,也一直没什么空,对于这一篇文章,我认为对于入门来说还是比较有好的,前面给了几个大点的基本知识讲解,然后从开发环境以及一个入门程序开始,再到请求以及如何响应,以及一些常用的注解,再到其他的,文件上传,异常处理,拦截器等知识,基本来说,达到了一个 工具书 + 入门讲解的效果,不过要说的点太多了,即使1w字的文章,实际上也只够简单提及,再加个小案例,就例如拦截器,或者文件上传的讲解,只能说讲了最基本的,对于已经有一定基础的朋友,自然没什么进阶的帮助,不过我的初心,也是想巩固一下自己的知识,然后能将文章带给刚接触 Spring MVC 的朋友,我也不是什么大牛,不过希望能给大家一点帮助,我们可以一起交流,一起进步哈!

感谢大家的支持!!!

结尾

如果文章中有什么不足,欢迎大家留言交流,感谢朋友们的支持!

如果能帮到你的话,那就来关注我吧!如果您更喜欢微信文章的阅读方式,可以关注我的公众号

在这里的我们素不相识,却都在为了自己的梦而努力 ❤

一个坚持推送原创开发技术文章的公众号:理想二旬不止

posted @ 2020-03-21 10:53  BWH_Steven  阅读(632)  评论(0编辑  收藏  举报