[Перевод] Пример использования 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 {
}
Конфигурация 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 initialConfiguration() {
return Collections.singleton(customKeyspaceSettings(CardInfoEntity.class, CacheName.CARD_INFO));
}
private KeyspaceSettings customKeyspaceSettings(Class 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 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