使用 Spring 和 Redis 创建处理敏感数据的服务

许多公司(如:金融科技公司)处理的用户敏感数据由于法律限制不能永久存储。根据规定,这些数据的存储时间不能超过预设期限,并且最好在用于服务目的之后就将其删除。解决这个问题有多种可能的方案。在本文中,我想展示一个利用 Spring 和 Redis 处理敏感数据的应用程序的简化示例。

Redis 是一种高性能的 NoSQL 数据库。通常,它被用作内存缓存解决方案,因为它的速度非常快。然而,在这个示例中,我们将把它用作主要的数据存储。它完美地符合我们问题的需求,并且与 Spring Data 有很好的集成。

我们将创建一个管理用户全名和卡详细信息(作为敏感数据的示例)的应用程序。卡详细信息将以加密字符串的形式通过 POST 请求传递给应用程序。数据将仅在数据库中存储五分钟。在通过 GET 请求读取数据之后,数据将被自动删除。

该应用程序被设计为公司内部的微服务,不提供公共访问权限。用户的数据可以从面向用户的服务传递过来。然后,其他内部微服务可以请求卡详细信息,确保敏感数据保持安全,且无法从外部服务访问。

初始化 Spring Boot 项目

让我们开始使用 Spring Initializr 创建项目。我们需要 Spring Web、Spring Data Redis 和 Lombok。我还添加了 Spring Boot Actuator,因为在真实微服务中它肯定会很有用。

在初始化服务之后,我们应该添加其他依赖项。为了能够在读取数据后自动删除数据,我们将使用 AspectJ。我还添加了一些其他对服务有帮助的依赖项,使它看起来更接近真实的服务。

最终的 build.gradle 文件如下所示:

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 的连接。application.yml 中的 Spring Data Redis 属性如下:

yaml 复制代码
spring:
  data:
    redis:
      host: localhost
      port: 6379

领域模型

CardInfo 是我们将要处理的数据对象。为了使其更加真实,我们让卡详细信息作为加密数据传递到服务中。我们需要解密、验证,然后存储传入的数据。领域模型将有三个层次:

  • DTO:请求级别,用于控制器
  • Model:服务级别,用于业务逻辑
  • Entity:持久化级别,用于仓库

DTO 和 Model 之间的转换在 CardInfoConverter 中完成。Model 和 Entity 之间的转换在 CardInfoEntityMapper 中完成。我们使用 Lombok 以方便开发。

DTO

java 复制代码
@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

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

这里的卡详细信息表示一个加密字符串,而 fullName 是作为一个单独的对象传递的。注意 cardDetails 字段是如何从 toString() 方法中排除的。由于数据是敏感的,不应意外记录。

Model

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

CardInfoCardInfoRequestDto 相同,只是 cardDetails 已经被转换(在 CardInfoEntityMapper 中完成)。CardDetails 现在是一个解密后的对象,它有两个敏感字段:pan(卡号)和 CVV(安全码):

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

再次看到,我们从 toString() 方法中排除了敏感的 pan 和 CVV 字段。

Entity

java 复制代码
@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 的方式:

java 复制代码
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 对象。在真实的应用程序中,解密逻辑将在这个方法中实现。

仓库

使用 Spring Data 创建仓库。服务中的 CardInfo 通过其 ID 检索,因此不需要定义自定义方法,代码如下所示:

java 复制代码
@Repository
public interface CardInfoRepository extends CrudRepository<CardInfoEntity, String> {
}

Redis 配置

我们需要实体只存储五分钟。为了实现这一点,我们需要设置 TTL(生存时间)。我们可以通过在 CardInfoEntity 中引入一个字段并添加 @TimeToLive 注解来实现。也可以通过在 @RedisHash 上添加值来实现:@RedisHash(timeToLive = 5*60)

这两种方法都有些缺点。在第一种情况下,我们需要引入一个与业务逻辑无关的字段。在第二种情况下,值是硬编码的。还有另一种选择:实现 KeyspaceConfiguration。通过这种方法,我们可以使用 application.yml 中的属性来设置 TTL,如果需要的话,还可以设置其他 Redis 属性。

java 复制代码
@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 删除实体,需要在 @EnableRedisRepositories 注解中添加 enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP。我引入了 CacheName 类,以便使用常量作为实体名称,并反映如果需要的话可以对多个实体进行不同的配置。

TTL 的值是从 RedisKeysProperties 对象中获取的。

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

这里只有 cardInfo 这个实体,但可能还有其他实体存在。 应用.yml 中的 TTL 属性:

YAML 复制代码
redis:
 keys:
   cardInfo:
     timeToLive: PT5M

Controller

让我们为该服务添加 API,以便能够通过 HTTP 存储和访问数据。

java 复制代码
@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 进行注解。

java 复制代码
@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 方法定义了具体的逻辑,它通过 CardInfoRepository 按照 ID 删除 cardInfo 实体。@AfterReturning 注解表明该方法会在 value 属性中定义的方法成功返回后执行。

此外,我还使用了 @ConditionalOnExpression 注解来根据配置属性开启或关闭这一功能。

测试

我们将使用 MockMvc 和 Testcontainers 来编写 test case。

java 复制代码
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 请求的基本测试:

java 复制代码
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 进行自动删除功能测试:

java 复制代码
@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 逻辑可以在配置文件中关闭,而该注解则用于决定是否要运行该测试。

相关推荐
常年游走在bug的边缘几秒前
Spring Boot 集成 tess4j 实现图片识别文本
java·spring boot·后端·图片识别
NovakG_2 分钟前
SpringCloud小白入门+项目搭建
后端·spring·spring cloud
努力的搬砖人.5 分钟前
Spring Boot 实现定时任务的案例
spring boot·后端
Asthenia041224 分钟前
不知道LVS是什么?那你的系统设计题怎么回答!
后端
pedestrian_h25 分钟前
springboot+vue3+mysql+websocket实现的即时通讯软件
spring boot·后端·websocket
AskHarries38 分钟前
使用Cloudflare加速网站的具体操作步骤
后端
Asthenia041242 分钟前
深入剖析架构设计中的接入层:Nginx、LVS、F5详解与面试应对
后端
yuhaiqiang3 小时前
在公司写代码是工作,在开源社区写代码是生活
前端·后端
追逐时光者3 小时前
C#/.NET/.NET Core技术前沿周刊 | 第 35 期(2025年4.14-4.20)
后端·.net
洛神灬殇3 小时前
【Redis技术进阶之路】「原理分析系列开篇」分析客户端和服务端网络诵信交互实现(服务端执行命令请求的过程 - 初始化服务器)
数据库·redis·后端