Spring Boot學習筆記

Spring Boot學習筆記

1. Spring Boot是什麼

我們知道,從 2002 年開始,Spring 一直在飛速的發展,如今已經成爲了在Java EE(Java Enterprise Edition)開發中真正意義上的標準,但是隨着技術的發展,Java EE使用 Spring 逐漸變得笨重起來,大量的 XML 文件存在於項目之中。繁瑣的配置,整合第三方框架的配置問題,導致了開發和部署效率的降低

2012 年 10 月,Mike Youngstrom 在 Spring jira 中創建了一個功能請求,要求在 Spring 框架中支持無容器 Web 應用程序體系結構。他談到了在主容器引導 spring 容器內配置 Web 容器服務。這是 jira 請求的摘錄:

我認爲 Spring 的 Web 應用體系結構可以大大簡化,如果它提供了從上到下利用 Spring 組件和配置模型的工具和參考體系結構。在簡單的 main()方法引導的 Spring 容器內嵌入和統一這些常用Web 容器服務的配置。

這一要求促使了 2013 年初開始的 Spring Boot 項目的研發,到今天,Spring Boot 的版本已經到了 2.0.3 RELEASE。Spring Boot 並不是用來替代 Spring 的解決方案,而是和 Spring 框架緊密結合用於提升 Spring 開發者體驗的工具

它集成了大量常用的第三方庫配置,Spring Boot應用中這些第三方庫幾乎可以是零配置的開箱即用(out-of-the-box),大部分的 Spring Boot 應用都只需要非常少量的配置代碼(基於 Java 的配置),開發者能夠更加專注於業務邏輯。

2. 爲什麼學習Spring Boot

2.1 從Spring官方來看

我們打開 Spring 的官方網站,可以看到下圖:

Spring官網首圖

我們可以看到圖中官方對 Spring Boot 的定位:Build Anything, Build任何東西。Spring Boot旨在儘可能快地啓動和運行,並且只需最少的 Spring 前期配置。 同時我們也來看一下官方對後面兩個的定位:

SpringCloud:Coordinate Anything,協調任何事情;
SpringCloud Data Flow:Connect everything,連接任何東西。

仔細品味一下,Spring 官網對 Spring Boot、SpringCloud 和 SpringCloud Data Flow三者定位的措辭非常有味道,同時也可以看出,Spring 官方對這三個技術非常重視,是現在以及今後學習的重點(SpringCloud 相關達人課課程屆時也會上線)。

2.2 從Spring Boot的優點來看

Spring Boot 有哪些優點?主要給我們解決了哪些問題呢?我們以下圖來說明:

Spring Boot的優點

2.2.1 良好的基因

Spring Boot 是伴隨着 Spring 4.0 誕生的,從字面理解,Boot是引導的意思,因此 Spring Boot 旨在幫助開發者快速搭建 Spring 框架。Spring Boot 繼承了原有 Spring 框架的優秀基因,使 Spring 在使用中更加方便快捷。

Spring Boot與Spring

2.2.2 簡化編碼

舉個例子,比如我們要創建一個 web 項目,使用 Spring 的朋友都知道,在使用 Spring 的時候,需要在 pom 文件中添加多個依賴,而 Spring Boot 則會幫助開發着快速啓動一個 web 容器,在 Spring Boot 中,我們只需要在 pom 文件中添加如下一個 starter-web 依賴即可。

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

我們點擊進入該依賴後可以看到,Spring Boot 這個 starter-web 已經包含了多個依賴,包括之前在 Spring 工程中需要導入的依賴,我們看一下其中的一部分,如下:

<!-- .....省略其他依賴 -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-web</artifactId>
    <version>5.0.7.RELEASE</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-webmvc</artifactId>
    <version>5.0.7.RELEASE</version>
    <scope>compile</scope>
</dependency>

由此可以看出,Spring Boot 大大簡化了我們的編碼,我們不用一個個導入依賴,直接一個依賴即可。

2.2.3 簡化配置

Spring 雖然使Java EE輕量級框架,但由於其繁瑣的配置,一度被人認爲是“配置地獄”。各種XML、Annotation配置會讓人眼花繚亂,而且配置多的話,如果出錯了也很難找出原因。Spring Boot更多的是採用 Java Config 的方式,對 Spring 進行配置。舉個例子:

我新建一個類,但是我不用 @Service註解,也就是說,它是個普通的類,那麼我們如何使它也成爲一個 Bean 讓 Spring 去管理呢?只需要@Configuration@Bean兩個註解即可,如下:

public class TestService {
    public String sayHello () {
        return "Hello Spring Boot!";
    }
}
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JavaConfig {
    @Bean
    public TestService getTestService() {
        return new TestService();
    }
}

@Configuration表示該類是個配置類,@Bean表示該方法返回一個 Bean。這樣就把TestService作爲 Bean 讓 Spring 去管理了,在其他地方,我們如果需要使用該 Bean,和原來一樣,直接使用@Resource註解注入進來即可使用,非常方便。

@Resource
private TestService testService;

另外,部署配置方面,原來 Spring 有多個 xml 和 properties配置,在 Spring Boot 中只需要個 application.yml即可。

2.2.4 簡化部署

在使用 Spring 時,項目部署時需要我們在服務器上部署 tomcat,然後把項目打成 war 包扔到 tomcat裏,在使用 Spring Boot 後,我們不需要在服務器上去部署 tomcat,因爲 Spring Boot 內嵌了 tomcat,我們只需要將項目打成 jar 包,使用 java -jar xxx.jar一鍵式啓動項目。

另外,也降低對運行環境的基本要求,環境變量中有JDK即可。

2.2.5 簡化監控

我們可以引入 spring-boot-start-actuator 依賴,直接使用 REST 方式來獲取進程的運行期性能參數,從而達到監控的目的,比較方便。但是 Spring Boot 只是個微框架,沒有提供相應的服務發現與註冊的配套功能,沒有外圍監控集成方案,沒有外圍安全管理方案,所以在微服務架構中,還需要 Spring Cloud 來配合一起使用。

2.3 從未來發展的趨勢來看

微服務是未來發展的趨勢,項目會從傳統架構慢慢轉向微服務架構,因爲微服務可以使不同的團隊專注於更小範圍的工作職責、使用獨立的技術、更安全更頻繁地部署。而 繼承了 Spring 的優良特性,與 Spring 一脈相承,而且 支持各種REST API 的實現方式。Spring Boot 也是官方大力推薦的技術,可以看出,Spring Boot 是未來發展的一個大趨勢。

3. 本課程能學到什麼

本課程使用目前 Spring Boot 最新版本2.0.3 RELEASE,課程文章均爲作者在實際項目中剝離出來的場景和demo,目標是帶領學習者快速上手 Spring Boot,將 Spring Boot 相關技術點快速運用在微服務項目中。全篇分爲兩部分:基礎篇和進階篇。

基礎篇(01—10課)主要介紹 Spring Boot 在項目中最常使用的一些功能點,旨在帶領學習者快速掌握 Spring Boot 在開發時需要的知識點,能夠把 Spring Boot 相關技術運用到實際項目架構中去。該部分以 Spring Boot 框架爲主線,內容包括Json數據封裝、日誌記錄、屬性配置、MVC支持、在線文檔、模板引擎、異常處理、AOP 處理、持久層集成等等。

進階篇(11—17課)主要是介紹 Spring Boot 在項目中拔高一些的技術點,包括集成的一些組件,旨在帶領學習者在項目中遇到具體的場景時能夠快速集成,完成對應的功能。該部分以 Spring Boot 框架爲主線,內容包括攔截器、監聽器、緩存、安全認證、分詞插件、消息隊列等等。

認真讀完該系列文章之後,學習者會快速瞭解並掌握 Spring Boot 在項目中最常用的技術點,作者課程的最後,會基於課程內容搭建一個 Spring Boot 項目的空架構,該架構也是從實際項目中剝離出來,學習者可以運用該架構於實際項目中,具備使用 Spring Boot 進行實際項目開發的能力。

課程所有源碼提供免費下載:下載地址

歡迎關注我的爲微信公衆號:武哥聊編程

4. 適合閱讀的人羣

本課程適合以下人羣閱讀:

  • 有一定的Java語言基礎,瞭解Spring、Maven的在校學生或自學者
  • 有傳統項目經驗,想往微服務方向發展的工作人員
  • 熱衷於新技術並對 Spring Boot 感興趣的人員
  • 希望瞭解 Spring Boot 2.0.3 的研究人員

5. 本課程開發環境和插件

本課程的開發環境:

  • 開發工具:IDEA 2017
  • JDK版本: JDK 1.8
  • Spring Boot版本:2.0.3 RELEASE
  • Maven版本:3.5.2

涉及到的插件:

  • FastJson
  • Swagger2
  • Thymeleaf
  • MyBatis
  • Redis
  • ActiveMQ
  • Shiro
  • Lucence

6. 課程目錄

  • 導讀:課程概覽
  • 第01課:Spring Boot開發環境搭建和項目啓動
  • 第02課:Spring Boot返回Json數據及數據封裝
  • 第03課:Spring Boot使用slf4j進行日誌記錄
  • 第04課:Spring Boot中的項目屬性配置
  • 第05課:Spring Boot中的MVC支持
  • 第06課:Spring Boot集成Swagger2展現在線接口文檔
  • 第07課:Spring Boot集成Thymeleaf模板引擎
  • 第08課:Spring Boot中的全局異常處理
  • 第09課:Spring Boot中的切面AOP處理
  • 第10課:Spring Boot中集成MyBatis
  • 第11課:Spring Boot事務配置管理
  • 第12課:Spring Boot中使用監聽器
  • 第13課:Spring Boot中使用攔截器
  • 第14課:Spring Boot中集成Redis
  • 第15課:Spring Boot中集成ActiveMQ
  • 第16課:Spring Boot中集成Shiro
  • 第17課:Spring Boot中結成Lucence
  • 第18課:Spring Boot搭建實際項目開發中的架構

歡迎關注我的爲微信公衆號:武哥聊編程

第01課:Spring Boot開發環境搭建和項目啓動

上一節對 SpringBoot 的特性做了一個介紹,本節主要對 jdk 的配置、Spring Boot工程的構建和項目的啓動、Spring Boot 項目工程的結構做一下講解和分析

1. jdk 的配置

本課程是使用 IDEA 進行開發,在IDEA 中配置 jdk 的方式很簡單,打開File->Project Structure,如下圖所:

IDEA中配置jdk

  1. 選擇 SDKs
  2. 在 JDK home path 中選擇本地 jdk 的安裝目錄
  3. 在 Name 中爲 jdk 自定義名字

通過以上三步驟,即可導入本地安裝的 jdk。如果是使用 STS 或者 eclipse 的朋友,可以通過兩步驟添加:

  • window->preference->java->Instralled JRES來添加本地 jdk。
  • window-->preference-->java-->Compiler選擇 jre,和 jdk 保持一致。

2. Spring Boot 工程的構建

2.1 IDEA 快速構建

IDEA 中可以通過File->New->Project來快速構建 Spring Boot 工程。如下,選擇 Spring Initializr,在 Project SDK 中選擇剛剛我們導入的 jdk,點擊 Next,到了項目的配置信息。

  • Group:填企業域名,本課程使用com.itcodai
  • Artifact:填項目名稱,本課程中每一課的工程名以course+課號命令,這裏使用 course01
  • Dependencies:可以添加我們項目中所需要的依賴信息,根據實際情況來添加,本課程只需要選擇 Web 即可。

2.2 官方構建

第二種方式可以通過官方構建,步驟如下:

  • 訪問 http://start.spring.io/。
  • 在頁面上輸入相應的 Spring Boot 版本、Group 和 Artifact 信息以及項目依賴,然後創建項目。
  • 創建Spring Boot工程
  • 解壓後,使用 IDEA 導入該 maven 工程:File->New->Model from Existing Source,然後選擇解壓後的項目文件夾即可。如果是使用 eclipse 的朋友,可以通過Import->Existing Maven Projects->Next,然後選擇解壓後的項目文件夾即可。

2.3 maven配置

創建了 Spring Boot 項目之後,需要進行 maven 配置。打開File->settings,搜索 maven,配置一下本地的 maven 信息。如下:

maven配置

在 Maven home directory 中選擇本地 Maven 的安裝路徑;在 User settings file 中選擇本地 Maven 的配置文件所在路徑。在配置文件中,我們配置一下國內阿里的鏡像,這樣在下載 maven 依賴時,速度很快。

<mirror>
	<id>nexus-aliyun</id>
	<mirrorOf>*</mirrorOf>
	<name>Nexus aliyun</name>
	<url>http://maven.aliyun.com/nexus/content/groups/public</url>
</mirror>

如果是使用 eclipse 的朋友,可以通過window-->preference-->Maven-->User Settings來配置,配置方式和上面一致。

2.4 編碼配置

同樣地,新建項目後,我們一般都需要配置編碼,這點非常重要,很多初學者都會忘記這一步,所以要養成良好的習慣。

IDEA 中,仍然是打開File->settings,搜索 encoding,配置一下本地的編碼信息。如下:

編碼配置

如果是使用 eclipse 的朋友,有兩個地方需要設置一下編碼:

  • window–> perferences–>General–>Workspace,將Text file encoding改成utf-8
  • window–>perferences–>General–>content types,選中Text,將Default encoding填入utf-8

OK,編碼設置完成即可啓動項目工程了。

3. Spring Boot 項目工程結構

Spring Boot 項目總共有三個模塊,如下圖所示:

Spring Boot項目工程結構

  • src/main/java路徑:主要編寫業務程序
  • src/main/resources路徑:存放靜態文件和配置文件
  • src/test/java路徑:主要編寫測試程序

默認情況下,如上圖所示會創建一個啓動類 Course01Application,該類上面有個@SpringBootApplication註解,該啓動類中有個 main 方法,沒錯,Spring Boot 啓動只要運行該 main 方法即可,非常方便。另外,Spring Boot 內部集成了 tomcat,不需要我們人爲手動去配置 tomcat,開發者只需要關注具體的業務邏輯即可。

到此爲止,Spring Boot 就啓動成功了,爲了比較清楚的看到效果,我們寫一個 Controller 來測試一下,如下:

package com.itcodai.course01.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/start")
public class StartController {

    @RequestMapping("/springboot")
    public String startSpringBoot() {
        return "Welcome to the world of Spring Boot!";
    }
}

重新運行 main 方法啓動項目,在瀏覽器中輸入 localhost:8080/start/springboot,如果看到 “Welcome to the world of Spring Boot!”,那麼恭喜你項目啓動成功!Spring Boot 就是這麼簡單方便!端口號默認是8080,如果想要修改,可以在 application.yml 文件中使用 server.port 來人爲指定端口,如8001端口:

server:
  port: 8001

4. 總結

本節我們快速學習瞭如何在 IDEA 中導入 jdk,以及使用 IDEA 如何配置 maven 和編碼,如何快速的創建和啓動 Spring Boot 工程。IDEA 對 Spring Boot 的支持非常友好,建議大家使用 IDEA 進行 Spring Boot 的開發,從下一課開始,我們真正進入 Spring Boot 的學習中。
課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第02課:Spring Boot返回Json數據及數據封裝

在項目開發中,接口與接口之間,前後端之間數據的傳輸都使用 Json 格式,在 Spring Boot 中,接口返回 Json 格式的數據很簡單,在 Controller 中使用@RestController註解即可返回 Json 格式的數據,@RestController也是 Spring Boot 新增的一個註解,我們點進去看一下該註解都包含了哪些東西。

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
    String value() default "";
}

可以看出, @RestController 註解包含了原來的 @Controller@ResponseBody 註解,使用過 Spring 的朋友對 @Controller 註解已經非常瞭解了,這裏不再贅述, @ResponseBody 註解是將返回的數據結構轉換爲 Json 格式。所以在默認情況下,使用了 @RestController 註解即可將返回的數據結構轉換成 Json 格式,Spring Boot 中默認使用的 Json 解析技術框架是 jackson。我們點開 pom.xml 中的 spring-boot-starter-web 依賴,可以看到一個 spring-boot-starter-json 依賴:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-json</artifactId>
    <version>2.0.3.RELEASE</version>
    <scope>compile</scope>
</dependency>

Spring Boot 中對依賴都做了很好的封裝,可以看到很多 spring-boot-starter-xxx 系列的依賴,這是 Spring Boot 的特點之一,不需要人爲去引入很多相關的依賴了,starter-xxx 系列直接都包含了所必要的依賴,所以我們再次點進去上面這個 spring-boot-starter-json 依賴,可以看到:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.9.6</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jdk8</artifactId>
    <version>2.9.6</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.9.6</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-parameter-names</artifactId>
    <version>2.9.6</version>
    <scope>compile</scope>
</dependency>

到此爲止,我們知道了 Spring Boot 中默認使用的 json 解析框架是 jackson。下面我們看一下默認的 jackson 框架對常用數據類型的轉 Json 處理。

1. Spring Boot 默認對Json的處理

在實際項目中,常用的數據結構無非有類對象、List對象、Map對象,我們看一下默認的 jackson 框架對這三個常用的數據結構轉成 json 後的格式如何。

1.1 創建 User 實體類

爲了測試,我們需要創建一個實體類,這裏我們就用 User 來演示。

public class User {
    private Long id;
    private String username;
    private String password;
	/* 省略get、set和帶參構造方法 */
}

1.2 創建Controller類

然後我們創建一個 Controller,分別返回 User對象、List<User>Map<String, Object>

import com.itcodai.course02.entity.User;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/json")
public class JsonController {

    @RequestMapping("/user")
    public User getUser() {
        return new User(1, "倪升武", "123456");
    }

    @RequestMapping("/list")
    public List<User> getUserList() {
        List<User> userList = new ArrayList<>();
        User user1 = new User(1, "倪升武", "123456");
        User user2 = new User(2, "達人課", "123456");
        userList.add(user1);
        userList.add(user2);
        return userList;
    }

    @RequestMapping("/map")
    public Map<String, Object> getMap() {
        Map<String, Object> map = new HashMap<>(3);
        User user = new User(1, "倪升武", "123456");
        map.put("作者信息", user);
        map.put("博客地址", "http://blog.itcodai.com");
        map.put("CSDN地址", "http://blog.csdn.net/eson_15");
        map.put("粉絲數量", 4153);
        return map;
    }
}

1.3 測試不同數據類型返回的json

OK,寫好了接口,分別返回了一個 User 對象、一個 List 集合和一個 Map 集合,其中 Map 集合中的 value 存的是不同的數據類型。接下來我們依次來測試一下效果。

在瀏覽器中輸入:localhost:8080/json/user 返回 json 如下:

{"id":1,"username":"倪升武","password":"123456"}

在瀏覽器中輸入:localhost:8080/json/list 返回 json 如下:

[{"id":1,"username":"倪升武","password":"123456"},{"id":2,"username":"達人課","password":"123456"}]

在瀏覽器中輸入:localhost:8080/json/map 返回 json 如下:

{"作者信息":{"id":1,"username":"倪升武","password":"123456"},"CSDN地址":"http://blog.csdn.net/eson_15","粉絲數量":4153,"博客地址":"http://blog.itcodai.com"}

可以看出,map 中不管是什麼數據類型,都可以轉成相應的 json 格式,這樣就非常方便。

1.4 jackson 中對null的處理

在實際項目中,我們難免會遇到一些 null 值出現,我們轉 json 時,是不希望有這些 null 出現的,比如我們期望所有的 null 在轉 json 時都變成 “” 這種空字符串,那怎麼做呢?在 Spring Boot 中,我們做一下配置即可,新建一個 jackson 的配置類:

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;

import java.io.IOException;

@Configuration
public class JacksonConfig {
    @Bean
    @Primary
    @ConditionalOnMissingBean(ObjectMapper.class)
    public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();
        objectMapper.getSerializerProvider().setNullValueSerializer(new JsonSerializer<Object>() {
            @Override
            public void serialize(Object o, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
                jsonGenerator.writeString("");
            }
        });
        return objectMapper;
    }
}

然後我們修改一下上面返回 map 的接口,將幾個值改成 null 測試一下:

@RequestMapping("/map")
public Map<String, Object> getMap() {
    Map<String, Object> map = new HashMap<>(3);
    User user = new User(1, "倪升武", null);
    map.put("作者信息", user);
    map.put("博客地址", "http://blog.itcodai.com");
    map.put("CSDN地址", null);
    map.put("粉絲數量", 4153);
    return map;
}

重啓項目,再次輸入:localhost:8080/json/map,可以看到 jackson 已經將所有 null 字段轉成了空字符串了。

{"作者信息":{"id":1,"username":"倪升武","password":""},"CSDN地址":"","粉絲數量":4153,"博客地址":"http://blog.itcodai.com"}

2. 使用阿里巴巴FastJson的設置

2.1 jackson 和 fastJson 的對比

有很多朋友習慣於使用阿里巴巴的 fastJson 來做項目中 json 轉換的相關工作,目前我們項目中使用的就是阿里的 fastJson,那麼 jackson 和 fastJson 有哪些區別呢?根據網上公開的資料比較得到下表。

選項 fastJson jackson
上手難易程度 容易 中等
高級特性支持 中等 豐富
官方文檔、Example支持 中文 英文
處理json速度 略快

關於 fastJson 和 jackson 的對比,網上有很多資料可以查看,主要是根據自己實際項目情況來選擇合適的框架。從擴展上來看,fastJson 沒有 jackson 靈活,從速度或者上手難度來看,fastJson 可以考慮,我們項目中目前使用的是阿里的 fastJson,挺方便的。

2.2 fastJson依賴導入

使用 fastJson 需要導入依賴,本課程使用 1.2.35 版本,依賴如下:

<dependency>
	<groupId>com.alibaba</groupId>
	<artifactId>fastjson</artifactId>
	<version>1.2.35</version>
</dependency>

2.2 使用 fastJson 處理 null

使用 fastJson 時,對 null 的處理和 jackson 有些不同,需要繼承 WebMvcConfigurationSupport 類,然後覆蓋 configureMessageConverters 方法,在方法中,我們可以選擇對要實現 null 轉換的場景,配置好即可。如下:

import com.alibaba.fastjson.serializer.SerializerFeature;
import com.alibaba.fastjson.support.config.FastJsonConfig;
import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;

import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;

@Configuration
public class fastJsonConfig extends WebMvcConfigurationSupport {

    /**
     * 使用阿里 FastJson 作爲JSON MessageConverter
     * @param converters
     */
    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter();
        FastJsonConfig config = new FastJsonConfig();
        config.setSerializerFeatures(
                // 保留map空的字段
                SerializerFeature.WriteMapNullValue,
                // 將String類型的null轉成""
                SerializerFeature.WriteNullStringAsEmpty,
                // 將Number類型的null轉成0
                SerializerFeature.WriteNullNumberAsZero,
                // 將List類型的null轉成[]
                SerializerFeature.WriteNullListAsEmpty,
                // 將Boolean類型的null轉成false
                SerializerFeature.WriteNullBooleanAsFalse,
                // 避免循環引用
                SerializerFeature.DisableCircularReferenceDetect);

        converter.setFastJsonConfig(config);
        converter.setDefaultCharset(Charset.forName("UTF-8"));
        List<MediaType> mediaTypeList = new ArrayList<>();
        // 解決中文亂碼問題,相當於在Controller上的@RequestMapping中加了個屬性produces = "application/json"
        mediaTypeList.add(MediaType.APPLICATION_JSON);
        converter.setSupportedMediaTypes(mediaTypeList);
        converters.add(converter);
    }
}

3. 封裝統一返回的數據結構

以上是 Spring Boot 返回 json 的幾個代表的例子,但是在實際項目中,除了要封裝數據之外,我們往往需要在返回的 json 中添加一些其他信息,比如返回一些狀態碼 code ,返回一些 msg 給調用者,這樣調用者可以根據 code 或者 msg 做一些邏輯判斷。所以在實際項目中,我們需要封裝一個統一的 json 返回結構存儲返回信息。

3.1 定義統一的 json 結構

由於封裝的 json 數據的類型不確定,所以在定義統一的 json 結構時,我們需要用到泛型。統一的 json 結構中屬性包括數據、狀態碼、提示信息即可,構造方法可以根據實際業務需求做相應的添加即可,一般來說,應該有默認的返回結構,也應該有用戶指定的返回結構。如下:

public class JsonResult<T> {

    private T data;
    private String code;
    private String msg;

    /**
     * 若沒有數據返回,默認狀態碼爲0,提示信息爲:操作成功!
     */
    public JsonResult() {
        this.code = "0";
        this.msg = "操作成功!";
    }

    /**
     * 若沒有數據返回,可以人爲指定狀態碼和提示信息
     * @param code
     * @param msg
     */
    public JsonResult(String code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    /**
     * 有數據返回時,狀態碼爲0,默認提示信息爲:操作成功!
     * @param data
     */
    public JsonResult(T data) {
        this.data = data;
        this.code = "0";
        this.msg = "操作成功!";
    }

    /**
     * 有數據返回,狀態碼爲0,人爲指定提示信息
     * @param data
     * @param msg
     */
    public JsonResult(T data, String msg) {
        this.data = data;
        this.code = "0";
        this.msg = msg;
    }
    // 省略get和set方法
}

3.2 修改 Controller 中的返回值類型及測試

由於 JsonResult 使用了泛型,所以所有的返回值類型都可以使用該統一結構,在具體的場景將泛型替換成具體的數據類型即可,非常方便,也便於維護。在實際項目中,還可以繼續封裝,比如狀態碼和提示信息可以定義一個枚舉類型,以後我們只需要維護這個枚舉類型中的數據即可(在本課程中就不展開了)。根據以上的 JsonResult,我們改寫一下 Controller,如下:

@RestController
@RequestMapping("/jsonresult")
public class JsonResultController {

