Пример использования Spring Data и Redis для временного хранения персональных данных

от автора

Некоторые компании, работающие с персональными данными пользователей, сталкиваются с невозможностью хранить их в течение долгого периода времени из-за правовых ограничений. Такое часто можно встретить в финтехах. Позволяется сохранить данные на очень короткое время, которые также должны быть удалены сразу после использования в целях сервиса. Существует несколько вариантов решения этой задачи. В данном посте я показываю упрощенный пример микросервиса, работающего с чувствительной информацией, используя Spring и Redis

Redis это высокопроизводительная NoSQL база данных, которая обычно используется в качестве решения для кеширования данных. В данном примере мы будем использовать Redis как основную БД сервиса. Она хорошо подходит для нашей задачи, а также имеет удобную интеграцию с Spring Data. Мы создадим микросервис, который взаимодействует с данными пользователей: ФИО пользователя и данные кредитной карты (как пример чувствительных данных). Карточные данные передаются в сервис (POST запрос) в виде закодированной строки (обычная строка для простоты нашего примера). Информация хранится в БД только 5 минут. После того, как данные впоследствии прочитаны (GET запрос), они сразу же автоматически удаляются.

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

Инициализация Spring Boot проекта

Для начала создадим приложение используя Spring initializr. Нам понадобятся Spring Web, Spring Data Redis, Lombok. Я также добавил Spring Boot Actuator, т.к. он явно бы понадобился в реальном приложении.
После инициализации сервиса добавим необходимые зависимости. Для того чтобы иметь возможность автоматически удалять данные после того, как они были прочитаны, мы будем использовать AspectJ. Я также добавил некоторые другие полезные зависимости, которые делают приложения более реалистичным (например, вам точно понадобилась бы валидация в настоящем сервисе).

Финальная версия build.gradle выглядит следующим образом:

