Разработка стека технологий Spring Security, авторизация аутентификации на уровне предприятия

Spring

личный блог:www.zhenganwen.top, в конце статьи сюрприз!

Подготовка окружающей среды

Все примеры кода в этой статье размещены в облаке кода:git ee.com/beginning/…

В конце текста сюрприз!

Среда разработки

  • JDK1.8
  • Maven

Структура проекта

image.png

  • spring-security-demo

    Родительский проект, используемый для зависимостей всего проекта

  • security-core

    Основной модуль аутентификации безопасности,security-browserиsecurity-appоснованы на нем

  • security-browser

    Авторизация в браузере ПК, в основном черезSession

  • security-app

    Авторизация мобильной связи

  • security-demo

    применениеsecurity-browserиsecurity-app

полагаться

spring-security-demo

Добавить к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>

security-core

добавить настойчивости,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-browser

Добавить к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-app

Добавить кsecurity-core

<dependencies>
    <dependency>
        <groupId>top.zhenganwen</groupId>
        <artifactId>security-core</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>

security-demo

Временно цитируетсяsecurity-browserПодтвердить на ПК

<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

Спокойная VS традиция

RestfulHTTP-интерфейс - это стиль письма, а не стандарт или регулирование. использоватьRestfulОсновные различия между стилем и традиционным способом заключаются в следующем.

  • URL
    • Традиционный способ, как правило, черезURLДобавьте строки и параметры запроса, указывающие на поведение интерфейса, например/user/get?username=xxx
    • Restfulstyle рекомендует, чтобы URL-адрес представлял собой системный ресурс,/user/1должен указывать доступ к системеid1 пользователь
  • метод запроса
    • Традиционный способ, как правило, черезgetПодать, недостатокgetПри отправке параметры запроса будут прикреплены к URL-адресу, а URL-адрес имеет ограничение по длине, и если не выполняется специальная обработка, параметры отображаются в URL-адресе в виде простого текста, что небезопасно. Запросы, требующие двух вышеуказанных пунктов, будут использоватьpostОтправить
    • RestfulЭтот стиль поддерживает использование методов отправки для описания поведения запросов, таких какPOST,DELETE,PUT,GETДолжен соответствовать запросам на добавление, удаление, изменение и проверку типов
  • средство связи
    • Традиционно ответом на запрос является страница, поэтому необходимо разработать несколько систем для разных терминалов, а логика внешнего и внутреннего интерфейса связана.
    • Restfulстиль предполагает использованиеJSONВ качестве средства связи между передней и задней частями передняя и задняя части разделены; тип результата ответа идентифицируется кодом состояния ответа, например200Указывает, что запрос успешно обрабатывается,404Указывает, что соответствующий ресурс не найден,500Указывает, что сервер обрабатывает исключение.

RestfulПодробная ссылка:У-у-у. Беги, о, quilt.com/my3C note/hot water…

Расширенные функции SpringMVC и службы REST

Запуск в пакете Jar

Созданная выше среда была запущена через 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.originaldemo.jarЯвляется исполняемым иdemo.jar.originalЭто сохраняетсяmavenУпаковка по умолчанию

Используйте MockMVC для написания тестов интерфейса.

Придерживаясь принципа сначала тестирования (желательно сначала написать тестовые примеры, а затем написать интерфейсы, и проверить, что программа работает в соответствии с нашими идеями), нам нужно использоватьspring-boot-starter-testПлатформа тестирования и все, что с ней связаноMockMvcAPI.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, его необходимо внедрить в веб-контейнер.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?please=just…

Наиболее важными API являютсяMockMvc,MockMvcRequestBuilders,MockMvcRequestBuilders

  • MockMvc,перечислитьperformУкажите адрес интерфейса
  • MockMvcRequestBuilders, Запросы на сборку (включая путь запроса, отправку, заголовок запроса, тело запроса и т. д.)
  • MockMvcRequestBuilders, подтвердите результат ответа, например код состояния ответа, тело ответа