    @RequestMapping("/user")
    public JsonResult<User> getUser() {
        User user = new User(1, "倪升武", "123456");
        return new JsonResult<>(user);
    }

    @RequestMapping("/list")
    public JsonResult<List> getUserList() {
        List<User> userList = new ArrayList<>();
        User user1 = new User(1, "倪升武", "123456");
        User user2 = new User(2, "達人課", "123456");
        userList.add(user1);
        userList.add(user2);
        return new JsonResult<>(userList, "獲取用戶列表成功");
    }

    @RequestMapping("/map")
    public JsonResult<Map> getMap() {
        Map<String, Object> map = new HashMap<>(3);
        User user = new User(1, "倪升武", null);
        map.put("作者信息", user);
        map.put("博客地址", "http://blog.itcodai.com");
        map.put("CSDN地址", null);
        map.put("粉絲數量", 4153);
        return new JsonResult<>(map);
    }
}

我們重新在瀏覽器中輸入:localhost:8080/jsonresult/user 返回 json 如下:

{"code":"0","data":{"id":1,"password":"123456","username":"倪升武"},"msg":"操作成功!"}

輸入:localhost:8080/jsonresult/list,返回 json 如下:

{"code":"0","data":[{"id":1,"password":"123456","username":"倪升武"},{"id":2,"password":"123456","username":"達人課"}],"msg":"獲取用戶列表成功"}

輸入:localhost:8080/jsonresult/map,返回 json 如下:

{"code":"0","data":{"作者信息":{"id":1,"password":"","username":"倪升武"},"CSDN地址":null,"粉絲數量":4153,"博客地址":"http://blog.itcodai.com"},"msg":"操作成功!"}

通過封裝,我們不但將數據通過 json 傳給前端或者其他接口,還帶上了狀態碼和提示信息,這在實際項目場景中應用非常廣泛。

4. 總結

本節主要對 Spring Boot 中 json 數據的返回做了詳細的分析,從 Spring Boot 默認的 jackson 框架到阿里巴巴的 fastJson 框架,分別對它們的配置做了相應的講解。另外,結合實際項目情況,總結了實際項目中使用的 json 封裝結構體,加入了狀態碼和提示信息,使得返回的 json 數據信息更加完整。
課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第03課:Spring Boot使用slf4j進行日誌記錄

在開發中,我們經常使用 System.out.println() 來打印一些信息,但是這樣不好,因爲大量的使用 System.out 會增加資源的消耗。我們實際項目中使用的是 slf4j 的 logback 來輸出日誌,效率挺高的,Spring Boot 提供了一套日誌系統,logback 是最優的選擇。

1. slf4j 介紹

引用百度百科裏的一段話:

SLF4J,即簡單日誌門面(Simple Logging Facade for Java),不是具體的日誌解決方案,它只服務於各種各樣的日誌系統。按照官方的說法,SLF4J是一個用於日誌系統的簡單Facade,允許最終用戶在部署其應用時使用其所希望的日誌系統。

這段的大概意思是:你只需要按統一的方式寫記錄日誌的代碼,而無需關心日誌是通過哪個日誌系統,以什麼風格輸出的。因爲它們取決於部署項目時綁定的日誌系統。例如,在項目中使用了 slf4j 記錄日誌,並且綁定了 log4j(即導入相應的依賴),則日誌會以 log4j 的風格輸出;後期需要改爲以 logback 的風格輸出日誌,只需要將 log4j 替換成 logback 即可,不用修改項目中的代碼。這對於第三方組件的引入的不同日誌系統來說幾乎零學習成本,況且它的優點不僅僅這一個而已,還有簡潔的佔位符的使用和日誌級別的判斷。

正因爲 sfl4j 有如此多的優點,阿里巴巴已經將 slf4j 作爲他們的日誌框架了。在《阿里巴巴Java開發手冊(正式版)》中,日誌規約一項第一條就強制要求使用 slf4j:

1.【強制】應用中不可直接使用日誌系統(Log4j、Logback)中的API,而應依賴使用日誌框架SLF4J中的API,使用門面模式的日誌框架,有利於維護和各個類的日誌處理方式統一。

“強制”兩個字體現出了 slf4j 的優勢,所以建議在實際項目中,使用 slf4j 作爲自己的日誌框架。使用 slf4j 記錄日誌非常簡單,直接使用 LoggerFactory 創建即可。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Test {
    private static final Logger logger = LoggerFactory.getLogger(Test.class);
    // ……
}

2. application.yml 中對日誌的配置

Spring Boot 對 slf4j 支持的很好,內部已經集成了 slf4j,一般我們在使用的時候,會對slf4j 做一下配置。application.yml 文件是 Spring Boot 中唯一一個需要配置的文件,一開始創建工程的時候是 application.properties 文件,個人比較細化用 yml 文件,因爲 yml 文件的層次感特別好,看起來更直觀,但是 yml 文件對格式要求比較高,比如英文冒號後面必須要有個空格,否則項目估計無法啓動,而且也不報錯。用 properties 還是 yml 視個人習慣而定,都可以。本課程使用 yml。

我們看一下 application.yml 文件中對日誌的配置:

logging:
  config: logback.xml
  level:
    com.itcodai.course03.dao: trace

logging.config 是用來指定項目啓動的時候,讀取哪個配置文件,這裏指定的是日誌配置文件是根路徑下的 logback.xml 文件,關於日誌的相關配置信息,都放在 logback.xml 文件中了。logging.level 是用來指定具體的 mapper 中日誌的輸出級別,上面的配置表示 com.itcodai.course03.dao 包下的所有 mapper 日誌輸出級別爲 trace,會將操作數據庫的 sql 打印出來,開發時設置成 trace 方便定位問題,在生產環境上,將這個日誌級別再設置成 error 級別即可(本節課不討論 mapper 層,在後面 Spring Boot 集成 MyBatis 時再詳細討論)。

常用的日誌級別按照從高到低依次爲:ERROR、WARN、INFO、DEBUG。

3. logback.xml 配置文件解析

在上面 application.yml 文件中,我們指定了日誌配置文件 logback.xmllogback.xml 文件中主要用來做日誌的相關配置。在 logback.xml 中,我們可以定義日誌輸出的格式、路徑、控制檯輸出格式、文件大小、保存時長等等。下面來分析一下:

3.1 定義日誌輸出格式和存儲路徑

<configuration>
	<property name="LOG_PATTERN" value="%date{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" />
	<property name="FILE_PATH" value="D:/logs/course03/demo.%d{yyyy-MM-dd}.%i.log" />
</configuration>

我們來看一下這個定義的含義:首先定義一個格式,命名爲 “LOG_PATTERN”,該格式中 %date 表示日期,%thread 表示線程名,%-5level 表示級別從左顯示5個字符寬度,%logger{36} 表示 logger 名字最長36個字符,%msg 表示日誌消息,%n 是換行符。

然後再定義一下名爲 “FILE_PATH” 文件路徑,日誌都會存儲在該路徑下。%i 表示第 i 個文件,當日志文件達到指定大小時,會將日誌生成到新的文件裏,這裏的 i 就是文件索引,日誌文件允許的大小可以設置,下面會講解。這裏需要注意的是,不管是 windows 系統還是 Linux 系統,日誌存儲的路徑必須要是絕對路徑。

3.2 定義控制檯輸出

<configuration>
	<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
		<encoder>
            <!-- 按照上面配置的LOG_PATTERN來打印日誌 -->
			<pattern>${LOG_PATTERN}</pattern>
		</encoder>
	</appender>
</configuration>

使用 <appender> 節點設置個控制檯輸出(class="ch.qos.logback.core.ConsoleAppender")的配置,定義爲 “CONSOLE”。使用上面定義好的輸出格式(LOG_PATTERN)來輸出,使用 ${} 引用進來即可。

3.3 定義日誌文件的相關參數

<configuration>
	<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
		<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
			<!-- 按照上面配置的FILE_PATH路徑來保存日誌 -->
			<fileNamePattern>${FILE_PATH}</fileNamePattern>
			<!-- 日誌保存15天 -->
			<maxHistory>15</maxHistory>
			<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
				<!-- 單個日誌文件的最大,超過則新建日誌文件存儲 -->
				<maxFileSize>10MB</maxFileSize>
			</timeBasedFileNamingAndTriggeringPolicy>
		</rollingPolicy>

		<encoder>
			<!-- 按照上面配置的LOG_PATTERN來打印日誌 -->
			<pattern>${LOG_PATTERN}</pattern>
		</encoder>
	</appender>
</configuration>

使用 <appender> 定義一個名爲 “FILE” 的文件配置,主要是配置日誌文件保存的時間、單個日誌文件存儲的大小、以及文件保存的路徑和日誌的輸出格式。

3.4 定義日誌輸出級別

<configuration>
	<logger name="com.itcodai.course03" level="INFO" />
	<root level="INFO">
		<appender-ref ref="CONSOLE" />
		<appender-ref ref="FILE" />
	</root>
</configuration>

有了上面那些定義後,最後我們使用 <logger> 來定義一下項目中默認的日誌輸出級別,這裏定義級別爲 INFO,然後針對 INFO 級別的日誌,使用 <root> 引用上面定義好的控制檯日誌輸出和日誌文件的參數。這樣 logback.xml 文件中的配置就設置完了。

4. 使用Logger在項目中打印日誌

在代碼中,我們一般使用 Logger 對象來打印出一些 log 信息,可以指定打印出的日誌級別,也支持佔位符,很方便。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

    private final static Logger logger = LoggerFactory.getLogger(TestController.class);

    @RequestMapping("/log")
    public String testLog() {
        logger.debug("=====測試日誌debug級別打印====");
        logger.info("======測試日誌info級別打印=====");
        logger.error("=====測試日誌error級別打印====");
        logger.warn("======測試日誌warn級別打印=====");

        // 可以使用佔位符打印出一些參數信息
        String str1 = "blog.itcodai.com";
        String str2 = "blog.csdn.net/eson_15";
        logger.info("======倪升武的個人博客:{};倪升武的CSDN博客:{}", str1, str2);

        return "success";
    }
}

啓動該項目,在瀏覽器中輸入 localhost:8080/test/log 後可以看到控制檯的日誌記錄:

==測試日誌info級別打印=
=測試日誌error級別打印
==測試日誌warn級別打印=
======倪升武的個人博客:blog.itcodai.com;倪升武的CSDN博客:blog.csdn.net/eson_15

因爲 INFO 級別比 DEBUG 級別高,所以 debug 這條沒有打印出來,如果將 logback.xml 中的日誌級別設置成 DEBUG,那麼四條語句都會打印出來,這個大家自己去測試了。同時可以打開 D:\logs\course03\ 目錄,裏面有剛剛項目啓動,以後後面生成的所有日誌記錄。在項目部署後,我們大部分都是通過查看日誌文件來定位問題。

5. 總結

本節課主要對 slf4j 做了一個簡單的介紹,並且對 Spring Boot 中如何使用 slf4j 輸出日誌做了詳細的說明,着重分析了 logback.xml 文件中對日誌相關信息的配置,包括日誌的不同級別。最後針對這些配置,在代碼中使用 Logger 打印出一些進行測試。在實際項目中,這些日誌都是排查問題的過程中非常重要的資料。
課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第04課:Spring Boot中的項目屬性配置

我們知道,在項目中,很多時候需要用到一些配置的信息,這些信息可能在測試環境和生產環境下會有不同的配置,後面根據實際業務情況有可能還會做修改,針對這種情況,我們不能將這些配置在代碼中寫死,最好就是寫到配置文件中。比如可以把這些信息寫到 application.yml 文件中。

1. 少量配置信息的情形

舉個例子,在微服務架構中,最常見的就是某個服務需要調用其他服務來獲取其提供的相關信息,那麼在該服務的配置文件中需要配置被調用的服務地址,比如在當前服務裏,我們需要調用訂單微服務獲取訂單相關的信息,假設 訂單服務的端口號是 8002,那我們可以做如下配置:

server:
  port: 8001

# 配置微服務的地址
url:
  # 訂單微服務的地址
  orderUrl: http://localhost:8002

然後在業務代碼中如何獲取到這個配置的訂單服務地址呢?我們可以使用 @Value 註解來解決。在對應的類中加上一個屬性,在屬性上使用 @Value 註解即可獲取到配置文件中的配置信息,如下:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/test")
public class ConfigController {

    private static final Logger LOGGER = LoggerFactory.getLogger(ConfigController.class);

    @Value("${url.orderUrl}")
    private String orderUrl;
    
    @RequestMapping("/config")
    public String testConfig() {
        LOGGER.info("=====獲取的訂單服務地址爲:{}", orderUrl);
        return "success";
    }
}

@Value 註解上通過 ${key} 即可獲取配置文件中和 key 對應的 value 值。我們啓動一下項目,在瀏覽器中輸入 localhost:8080/test/config 請求服務後,可以看到控制檯會打印出訂單服務的地址:

=====獲取的訂單服務地址爲:http://localhost:8002

說明我們成功獲取到了配置文件中的訂單微服務地址,在實際項目中也是這麼用的,後面如果因爲服務器部署的原因,需要修改某個服務的地址,那麼只要在配置文件中修改即可。

2. 多個配置信息的情形

這裏再引申一個問題,隨着業務複雜度的增加,一個項目中可能會有越來越多的微服務,某個模塊可能需要調用多個微服務獲取不同的信息,那麼就需要在配置文件中配置多個微服務的地址。可是,在需要調用這些微服務的代碼中,如果這樣一個個去使用 @Value 註解引入相應的微服務地址的話,太過於繁瑣,也不科學。

所以,在實際項目中,業務繁瑣,邏輯複雜的情況下,需要考慮封裝一個或多個配置類。舉個例子:假如在當前服務中,某個業務需要同時調用訂單微服務、用戶微服務和購物車微服務,分別獲取訂單、用戶和購物車相關信息,然後對這些信息做一定的邏輯處理。那麼在配置文件中,我們需要將這些微服務的地址都配置好:

# 配置多個微服務的地址
url:
  # 訂單微服務的地址
  orderUrl: http://localhost:8002
  # 用戶微服務的地址
  userUrl: http://localhost:8003
  # 購物車微服務的地址
  shoppingUrl: http://localhost:8004

也許實際業務中,遠遠不止這三個微服務,甚至十幾個都有可能。對於這種情況,我們可以先定義一個 MicroServiceUrl 類來專門保存微服務的 url,如下:

@Component
@ConfigurationProperties(prefix = "url")
public class MicroServiceUrl {

    private String orderUrl;
    private String userUrl;
    private String shoppingUrl;
    // 省去get和set方法
}

細心的朋友應該可以看到,使用 @ConfigurationProperties 註解並且使用 prefix 來指定一個前綴,然後該類中的屬性名就是配置中去掉前綴後的名字,一一對應即可。即:前綴名 + 屬性名就是配置文件中定義的 key。同時,該類上面需要加上 @Component 註解,把該類作爲組件放到Spring容器中,讓 Spring 去管理,我們使用的時候直接注入即可。

需要注意的是,使用 @ConfigurationProperties 註解需要導入它的依賴:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-configuration-processor</artifactId>
	<optional>true</optional>
</dependency>

OK,到此爲止,我們將配置寫好了,接下來寫個 Controller 來測試一下。此時,不需要在代碼中一個個引入這些微服務的 url 了,直接通過 @Resource 註解將剛剛寫好配置類注入進來即可使用了,非常方便。如下:

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

    private static final Logger LOGGER = LoggerFactory.getLogger(TestController.class);

    @Resource
    private MicroServiceUrl microServiceUrl;
    
    @RequestMapping("/config")
    public String testConfig() {
        LOGGER.info("=====獲取的訂單服務地址爲:{}", microServiceUrl.getOrderUrl());
        LOGGER.info("=====獲取的用戶服務地址爲:{}", microServiceUrl.getUserUrl());
        LOGGER.info("=====獲取的購物車服務地址爲:{}", microServiceUrl.getShoppingUrl());

        return "success";
    }
}

再次啓動項目,請求一下可以看到,控制檯打印出如下信息,說明配置文件生效,同時正確獲取配置文件內容:

=====獲取的訂單服務地址爲:http://localhost:8002
=====獲取的訂單服務地址爲:http://localhost:8002
=====獲取的用戶服務地址爲:http://localhost:8003
=====獲取的購物車服務地址爲:http://localhost:8004

3. 指定項目配置文件

我們知道,在實際項目中,一般有兩個環境:開發環境和生產環境。開發環境中的配置和生產環境中的配置往往不同,比如:環境、端口、數據庫、相關地址等等。我們不可能在開發環境調試好之後,部署到生產環境後,又要將配置信息全部修改成生產環境上的配置,這樣太麻煩,也不科學。

最好的解決方法就是開發環境和生產環境都有一套對用的配置信息,然後當我們在開發時,指定讀取開發環境的配置,當我們將項目部署到服務器上之後,再指定去讀取生產環境的配置。

我們新建兩個配置文件: application-dev.ymlapplication-pro.yml,分別用來對開發環境和生產環境進行相關配置。這裏爲了方便,我們分別設置兩個訪問端口號,開發環境用 8001,生產環境用 8002.

# 開發環境配置文件
server:
  port: 8001
# 開發環境配置文件
server:
  port: 8002

然後在 application.yml 文件中指定讀取哪個配置文件即可。比如我們在開發環境下,指定讀取 applicationn-dev.yml 文件,如下:

spring:
  profiles:
    active:
    - dev

這樣就可以在開發的時候,指定讀取 application-dev.yml 文件,訪問的時候使用 8001 端口,部署到服務器後,只需要將 application.yml 中指定的文件改成 application-pro.yml 即可,然後使用 8002 端口訪問,非常方便。

4. 總結

本節課主要講解了 Spring Boot 中如何在業務代碼中讀取相關配置,包括單一配置和多個配置項,在微服務中,這種情況非常常見,往往會有很多其他微服務需要調用,所以封裝一個配置類來接收這些配置是個很好的處理方式。除此之外,例如數據庫相關的連接參數等等,也可以放到一個配置類中,其他遇到類似的場景,都可以這麼處理。最後介紹了開發環境和生產環境配置的快速切換方式,省去了項目部署時,諸多配置信息的修改。
課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第05課:Spring Boot中的MVC支持

Spring Boot 的 MVC 支持主要來介紹實際項目中最常用的幾個註解,包括 @RestController@RequestMapping@PathVariable@RequestParam 以及 @RequestBody。主要介紹這幾個註解常用的使用方式和特點。

1. @RestController

@RestController 是 Spring Boot 新增的一個註解,我們看一下該註解都包含了哪些東西。

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
    String value() default "";
}

可以看出, @RestController 註解包含了原來的 @Controller@ResponseBody 註解,使用過 Spring 的朋友對 @Controller 註解已經非常瞭解了,這裏不再贅述, @ResponseBody 註解是將返回的數據結構轉換爲 Json 格式。所以 @RestController 可以看作是 @Controller@ResponseBody 的結合體,相當於偷個懶,我們使用 @RestController 之後就不用再使用 @Controller 了。但是需要注意一個問題:如果是前後端分離,不用模板渲染的話,比如 Thymeleaf,這種情況下是可以直接使用@RestController 將數據以 json 格式傳給前端,前端拿到之後解析;但如果不是前後端分離,需要使用模板來渲染的話,一般 Controller 中都會返回到具體的頁面,那麼此時就不能使用@RestController了,比如:

public String getUser() {
	return "user";
}

其實是需要返回到 user.html 頁面的,如果使用 @RestController 的話,會將 user 作爲字符串返回的,所以這時候我們需要使用 @Controller 註解。這在下一節 Spring Boot 集成 Thymeleaf 模板引擎中會再說明。

2. @RequestMapping

@RequestMapping 是一個用來處理請求地址映射的註解,它可以用於類上,也可以用於方法上。在類的級別上的註解會將一個特定請求或者請求模式映射到一個控制器之上,表示類中的所有響應請求的方法都是以該地址作爲父路徑;在方法的級別表示進一步指定到處理方法的映射關係。

該註解有6個屬性,一般在項目中比較常用的有三個屬性:value、method 和 produces。

  • value 屬性:指定請求的實際地址,value 可以省略不寫
  • method 屬性:指定請求的類型,主要有 GET、PUT、POST、DELETE,默認爲 GET
  • produces屬性:指定返回內容類型,如 produces = “application/json; charset=UTF-8”

@RequestMapping 註解比較簡單,舉個例子:

@RestController
@RequestMapping(value = "/test", produces = "application/json; charset=UTF-8")
public class TestController {

    @RequestMapping(value = "/get", method = RequestMethod.GET)
    public String testGet() {
        return "success";
    }
}

這個很簡單,啓動項目在瀏覽器中輸入 localhost:8080/test/get 測試一下即可。

針對四種不同的請求方式,是有相應註解的,不用每次在 @RequestMapping 註解中加 method 屬性來指定,上面的 GET 方式請求可以直接使用 @GetMapping("/get") 註解,效果一樣。相應地,PUT 方式、POST 方式和 DELETE 方式對應的註解分別爲 @PutMapping@PostMappingDeleteMapping

3. @PathVariable

@PathVariable 註解主要是用來獲取 url 參數,Spring Boot 支持 restfull 風格的 url,比如一個 GET 請求攜帶一個參數 id 過來,我們將 id 作爲參數接收,可以使用 @PathVariable 註解。如下:

@GetMapping("/user/{id}")
public String testPathVariable(@PathVariable Integer id) {
	System.out.println("獲取到的id爲:" + id);
	return "success";
}

這裏需要注意一個問題,如果想要 url 中佔位符中的 id 值直接賦值到參數 id 中,需要保證 url 中的參數和方法接收參數一致,否則就無法接收。如果不一致的話,其實也可以解決,需要用 @PathVariable 中的 value 屬性來指定對應關係。如下:

@RequestMapping("/user/{idd}")
public String testPathVariable(@PathVariable(value = "idd") Integer id) {
	System.out.println("獲取到的id爲:" + id);
	return "success";
}

對於訪問的 url,佔位符的位置可以在任何位置,不一定非要在最後,比如這樣也行:/xxx/{id}/user。另外,url 也支持多個佔位符,方法參數使用同樣數量的參數來接收,原理和一個參數是一樣的,例如:

@GetMapping("/user/{idd}/{name}")
    public String testPathVariable(@PathVariable(value = "idd") Integer id, @PathVariable String name) {
        System.out.println("獲取到的id爲:" + id);
        System.out.println("獲取到的name爲:" + name);
        return "success";
    }

運行項目,在瀏覽器中請求 localhost:8080/test/user/2/zhangsan 可以看到控制檯輸出如下信息:

獲取到的id爲:2
獲取到的name爲:zhangsan

所以支持多個參數的接收。同樣地,如果 url 中的參數和方法中的參數名稱不同的話,也需要使用 value 屬性來綁定兩個參數。

4. @RequestParam

@RequestParam 註解顧名思義,也是獲取請求參數的,上面我們介紹了 @PathValiable 註解也是獲取請求參數的,那麼 @RequestParam@PathVariable 有什麼不同呢?主要區別在於: @PathValiable 是從 url 模板中獲取參數值, 即這種風格的 url:http://localhost:8080/user/{id} ;而 @RequestParam 是從 request 裏面獲取參數值,即這種風格的 url:http://localhost:8080/user?id=1 。我們使用該 url 帶上參數 id 來測試一下如下代碼:

@GetMapping("/user")
public String testRequestParam(@RequestParam Integer id) {
	System.out.println("獲取到的id爲:" + id);
	return "success";
}

可以正常從控制檯打印出 id 信息。同樣地,url 上面的參數和方法的參數需要一致,如果不一致,也需要使用 value 屬性來說明,比如 url 爲:http://localhost:8080/user?idd=1

@RequestMapping("/user")
public String testRequestParam(@RequestParam(value = "idd", required = false) Integer id) {
	System.out.println("獲取到的id爲:" + id);
	return "success";
}

除了 value 屬性外,還有個兩個屬性比較常用:

  • required 屬性:true 表示該參數必須要傳,否則就會報 404 錯誤,false 表示可有可無。
  • defaultValue 屬性:默認值,表示如果請求中沒有同名參數時的默認值。

從 url 中可以看出,@RequestParam 註解用於 GET 請求上時,接收拼接在 url 中的參數。除此之外,該註解還可以用於 POST 請求,接收前端表單提交的參數,假如前端通過表單提交 username 和 password 兩個參數,那我們可以使用 @RequestParam 來接收,用法和上面一樣。

@PostMapping("/form1")
    public String testForm(@RequestParam String username, @RequestParam String password) {
        System.out.println("獲取到的username爲:" + username);
        System.out.println("獲取到的password爲:" + password);
        return "success";
    }

我們使用 postman 來模擬一下表單提交,測試一下接口:

使用postman測試表單提交

那麼問題來了,如果表單數據很多,我們不可能在後臺方法中寫上很多參數,每個參數還要 @RequestParam 註解。針對這種情況,我們需要封裝一個實體類來接收這些參數,實體中的屬性名和表單中的參數名一致即可。