plugins {     id 'java'     id 'org.springframework.boot' version '3.3.3'     id 'io.spring.dependency-management' version '1.1.6'     id "io.freefair.lombok" version "8.10.2" }  java {     toolchain {         languageVersion = JavaLanguageVersion.of(22)     } }  repositories {     mavenCentral() }  ext {     springBootVersion = '3.3.3'     springCloudVersion = '2023.0.3'     dependencyManagementVersion = '1.1.6'     aopVersion = "1.9.19"     hibernateValidatorVersion = '8.0.1.Final'     testcontainersVersion = '1.20.2'     jacksonVersion = '2.18.0'     javaxValidationVersion = '3.1.0' }  dependencyManagement {     imports {         mavenBom "org.springframework.boot:spring-boot-dependencies:${springBootVersion}"         mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"     } }  dependencies {     implementation 'org.springframework.boot:spring-boot-starter-data-redis'     implementation 'org.springframework.boot:spring-boot-starter-web'     implementation 'org.springframework.boot:spring-boot-starter-actuator'     implementation "org.aspectj:aspectjweaver:${aopVersion}"       implementation "com.fasterxml.jackson.core:jackson-core:${jacksonVersion}"     implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}"     implementation "com.fasterxml.jackson.core:jackson-annotations:${jacksonVersion}"      implementation "jakarta.validation:jakarta.validation-api:${javaxValidationVersion}"     implementation "org.hibernate:hibernate-validator:${hibernateValidatorVersion}"      testImplementation('org.springframework.boot:spring-boot-starter-test') {         exclude group: 'org.junit.vintage'     }     testImplementation "org.testcontainers:testcontainers:${testcontainersVersion}"     testImplementation 'org.junit.jupiter:junit-jupiter' }  tasks.named('test') {     useJUnitPlatform() }

Необходимо установить соединения с Redis. Spring Data Redis проперти в application.yml:

spring:  data:    redis:      host: localhost      port: 6379

Модель

CardInfo представляет собой главный объект, с которым мы будем работать. Для большей реалистичности данные кредитной карты будем передавать в виде закодированной строки. Мы должны раскодировать, провалидировать и затем сохранить входящие данные. Всего в приложении 3 доменных слоя:

  • DTO — уровень запросов, используется в контроллерах

  • Model — сервисный уровень, используется в бизнес-логике

  • Entity — персистентный уровень, используется репозиториями

DTO конвертируется в Model и наоборот в CardInfoConverter.
Model конвертируется в Entity и наоборот в CardInfoEntityMapper.

Для удобства используем Lombok.

DTO:

@Builder @Getter @ToString(exclude = "cardDetails") @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class CardInfoRequestDto {    @NotBlank    private String id;    @Valid    private UserNameDto fullName;    @NotNull     private String cardDetails; }

Где UserNameDto:

@Builder @Getter @ToString @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class UserNameDto {    @NotBlank    private String firstName;    @NotBlank    private String lastName; }

Карточные данные представляют собой строку, а fullName это отдельный объект, передаваемые в виде обычного Json.
Обратите внимание, как поле cardDetails исключено из метода toString(). Персональные данные не должны быть случайно залогированы.

Model:

@Data @Builder public class CardInfo {     @NotBlank    private String id;    @Valid    private UserName userName;    @Valid    private CardDetails cardDetails; }
@Data @Builder public class UserName {     private String firstName;    private String lastName; }

CardInfo является таким же объектом, как и CardInfoRequestDto кроме поля cardDetails (сконвертировано в CardInfoEntityMapper). CardDetails здесь — объект, десериализованный из строки, у которого есть 2 поля: pan (номер карты) и cvv (проверочный код)

@Data @Builder @NoArgsConstructor @AllArgsConstructor @ToString(exclude = {"pan", "cvv"}) public class CardDetails {    @NotBlank    private String pan;    private String cvv; }

Снова обратите внимание, что мы исключили pan и cvv из метода toString().

Entity:

@Getter @Setter @ToString(exclude = "cardDetails") @NoArgsConstructor @AllArgsConstructor @Builder @RedisHash public class CardInfoEntity {     @Id    private String id;    private String cardDetails;    private String firstName;    private String lastName; }

Для того, чтобы Redis мог создать ключ сохраняемого объекта, необходимо добавить аннотации @RedisHash и @Id.

А вот как происходит конвертация dto -> model:

public CardInfo toModel(@NonNull CardInfoRequestDto dto) {    final UserNameDto userName = dto.getFullName();    return CardInfo.builder()            .id(dto.getId())            .userName(UserName.builder()                    .firstName(ofNullable(userName).map(UserNameDto::getFirstName).orElse(null))                    .lastName(ofNullable(userName).map(UserNameDto::getLastName).orElse(null))                    .build())            .cardDetails(getDecryptedCardDetails(dto.getCardDetails()))            .build(); }  private CardDetails getDecryptedCardDetails(@NonNull String cardDetails) {    try {        return objectMapper.readValue(cardDetails, CardDetails.class);    } catch (IOException e) {        throw new IllegalArgumentException("Card details string cannot be transformed to Json object", e);    } }

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

Repository

Для создания Repository используем Spring Data. CardInfo извлекается по id, так что нам не нужно создавать кастомные методы:

@Repository public interface CardInfoRepository extends CrudRepository<CardInfoEntity, String> { }

Конфигурация Redis

Мы должны хранить данные только 5 минут. Для это мы должны задать TTL (time to leave). Мы можем сделать это введя новое поле в CardInfoEntity и добавив над ним аннотацию @TimeToLive. Так же, мы могли бы добиться этого задав значение атрибута timeToLive в аннотации @RedisHash: @RedisHash(timeToLive = 5*60). У обоих способов есть недостатки. В первом случае нам приходится добавлять поле, которое не имеет отношения к бизнес-логике. Во втором случае значение TTL захардкожено. Мы воспользуемся другим способом: имплементируем KeyspaceConfiguration. В этом случае мы можем использовать значение проперти из application.yml, чтобы установить TTL и, если необходимо, другие настройки Redis.

@Configuration @RequiredArgsConstructor @EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) public class RedisConfiguration {    private final RedisKeysProperties properties;     @Bean    public RedisMappingContext keyValueMappingContext() {        return new RedisMappingContext(                new MappingConfiguration(new IndexConfiguration(), new CustomKeyspaceConfiguration()));    }     public class CustomKeyspaceConfiguration extends KeyspaceConfiguration {         @Override        protected Iterable<KeyspaceSettings> initialConfiguration() {            return Collections.singleton(customKeyspaceSettings(CardInfoEntity.class, CacheName.CARD_INFO));        }         private <T> KeyspaceSettings customKeyspaceSettings(Class<T> type, String keyspace) {            final KeyspaceSettings keyspaceSettings = new KeyspaceSettings(type, keyspace);            keyspaceSettings.setTimeToLive(properties.getCardInfo().getTimeToLive().toSeconds());            return keyspaceSettings;        }    }     @NoArgsConstructor(access = AccessLevel.PRIVATE)    public static class CacheName {        public static final String CARD_INFO = "cardInfo";    } }

Для того, чтобы Redis удалял данные старше TTL необходимо добавить enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP в аннотацию @EnableRedisRepositories annotation.
Я создал класс CacheName для использования констант в качестве имен entity, а также, чтобы показать, что может быть несколько entity, которые могут быть сконфигурированы по-разному при необходимости.

Значение TTL извлекается из RedisKeysProperties:

@Data @Component @ConfigurationProperties("redis.keys") @Validated public class RedisKeysProperties {    @NotNull    private KeyParameters cardInfo;     @Data    @Validated    public static class KeyParameters {        @NotNull        private Duration timeToLive;    } }

В данном примере у нас есть только cardInfo, но могут быть и другие entity.

Значение TTL в application.yml:

redis:  keys:    cardInfo:      timeToLive: PT5M

