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

相关推荐
怡人蝶梦25 分钟前
Java后端技术栈问题排查实战:Spring Boot启动慢、Redis缓存击穿与Kafka消费堆积
java·jvm·redis·kafka·springboot·prometheus
瓯雅爱分享29 分钟前
MES管理系统:Java+Vue,含源码与文档,实现生产过程实时监控、调度与优化,提升制造企业效能
java·mysql·vue·软件工程·源代码管理
bubiyoushang88831 分钟前
matlab雷达定位仿真
开发语言·matlab
鬼多不菜1 小时前
一篇学习CSS的笔记
java·前端·css
深色風信子1 小时前
Eclipse 插件开发 5.3 编辑器 监听输入
java·eclipse·编辑器·编辑器 监听输入·插件 监听输入
yezipi耶不耶1 小时前
Rust入门之并发编程基础(一)
开发语言·后端·rust
Blossom.1182 小时前
人工智能在智能健康监测中的创新应用与未来趋势
java·人工智能·深度学习·机器学习·语音识别
shangjg32 小时前
Kafka 如何保证不重复消费
java·分布式·后端·kafka
无处不在的海贼2 小时前
小明的Java面试奇遇之互联网保险系统架构与性能优化
java·面试·架构