public class User {
	private String username;
	private String password;
	// set get
}

使用實體接收的話,我們不能在前面加 @RequestParam 註解了,直接使用即可。

@PostMapping("/form2")
    public String testForm(User user) {
        System.out.println("獲取到的username爲:" + user.getUsername());
        System.out.println("獲取到的password爲:" + user.getPassword());
        return "success";
    }

使用 postman 再次測試一下表單提交,觀察一下返回值和控制檯打印出的日誌即可。在實際項目中,一般都是封裝一個實體類來接收表單數據,因爲實際項目中表單數據一般都很多。

5. @RequestBody

@RequestBody 註解用於接收前端傳來的實體,接收參數也是對應的實體,比如前端通過 json 提交傳來兩個參數 username 和 password,此時我們需要在後端封裝一個實體來接收。在傳遞的參數比較多的情況下,使用 @RequestBody 接收會非常方便。例如:

public class User {
	private String username;
	private String password;
	// set get
}
@PostMapping("/user")
public String testRequestBody(@RequestBody User user) {
	System.out.println("獲取到的username爲:" + user.getUsername());
	System.out.println("獲取到的password爲:" + user.getPassword());
	return "success";
}

我們使用 postman 工具來測試一下效果,打開 postman,然後輸入請求地址和參數,參數我們用 json 來模擬,如下圖所有,調用之後返回 success。

使用Postman測試requestBody

同時看一下後臺控制檯輸出的日誌:

獲取到的username爲:倪升武
獲取到的password爲:123456

可以看出,@RequestBody 註解用於 POST 請求上,接收 json 實體參數。它和上面我們介紹的表單提交有點類似,只不過參數的格式不同,一個是 json 實體,一個是表單提交。在實際項目中根據具體場景和需要使用對應的註解即可。

6. 總結

本節課主要講解了 Spring Boot 中對 MVC 的支持,分析了 @RestController@RequestMapping@PathVariable@RequestParam@RequestBody 四個註解的使用方式,由於 @RestController 中集成了 @ResponseBody 所以對返回 json 的註解不再贅述。以上四個註解是使用頻率很高的註解,在所有的實際項目中基本都會遇到,要熟練掌握。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第06課:Spring Boot集成 Swagger2 展現在線接口文檔

1. Swagger 簡介

1.1 解決的問題

隨着互聯網技術的發展,現在的網站架構基本都由原來的後端渲染,變成了前後端分離的形態,而且前端技術和後端技術在各自的道路上越走越遠。前端和後端的唯一聯繫,變成了 API 接口,所以 API 文檔變成了前後端開發人員聯繫的紐帶,變得越來越重要。

那麼問題來了,隨着代碼的不斷更新,開發人員在開發新的接口或者更新舊的接口後,由於開發任務的繁重,往往文檔很難持續跟着更新,Swagger 就是用來解決該問題的一款重要的工具,對使用接口的人來說,開發人員不需要給他們提供文檔,只要告訴他們一個 Swagger 地址,即可展示在線的 API 接口文檔,除此之外,調用接口的人員還可以在線測試接口數據,同樣地,開發人員在開發接口時,同樣也可以利用 Swagger 在線接口文檔測試接口數據,這給開發人員提供了便利。

1.2 Swagger 官方

我們打開 Swagger 官網,官方對 Swagger 的定義爲:

The Best APIs are Built with Swagger Tools

翻譯成中文是:“最好的 API 是使用 Swagger 工具構建的”。由此可見,Swagger 官方對其功能和所處的地位非常自信,由於其非常好用,所以官方對其定位也合情合理。如下圖所示:

官方對swagger的定位

本文主要講解在 Spring Boot 中如何導入 Swagger2 工具來展現項目中的接口文檔。本節課使用的 Swagger 版本爲 2.2.2。下面開始進入 Swagger2 之旅。

2. Swagger2 的 maven 依賴

使用 Swagger2 工具,必須要導入 maven 依賴,當前官方最高版本是 2.8.0,我嘗試了一下,個人感覺頁面展示的效果不太好,而且不夠緊湊,不利於操作。另外,最新版本並不一定是最穩定版本,當前我們實際項目中使用的是 2.2.2 版本,該版本穩定,界面友好,所以本節課主要圍繞着 2.2.2 版本來展開,依賴如下:

<dependency>
	<groupId>io.springfox</groupId>
	<artifactId>springfox-swagger2</artifactId>
	<version>2.2.2</version>
</dependency>
<dependency>
	<groupId>io.springfox</groupId>
	<artifactId>springfox-swagger-ui</artifactId>
	<version>2.2.2</version>
</dependency>

3. Swagger2 的配置

使用 Swagger2 需要進行配置,Spring Boot 中對 Swagger2 的配置非常方便,新建一個配置類,Swagger2 的配置類上除了添加必要的 @Configuration 註解外,還需要添加 @EnableSwagger2 註解。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

/**
 * @author shengwu ni
 */
@Configuration
@EnableSwagger2
public class SwaggerConfig {

    @Bean
    public Docket createRestApi() {
        return new Docket(DocumentationType.SWAGGER_2)
                // 指定構建api文檔的詳細信息的方法:apiInfo()
                .apiInfo(apiInfo())
                .select()
                // 指定要生成api接口的包路徑,這裏把controller作爲包路徑,生成controller中的所有接口
                .apis(RequestHandlerSelectors.basePackage("com.itcodai.course06.controller"))
                .paths(PathSelectors.any())
                .build();
    }

    /**
     * 構建api文檔的詳細信息
     * @return
     */
    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                // 設置頁面標題
                .title("Spring Boot集成Swagger2接口總覽")
                // 設置接口描述
                .description("跟武哥一起學Spring Boot第06課")
                // 設置聯繫方式
                .contact("倪升武," + "CSDN:http://blog.csdn.net/eson_15")
                // 設置版本
                .version("1.0")
                // 構建
                .build();
    }
}

在該配置類中,已經使用註釋詳細解釋了每個方法的作用了,在此不再贅述。到此爲止,我們已經配置好了 Swagger2 了。現在我們可以測試一下配置有沒有生效,啓動項目,在瀏覽器中輸入 localhost:8080/swagger-ui.html,即可看到 swagger2 的接口頁面,如下圖所示,說明Swagger2 集成成功。

swagger2頁面

結合該圖,對照上面的 Swagger2 配置文件中的配置,可以很明確的知道配置類中每個方法的作用。這樣就很容易理解和掌握 Swagger2 中的配置了,也可以看出,其實 Swagger2 配置很簡單。

【友情提示】可能有很多朋友在配置 Swagger 的時候會遇到下面的情況,而且還關不掉的,這是因爲瀏覽器緩存引起的,清空一下瀏覽器緩存即可解決問題。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-Q6PVFDIl-1583223598303)(http://p99jlm9k5.bkt.clouddn.com/blog/images/1/error.png)]

4. Swagger2 的使用

上面我們已經配置好了 Swagger2,並且也啓動測試了一下,功能正常,下面我們開始使用 Swagger2,主要來介紹 Swagger2 中的幾個常用的註解,分別在實體類上、 Controller 類上以及 Controller 中的方法上,最後我們看一下 Swagger2 是如何在頁面上呈現在線接口文檔的,並且結合 Controller 中的方法在接口中測試一下數據。

4.1 實體類註解

本節我們建一個 User 實體類,主要介紹一下 Swagger2 中的 @ApiModel@ApiModelProperty 註解,同時爲後面的測試做準備。

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;

@ApiModel(value = "用戶實體類")
public class User {

    @ApiModelProperty(value = "用戶唯一標識")
    private Long id;

    @ApiModelProperty(value = "用戶姓名")
    private String username;

    @ApiModelProperty(value = "用戶密碼")
    private String password;

	// 省略set和get方法
}

解釋下 @ApiModel@ApiModelProperty 註解:

@ApiModel 註解用於實體類,表示對類進行說明,用於參數用實體類接收。
@ApiModelProperty 註解用於類中屬性,表示對 model 屬性的說明或者數據操作更改。

該註解在在線 API 文檔中的具體效果在下文說明。

4.2 Controller 類中相關注解

我們寫一個 TestController,再寫幾個接口,然後學習一下 Controller 中和 Swagger2 相關的註解。

import com.itcodai.course06.entiy.JsonResult;
import com.itcodai.course06.entiy.User;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/swagger")
@Api(value = "Swagger2 在線接口文檔")
public class TestController {

    @GetMapping("/get/{id}")
    @ApiOperation(value = "根據用戶唯一標識獲取用戶信息")
    public JsonResult<User> getUserInfo(@PathVariable @ApiParam(value = "用戶唯一標識") Long id) {
        // 模擬數據庫中根據id獲取User信息
        User user = new User(id, "倪升武", "123456");
        return new JsonResult(user);
    }
}

我們來學習一下 @Api@ApiOperation@ApiParam 註解。

@Api 註解用於類上,表示標識這個類是 swagger 的資源。
@ApiOperation 註解用於方法,表示一個 http 請求的操作。
@ApiParam 註解用於參數上,用來標明參數信息。

這裏返回的是 JsonResult,是第02課中學習返回 json 數據時封裝的實體。以上是 Swagger 中最常用的 5 個註解,接下來運行一下項目工程,在瀏覽器中輸入 localhost:8080/swagger-ui.html 看一下 Swagger 頁面的接口狀態。

swagger接口展示

可以看出,Swagger 頁面對該接口的信息展示的非常全面,每個註解的作用以及展示的地方在上圖中已經標明,通過頁面即可知道該接口的所有信息,那麼我們直接在線測試一下該接口返回的信息,輸入id爲1,看一下返回數據:

返回數據測試

可以看出,直接在頁面返回了 json 格式的數據,開發人員可以直接使用該在線接口來測試數據的正確與否,非常方便。上面是對於單個參數的輸入,如果輸入參數爲某個對象這種情況,Swagger 是什麼樣子呢?我們再寫一個接口。

@PostMapping("/insert")
    @ApiOperation(value = "添加用戶信息")
    public JsonResult<Void> insertUser(@RequestBody @ApiParam(value = "用戶信息") User user) {
        // 處理添加邏輯
        return new JsonResult<>();
    }

重啓項目,在瀏覽器中輸入 localhost:8080/swagger-ui.html 看一下效果:

swagger接口展示

5. 總結

OK,本節課詳細分析了 Swagger 的優點,以及 Spring Boot 如何集成 Swagger2,包括配置,相關注解的講解,涉及到了實體類和接口類,以及如何使用。最後通過頁面測試,體驗了 Swagger 的強大之處,基本上是每個項目組中必備的工具之一,所以要掌握該工具的使用,也不難。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第07課:Spring Boot集成Thymeleaf模板引擎

1. Thymeleaf 介紹

Thymeleaf 是適用於 Web 和獨立環境的現代服務器端 Java 模板引擎。
Thymeleaf 的主要目標是爲您的開發工作流程帶來優雅的自然模板 - 可以在瀏覽器中正確顯示的HTML,也可以用作靜態原型,從而在開發團隊中實現更強大的協作。

以上翻譯自 Thymeleaf 官方網站。傳統的 JSP+JSTL 組合是已經過去了,Thymeleaf 是現代服務端的模板引擎,與傳統的 JSP 不同,Thymeleaf 可以使用瀏覽器直接打開,因爲可以忽略掉拓展屬性,相當於打開原生頁面,給前端人員也帶來一定的便利。

什麼意思呢?就是說在本地環境或者有網絡的環境下,Thymeleaf 均可運行。由於 thymeleaf 支持 html 原型,也支持在 html 標籤裏增加額外的屬性來達到 “模板+數據” 的展示方式,所以美工可以直接在瀏覽器中查看頁面效果,當服務啓動後,也可以讓後臺開發人員查看帶數據的動態頁面效果。比如:

<div class="ui right aligned basic segment">
      <div class="ui orange basic label" th:text="${blog.flag}">靜態原創信息</div>
</div>
<h2 class="ui center aligned header" th:text="${blog.title}">這是靜態標題</h2>

類似與上面這樣,在靜態頁面時,會展示靜態信息,當服務啓動後,動態獲取數據庫中的數據後,就可以展示動態數據,th:text 標籤是用來動態替換文本的,這會在下文說明。該例子說明瀏覽器解釋 html 時會忽略 html 中未定義的標籤屬性(比如 th:text),所以 thymeleaf 的模板可以靜態地運行;當有數據返回到頁面時,Thymeleaf 標籤會動態地替換掉靜態內容,使頁面動態顯示數據。

2. 依賴導入

在 Spring Boot 中使用 thymeleaf 模板需要引入依賴,可以在創建項目工程時勾選 Thymeleaf,也可以創建之後再手動導入,如下:

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

另外,在 html 頁面上如果要使用 thymeleaf 模板,需要在頁面標籤中引入:

<html xmlns:th="http://www.thymeleaf.org">

3. Thymeleaf相關配置

因爲 Thymeleaf 中已經有默認的配置了,我們不需要再對其做過多的配置,有一個需要注意一下,Thymeleaf 默認是開啓頁面緩存的,所以在開發的時候,需要關閉這個頁面緩存,配置如下。

spring:
  thymeleaf:
    cache: false #關閉緩存

否則會有緩存,導致頁面沒法及時看到更新後的效果。 比如你修改了一個文件,已經 update 到 tomcat 了,但刷新頁面還是之前的頁面,就是因爲緩存引起的。

4. Thymeleaf 的使用

4.1 訪問靜態頁面

這個和 Thymeleaf 沒啥關係,應該說是通用的,我把它一併寫到這裏的原因是一般我們做網站的時候,都會做一個 404 頁面和 500 頁面,爲了出錯時給用戶一個友好的展示,而不至於一堆異常信息拋出來。Spring Boot 中會自動識別模板目錄(templates/)下的 404.html 和 500.html 文件。我們在 templates/ 目錄下新建一個 error 文件夾,專門放置錯誤的 html 頁面,然後分別打印些信息。以 404.html 爲例:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    這是404頁面
</body>
</html>

我們再寫一個 controller 來測試一下 404 和 500 頁面:

@Controller
@RequestMapping("/thymeleaf")
public class ThymeleafController {

    @RequestMapping("/test404")
    public String test404() {
        return "index";
    }

    @RequestMapping("/test500")
    public String test500() {
        int i = 1 / 0;
        return "index";
    }
}

當我們在瀏覽器中輸入 localhost:8080/thymeleaf/test400 時,故意輸入錯誤,找不到對應的方法,就會跳轉到 404.html 顯示。
當我們在瀏覽器中輸入 localhost:8088/thymeleaf/test505 時,會拋出異常,然後會自動跳轉到 500.html 顯示。

【注】這裏有個問題需要注意一下,前面的課程中我們說了微服務中會走向前後端分離,我們在 Controller 層上都是使用的 @RestController 註解,自動會把返回的數據轉成 json 格式。但是在使用模板引擎時,Controller 層就不能用 @RestController 註解了,因爲在使用 thymeleaf 模板時,返回的是視圖文件名,比如上面的 Controller 中是返回到 index.html 頁面,如果使用 @RestController 的話,會把 index 當作 String 解析了,直接返回到頁面了,而不是去找 index.html 頁面,大家可以試一下。所以在使用模板時要用 @Controller 註解。

4.2 Thymeleaf 中處理對象

我們來看一下 thymeleaf 模板中如何處理對象信息,假如我們在做個人博客的時候,需要給前端傳博主相關信息來展示,那麼我們會封裝成一個博主對象,比如:

public class Blogger {
    private Long id;
    private String name;
    private String pass;
	// 省去set和get
}

然後在controller層中初始化一下:

@GetMapping("/getBlogger")
public String getBlogger(Model model) {
	Blogger blogger = new Blogger(1L, "倪升武", "123456");
	model.addAttribute("blogger", blogger);
	return "blogger";
}

我們先初始化一個 Blogger 對象,然後將該對象放到 Model 中,然後返回到 blogger.html 頁面去渲染。接下來我們再寫一個 blogger.html 來渲染 blogger 信息:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>博主信息</title>
</head>
<body>
<form action="" th:object="${blogger}" >
    用戶編號:<input name="id" th:value="${blogger.id}"/><br>
    用戶姓名:<input type="text" name="username" th:value="${blogger.getName()}" /><br>
    登陸密碼:<input type="text" name="password" th:value="*{pass}" />
</form>
</body>
</html>

可以看出,在 thymeleaf 模板中,使用 th:object="${}" 來獲取對象信息,然後在表單裏面可以有三種方式來獲取對象屬性。如下:

使用 th:value="*{屬性名}"
使用 th:value="${對象.屬性名}",對象指的是上面使用 th:object 獲取的對象
使用 th:value="${對象.get方法}",對象指的是上面使用 th:object 獲取的對象

可以看出,在 Thymeleaf 中可以像寫 java 一樣寫代碼,很方便。我們在瀏覽器中輸入 localhost:8080/thymeleaf/getBlogger 來測試一下數據:

thymeleaf中處理對象

4.3 Thymeleaf 中處理 List

處理 List 的話,和處理上面介紹的對象差不多,但是需要在 thymeleaf 中進行遍歷。我們先在 Controller 中模擬一個 List。

@GetMapping("/getList")
public String getList(Model model) {
    Blogger blogger1 = new Blogger(1L, "倪升武", "123456");
    Blogger blogger2 = new Blogger(2L, "達人課", "123456");
    List<Blogger> list = new ArrayList<>();
    list.add(blogger1);
    list.add(blogger2);
    model.addAttribute("list", list);
    return "list";
}

接下來我們寫一個 list.html 來獲取該 list 信息,然後在 list.html 中遍歷這個list。如下:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>博主信息</title>
</head>
<body>
<form action="" th:each="blogger : ${list}" >
    用戶編號:<input name="id" th:value="${blogger.id}"/><br>
    用戶姓名:<input type="text" name="password" th:value="${blogger.name}"/><br>
    登錄密碼:<input type="text" name="username" th:value="${blogger.getPass()}"/>
</form>
</body>
</html>

可以看出,其實和處理單個對象信息差不多,Thymeleaf 使用 th:each 進行遍歷,${} 取 model 中傳過來的參數,然後自定義 list 中取出來的每個對象,這裏定義爲 blogger。表單裏面可以直接使用 ${對象.屬性名} 來獲取 list 中對象的屬性值,也可以使用 ${對象.get方法} 來獲取,這點和上面處理對象信息是一樣的,但是不能使用 *{屬性名} 來獲取對象中的屬性,thymeleaf 模板獲取不到。

4.4 其他常用 thymeleaf 操作

我們來總結一下 thymeleaf 中的一些常用的標籤操作,如下:

標籤 功能 例子
th:value 給屬性賦值 <input th:value="${blog.name}" />
th:style 設置樣式 th:style="'display:'+@{(${sitrue}?'none':'inline-block')} + ''"
th:onclick 點擊事件 th:onclick="'getInfo()'"
th:if 條件判斷 <a th:if="${userId == collect.userId}" >
th:href 超鏈接 <a th:href="@{/blogger/login}">Login</a> />
th:unless 條件判斷和th:if相反 <a th:href="@{/blogger/login}" th:unless=${session.user != null}>Login</a>
th:switch 配合th:case <div th:switch="${user.role}">
th:case 配合th:switch <p th:case="'admin'">administator</p>
th:src 地址引入 <img alt="csdn logo" th:src="@{/img/logo.png}" />
th:action 表單提交的地址 <form th:action="@{/blogger/update}">

Thymeleaf 還有很多其他用法,這裏就不總結了,具體的可以參考Thymeleaf的官方文檔(v3.0)。主要要學會如何在 Spring Boot 中去使用 thymeleaf,遇到對應的標籤或者方法,查閱官方文檔即可。

5. 總結

Thymeleaf 在 Spring Boot 中使用非常廣泛,本節課主要分析了 thymeleaf 的優點,以及如何在 Spring Boot 中集成並使用 thymeleaf 模板,包括依賴、配置,相關數據的獲取、以及一些注意事項等等。最後列舉了一些 thymeleaf 中常用的標籤,在實際項目中多使用,多查閱就能熟練掌握,thymeleaf 中的一些標籤或者方法不用死記硬背,用到什麼去查閱什麼,關鍵是要會在 Spring Boot 中集成,用的多了就熟能生巧。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第08課:Spring Boot中的全局異常處理

在項目開發過程中,不管是對底層數據庫的操作過程,還是業務層的處理過程,還是控制層的處理過程,都不可避免會遇到各種可預知的、不可預知的異常需要處理。如果對每個過程都單獨作異常處理,那系統的代碼耦合度會變得很高,此外,開發工作量也會加大而且不好統一,這也增加了代碼的維護成本。
針對這種實際情況,我們需要將所有類型的異常處理從各處理過程解耦出來,這樣既保證了相關處理過程的功能單一,也實現了異常信息的統一處理和維護。同時,我們也不希望直接把異常拋給用戶,應該對異常進行處理,對錯誤信息進行封裝,然後返回一個友好的信息給用戶。這節主要總結一下項目中如何使用 Spring Boot 如何攔截並處理全局的異常。

1. 定義返回的統一 json 結構

前端或者其他服務請求本服務的接口時,該接口需要返回對應的 json 數據,一般該服務只需要返回請求着需要的參數即可,但是在實際項目中,我們需要封裝更多的信息,比如狀態碼 code、相關信息 msg 等等,這一方面是在項目中可以有個統一的返回結構,整個項目組都適用,另一方面是方便結合全局異常處理信息,因爲異常處理信息中一般我們需要把狀態碼和異常內容反饋給調用方。
這個統一的 json 結構這可以參考第02課:Spring Boot 返回 JSON 數據及數據封裝中封裝的統一 json 結構,本節內容我們簡化一下,只保留狀態碼 code 和異常信息 msg即可。如下:

public class JsonResult {
    /**
     * 異常碼
     */
    protected String code;

    /**
     * 異常信息
     */
    protected String msg;
	
    public JsonResult() {
        this.code = "200";
        this.msg = "操作成功";
    }
    
    public JsonResult(String code, String msg) {
        this.code = code;
        this.msg = msg;
    }
	// get set
}

2. 處理系統異常

新建一個 GlobalExceptionHandler 全局異常處理類,然後加上 @ControllerAdvice 註解即可攔截項目中拋出的異常,如下:

@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
	// 打印log
    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    // ……
}

我們點開 @ControllerAdvice 註解可以看到,@ControllerAdvice 註解包含了 @Component 註解,說明在 Spring Boot 啓動時,也會把該類作爲組件交給 Spring 來管理。除此之外,該註解還有個 basePackages 屬性,該屬性是用來攔截哪個包中的異常信息,一般我們不指定這個屬性,我們攔截項目工程中的所有異常。@ResponseBody 註解是爲了異常處理完之後給調用方輸出一個 json 格式的封裝數據。
在項目中如何使用呢?Spring Boot 中很簡單,在方法上通過 @ExceptionHandler 註解來指定具體的異常,然後在方法中處理該異常信息,最後將結果通過統一的 json 結構體返回給調用者。下面我們舉幾個例子來說明如何來使用。

2.1 處理參數缺失異常

在前後端分離的架構中,前端請求後臺的接口都是通過 rest 風格來調用,有時候,比如 POST 請求 需要攜帶一些參數,但是往往有時候參數會漏掉。另外,在微服務架構中,涉及到多個微服務之間的接口調用時,也可能出現這種情況,此時我們需要定義一個處理參數缺失異常的方法,來給前端或者調用方提示一個友好信息。

參數缺失的時候,會拋出 HttpMessageNotReadableException,我們可以攔截該異常,做一個友好處理,如下:

/**
* 缺少請求參數異常
* @param ex HttpMessageNotReadableException
* @return
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseStatus(value = HttpStatus.BAD_REQUEST)
public JsonResult handleHttpMessageNotReadableException(
    MissingServletRequestParameterException ex) {
    logger.error("缺少請求參數,{}", ex.getMessage());
    return new JsonResult("400", "缺少必要的請求參數");
}

我們來寫個簡單的 Controller 測試一下該異常,通過 POST 請求方式接收兩個參數:姓名和密碼。

@RestController
@RequestMapping("/exception")
public class ExceptionController {

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @PostMapping("/test")
    public JsonResult test(@RequestParam("name") String name,
                           @RequestParam("pass") String pass) {
        logger.info("name:{}", name);
        logger.info("pass:{}", pass);
        return new JsonResult();
    }
}

然後使用 Postman 來調用一下該接口,調用的時候,只傳姓名,不傳密碼,就會拋缺少參數異常,該異常被捕獲之後,就會進入我們寫好的邏輯,給調用方返回一個友好信息,如下:

缺失參數異常

2.2 處理空指針異常

空指針異常是開發中司空見慣的東西了,一般發生的地方有哪些呢?
先來聊一聊一些注意的地方,比如在微服務中,經常會調用其他服務獲取數據,這個數據主要是 json 格式的,但是在解析 json 的過程中,可能會有空出現,所以我們在獲取某個 jsonObject 時,再通過該 jsonObject 去獲取相關信息時,應該要先做非空判斷。
還有一個很常見的地方就是從數據庫中查詢的數據,不管是查詢一條記錄封裝在某個對象中,還是查詢多條記錄封裝在一個 List 中,我們接下來都要去處理數據,那麼就有可能出現空指針異常,因爲誰也不能保證從數據庫中查出來的東西就一定不爲空,所以在使用數據時一定要先做非空判斷。
對空指針異常的處理很簡單,和上面的邏輯一樣,將異常信息換掉即可。如下:

@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    /**
     * 空指針異常
     * @param ex NullPointerException
     * @return
     */
    @ExceptionHandler(NullPointerException.class)
    @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
    public JsonResult handleTypeMismatchException(NullPointerException ex) {
        logger.error("空指針異常,{}", ex.getMessage());
        return new JsonResult("500", "空指針異常了");
    }
}