Controller

Добавим API в сервис, чтобы иметь возможность работать с данными через HTTP.

@RestController @RequiredArgsConstructor @RequestMapping( "/api/cards") public class CardController {    private final CardService cardService;    private final CardInfoConverter cardInfoConverter;     @PostMapping    @ResponseStatus(CREATED)    public void createCard(@Valid @RequestBody CardInfoRequestDto cardInfoRequest) {        cardService.createCard(cardInfoConverter.toModel(cardInfoRequest));    }     @GetMapping("/{id}")    public ResponseEntity<CardInfoResponseDto> getCard(@PathVariable("id") String id) {        return ResponseEntity.ok(cardInfoConverter.toDto(cardService.getCard(id)));    } }

Автоматическое удаление данных с AOP

Нам нужно, чтобы данные удалялись сразу после того, как были успешно возвращены в GET запросе. Мы можем сделать это с помощью AOP и AspectJ. Необходимо создать Spring Bean и аннотировать его @Aspect

@Aspect @Component @RequiredArgsConstructor @ConditionalOnExpression("${aspect.cardRemove.enabled:false}") public class CardRemoveAspect {    private final CardInfoRepository repository;     @Pointcut("execution(* com.cards.manager.controllers.CardController.getCard(..)) && args(id)")    public void cardController(String id) {    }     @AfterReturning(value = "cardController(id)", argNames = "id")    public void deleteCard(String id) {        repository.deleteById(id);    } }

@Pointcut определяет, где должна быть применена логика или, другими словами, что вызывает выполнение логики, определенной в аспекте. В методе deleteCard() определятся, что должно выполнятся. Он удаляет cardInfo по id используя для этого CardInfoRepository. Аннотация @AfterReturning означает, что этот метод должны запускаться после успешного возвращения значения из метода, указанного в value (cardController(id)). 
Я также аннотировал класс @ConditionalOnExpression для включения/отключения этого функционала из пропертей.

Testing

Мы будем тестировать используя MockMvc и Testcontainers.
Testcontainers initializer для Redis:

public abstract class RedisContainerInitializer {    private static final int PORT = 6379;    private static final String DOCKER_IMAGE = "redis:6.2.6";     private static final GenericContainer REDIS_CONTAINER = new GenericContainer(DockerImageName.parse(DOCKER_IMAGE))            .withExposedPorts(PORT)            .withReuse(true);     static {        REDIS_CONTAINER.start();    }     @DynamicPropertySource    static void properties(DynamicPropertyRegistry registry) {        registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost);        registry.add("spring.data.redis.port", () -> REDIS_CONTAINER.getMappedPort(PORT));    } }

С помощью @DynamicPropertySource мы можем установить значения проперти из стартовавшего Redis Docker контейнера. Затем эти значения будут прочитаны приложением для установки соединения с Redis.

Базовые тесты для POST и GET запросов:

public class CardControllerTest extends BaseTest {     private static final String CARDS_URL = "/api/cards";    private static final String CARDS_ID_URL = CARDS_URL + "/{id}";     @Autowired    private CardInfoRepository repository;     @BeforeEach    public void setUp() {        repository.deleteAll();    }     @Test    public void createCard_success() throws Exception {        final CardInfoRequestDto request = aCardInfoRequestDto().build();         mockMvc.perform(post(CARDS_URL)                        .contentType(APPLICATION_JSON)                        .content(objectMapper.writeValueAsBytes(request)))                .andExpect(status().isCreated())        ;        assertCardInfoEntitySaved(request);    }     @Test    public void getCard_success() throws Exception {        final CardInfoEntity entity = aCardInfoEntityBuilder().build();        prepareCardInfoEntity(entity);         mockMvc.perform(get(CARDS_ID_URL, entity.getId()))                .andExpect(status().isOk())                .andExpect(jsonPath("$.id", is(entity.getId())))                .andExpect(jsonPath("$.cardDetails", notNullValue()))                .andExpect(jsonPath("$.cardDetails.cvv", is(CVV)))        ;    } }

Тест, который проверяет автоматическое удаление данных через AOP:

@Test @EnabledIf(        expression = "${aspect.cardRemove.enabled}",        loadContext = true ) public void getCard_deletedAfterRead() throws Exception {    final CardInfoEntity entity = aCardInfoEntityBuilder().build();    prepareCardInfoEntity(entity);     mockMvc.perform(get(CARDS_ID_URL, entity.getId()))            .andExpect(status().isOk());    mockMvc.perform(get(CARDS_ID_URL, entity.getId()))            .andExpect(status().isNotFound())    ; }

Я добавил аннотацию @EnabledIf, так как логика AOP может быть отключена в пропертях и эта аннотация определяет нужно ли запускать тест.

Ссылки

Полная версия микросервиса доступна на GitHub


ссылка на оригинал статьи https://habr.com/ru/articles/857518/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *