俗称 Spring 全家桶。为什么大家都要用它?因为它好用啊。

  • 在 Spring 容器中引入 Bean
  • Spring + MyBatis
  • 模板引擎(后端渲染 HTML)
  • 前后端分离和后端渲染


1. Spring 中 Bean 配置与 Spring+MyBatis 实战

先说个宏观印象,每个需要 @Autowired 的 Bean 之所以是个 Bean,一定是某种方式通知了 Spring 这是个 Bean,一般要么通过配置文件(查看上一篇笔记),要么在 Bean 的类上添加五花八门的注解,之所以五花八门,自然是希望传达更多的角色分工的信息,而他们所传达的最基本的信息仍然是:告诉 Spring 这里的类是一个 Bean。

一般分为三层:
(HTTP)

  • Controller【负责 HTTP 请求和响应】
  • Service【业务代码】
  • Dao【负责和数据库交互】

(数据库)

接下来,做一个 Rank 排行榜的页面。

在上一篇的 Spring boot Web 基础上继续增加 Mabatis 相关依赖,H2 的依赖。
https://mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/

pom.xml:

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  3. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  4. <modelVersion>4.0.0</modelVersion>
  5. <groupId>com.example</groupId>
  6. <artifactId>my-spring-boot</artifactId>
  7. <version>0.0.1-SNAPSHOT</version>
  8. <name>spring-boot</name>
  9. <description>Demo project for Spring Boot</description>
  10. <parent>
  11. <groupId>org.springframework.boot</groupId>
  12. <artifactId>spring-boot-starter-parent</artifactId>
  13. <version>2.2.2.RELEASE</version>
  14. <relativePath/> <!-- lookup parent from repository -->
  15. </parent>
  16. <properties>
  17. <java.version>1.8</java.version>
  18. </properties>
  19. <dependencies>
  20. <dependency>
  21. <groupId>org.springframework.boot</groupId>
  22. <artifactId>spring-boot-starter-web</artifactId>
  23. </dependency>
  24. <dependency>
  25. <groupId>org.springframework.boot</groupId>
  26. <artifactId>spring-boot-starter-freemarker</artifactId>
  27. </dependency>
  28. <dependency>
  29. <groupId>org.mybatis.spring.boot</groupId>
  30. <artifactId>mybatis-spring-boot-starter</artifactId>
  31. <version>2.1.1</version>
  32. </dependency>
  33. <dependency>
  34. <groupId>org.springframework.boot</groupId>
  35. <artifactId>spring-boot-starter-test</artifactId>
  36. <scope>test</scope>
  37. <exclusions>
  38. <exclusion>
  39. <groupId>org.junit.vintage</groupId>
  40. <artifactId>junit-vintage-engine</artifactId>
  41. </exclusion>
  42. </exclusions>
  43. </dependency>
  44. <dependency>
  45. <groupId>com.h2database</groupId>
  46. <artifactId>h2</artifactId>
  47. <version>1.4.200</version>
  48. </dependency>
  49. </dependencies>
  50. <build>
  51. <plugins>
  52. <plugin>
  53. <groupId>org.springframework.boot</groupId>
  54. <artifactId>spring-boot-maven-plugin</artifactId>
  55. </plugin>
  56. <plugin>
  57. <groupId>org.flywaydb</groupId>
  58. <artifactId>flyway-maven-plugin</artifactId>
  59. <version>6.4.3</version>
  60. <configuration>
  61. <url>jdbc:h2:file:${project.basedir}/target/test</url>
  62. <user>root</user>
  63. <password>root</password>
  64. </configuration>
  65. </plugin>
  66. </plugins>
  67. </build>
  68. </project>

增加 src/java/main/resources/application.properties 文件:

spring.datasource.url=jdbc:h2:file:/Users/peng/IdeaProjects/tmp/my-first-spring/target/test
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=org.h2.Driver
mybatis.config-location=classpath:db/mybatis/config.xml

引入 Flyway 插件,方便数据库快速迁移。

<plugin>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-maven-plugin</artifactId>
  <version>6.4.3</version>
  <configuration>
    <url>jdbc:h2:file:${project.basedir}/target/test</url>
    <user>root</user>
    <password>root</password>
  </configuration>
</plugin>

然后resources/db/migration/V1__CreateTables.sql 创建两张表,一张用户表(User),一张比赛表(Match),联表查询即可得到 Rank 排行榜。

create table user
(
    id   bigint primary key auto_increment,
    name varchar(100)
);

create table match
(
    id      bigint primary key auto_increment,
    user_id bigint,
    score   int
);

insert into user(id, name)
values (1, 'AAA');
insert into user(id, name)
values (2, 'BBB');
insert into user(id, name)
values (3, 'CCC');