這個我就不測試了,代碼中 ExceptionController 有個 testNullPointException 方法,模擬了一個空指針異常,我們在瀏覽器中請求一下對應的 url 即可看到返回的信息:

{"code":"500","msg":"空指針異常了"}

2.3 一勞永逸?

當然了,異常很多,比如還有 RuntimeException,數據庫還有一些查詢或者操作異常等等。由於 Exception 異常是父類,所有異常都會繼承該異常,所以我們可以直接攔截 Exception 異常,一勞永逸:

@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    /**
     * 系統異常 預期以外異常
     * @param ex
     * @return
     */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
    public JsonResult handleUnexpectedServer(Exception ex) {
        logger.error("系統異常:", ex);
        return new JsonResult("500", "系統發生異常,請聯繫管理員");
    }
}

但是項目中,我們一般都會比較詳細的去攔截一些常見異常,攔截 Exception 雖然可以一勞永逸,但是不利於我們去排查或者定位問題。實際項目中,可以把攔截 Exception 異常寫在 GlobalExceptionHandler 最下面,如果都沒有找到,最後再攔截一下 Exception 異常,保證輸出信息友好。

3. 攔截自定義異常

在實際項目中,除了攔截一些系統異常外,在某些業務上,我們需要自定義一些業務異常,比如在微服務中,服務之間的相互調用很平凡,很常見。要處理一個服務的調用時,那麼可能會調用失敗或者調用超時等等,此時我們需要自定義一個異常,當調用失敗時拋出該異常,給 GlobalExceptionHandler 去捕獲。

3.1 定義異常信息

由於在業務中,有很多異常,針對不同的業務,可能給出的提示信息不同,所以爲了方便項目異常信息管理,我們一般會定義一個異常信息枚舉類。比如:

/**
 * 業務異常提示信息枚舉類
 * @author shengwu ni
 */
public enum BusinessMsgEnum {
    /** 參數異常 */
    PARMETER_EXCEPTION("102", "參數異常!"),
    /** 等待超時 */
    SERVICE_TIME_OUT("103", "服務調用超時!"),
    /** 參數過大 */
    PARMETER_BIG_EXCEPTION("102", "輸入的圖片數量不能超過50張!"),
    /** 500 : 一勞永逸的提示也可以在這定義 */
    UNEXPECTED_EXCEPTION("500", "系統發生異常,請聯繫管理員!");
    // 還可以定義更多的業務異常

    /**
     * 消息碼
     */
    private String code;
    /**
     * 消息內容
     */
    private String msg;

    private BusinessMsgEnum(String code, String msg) {
        this.code = code;
        this.msg = msg;
    }
	// set get方法
}

3.2 攔截自定義異常

然後我們可以定義一個業務異常,當出現業務異常時,我們就拋這個自定義的業務異常即可。比如我們定義一個 BusinessErrorException 異常,如下:

/**
 * 自定義業務異常
 * @author shengwu ni
 */
public class BusinessErrorException extends RuntimeException {
    
    private static final long serialVersionUID = -7480022450501760611L;

    /**
     * 異常碼
     */
    private String code;
    /**
     * 異常提示信息
     */
    private String message;

    public BusinessErrorException(BusinessMsgEnum businessMsgEnum) {
        this.code = businessMsgEnum.code();
        this.message = businessMsgEnum.msg();
    }
	// get set方法
}

在構造方法中,傳入我們上面自定義的異常枚舉類,所以在項目中,如果有新的異常信息需要添加,我們直接在枚舉類中添加即可,很方便,做到統一維護,然後再攔截該異常時獲取即可。

@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    /**
     * 攔截業務異常,返回業務異常信息
     * @param ex
     * @return
     */
    @ExceptionHandler(BusinessErrorException.class)
    @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
    public JsonResult handleBusinessError(BusinessErrorException ex) {
        String code = ex.getCode();
        String message = ex.getMessage();
        return new JsonResult(code, message);
    }
}

在業務代碼中,我們可以直接模擬一下拋出業務異常,測試一下:

@RestController
@RequestMapping("/exception")
public class ExceptionController {

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @GetMapping("/business")
    public JsonResult testException() {
        try {
            int i = 1 / 0;
        } catch (Exception e) {
            throw new BusinessErrorException(BusinessMsgEnum.UNEXPECTED_EXCEPTION);
        }
        return new JsonResult();
    }
}

運行一下項目,測試一下,返回 json 如下,說明我們自定義的業務異常捕獲成功:

{"code":"500","msg":"系統發生異常,請聯繫管理員!"}

4. 總結

本節課程主要講解了Spring Boot 的全局異常處理,包括異常信息的封裝、異常信息的捕獲和處理,以及在實際項目中,我們用到的自定義異常枚舉類和業務異常的捕獲與處理,在項目中運用的非常廣泛,基本上每個項目中都需要做全局異常處理。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第09課:Spring Boot中的切面AOP處理

1. 什麼是AOP

AOP:Aspect Oriented Programming 的縮寫,意爲:面向切面編程。面向切面編程的目標就是分離關注點。什麼是關注點呢?就是關注點,就是你要做的事情。假如你是一位公子哥,沒啥人生目標,每天衣來伸手,飯來張口,整天只知道一件事:玩(這就是你的關注點,你只要做這一件事)!但是有個問題,你在玩之前,你還需要起牀、穿衣服、穿鞋子、疊被子、做早飯等等等等,但是這些事情你不想關注,也不用關注,你只想想玩,那麼怎麼辦呢?

對!這些事情通通交給下人去幹。你有一個專門的僕人 A 幫你穿衣服,僕人 B 幫你穿鞋子,僕人 C 幫你疊好被子,僕人 D 幫你做飯,然後你就開始吃飯、去玩(這就是你一天的正事),你幹完你的正事之後,回來,然後一系列僕人又開始幫你幹這個幹那個,然後一天就結束了!

這就是 AOP。AOP 的好處就是你只需要幹你的正事,其它事情別人幫你幹。也許有一天,你想裸奔,不想穿衣服,那麼你把僕人 A 解僱就是了!也許有一天,出門之前你還想帶點錢,那麼你再僱一個僕人 E 專門幫你幹取錢的活!這就是AOP。每個人各司其職,靈活組合,達到一種可配置的、可插拔的程序結構。

2. Spring Boot 中的 AOP 處理

2.1 AOP 依賴

使用AOP,首先需要引入AOP的依賴。

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

2.2 實現 AOP 切面

Spring Boot 中使用 AOP 非常簡單,假如我們要在項目中打印一些 log,在引入了上面的依賴之後,我們新建一個類 LogAspectHandler,用來定義切面和處理方法。只要在類上加個@Aspect註解即可。@Aspect 註解用來描述一個切面類,定義切面類的時候需要打上這個註解。@Component 註解讓該類交給 Spring 來管理。

@Aspect
@Component
public class LogAspectHandler {

}

這裏主要介紹幾個常用的註解及使用:

1.@Pointcut:定義一個切面,即上面所描述的關注的某件事入口。
2.@Before:在做某件事之前做的事。
3.@After:在做某件事之後做的事。
4.@AfterReturning:在做某件事之後,對其返回值做增強處理。
5.@AfterThrowing:在做某件事拋出異常時,處理。

2.2.1 @Pointcut 註解

@Pointcut 註解:用來定義一個切面(切入點),即上文中所關注的某件事情的入口。切入點決定了連接點關注的內容,使得我們可以控制通知什麼時候執行。

@Aspect
@Component
public class LogAspectHandler {

    /**
     * 定義一個切面,攔截com.itcodai.course09.controller包和子包下的所有方法
     */
    @Pointcut("execution(* com.itcodai.course09.controller..*.*(..))")
    public void pointCut() {}
}

@Pointcut 註解指定一個切面,定義需要攔截的東西,這裏介紹兩個常用的表達式:一個是使用 execution(),另一個是使用 annotation()
execution(* com.itcodai.course09.controller..*.*(..))) 表達式爲例,語法如下:

execution() 爲表達式主體
第一個 * 號的位置:表示返回值類型,* 表示所有類型
包名:表示需要攔截的包名,後面的兩個句點表示當前包和當前包的所有子包,com.itcodai.course09.controller 包、子包下所有類的方法
第二個 * 號的位置:表示類名,* 表示所有類
*(..) :這個星號表示方法名,* 表示所有的方法,後面括弧裏面表示方法的參數,兩個句點表示任何參數

annotation() 方式是針對某個註解來定義切面,比如我們對具有@GetMapping註解的方法做切面,可以如下定義切面:

@Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")
public void annotationCut() {}

然後使用該切面的話,就會切入註解是 @GetMapping 的方法。因爲在實際項目中,可能對於不同的註解有不同的邏輯處理,比如 @GetMapping@PostMapping@DeleteMapping 等。所以這種按照註解的切入方式在實際項目中也很常用。

2.2.2 @Before 註解

@Before 註解指定的方法在切面切入目標方法之前執行,可以做一些 log 處理,也可以做一些信息的統計,比如獲取用戶的請求 url 以及用戶的 ip 地址等等,這個在做個人站點的時候都能用得到,都是常用的方法。例如:

@Aspect
@Component
public class LogAspectHandler {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * 在上面定義的切面方法之前執行該方法
     * @param joinPoint jointPoint
     */
    @Before("pointCut()")
    public void doBefore(JoinPoint joinPoint) {
        logger.info("====doBefore方法進入了====");

        // 獲取簽名
        Signature signature = joinPoint.getSignature();
        // 獲取切入的包名
        String declaringTypeName = signature.getDeclaringTypeName();
        // 獲取即將執行的方法名
        String funcName = signature.getName();
        logger.info("即將執行方法爲: {},屬於{}包", funcName, declaringTypeName);
        
        // 也可以用來記錄一些信息,比如獲取請求的url和ip
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        // 獲取請求url
        String url = request.getRequestURL().toString();
        // 獲取請求ip
        String ip = request.getRemoteAddr();
        logger.info("用戶請求的url爲:{},ip地址爲:{}", url, ip);
    }
}

JointPoint 對象很有用,可以用它來獲取一個簽名,然後利用簽名可以獲取請求的包名、方法名,包括參數(通過 joinPoint.getArgs() 獲取)等等。

2.2.3 @After 註解

@After 註解和 @Before 註解相對應,指定的方法在切面切入目標方法之後執行,也可以做一些完成某方法之後的 log 處理。

@Aspect
@Component
public class LogAspectHandler {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * 定義一個切面,攔截com.itcodai.course09.controller包下的所有方法
     */
    @Pointcut("execution(* com.itcodai.course09.controller..*.*(..))")
    public void pointCut() {}

    /**
     * 在上面定義的切面方法之後執行該方法
     * @param joinPoint jointPoint
     */
    @After("pointCut()")
    public void doAfter(JoinPoint joinPoint) {

        logger.info("====doAfter方法進入了====");
        Signature signature = joinPoint.getSignature();
        String method = signature.getName();
        logger.info("方法{}已經執行完", method);
    }
}

到這裏,我們來寫一個 Controller 來測試一下執行結果,新建一個 AopController 如下:

@RestController
@RequestMapping("/aop")
public class AopController {

    @GetMapping("/{name}")
    public String testAop(@PathVariable String name) {
        return "Hello " + name;
    }
}

啓動項目,在瀏覽器中輸入 localhost:8080/aop/CSDN,觀察一下控制檯的輸出信息:

====doBefore方法進入了====  
即將執行方法爲: testAop,屬於com.itcodai.course09.controller.AopController包  
用戶請求的url爲:http://localhost:8080/aop/name,ip地址爲:0:0:0:0:0:0:0:1  
====doAfter方法進入了====  
方法testAop已經執行完

從打印出來的 log 中可以看出程序執行的邏輯與順序,可以很直觀的掌握 @Before@After 兩個註解的實際作用。

2.2.4 @AfterReturning 註解

@AfterReturning 註解和 @After 有些類似,區別在於 @AfterReturning 註解可以用來捕獲切入方法執行完之後的返回值,對返回值進行業務邏輯上的增強處理,例如:

@Aspect
@Component
public class LogAspectHandler {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * 在上面定義的切面方法返回後執行該方法,可以捕獲返回對象或者對返回對象進行增強
     * @param joinPoint joinPoint
     * @param result result
     */
    @AfterReturning(pointcut = "pointCut()", returning = "result")
    public void doAfterReturning(JoinPoint joinPoint, Object result) {

        Signature signature = joinPoint.getSignature();
        String classMethod = signature.getName();
        logger.info("方法{}執行完畢,返回參數爲:{}", classMethod, result);
        // 實際項目中可以根據業務做具體的返回值增強
        logger.info("對返回參數進行業務上的增強:{}", result + "增強版");
    }
}

需要注意的是:在 @AfterReturning註解 中,屬性 returning 的值必須要和參數保持一致,否則會檢測不到。該方法中的第二個入參就是被切方法的返回值,在 doAfterReturning 方法中可以對返回值進行增強,可以根據業務需要做相應的封裝。我們重啓一下服務,再測試一下(多餘的 log 我不貼出來了):

方法testAop執行完畢,返回參數爲:Hello CSDN  
對返回參數進行業務上的增強:Hello CSDN增強版

2.2.5 @AfterThrowing 註解

顧名思義,@AfterThrowing 註解是當被切方法執行時拋出異常時,會進入 @AfterThrowing 註解的方法中執行,在該方法中可以做一些異常的處理邏輯。要注意的是 throwing 屬性的值必須要和參數一致,否則會報錯。該方法中的第二個入參即爲拋出的異常。

/**
 * 使用AOP處理log
 * @author shengwu ni
 * @date 2018/05/04 20:24
 */
@Aspect
@Component
public class LogAspectHandler {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * 在上面定義的切面方法執行拋異常時,執行該方法
     * @param joinPoint jointPoint
     * @param ex ex
     */
    @AfterThrowing(pointcut = "pointCut()", throwing = "ex")
    public void afterThrowing(JoinPoint joinPoint, Throwable ex) {
        Signature signature = joinPoint.getSignature();
        String method = signature.getName();
        // 處理異常的邏輯
        logger.info("執行方法{}出錯,異常爲:{}", method, ex);
    }
}

該方法我就不測試了,大家可以自行測試一下。

3. 總結

本節課針對 Spring Boot 中的切面 AOP 做了詳細的講解,主要介紹了 Spring Boot 中 AOP 的引入,常用註解的使用,參數的使用,以及常用 api 的介紹。AOP 在實際項目中很有用,對切面方法執行前後都可以根據具體的業務,做相應的預處理或者增強處理,同時也可以用作異常捕獲處理,可以根據具體業務場景,合理去使用 AOP。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第10課:Spring Boot集成MyBatis

1. MyBatis 介紹

大家都知道,MyBatis 框架是一個持久層框架,是 Apache 下的頂級項目。Mybatis 可以讓開發者的主要精力放在 sql 上,通過 Mybatis 提供的映射方式,自由靈活的生成滿足需要的 sql 語句。使用簡單的 XML 或註解來配置和映射原生信息,將接口和 Java 的 POJOs 映射成數據庫中的記錄,在國內可謂是佔據了半壁江山。本節課程主要通過兩種方式來對 Spring Boot 集成 MyBatis 做一講解。重點講解一下基於註解的方式。因爲實際項目中使用註解的方式更多一點,更簡潔一點,省去了很多 xml 配置(這不是絕對的,有些項目組中可能也在使用 xml 的方式)。

2. MyBatis 的配置

2.1 依賴導入

Spring Boot 集成 MyBatis,需要導入 mybatis-spring-boot-starter 和 mysql 的依賴,這裏我們使用的版本時 1.3.2,如下:

<dependency>
	<groupId>org.mybatis.spring.boot</groupId>
	<artifactId>mybatis-spring-boot-starter</artifactId>
	<version>1.3.2</version>
</dependency>
<dependency>
	<groupId>mysql</groupId>
	<artifactId>mysql-connector-java</artifactId>
	<scope>runtime</scope>
</dependency>

我們點開 mybatis-spring-boot-starter 依賴,可以看到我們之前使用 Spring 時候熟悉的依賴,就像我在課程的一開始介紹的那樣,Spring Boot 致力於簡化編碼,使用 starter 系列將相關依賴集成在一起,開發者不需要關注繁瑣的配置,非常方便。

<!-- 省去其他 -->
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis-spring</artifactId>
</dependency>

2.2 properties.yml配置

我們再來看一下,集成 MyBatis 時需要在 properties.yml 配置文件中做哪些基本配置呢?

# 服務端口號
server:
  port: 8080

# 數據庫地址
datasource:
  url: localhost:3306/blog_test

spring:
  datasource: # 數據庫配置
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://${datasource.url}?useSSL=false&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&autoReconnect=true&failOverReadOnly=false&maxReconnects=10
    username: root
    password: 123456
    hikari:
      maximum-pool-size: 10 # 最大連接池數
      max-lifetime: 1770000