Сведения об аннотации MVC

@RestController

используется для идентификацииControllerзаRestful Controller, где возвращаемый результат метода будетSpringMVCавтоматически преобразуется вJSONИ заголовок ответа для установкиContent-Type=application/json

@RequestMapping

для сопоставления 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

@RequestParam

Обязательные параметры

Приведенный выше пример кода, если запрос не приходит с параметрами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.

и не думай о встрече@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), чтобы помочь нам инкапсулировать DTO подкачкиPageable, передаст параметр разбиения на страницы, который мы передаем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

@PathVariable

переменный заполнитель

наиболее общийRestful URL,рисунокGET /user/1Получатьidза1информация о пользователе, то когда мы пишем интерфейс, нам нужно поставить1заменить заполнителем, например{id}, динамически связан с параметрами метода в соответствии с фактическим запросом URLidначальство

@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());
}

@JsonView

Сценарии применения

Иногда нам нужно отфильтровать некоторые поля объекта ответа, например, не показывать при запросе всех пользователей.passwordполе, согласноidОтображается при запросе пользователейpasswordполе, вы можете пройти@JsonViewАннотация для достижения такой функции

инструкции

1. Объявите интерфейс представления, каждый интерфейс представляет стратегию видимости поля объекта при ответе на данные

Представление здесь относится к стратегии включения полей, которая добавляется позже.@JsonViewбудет использоваться, когда

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {

    /**
     * 普通视图,返回用户基本信息
     */
    public interface UserOrdinaryView {

    }

    /**
     * 详情视图,除了普通视图包含的字段,还返回密码等详细信息
     */
    public interface UserDetailsView extends UserOrdinaryView{
        
    }

    private String username;
    
    private String password;
}

Между представлениями и представлениями может существовать отношение наследования.После наследования представления будут унаследованы поля, содержащиеся в представлении.

2. Добавьте представление к полю объекта ответа, указав, что поле включено в представление.

@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;
}

3. Добавьте представление в метод Controller, указав, что данные объекта, возвращаемые методом, отображают только поля, содержащиеся в представлении.

@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");
    }
}

Хотя это очень детальный вопрос, у нас должна быть такая мысль и привычка.

Не забудьте повторно запустить все тестовые примеры после рефакторинга, чтобы убедиться, что рефакторинг не изменил поведение программы.

Обработать тело запроса

@RequestBody сопоставляет тело запроса с параметрами метода Java.

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)

Аннотация @Valid проверяет правильность параметров запроса.

Логика проверки извлечения

существует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Предоставленные аннотации ограничений следующие:

image.png

image.png

Например, при создании пользователя ограничьте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 обрабатывает результаты проверки

Вышеупомянутый метод обработки все еще не идеален. Мы только сообщаем интерфейсу, что формат данных запроса неверен через код состояния ответа, но мы не указываем четко, что не так. Нам нужно предоставить интерфейсу более четкую информацию. .

В приведенном выше примере, если он не пройдет проверку, метод не будет выполнен и вернется напрямую.Нет возможности написать какую-либо подсказку, если мы хотим ее вставить. В это время вы можете использовать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Предоставляются некоторые часто используемые ограничения, но для сложных бизнес-сценариев все же необходимо настроить аннотацию ограничения.Ведь иногда недостаточно проверки, что не пусто или форматно.Может нам нужно перейти к запросу к базе данных, чтобы проверить

Далее мы обратимся к существующим аннотациям ограничений и настроим аннотацию ограничения «имя пользователя нельзя повторять» в соответствии с рекомендациями по рисованию тыквы.

1. Создайте новый класс аннотаций ограничений

Мы хотим, чтобы эта аннотация была отмечена в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 { };
}

2. Напишите класс логики проверки

Согласно существующей аннотации, все они имеют аннотацию@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;
    }
}

3. Укажите класс проверки в аннотации ограничения.

пройти через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 { };
}

4. Тест

@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

обработка ошибок

Механизм обработки ошибок SpringBoot по умолчанию