insert into match(id, user_id, score)
values (1, 1, 1000);
insert into match(id, user_id, score)
values (2, 1, 2000);
insert into match(id, user_id, score)
values (3, 2, 500);
insert into match(id, user_id, score)
values (4, 3, 300);

然后运行 mvn flyway:migrate ,会自动创建一个 H2数据库,并且执行刚才的 sql。

在 Spring 的 application.properties 文件指定 Mybatis 配置文件的路径,并创建 Mybatis 的配置文件,以及基于注解的 mapper 和基于 xml 的 mapper:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <settings>
        <setting name="logImpl" value="LOG4J"/>
    </settings>
    <mappers>
        <mapper resource="db/mybatis/MyMapper.xml"/>
        <mapper class="hello.dao.UserMapper"/>
    </mappers>
</configuration>
// 这里的 UserMapper 只在这里测试连接的时候用到了,后面都没用到
package hello.dao;

import hello.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;

@Mapper
public interface UserMapper {

    @Select("select * from user where id = #{id}")
    User getUserById(@Param("id") Integer id);
}

下面演示使用基于注解的 mapper,把 UserMapper 引入进来,然后使用 @Autowired 自动进行装配(IDEA可能提示找不到UserMapper bean,alt+enter 进行忽略,因为动态运行,静态检查难免无力)(注意,现在为了方便,暂时没有引入 Service 层):

package hello;

import hello.dao.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @Autowired
    private UserMapper userMapper;

    @RequestMapping("/")
    @ResponseBody
    public Object search() {
        return userMapper.getUserById(1);
    }
}

现在访问 http://localhost:8080/ 已经有返回了。

增加 RankItem、RankDao、RankService,代码解释往下看:

package hello.entity;

public class RankItem {
    private int score;
    private User user;

    public int getScore() {
        return score;
    }

    public void setScore(int score) {
        this.score = score;
    }

    public User getUser() {
        return user;
    }

    public void setUser(User user) {
        this.user = user;
    }
}
package hello.dao;

import hello.entity.RankItem;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class RankDao {

    @Autowired
    private SqlSession sqlSession;

    public List<RankItem> getRank() {
        return sqlSession.selectList("MyMapper.selectRank");
    }
}
package hello.service;

import hello.dao.RankDao;
import hello.entity.RankItem;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class RankService {
    @Autowired
    RankDao rankDao;

    public List<RankItem> getRank() {
        return rankDao.getRank();
    }
}

Spring 如何知道这是一个 Bean?传统方式是配置 xml(上一篇中有提到),而更方便的方法是,假如这是一个 Service,那么直接使用 @Service 注解告诉 Spring 即可,然后 Spring 进一步根据 Bean 中的 @Autowired 注解来对 Bean 进行识别、自动装配、注入(Spring Bean 默认都是单例模式)。

如果想配置非常复杂的 Bean,可以额外引入配置,使用 @Configuration,比单纯使用 @Service 更灵活复杂(下一篇再细说,本篇先简单使用 @Service 即可)。

接下来处理一下最重要的数据库操作,选出 用户id、用户总分、用户名(并按照用户总分降序排列)数据。
由于比较复杂,所以使用 Mybatis 的 xml mapper,并使用了 resultMap 映射出存在嵌套对象的 RankItem 对象(其代表最终的一行排名信息)。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="MyMapper">
    <select id="selectRank" resultMap="rankItem">
        select t.user_id, t.score_sum, user.name as user_name
        from
        (
        select user_id, sum(score) as score_sum from match
            group by user_id
            order by score_sum desc
        ) t
         inner join user on t.user_id = user.id;
    </select>

    <resultMap id="rankItem" type="hello.entity.RankItem">
        <result property="score" column="score_sum"/>
        <association property="user" javaType="hello.entity.User">
            <result property="id" column="user_id"/>
            <result property="name" column="user_name"/>
        </association>
    </resultMap>
</mapper>

接下来问题来了,之前学习 Mybatis 基于 xml mapper 时,需要用 SqlSessionFactory 创建出一个 SqlSession(sql 会话),然后执行相应的操作类型,以及具体的 xml sql mapper,例如:

// 仅供演示之前做法
public List<Order> getInnerJoinOrders() {
    try (SqlSession session = sqlSessionFactory.openSession()) {
        return session.selectList("MyMapper.getInnerJoinOrders");
    }
}

现在既然使用了 Spring,也依赖了基于 Spring 的 Mybatis 包,那么只需要在需要一个 SqlSession 的时候使用@Autowired 注解即可,由 Spring 启动后负责创建实例。
另外,目前为止,看似 RankService 比较尴尬,该 Service 只是作为了 Dao 和 Controller 之间“多余”的一层桥梁,这里是完全可以直接在 Controller 中引用 Dao 的。但别忘了,以后业务逻辑逐渐复杂时,就体现出 Service 层的必要性了。