mybatis:
  # 指定別名設置的包爲所有entity
  type-aliases-package: com.itcodai.course10.entity
  configuration:
    map-underscore-to-camel-case: true # 駝峯命名規範
  mapper-locations: # mapper映射文件位置
    - classpath:mapper/*.xml

我們來簡單介紹一下上面的這些配置:關於數據庫的相關配置,我就不詳細的解說了,這點相信大家已經非常熟練了,配置一下用戶名、密碼、數據庫連接等等,這裏使用的連接池是 Spring Boot 自帶的 hikari,感興趣的朋友可以去百度或者谷歌搜一搜,瞭解一下。

這裏說明一下 map-underscore-to-camel-case: true, 用來開啓駝峯命名規範,這個比較好用,比如數據庫中字段名爲:user_name, 那麼在實體類中可以定義屬性爲 userName (甚至可以寫成 username,也能映射上),會自動匹配到駝峯屬性,如果不這樣配置的話,針對字段名和屬性名不同的情況,會映射不到。

3. 基於 xml 的整合

使用原始的 xml 方式,需要新建 UserMapper.xml 文件,在上面的 application.yml 配置文件中,我們已經定義了 xml 文件的路徑:classpath:mapper/*.xml,所以我們在 resources 目錄下新建一個 mapper 文件夾,然後創建一個 UserMapper.xml 文件。

<?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="com.itcodai.course10.dao.UserMapper">
  <resultMap id="BaseResultMap" type="com.itcodai.course10.entity.User">

    <id column="id" jdbcType="BIGINT" property="id" />
    <result column="user_name" jdbcType="VARCHAR" property="username" />
    <result column="password" jdbcType="VARCHAR" property="password" />
  </resultMap>
  
   <select id="getUserByName" resultType="User" parameterType="String">
       select * from user where user_name = #{username}
  </select>
</mapper>

這和整合 Spring 一樣的,namespace 中指定的是對應的 Mapper, <resultMap> 中指定對應的實體類,即 User。然後在內部指定表的字段和實體的屬性相對應即可。這裏我們寫一個根據用戶名查詢用戶的 sql。

實體類中有 id,username 和 password,我不在這貼代碼,大家可以下載源碼查看。UserMapper.java 文件中寫一個接口即可:

User getUserByName(String username);

中間省略 service 的代碼,我們寫一個 Controller 來測試一下:

@RestController
public class TestController {

    @Resource
    private UserService userService;
    
    @RequestMapping("/getUserByName/{name}")
    public User getUserByName(@PathVariable String name) {
        return userService.getUserByName(name);
    }
}

啓動項目,在瀏覽器中輸入:http://localhost:8080/getUserByName/CSDN 即可查詢到數據庫表中用戶名爲 CSDN 的用戶信息(事先搞兩個數據進去即可):

{"id":2,"username":"CSDN","password":"123456"}

這裏需要注意一下:Spring Boot 如何知道這個 Mapper 呢?一種方法是在上面的 mapper 層對應的類上面添加 @Mapper 註解即可,但是這種方法有個弊端,當我們有很多個 mapper 時,那麼每一個類上面都得添加 @Mapper 註解。另一種比較簡便的方法是在 Spring Boot 啓動類上添加@MaperScan 註解,來掃描一個包下的所有 mapper。如下:

@SpringBootApplication
@MapperScan("com.itcodai.course10.dao")
public class Course10Application {

	public static void main(String[] args) {
		SpringApplication.run(Course10Application.class, args);
	}
}

這樣的話,com.itcodai.course10.dao 包下的所有 mapper 都會被掃描到了。

4. 基於註解的整合

基於註解的整合就不需要 xml 配置文件了,MyBatis 主要提供了 @Select@Insert@UpdateDelete 四個註解。這四個註解是用的非常多的,也很簡單,註解後面跟上對應的 sql 語句即可,我們舉個例子:

@Select("select * from user where id = #{id}")
User getUser(Long id);

這跟 xml 文件中寫 sql 語句是一樣的,這樣就不需要 xml 文件了,但是有個問題,有人可能會問,如果是兩個參數呢?如果是兩個參數,我們需要使用 @Param 註解來指定每一個參數的對應關係,如下:

@Select("select * from user where id = #{id} and user_name=#{name}")
User getUserByIdAndName(@Param("id") Long id, @Param("name") String username);

可以看出,@Param 指定的參數應該要和 sql 中 #{} 取的參數名相同,不同則取不到。可以在 controller 中自行測試一下,接口都在源碼中,文章中我就不貼測試代碼和結果了。

有個問題需要注意一下,一般我們在設計表字段後,都會根據自動生成工具生成實體類,這樣的話,基本上實體類是能和表字段對應上的,最起碼也是駝峯對應的,由於在上面配置文件中開啓了駝峯的配置,所以字段都是能對的上的。但是,萬一有對不上的呢?我們也有解決辦法,使用 @Results 註解來解決。

@Select("select * from user where id = #{id}")
@Results({
        @Result(property = "username", column = "user_name"),
        @Result(property = "password", column = "password")
})
User getUser(Long id);

@Results 中的 @Result 註解是用來指定每一個屬性和字段的對應關係,這樣的話就可以解決上面說的這個問題了。

當然了,我們也可以 xml 和註解相結合使用,目前我們實際的項目中也是採用混用的方式,因爲有時候 xml 方便,有時候註解方便,比如就上面這個問題來說,如果我們定義了上面的這個 UserMapper.xml,那麼我們完全可以使用 @ResultMap 註解來替代 @Results 註解,如下:

@Select("select * from user where id = #{id}")
@ResultMap("BaseResultMap")
User getUser(Long id);

@ResultMap 註解中的值從哪來呢?對應的是 UserMapper.xml 文件中定義的 <resultMap> 時對應的 id 值:

<resultMap id="BaseResultMap" type="com.itcodai.course10.entity.User">

這種 xml 和註解結合着使用的情況也很常見,而且也減少了大量的代碼,因爲 xml 文件可以使用自動生成工具去生成,也不需要人爲手動敲,所以這種使用方式也很常見。

5. 總結

本節課主要系統的講解了 Spring Boot 集成 MyBatis 的過程,分爲基於 xml 形式和基於註解的形式來講解,通過實際配置手把手講解了 Spring Boot 中 MyBatis 的使用方式,並針對註解方式,講解了常見的問題已經解決方式,有很強的實戰意義。在實際項目中,建議根據實際情況來確定使用哪種方式,一般 xml 和註解都在用。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第11課:Spring Boot事務配置管理

1. 事務相關

場景:我們在開發企業應用時,由於數據操作在順序執行的過程中,線上可能有各種無法預知的問題,任何一步操作都有可能發生異常,異常則會導致後續的操作無法完成。此時由於業務邏輯並未正確的完成,所以在之前操作過數據庫的動作並不可靠,需要在這種情況下進行數據的回滾。

事務的作用就是爲了保證用戶的每一個操作都是可靠的,事務中的每一步操作都必須成功執行,只要有發生異常就回退到事務開始未進行操作的狀態。這很好理解,轉賬、購票等等,必須整個事件流程全部執行完才能人爲該事件執行成功,不能轉錢轉到一半,系統死了,轉賬人錢沒了,收款人錢還沒到。

事務管理是 Spring Boot 框架中最爲常用的功能之一,我們在實際應用開發時,基本上在 service 層處理業務邏輯的時候都要加上事務,當然了,有時候可能由於場景需要,也不用加事務(比如我們就要往一個表裏插數據,相互沒有影響,插多少是多少,不能因爲某個數據掛了,把之前插的全部回滾)。

2. Spring Boot 事務配置

2.1 依賴導入

在 Spring Boot 中使用事務,需要導入 mysql 依賴:

<dependency>
	<groupId>org.mybatis.spring.boot</groupId>
	<artifactId>mybatis-spring-boot-starter</artifactId>
	<version>1.3.2</version>
</dependency>

導入了 mysql 依賴後,Spring Boot 會自動注入 DataSourceTransactionManager,我們不需要任何其他的配置就可以用 @Transactional 註解進行事務的使用。關於 mybatis 的配置,在上一節課中已經說明了,這裏還是使用上一節課中的 mybatis 配置即可。

2.2 事務的測試

我們首先在數據庫表中插入一條數據:

id user_name password
1 倪升武 123456

然後我們寫一個插入的 mapper:

public interface UserMapper {

    @Insert("insert into user (user_name, password) values (#{username}, #{password})")
    Integer insertUser(User user);
}

OK,接下來我們來測試一下 Spring Boot 中的事務處理,在 service 層,我們手動拋出個異常來模擬實際中出現的異常,然後觀察一下事務有沒有回滾,如果數據庫中沒有新的記錄,則說明事務回滾成功。

@Service
public class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;

    @Override
    @Transactional
    public void isertUser(User user) {
        // 插入用戶信息
        userMapper.insertUser(user);
        // 手動拋出異常
        throw new RuntimeException();
    }
}

我們來測試一下:

@RestController
public class TestController {

    @Resource
    private UserService userService;

    @PostMapping("/adduser")
    public String addUser(@RequestBody User user) throws Exception {
        if (null != user) {
            userService.isertUser(user);
            return "success";
        } else {
            return "false";
        }
    }
}

我們使用 postman 調用一下該接口,因爲在程序中拋出了個異常,會造成事務回滾,我們刷新一下數據庫,並沒有增加一條記錄,說明事務生效了。事務很簡單,我們平時在使用的時候,一般不會有多少問題,但是並不僅僅如此……

3. 常見問題總結

從上面的內容中可以看出,Spring Boot 中使用事務非常簡單,@Transactional 註解即可解決問題,說是這麼說,但是在實際項目中,是有很多小坑在等着我們,這些小坑是我們在寫代碼的時候沒有注意到,而且正常情況下不容易發現這些小坑,等項目寫大了,某一天突然出問題了,排查問題非常困難,到時候肯定是抓瞎,需要費很大的精力去排查問題。

這一小節,我專門針對實際項目中經常出現的,和事務相關的細節做一下總結,希望讀者在讀完之後,能夠落實到自己的項目中,能有所受益。

3.1 異常並沒有被 ”捕獲“ 到

首先要說的,就是異常並沒有被 ”捕獲“ 到,導致事務並沒有回滾。我們在業務層代碼中,也許已經考慮到了異常的存在,或者編輯器已經提示我們需要拋出異常,但是這裏面有個需要注意的地方:並不是說我們把異常拋出來了,有異常了事務就會回滾,我們來看一個例子:

@Service
public class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;
    
    @Override
    @Transactional
    public void isertUser2(User user) throws Exception {
        // 插入用戶信息
        userMapper.insertUser(user);
        // 手動拋出異常
        throw new SQLException("數據庫異常");
    }
}

我們看上面這個代碼,其實並沒有什麼問題,手動拋出一個 SQLException 來模擬實際中操作數據庫發生的異常,在這個方法中,既然拋出了異常,那麼事務應該回滾,實際卻不如此,讀者可以使用我源碼中 controller 的接口,通過 postman 測試一下,就會發現,仍然是可以插入一條用戶數據的。

那麼問題出在哪呢?因爲 Spring Boot 默認的事務規則是遇到運行異常(RuntimeException)和程序錯誤(Error)纔會回滾。比如上面我們的例子中拋出的 RuntimeException 就沒有問題,但是拋出 SQLException 就無法回滾了。針對非運行時異常,如果要進行事務回滾的話,可以在 @Transactional 註解中使用 rollbackFor 屬性來指定異常,比如 @Transactional(rollbackFor = Exception.class),這樣就沒有問題了,所以在實際項目中,一定要指定異常。

3.2 異常被 ”吃“ 掉

這個標題很搞笑,異常怎麼會被吃掉呢?還是迴歸到現實項目中去,我們在處理異常時,有兩種方式,要麼拋出去,讓上一層來捕獲處理;要麼把異常 try catch 掉,在異常出現的地方給處理掉。就因爲有這中 try…catch,所以導致異常被 ”吃“ 掉,事務無法回滾。我們還是看上面那個例子,只不過簡單修改一下代碼:

@Service
public class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void isertUser3(User user) {
        try {
            // 插入用戶信息
            userMapper.insertUser(user);
            // 手動拋出異常
            throw new SQLException("數據庫異常");
        } catch (Exception e) {
			// 異常處理邏輯
        }
    }
}

讀者可以使用我源碼中 controller 的接口,通過 postman 測試一下,就會發現,仍然是可以插入一條用戶數據,說明事務並沒有因爲拋出異常而回滾。這個細節往往比上面那個坑更難以發現,因爲我們的思維很容易導致 try…catch 代碼的產生,一旦出現這種問題,往往排查起來比較費勁,所以我們平時在寫代碼時,一定要多思考,多注意這種細節,儘量避免給自己埋坑。

那這種怎麼解決呢?直接往上拋,給上一層來處理即可,千萬不要在事務中把異常自己 ”吃“ 掉。

3.3 事務的範圍

事務範圍這個東西比上面兩個坑埋的更深!我之所以把這個也寫上,是因爲這是我之前在實際項目中遇到的,該場景在這個課程中我就不模擬了,我寫一個 demo 讓大家看一下,把這個坑記住即可,以後在寫代碼時,遇到併發問題,就會注意這個坑了,那麼這節課也就有價值了。

我來寫個 demo:

@Service
public class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public synchronized void isertUser4(User user) {
        // 實際中的具體業務……
        userMapper.insertUser(user);
    }
}

可以看到,因爲要考慮併發問題,我在業務層代碼的方法上加了個 synchronized 關鍵字。我舉個實際的場景,比如一個數據庫中,針對某個用戶,只有一條記錄,下一個插入動作過來,會先判斷該數據庫中有沒有相同的用戶,如果有就不插入,就更新,沒有才插入,所以理論上,數據庫中永遠就一條同一用戶信息,不會出現同一數據庫中插入了兩條相同用戶的信息。

但是在壓測時,就會出現上面的問題,數據庫中確實有兩條同一用戶的信息,分析其原因,在於事務的範圍和鎖的範圍問題。

從上面方法中可以看到,方法上是加了事務的,那麼也就是說,在執行該方法開始時,事務啓動,執行完了後,事務關閉。但是 synchronized 沒有起作用,其實根本原因是因爲事務的範圍比鎖的範圍大。也就是說,在加鎖的那部分代碼執行完之後,鎖釋放掉了,但是事務還沒結束,此時另一個線程進來了,事務沒結束的話,第二個線程進來時,數據庫的狀態和第一個線程剛進來是一樣的。即由於mysql Innodb引擎的默認隔離級別是可重複讀(在同一個事務裏,SELECT的結果是事務開始時時間點的狀態),線程二事務開始的時候,線程一還沒提交完成,導致讀取的數據還沒更新。第二個線程也做了插入動作,導致了髒數據。

這個問題可以避免,第一,把事務去掉即可(不推薦);第二,在調用該 service 的地方加鎖,保證鎖的範圍比事務的範圍大即可。

4. 總結

本章主要總結了 Spring Boot 中如何使用事務,只要使用 @Transactional 註解即可使用,非常簡單方便。除此之外,重點總結了三個在實際項目中可能遇到的坑點,這非常有意義,因爲事務這東西不出問題還好,出了問題比較難以排查,所以總結的這三點注意事項,希望能幫助到開發中的朋友。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第12課:Spring Boot中使用監聽器

1. 監聽器介紹

什麼是 web 監聽器?web 監聽器是一種 Servlet 中特殊的類,它們能幫助開發者監聽 web 中特定的事件,比如 ServletContext, HttpSession, ServletRequest 的創建和銷燬;變量的創建、銷燬和修改等。可以在某些動作前後增加處理,實現監控。

2. Spring Boot中監聽器的使用

web 監聽器的使用場景很多,比如監聽 servlet 上下文用來初始化一些數據、監聽 http session 用來獲取當前在線的人數、監聽客戶端請求的 servlet request 對象來獲取用戶的訪問信息等等。這一節中,我們主要通過這三個實際的使用場景來學習一下 Spring Boot 中監聽器的使用。

2.1 監聽Servlet上下文對象

監聽 servlet 上下文對象可以用來初始化數據,用於緩存。什麼意思呢?我舉一個很常見的場景,比如用戶在點擊某個站點的首頁時,一般都會展現出首頁的一些信息,而這些信息基本上或者大部分時間都保持不變的,但是這些信息都是來自數據庫。如果用戶的每次點擊,都要從數據庫中去獲取數據的話,用戶量少還可以接受,如果用戶量非常大的話,這對數據庫也是一筆很大的開銷。

針對這種首頁數據,大部分都不常更新的話,我們完全可以把它們緩存起來,每次用戶點擊的時候,我們都直接從緩存中拿,這樣既可以提高首頁的訪問速度,又可以降低服務器的壓力。如果做的更加靈活一點,可以再加個定時器,定期的來更新這個首頁緩存。就類似與 CSDN 個人博客首頁中排名的變化一樣。

下面我們針對這個功能,來寫一個 demo,在實際中,讀者可以完全套用該代碼,來實現自己項目中的相關邏輯。首先寫一個 Service,模擬一下從數據庫查詢數據:

@Service
public class UserService {

    /**
     * 獲取用戶信息
     * @return
     */
    public User getUser() {
        // 實際中會根據具體的業務場景,從數據庫中查詢對應的信息
        return new User(1L, "倪升武", "123456");
    }
}

然後寫一個監聽器,實現 ApplicationListener<ContextRefreshedEvent> 接口,重寫 onApplicationEvent 方法,將 ContextRefreshedEvent 對象傳進去。如果我們想在加載或刷新應用上下文時,也重新刷新下我們預加載的資源,就可以通過監聽 ContextRefreshedEvent 來做這樣的事情。如下:

/**
 * 使用ApplicationListener來初始化一些數據到application域中的監聽器
 * @author shengni ni
 * @date 2018/07/05
 */
@Component
public class MyServletContextListener implements ApplicationListener<ContextRefreshedEvent> {

    @Override
    public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
        // 先獲取到application上下文
        ApplicationContext applicationContext = contextRefreshedEvent.getApplicationContext();
        // 獲取對應的service
        UserService userService = applicationContext.getBean(UserService.class);
        User user = userService.getUser();
        // 獲取application域對象,將查到的信息放到application域中
        ServletContext application = applicationContext.getBean(ServletContext.class);
        application.setAttribute("user", user);
    }
}

正如註釋中描述的一樣,首先通過 contextRefreshedEvent 來獲取 application 上下文,再通過 application 上下文來獲取 UserService 這個 bean,項目中可以根據實際業務場景,也可以獲取其他的 bean,然後再調用自己的業務代碼獲取相應的數據,最後存儲到 application 域中,這樣前端在請求相應數據的時候,我們就可以直接從 application 域中獲取信息,減少數據庫的壓力。下面寫一個 Controller 直接從 application 域中獲取 user 信息來測試一下。

@RestController
@RequestMapping("/listener")
public class TestController {

    @GetMapping("/user")
    public User getUser(HttpServletRequest request) {
        ServletContext application = request.getServletContext();
        return (User) application.getAttribute("user");
    }
}

啓動項目,在瀏覽器中輸入 http://localhost:8080/listener/user 測試一下即可,如果正常返回 user 信息,那麼說明數據已經緩存成功。不過 application 這種是緩存在內存中,對內存會有消耗,後面的課程中我會講到 redis,到時候再給大家介紹一下 redis 的緩存。

2.2 監聽HTTP會話 Session對象

監聽器還有一個比較常用的地方就是用來監聽 session 對象,來獲取在線用戶數量,現在有很多開發者都有自己的網站,監聽 session 來獲取當前在下用戶數量是個很常見的使用場景,下面來介紹一下如何來使用。

/**
 * 使用HttpSessionListener統計在線用戶數的監聽器
 * @author shengwu ni
 * @date 2018/07/05
 */
@Component
public class MyHttpSessionListener implements HttpSessionListener {

    private static final Logger logger = LoggerFactory.getLogger(MyHttpSessionListener.class);

    /**
     * 記錄在線的用戶數量
     */
    public Integer count = 0;

    @Override
    public synchronized void sessionCreated(HttpSessionEvent httpSessionEvent) {
        logger.info("新用戶上線了");
        count++;
        httpSessionEvent.getSession().getServletContext().setAttribute("count", count);
    }

    @Override
    public synchronized void sessionDestroyed(HttpSessionEvent httpSessionEvent) {
        logger.info("用戶下線了");
        count--;
        httpSessionEvent.getSession().getServletContext().setAttribute("count", count);
    }
}

可以看出,首先該監聽器需要實現 HttpSessionListener 接口,然後重寫 sessionCreatedsessionDestroyed 方法,在 sessionCreated 方法中傳遞一個 HttpSessionEvent 對象,然後將當前 session 中的用戶數量加1,sessionDestroyed 方法剛好相反,不再贅述。然後我們寫一個 Controller 來測試一下。

@RestController
@RequestMapping("/listener")
public class TestController {

    /**
     * 獲取當前在線人數,該方法有bug
     * @param request
     * @return
     */
    @GetMapping("/total")
    public String getTotalUser(HttpServletRequest request) {
        Integer count = (Integer) request.getSession().getServletContext().getAttribute("count");
        return "當前在線人數:" + count;
    }
}

該 Controller 中是直接獲取當前 session 中的用戶數量,啓動服務器,在瀏覽器中輸入 localhost:8080/listener/total 可以看到返回的結果是1,再打開一個瀏覽器,請求相同的地址可以看到 count 是 2 ,這沒有問題。但是如果關閉一個瀏覽器再打開,理論上應該還是2,但是實際測試卻是 3。原因是 session 銷燬的方法沒有執行(可以在後臺控制檯觀察日誌打印情況),當重新打開時,服務器找不到用戶原來的 session,於是又重新創建了一個 session,那怎麼解決該問題呢?我們可以將上面的 Controller 方法改造一下:

@GetMapping("/total2")
public String getTotalUser(HttpServletRequest request, HttpServletResponse response) {
    Cookie cookie;
    try {
        // 把sessionId記錄在瀏覽器中
        cookie = new Cookie("JSESSIONID", URLEncoder.encode(request.getSession().getId(), "utf-8"));
        cookie.setPath("/");
        //設置cookie有效期爲2天,設置長一點
        cookie.setMaxAge( 48*60 * 60);
        response.addCookie(cookie);
    } catch (UnsupportedEncodingException e) {
        e.printStackTrace();
    }
    Integer count = (Integer) request.getSession().getServletContext().getAttribute("count");
    return "當前在線人數:" + count;
}

可以看出,該處理邏輯是讓服務器記得原來那個 session,即把原來的 sessionId 記錄在瀏覽器中,下次再打開時,把這個 sessionId 傳過去,這樣服務器就不會重新再創建了。重啓一下服務器,在瀏覽器中再次測試一下,即可避免上面的問題。

2.3 監聽客戶端請求Servlet Request對象

使用監聽器獲取用戶的訪問信息比較簡單,實現 ServletRequestListener 接口即可,然後通過 request 對象獲取一些信息。如下:

/**
 * 使用ServletRequestListener獲取訪問信息
 * @author shengwu ni
 * @date 2018/07/05
 */
@Component
public class MyServletRequestListener implements ServletRequestListener {

    private static final Logger logger = LoggerFactory.getLogger(MyServletRequestListener.class);

    @Override
    public void requestInitialized(ServletRequestEvent servletRequestEvent) {
        HttpServletRequest request = (HttpServletRequest) servletRequestEvent.getServletRequest();
        logger.info("session id爲:{}", request.getRequestedSessionId());
        logger.info("request url爲:{}", request.getRequestURL());

        request.setAttribute("name", "倪升武");
    }

    @Override
    public void requestDestroyed(ServletRequestEvent servletRequestEvent) {

        logger.info("request end");
        HttpServletRequest request = (HttpServletRequest) servletRequestEvent.getServletRequest();
        logger.info("request域中保存的name值爲:{}", request.getAttribute("name"));

    }

}

這個比較簡單,不再贅述,接下來寫一個 Controller 測試一下即可。

@GetMapping("/request")
public String getRequestInfo(HttpServletRequest request) {
    System.out.println("requestListener中的初始化的name數據:" + request.getAttribute("name"));
    return "success";
}

3. Spring Boot中自定義事件監聽

在實際項目中,我們往往需要自定義一些事件和監聽器來滿足業務場景,比如在微服務中會有這樣的場景:微服務 A 在處理完某個邏輯之後,需要通知微服務 B 去處理另一個邏輯,或者微服務 A 處理完某個邏輯之後,需要將數據同步到微服務 B,這種場景非常普遍,這個時候,我們可以自定義事件以及監聽器來監聽,一旦監聽到微服務 A 中的某事件發生,就去通知微服務 B 處理對應的邏輯。

3.1 自定義事件

自定義事件需要繼承 ApplicationEvent 對象,在事件中定義一個 User 對象來模擬數據,構造方法中將 User 對象傳進來初始化。如下:

/**
 * 自定義事件
 * @author shengwu ni
 * @date 2018/07/05
 */
public class MyEvent extends ApplicationEvent {

    private User user;

    public MyEvent(Object source, User user) {
        super(source);
        this.user = user;
    }

    // 省去get、set方法
}

3.2 自定義監聽器

接下來,自定義一個監聽器來監聽上面定義的 MyEvent 事件,自定義監聽器需要實現 ApplicationListener 接口即可。如下:

/**
 * 自定義監聽器,監聽MyEvent事件
 * @author shengwu ni
 * @date 2018/07/05
 */
@Component
public class MyEventListener implements ApplicationListener<MyEvent> {
    @Override
    public void onApplicationEvent(MyEvent myEvent) {
        // 把事件中的信息獲取到
        User user = myEvent.getUser();
        // 處理事件,實際項目中可以通知別的微服務或者處理其他邏輯等等
        System.out.println("用戶名:" + user.getUsername());
        System.out.println("密碼:" + user.getPassword());

    }
}

然後重寫 onApplicationEvent 方法,將自定義的 MyEvent 事件傳進來,因爲該事件中,我們定義了 User 對象(該對象在實際中就是需要處理的數據,在下文來模擬),然後就可以使用該對象的信息了。

OK,定義好了事件和監聽器之後,需要手動發佈事件,這樣監聽器才能監聽到,這需要根據實際業務場景來觸發,針對本文的例子,我寫個觸發邏輯,如下:

/**
 * UserService
 * @author shengwu ni
 */
@Service
public class UserService {

    @Resource
    private ApplicationContext applicationContext;

    /**
     * 發佈事件
     * @return
     */
    public User getUser2() {
        User user = new User(1L, "倪升武", "123456");
        // 發佈事件
        MyEvent event = new MyEvent(this, user);
        applicationContext.publishEvent(event);
        return user;
    }
}

在 service 中注入 ApplicationContext,在業務代碼處理完之後,通過 ApplicationContext 對象手動發佈 MyEvent 事件,這樣我們自定義的監聽器就能監聽到,然後處理監聽器中寫好的業務邏輯。

最後,在 Controller 中寫一個接口來測試一下:

@GetMapping("/request")
public String getRequestInfo(HttpServletRequest request) {
    System.out.println("requestListener中的初始化的name數據:" + request.getAttribute("name"));
    return "success";
}

在瀏覽器中輸入 http://localhost:8080/listener/publish,然後觀察一下控制檯打印的用戶名和密碼,即可說明自定義監聽器已經生效。

4. 總結

本課系統的介紹了監聽器原理,以及在 Spring Boot 中如何使用監聽器,列舉了監聽器的三個常用的案例,有很好的實戰意義。最後講解了項目中如何自定義事件和監聽器,並結合微服務中常見的場景,給出具體的代碼模型,均能運用到實際項目中去,希望讀者認真消化。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第13課:Spring Boot中使用攔截器

攔截器的原理很簡單,是 AOP 的一種實現,專門攔截對動態資源的後臺請求,即攔截對控制層的請求。使用場景比較多的是判斷用戶是否有權限請求後臺,更拔高一層的使用場景也有,比如攔截器可以結合 websocket 一起使用,用來攔截 websocket 請求,然後做相應的處理等等。攔截器不會攔截靜態資源,Spring Boot 的默認靜態目錄爲 resources/static,該目錄下的靜態頁面、js、css、圖片等等,不會被攔截(也要看如何實現,有些情況也會攔截,我在下文會指出)。

1. 攔截器的快速使用

使用攔截器很簡單,只需要兩步即可:定義攔截器和配置攔截器。在配置攔截器中,Spring Boot 2.0 以後的版本和之前的版本有所不同,我會重點講解一下這裏可能出現的坑。

1.1 定義攔截器

定義攔截器,只需要實現 HandlerInterceptor 接口,HandlerInterceptor 接口是所有自定義攔截器或者 Spring Boot 提供的攔截器的鼻祖,所以,首先來了解下該接口。該接口中有三個方法: preHandle(……)postHandle(……)afterCompletion(……)

preHandle(……) 方法:該方法的執行時機是,當某個 url 已經匹配到對應的 Controller 中的某個方法,且在這個方法執行之前。所以 preHandle(……) 方法可以決定是否將請求放行,這是通過返回值來決定的,返回 true 則放行,返回 false 則不會向後執行。
postHandle(……) 方法:該方法的執行時機是,當某個 url 已經匹配到對應的 Controller 中的某個方法,且在執行完了該方法,但是在 DispatcherServlet 視圖渲染之前。所以在這個方法中有個 ModelAndView 參數,可以在此做一些修改動作。
afterCompletion(……) 方法:顧名思義,該方法是在整個請求處理完成後(包括視圖渲染)執行,這時做一些資源的清理工作,這個方法只有在 preHandle(……) 被成功執行後並且返回 true 纔會被執行。

瞭解了該接口,接下來自定義一個攔截器。

/**
 * 自定義攔截器
 * @author shengwu ni
 * @date 2018/08/03
 */
public class MyInterceptor implements HandlerInterceptor {

    private static final Logger logger = LoggerFactory.getLogger(MyInterceptor.class);

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        String methodName = method.getName();
        logger.info("====攔截到了方法:{},在該方法執行之前執行====", methodName);
        // 返回true纔會繼續執行,返回false則取消當前請求
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        logger.info("執行完方法之後進執行(Controller方法調用之後),但是此時還沒進行視圖渲染");
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        logger.info("整個請求都處理完咯,DispatcherServlet也渲染了對應的視圖咯,此時我可以做一些清理的工作了");
    }
}

OK,到此爲止,攔截器已經定義完成,接下來就是對該攔截器進行攔截配置。

1.2 配置攔截器

在 Spring Boot 2.0 之前,我們都是直接繼承 WebMvcConfigurerAdapter 類,然後重寫 addInterceptors 方法來實現攔截器的配置。但是在 Spring Boot 2.0 之後,該方法已經被廢棄了(當然,也可以繼續用),取而代之的是 WebMvcConfigurationSupport 方法,如下:

@Configuration
public class MyInterceptorConfig extends WebMvcConfigurationSupport {

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new MyInterceptor()).addPathPatterns("/**");
        super.addInterceptors(registry);
    }
}

在該配置中重寫 addInterceptors 方法,將我們上面自定義的攔截器添加進去,addPathPatterns 方法是添加要攔截的請求,這裏我們攔截所有的請求。這樣就配置好攔截器了,接下來寫一個 Controller 測試一下:

@Controller
@RequestMapping("/interceptor")
public class InterceptorController {

    @RequestMapping("/test")
    public String test() {
        return "hello";
    }
}

讓其跳轉到 hello.html 頁面,直接在 hello.html 中輸出 hello interceptor 即可。啓動項目,在瀏覽器中輸入 localhost:8080/interceptor/test 看一下控制檯的日誌:

====攔截到了方法:test,在該方法執行之前執行====  
執行完方法之後進執行(Controller方法調用之後),但是此時還沒進行視圖渲染  
整個請求都處理完咯,DispatcherServlet也渲染了對應的視圖咯,此時我可以做一些清理的工作了

可以看出攔截器已經生效,並能看出其執行順序。

1.3 解決靜態資源被攔截問題

上文中已經介紹了攔截器的定義和配置,但是這樣是否就沒問題了呢?其實不然,如果使用上面這種配置的話,我們會發現一個缺陷,那就是靜態資源被攔截了。可以在 resources/static/ 目錄下放置一個圖片資源或者 html 文件,然後啓動項目直接訪問,即可看到無法訪問的現象。

也就是說,雖然 Spring Boot 2.0 廢棄了WebMvcConfigurerAdapter,但是 WebMvcConfigurationSupport 又會導致默認的靜態資源被攔截,這就需要我們手動將靜態資源放開。

如何放開呢?除了在 MyInterceptorConfig 配置類中重寫 addInterceptors 方法外,還需要再重寫一個方法:addResourceHandlers,將靜態資源放開:

/**
 * 用來指定靜態資源不被攔截,否則繼承WebMvcConfigurationSupport這種方式會導致靜態資源無法直接訪問
 * @param registry
 */
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
    super.addResourceHandlers(registry);
}

這樣配置好之後,重啓項目,靜態資源也可以正常訪問了。如果你是個善於學習或者研究的人,那肯定不會止步於此,沒錯,上面這種方式的確能解決靜態資源無法訪問的問題,但是,還有更方便的方式來配置。

我們不繼承 WebMvcConfigurationSupport 類,直接實現 WebMvcConfigurer 接口,然後重寫 addInterceptors 方法,將自定義的攔截器添加進去即可,如下:

