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