把前面的 HelloController 改为以下,验证以下 Controller、Service、Dao 三者之间递进的分层关系:

package hello;

import hello.service.RankService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @Autowired
    private RankService rankService;

    @RequestMapping("/")
    @ResponseBody
    public Object search() {
        return rankService.getRank();
    }
}

如果没写错的话,再次启动 Spring,访问 http://localhost:8080/ 可以看到返回的 RankItem 列表:

[
    {
        "score": 3000,
        "user": {
            "id": 1,
            "name": "AAA"
        }
    },
    {
        "score": 500,
        "user": {
            "id": 2,
            "name": "BBB"
        }
    },
    {
        "score": 300,
        "user": {
            "id": 3,
            "name": "CCC"
        }
    }
]

2. Web 页面生成原理

这里再说下 MVC,即 数据模型(Model)、视图(View)、控制器(Controller)。

Web 页面的生成简单分为两种:

  • 后端渲染,后端提前填充好数据,返回包含全量信息的 HTML 文件/字符串。
  • 前端渲染,后端返回精简的 HTML 字符串,其中包含了关键的 标签,通过在前端浏览器中执行 JavaScript 脚本,发出 AJAX 请求拿到数据,然后操作 DOM,填充数据。

src/java/resources/static 目录会被 Spring 处理成静态资源服务,写个 index.html 文件放入该目录下,然后访问 localhost:8080/index.html 即可看到返回的 html 文件:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
</head>
<body>
    <table>
        <tr>
            <th>排名</th>
            <th>名字</th>
            <th>分数</th>
        </tr>
        <tr>
            <td>1</td>
            <td>Tom</td>
            <td>3000</td>
        </tr>
        <tr>
            <td>2</td>
            <td>Tom</td>
            <td>3000</td>
        </tr>
        <tr>
            <td>3</td>
            <td>John</td>
            <td>300</td>
        </tr>
    </table>
</body>
</html>

image.png
但这样的 html 是写死的文件,为了动态的进行后端渲染,需要模板引擎,常见的有 FreeMaker、JSP 等,JSP 已经过时了,下面以 FreeMaker 为例。

3. 使用 FreeMaker 进行后端渲染

这里有 FreeMaker + SpringBoot 的教程,pom.xml 中引入 spring-boot-starter-freemarker 依赖。
按照目录约定,视图模板放在 resources/templates 下面,且注意如果使用的是最新版的Spring Boot (2.2+),默认的 Freemarker 扩展名变成了 ftlh ,如果仍然使用 ftl 会报错404!

rank.ftlh,使用了遍历语法,具体可查看官方文档:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
</head>
<body>
<h1>排行榜</h1>
<table>
    <tr>
        <th>排名</th>
        <th>名字</th>
        <th>分数</th>
    </tr>

    <#list items as item>
        <tr>
            <td>${item?index + 1}</td>
            <td>${item.user.name}</td>
            <td>${item.score}</td>
        </tr>
    </#list>
</table>
</body>
</html>

然后在 controller 中增加一条请求映射:

@RequestMapping("/rank")
public ModelAndView rank() {
    List<RankItem> items = rankService.getRank();
    Map<String, Object> model = new HashMap<>();
    model.put("items", items);
    return new ModelAndView("rank", model);
}

image.png

4. 使用 JS 和 JSON 异步请求进行前端渲染

既然是前端渲染,那就请求骨架 html,然后等待 ajax 请求返回的数据,然后往 dom 中塞即可。
所以继续在 resources/static 目录下放一个 html 文件,然后写点 js 代码即可:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
</head>
<body>
<h1>排行榜</h1>
<table id="rank">
    <tr>
        <th>排名</th>
        <th>名字</th>
        <th>分数</th>
    </tr>>
</table>


<script>
  var renderRank = function (data) {
    var table = document.getElementById('rank');
    data.forEach(function (item, index) {
      var trString = `<tr>
                <td>${index + 1}</td>
                <td>${item.user.name}</td>
                <td>${item.score}</td>
            </tr>`;
      table.querySelector('tbody').innerHTML += trString;
    });
  };

  var xhr = new XMLHttpRequest();
  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) {
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        var json = xhr.responseText;
        console.log(JSON.parse(json));
        renderRank(JSON.parse(json));
      } else {
        console.log('服务器异常');
      }
    }
  };
  xhr.open('GET', '/rankData'); // rankData 便是接口地址
  xhr.send();

</script>
</body>
</html>

然后在 Controller 中提供接口(默认即可序列化为 json 字符串):

@RequestMapping("/rankData")
@ResponseBody
public Object search() {
    return rankService.getRank();
}

现在访问 localhost:8080/rank.html 即可看到效果(截图略,最终视觉结果和前面一样)。

本篇项目代码演示地址