@Configuration
public class MyInterceptorConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 實現WebMvcConfigurer不會導致靜態資源被攔截
        registry.addInterceptor(new MyInterceptor()).addPathPatterns("/**");
    }
}

這樣就非常方便了,實現 WebMvcConfigure 接口的話,不會攔截 Spring Boot 默認的靜態資源。

這兩種方式都可以,具體他們之間的細節,感興趣的讀者可以做進一步的研究,由於這兩種方式的不同,繼承 WebMvcConfigurationSupport 類的方式可以用在前後端分離的項目中,後臺不需要訪問靜態資源(就不需要放開靜態資源了);實現 WebMvcConfigure 接口的方式可以用在非前後端分離的項目中,因爲需要讀取一些圖片、css、js文件等等。

2. 攔截器使用實例

2.1 判斷用戶有沒有登錄

一般用戶登錄功能我們可以這麼做,要麼往 session 中寫一個 user,要麼針對每個 user 生成一個 token,第二種要更好一點,那麼針對第二種方式,如果用戶登錄成功了,每次請求的時候都會帶上該用戶的 token,如果未登錄,則沒有該 token,服務端可以檢測這個 token 參數的有無來判斷用戶有沒有登錄,從而實現攔截功能。我們改造一下 preHandle 方法,如下:

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

    HandlerMethod handlerMethod = (HandlerMethod) handler;
    Method method = handlerMethod.getMethod();
    String methodName = method.getName();
    logger.info("====攔截到了方法:{},在該方法執行之前執行====", methodName);

    // 判斷用戶有沒有登陸,一般登陸之後的用戶都有一個對應的token
    String token = request.getParameter("token");
    if (null == token || "".equals(token)) {
        logger.info("用戶未登錄,沒有權限執行……請登錄");
        return false;
    }

    // 返回true纔會繼續執行,返回false則取消當前請求
    return true;
}

重啓項目,在瀏覽器中輸入 localhost:8080/interceptor/test 後查看控制檯日誌,發現被攔截,如果在瀏覽器中輸入 localhost:8080/interceptor/test?token=123 即可正常往下走。

2.2 取消攔截操作

根據上文,如果我要攔截所有 /admin 開頭的 url 請求的話,需要在攔截器配置中添加這個前綴,但是在實際項目中,可能會有這種場景出現:某個請求也是 /admin 開頭的,但是不能攔截,比如 /admin/login 等等,這樣的話又需要去配置。那麼,可不可以做成一個類似於開關的東西,哪裏不需要攔截,我就在哪裏弄個開關上去,做成這種靈活的可插拔的效果呢?

是可以的,我們可以定義一個註解,該註解專門用來取消攔截操作,如果某個 Controller 中的方法我們不需要攔截掉,即可在該方法上加上我們自定義的註解即可,下面先定義一個註解:

/**
 * 該註解用來指定某個方法不用攔截
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UnInterception {
}

然後在 Controller 中的某個方法上添加該註解,在攔截器處理方法中添加該註解取消攔截的邏輯,如下:

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

    HandlerMethod handlerMethod = (HandlerMethod) handler;
    Method method = handlerMethod.getMethod();
    String methodName = method.getName();
    logger.info("====攔截到了方法:{},在該方法執行之前執行====", methodName);

    // 通過方法,可以獲取該方法上的自定義註解,然後通過註解來判斷該方法是否要被攔截
    // @UnInterception 是我們自定義的註解
    UnInterception unInterception = method.getAnnotation(UnInterception.class);
    if (null != unInterception) {
        return true;
    }
    // 返回true纔會繼續執行,返回false則取消當前請求
    return true;
}

Controller 中的方法代碼可以參見源碼,重啓項目在瀏覽器中輸入 http://localhost:8080/interceptor/test2?token=123 測試一下,可以看出,加了該註解的方法不會被攔截。

3. 總結

本節主要介紹了 Spring Boot 中攔截器的使用,從攔截器的創建、配置,到攔截器對靜態資源的影響,都做了詳細的分析。Spring Boot 2.0 之後攔截器的配置支持兩種方式,可以根據實際情況選擇不同的配置方式。最後結合實際中的使用,舉了兩個常用的場景,希望讀者能夠認真消化,掌握攔截器的使用。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第14課:Spring Boot 中集成Redis

1. Redis 介紹

Redis 是一種非關係型數據庫(NoSQL),NoSQL 是以 key-value 的形式存儲的,和傳統的關係型數據庫不一樣,不一定遵循傳統數據庫的一些基本要求,比如說 SQL 標準,ACID 屬性,表結構等等,這類數據庫主要有以下特點:非關係型的、分佈式的、開源的、水平可擴展的。
NoSQL 使用場景有:對數據高併發讀寫、對海量數據的高效率存儲和訪問、對數據的高可擴展性和高可用性等等。
Redis 的 key 可以是字符串、哈希、鏈表、集合和有序集合。value 類型很多,包括 String、list、set、zset。這些數據類型都支持 push/pop、add/remove、取交集和並集以及更多更豐富的操作,Redis 也支持各種不同方式的排序。爲了保證效率,數據都是在緩存在內存中,它也可以週期性的把更新的數據寫入磁盤或者把修改操作寫入追加的記錄文件中。 有了 redis 有哪些好處呢?舉個比較簡單的例子,看下圖:

Redis使用場景

Redis 集羣和 Mysql 是同步的,首先會從 redis 中獲取數據,如果 redis 掛了,再從 mysql 中獲取數據,這樣網站就不會掛掉。更多關於 redis 的介紹以及使用場景,可以谷歌和百度,在這就不贅述了。

2. Redis 安裝

本課程是在 vmvare 虛擬機中來安裝的 redis (centos 7),學習的時候如果有自己的阿里雲服務器,也可以在阿里雲中來安裝 redis,都可以。只要能 ping 的通雲主機或者虛擬機的 ip,然後在虛擬機或者雲主機中放行對應的端口(或者關掉防火牆)即可訪問 redis。下面來介紹一下 redis 的安裝過程:

  • 安裝 gcc 編譯

因爲後面安裝redis的時候需要編譯,所以事先得先安裝gcc編譯。阿里雲主機已經默認安裝了 gcc,如果是自己安裝的虛擬機,那麼需要先安裝一下 gcc:

yum install gcc-c++
  • 下載 redis

有兩種方式下載安裝包,一種是去官網上下載(https://redis.io),然後將安裝包考到 centos 中,另種方法是直接使用 wget 來下載:

wget http://download.redis.io/releases/redis-3.2.8.tar.gz

如果沒有安裝過 wget,可以通過如下命令安裝:

yum install wget
  • 解壓安裝

解壓安裝包:

tar –vzxf redis-3.2.8.tar.gz

然後將解壓的文件夾 redis-3.2.8 放到 /usr/local/ 下,一般安裝軟件都放在 /usr/local 下。然後進入 /usr/local/redis-3.2.8/ 文件夾下,執行 make 命令即可完成安裝。
【注】如果 make 失敗,可以嘗試如下命令:

make MALLOC=libc
make install
  • 修改配置文件

安裝成功之後,需要修改一下配置文件,包括允許接入的 ip,允許後臺執行,設置密碼等等。
打開 redis 配置文件:vi redis.conf
在命令模式下輸入 /bind 來查找 bind 配置,按 n 來查找下一個,找到配置後,將 bind 配置成 0.0.0.0,允許任意服務器來訪問 redis,即:

bind 0.0.0.0

使用同樣的方法,將 daemonize 改成 yes (默認爲 no),允許 redis 在後臺執行。
將 requirepass 註釋打開,並設置密碼爲 123456(密碼自己設置)。

  • 啓動 redis

在 redis-3.2.8 目錄下,指定剛剛修改好的配置文件 redis.conf 來啓動 redis:

redis-server ./redis.conf

再啓動 redis 客戶端:

redis-cli

由於我們設置了密碼,在啓動客戶端之後,輸入 auth 123456 即可登錄進入客戶端。
然後我們來測試一下,往 redis 中插入一個數據:

set name CSDN

然後來獲取 name

get name

如果正常獲取到 CSDN,則說明沒有問題。

3. Spring Boot 集成 Redis

3.1 依賴導入

Spring Boot 集成 redis 很方便,只需要導入一個 redis 的 starter 依賴即可。如下:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--阿里巴巴fastjson -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.35</version>
</dependency>

這裏也導入阿里巴巴的 fastjson 是爲了在後面我們要存一個實體,爲了方便把實體轉換成 json 字符串存進去。

3.2 Redis 配置

導入了依賴之後,我們在 application.yml 文件裏配置 redis:

server:
  port: 8080
spring:
  #redis相關配置
  redis:
    database: 5
    # 配置redis的主機地址,需要修改成自己的
    host: 192.168.48.190
    port: 6379
    password: 123456
    timeout: 5000
    jedis:
      pool:
        # 連接池中的最大空閒連接,默認值也是8。
        max-idle: 500
        # 連接池中的最小空閒連接,默認值也是0。
        min-idle: 50
        # 如果賦值爲-1,則表示不限制;如果pool已經分配了maxActive個jedis實例,則此時pool的狀態爲exhausted(耗盡)
        max-active: 1000
        # 等待可用連接的最大時間,單位毫秒,默認值爲-1,表示永不超時。如果超過等待時間,則直接拋出JedisConnectionException
        max-wait: 2000

3.3 常用 api 介紹

Spring Boot 對 redis 的支持已經非常完善了,豐富的 api 已經足夠我們日常的開發,這裏我介紹幾個最常用的供大家學習,其他 api 希望大家自己多學習,多研究。用到會去查即可。

有兩個 redis 模板:RedisTemplate 和 StringRedisTemplate。我們不使用 RedisTemplate,RedisTemplate 提供給我們操作對象,操作對象的時候,我們通常是以 json 格式存儲,但在存儲的時候,會使用 Redis 默認的內部序列化器;導致我們存進裏面的是亂碼之類的東西。當然了,我們可以自己定義序列化,但是比較麻煩,所以使用 StringRedisTemplate 模板。StringRedisTemplate 主要給我們提供字符串操作,我們可以將實體類等轉成 json 字符串即可,在取出來後,也可以轉成相應的對象,這就是上面我導入了阿里 fastjson 的原因。

3.3.1 redis:string 類型

新建一個 RedisService,注入 StringRedisTemplate,使用 stringRedisTemplate.opsForValue() 可以獲取 ValueOperations<String, String> 對象,通過該對象即可讀寫 redis 數據庫了。如下:

public class RedisService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * set redis: string類型
     * @param key key
     * @param value value
     */
    public void setString(String key, String value){
        ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
        valueOperations.set(key, value);
    }

    /**
     * get redis: string類型
     * @param key key
     * @return
     */
    public String getString(String key){
        return stringRedisTemplate.opsForValue().get(key);
    }

該對象操作的是 string,我們也可以存實體類,只需要將實體類轉換成 json 字符串即可。下面來測試一下:

@RunWith(SpringRunner.class)
@SpringBootTest
public class Course14ApplicationTests {

    private static final Logger logger = LoggerFactory.getLogger(Course14ApplicationTests.class);

	@Resource
	private RedisService redisService;

	@Test
	public void contextLoads() {
        //測試redis的string類型
        redisService.setString("weichat","程序員私房菜");
        logger.info("我的微信公衆號爲:{}", redisService.getString("weichat"));

        // 如果是個實體,我們可以使用json工具轉成json字符串,
        User user = new User("CSDN", "123456");
        redisService.setString("userInfo", JSON.toJSONString(user));
        logger.info("用戶信息:{}", redisService.getString("userInfo"));
    }
}

先啓動 redis,然後運行這個測試用例,觀察控制檯打印的日誌如下:

我的微信公衆號爲:程序員私房菜
用戶信息:{"password":"123456","username":"CSDN"}

3.3.2 redis:hash 類型

hash 類型其實原理和 string 一樣的,但是有兩個 key,使用 stringRedisTemplate.opsForHash() 可以獲取 HashOperations<String, Object, Object> 對象。比如我們要存儲訂單信息,所有訂單信息都放在 order 下,針對不同用戶的訂單實體,可以通過用戶的 id 來區分,這就相當於兩個 key 了。

@Service
public class RedisService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * set redis: hash類型
     * @param key key
     * @param filedKey filedkey
     * @param value value
     */
    public void setHash(String key, String filedKey, String value){
        HashOperations<String, Object, Object> hashOperations = stringRedisTemplate.opsForHash();
        hashOperations.put(key,filedKey, value);
    }

    /**
     * get redis: hash類型
     * @param key key
     * @param filedkey filedkey
     * @return
     */
    public String getHash(String key, String filedkey){
        return (String) stringRedisTemplate.opsForHash().get(key, filedkey);
    }
}

可以看出,hash 和 string 沒啥兩樣,只不過多了個參數,Spring Boot 中操作 redis 非常簡單方便。來測試一下:

@SpringBootTest
public class Course14ApplicationTests {

    private static final Logger logger = LoggerFactory.getLogger(Course14ApplicationTests.class);

	@Resource
	private RedisService redisService;

	@Test
	public void contextLoads() {
        //測試redis的hash類型
        redisService.setHash("user", "name", JSON.toJSONString(user));
        logger.info("用戶姓名:{}", redisService.getHash("user","name"));
    }
}

3.3.3 redis:list 類型

使用 stringRedisTemplate.opsForList() 可以獲取 ListOperations<String, String> listOperations redis 列表對象,該列表是個簡單的字符串列表,可以支持從左側添加,也可以支持從右側添加,一個列表最多包含 2 ^ 32 -1 個元素。

@Service
public class RedisService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * set redis:list類型
     * @param key key
     * @param value value
     * @return
     */
    public long setList(String key, String value){
        ListOperations<String, String> listOperations = stringRedisTemplate.opsForList();
        return listOperations.leftPush(key, value);
    }

    /**
     * get redis:list類型
     * @param key key
     * @param start start
     * @param end end
     * @return
     */
    public List<String> getList(String key, long start, long end){
        return stringRedisTemplate.opsForList().range(key, start, end);
    }
}

可以看出,這些 api 都是一樣的形式,方便記憶也方便使用。具體的 api 細節我就不展開了,大家可以自己看 api 文檔。其實,這些 api 根據參數和返回值也能知道它們是做什麼用的。來測試一下:

@RunWith(SpringRunner.class)
@SpringBootTest
public class Course14ApplicationTests {

    private static final Logger logger = LoggerFactory.getLogger(Course14ApplicationTests.class);

	@Resource
	private RedisService redisService;

	@Test
	public void contextLoads() {
        //測試redis的list類型
        redisService.setList("list", "football");
        redisService.setList("list", "basketball");
        List<String> valList = redisService.getList("list",0,-1);
        for(String value :valList){
            logger.info("list中有:{}", value);
        }
    }
}

4. 總結

本節主要介紹了 redis 的使用場景、安裝過程,以及 Spring Boot 中集成 redis 的詳細步驟。在實際項目中,通常都用 redis 作爲緩存,在查詢數據庫的時候,會先從 redis 中查找,如果有信息,則從 redis 中取;如果沒有,則從數據庫中查,並且同步到 redis 中,下次 redis 中就有了。更新和刪除也是如此,都需要同步到 redis。redis 在高併發場景下運用的很多。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第15課: Spring Boot中集成ActiveMQ

1. JMS 和 ActiveMQ 介紹

1.1 JMS 是啥

百度百科的解釋:

JMS 即 Java 消息服務(Java Message Service)應用程序接口,是一個Java平臺中關於面向消息中間件(MOM)的 API,用於在兩個應用程序之間,或分佈式系統中發送消息,進行異步通信。Java 消息服務是一個與具體平臺無關的 API,絕大多數 MOM 提供商都對 JMS 提供支持。

JMS 只是接口,不同的提供商或者開源組織對其有不同的實現,ActiveMQ 就是其中之一,它支持JMS,是 Apache 推出的。JMS 中有幾個對象模型:

連接工廠:ConnectionFactory
JMS連接:Connection
JMS會話:Session
JMS目的:Destination
JMS生產者:Producer
JMS消費者:Consumer
JMS消息兩種類型:點對點和發佈/訂閱。

可以看出 JMS 實際上和 JDBC 有點類似,JDBC 是可以用來訪問許多不同關係數據庫的 API,而 JMS 則提供同樣與廠商無關的訪問方法,以訪問消息收發服務。本文主要使用 ActiveMQ。

1.2 ActiveMQ

ActiveMQ 是 Apache 的一個能力強勁的開源消息總線。ActiveMQ 完全支持JMS1.1和J2EE 1.4規範,儘管 JMS 規範出臺已經是很久的事情了,但是 JMS 在當今的 Java EE 應用中間仍然扮演着特殊的地位。ActiveMQ 用在異步消息的處理上,所謂異步消息即消息發送者無需等待消息接收者的處理以及返回,甚至無需關心消息是否發送成功。

異步消息主要有兩種目的地形式,隊列(queue)和主題(topic),隊列用於點對點形式的消息通信,主題用於發佈/訂閱式的消息通信。本章節主要來學習一下在 Spring Boot 中如何使用這兩種形式的消息。

2. ActiveMQ安裝

使用 ActiveMQ 首先需要去官網下載,官網地址爲:http://activemq.apache.org/
本課程使用的版本是 apache-activemq-5.15.3,下載後解壓縮會有一個名爲 apache-activemq-5.15.3 的文件夾,沒錯,這就安裝好了,非常簡單,開箱即用。打開文件夾會看到裏面有個 activemq-all-5.15.3.jar,這個 jar 我們是可以加進工程裏的,但是使用 maven 的話,這個 jar 我們不需要。

在使用 ActiveMQ 之前,首先得先啓動,剛纔解壓後的目錄中有個 bin 目錄,裏面有 win32 和 win64 兩個目錄,根據自己電腦選擇其中一個打開運行裏面的 activemq.bat 即可啓動 ActiveMQ。
消息生產者生產消息發佈到queue中,然後消息消費者從queue中取出,並且消費消息。這裏需要注意:消息被消費者消費以後,queue中不再有存儲,所以消息消費者不可消費到已經被消費的消息。Queue支持存在多個消息消費者,但是對一個消息而言,只會有一個消費者可以消費
啓動完成後,在瀏覽器中輸入 http://127.0.0.1:8161/admin/ 來訪問 ActiveMQ 的服務器,用戶名和密碼是 admin/admin。如下:

activemq

我們可以看到有 Queues 和 Topics 這兩個選項,這兩個選項分別是點對點消息和發佈/訂閱消息的查看窗口。何爲點對點消息和發佈/訂閱消息呢?

點對點消息:消息生產者生產消息發佈到 queue 中,然後消息消費者從 queue 中取出,並且消費消息。這裏需要注意:消息被消費者消費以後,queue 中不再有存儲,所以消息消費者不可消費到已經被消費的消息。Queue 支持存在多個消息消費者,但是對一個消息而言,只會有一個消費者可以消費。

發佈/訂閱消息:消息生產者(發佈)將消息發佈到 topic 中,同時有多個消息消費者(訂閱)消費該消息。和點對點方式不同,發佈到 topic 的消息會被所有訂閱者消費。下面分析具體的實現方式。

3. ActiveMQ集成

3.1 依賴導入和配置

在 Spring Boot 中集成 ActiveMQ 需要導入如下 starter 依賴:

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

然後在 application.yml 配置文件中,對 activemq 做一下配置:

spring:
  activemq:
  	# activemq url
    broker-url: tcp://localhost:61616
    in-memory: true
    pool:
      # 如果此處設置爲true,需要添加activemq-pool的依賴包,否則會自動配置失敗,無法注入JmsMessagingTemplate
      enabled: false

3.2 Queue 和 Topic 的創建

首先我們需要創建兩種消息 Queue 和 Topic,這兩種消息的創建,我們放到 ActiveMqConfig 中來創建,如下:

/**
 * activemq的配置
 * @author  shengwu ni
 */
@Configuration
public class ActiveMqConfig {
    /**
     * 發佈/訂閱模式隊列名稱
     */
    public static final String TOPIC_NAME = "activemq.topic";
    /**
     * 點對點模式隊列名稱
     */
    public static final String QUEUE_NAME = "activemq.queue";

    @Bean
    public Destination topic() {
        return new ActiveMQTopic(TOPIC_NAME);
    }

    @Bean
    public Destination queue() {
        return new ActiveMQQueue(QUEUE_NAME);
    }
}

可以看出創建 Queue 和 Topic 兩種消息,分別使用 new ActiveMQQueuenew ActiveMQTopic 來創建,分別跟上對應消息的名稱即可。這樣在其他地方就可以直接將這兩種消息作爲組件注入進來了。

3.3 消息的發送接口

在 Spring Boot 中,我們只要注入 JmsMessagingTemplate 模板即可快速發送消息,如下:

/**
 * 消息發送者
 * @author shengwu ni
 */
@Service
public class MsgProducer {

    @Resource
    private JmsMessagingTemplate jmsMessagingTemplate;

    public void sendMessage(Destination destination, String msg) {
        jmsMessagingTemplate.convertAndSend(destination, msg);
    }
}

convertAndSend 方法中第一個參數是消息發送的目的地,第二個參數是具體的消息內容。

3.4 點對點消息生產與消費

3.4.1 點對點消息的生產

消息的生產,我們放到 Controller 中來做,由於上面已經生成了 Queue 消息的組件,所以在 Controller 中我們直接注入進來即可。然後調用上文的消息發送方法 sendMessage 即可成功生產一條消息。

/**
 * ActiveMQ controller
 * @author shengwu ni
 */
@RestController
@RequestMapping("/activemq")
public class ActiveMqController {

    private static final Logger logger = LoggerFactory.getLogger(ActiveMqController.class);

    @Resource
    private MsgProducer producer;
    @Resource
    private Destination queue;

    @GetMapping("/send/queue")
    public String sendQueueMessage() {

        logger.info("===開始發送點對點消息===");
        producer.sendMessage(queue, "Queue: hello activemq!");
        return "success";
    }
}

3.4.2 點對點消息的消費

點對點消息的消費很簡單,只要我們指定目的地即可,jms 監聽器一直在監聽是否有消息過來,如果有,則消費。

/**
 * 消息消費者
 * @author shengwu ni
 */
@Service
public class QueueConsumer {

    /**
     * 接收點對點消息
     * @param msg
     */
    @JmsListener(destination = ActiveMqConfig.QUEUE_NAME)
    public void receiveQueueMsg(String msg) {
        System.out.println("收到的消息爲:" + msg);
    }
}

可以看出,使用 @JmsListener 註解來指定要監聽的目的地,在消息接收方法內部,我們可以根據具體的業務需求做相應的邏輯處理即可。

3.4.3 測試一下

啓動項目,在瀏覽器中輸入:http://localhost:8081/activemq/send/queue,觀察控制檯的輸出日誌,出現下面的日誌說明消息發送和消費成功。

收到的消息爲:Queue: hello activemq!

3.5 發佈/訂閱消息的生產和消費

3.5.1 發佈/訂閱消息的生產

和點對點消息一樣,我們注入 topic 並調用 producer 的 sendMessage 方法即可發送訂閱消息,如下,不再贅述:

@RestController
@RequestMapping("/activemq")
public class ActiveMqController {

    private static final Logger logger = LoggerFactory.getLogger(ActiveMqController.class);

    @Resource
    private MsgProducer producer;
    @Resource
    private Destination topic;

    @GetMapping("/send/topic")
    public String sendTopicMessage() {

        logger.info("===開始發送訂閱消息===");
        producer.sendMessage(topic, "Topic: hello activemq!");
        return "success";
    }
}

3.5.2 發佈/訂閱消息的消費

發佈/訂閱消息的消費和點對點不同,訂閱消息支持多個消費者一起消費。其次,Spring Boot 中默認的時點對點消息,所以在使用 topic 時,會不起作用,我們需要在配置文件 application.yml 中添加一個配置:

spring:
  jms:
    pub-sub-domain: true

該配置是 false 的話,則爲點對點消息,也是 Spring Boot 默認的。這樣是可以解決問題,但是如果這樣配置的話,上面提到的點對點消息又不能正常消費了。所以二者不可兼得,這並非一個好的解決辦法。

比較好的解決辦法是,我們定義一個工廠,@JmsListener 註解默認只接收 queue 消息,如果要接收 topic 消息,需要設置一下 containerFactory。我們還在上面的那個 ActiveMqConfig 配置類中添加:

/**
 * activemq的配置
 *
 * @author shengwu ni
 */
@Configuration
public class ActiveMqConfig {
    // 省略其他內容

    /**
     * JmsListener註解默認只接收queue消息,如果要接收topic消息,需要設置containerFactory
     */
    @Bean
    public JmsListenerContainerFactory topicListenerContainer(ConnectionFactory connectionFactory) {
        DefaultJmsListenerContainerFactory factory = new DefaultJmsListenerContainerFactory();
        factory.setConnectionFactory(connectionFactory);
        // 相當於在application.yml中配置:spring.jms.pub-sub-domain=true
        factory.setPubSubDomain(true);
        return factory;
    }
}

經過這樣的配置之後,我們在消費的時候,在 @JmsListener 註解中指定這個容器工廠即可消費 topic 消息。如下:

/**
 * Topic消息消費者
 * @author shengwu ni
 */
@Service
public class TopicConsumer1 {