Дифференцируйте клиента, чтобы ответить

Если при обработке запроса возникает ошибка,SpringMVCВ зависимости от типа клиента будут разные результаты ответа, такие как доступ через браузерlocalhost:8080/xxxвернет следующую страницу ошибки

image.png

при использовании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Он также возвращается, чтобы дать внешнему интерфейсу более четкую подсказку, что требует от нас настройки обработки исключений.

  1. Необходимо добавить пользовательские классы обработки исключений.@ControllerAdvice
  2. Используется в методе лечения ненормального@ExceptionHandlerОбъявите, какие исключения должны быть перехвачены, всеControllerЕсли возникнет одно из этих исключений, вместо него будет выполнен метод.
  3. Перехваченное исключение будет использоваться как параметр метода
  4. Результат, возвращаемый методом, такой же, как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Есть два шага к использованию фильтров в

1. Осознайте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");
    }
}

2. Конфигурация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;
    }
}

3. Тест

доступ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Предоставленный перехватчик также требует двух шагов

1. Осознайте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());
        }
    }
}

2. Класс конфигурации наследует WebMvcConfigureAdapter и переписывает метод addInterceptor для добавления пользовательского перехватчика.

@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Можно добавить несколько перехватчиков

3. Тест

  • 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

Схема последовательности вызовов методов примерно следующая

image.png

Срез Аспект

Сценарии применения

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Нам нужно три шага, чтобы использовать АОП в

  • Напишите классы срезов/аспектов, которые сочетают в себе точечные разрезы и улучшения.
    • Добавить к@Component, введенный в контейнер Spring
    • Добавить к@Aspect, активируйте переключатель программирования соотношения сторон
  • Напишите pointcut, который можно сделать с помощью аннотаций Pointcut состоит из двух частей: какие методы нужно улучшить и когда улучшить
    • пора резать
      • @Before, до выполнения метода
      • @AfterReturning, после окончания нормального выполнения метода
      • @AfterThrowing, после того как метод выдает исключение
      • @After, метод завершается нормальноreturnраньше, эквивалентноreturnвставлен передfinally
      • @Around, вы можете использовать введенные входные параметрыProceedingJoinPointГибкая реализация вышеуказанных четырех таймингов, его функция такая же, как и в методе перехватчикаhandlerАналогично, но с более полезной информацией о времени выполнения
    • pointcut, вы можете использоватьexecutionвыражение см.:docs.spring.IO/весна/документы…
  • написать методы улучшения,
    • Только один@AroundМожет иметь доход, может получитьProceedingJoinPointпример
    • позвонивProceedingJoinPointизpoint.proceed()Может вызвать соответствующий метод контроллера и получить возвращаемое значение.
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

Суммировать

процесс запроса

image.png

процесс ответа

image.png

Загрузка и загрузка файлов и пробный тест

Файл загружен

Старые правила, сначала проверьте, но используйте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);
    }

Контроллер управления файлами

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

Асинхронная обработка REST-сервисов

Раньше у нас было так, что каждый раз, когда клиент отправляет запрос,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Результаты теста следующие

image.png

Журнал консоли:

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Поток, который прослушивает запросы, занят в течение очень короткого времени, что значительно улучшает его возможности параллелизма.

DeferredResult обрабатывается асинхронно

CallableНедостатком асинхронной обработки является то, что асинхронная обработка может быть выполнена только путем локального создания нового вторичного потока, но сейчас, с преобладанием микросервисной архитектуры, нам часто требуется асинхронная обработка в разных системах. Например, в системе seckill количество одновременных запросов заказов велико, и если бэкенд обрабатывает каждый запрос заказа синхронно (то есть обрабатывает заказ в потоке запросов), а затем возвращает результат ответа, то обслуживание будет приостановлено ( отправить запрос на заказ). Ответа нет); в это время мы можем использовать промежуточное программное обеспечение сообщений, поток запросов отвечает только за мониторинг запроса на заказ, а затем отправляет сообщение в MQ, чтобы система заказов вытягивала сообщение (например, номер заказа) от MQ для обработки заказа и Результат обработки возвращается в систему seckill; система seckill самостоятельно настраивает поток для отслеживания сообщения о результате обработки заказа и возвращает результат обработки клиенту. как показано на рисунке

image.png

Для достижения эффекта, подобного вышеописанному, необходимо использоватьFutureРежим (см. «Практика многопоточного программирования на Java (режим проектирования)»), то есть мы можем установить сертификат результата обработкиDeferredResult, если мы прямо назовем егоgetResultРезультат обработки не может быть получен (будет заблокирован, это означает, что хотя поток запроса продолжает обрабатывать запрос, клиент по-прежнемуpending, только когда поток вызывает свойsetResult(result), будет соответствоватьresultответ клиенту

В этом примере для уменьшения сложности используйте локальную памятьLinkedListВместо промежуточного программного обеспечения для распределенного обмена сообщениями, вместо использования локальной новой системы упорядочения потоков, отношения между различными типами выглядят следующим образом.

image.png

Убейте системный AsyncOrderController за секунды

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();
    }
}

контрольная работа

image.png

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

перехват асинхронной обработки configureSync, тайм-аут, конфигурация пула потоков

расширяться перед намиWebMvcConfigureAdapterподклассWebConfigможно переписать вconfigureAsyncSupportспособ сделать некоторую настройку для асинхронной обработки

image.png

registerCallableInterceptors & registerDeferredResultInterceptors

Ранее мы переписалиaddInterceptorsПара зарегистрированных перехватчиков методомCallableиDeferredResultДва вида асинхронной обработки недействительны. Если вы хотите настроить перехватчики для обоих, вам необходимо переопределить эти два метода.

setDefaultTimeout

Установите время ожидания для асинхронной обработки. Если время превышает это время, он ответит напрямую, не дожидаясь завершения асинхронной задачи.

setTaskExecutor

SpringBootПо умолчанию асинхронные задачи выполняются путем создания новых потоков.После выполнения потоки уничтожаются.Для выполнения асинхронных задач путем мультиплексирования потоков (пулов потоков) вы можете передать через этот метод пользовательский пул потоков.

Переднее и заднее разделение

Документация по интерфейсу Swagger

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, аннотированные к параметрам метода контроллера, чтобы описать значение параметров

    @DeleteMapping("/{id:\\d+}")
    public void delete(@ApiParam("用户id") @PathVariable Long id) {
        System.out.println(id);
    }
    

Документация по интерфейсу будет восстановлена ​​после перезапуска.

image.png

image.png

WireMock

Чтобы облегчить параллельную разработку передней и задней частей, мы можем использоватьWireMockв качестве сервера виртуального интерфейса

Когда внутренний интерфейс не разработан, внешний интерфейс может подделывать некоторые статические данные (например, файлы JSON) в качестве результата запроса с помощью локальных файлов.Этот метод подходит, когда внешний интерфейс имеет только один Терминал. Однако, когда существует много видов внешних интерфейсов, таких как ПК, H5, 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)
                        )
        );
    }
}

Вы можете сначала сохранить данные JSONresources, затем черезClassPathResource#getFileиFileUtils#readLinesчитать данные как строку

доступlocalhost:8062/order/1:

{
    id: 1,
    orderNumber: "545616156"
}

пройти черезWireMockAPI, вы можете настроить различные сервисы интерфейса для виртуального сервера

Разработка аутентификации на основе форм с помощью Spring Security

Summary

Основные функции Spring Security

  • Аутентификация (кто вы)
  • Авторизация (что можно сделать)
  • Защита от атак (предотвращение подделки удостоверений, если хакеры могут подделывать удостоверения для входа в систему, две вышеуказанные функции не будут работать)

Содержание этой главы

  • Основы безопасности Spring
  • Реализовать аутентификацию по имени пользователя и паролю
  • Используйте номер мобильного телефона + SMS-аутентификация

