我的博客:www.zhenganwen.top,文末有驚喜!html
本文中全部實例代碼已託管碼雲:gitee.com/zhenganwen/…前端
文末有驚喜!java
JDK1.8
Maven
spring-security-demo
mysql
父工程,用於整個項目的依賴git
security-core
github
安全認證核心模塊,security-browser
和security-app
都基於其來構建web
security-browser
redis
PC端瀏覽器受權,主要經過Session
spring
security-app
sql
移動端受權
security-demo
應用security-browser
和security-app
添加spring
依賴自動兼容依賴和編譯插件
<packaging>pom</packaging>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.spring.platform</groupId>
<artifactId>platform-bom</artifactId>
<version>Brussels-SR4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Dalston.SR2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.3.2</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
複製代碼
添加持久化、OAuth
認證、social
認證以及commons
工具類等依賴,一些依賴只是先加進來以備後用
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-web</artifactId>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
</dependency>
<dependency>
<groupId>commons-beanutils</groupId>
<artifactId>commons-beanutils</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.22</version>
<scope>compile</scope>
</dependency>
</dependencies>
複製代碼
添加security-core
和集羣管理依賴
<dependencies>
<dependency>
<groupId>top.zhenganwen</groupId>
<artifactId>security-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
</dependency>
</dependencies>
複製代碼
添加security-core
<dependencies>
<dependency>
<groupId>top.zhenganwen</groupId>
<artifactId>security-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
複製代碼
暫時引用security-browser
作PC端的驗證
<artifactId>security-demo</artifactId>
<dependencies>
<dependency>
<groupId>top.zhenganwen</groupId>
<artifactId>security-browser</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
複製代碼
在security-demo
中添加啓動類以下
package top.zhenganwen.securitydemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** * @author zhenganwen * @date 2019/8/18 * @desc SecurityDemoApplication */
@SpringBootApplication
@RestController
public class SecurityDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityDemoApplication.class, args);
}
@RequestMapping("/hello")
public String hello() {
return "hello spring security";
}
}
複製代碼
根據報錯信息添加mysql
鏈接信息
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/test?useUnicode=yes&characterEncoding=UTF-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=123456
複製代碼
暫時用不到session
集羣共享和redis
,先禁用掉
spring.session.store-type=none
複製代碼
@SpringBootApplication(exclude = {RedisAutoConfiguration.class,RedisRepositoriesAutoConfiguration.class})
@RestController
public class SecurityDemoApplication {
複製代碼
而後發現可以啓動成功了,然而訪問/hello
去發現提示咱們要登陸,這是Spring Security
的默認認證策略在起做用,咱們也先禁用它
security.basic.enabled = false
複製代碼
重啓訪問/hello
,頁面顯示hello spring security
,環境搭建成功
Restful
是一種HTTP接口編寫風格,而不是一種標準或規定。使用Restful
風格和傳統方式的區別主要以下
URL
中添加代表接口行爲的字符串和查詢參數,如/user/get?username=xxx
Restful
風格則推薦一個URL表明一個系統資源,/user/1
應表示訪問系統中id
爲1的用戶get
提交,弊端是get
提交會將請求參數附在URL上,而URL有長度限制,而且若不特殊處理,參數在URL上是明文顯示的,不安全。對上述兩點有要求的請求會使用post
提交Restful
風格推崇使用提交方式描述請求行爲,如POST
、DELETE
、PUT
、GET
應對應增、刪、改、查類型的請求Restful
風格提倡使用JSON
做爲先後端通信媒介,先後端分離;經過響應狀態碼來標識響應結果類型,如200
表示請求被成功處理,404
表示沒有找到相應資源,500
表示服務端處理異常。
Restful
詳解參考:www.runoob.com/w3cnote/res…
上述搭建的環境已經能經過IDE運行並訪問/hello
,可是生產環境通常是將項目打成一個可執行的jar
包,可以經過java -jar
直接運行。
此時若是咱們右鍵父工程運行maven
命令clean package
你會發現security-demo/target
中生成的jar
只有7KB
,這是由於maven
默認的打包方式是不會將其依賴的jar
進來而且設置springboot
啓動類的。這時咱們須要在security-demo
的pom
中添加一個打包插件
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>1.3.3.RELEASE</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
<!-- 生成的jar文件名 -->
<finalName>demo</finalName>
</build>
複製代碼
這樣再執行clean package
就會發現target
下生產了一個demo.jar
和demo.jar.original
,其中demo.jar
是可執行的,而demo.jar.original
是保留了maven
默認打包方式
秉着測試先行的原則(提倡先寫測試用例再寫接口,驗證程序按照咱們的想法運行),咱們須要藉助spring-boot-starter-test
測試框架和其中相關的MockMvc
API。mock
爲打樁的意思,意爲使用測試用例將程序打造牢固。
首先在security-demo
中添加測試依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
複製代碼
而後在src/test/java
中新建測試類以下
package top.zhenganwen.securitydemo;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.c.status;
/** * @author zhenganwen * @date 2019/8/18 * @desc SecurityDemoApplicationTest */
@RunWith(SpringRunner.class)
@SpringBootTest
public class SecurityDemoApplicationTest {
@Autowired
WebApplicationContext webApplicationContext;
private MockMvc mockMvc;
@Before
public void before() {
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
}
@Test
public void hello() throws Exception {
mockMvc.perform(get("/hello").contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk())
.andExpect(jsonPath("$").value("hello spring security"));
}
}
複製代碼
由於是測試HTTP接口,所以須要注入web容器WebApplicationContext
。其中get()
、status()
、jsonPath()
都是靜態導入的方法,測試代碼的意思是經過GET
提交方式請求/hello
(get("/hello")
)並附加請求頭爲Content-Type: application/json
(這樣參數就會以json
的方式附在請求體中,是的沒錯,GET
請求也是能夠附帶請求體的!)
andExpect(status().isOk())
指望響應狀態碼爲200
(參見HTTP狀態碼),andExpect((jsonPath("$").value("hello spring security"))
指望響應的JSON
數據是一個字符串且內容爲hello spring security
(該方法依賴JSON
解析框架jsonpath
,$
表示JSON
本體在Java中對應的數據類型對象,更多API詳見:github.com/search?q=js…
其中比較重要的API爲MockMvc
、MockMvcRequestBuilders
、MockMvcRequestBuilders
MockMvc
,調用perform
指定接口地址MockMvcRequestBuilders
,構建請求(包括請求路徑、提交方式、請求頭、請求體等)MockMvcRequestBuilders
,斷言響應結果,如響應狀態碼、響應體用於標識一個Controller
爲Restful Controller
,其中方法的返回結果會被SpringMVC
自動轉換爲JSON
並設置響應頭爲Content-Type=application/json
用於將URL映射到方法上,而且SpringMVC
會自動將請求參數按照按照參數名對應關係綁定到方法入參上
package top.zhenganwen.securitydemo.dto;
import lombok.Data;
import java.io.Serializable;
/** * @author zhenganwen * @date 2019/8/18 * @desc User */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
private String username;
private String password;
}
複製代碼
package top.zhenganwen.securitydemo.web.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import top.zhenganwen.securitydemo.dto.User;
import java.util.Arrays;
import java.util.List;
/** * @author zhenganwen * @date 2019/8/18 * @desc UserController */
@RestController
public class UserController {
@GetMapping("/user")
public List<User> query(String username) {
System.out.println(username);
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}
}
複製代碼
package top.zhenganwen.securitydemo.web.controller;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** * @author zhenganwen * @date 2019/8/18 * @desc UserControllerTest */
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserControllerTest {
@Autowired
private WebApplicationContext webApplicationContext;
private MockMvc mockMvc;
@Before
public void setUp() throws Exception {
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
}
@Test
public void query() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.param("username", "tom"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(3));
}
}
複製代碼
經過MockMvcRequestBuilders.param
能夠爲請求附帶URL形式參數。
若是沒有經過method
屬性指定提交方式,那麼全部的提交方式都會被受理,但若是設置@RequestMapping(method = RequestMethod.GET)
,那麼只有GET
請求會被受理,其餘提交方式都會致使405 unsupported request method
上例代碼,若是請求不附帶參數username
,那麼Controller
的參數就會被賦予數據類型默認值。若是你想請求必須攜帶該參數,不然不予處理,那麼就可使用@RequestParam
並指定required=true
(不指定也能夠,默認就是)
Controller
@GetMapping("/user")
public List<User> query(@RequestParam String username) {
System.out.println(username);
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}
複製代碼
ControllerTest
@Test
public void testBadRequest() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().is4xxClientError());
}
複製代碼
由於請求沒有附帶參數username
,因此會報錯400 bad request
,咱們可使用is4xxClientError()
對響應狀態碼爲400
的請求進行斷言
SpringMVC
默認是按參數名相同這一規則映射參數值得,若是你想將請求中參數username
的值綁定到方法參數userName
上,能夠經過name
屬性或value
屬性
@GetMapping("/user")
public List<User> query(@RequestParam(name = "username") String userName) {
System.out.println(userName);
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}
@GetMapping("/user")
public List<User> query(@RequestParam("username") String userName) {
System.out.println(userName);
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}
複製代碼
@Test
public void testParamBind() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.param("username", "tom"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(3));
}
複製代碼
若是但願不強制請求攜帶某參數,但又但願方法參數在沒有接收到參數值時能有個默認值(例如「」
比null
更不容易報錯),那麼能夠經過defaultValue
屬性
@GetMapping("/user")
public List<User> query(@RequestParam(required = false,defaultValue = "") String userName) {
Objects.requireNonNull(userName);
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}
複製代碼
@Test
public void testDefaultValue() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(3));
}
複製代碼
若是請求附帶的參數較多,而且各參數都隸屬於某個對象的屬性,那麼將它們一一寫在方法參列比較冗餘,咱們能夠將它們統一封裝到一個數據傳輸對象(Data Transportation Object DTO
)中,如
package top.zhenganwen.securitydemo.dto;
import lombok.Data;
/** * @author zhenganwen * @date 2019/8/19 * @desc UserCondition */
@Data
public class UserQueryConditionDto {
private String username;
private String password;
private String phone;
}
複製代碼
而後在方法入參填寫該對象便可,SpringMVC
會幫咱們實現請求參數到對象屬性的綁定(默認綁定規則是參數名一致)
@GetMapping("/user")
public List<User> query(@RequestParam("username") String userName, UserQueryConditionDto userQueryConditionDto) {
System.out.println(userName);
System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto, ToStringStyle.MULTI_LINE_STYLE));
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}
複製代碼
ReflectionToStringBuilder
反射工具類可以在對象沒有重寫toString
方法時經過反射幫咱們查看對象的屬性。
@Test
public void testDtoBind() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.param("username", "tom")
.param("password", "123456")
.param("phone", "12345678911"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(3));
}
複製代碼
而且不用擔憂會和@RequestParam
衝突,輸出以下
tom
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[
username=tom
password=123456
phone=12345678911
]
複製代碼
可是,若是不給userName
添加@RequestParam
註解,那麼它接收到的將是一個null
null
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@440ef8d[
username=tom
password=123456
phone=12345678911
]
複製代碼
spring-data
家族(如spring-boot-data-redis
)幫咱們封裝了一個分頁DTOPageable
,會將咱們傳遞的分頁參數size
(每頁行數)、page
(當前頁碼)、sort
(排序字段和排序策略)自動綁定到自動注入的Pageable
實例中
@GetMapping("/user")
public List<User> query(String userName, UserQueryConditionDto userQueryConditionDto, Pageable pageable) {
System.out.println(userName);
System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto, ToStringStyle.MULTI_LINE_STYLE));
System.out.println(pageable.getPageNumber());
System.out.println(pageable.getPageSize());
System.out.println(pageable.getSort());
List<User> users = Arrays.asList(new User(), new User(), new User());
return users;
}
複製代碼
@Test
public void testPageable() throws Exception {
mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.param("username", "tom")
.param("password", "123456")
.param("phone", "12345678911")
.param("page", "2")
.param("size", "30")
.param("sort", "age,desc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(3));
}
複製代碼
null
top.zhenganwen.securitydemo.dto.UserQueryConditionDto@24e5389c[
username=tom
password=123456
phone=12345678911
]
2
30
age: DESC
複製代碼
最多見的Restful URL
,像GET /user/1
獲取id
爲1
的用戶的信息,這時咱們在編寫接口時須要將路徑中的1
替換成一個佔位符如{id}
,根據實際的URL請求動態的綁定到方法參數id
上
@GetMapping("/user/{id}")
public User info(@PathVariable("id") Long id) {
System.out.println(id);
return new User("jack","123");
}
複製代碼
@Test
public void testPathVariable() throws Exception {
mockMvc.perform(get("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("jack"));
}
1
複製代碼
當方法參數名和URL佔位符變量名一致時,能夠省去@PathVariable
的value
屬性
有時咱們須要對URL的匹配作細粒度的控制,例如/user/1
會匹配到/user/{id}
,而/user/xxx
則不會匹配到/user/{id}
@GetMapping("/user/{id:\\d+}")
public User getInfo(@PathVariable("id") Long id) {
System.out.println(id);
return new User("jack","123");
}
複製代碼
@Test
public void testRegExSuccess() throws Exception {
mockMvc.perform(get("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk());
}
@Test
public void testRegExFail() throws Exception {
mockMvc.perform(get("/user/abc").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().is4xxClientError());
}
複製代碼
有時咱們須要對響應對象的某些字段進行過濾,例如查詢全部用戶時不顯示password
字段,根據id
查詢用戶時則顯示password
字段,這時能夠經過@JsonView
註解實現此類功能
這裏視圖指的就是一種字段包含策略,後面添加@JsonView
時會用到
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
/** * 普通視圖,返回用戶基本信息 */
public interface UserOrdinaryView {
}
/** * 詳情視圖,除了普通視圖包含的字段,還返回密碼等詳細信息 */
public interface UserDetailsView extends UserOrdinaryView{
}
private String username;
private String password;
}
複製代碼
視圖和視圖之間能夠存在繼承關係,繼承視圖後會繼承該視圖包含的字段
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
/** * 普通視圖,返回用戶基本信息 */
public interface UserOrdinaryView {
}
/** * 詳情視圖,除了普通視圖包含的字段,還返回密碼等詳細信息 */
public interface UserDetailsView extends UserOrdinaryView{
}
@JsonView(UserOrdinaryView.class)
private String username;
@JsonView(UserDetailsView.class)
private String password;
}
複製代碼
@GetMapping("/user")
@JsonView(User.UserBasicView.class)
public List<User> query(String userName, UserQueryConditionDto userQueryConditionDto, Pageable pageable) {
System.out.println(userName);
System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto, ToStringStyle.MULTI_LINE_STYLE));
System.out.println(pageable.getPageNumber());
System.out.println(pageable.getPageSize());
System.out.println(pageable.getSort());
List<User> users = Arrays.asList(new User("tom","123"), new User("jack","456"), new User("alice","789"));
return users;
}
@GetMapping("/user/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
System.out.println(id);
return new User("jack","123");
}
複製代碼
@Test
public void testUserBasicViewSuccess() throws Exception {
MvcResult mvcResult = mockMvc.perform(get("/user").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk())
.andReturn();
System.out.println(mvcResult.getResponse().getContentAsString());
}
[{"username":"tom"},{"username":"jack"},{"username":"alice"}]
@Test
public void testUserDetailsViewSuccess() throws Exception {
MvcResult mvcResult = mockMvc.perform(get("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk())
.andReturn();
System.out.println(mvcResult.getResponse().getContentAsString());
}
{"username":"jack","password":"123"}
複製代碼
重構須要 小步快跑,即每寫完一部分功能都要回頭來看一下有哪些須要優化的地方
代碼中兩個方法都的RequestMapping
都用了/user
,咱們能夠將其提至類上以供複用
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping
@JsonView(User.UserBasicView.class)
public List<User> query(String userName, UserQueryConditionDto userQueryConditionDto, Pageable pageable) {
System.out.println(userName);
System.out.println(ReflectionToStringBuilder.toString(userQueryConditionDto, ToStringStyle.MULTI_LINE_STYLE));
System.out.println(pageable.getPageNumber());
System.out.println(pageable.getPageSize());
System.out.println(pageable.getSort());
List<User> users = Arrays.asList(new User("tom","123"), new User("jack","456"), new User("alice","789"));
return users;
}
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
System.out.println(id);
return new User("jack","123");
}
}
複製代碼
雖然是一個很細節的問題,可是必定要有這個思想和習慣
別忘了重構後從新運行一遍全部的測試用例,確保重構沒有更改程序行爲
SpringMVC
默認不會解析請求體中的參數並綁定到方法參數
@PostMapping
public void createUser(User user) {
System.out.println(user);
}
複製代碼
@Test
public void testCreateUser() throws Exception {
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"123\"}"))
.andExpect(status().isOk());
}
User(id=null, username=null, password=null)
複製代碼
使用@RequestBody
能夠將請求體中的JSON
數據解析成Java對象並綁定到方法入參
@PostMapping
public void createUser(@RequestBody User user) {
System.out.println(user);
}
複製代碼
@Test
public void testCreateUser() throws Exception {
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"123\"}"))
.andExpect(status().isOk());
}
User(id=null, username=jack, password=123)
複製代碼
若是須要將時間類型數據綁定到Bean
的Date
字段上,網上常見的解決方案是加一個json
消息轉換器進行格式化,這樣的話就將日期的顯示邏輯寫死在後端的。
比較好的作法應該是後端只保存時間戳,傳給前端時也只傳時間戳,將格式化顯示的責任交給前端,前端愛怎麼顯示怎麼顯示
@PostMapping
public void createUser(@RequestBody User user) {
System.out.println(user);
}
複製代碼
@Test
public void testDateBind() throws Exception {
Date date = new Date();
System.out.println(date.getTime());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
1566212381139
User(id=null, username=jack, password=123, birthday=Mon Aug 19 18:59:41 CST 2019)
複製代碼
在Controller
方法中,咱們常常須要對請求參數進行合法性校驗後再執行處理邏輯,傳統的寫法是使用if
判斷
@PostMapping
public void createUser(@RequestBody User user) {
if (StringUtils.isBlank(user.getUsername())) {
throw new IllegalArgumentException("用戶名不能爲空");
}
if (StringUtils.isBlank(user.getPassword())) {
throw new IllegalArgumentException("密碼不能爲空");
}
System.out.println(user);
}
複製代碼
可是若是其餘地方也須要校驗就須要編寫重複的代碼,一旦校驗邏輯發生改變就須要改變多處,而且若是有所遺漏還會給程序埋下隱患。有點重構意識的可能會將每一個校驗邏輯單獨封裝一個方法,但仍顯冗餘。
SpringMVC Restful
則推薦使用@Valid
來實現參數的校驗,而且未經過校驗的會響應400 bad request
給前端,以狀態碼錶示處理結果(及請求格式不對),而不是像上述代碼同樣直接拋異常致使前端收到的狀態碼是500
首先咱們要使用hibernate-validator
校驗框架提供的一些約束註解來約束Bean
字段
@NotBlank
@JsonView(UserBasicView.class)
private String username;
@NotBlank
@JsonView(UserDetailsView.class)
private String password;
複製代碼
僅添加這些註解,SpringMVC
是不會幫咱們校驗的
@PostMapping
public void createUser(@RequestBody User user) {
System.out.println(user);
}
複製代碼
@Test
public void testConstraintValidateFail() throws Exception {
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"\"}"))
.andExpect(status().isOk());
}
User(id=null, username=, password=null, birthday=null)
複製代碼
咱們還要在須要校驗的Bean
前添加@Valid
註解,這樣SpringMVC
會根據咱們在該Bean
中添加的約束註解進行校驗,在校驗不經過時響應400 bad request
@PostMapping
public void createUser(@Valid @RequestBody User user) {
System.out.println(user);
}
複製代碼
@Test
public void testConstraintValidateSuccess() throws Exception {
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"\"}"))
.andExpect(status().is4xxClientError());
}
複製代碼
hibernate-validator
提供的約束註解以下
例如,建立用戶時限制請求參數中的birthday
的值是一個過去時間
首先在Bean
的字段添加約束註解
@Past
private Date birthday;
複製代碼
而後在要驗證的Bean
前添加@Valid
註解
@PostMapping
public void createUser(@Valid @RequestBody User user) {
System.out.println(user);
}
複製代碼
@Test
public void testValidatePastTimeSuccess() throws Exception {
// 獲取一年前的時間點
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
@Test
public void testValidatePastTimeFail() throws Exception {
// 獲取一年後的時間點
Date date = new Date(LocalDateTime.now().plusYears(1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().is4xxClientError());
}
複製代碼
這樣,若是咱們須要對修改用戶的方法添加校驗,只需添加@Valid
便可
@PutMapping("/{id}")
public void update(@Valid @RequestBody User user, @PathVariable Long id) {
System.out.println(user);
System.out.println(id);
}
複製代碼
@Test
public void testUpdateSuccess() throws Exception {
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\"789\"}"))
.andExpect(status().isOk());
}
User(id=null, username=jack, password=789, birthday=null)
1
@Test
public void testUpdateFail() throws Exception {
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":\" \"}"))
.andExpect(status().is4xxClientError());
}
複製代碼
約束邏輯只需在Bean
中經過約束註解聲明一次,其餘任何須要使用到該約束校驗的地方只需添加@Valid
便可
上述處理方式仍是不夠完美,咱們只是經過響應狀態碼告訴前端請求數據格式不對,可是沒有明確指明哪裏不對,咱們須要給前端一些更明確的信息
上例中,若是沒有經過校驗,那麼方法就不會被執行而直接返回了,咱們想要插入一些提示信息都沒有辦法編寫。這時可使用BindingResult
,它可以幫助咱們獲取校驗失敗信息並返回給前端,同時響應狀態碼會變爲200
@PostMapping
public void createUser(@Valid @RequestBody User user,BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
}
System.out.println(user);
}
@PutMapping("/{id}")
public void update(@PathVariable Long id,@Valid @RequestBody User user, BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
}
System.out.println(user);
System.out.println(id);
}
複製代碼
@Test
public void testBindingResult() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
may not be empty User(id=null, username=jack, password=null, birthday=Sun Aug 19 20:44:02 CST 2018) @Test public void testBindingResult2() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
may not be empty User(id=null, username=jack, password=null, birthday=Sun Aug 19 20:42:56 CST 2018) 1 複製代碼
值得注意的是,
BindingResult
必須和@Valid
一塊兒使用,而且在參列中的位置必須緊跟在@Valid
修飾的參數後面,不然會出現以下使人困惑的結果
@PutMapping("/{id}")
public void update(@Valid @RequestBody User user, @PathVariable Long id, BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
}
System.out.println(user);
System.out.println(id);
}
複製代碼
上述代碼中,在校驗的Bean
和BindingResult
之間插入了一個id
,你會發現BindingResult
不起做用了
@Test
public void testBindingResult2() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"jack\",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
java.lang.AssertionError: Status
Expected :200
Actual :400
複製代碼
如今咱們能夠經過BindingResult
獲得校驗失敗信息了
@PutMapping("/{id:\\d+}")
public void update(@PathVariable Long id, @Valid @RequestBody User user, BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> {
FieldError fieldError = (FieldError) error;
System.out.println(fieldError.getField() + " " + fieldError.getDefaultMessage());
});
}
System.out.println(user);
}
複製代碼
@Test
public void testBindingResult3() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\" \",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
password may not be empty username may not be empty User(id=null, username= , password=null, birthday=Sun Aug 19 20:56:35 CST 2018) 複製代碼
可是默認的消息提示不太友好而且還須要咱們本身拼接,這時咱們須要自定義消息提示,只須要使用約束註解的message
屬性指定驗證未經過的提示消息便可
@NotBlank(message = "用戶名不能爲空")
@JsonView(UserBasicView.class)
private String username;
@NotBlank(message = "密碼不能爲空")
@JsonView(UserDetailsView.class)
private String password;
複製代碼
@Test
public void testBindingResult3() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(put("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\" \",\"password\":null,\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
password 密碼不能爲空
username 用戶名不能爲空
User(id=null, username= , password=null, birthday=Sun Aug 19 21:03:18 CST 2018)
複製代碼
雖然hibernate-validator
提供了一些經常使用的約束註解,可是對於複雜的業務場景仍是須要咱們自定義一個約束註解,畢竟有時僅僅是非空或格式合法的校驗是不夠的,可能咱們須要去數據庫查詢進行校驗
下面咱們就參考已有的約束註解照葫蘆畫瓢自定義一個「用戶名不可重複」的約束註解
咱們但願該註解標註在Bean
的某些字段上,使用@Target({FIELD})
;此外,要想該註解在運行期起做用,還要添加@Retention(RUNTIME)
package top.zhenganwen.securitydemo.annotation.valid;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/** * @author zhenganwen * @date 2019/8/20 * @desc Unrepeatable */
@Target({FIELD})
@Retention(RUNTIME)
public @interface Unrepeatable {
}
複製代碼
參考已有的約束註解如NotNull
、NotBlank
,它們都有三個方法
String message() default "{org.hibernate.validator.constraints.NotBlank.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
複製代碼
因而咱們也聲明這三個方法
@Target({FIELD})
@Retention(RUNTIME)
public @interface Unrepeatable {
String message() default "用戶名已被註冊";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
複製代碼
依照已有註解,它們都還有一個註解@Constraint
@Documented
@Constraint(validatedBy = { })
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@ReportAsSingleViolation
@NotNull
public @interface NotBlank {
複製代碼
按住Ctrl
點擊validateBy
屬性進行查看,發現它須要一個ConstraintValidator
的實現類,如今咱們須要編寫一個ConstraintValidator
自定義校驗邏輯並經過validatedBy
屬性將其綁定到咱們的Unrepeatable
註解上
package top.zhenganwen.securitydemo.annotation.valid;
import org.springframework.beans.factory.annotation.Autowired;
import top.zhenganwen.securitydemo.service.UserService;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
/** * @author zhenganwen * @date 2019/8/20 * @desc UsernameUnrepeatableValidator */
public class UsernameUnrepeatableValidator implements ConstraintValidator<Unrepeatable,String> {
@Autowired
private UserService userService;
@Override
public void initialize(Unrepeatable unrepeatableAnnotation) {
System.out.println(unrepeatableAnnotation);
System.out.println("UsernameUnrepeatableValidator initialized===================");
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
System.out.println("the request username is " + value);
boolean ifExists = userService.checkUsernameIfExists( value);
// 若是用戶名存在,則拒絕請求並提示用戶名已被註冊,不然處理請求
return ifExists == true ? false : true;
}
}
複製代碼
其中,ConstraintValidator<A,T>
泛型A
指定爲要綁定到的註解,T
指定要校驗字段的類型;isValid
用來編寫自定義校驗邏輯,如查詢數據庫是否存在該用戶名的記錄,返回true
表示校驗經過,false
校驗失敗
@ComponentScan
掃描範圍內的ConstraintValidator
實現類會被Spring
注入到容器中,所以你無須在該類上標註Component
便可在類中注入其餘Bean
,例如本例中注入了一個UserService
package top.zhenganwen.securitydemo.service;
import org.springframework.stereotype.Service;
import java.util.Objects;
/** * @author zhenganwen * @date 2019/8/20 * @desc UserService */
@Service
public class UserService {
public boolean checkUsernameIfExists(String username) {
// select count(username) from user where username=?
// as if username "tom" has been registered
if (Objects.equals(username, "tom")) {
return true;
}
return false;
}
}
複製代碼
經過validatedBy
屬性指定該註解綁定的一系列校驗類(這些校驗類必須是ConstraintValidator<A,T>
的實現類
@Target({FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = { UsernameUnrepeatableValidator.class})
public @interface Unrepeatable {
String message() default "用戶名已被註冊";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
複製代碼
@PostMapping
public void createUser(@Valid @RequestBody User user,BindingResult errors) {
if (errors.hasErrors()) {
errors.getAllErrors().stream().forEach(error -> System.out.println(error.getDefaultMessage()));
}
System.out.println(user);
}
複製代碼
@Test
public void testCreateUserWithNewUsername() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"alice\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
the request username is alice User(id=null, username=alice, password=123, birthday=Mon Aug 20 08:25:11 CST 2018) @Test public void testCreateUserWithExistedUsername() throws Exception {
Date date = new Date(LocalDateTime.now().plusYears(-1).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
mockMvc.perform(post("/user").
contentType(MediaType.APPLICATION_JSON_UTF8)
.content("{\"username\":\"tom\",\"password\":\"123\",\"birthday\":\"" + date.getTime() + "\"}"))
.andExpect(status().isOk());
}
the request username is tom
用戶名已被註冊
User(id=null, username=tom, password=123, birthday=Mon Aug 20 08:25:11 CST 2018)
複製代碼
@Test
public void testDeleteUser() throws Exception {
mockMvc.perform(delete("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk());
}
java.lang.AssertionError: Status
Expected :200
Actual :405
複製代碼
測試先行,即先寫測試用例後寫功能代碼,即便咱們知道沒有編寫該功能測試確定不會經過,但測試代碼也是須要檢驗的,確保測試邏輯的正確性
Restful
提倡以響應狀態碼來表示請求處理結果,例如200表示刪除成功,若沒有特別要求須要返回某些信息,那麼無需添加響應體
@DeleteMapping("/{id:\\d+}")
public void delete(@PathVariable Long id) {
System.out.println(id);
// delete user
}
複製代碼
@Test
public void testDeleteUser() throws Exception {
mockMvc.perform(delete("/user/1").
contentType(MediaType.APPLICATION_JSON_UTF8))
.andExpect(status().isOk());
}
1
複製代碼
當請求處理髮生錯誤時,SpringMVC
根據客戶端的類型會有不一樣的響應結果,例如瀏覽器訪問localhost:8080/xxx
會返回以下錯誤頁面
而使用Postman
請求則會獲得以下響應
{
"timestamp": 1566268880358,
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/xxx"
}
複製代碼
該機制對應的源碼在BasicErrorController
中(發生4xx
或500
異常時,會將請求轉發到/error
,由BasicErrorController
決定異常響應邏輯)
@RequestMapping(produces = "text/html")
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
HttpStatus status = getStatus(request);
Map<String, Object> model = Collections.unmodifiableMap(getErrorAttributes(
request, isIncludeStackTrace(request, MediaType.TEXT_HTML)));
response.setStatus(status.value());
ModelAndView modelAndView = resolveErrorView(request, response, status, model);
return (modelAndView == null ? new ModelAndView("error", model) : modelAndView);
}
@RequestMapping
@ResponseBody
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
Map<String, Object> body = getErrorAttributes(request,
isIncludeStackTrace(request, MediaType.ALL));
HttpStatus status = getStatus(request);
return new ResponseEntity<Map<String, Object>>(body, status);
}
複製代碼
若是是瀏覽器發出的請求,它的請求頭會附帶Accept: text/html...
,而Postman
發出的請求則是Accept: */*
,所以前者會執行errorHtml
響應錯誤頁面,而error
會收集異常信息以map
的形式返回
對於客戶端是瀏覽器的錯誤響應,例如404/500,咱們能夠在src/main/resources/resources/error
文件夾下編寫自定義錯誤頁面,SpringMVC
會在發生相應異常時返回該文件夾下的404.html
或500.html
建立src/main/resources/resources/error
文件夾並添加404.html
和500.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>頁面找不到了</title>
</head>
<body>
抱歉,頁面找不到了!
</body>
</html>
複製代碼
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>服務異常</title>
</head>
<body>
服務端內部錯誤
</body>
</html>
複製代碼
模擬處理請求時發生異常
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
throw new RuntimeException("id不存在");
// System.out.println(id);
// return new User(1L, "jack", "123");
// return null;
}
複製代碼
訪問localhost:8080/xxx
顯示404.html
頁面,訪問localhost:8080/user/1
顯示500.html
頁面
值得注意的是,自定義異常頁面並不會致使非瀏覽器請求也會響應該頁面
對於4XX
的客戶端錯誤,SpringMVC
會直接返回錯誤響應和不會執行Controller
方法;對於500
的服務端拋出異常,則會收集異常類的message
字段值返回
例如客戶端錯誤,GET /user/1
{
"timestamp": 1566270327128,
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.RuntimeException",
"message": "id不存在",
"path": "/user/1"
}
複製代碼
例如服務端錯誤
@PostMapping
public void createUser(@Valid @RequestBody User user) {
System.out.println(user);
}
複製代碼
POST localhost:8080/user
Body {}
複製代碼
{
"timestamp": 1566272056042,
"status": 400,
"error": "Bad Request",
"exception": "org.springframework.web.bind.MethodArgumentNotValidException",
"errors": [
{
"codes": [
"NotBlank.user.username",
"NotBlank.username",
"NotBlank.java.lang.String",
"NotBlank"
],
"arguments": [
{
"codes": [
"user.username",
"username"
],
"arguments": null,
"defaultMessage": "username",
"code": "username"
}
],
"defaultMessage": "用戶名不能爲空",
"objectName": "user",
"field": "username",
"rejectedValue": null,
"bindingFailure": false,
"code": "NotBlank"
},
{
"codes": [
"NotBlank.user.password",
"NotBlank.password",
"NotBlank.java.lang.String",
"NotBlank"
],
"arguments": [
{
"codes": [
"user.password",
"password"
],
"arguments": null,
"defaultMessage": "password",
"code": "password"
}
],
"defaultMessage": "密碼不能爲空",
"objectName": "user",
"field": "password",
"rejectedValue": null,
"bindingFailure": false,
"code": "NotBlank"
}
],
"message": "Validation failed for object='user'. Error count: 2",
"path": "/user"
}
複製代碼
有時咱們須要常常在處理請求時拋出異常以終止對該請求的處理,例如
package top.zhenganwen.securitydemo.web.exception.response;
import lombok.Data;
import java.io.Serializable;
/** * @author zhenganwen * @date 2019/8/20 * @desc IdNotExistException */
@Data
public class IdNotExistException extends RuntimeException {
private Serializable id;
public IdNotExistException(Serializable id) {
super("id不存在");
this.id = id;
}
}
複製代碼
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
throw new IdNotExistException(id);
}
複製代碼
GET /user/1
{
"timestamp": 1566270990177,
"status": 500,
"error": "Internal Server Error",
"exception": "top.zhenganwen.securitydemo.exception.response.IdNotExistException",
"message": "id不存在",
"path": "/user/1"
}
複製代碼
SpringMVC
默認只會將異常的message
返回,若是咱們須要將IdNotExistException
的id
也返回以給前端更明確的提示,就須要咱們自定義異常處理
@ControllerAdvice
@ExceptionHandler
聲明該方法要截獲哪些異常,全部的Controller
若拋出這些異常中的一個則會轉爲執行該方法Controller
方法返回的結果意義相同,若是須要返回json
則需在方法上添加@ResponseBody
註解,若是在類上添加該註解則表示每一個方法都有該註解package top.zhenganwen.securitydemo.web.exception.handler;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import top.zhenganwen.securitydemo.web.exception.response.IdNotExistException;
import java.util.HashMap;
import java.util.Map;
/** * @author zhenganwen * @date 2019/8/20 * @desc UserControllerExceptionHandler */
@ControllerAdvice
@ResponseBody
public class UserControllerExceptionHandler {
@ExceptionHandler(IdNotExistException.class)
public Map<String, Object> handleIdNotExistException(IdNotExistException e) {
Map<String, Object> jsonResult = new HashMap<>();
jsonResult.put("message", e.getMessage());
jsonResult.put("id", e.getId());
return jsonResult;
}
}
複製代碼
重啓後使用Postman GET /user/1
獲得響應以下
{
"id": 1,
"message": "id不存在"
}
複製代碼
需求:記錄全部請求 的處理時間
過濾器是JavaEE
中的標準,是不依賴SpringMVC
的,要想在SpringMVC
中使用過濾器須要兩步
Filter
接口並注入到Spring容器package top.zhenganwen.securitydemo.web.filter;
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
/** * @author zhenganwen * @date 2019/8/20 * @desc TimeFilter */
@Component
public class TimeFilter implements Filter {
// 在web容器啓動時執行
@Override
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("TimeFilter init");
}
// 在收到請求時執行,這時請求還未到達SpringMVC的入口DispatcherServlet
// 單次請求只會執行一次(不論期間發生了幾回請求轉發)
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
String service = "【" + request.getMethod() + " " + request.getRequestURI() + "】";
System.out.println("[TimeFilter] 收到服務調用:" + service);
Date start = new Date();
System.out.println("[TimeFilter] 開始執行服務" + service + simpleDateFormat.format(start));
filterChain.doFilter(servletRequest, servletResponse);
Date end = new Date();
System.out.println("[TimeFilter] 服務" + service + "執行完畢 " + simpleDateFormat.format(end) +
",共耗時:" + (end.getTime() - start.getTime()) + "ms");
}
// 在容器銷燬時執行
@Override
public void destroy() {
System.out.println("TimeFilter destroyed");
}
}
複製代碼
FilterRegistrationBean
,這一步至關於傳統方式在web.xml
中添加一個<Filter>
節點package top.zhenganwen.securitydemo.web.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.securitydemo.web.filter.TimeFilter;
/** * @author zhenganwen * @date 2019/8/20 * @desc WebConfig */
@Configuration
public class WebConfig {
@Autowired
TimeFilter timeFilter;
// 添加這個bean至關於在web.xml中添加一個Fitler節點
@Bean
public FilterRegistrationBean registerTimeFilter() {
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(timeFilter);
return filterRegistrationBean;
}
}
複製代碼
訪問GET /user/1
,控制檯日誌以下
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
// throw new IdNotExistException(id);
User user = new User();
return user;
}
複製代碼
[TimeFilter] 收到服務調用:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 02:13:44
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 02:13:44,共耗時:4ms
複製代碼
因爲Filter
是JavaEE
中的標準,因此它僅依賴servlet-api
而不依賴任何第三方類庫,所以它天然也不知道Controller
的存在,天然也就沒法知道本次請求將被映射到哪一個方法上,SpringMVC
經過引入攔截器彌補了這一缺點
經過filterRegistrationBean.addUrlPattern
能夠爲過濾器添加攔截規則,默認的攔截規則是全部URL
@Bean
public FilterRegistrationBean registerTimeFilter() {
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(timeFilter);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
複製代碼
攔截器與Filter
的有以下不一樣之處
Filter
是基於請求的,Interceptor
是基於Controller
的,一次請求可能會執行多個Controller
(經過轉發),所以一次請求只會執行一次Filter
但可能執行屢次Interceptor
Interceptor
是SpringMVC
中的組件,所以它知道Controller
的存在,可以獲取相關信息(如該請求映射的方法,方法所在的bean
等)使用SpringMVC
提供的攔截器也須要兩步
HandlerInterceptor
接口package top.zhenganwen.securitydemo.web.interceptor;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.text.SimpleDateFormat;
import java.util.Date;
/** * @author zhenganwen * @date 2019/8/20 * @desc TimeInterceptor */
@Component
public class TimeInterceptor implements HandlerInterceptor {
/** * 在Controller方法執行前被執行 * @param httpServletRequest * @param httpServletResponse * @param handler 處理器(Controller方法的封裝) * @return true 會接着執行Controller方法 * false 不會執行Controller方法,直接響應200 * @throws Exception */
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
Date start = new Date();
System.out.println("[TimeInterceptor # preHandle] 服務" + service + "被調用 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(start));
httpServletRequest.setAttribute("start", start.getTime());
return true;
}
/** * 在Controller方法正常執行完畢後執行,若是Controller方法拋出異常則不會執行此方法 * @param httpServletRequest * @param httpServletResponse * @param handler * @param modelAndView Controller方法返回的視圖 * @throws Exception */
@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler, ModelAndView modelAndView) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
Date end = new Date();
System.out.println("[TimeInterceptor # postHandle] 服務" + service + "調用結束 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(end)
+ " 共耗時:" + (end.getTime() - (Long) httpServletRequest.getAttribute("start")) + "ms");
}
/** * 不管Controller方法是否拋出異常,都會被執行 * @param httpServletRequest * @param httpServletResponse * @param handler * @param e 若是Controller方法拋出異常則爲對應拋出的異常,不然爲null * @throws Exception */
@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler, Exception e) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
String service = "【" + handlerMethod.getBean() + "#" + handlerMethod.getMethod().getName() + "】";
Date end = new Date();
System.out.println("[TimeInterceptor # afterCompletion] 服務" + service + "調用結束 " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(end)
+ " 共耗時:" + (end.getTime() - (Long) httpServletRequest.getAttribute("start")) + "ms");
if (e != null) {
System.out.println("[TimeInterceptor#afterCompletion] 服務" + service + "調用異常:" + e.getMessage());
}
}
}
複製代碼
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {
@Autowired
TimeFilter timeFilter;
@Autowired
TimeInterceptor timeInterceptor;
@Bean
public FilterRegistrationBean registerTimeFilter() {
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(timeFilter);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(timeInterceptor);
}
}
複製代碼
屢次調用addInterceptor
可添加多個攔截器
GET /user/1
[TimeFilter] 收到服務調用:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 02:59:00
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被調用 2019-08-20 02:59:00
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 02:59:00,共耗時:2ms
複製代碼
preHandle
返回值改成true
[TimeFilter] 收到服務調用:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 02:59:20
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被調用 2019-08-20 02:59:20
[TimeInterceptor # postHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】調用結束 2019-08-20 02:59:20 共耗時:39ms
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】調用結束 2019-08-20 02:59:20 共耗時:39ms
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 02:59:20,共耗時:42ms
複製代碼
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
throw new IdNotExistException(id);
// User user = new User();
// return user;
}
複製代碼
[TimeFilter] 收到服務調用:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 03:05:56
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被調用 2019-08-20 03:05:56
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】調用結束 2019-08-20 03:05:56 共耗時:11ms
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 03:05:56,共耗時:14ms
複製代碼
發現afterCompletion
中的異常打印邏輯並未被執行,這是由於IdNotExistException
被咱們以前自定義的異常處理器處理掉了,沒有拋出來。咱們改成拋出RuntimeException
再試一下
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
throw new RuntimeException("id not exist");
}
複製代碼
[TimeFilter] 收到服務調用:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 03:09:38
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】被調用 2019-08-20 03:09:38
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】調用結束 2019-08-20 03:09:38 共耗時:7ms
[TimeInterceptor#afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@2b6a0ea9#getInfo】調用異常:id not exist
java.lang.RuntimeException: id not exist
at top.zhenganwen.securitydemo.web.controller.UserController.getInfo(UserController.java:42)
...
[TimeInterceptor # preHandle] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】被調用 2019-08-20 03:09:38
[TimeInterceptor # postHandle] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】調用結束 2019-08-20 03:09:38 共耗時:7ms
[TimeInterceptor # afterCompletion] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@33f17289#error】調用結束 2019-08-20 03:09:38 共耗時:7ms
複製代碼
方法調用時序圖大體以下
Interceptor
仍然有它的侷限性,即沒法獲取調用Controller方法的入參信息,例如咱們須要對用戶下單請求的訂單物品信息記錄日誌以便爲推薦系統提供數據,那麼這時Interceptor
就無能爲力了
追蹤源碼DispatcherServlet -> doService -> doDispatch
可發現Interceptor
沒法獲取入參的緣由:
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
// Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
複製代碼
mappedHandler.applyPreHandle
其實就是調用HandlerInterceptor
的preHandle
方法,而在此以後才調用ha.handle(processedRequest, response, mappedHandler.getHandler())
將請求參數processedRequest
注入到handler
入參上
面向切面編程(Aspect-Oriented Program AOP
)是基於動態代理的一種對象加強設計模式,可以實如今不修改現有代碼的前提下添加可插拔的功能。
在SpringMVC
中使用AOP咱們須要三步
@Component
,注入Spring容器@Aspect
,啓動切面編程開關@Before
,方法執行前@AfterReturning
,方法正常執行結束後@AfterThrowing
,方法拋出異常後@After
,方法正常執行結束return
前,至關於在return
前插入了一段finally
@Around
,可利用注入的入參ProceedingJoinPoint
靈活的實現上述4種時機,它的做用與攔截器方法中的handler
相似,只不過提供了更多有用的運行時信息execution
表達式,具體詳見:docs.spring.io/spring/docs…@Around
能夠有入參,能拿到ProceedingJoinPoint
實例ProceedingJoinPoint
的point.proceed()
可以調用對應的Controller方法並拿到返回值package top.zhenganwen.securitydemo.web.aspect;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
/** * @author zhenganwen * @date 2019/8/20 * @desc GlobalControllerAspect */
@Aspect
@Component
public class GlobalControllerAspect {
// top.zhenganwen.securitydemo.web.controller包下的全部Controller的全部方法
@Around("execution(* top.zhenganwen.securitydemo.web.controller.*.*(..))")
public Object handleControllerMethod(ProceedingJoinPoint point) throws Throwable {
// handler對應的方法簽名(哪一個類的哪一個方法,參數列表是什麼)
String service = "【"+point.getSignature().toLongString()+"】";
// 傳入handler的參數值
Object[] args = point.getArgs();
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
Date start = new Date();
System.out.println("[GlobalControllerAspect]開始調用服務" + service + " 請求參數: " + Arrays.toString(args) + ", " + simpleDateFormat.format(start));
Object result = null;
try {
// 調用實際的handler並取得結果
result = point.proceed();
} catch (Throwable throwable) {
System.out.println("[GlobalControllerAspect]調用服務" + service + "發生異常, message=" + throwable.getMessage());
throw throwable;
}
Date end = new Date();
System.out.println("[GlobalControllerAspect]服務" + service + "調用結束,響應結果爲: " + result+", "+simpleDateFormat.format(end)+", 共耗時: "+(end.getTime()-start.getTime())+
"ms");
// 返回響應結果,不必定要和handler的處理結果一致
return result;
}
}
複製代碼
@GetMapping("/{id:\\d+}")
@JsonView(User.UserDetailsView.class)
public User getInfo(@PathVariable("id") Long id) {
System.out.println("[UserController # getInfo]query user by id");
return new User();
}
複製代碼
GET /user/1
[TimeFilter] 收到服務調用:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 05:21:48
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】被調用 2019-08-20 05:21:48
[GlobalControllerAspect]開始調用服務【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】 請求參數: [1], 2019-08-20 05:21:48
[UserController # getInfo]query user by id
[GlobalControllerAspect]服務【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】調用結束,響應結果爲: User(id=null, username=null, password=null, birthday=null), 2019-08-20 05:21:48, 共耗時: 0ms
[TimeInterceptor # postHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】調用結束 2019-08-20 05:21:48 共耗時:4ms
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】調用結束 2019-08-20 05:21:48 共耗時:4ms
[TimeFilter] 服務【GET /user/1】執行完畢 2019-08-20 05:21:48,共耗時:6ms
複製代碼
[TimeFilter] 收到服務調用:【GET /user/1】
[TimeFilter] 開始執行服務【GET /user/1】2019-08-20 05:24:40
[TimeInterceptor # preHandle] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】被調用 2019-08-20 05:24:40
[GlobalControllerAspect]開始調用服務【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】 請求參數: [1], 2019-08-20 05:24:40
[UserController # getInfo]query user by id
[GlobalControllerAspect]調用服務【public top.zhenganwen.securitydemo.dto.User top.zhenganwen.securitydemo.web.controller.UserController.getInfo(java.lang.Long)】發生異常, message=id not exist
[TimeInterceptor # afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】調用結束 2019-08-20 05:24:40 共耗時:2ms
[TimeInterceptor#afterCompletion] 服務【top.zhenganwen.securitydemo.web.controller.UserController@49433c98#getInfo】調用異常:id not exist
java.lang.RuntimeException: id not exist
at top.zhenganwen.securitydemo.web.controller.UserController.getInfo(UserController.java:42)
...
[TimeInterceptor # preHandle] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】被調用 2019-08-20 05:24:40
[TimeInterceptor # postHandle] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】調用結束 2019-08-20 05:24:40 共耗時:2ms
[TimeInterceptor # afterCompletion] 服務【org.springframework.boot.autoconfigure.web.BasicErrorController@445821a6#error】調用結束 2019-08-20 05:24:40 共耗時:3ms
複製代碼
老規矩,測試先行,不過使用MockMvc
模擬文件上傳請求仍是有些不同的,請求須要使用靜態方法fileUpload
且要設置contentType
爲multipart/form-data
@Test
public void upload() throws Exception {
File file = new File("C:\\Users\\zhenganwen\\Desktop", "hello.txt");
FileInputStream fis = new FileInputStream(file);
byte[] content = new byte[fis.available()];
fis.read(content);
String fileKey = mockMvc.perform(fileUpload("/file")
/** * name 請求參數,至關於<input>標籤的的`name`屬性 * originalName 上傳的文件名稱 * contentType 上傳文件需指定爲`multipart/form-data` * content 字節數組,上傳文件的內容 */
.file(new MockMultipartFile("file", "hello.txt", "multipart/form-data", content)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
System.out.println(fileKey);
}
複製代碼
文件管理Controller
package top.zhenganwen.securitydemo.web.controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.util.Date;
/** * @author zhenganwen * @date 2019/8/21 * @desc FileController */
@RestController
@RequestMapping("/file")
public class FileController {
public static final String FILE_STORE_FOLDER = "C:\\Users\\zhenganwen\\Desktop\\";
@PostMapping
public String upload(MultipartFile file) throws IOException {
System.out.println("[FileController]文件請求參數: " + file.getName());
System.out.println("[FileController]文件名稱: " + file.getName());
System.out.println("[FileController]文件大小: "+file.getSize()+"字節");
String fileKey = new Date().getTime() + "_" + file.getOriginalFilename();
File storeFile = new File(FILE_STORE_FOLDER, fileKey);
// 能夠經過file.getInputStream將文件上傳到FastDFS、雲OSS等存儲系統中
// InputStream inputStream = file.getInputStream();
// byte[] content = new byte[inputStream.available()];
// inputStream.read(content);
file.transferTo(storeFile);
return fileKey;
}
}
複製代碼
測試結果
[FileController]文件請求參數: file
[FileController]文件名稱: file
[FileController]文件大小: 12字節
1566349460611_hello.txt
複製代碼
查看桌面發現多了一個1566349460611_hello.txt
而且其中的內容爲hello upload
引入apache io
工具包
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.5</version>
</dependency>
複製代碼
文件下載接口
@GetMapping("/{fileKey:.+}")
public void download(@PathVariable String fileKey, HttpServletResponse response) throws IOException {
try (
InputStream is = new FileInputStream(new File(FILE_STORE_FOLDER, fileKey));
OutputStream os = response.getOutputStream()
) {
// 下載須要設置響應頭爲 application/x-download
response.setContentType("application/x-download");
// 設置下載詢問框中的文件名
response.setHeader("Content-Disposition", "attachment;filename=" + fileKey);
IOUtils.copy(is, os);
os.flush();
}
}
複製代碼
測試:瀏覽器訪問http://localhost:8080/file/1566349460611_hello.txt
映射寫成/{fileKey:.+}
而不是/{fileKey}
的緣由是SpringMVC
會忽略映射中.
符號以後的字符。正則.+
表示匹配任意個非\n
的字符,不加該正則的話,方法入參fileKey
獲取到的值將是1566349460611_hello
而不是1566349460611_hello.txt
咱們以前都是客戶端每發送一個請求,tomcat
線程池就派一個線程進行處理,直到請求處理完成響應結果,該線程都是被佔用的。一旦系統併發量上來了,那麼tomcat
線程池會顯得分身乏力,這時咱們能夠採起異步處理的方式。
爲避免前文添加的過濾器、攔截器、切片日誌的干擾,咱們暫時先註釋掉
//@Component
public class TimeFilter implements Filter {
複製代碼
忽然發現實現過濾器好像繼承了
Filter
接口並添加@Component
就能生效,由於僅註釋掉WebConfig
中的registerTimeFilter
方法,發現TimeFilter
仍是打印了日誌
//@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {
複製代碼
//@Aspect
//@Component
public class GlobalControllerAspect {
複製代碼
在Controller
中,若是將一個Callable
做爲方法的返回值,那麼tomcat
線程池中的線程在響應結果時會新建一個線程執行該Callable
並將其返回結果返回給客戶端
package top.zhenganwen.securitydemo.web.controller;
import org.apache.commons.lang.RandomStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
/** * @author zhenganwen * @date 2019/8/7 * @desc AsyncController */
@RestController
@RequestMapping("/order")
public class AsyncOrderController {
private Logger logger = LoggerFactory.getLogger(getClass());
// 建立訂單
@PostMapping
public Callable<String> createOrder() {
// 生成12位單號
String orderNumber = RandomStringUtils.randomNumeric(12);
logger.info("[主線程]收到建立訂單請求,訂單號=>" + orderNumber);
Callable<String> result = () -> {
logger.info("[副線程]建立訂單開始,訂單號=>"+orderNumber);
// 模擬建立訂單邏輯
TimeUnit.SECONDS.sleep(3);
logger.info("[副線程]建立訂單完成,訂單號=>" + orderNumber+",返回結果給客戶端");
return orderNumber;
};
logger.info("[主線程]已將請求委託副線程處理(訂單號=>" + orderNumber + "),繼續處理其它請求");
return result;
}
}
複製代碼
使用Postman
測試結果以下
控制檯日誌:
2019-08-21 21:10:39.059 INFO 17044 --- [nio-8080-exec-2] t.z.s.w.controller.AsyncOrderController : [主線程]收到建立訂單請求,訂單號=>719547514079
2019-08-21 21:10:39.059 INFO 17044 --- [nio-8080-exec-2] t.z.s.w.controller.AsyncOrderController : [主線程]已將請求委託副線程處理(訂單號=>719547514079),繼續處理其它請求
2019-08-21 21:10:39.063 INFO 17044 --- [ MvcAsync1] t.z.s.w.controller.AsyncOrderController : [副線程]建立訂單開始,訂單號=>719547514079
2019-08-21 21:10:42.064 INFO 17044 --- [ MvcAsync1] t.z.s.w.controller.AsyncOrderController : [副線程]建立訂單完成,訂單號=>719547514079,返回結果給客戶端
複製代碼
觀察可知主線程並無執行Callable
下單任務而直接跑去繼續監聽其餘請求了,下單任務由SpringMVC
新啓了一個線程MvcAsync1
執行,Postman
的響應時間也是在Callable
執行完畢後獲得了它的返回值。對於客戶端來講,後端的異步處理是透明的,與同步時沒有什麼區別;可是對於後端來講,tomcat
監聽請求的線程被佔用的時間很短,大大提升了自身的併發能力
Callable
異步處理的缺陷是,只能經過在本地新建副線程的方式進行異步處理,但如今隨着微服務架構的盛行,咱們常常須要跨系統的異步處理。例如在秒殺系統中,併發下單請求量較大,若是後端對每一個下單請求作同步處理(即在請求線程中處理訂單)後再返回響應結果,會致使服務假死(發送下單請求沒有任何響應);這時咱們可能會利用消息中間件,請求線程只負責監聽下單請求,而後發消息給MQ,讓訂單系統從MQ中拉取消息(如單號)進行下單處理並將處理結果返回給秒殺系統;秒殺系統獨立設一個監聽訂單處理結果消息的線程,將處理結果返回給客戶端。如圖所示
要實現相似上述的效果,須要使用Future
模式(可參考《Java多線程編程實戰(設計模式篇)》),即咱們能夠設置一個處理結果憑證DeferredResult
,若是咱們直接調用它的getResult
是獲取不處處理結果的(會被阻塞,表現爲雖然請求線程繼續處理請求了,可是客戶端仍在pending
,只有當某個線程調用它的setResult(result)
,纔會將對應的result
響應給客戶端
本例中,爲下降複雜性,使用本地內存中的LinkedList
代替分佈式消息中間件,使用本地新建線程代替訂單系統線程,各種之間的關係以下
package top.zhenganwen.securitydemo.web.async;
import org.apache.commons.lang.RandomStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
import java.util.concurrent.TimeUnit;
/** * @author zhenganwen * @date 2019/8/7 * @desc AsyncController */
@RestController
@RequestMapping("/order")
public class AsyncOrderController {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private DeferredResultHolder deferredResultHolder;
@Autowired
private OrderProcessingQueue orderProcessingQueue;
// 秒殺系統下單請求
@PostMapping
public DeferredResult<String> createOrder() {
logger.info("【請求線程】收到下單請求");
// 生成12位單號
String orderNumber = RandomStringUtils.randomNumeric(12);
// 建立處理結果憑證放入緩存,以便監聽(訂單系統向MQ發送的訂單處理結果消息的)線程向憑證中設置結果,這會觸發該結果響應給客戶端
DeferredResult<String> deferredResult = new DeferredResult<>();
deferredResultHolder.placeOrder(orderNumber, deferredResult);
// 異步向MQ發送下單消息,假設須要200ms
new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500);
synchronized (orderProcessingQueue) {
while (orderProcessingQueue.size() >= Integer.MAX_VALUE) {
try {
orderProcessingQueue.wait();
} catch (Exception e) {
}
}
orderProcessingQueue.addLast(orderNumber);
orderProcessingQueue.notifyAll();
}
logger.info("向MQ發送下單消息, 單號: {}", orderNumber);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "本地臨時線程-向MQ發送下單消息")
.start();
logger.info("【請求線程】繼續處理其它請求");
// 並不會當即將deferredResult序列化成JSON並返回給客戶端,而會等deferredResult的setResult被調用後,將傳入的result轉成JSON返回
return deferredResult;
}
}
複製代碼
package top.zhenganwen.securitydemo.web.async;
import org.springframework.stereotype.Component;
import java.util.LinkedList;
/** * @author zhenganwen * @date 2019/8/22 * @desc OrderProcessingQueue 下單消息MQ */
@Component
public class OrderProcessingQueue extends LinkedList<String> {
}
複製代碼
package top.zhenganwen.securitydemo.web.async;
import org.springframework.stereotype.Component;
import java.util.LinkedList;
/** * @author zhenganwen * @date 2019/8/22 * @desc OrderCompletionQueue 訂單處理完成MQ */
@Component
public class OrderCompletionQueue extends LinkedList<OrderCompletionResult> {
}
複製代碼
package top.zhenganwen.securitydemo.web.async;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/** * @author zhenganwen * @date 2019/8/22 * @desc OrderCompletionResult 訂單處理完成結果信息,包括單號和是否成功 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderCompletionResult {
private String orderNumber;
private String result;
}
複製代碼
package top.zhenganwen.securitydemo.web.async;
import org.hibernate.validator.constraints.NotBlank;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.async.DeferredResult;
import javax.validation.constraints.NotNull;
import java.util.HashMap;
import java.util.Map;
/** * @author zhenganwen * @date 2019/8/22 * @desc DeferredResultHolder 訂單處理結果憑證緩存,經過憑證能夠在將來的時間點獲取處理結果 */
@Component
public class DeferredResultHolder {
private Map<String, DeferredResult<String>> holder = new HashMap<>();
// 將訂單處理結果憑證放入緩存
public void placeOrder(@NotBlank String orderNumber, @NotNull DeferredResult<String> result) {
holder.put(orderNumber, result);
}
// 向憑證中設置訂單處理完成結果
public void completeOrder(@NotBlank String orderNumber, String result) {
if (!holder.containsKey(orderNumber)) {
throw new IllegalArgumentException("orderNumber not exist");
}
DeferredResult<String> deferredResult = holder.get(orderNumber);
deferredResult.setResult(result);
}
}
複製代碼
package top.zhenganwen.securitydemo.web.async;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/** * @author zhenganwen * @date 2019/8/22 * @desc OrderProcessResultListener */
@Component
public class OrderProcessingListener implements ApplicationListener<ContextRefreshedEvent> {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
OrderProcessingQueue orderProcessingQueue;
@Autowired
OrderCompletionQueue orderCompletionQueue;
@Autowired
DeferredResultHolder deferredResultHolder;
// spring容器啓動或刷新時執行此方法
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
// 本系統(秒殺系統)啓動時,啓動一個監聽MQ下單完成消息的線程
new Thread(() -> {
while (true) {
String finishedOrderNumber;
OrderCompletionResult orderCompletionResult;
synchronized (orderCompletionQueue) {
while (orderCompletionQueue.isEmpty()) {
try {
orderCompletionQueue.wait();
} catch (InterruptedException e) { }
}
orderCompletionResult = orderCompletionQueue.pollFirst();
orderCompletionQueue.notifyAll();
}
finishedOrderNumber = orderCompletionResult.getOrderNumber();
logger.info("收到訂單處理完成消息,單號爲: {}", finishedOrderNumber);
deferredResultHolder.completeOrder(finishedOrderNumber, orderCompletionResult.getResult());
}
},"本地監聽線程-監聽訂單處理完成")
.start();
// 假設是訂單系統監聽MQ下單消息的線程
new Thread(() -> {
while (true) {
String orderNumber;
synchronized (orderProcessingQueue) {
while (orderProcessingQueue.isEmpty()) {
try {
orderProcessingQueue.wait();
} catch (InterruptedException e) {
}
}
orderNumber = orderProcessingQueue.pollFirst();
orderProcessingQueue.notifyAll();
}
logger.info("收到下單請求,開始執行下單邏輯,單號爲: {}", orderNumber);
boolean status;
// 模擬執行下單邏輯
try {
TimeUnit.SECONDS.sleep(2);
status = true;
} catch (Exception e) {
logger.info("下單失敗=>{}", e.getMessage());
status = false;
}
// 向 訂單處理完成MQ 發送消息
synchronized (orderCompletionQueue) {
orderCompletionQueue.addLast(new OrderCompletionResult(orderNumber, status == true ? "success" : "error"));
logger.info("發送訂單完成消息, 單號: {}",orderNumber);
orderCompletionQueue.notifyAll();
}
}
},"訂單系統線程-監聽下單消息")
.start();
}
}
複製代碼
2019-08-22 13:22:05.520 INFO 21208 --- [nio-8080-exec-2] t.z.s.web.async.AsyncOrderController : 【請求線程】收到下單請求
2019-08-22 13:22:05.521 INFO 21208 --- [nio-8080-exec-2] t.z.s.web.async.AsyncOrderController : 【請求線程】繼續處理其它請求
2019-08-22 13:22:06.022 INFO 21208 --- [ 訂單系統線程-監聽下單消息] t.z.s.web.async.OrderProcessingListener : 收到下單請求,開始執行下單邏輯,單號爲: 104691998710
2019-08-22 13:22:06.022 INFO 21208 --- [地臨時線程-向MQ發送下單消息] t.z.s.web.async.AsyncOrderController : 向MQ發送下單消息, 單號: 104691998710
2019-08-22 13:22:08.023 INFO 21208 --- [ 訂單系統線程-監聽下單消息] t.z.s.web.async.OrderProcessingListener : 發送訂單完成消息, 單號: 104691998710
2019-08-22 13:22:08.023 INFO 21208 --- [本地監聽線程-監聽訂單處理完成] t.z.s.web.async.OrderProcessingListener : 收到訂單處理完成消息,單號爲: 104691998710
複製代碼
在咱們以前擴展WebMvcConfigureAdapter
的子類WebConfig
中能夠經過重寫configureAsyncSupport
方法對異步處理進行一些配置
咱們以前經過重寫addInterceptors
方法註冊的攔截器對Callable
和DeferredResult
兩種異步處理是無效的,若是想爲這二者配置攔截器需重寫這兩個方法
設置異步處理的超時時間,超過該時間就直接響應而不會等異步任務結束了
SpringBoot
默認是經過新建線程的方式執行異步任務的,執行完後線程就被銷燬了,要想經過複用線程(線程池)的方式執行異步任務,你能夠經過此方法傳入一個自定義的線程池
swagger
項目可以根據咱們所寫的接口自動生成接口文檔,方便咱們先後端分離開發
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.7.0</version>
</dependency>
複製代碼
在啓動類SecurityDemoApplication
上添加@@EnableSwagger2
註解開啓接口文檔自動生成開關,啓動後訪問localhost:8080/swagger-ui.html
@ApiOperation
,註解在Controller方法上,用來描述方法的行爲
@GetMapping
@JsonView(User.UserBasicView.class)
@ApiOperation("用戶查詢服務")
public List<User> query(String userName, UserQueryConditionDto userQueryConditionDto, Pageable pageable) {
複製代碼
@ApiModelProperty
,註解在Bean
的字段上,用來描述字段的含義
@Data
public class UserQueryConditionDto {
@ApiModelProperty("用戶名")
private String username;
@ApiModelProperty("密碼")
private String password;
@ApiModelProperty("電話號碼")
private String phone;
}
複製代碼
@ApiParam
,註解在Controller方法參數上,用來描述參數含義
@DeleteMapping("/{id:\\d+}")
public void delete(@ApiParam("用戶id") @PathVariable Long id) {
System.out.println(id);
}
複製代碼
重啓後接口文檔會從新生成
爲了方便先後端並行開發,咱們可使用WireMock
做爲虛擬接口服務器
在後端接口沒開發完成時,前端可能會經過本地文件的方式僞造一些靜態數據(例如JSON文件)做爲請求的響應結果,這種方式在前端只有一種終端時是沒問題的。可是當前端有多種,如PC、H五、APP、小程序等時,每種都去在本身的本地僞造數據,那麼就顯得有些重複,並且每一個人按照本身的想法僞造數據可能會致使最終和真實接口沒法無縫對接
這時wiremock
的出現就解決了這一痛點,wiremock
是用Java
開發的一個獨立服務器,可以對外提供HTTP服務,咱們能夠經過wiremock
客戶端去編輯/配置wiremock
服務器使它能像web
服務同樣提供各類各樣的接口,並且無需從新部署
wiremock能夠以jar
方式運行,下載地址,下載完成後切換到其所在目錄cmd
執行如下命令啓動wiremock
服務器,--port=
指定運行端口
java -jar wiremock-standalone-2.24.1.jar --port=8062
複製代碼
引入wiremock
客戶端依賴及其依賴的httpclient
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
複製代碼
因爲在父工程中已經使用了依賴自動兼容,因此無需指定版本號。接着經過客戶端API去編輯wiremock
服務器,爲其添加接口
package top.zhenganwen.securitydemo.wiremock;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
/** * @author zhenganwen * @date 2019/8/22 * @desc MockServer */
public class MockServer {
public static void main(String[] args) {
configureFor("127.0.0.1",8062);
removeAllMappings(); // 移除全部舊的配置
// 添加配置,一個stub表明一個接口
stubFor(
get(urlEqualTo("/order/1")).
// 設置響應結果
willReturn(
aResponse()
.withBody("{\"id\":1,\"orderNumber\":\"545616156\"}")
.withStatus(200)
)
);
}
}
複製代碼
你能夠先將JSON數據存在
resources
中,而後經過ClassPathResource#getFile
和FileUtils#readLines
將數據讀成字符串
訪問localhost:8062/order/1
:
{
id: 1,
orderNumber: "545616156"
}
複製代碼
經過WireMock
API,你能夠爲虛擬服務器配置各類各樣的接口服務
Security
有一個默認的基礎認證機制,咱們註釋掉配置項security.basic.enabled=false
(默認值爲true
),重啓查看日誌會發現一條信息
Using default security password: f84e3dea-d231-47a2-b20a-48bac8ed5f1e
複製代碼
而後咱們訪問GET /user
,彈出登陸框讓咱們登陸,security
默認內置了一個用戶名爲user
,密碼爲上述日誌中Using default security password: f84e3dea-d231-47a2-b20a-48bac8ed5f1e
的用戶(該密碼每次重啓都會從新生成),咱們使用這二者登陸表單後頁面從新跳轉到了咱們要訪問的服務
從本節開始咱們將在security-browser
模塊中編寫咱們的瀏覽器認證邏輯
咱們能夠經過添加配置類的方式(添加Configuration
,並擴展WebSecurityConfigureAdapter
)來配置驗證方式、驗證邏輯等,如設置驗證方式爲表單驗證:
package top.zhenganwen.securitydemo.browser.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
/** * @author zhenganwen * @date 2019/8/22 * @desc SecurityConfig */
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//設置認證方式爲表單登陸,若未登陸而訪問受保護的URL則跳轉到表單登陸頁(security幫咱們寫了一個默認的登陸頁)
.formLogin()
// 添加其餘配置
.and()
// 驗證方式配置結束,開始配置驗證規則
.authorizeRequests()
// 設置任何請求都須要經過認證
.anyRequest()
.authenticated();
}
}
複製代碼
訪問/user
,跳轉到默認的登陸頁/login
(該登陸頁和登陸URL咱們能夠自定義),用戶名user
,密碼仍是日誌中的,登陸成功跳轉到/user
若是將認證方式由formLogin
改成httpBasic
就是security
最默認的配置(至關於引入security
依賴後什麼都不配的效果),即彈出登陸框
如圖所示,Spring Security
的核心其實就是一串過濾器鏈,因此它是非侵入式可插拔的。過濾器鏈中的過濾器分3種:
認證過濾器XxxAuthenticationFilter
,如上圖中標註爲綠色的,它們的類名以AuthenticationFilter
結尾,做用是將登陸的信息保存起來。這些過濾器是根據咱們的配置動態生效的,如咱們以前調用formLogin()
其實就是啓用了UsernamePasswordAuthenticationFilter
,調用httpBaisc()
就是啓用了BasicAuthenticationFilter
後面最貼近Controller
的兩個過濾器ExceptionTranslationFilter
和FilterSecurityInterceptor
包含了最核心的認證邏輯,默認是啓用的,並且咱們也沒法禁用它們
FilterSecurityInterceptor
,雖然命名以Interceptor
結尾,但其實仍是一個Filter
,它是最貼近Controller
的一個過濾器,它會根據咱們配置的攔截規則(哪些URL須要登陸後才能訪問,哪些URL須要某些特定的權限才能訪問等)對訪問相應URL的請求進行攔截,如下是它的部分源碼
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}
public void invoke(FilterInvocation fi) throws IOException, ServletException {
...
InterceptorStatusToken token = super.beforeInvocation(fi);
...
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
...
}
複製代碼
doFilter
就是真正調用咱們的Controller
了(由於它是過濾器鏈的末尾),但在此以前它會調用beforeInvocation
對請求進行攔截校驗是否有相關的身份和權限,校驗失敗對應會拋出未經認證異常(Unauthenticated
)和未經受權異常(Unauthorized
),這些異常會被ExceptionTranslationFilter
捕獲到
ExceptionTranslationFilter
,顧名思義就是解析異常的,其部分源碼以下
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
try {
chain.doFilter(request, response);
}
catch (Exception ex) {
// Try to extract a SpringSecurityException from the stacktrace
...
}
}
複製代碼
它調用chain.doFilter
其實就是去到了FilterSecurityInterceptor
,它會對FilterSecurityInterceptor.doFilter
中拋出的SpringSecurityException
異常進行捕獲並解析處理,例如FilterSecurityInterceptor
拋出了Unauthenticated
異常,那麼ExceptionTranslationFilter
就會重定向到登陸頁或是彈出登陸框(取決於咱們配置了什麼認證過濾器),當咱們成功登陸後,認證過濾又會重定向到咱們最初要訪問的URL
咱們能夠經過斷點調試的方式來驗證上述所說,將驗證方式設爲formLogin
,而後在3個過濾器和Controller
中分別打斷點,重啓服務訪問/user
到此爲止咱們登陸都是經過user
和啓動日誌生成的密碼,這是security
內置了一個user
用戶。實際項目中咱們通常有一個專門存放用戶的表,會經過jdbc
或從其餘存儲系統讀取用戶信息,這時就須要咱們自定義讀取用戶信息的邏輯,經過實現UserDetailsService
接口便可告訴security
從如何獲取用戶信息
package top.zhenganwen.securitydemo.browser.config;
import org.hibernate.validator.constraints.NotBlank;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import java.util.Objects;
/** * @author zhenganwen * @date 2019/8/23 * @desc CustomUserDetailsService */
@Component
public class CustomUserDetailsService implements UserDetailsService {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public UserDetails loadUserByUsername(@NotBlank String username) throws UsernameNotFoundException {
logger.info("登陸用戶名: " + username);
// 實際項目中你能夠調用Dao或Repository來查詢用戶是否存在
if (Objects.equals(username, "admin") == false) {
throw new UsernameNotFoundException("用戶名不存在");
}
// 在查詢到用戶後須要將相關信息包裝成一個UserDetails實例返回給security,這裏的User是security提供的一個實現
// 第三個參數須要傳一個權限集合,這裏使用了一個security提供的工具類將用分號分隔的權限字符串轉成權限集合,原本應該從用戶權限表查詢的
return new org.springframework.security.core.userdetails.User(
"admin","123456", AuthorityUtils.commaSeparatedStringToAuthorityList("user,admin")
);
}
}
複製代碼
重啓服務後只能經過admin,123456
來登陸了
咱們來看一下UserDetails
接口源碼
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
// 用來和用戶登陸時填寫的密碼進行比對
String getPassword();
String getUsername();
// 帳戶是不是非過時的
boolean isAccountNonExpired();
// 帳戶是不是非凍結的
boolean isAccountNonLocked();
// 密碼是不是非過時的,有些安全性較高的系統須要帳戶每隔一段時間更換密碼
boolean isCredentialsNonExpired();
// 帳戶是否可用,能夠對應邏輯刪除字段
boolean isEnabled();
}
複製代碼
在重寫以is
開頭的四個方法時,若是無需相應判斷,則返回true
便可,例如對應用戶表的實體類以下
@Data
public class User{
private Long id;
private String username;
private String password;
private String phone;
private int deleted; //0-"正常的",1-"已刪除的"
private int accountNonLocked; //0-"帳號未被凍結",1-"帳號已被凍結"
}
複製代碼
爲了方便,咱們能夠直接使用實體類實現UserDetails
接口
@Data
public class User implements UserDetails{
private Long id;
private String uname;
private String pwd;
private String phone;
private int deleted;
private int accountNonLocked;
public String getPassword(){
return pwd;
}
public String getUsername(){
return uname;
}
public boolean isAccountNonExpired(){
return true;
}
public boolean isAccountNonLocked(){
return accountNonLocked == 0;
}
public boolean isCredentialsNonExpired(){
return true;
}
public boolean isEnabled(){
return deleted == 0;
}
}
複製代碼
用戶表中的密碼字段通常不會存放密碼的明文而是存放加密後的密文,這時咱們就須要PasswordEncoder
的支持了:
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
}
複製代碼
咱們在插入用戶到數據庫時,須要調用encode
對明文密碼加密後再插入;在用戶登陸時,security
會調用matches
將咱們從數據庫查出的密文面和用戶提交的明文密碼進行比對。
security
爲咱們提供了一個該接口的非對稱加密(對同一明文密碼,每次調用encode
獲得的密文都是不同的,只有經過matches
來比對明文和密文是否對應)實現類BCryptPasswordEncoder
,咱們只需配置一個該類的Bean
,security
就會認爲咱們返回的UserDetails
的getPassword
返回的密碼是經過該Bean
加密過的(因此在插入用戶時要注意調用該Bean
的encode
對密碼加密一下在插入數據庫)
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
複製代碼
@Component
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
BCryptPasswordEncoder passwordEncoder;
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public UserDetails loadUserByUsername(@NotBlank String username) throws UsernameNotFoundException {
logger.info("登陸用戶名: " + username);
// 實際項目中你能夠調用Dao或Repository來查詢用戶是否存在
if (Objects.equals(username, "admin") == false) {
throw new UsernameNotFoundException("用戶名不存在");
}
// 假設查出來的密碼以下
String pwd = passwordEncoder.encode("123456");
return new org.springframework.security.core.userdetails.User(
"admin", pwd, AuthorityUtils.commaSeparatedStringToAuthorityList("user,admin")
);
}
}
複製代碼
BCryptPasswordEncoder
不必定只能用於密碼的加密和校驗,平常開發中涉及到加密的功能咱們都能使用它的encode
方法,也能使用matches
方法比對某密文是不是某明文加密後的結果
在formLogin()
後使用loginPage()
就能指定登陸的頁面,同時要記得將該URL的攔截放開;UsernamePasswordAuthenticationFilter
默認攔截提交到/login
的POST
請求並獲取登陸信息,若是你想表單填寫的action
不爲/post
,那麼能夠配置loginProcessingUrl
使UsernamePasswordAuthenticationFilter
與之對應
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//設置認證方式爲表單登陸,若未登陸而訪問受保護的URL則跳轉到表單登陸頁(security幫咱們寫了一個默認的登陸頁)
.formLogin()
.loginPage("/sign-in.html").loginProcessingUrl("/auth/login")
.and()
// 驗證方式配置結束,開始配置驗證規則
.authorizeRequests()
// 登陸頁面不須要攔截
.antMatchers("/sign-in.html").permitAll()
// 設置任何請求都須要經過認證
.anyRequest().authenticated();
}
}
複製代碼
自定義登陸頁:security-browser/src/main/resource/resources/sign-in.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登陸頁面</title>
</head>
<body>
<form action="/auth/login" method="post">
用戶名: <input type="text" name="username">
密碼: <input type="password" name="password">
<button type="submit">提交</button>
</form>
</body>
</html>
複製代碼
重啓後訪問GET /user
,調整到了咱們寫的登陸頁sign-in.html
,填寫admin,123456
登陸,發現仍是報錯以下
There was an unexpected error (type=Forbidden, status=403).
Invalid CSRF Token 'null' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.
複製代碼
這是由於security
默認啓用了跨站僞造請求防禦CSRF(例如使用HTTP客戶端Postman
也能夠發出這樣的登陸請求),咱們先禁用它
http
.formLogin()
.loginPage("/sign-in.html").loginProcessingUrl("/auth/login")
.and()
.authorizeRequests()
.antMatchers("/sign-in.html").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable()
複製代碼
再重啓訪問GET /user
,跳轉登陸後,自動跳轉回/user
,自定義登陸頁成功
因爲咱們是基於REST
的服務,因此若是是非瀏覽器請求,咱們應該返回401狀態碼告訴客戶端須要認證,而不是重定向到登陸頁
這時咱們就不能將loginPage
寫成登陸頁路徑了,而應該重定向到一個Controller
,由Controller
判斷用戶是在瀏覽器訪問頁面時跳轉過來的仍是非瀏覽器如安卓訪問REST服務時跳轉過來,若是是前者那麼就重定向到登陸頁,若是是後者就響應401狀態碼和JSON消息
package top.zhenganwen.securitydemo.browser;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import top.zhenganwen.securitydemo.browser.support.SimpleResponseResult;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/** * @author zhenganwen * @date 2019/8/23 * @desc AuthenticationController */
@RestController
public class BrowserSecurityController {
private Logger logger = LoggerFactory.getLogger(getClass());
// security會將跳轉前的請求存儲在session中
private RequestCache requestCache = new HttpSessionRequestCache();
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@RequestMapping("/auth/require")
// 該註解可設置響應狀態碼
@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
public SimpleResponseResult requireAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 從session中取出跳轉前用戶訪問的URL
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
String redirectUrl = savedRequest.getRedirectUrl();
logger.info("引起跳轉到/auth/login的請求是: {}", redirectUrl);
if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) {
// 若是用戶是訪問html頁面被FilterSecurityInterceptor攔截從而跳轉到了/auth/login,那麼就重定向到登陸頁面
redirectStrategy.sendRedirect(request, response, "/sign-in.html");
}
}
// 若是不是訪問html而被攔截跳轉到了/auth/login,則返回JSON提示
return new SimpleResponseResult("用戶未登陸,請引導用戶至登陸頁");
}
}
複製代碼
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/auth/require").loginProcessingUrl("/auth/login")
.and()
.authorizeRequests()
.antMatchers("/auth/require").permitAll()
.antMatchers("/sign-in.html").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
複製代碼
因爲咱們的security-browser
模塊是做爲可複用模塊來開發的,應該支持自定義配置,例如其餘應用引入咱們的security-browser
模塊以後,應該能配置他們本身的登陸頁,若是他們沒有配置那就使用咱們默認提供的sign-in.html
,要想作到這點,咱們須要提供一些配置項,例如別人引入咱們的security-browser
以後經過添加demo.security.browser.loginPage=/login.html
就能將他們項目的login.html
替換掉咱們的sign-in.html
因爲後續security-app
也可能會須要支持相似的配置,所以咱們在security-core
中定義一個總的配置類來封裝各模塊的不一樣配置項
security-core
中的類:
package top.zhenganwen.security.core.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** * @author zhenganwen * @date 2019/8/23 * @desc SecurityProperties 封裝整個項目各模塊的配置項 */
@Data
@ConfigurationProperties(prefix = "demo.security")
public class SecurityProperties {
private BrowserProperties browser = new BrowserProperties();
}
複製代碼
package top.zhenganwen.security.core.properties;
import lombok.Data;
/** * @author zhenganwen * @date 2019/8/23 * @desc BrowserProperties 封裝security-browser模塊的配置項 */
@Data
public class BrowserProperties {
private String loginPage = "/sign-in.html"; //提供一個默認的登陸頁
}
複製代碼
package top.zhenganwen.security.core;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import top.zhenganwen.security.core.properties.SecurityProperties;
/** * @author zhenganwen * @date 2019/8/23 * @desc SecurityCoreConfig */
@Configuration
// 啓用在啓動時將application.properties中的demo.security前綴的配置項注入到SecurityProperties中
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {
}
複製代碼
而後在security-browser
中將SecurityProperties
注入進來,將重定向到登陸頁的邏輯依賴配置文件中的demo.security.browser.loginPage
@RestController
public class BrowserSecurityController {
private Logger logger = LoggerFactory.getLogger(getClass());
private RequestCache requestCache = new HttpSessionRequestCache();
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Autowired
private SecurityProperties securityProperties;
@RequestMapping("/auth/require")
@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
public SimpleResponseResult requireAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException {
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
String redirectUrl = savedRequest.getRedirectUrl();
logger.info("引起跳轉到/auth/login的請求是: {}", redirectUrl);
if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) {
redirectStrategy.sendRedirect(request, response, securityProperties.getBrowser().getLoginPage());
}
}
return new SimpleResponseResult("用戶未登陸,請引導用戶至登陸頁");
}
}
複製代碼
將不攔截的登陸頁URL設置爲動態的
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
private SecurityProperties securityProperties;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/auth/require").loginProcessingUrl("/auth/login")
.and()
.authorizeRequests()
.antMatchers("/auth/require").permitAll()
// 將不攔截的登陸頁URL設置爲動態的
.antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
複製代碼
如今,咱們將security-demo
模塊當作第三方應用,使用可複用的security-browser
首先,要將security-demo
模塊的啓動類SecurityDemoApplication
移到top.zhenganwen.securitydemo
包下,確保可以掃描到security-core
下的top.zhenganwen.securitydemo.core.SecurityCoreConfig
和security-browser
下的top.zhenganwen.securitydemo.browser.SecurityBrowserConfig
而後,在security-demo
的application.properties
中添加配置項demo.security.browser.loginPage=/login.html
並在resources
下新建resources
文件夾和其中的login.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Security Demo應用的登陸頁面</h1>
<form action="/auth/login" method="post">
用戶名: <input type="text" name="username">
密碼: <input type="password" name="password">
<button type="submit">提交</button>
</form>
</body>
</html>
複製代碼
重啓服務,訪問/user.html
發現跳轉到了login.html
;註釋掉demo.security.browser.loginPage=/login.html
,再重啓服務訪問/user.html
發現跳轉到了sign-in.html
,重構成功!
security
處理登陸成功的邏輯默認是重定向到以前被攔截的請求,可是對於REST服務來講,前端多是AJAX請求登陸,但願獲取的響應是用戶的相關信息,這時你給他重定向顯然不合適。要想自定義登陸成功後的處理,咱們須要實現AuthenticationSuccessHandler
接口
package top.zhenganwen.securitydemo.browser.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/** * @author zhenganwen * @date 2019/8/24 * @desc CustomAuthenticationSuccessHandler */
@Component("customAuthenticationSuccessHandler")
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException , ServletException {
logger.info("用戶{}登陸成功", authentication.getName());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
response.getWriter().flush();
}
}
複製代碼
在登陸成功後,咱們會拿到一個Authentication
,這也是security
的一個核心接口,做用是封裝用戶的相關信息,這裏咱們將其轉成JSON串響應給前端看一下它包含了哪些內容
咱們還須要經過successHandler()
將其配置到HttpSecurity
中以使之生效(替代默認的登陸成功處理邏輯):
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
private SecurityProperties securityProperties;
@Autowired
private AuthenticationSuccessHandler customAuthenticationSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/auth/require").loginProcessingUrl("/auth/login")
.successHandler(customAuthenticationSuccessHandler)
.and()
.authorizeRequests()
.antMatchers("/auth/require").permitAll()
.antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
複製代碼
重啓服務,訪問/login.html
並登陸:
{
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
details: {
remoteAddress: "0:0:0:0:0:0:0:1",
sessionId: "3BA37577BAC493D0FE1E07192B5524B1"
},
authenticated: true,
principal: {
password: null,
username: "admin",
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
accountNonExpired: true,
accountNonLocked: true,
credentialsNonExpired: true,
enabled: true
},
credentials: null,
name: "admin"
}
複製代碼
能夠發現Authentication
包含了如下信息
authorities
,權限,對應UserDetials
中getAuthorities()
的返回結果details
,回話,客戶端的IP以及本次回話的SESSIONIDauthenticated
,是否經過認證principle
,對應UserDetailsService
中loadUserByUsername
返回的UserDetails
credentials
,密碼,security
默認作了處理,不將密碼返回給前端name
,用戶名這裏由於咱們是表單登陸,因此返回的是以上信息,以後咱們作第三方登陸如微信、QQ,那麼Authentication
包含的信息就可能不同了,也就是說重寫的onAuthenticationSuccess
方法的入參Authentication
會根據登陸方式的不一樣傳給咱們不一樣的Authentication
實現類對象
與登陸成功處理對應,天然也能夠自定義登陸失敗處理
package top.zhenganwen.securitydemo.browser.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/** * @author zhenganwen * @date 2019/8/24 * @desc CustomAuthenticationFailureHandler */
@Component("customAuthenticationFailureHandler")
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
logger.info("登陸失敗=>{}", exception.getMessage());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(exception));
response.getWriter().flush();
}
}
複製代碼
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
private SecurityProperties securityProperties;
@Autowired
private AuthenticationSuccessHandler customAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler customAuthenticationFailureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/auth/require")
.loginProcessingUrl("/auth/login")
.successHandler(customAuthenticationSuccessHandler)
.failureHandler(customAuthenticationFailureHandler)
.and()
.authorizeRequests()
.antMatchers("/auth/require").permitAll()
.antMatchers(securityProperties.getBrowser().getLoginPage()).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
複製代碼
訪問/login.html
輸入錯誤的密碼登陸:
{
cause: null,
stackTrace: [...],
localizedMessage: "壞的憑證",
message: "壞的憑證",
suppressed: [ ]
}
複製代碼
爲了使security-browser
成爲可複用的模塊,咱們應該將登陸成功/失敗處理策略抽離出去,讓第三方應用自由選擇,這時咱們又能夠新增一個配置項demo.security.browser.loginProcessType
切換到security-core
:
package top.zhenganwen.security.core.properties;
/** * @author zhenganwen * @date 2019/8/24 * @desc LoginProcessTypeEnum */
public enum LoginProcessTypeEnum {
// 重定向到以前的請求頁或登陸失敗頁
REDIRECT("redirect"),
// 登陸成功返回用戶信息,登陸失敗返回錯誤信息
JSON("json");
private String type;
LoginProcessTypeEnum(String type) {
this.type = type;
}
}
複製代碼
@Data
public class BrowserProperties {
private String loginPage = "/sign-in.html";
private LoginProcessTypeEnum loginProcessType = LoginProcessTypeEnum.JSON; //默認返回JSON信息
}
複製代碼
重構登陸成功/失敗處理器,其中SavedRequestAwareAuthenticationSuccessHandler
和SimpleUrlAuthenticationFailureHandler
就是security
提供的默認的登陸成功(跳轉到登陸以前請求的頁面)和登陸失敗(跳轉到異常頁)的處理器
package top.zhenganwen.securitydemo.browser.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.properties.LoginProcessTypeEnum;
import top.zhenganwen.security.core.properties.SecurityProperties;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/** * @author zhenganwen * @date 2019/8/24 * @desc CustomAuthenticationSuccessHandler */
@Component("customAuthenticationSuccessHandler")
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Autowired
private SecurityProperties securityProperties;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException , ServletException {
if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
// 重定向到緩存在session中的登陸前請求的URL
super.onAuthenticationSuccess(request, response, authentication);
return;
}
logger.info("用戶{}登陸成功", authentication.getName());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
response.getWriter().flush();
}
}
複製代碼
package top.zhenganwen.securitydemo.browser.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.properties.LoginProcessTypeEnum;
import top.zhenganwen.security.core.properties.SecurityProperties;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/** * @author zhenganwen * @date 2019/8/24 * @desc CustomAuthenticationFailureHandler */
@Component("customAuthenticationFailureHandler")
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Autowired
private SecurityProperties securityProperties;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
super.onAuthenticationFailure(request, response, exception);
return;
}
logger.info("登陸失敗=>{}", exception.getMessage());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(exception));
response.getWriter().flush();
}
}
複製代碼
訪問/login.html
,分別進行登陸成功和登陸失敗測試,返回JSON響應
在security-demo
中
application.properties
中添加demo.security.browser.loginProcessType=redirect
新建/resources/resources/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Spring Demo應用首頁</h1>
</body>
</html>
複製代碼
新建/resources/resources/401.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>login fail!</h1>
</body>
</html>
複製代碼
重啓服務,登陸成功跳轉到index.html
,登陸失敗跳轉到401.html
通過上述兩節,咱們已經會使用security
的一些基礎功能了,但都是碎片化的,對總體流程的把握還很模糊。知其然還要知其因此然,咱們須要分析在登陸時security
都幫咱們作了哪些事
上圖是登陸處理的大體流程,登陸請求的過濾器XxxAutenticationFilter
在攔截到登陸請求後會見登陸信息封裝成一個authenticated=false
的Authentication
傳給AuthenticationManager
讓幫忙校驗,AuthenticationManager
自己也不會作校驗邏輯,會委託AuthenticationProvider
幫忙校驗,AuthenticationProvider
會在校驗過程當中拋出校驗失敗異常或校驗經過返回一個新的帶有UserDetials
的Authentication
返回,請求過濾器收到XxxAuthenticationFilter
以後會調用登陸成功處理器執行登陸成功邏輯
咱們以用戶名密碼錶單登陸方式來斷點調試逐步分析一下校驗流程,其餘的登陸方式也就大同小異了
要想在多個請求之間共享數據,須要藉助session
,接下來咱們看一下security
將什麼東西放到了session
中,又在何時會從session
讀取
上節說道在AbstractAuthenticationProcessingFilter
的``doFilter方法中,校驗成功以後會調用
successfulAuthentication(request, response, chain, authResult)`,咱們來看一下這個方法幹了些什麼
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
if (logger.isDebugEnabled()) {
logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
+ authResult);
}
SecurityContextHolder.getContext().setAuthentication(authResult);
...
successHandler.onAuthenticationSuccess(request, response, authResult);
}
複製代碼
能夠發現,在調用登陸成功處理器的處理邏輯以前,調用了一下SecurityContextHolder.getContext().setAuthentication(authResult)
,查看可知SecurityContextHolder.getContext()
就是獲取當前線程綁定的SecurityContext
(能夠看作是一個線程變量,做用域爲線程的生命週期),而SecurityContext
其實就是對Authentication
的一層包裝
public class SecurityContextHolder {
private static SecurityContextHolderStrategy strategy;
public static SecurityContext getContext() {
return strategy.getContext();
}
}
複製代碼
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<SecurityContext>();
public SecurityContext getContext() {
SecurityContext ctx = contextHolder.get();
if (ctx == null) {
ctx = createEmptyContext();
contextHolder.set(ctx);
}
return ctx;
}
}
複製代碼
public interface SecurityContext extends Serializable {
Authentication getAuthentication();
void setAuthentication(Authentication authentication);
}
複製代碼
public class SecurityContextImpl implements SecurityContext {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
public Authentication getAuthentication() {
return authentication;
}
public int hashCode() {
if (this.authentication == null) {
return -1;
}
else {
return this.authentication.hashCode();
}
}
public void setAuthentication(Authentication authentication) {
this.authentication = authentication;
}
...
}
複製代碼
那麼將Authentication
保存到當前線程的SecurityContext
中的用意是什麼呢?
這就涉及到了另一個特別的過濾器SecurityContextPersistenceFilter
,它位於security
的整個過濾器鏈的最前端:
private SecurityContextRepository repo;
// 請求到達的第一個過濾器
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
...
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,response);
// 從Session中獲取SecurityContext,未登陸時獲取的則是空
SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
try {
// 將SecurityContext保存到當前線程的ThreadLocalMap中
SecurityContextHolder.setContext(contextBeforeChainExecution);
// 執行後續過濾器和Controller方法
chain.doFilter(holder.getRequest(), holder.getResponse());
}
// 在請求響應時通過的最後一個過濾器
finally {
// 從當前線程獲取SecurityContext
SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
SecurityContextHolder.clearContext();
// 將SecurityContext持久化到Session
repo.saveContext(contextAfterChainExecution, holder.getRequest(),holder.getResponse());
...
}
}
複製代碼
public class HttpSessionSecurityContextRepository implements SecurityContextRepository {
...
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
HttpServletRequest request = requestResponseHolder.getRequest();
HttpServletResponse response = requestResponseHolder.getResponse();
HttpSession httpSession = request.getSession(false);
SecurityContext context = readSecurityContextFromSession(httpSession);
...
return context;
}
...
}
複製代碼
在咱們的代碼中能夠經過靜態方法SecurityContextHolder.getContext().getAuthentication
來獲取用戶信息,或者能夠直接在Controller
入參聲明Authentication
,security
會幫咱們自動注入,若是隻想獲取Authentication
中的UserDetails
對應的部分,則可以使用@AuthenticationPrinciple UserDetails currentUser
@GetMapping("/info1")
public Object info1() {
return SecurityContextHolder.getContext().getAuthentication();
}
@GetMapping("/info2")
public Object info2(Authentication authentication) {
return authentication;
}
複製代碼
GET /user/info1
{
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
details: {
remoteAddress: "0:0:0:0:0:0:0:1",
sessionId: "24AE70712BB99A969A5C56907C39C20E"
},
authenticated: true,
principal: {
password: null,
username: "admin",
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
accountNonExpired: true,
accountNonLocked: true,
credentialsNonExpired: true,
enabled: true
},
credentials: null,
name: "admin"
}
複製代碼
@GetMapping("/info3")
public Object info3(@AuthenticationPrincipal UserDetails currentUser) {
return currentUser;
}
複製代碼
GET /user/info3
{
password: null,
username: "admin",
authorities: [
{
authority: "admin"
},
{
authority: "user"
}
],
accountNonExpired: true,
accountNonLocked: true,
credentialsNonExpired: true,
enabled: true
}
複製代碼
視頻教程
連接: pan.baidu.com/s/1wQWD4wE0… 提取碼: z6zi