    /**
     * 接收訂閱消息
     * @param msg
     */
    @JmsListener(destination = ActiveMqConfig.TOPIC_NAME, containerFactory = "topicListenerContainer")
    public void receiveTopicMsg(String msg) {
        System.out.println("收到的消息爲:" + msg);
    }

}

指定 containerFactory 屬性爲上面我們自己配置的 topicListenerContainer 即可。由於 topic 消息可以多個消費,所以該消費的類可以拷貝幾個一起測試一下,這裏我就不貼代碼了,可以參考我的源碼測試。

3.5.3 測試一下

啓動項目,在瀏覽器中輸入:http://localhost:8081/activemq/send/topic,觀察控制檯的輸出日誌,出現下面的日誌說明消息發送和消費成功。

收到的消息爲:Topic: hello activemq!
收到的消息爲:Topic: hello activemq!

4. 總結

本章主要介紹了 jms 和 activemq 的相關概念、activemq 的安裝與啓動。詳細分析了 Spring Boot 中點對點消息和發佈/訂閱消息兩種方式的配置、消息生產和消費方式。ActiveMQ 是能力強勁的開源消息總線,在異步消息的處理上很有用,希望大家好好消化一下。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第16課:Spring Boot中集成 Shiro

Shiro 是一個強大、簡單易用的 Java 安全框架,主要用來更便捷的認證,授權,加密,會話管等等,可爲任何應用提供安全保障。本課程主要來介紹 Shiro 的認證和授權功能。

1. Shiro 三大核心組件

Shiro 有三大核心的組件:SubjectSecurityManagerRealm。先來看一下它們之間的關係。

三大核心組件的關係

  1. Subject:認證主體。它包含兩個信息:Principals 和 Credentials。看一下這兩個信息具體是什麼。

Principals:身份。可以是用戶名,郵件,手機號碼等等,用來標識一個登錄主體身份;
Credentials:憑證。常見有密碼,數字證書等等。

說白了,就是需要認證的東西,最常見的就是用戶名密碼了,比如用戶在登錄的時候,Shiro 需要去進行身份認證,就需要 Subject 認證主體。

  1. SecurityManager:安全管理員。這是 Shiro 架構的核心,它就像 Shiro 內部所有原件的保護傘一樣。我們在項目中一般都會配置 SecurityManager,開發人員大部分精力主要是在 Subject 認證主體上面。我們在與 Subject 進行交互的時候,實際上是 SecurityManager 在背後做一些安全操作。

  2. Realms:Realms 是一個域,它是連接 Shiro 和具體應用的橋樑,當需要與安全數據交互的時候,比如用戶賬戶、訪問控制等,Shiro 就會從一個或多個 Realms 中去查找。我們一般會自己定製 Realm,這在下文會詳細說明。

1. Shiro 身份和權限認證

1.2 Shiro 身份認證

我們來分析一下 Shiro 身份認證的過程,看一下官方的一個認證圖:

認證過程

Step1:應用程序代碼在調用 Subject.login(token) 方法後,傳入代表最終用戶的身份和憑證的 AuthenticationToken 實例 token。

Step2:將 Subject 實例委託給應用程序的 SecurityManager(Shiro的安全管理)來開始實際的認證工作。這裏開始真正的認證工作了。

Step3,4,5:然後 SecurityManager 就會根據具體的 realm 去進行安全認證了。 從圖中可以看出,realm 可以自定義(Custom Realm)。

1.3 Shiro 權限認證

權限認證,也就是訪問控制,即在應用中控制誰能訪問哪些資源。在權限認證中,最核心的三個要素是:權限,角色和用戶。

權限(permission):即操作資源的權利,比如訪問某個頁面,以及對某個模塊的數據的添加,修改,刪除,查看的權利;
角色(role):指的是用戶擔任的的角色,一個角色可以有多個權限;
用戶(user):在 Shiro 中,代表訪問系統的用戶,即上面提到的 Subject 認證主體。

它們之間的的關係可以用下圖來表示:

用戶、角色和權限的關係

一個用戶可以有多個角色,而不同的角色可以有不同的權限,也可由有相同的權限。比如說現在有三個角色,1是普通角色,2也是普通角色,3是管理員,角色1只能查看信息,角色2只能添加信息,管理員都可以,而且還可以刪除信息,類似於這樣。

2. Spring Boot 集成 Shiro 過程

2.1 依賴導入

Spring Boot 2.0.3 集成 Shiro 需要導入如下 starter 依賴:

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.4.0</version>
</dependency>

2.2 數據庫表數據初始化

這裏主要涉及到三張表:用戶表、角色表和權限表,其實在 demo 中,我們完全可以自己模擬一下,不用建表,但是爲了更加接近實際情況,我們還是加入 mybatis,來操作數據庫。下面是數據庫表的腳本。

CREATE TABLE `t_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `rolename` varchar(20) DEFAULT NULL COMMENT '角色名稱',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8

CREATE TABLE `t_user` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '用戶主鍵',
  `username` varchar(20) NOT NULL COMMENT '用戶名',
  `password` varchar(20) NOT NULL COMMENT '密碼',
  `role_id` int(11) DEFAULT NULL COMMENT '外鍵關聯role表',
  PRIMARY KEY (`id`),
  KEY `role_id` (`role_id`),
  CONSTRAINT `t_user_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `t_role` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8