Первые впечатления от Spring Security

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(пароль будет восстанавливаться каждый раз при перезапуске), мы используем эти две формы входа, и страница возвращается к службе, к которой мы хотим получить доступ.

formLogin

Из этого раздела мы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

httpBasic

Если метод аутентификации установленformLoginизменить наhttpBasicэтоsecurityКонфигурация по умолчанию (эквивалентно импортуsecurityЭффект, что ничего не совпадает после зависимости), то есть выскакивает окно логина

Основы безопасности Spring

три фильтра

image.png

как показано на рисунке,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

image.png

Пользовательская логика аутентификации пользователей

Логика получения информации о пользователях — UserDetailsService

До сих пор мы вошли через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

Давайте взглянем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

Поле пароля в пользовательской таблице обычно хранит не открытый текст пароля, а зашифрованный зашифрованный текст.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пользовательская страница входа в систему успешно

Логин отдыха логин

Поскольку мы основываемся наRESTservice, поэтому, если это запрос не из браузера, мы должны вернуть код состояния 401, сообщающий клиенту, что требуется аутентификация, вместо перенаправления на страницу входа.

Тогда мы не можемloginPageЗаписывается как путь к странице входа, но должен быть перенаправлен наController,Зависит отControllerОпределите, переходит ли пользователь, когда браузер получает доступ к странице или когда не-браузер, такой как Android, обращается к службе 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();
    }
}

image.png

Рефакторинг — конфигурация вместо хардкода

благодаря нашему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, реконструкция прошла успешно!

Пользовательская обработка успешного входа в систему — AuthenticationSuccessHandler

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-адрес клиента и SESSIONID этого сеанса
  • authenticated, пройти ли сертификацию
  • principle,соответствоватьUserDetailsServiceсерединаloadUserByUsernameвозвращениеUserDetails
  • credentials,пароль,securityПо умолчанию обрабатывается, и пароль не возвращается на внешний интерфейс
  • name,имя пользователя

Здесь, поскольку мы формируем логин, возвращается указанная выше информация, а затем мы выполняем сторонний логин, такой как WeChat и QQ, затемAuthenticationСодержащаяся информация может быть другой, то есть переписаннойonAuthenticationSuccessМетод вводаAuthenticationбудут отправлены нам в соответствии с различными методами входаAuthenticationреализовать объект класса

Пользовательская обработка ошибок при входе в систему — AuthenticationFailureHandler

В соответствии с обработкой успешного входа в систему также можно настроить обработку неудачных попыток входа.

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что ты сделал для нас?

Процесс аутентификации

image.png

На приведенном выше рисунке показан общий поток обработки входа в систему, фильтр запроса на входXxxAutenticationFilterПосле перехвата запроса на вход информация для входа инкапсулируется вauthenticated=falseизAuthenticationперейти кAuthenticationManagerПоможем проверить,AuthenticationManagerОн не будет сам выполнять логику проверки, он будет делегироватьAuthenticationProviderпомогите проверить,AuthenticationProviderВо время процесса проверки будет выдано исключение ошибки проверки, или проход проверки вернет новыйUserDetialsизAuthenticationreturn, фильтр запроса полученXxxAuthenticationFilterПосле успешного входа в систему будет вызван процессор для выполнения успешной логики входа в систему.

Мы используем метод входа в форму имени пользователя и пароля для отладки точки останова и пошагового анализа процесса проверки.Другие методы входа аналогичны.

image.png

image.png

securityloginProcess1.gif

Как результаты аутентификации распределяются между несколькими запросами

Чтобы обмениваться данными между несколькими запросами, вам нужно использоватьsession, Давайте посмотрим наsecurityположить что-нибудь вsession, когда будетsessionЧитать

В предыдущем разделе было сказано, чтоAbstractAuthenticationProcessingFilter``делать фильтр方法中,校验成功之后会调用successAuthentication(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;
	}
    ...
}

image.png

Получить аутентифицированную информацию о пользователе

В нашем коде мы можем передавать статические методы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
}

использованная литература