CREATE TABLE `t_permission` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `permissionname` varchar(50) NOT NULL COMMENT '權限名',
  `role_id` int(11) DEFAULT NULL COMMENT '外鍵關聯role',
  PRIMARY KEY (`id`),
  KEY `role_id` (`role_id`),
  CONSTRAINT `t_permission_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `t_role` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8

其中,t_user,t_role 和 t_permission,分別存儲用戶信息,角色信息和權限信息,表建立好了之後,我們往表裏插入一些測試數據。
t_user 表:

id username password role_id
1 csdn1 123456 1
2 csdn2 123456 2
3 csdn3 123456 3

t_role 表:

id rolename
1 admin
2 teacher
3 student

t_permission 表:

id permissionname role_id
1 user:* 1
2 student:* 2

解釋一下這裏的權限:user:*表示權限可以是 user:create 或者其他,* 處表示一個佔位符,我們可以自己定義,具體的會在下文 Shiro 配置那裏說明。

2.2 自定義 Realm

有了數據庫表和數據之後,我們開始自定義 realm,自定義 realm 需要繼承 AuthorizingRealm 類,因爲該類封裝了很多方法,它也是一步步繼承自 Realm 類的,繼承了 AuthorizingRealm 類後,需要重寫兩個方法:

doGetAuthenticationInfo() 方法:用來驗證當前登錄的用戶,獲取認證信息
doGetAuthorizationInfo() 方法:用來爲當前登陸成功的用戶授予權限和角色

具體實現如下,相關的解釋我放在代碼的註釋中,這樣更加方便直觀:

/**
 * 自定義realm
 * @author shengwu ni
 */
public class MyRealm extends AuthorizingRealm {

    @Resource
    private UserService userService;

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        // 獲取用戶名
        String username = (String) principalCollection.getPrimaryPrincipal();
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        // 給該用戶設置角色,角色信息存在t_role表中取
        authorizationInfo.setRoles(userService.getRoles(username));
        // 給該用戶設置權限,權限信息存在t_permission表中取
        authorizationInfo.setStringPermissions(userService.getPermissions(username));
        return authorizationInfo;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        // 根據token獲取用戶名,如果您不知道該該token怎麼來的,先可以不管,下文會解釋
        String username = (String) authenticationToken.getPrincipal();
        // 根據用戶名從數據庫中查詢該用戶
        User user = userService.getByUsername(username);
        if(user != null) {
            // 把當前用戶存到session中
            SecurityUtils.getSubject().getSession().setAttribute("user", user);
            // 傳入用戶名和密碼進行身份認證,並返回認證信息
            AuthenticationInfo authcInfo = new SimpleAuthenticationInfo(user.getUsername(), user.getPassword(), "myRealm");
            return authcInfo;
        } else {
            return null;
        }
    }
}

從上面兩個方法中可以看出:驗證身份的時候是根據用戶輸入的用戶名先從數據庫中查出該用戶名對應的用戶,這時候並沒有涉及到密碼,也就是說到這一步的時候,即使用戶輸入的密碼不對,也是可以查出來該用戶的,然後將該用戶的正確信息封裝到 authcInfo 中返回給 Shiro,接下來就是Shiro的事了,它會根據這裏面的真實信息與用戶前臺輸入的用戶名和密碼進行校驗, 這個時候也要校驗密碼了,如果校驗通過就讓用戶登錄,否則跳轉到指定頁面。同理,權限驗證的時候也是先根據用戶名從數據庫中獲取與該用戶名有關的角色和權限,然後封裝到 authorizationInfo 中返回給 Shiro。

2.3 Shiro 配置

自定義的 realm 寫好了,接下來需要對 Shiro 進行配置了。我們主要配置三個東西:自定義 realm、安全管理器 SecurityManager 和 Shiro 過濾器。如下:

配置自定義 realm:

@Configuration
public class ShiroConfig {

    private static final Logger logger = LoggerFactory.getLogger(ShiroConfig.class);

    /**
     * 注入自定義的realm
     * @return MyRealm
     */
    @Bean
    public MyRealm myAuthRealm() {
        MyRealm myRealm = new MyRealm();
        logger.info("====myRealm註冊完成=====");
        return myRealm;
    }
}

配置安全管理器 SecurityManager:

@Configuration
public class ShiroConfig {

    private static final Logger logger = LoggerFactory.getLogger(ShiroConfig.class);

    /**
     * 注入安全管理器
     * @return SecurityManager
     */
    @Bean
    public SecurityManager securityManager() {
        // 將自定義realm加進來
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(myAuthRealm());
        logger.info("====securityManager註冊完成====");
        return securityManager;
    }
}

配置 SecurityManager 時,需要將上面的自定義 realm 添加進來,這樣的話 Shiro 纔會走到自定義的 realm 中。

配置 Shiro 過濾器:

@Configuration
public class ShiroConfig {

    private static final Logger logger = LoggerFactory.getLogger(ShiroConfig.class);
    
    /**
     * 注入Shiro過濾器
     * @param securityManager 安全管理器
     * @return ShiroFilterFactoryBean
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
        // 定義shiroFactoryBean
        ShiroFilterFactoryBean shiroFilterFactoryBean=new ShiroFilterFactoryBean();

        // 設置自定義的securityManager
        shiroFilterFactoryBean.setSecurityManager(securityManager);

        // 設置默認登錄的url,身份認證失敗會訪問該url
        shiroFilterFactoryBean.setLoginUrl("/login");
        // 設置成功之後要跳轉的鏈接
        shiroFilterFactoryBean.setSuccessUrl("/success");
        // 設置未授權界面,權限認證失敗會訪問該url
        shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized");

        // LinkedHashMap是有序的,進行順序攔截器配置
        Map<String,String> filterChainMap = new LinkedHashMap<>();

        // 配置可以匿名訪問的地址,可以根據實際情況自己添加,放行一些靜態資源等,anon表示放行
        filterChainMap.put("/css/**", "anon");
        filterChainMap.put("/imgs/**", "anon");
        filterChainMap.put("/js/**", "anon");
        filterChainMap.put("/swagger-*/**", "anon");
        filterChainMap.put("/swagger-ui.html/**", "anon");
        // 登錄url 放行
        filterChainMap.put("/login", "anon");

        ///user/admin” 開頭的需要身份認證,authc表示要身份認證
        filterChainMap.put("/user/admin*", "authc");
        ///user/student” 開頭的需要角色認證,是“admin”才允許
        filterChainMap.put("/user/student*/**", "roles[admin]");
        // “/user/teacher” 開頭的需要權限認證,是“user:create”才允許
        filterChainMap.put("/user/teacher*/**", "perms[\"user:create\"]");

        // 配置logout過濾器
        filterChainMap.put("/logout", "logout");

        // 設置shiroFilterFactoryBean的FilterChainDefinitionMap
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainMap);
        logger.info("====shiroFilterFactoryBean註冊完成====");
        return shiroFilterFactoryBean;
    }
}

配置 Shiro 過濾器時會傳入一個安全管理器,可以看出,這是一環套一環,reaml -> SecurityManager -> filter。在過濾器中,我們需要定義一個 shiroFactoryBean,然後將 SecurityManager 添加進來,結合上面代碼可以看出,要配置的東西主要有:

默認登錄的 url:身份認證失敗會訪問該 url
認證成功之後要跳轉的 url
權限認證失敗會訪問該 url
需要攔截或者放行的 url:這些都放在一個 map 中

從上述代碼中可以看出,在 map 中,針對不同的 url,有不同的權限要求,這裏總結一下常用的幾個權限。

Filter 說明
anon 開放權限,可以理解爲匿名用戶或遊客,可以直接訪問的
authc 需要身份認證的
logout 註銷,執行後會直接跳轉到 shiroFilterFactoryBean.setLoginUrl(); 設置的 url,即登錄頁面
roles[admin] 參數可寫多個,表示是某個或某些角色才能通過,多個參數時寫 roles[“admin,user”],當有多個參數時必須每個參數都通過纔算通過
perms[user] 參數可寫多個,表示需要某個或某些權限才能通過,多個參數時寫 perms[“user, admin”],當有多個參數時必須每個參數都通過纔算通過

2.4 使用 Shiro 進行認證

到這裏,我們對 Shiro 的準備工作都做完了,接下來開始使用 Shiro 進行認證工作。我們首先來設計幾個接口:

接口一: 使用 http://localhost:8080/user/admin 來驗證身份認證
接口二: 使用 http://localhost:8080/user/student 來驗證角色認證
接口三: 使用 http://localhost:8080/user/teacher 來驗證權限認證
接口四: 使用 http://localhost:8080/user/login 來實現用戶登錄

然後來一下認證的流程:

流程一: 直接訪問接口一(此時還未登錄),認證失敗,跳轉到 login.html 頁面讓用戶登錄,登錄會請求接口四,實現用戶登錄功能,此時 Shiro 已經保存了用戶信息了。
流程二: 再次訪問接口一(此時用戶已經登錄),認證成功,跳轉到 success.html 頁面,展示用戶信息。
流程三: 訪問接口二,測試角色認證是否成功。
流程四: 訪問接口三,測試權限認證是否成功。

2.4.1 身份、角色、權限認證接口

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

    /**
     * 身份認證測試接口
     * @param request
     * @return
     */
    @RequestMapping("/admin")
    public String admin(HttpServletRequest request) {
        Object user = request.getSession().getAttribute("user");
        return "success";
    }

    /**
     * 角色認證測試接口
     * @param request
     * @return
     */
    @RequestMapping("/student")
    public String student(HttpServletRequest request) {
        return "success";
    }

    /**
     * 權限認證測試接口
     * @param request
     * @return
     */
    @RequestMapping("/teacher")
    public String teacher(HttpServletRequest request) {
        return "success";
    }
}

這三個接口很簡單,直接返回到指定頁面展示即可,只要認證成功就會正常跳轉,如果認證失敗,就會跳轉到上文 ShrioConfig 中配置的頁面進行展示。

2.4.2 用戶登錄接口

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

    /**
     * 用戶登錄接口
     * @param user user
     * @param request request
     * @return string
     */
    @PostMapping("/login")
    public String login(User user, HttpServletRequest request) {

        // 根據用戶名和密碼創建token
        UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());
        // 獲取subject認證主體
        Subject subject = SecurityUtils.getSubject();
        try{
            // 開始認證,這一步會跳到我們自定義的realm中
            subject.login(token);
            request.getSession().setAttribute("user", user);
            return "success";
        }catch(Exception e){
            e.printStackTrace();
            request.getSession().setAttribute("user", user);
            request.setAttribute("error", "用戶名或密碼錯誤!");
            return "login";
        }
    }
}

我們重點分析一下這個登錄接口,首先會根據前端傳過來的用戶名和密碼,創建一個 token,然後使用 SecurityUtils 來創建一個認證主體,接下來開始調用 subject.login(token) 開始進行身份認證了,注意這裏傳了剛剛創建的 token,就如註釋中所述,這一步會跳轉到我們自定義的 realm 中,進入 doGetAuthenticationInfo 方法,所以到這裏,您就會明白該方法中那個參數 token 了。然後就是上文分析的那樣,開始進行身份認證。

2.4.3 測試一下

最後,啓動項目,測試一下:
瀏覽器請求 http://localhost:8080/user/admin 會進行身份認證,因爲此時未登錄,所以會跳轉到 IndexController 中的 /login 接口,然後跳轉到 login.html 頁面讓我們登錄,使用用戶名密碼爲 csdn/123456 登錄之後,我們在瀏覽器中請求 http://localhost:8080/user/student 接口,會進行角色認證,因爲數據庫中 csdn1 的用戶角色是 admin,所以和配置中的吻合,認證通過;我們再請求 http://localhost:8080/user/teacher 接口,會進行權限認證,因爲數據庫中 csdn1 的用戶權限爲 user:*,滿足配置中的 user:create,所以認證通過。

接下來,我們點退出,系統會註銷重新讓我們登錄,我們使用 csdn2 這個用戶來登錄,重複上述操作,當在進行角色認證和權限認證這兩步時,就認證不通過了,因爲數據庫中 csdn2 這個用戶存的角色和權限與配置中的不同,所以認證不通過。

3. 總結

本節主要介紹了 Shiro 安全框架與 Spring Boot 的整合。先介紹了 Shiro 的三大核心組件已經它們的作用;然後介紹了 Shiro 的身份認證、角色認證和權限認證;最後結合代碼,詳細介紹了 Spring Boot 中是如何整合 Shiro 的,並設計了一套測試流程,逐步分析 Shiro 的工作流程和原理,讓讀者更直觀地體會出 Shiro 的整套工作流程。Shiro 使用的很廣泛,希望讀者將其掌握,並能運用到實際項目中。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第17課:Spring Boot中集成Lucence

1. Lucence 和全文檢索

Lucene 是什麼?看一下百度百科:

Lucene是一套用於全文檢索和搜尋的開源程式庫,由 Apache 軟件基金會支持和提供。Lucene 提供了一個簡單卻強大的應用程式接口,能夠做全文索引和搜尋。在 Java 開發環境裏 Lucene 是一個成熟的免費開源工具。就其本身而言,Lucene 是當前以及最近幾年最受歡迎的免費 Java 信息檢索程序庫。——《百度百科》

1.1 全文檢索

這裏提到了全文檢索的概念,我們先來分析一下什麼是全文檢索,理解了全文檢索之後,再理解 Lucene 的原理就非常簡單了。

何爲全文檢索?舉個例子,比如現在要在一個文件中查找某個字符串,最直接的想法就是從頭開始檢索,查到了就OK,這種對於小數據量的文件來說,很實用,但是對於大數據量的文件來說,就有點吃力了。或者說找包含某個字符串的文件,也是這樣,如果在一個擁有幾十個 G 的硬盤中找那效率可想而知,是很低的。

文件中的數據是屬於非結構化數據,也就是說它沒有什麼結構可言,要解決上面提到的效率問題,首先我們得將非結構化數據中的一部分信息提取出來,重新組織,使其變得有一定結構,然後對這些有一定結構的數據進行搜索,從而達到搜索相對較快的目的。這就叫全文搜索。即先建立索引,再對索引進行搜索的過程。

1.2 Lucene 建立索引的方式

那麼 Lucene 中是如何建立索引的呢?假設現在有兩篇文章,內容如下:

文章1的內容爲:Tom lives in Guangzhou, I live in Guangzhou too.
文章2的內容爲:He once lived in Shanghai.

首先第一步是將文檔傳給分詞組件(Tokenizer),分詞組件會將文檔分成一個個單詞,並去除標點符號和停詞。所謂的停詞指的是沒有特別意義的詞,比如英文中的 a,the,too 等。經過分詞後,得到詞元(Token) 。如下:

文章1經過分詞後的結果:[Tom] [lives] [Guangzhou] [I] [live] [Guangzhou]
文章2經過分詞後的結果:[He] [lives] [Shanghai]

然後將詞元傳給語言處理組件(Linguistic Processor),對於英語,語言處理組件一般會將字母變爲小寫,將單詞縮減爲詞根形式,如 ”lives” 到 ”live” 等,將單詞轉變爲詞根形式,如 ”drove” 到 ”drive” 等。然後得到詞(Term)。如下:

文章1經過處理後的結果:[tom] [live] [guangzhou] [i] [live] [guangzhou]
文章2經過處理後的結果:[he] [live] [shanghai]

最後將得到的詞傳給索引組件(Indexer),索引組件經過處理,得到下面的索引結構:

關鍵詞 文章號[出現頻率] 出現位置
guangzhou 1[2] 3,6
he 2[1] 1
i 1[1] 4
live 1[2],2[1] 2,5,2
shanghai 2[1] 3
tom 1[1] 1

以上就是Lucene 索引結構中最核心的部分。它的關鍵字是按字符順序排列的,因此 Lucene 可以用二元搜索算法快速定位關鍵詞。實現時 Lucene 將上面三列分別作爲詞典文件(Term Dictionary)、頻率文件(frequencies)和位置文件(positions)保存。其中詞典文件不僅保存有每個關鍵詞,還保留了指向頻率文件和位置文件的指針,通過指針可以找到該關鍵字的頻率信息和位置信息。
搜索的過程是先對詞典二元查找、找到該詞,通過指向頻率文件的指針讀出所有文章號,然後返回結果,然後就可以在具體的文章中根據出現位置找到該詞了。所以 Lucene 在第一次建立索引的時候可能會比較慢,但是以後就不需要每次都建立索引了,就快了。

理解了 Lucene 的分詞原理,接下來我們在 Spring Boot 中集成 Lucene 並實現索引和搜索的功能。

2. Spring Boot 中集成 Lucence

2.1 依賴導入

首先需要導入 Lucene 的依賴,它的依賴有好幾個,如下:

<!-- Lucence核心包 -->
<dependency>
	<groupId>org.apache.lucene</groupId>
	<artifactId>lucene-core</artifactId>
	<version>5.3.1</version>
</dependency>

<!-- Lucene查詢解析包 -->
<dependency>
	<groupId>org.apache.lucene</groupId>
	<artifactId>lucene-queryparser</artifactId>
	<version>5.3.1</version>
</dependency>

<!-- 常規的分詞(英文) -->
<dependency>
	<groupId>org.apache.lucene</groupId>
	<artifactId>lucene-analyzers-common</artifactId>
	<version>5.3.1</version>
</dependency>

<!--支持分詞高亮  -->
<dependency>
	<groupId>org.apache.lucene</groupId>
	<artifactId>lucene-highlighter</artifactId>
	<version>5.3.1</version>
</dependency>

<!--支持中文分詞  -->
<dependency>
	<groupId>org.apache.lucene</groupId>
	<artifactId>lucene-analyzers-smartcn</artifactId>
	<version>5.3.1</version>
</dependency>

最後一個依賴是用來支持中文分詞的,因爲默認是支持英文的。那個高亮的分詞依賴是最後我要做一個搜索,然後將搜到的內容高亮顯示,模擬當前互聯網上的做法,大家可以運用到實際項目中去。

2.2 快速入門

根據上文的分析,全文檢索有兩個步驟,先建立索引,再檢索。所以爲了測試這個過程,我新建兩個 java 類,一個用來建立索引的,另一個用來檢索。

2.2.1 建立索引

我們自己弄幾個文件,放到 D:\lucene\data 目錄下,新建一個 Indexer 類來實現建立索引功能。首先在構造方法中初始化標準分詞器和寫索引實例。

public class Indexer {

    /**
     * 寫索引實例
     */
    private IndexWriter writer;

    /**
     * 構造方法,實例化IndexWriter
     * @param indexDir
     * @throws Exception
     */
    public Indexer(String indexDir) throws Exception {
        Directory dir = FSDirectory.open(Paths.get(indexDir));
        //標準分詞器,會自動去掉空格啊,is a the等單詞
        Analyzer analyzer = new StandardAnalyzer();
        //將標準分詞器配到寫索引的配置中
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        //實例化寫索引對象
        writer = new IndexWriter(dir, config);
    }
}

在構造放發中傳一個存放索引的文件夾路徑,然後構建標準分詞器(這是英文的),再使用標準分詞器來實例化寫索引對象。接下來就開始建立索引了,我將解釋放到代碼註釋裏,方便大家跟進。

/**
 * 索引指定目錄下的所有文件
 * @param dataDir
 * @return
 * @throws Exception
 */
public int indexAll(String dataDir) throws Exception {
    // 獲取該路徑下的所有文件
    File[] files = new File(dataDir).listFiles();
    if (null != files) {
        for (File file : files) {
            //調用下面的indexFile方法,對每個文件進行索引
            indexFile(file);
        }
    }
    //返回索引的文件數
    return writer.numDocs();
}

/**
 * 索引指定的文件
 * @param file
 * @throws Exception
 */
private void indexFile(File file) throws Exception {
    System.out.println("索引文件的路徑:" + file.getCanonicalPath());
    //調用下面的getDocument方法,獲取該文件的document
    Document doc = getDocument(file);
    //將doc添加到索引中
    writer.addDocument(doc);
}

/**
 * 獲取文檔,文檔裏再設置每個字段,就類似於數據庫中的一行記錄
 * @param file
 * @return
 * @throws Exception
 */
private Document getDocument(File file) throws Exception {
    Document doc = new Document();
    //開始添加字段
    //添加內容
    doc.add(new TextField("contents", new FileReader(file)));
    //添加文件名,並把這個字段存到索引文件裏
    doc.add(new TextField("fileName", file.getName(), Field.Store.YES));
    //添加文件路徑
    doc.add(new TextField("fullPath", file.getCanonicalPath(), Field.Store.YES));
    return doc;
}

這樣就建立好索引了,我們在該類中寫一個 main 方法測試一下:

public static void main(String[] args) {
        //索引保存到的路徑
        String indexDir = "D:\\lucene";
        //需要索引的文件數據存放的目錄
        String dataDir = "D:\\lucene\\data";
        Indexer indexer = null;
        int indexedNum = 0;
        //記錄索引開始時間
        long startTime = System.currentTimeMillis();
        try {
            // 開始構建索引
            indexer = new Indexer(indexDir);
            indexedNum = indexer.indexAll(dataDir);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (null != indexer) {
                    indexer.close();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        //記錄索引結束時間
        long endTime = System.currentTimeMillis();
        System.out.println("索引耗時" + (endTime - startTime) + "毫秒");
        System.out.println("共索引了" + indexedNum + "個文件");
    }

我搞了兩個 tomcat 相關的文件放到 D:\lucene\data 下了,執行完之後,看到控制檯輸出:

索引文件的路徑:D:\lucene\data\catalina.properties
索引文件的路徑:D:\lucene\data\logging.properties
索引耗時882毫秒
共索引了2個文件

然後我們去 D:\lucene\ 目錄下可以看到一些索引文件,這些文件不能刪除,刪除了就需要重新構建索引,否則沒了索引,就無法去檢索內容了。

####2.2.2 檢索內容

上面把這兩個文件的索引建立好了,接下來我們就可以寫檢索程序了,在這兩個文件中查找特定的詞。

public class Searcher {

    public static void search(String indexDir, String q) throws Exception {

        //獲取要查詢的路徑,也就是索引所在的位置
        Directory dir = FSDirectory.open(Paths.get(indexDir));
        IndexReader reader = DirectoryReader.open(dir);
        //構建IndexSearcher
        IndexSearcher searcher = new IndexSearcher(reader);
        //標準分詞器,會自動去掉空格啊,is a the等單詞
        Analyzer analyzer = new StandardAnalyzer();
        //查詢解析器
        QueryParser parser = new QueryParser("contents", analyzer);
        //通過解析要查詢的String,獲取查詢對象,q爲傳進來的待查的字符串
        Query query = parser.parse(q);

        //記錄索引開始時間
        long startTime = System.currentTimeMillis();
        //開始查詢,查詢前10條數據,將記錄保存在docs中
        TopDocs docs = searcher.search(query, 10);
        //記錄索引結束時間
        long endTime = System.currentTimeMillis();
        System.out.println("匹配" + q + "共耗時" + (endTime-startTime) + "毫秒");
        System.out.println("查詢到" + docs.totalHits + "條記錄");

        //取出每條查詢結果
        for(ScoreDoc scoreDoc : docs.scoreDocs) {
            //scoreDoc.doc相當於docID,根據這個docID來獲取文檔
            Document doc = searcher.doc(scoreDoc.doc);
            //fullPath是剛剛建立索引的時候我們定義的一個字段,表示路徑。也可以取其他的內容,只要我們在建立索引時有定義即可。
            System.out.println(doc.get("fullPath"));
        }
        reader.close();
    }
}

ok,這樣我們檢索的代碼就寫完了,每一步解釋我寫在代碼中的註釋上了,下面寫個 main 方法來測試一下:

public static void main(String[] args) {
    String indexDir = "D:\\lucene";
    //查詢這個字符串
    String q = "security";
    try {
        search(indexDir, q);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

查一下 security 這個字符串,執行一下看控制檯打印的結果:

匹配security共耗時23毫秒
查詢到1條記錄
D:\lucene\data\catalina.properties

可以看出,耗時了23毫秒在兩個文件中找到了 security 這個字符串,並輸出了文件的名稱。上面的代碼我寫的很詳細,這個代碼已經比較全了,可以用在生產環境上。

2.3 中文分詞檢索高亮實戰

上文已經寫了建立索引和檢索的代碼,但是在實際項目中,我們往往是結合頁面做一些查詢結果的展示,比如我要查某個關鍵字,查到了之後,將相關的信息點展示出來,並將查詢的關鍵字高亮等等。這種需求在實際項目中非常常見,而且大多數網站中都會有這種效果。所以這一小節我們就使用 Lucene 來實現這種效果。

2.3.1 中文分詞

我們新建一個 ChineseIndexer 類來建立中文索引,建立過程和英文索引一樣的,不同的地方在於使用的是中文分詞器。除此之外,這裏我們不用通過讀取文件去建立索引,我們模擬一下用字符串來建立,因爲在實際項目中,絕大部分情況是獲取到一些文本字符串,然後根據一些關鍵字去查詢相關內容等等。代碼如下:

public class ChineseIndexer {

    /**
     * 存放索引的位置
     */
    private Directory dir;

    //準備一下用來測試的數據
    //用來標識文檔
    private Integer ids[] = {1, 2, 3};
    private String citys[] = {"上海", "南京", "青島"};
    private String descs[] = {
            "上海是個繁華的城市。",
            "南京是一個文化的城市南京,簡稱寧,是江蘇省會,地處中國東部地區,長江下游,瀕江近海。全市下轄11個區,總面積6597平方公里,2013年建成區面積752.83平方公里,常住人口818.78萬,其中城鎮人口659.1萬人。[1-4] “江南佳麗地,金陵帝王州”,南京擁有着6000多年文明史、近2600年建城史和近500年的建都史,是中國四大古都之一,有“六朝古都”、“十朝都會”之稱,是中華文明的重要發祥地,歷史上曾數次庇佑華夏之正朔,長期是中國南方的政治、經濟、文化中心,擁有厚重的文化底蘊和豐富的歷史遺存。[5-7] 南京是國家重要的科教中心,自古以來就是一座崇文重教的城市,有“天下文樞”、“東南第一學”的美譽。截至2013年,南京有高等院校75所,其中211高校8所,僅次於北京上海;國家重點實驗室25所、國家重點學科169個、兩院院士83人,均居中國第三。[8-10] 。",
            "青島是一個美麗的城市。"
    };

    /**
     * 生成索引
     * @param indexDir
     * @throws Exception
     */
    public void index(String indexDir) throws Exception {
        dir = FSDirectory.open(Paths.get(indexDir));
        // 先調用 getWriter 獲取IndexWriter對象
        IndexWriter writer = getWriter();
        for(int i = 0; i < ids.length; i++) {
            Document doc = new Document();
            // 把上面的數據都生成索引,分別用id、city和desc來標識
            doc.add(new IntField("id", ids[i], Field.Store.YES));
            doc.add(new StringField("city", citys[i], Field.Store.YES));
            doc.add(new TextField("desc", descs[i], Field.Store.YES));
            //添加文檔
            writer.addDocument(doc);
        }
        //close了才真正寫到文檔中
        writer.close();
    }

    /**
     * 獲取IndexWriter實例
     * @return
     * @throws Exception
     */
    private IndexWriter getWriter() throws Exception {
        //使用中文分詞器
        SmartChineseAnalyzer analyzer = new SmartChineseAnalyzer();
        //將中文分詞器配到寫索引的配置中
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        //實例化寫索引對象
        IndexWriter writer = new IndexWriter(dir, config);
        return writer;
    }

    public static void main(String[] args) throws Exception {
        new ChineseIndexer().index("D:\\lucene2");
    }
}

這裏我們用 id、city、desc 分別代表 id、城市名稱和城市描述,用他們作爲關鍵字來建立索引,後面我們獲取內容的時候,主要來獲取城市描述。南京的描述我故意寫的長一點,因爲下文檢索的時候,根據不同的關鍵字會檢索到不同部分的信息,有個權重的概念在裏面。
然後執行一下 main 方法,將索引保存到 D:\lucene2\ 中。

2.3.2 中文分詞查詢

中文分詞查詢代碼邏輯和默認的查詢差不多,有一些區別在於,我們需要將查詢出來的關鍵字標紅加粗等需要處理,需要計算出一個得分片段,這是什麼意思呢?比如我搜索 “南京文化” 跟搜索 “南京文明”,這兩個搜索結果應該根據關鍵字出現的位置,返回的結果不一樣纔對,這在下文會測試。我們先看一下代碼和註釋:

public class ChineseSearch {

    private static final Logger logger = LoggerFactory.getLogger(ChineseSearch.class);

    public static List<String> search(String indexDir, String q) throws Exception {

        //獲取要查詢的路徑,也就是索引所在的位置
        Directory dir = FSDirectory.open(Paths.get(indexDir));
        IndexReader reader = DirectoryReader.open(dir);
        IndexSearcher searcher = new IndexSearcher(reader);
        //使用中文分詞器
        SmartChineseAnalyzer analyzer = new SmartChineseAnalyzer();
        //由中文分詞器初始化查詢解析器
        QueryParser parser = new QueryParser("desc", analyzer);
        //通過解析要查詢的String,獲取查詢對象
        Query query = parser.parse(q);

        //記錄索引開始時間
        long startTime = System.currentTimeMillis();
        //開始查詢,查詢前10條數據,將記錄保存在docs中
        TopDocs docs = searcher.search(query, 10);
        //記錄索引結束時間
        long endTime = System.currentTimeMillis();
        logger.info("匹配{}共耗時{}毫秒", q, (endTime - startTime));
        logger.info("查詢到{}條記錄", docs.totalHits);

        //如果不指定參數的話,默認是加粗,即<b><b/>
        SimpleHTMLFormatter simpleHTMLFormatter = new SimpleHTMLFormatter("<b><font color=red>","</font></b>");
        //根據查詢對象計算得分,會初始化一個查詢結果最高的得分
        QueryScorer scorer = new QueryScorer(query);
        //根據這個得分計算出一個片段
        Fragmenter fragmenter = new SimpleSpanFragmenter(scorer);
        //將這個片段中的關鍵字用上面初始化好的高亮格式高亮
        Highlighter highlighter = new Highlighter(simpleHTMLFormatter, scorer);
        //設置一下要顯示的片段
        highlighter.setTextFragmenter(fragmenter);

        //取出每條查詢結果
        List<String> list = new ArrayList<>();
        for(ScoreDoc scoreDoc : docs.scoreDocs) {
            //scoreDoc.doc相當於docID,根據這個docID來獲取文檔
            Document doc = searcher.doc(scoreDoc.doc);
            logger.info("city:{}", doc.get("city"));
            logger.info("desc:{}", doc.get("desc"));
            String desc = doc.get("desc");

            //顯示高亮
            if(desc != null) {
                TokenStream tokenStream = analyzer.tokenStream("desc", new StringReader(desc));
                String summary = highlighter.getBestFragment(tokenStream, desc);
                logger.info("高亮後的desc:{}", summary);
                list.add(summary);
            }
        }
        reader.close();
        return list;
    }
}

每一步的註釋我寫的很詳細,在這就不贅述了。接下來我們來測試一下效果。

2.3.3 測試一下

這裏我們使用 thymeleaf 來寫個簡單的頁面來展示獲取到的數據,並高亮展示。在 controller 中我們指定索引的目錄和需要查詢的字符串,如下:

@Controller
@RequestMapping("/lucene")
public class IndexController {

    @GetMapping("/test")
    public String test(Model model) {
        // 索引所在的目錄
        String indexDir = "D:\\lucene2";
        // 要查詢的字符
//        String q = "南京文明";
        String q = "南京文化";
        try {
            List<String> list = ChineseSearch.search(indexDir, q);
            model.addAttribute("list", list);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "result";
    }
}

直接返回到 result.html 頁面,該頁面主要來展示一下 model 中的數據即可。

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div th:each="desc : ${list}">
    <div th:utext="${desc}"></div>
</div>
</body>
</html>

這裏注意一下,不能使用 th:test,否則字符串中的 html 標籤都會被轉義,不會被渲染到頁面。下面啓動服務,在瀏覽器中輸入 http://localhost:8080/lucene/test,測試一下效果,我們搜索的是 “南京文化”。

南京文化

再將 controller 中的搜索關鍵字改成 “南京文明”,看下命中的效果。

南京文明

可以看出,不同的關鍵詞,它會計算一個得分片段,也就是說不同的關鍵字會命中不同位置的內容,然後將關鍵字根據我們自己設定的形式高亮顯示。從結果中可以看出,Lucene 也可以很智能的將關鍵字拆分命中,這在實際項目中會很好用。

3. 總結

本節課首先詳細的分析了全文檢索的理論規則,然後結合 Lucene,系統的講述了在 Spring Boot 的集成步驟,首先快速帶領大家從直觀上感受 Lucene 如何建立索引已經如果檢索,其次通過中文檢索的具體實例,展示了 Lucene 在全文檢索中的廣泛應用。Lucene 不難,主要就是步驟比較多,代碼不用死記硬背,拿到項目中根據實際情況做對應的修改即可。

課程源代碼下載地址:戳我下載

歡迎關注我的爲微信公衆號:武哥聊編程

第18課:Spring Boot搭建實際項目開發中的架構

前面的課程中,我主要給大家講解了 Spring Boot 中常用的一些技術點,這些技術點在實際項目中可能不會全部用得到,因爲不同的項目可能使用的技術不同,但是希望大家都能掌握如何使用,並能自己根據實際項目中的需求進行相應的擴展。

不知道大家了不瞭解單片機,單片機裏有個最小系統,這個最小系統搭建好了之後,就可以在此基礎上進行人爲的擴展。這節課我們要做的就是搭建一個 “Spring Boot 最小系統架構” 。拿着這個架構,可以在此基礎上根據實際需求做相應的擴展。

從零開始搭建一個環境,主要要考慮幾點:統一封裝的數據結構、可調式的接口、json的處理、模板引擎的使用(本文不寫該項,因爲現在大部分項目都前後端分離了,但是考慮到也還有非前後端分離的項目,所以我在源代碼裏也加上了 thymeleaf)、持久層的集成、攔截器(這個也是可選的)和全局異常處理。一般包括這些東西的話,基本上一個 Spring Boot 項目環境就差不多了,然後就是根據具體情況來擴展了。

結合前面的課程和以上的這些點,本節課手把手帶領大家搭建一個實際項目開發中可用的 Spring Boot 架構。整個項目工程如下圖所示,學習的時候,可以結合我的源碼,這樣效果會更好。

工程架構

1. 統一的數據封裝

由於封裝的 json 數據的類型不確定,所以在定義統一的 json 結構時,我們需要用到泛型。統一的 json 結構中屬性包括數據、狀態碼、提示信息即可,構造方法可以根據實際業務需求做相應的添加即可,一般來說,應該有默認的返回結構,也應該有用戶指定的返回結構。如下:

/**
 * 統一返回對象
 * @author shengwu ni
 * @param <T>
 */
public class JsonResult<T> {

    private T data;
    private String code;
    private String msg;

    /**
     * 若沒有數據返回,默認狀態碼爲0,提示信息爲:操作成功!
     */
    public JsonResult() {
        this.code = "0";
        this.msg = "操作成功!";
    }

    /**
     * 若沒有數據返回,可以人爲指定狀態碼和提示信息
     * @param code
     * @param msg
     */
    public JsonResult(String code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    /**
     * 有數據返回時,狀態碼爲0,默認提示信息爲:操作成功!
     * @param data
     */
    public JsonResult(T data) {
        this.data = data;
        this.code = "0";
        this.msg = "操作成功!";
    }

    /**
     * 有數據返回,狀態碼爲0,人爲指定提示信息
     * @param data
     * @param msg
     */
    public JsonResult(T data, String msg) {
        this.data = data;
        this.code = "0";
        this.msg = msg;
    }
    
    /**
     * 使用自定義異常作爲參數傳遞狀態碼和提示信息
     * @param msgEnum
     */
    public JsonResult(BusinessMsgEnum msgEnum) {
        this.code = msgEnum.code();
        this.msg = msgEnum.msg();
    }

    // 省去get和set方法
}

大家可以根據自己項目中所需要的一些東西,合理的修改統一結構中的字段信息。

2. json的處理

Json 處理工具很多,比如阿里巴巴的 fastjson,不過 fastjson 對有些未知類型的 null 無法轉成空字符串,這可能是 fastjson 自身的缺陷,可擴展性也不是太好,但是使用起來方便,使用的人也蠻多的。這節課裏面我們主要集成 Spring Boot 自帶的 jackson。主要是對 jackson 做一下對 null 的配置即可,然後就可以在項目中使用了。

/**
 * jacksonConfig
 * @author shengwu ni
 */
@Configuration
public class JacksonConfig {
    @Bean
    @Primary
    @ConditionalOnMissingBean(ObjectMapper.class)
    public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();
        objectMapper.getSerializerProvider().setNullValueSerializer(new JsonSerializer<Object>() {
            @Override
            public void serialize(Object o, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
                jsonGenerator.writeString("");
            }
        });
        return objectMapper;
    }
}

這裏先不測試,等下面 swagger2 配置好了之後,我們一起來測試一下。

3. swagger2在線可調式接口

有了 swagger,開發人員不需要給其他人員提供接口文檔,只要告訴他們一個 Swagger 地址,即可展示在線的 API 接口文檔,除此之外,調用接口的人員還可以在線測試接口數據,同樣地,開發人員在開發接口時,同樣也可以利用 Swagger 在線接口文檔測試接口數據,這給開發人員提供了便利。使用 swagger 需要對其進行配置:

/**
 * swagger配置
 * @author shengwu ni
 */
@Configuration
@EnableSwagger2
public class SwaggerConfig {

    @Bean
    public Docket createRestApi() {
        return new Docket(DocumentationType.SWAGGER_2)
                // 指定構建api文檔的詳細信息的方法:apiInfo()
                .apiInfo(apiInfo())
                .select()
                // 指定要生成api接口的包路徑,這裏把controller作爲包路徑,生成controller中的所有接口
                .apis(RequestHandlerSelectors.basePackage("com.itcodai.course18.controller"))
                .paths(PathSelectors.any())
                .build();
    }

    /**
     * 構建api文檔的詳細信息
     * @return
     */
    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                // 設置頁面標題
                .title("Spring Boot搭建實際項目中開發的架構")
                // 設置接口描述
                .description("跟武哥一起學Spring Boot第18課")
                // 設置聯繫方式
                .contact("倪升武," + "微信公衆號:程序員私房菜")
                // 設置版本
                .version("1.0")
                // 構建
                .build();
    }
}

到這裏,可以先測試一下,寫一個 Controller,弄一個靜態的接口測試一下上面集成的內容。

@RestController
@Api(value = "用戶信息接口")
public class UserController {

    @Resource
    private UserService userService;

    @GetMapping("/getUser/{id}")
    @ApiOperation(value = "根據用戶唯一標識獲取用戶信息")
    public JsonResult<User> getUserInfo(@PathVariable @ApiParam(value = "用戶唯一標識") Long id) {
        User user = new User(id, "倪升武", "123456");
        return new JsonResult<>(user);
    }
}

然後啓動項目,在瀏覽器中輸入 localhost:8080/swagger-ui.html 即可看到 swagger 接口文檔頁面,調用一下上面這個接口,即可看到返回的 json 數據。

4. 持久層集成

每個項目中是必須要有持久層的,與數據庫交互,這裏我們主要來集成 mybatis,集成 mybatis 首先要在 application.yml 中進行配置。

# 服務端口號
server:
  port: 8080

# 數據庫地址
datasource:
  url: localhost:3306/blog_test

spring:
  datasource: # 數據庫配置
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://${datasource.url}?useSSL=false&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&autoReconnect=true&failOverReadOnly=false&maxReconnects=10
    username: root
    password: 123456
    hikari:
      maximum-pool-size: 10 # 最大連接池數
      max-lifetime: 1770000

mybatis:
  # 指定別名設置的包爲所有entity
  type-aliases-package: com.itcodai.course18.entity
  configuration:
    map-underscore-to-camel-case: true # 駝峯命名規範
  mapper-locations: # mapper映射文件位置
    - classpath:mapper/*.xml

配置好了之後,接下來我們來寫一下 dao 層,實際中我們使用註解比較多,因爲比較方便,當然也可以使用 xml 的方式,甚至兩種同時使用都行,這裏我們主要使用註解的方式來集成,關於 xml 的方式,大家可以查看前面課程,實際中根據項目情況來定。

public interface UserMapper {

    @Select("select * from user where id = #{id}")
    @Results({
            @Result(property = "username", column = "user_name"),
            @Result(property = "password", column = "password")
    })
    User getUser(Long id);

    @Select("select * from user where id = #{id} and user_name=#{name}")
    User getUserByIdAndName(@Param("id") Long id, @Param("name") String username);

    @Select("select * from user")
    List<User> getAll();
}

關於 service 層我就不在文章中寫代碼了,大家可以結合我的源代碼學習,這一節主要帶領大家來搭建一個 Spring Boot 空架構。最後別忘了在啓動類上添加註解掃描 @MapperScan("com.itcodai.course18.dao")

5. 攔截器

攔截器在項目中使用的是非常多的(但不是絕對的),比如攔截一些置頂的 url,做一些判斷和處理等等。除此之外,還需要將常用的靜態頁面或者 swagger 頁面放行,不能將這些靜態資源給攔截了。首先先自定義一個攔截器。

public class MyInterceptor implements HandlerInterceptor {

    private static final Logger logger = LoggerFactory.getLogger(MyInterceptor.class);

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        logger.info("執行方法之前執行(Controller方法調用之前)");
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        logger.info("執行完方法之後進執行(Controller方法調用之後),但是此時還沒進行視圖渲染");
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        logger.info("整個請求都處理完咯,DispatcherServlet也渲染了對應的視圖咯,此時我可以做一些清理的工作了");
    }
}

然後將自定義的攔截器加入到攔截器配置中。

@Configuration
public class MyInterceptorConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 實現WebMvcConfigurer不會導致靜態資源被攔截
        registry.addInterceptor(new MyInterceptor())
                // 攔截所有url
                .addPathPatterns("/**")
                // 放行swagger
                .excludePathPatterns("/swagger-resources/**");
    }
}

在 Spring Boot 中,我們通常會在如下目錄裏存放一些靜態資源:

classpath:/static
classpath:/public
classpath:/resources
classpath:/META-INF/resources

上面代碼中配置的 /** 是對所有 url 都進行了攔截,但我們實現了 WebMvcConfigurer 接口,不會導致 Spring Boot 對上面這些目錄下的靜態資源實施攔截。但是我們平時訪問的 swagger 會被攔截,所以要將其放行。swagger 頁面在 swagger-resources 目錄下,放行該目錄下所有文件即可。

然後在瀏覽器中輸入一下 swagger 頁面,若能正常顯示 swagger,說明放行成功。同時可以根據後臺打印的日誌判斷代碼執行的順序。

6. 全局異常處理

全局異常處理是每個項目中必須用到的東西,在具體的異常中,我們可能會做具體的處理,但是對於沒有處理的異常,一般會有一個統一的全局異常處理。在異常處理之前,最好維護一個異常提示信息枚舉類,專門用來保存異常提示信息的。如下:

public enum BusinessMsgEnum {
    /** 參數異常 */
    PARMETER_EXCEPTION("102", "參數異常!"),
    /** 等待超時 */
    SERVICE_TIME_OUT("103", "服務調用超時!"),
    /** 參數過大 */
    PARMETER_BIG_EXCEPTION("102", "輸入的圖片數量不能超過50張!"),
    /** 500 : 發生異常 */
    UNEXPECTED_EXCEPTION("500", "系統發生異常,請聯繫管理員!");

    /**
     * 消息碼
     */
    private String code;
    /**
     * 消息內容
     */
    private String msg;

    private BusinessMsgEnum(String code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public String code() {
        return code;
    }

    public String msg() {
        return msg;
    }

}

在全局統一異常處理類中,我們一般會對自定義的業務異常最先處理,然後去處理一些常見的系統異常,最後會來一個一勞永逸(Exception 異常)。

@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    /**
     * 攔截業務異常,返回業務異常信息
     * @param ex
     * @return
     */
    @ExceptionHandler(BusinessErrorException.class)
    @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
    public JsonResult handleBusinessError(BusinessErrorException ex) {
        String code = ex.getCode();
        String message = ex.getMessage();
        return new JsonResult(code, message);
    }

    /**
     * 空指針異常
     * @param ex NullPointerException
     * @return
     */
    @ExceptionHandler(NullPointerException.class)
    @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
    public JsonResult handleTypeMismatchException(NullPointerException ex) {
        logger.error("空指針異常,{}", ex.getMessage());
        return new JsonResult("500", "空指針異常了");
    }

    /**
     * 系統異常 預期以外異常
     * @param ex
     * @return
     */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
    public JsonResult handleUnexpectedServer(Exception ex) {
        logger.error("系統異常:", ex);
        return new JsonResult(BusinessMsgEnum.UNEXPECTED_EXCEPTION);
    }

}

其中,BusinessErrorException 是自定義的業務異常,繼承一下 RuntimeException 即可,具體可以看我的源代碼,文章中就不貼代碼了。
在 UserController 中有個 testException 方法,用來測試全局異常的,打開 swagger 頁面,調用一下該接口,可以看出返回用戶提示信息:”系統發生異常,請聯繫管理員!“。當然了,實際情況中,需要根據不同的業務提示不同的信息。



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章