使用Spring和Redis創(chuàng)建處理敏感數據的服務的示例代碼
許多公司(如:金融科技公司)處理的用戶敏感數據由于法律限制不能永久存儲。根據規(guī)定,這些數據的存儲時間不能超過預設期限,并且最好在用于服務目的之后就將其刪除。解決這個問題有多種可能的方案。在本文中,我想展示一個利用 Spring 和 Redis 處理敏感數據的應用程序的簡化示例。
Redis 是一種高性能的 NoSQL 數據庫。通常,它被用作內存緩存解決方案,因為它的速度非???。然而,在這個示例中,我們將把它用作主要的數據存儲。它完美地符合我們問題的需求,并且與 Spring Data 有很好的集成。
我們將創(chuàng)建一個管理用戶全名和卡詳細信息(作為敏感數據的示例)的應用程序??ㄔ敿毿畔⒁约用茏址男问酵ㄟ^ POST 請求傳遞給應用程序。數據將僅在數據庫中存儲五分鐘。在通過 GET 請求讀取數據之后,數據將被自動刪除。
該應用程序被設計為公司內部的微服務,不提供公共訪問權限。用戶的數據可以從面向用戶的服務傳遞過來。然后,其他內部微服務可以請求卡詳細信息,確保敏感數據保持安全,且無法從外部服務訪問。
初始化 Spring Boot 項目
讓我們開始使用 Spring Initializr 創(chuàng)建項目。我們需要 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 的連接。application.yml 中的 Spring Data Redis 屬性如下:
spring:
data:
redis:
host: localhost
port: 6379
領域模型
CardInfo 是我們將要處理的數據對象。為了使其更加真實,我們讓卡詳細信息作為加密數據傳遞到服務中。我們需要解密、驗證,然后存儲傳入的數據。領域模型將有三個層次:
- DTO:請求級別,用于控制器
- Model:服務級別,用于業(yè)務邏輯
- Entity:持久化級別,用于倉庫
DTO 和 Model 之間的轉換在 CardInfoConverter 中完成。Model 和 Entity 之間的轉換在 CardInfoEntityMapper 中完成。我們使用 Lombok 以方便開發(fā)。
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 是作為一個單獨的對象傳遞的。注意 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 現在是一個解密后的對象,它有兩個敏感字段:pan(卡號)和 CVV(安全碼):
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = {"pan", "cvv"})
public class CardDetails {
@NotBlank
private String pan;
private String cvv;
}
再次看到,我們從 toString() 方法中排除了敏感的 pan 和 CVV 字段。
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 為實體創(chuàng)建哈希鍵,需要添加 @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 對象。在真實的應用程序中,解密邏輯將在這個方法中實現。
倉庫
使用 Spring Data 創(chuàng)建倉庫。服務中的 CardInfo 通過其 ID 檢索,因此不需要定義自定義方法,代碼如下所示:
@Repository
public interface CardInfoRepository extends CrudRepository<CardInfoEntity, String> {
}
Redis 配置
我們需要實體只存儲五分鐘。為了實現這一點,我們需要設置 TTL(生存時間)。我們可以通過在 CardInfoEntity 中引入一個字段并添加 @TimeToLive 注解來實現。也可以通過在 @RedisHash 上添加值來實現:@RedisHash(timeToLive = 5*60)。
這兩種方法都有些缺點。在第一種情況下,我們需要引入一個與業(yè)務邏輯無關的字段。在第二種情況下,值是硬編碼的。還有另一種選擇:實現 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 刪除實體,需要在 @EnableRedisRepositories 注解中添加 enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP。我引入了 CacheName 類,以便使用常量作為實體名稱,并反映如果需要的話可以對多個實體進行不同的配置。
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 這個實體,但可能還有其他實體存在。 應用.yml 中的 TTL 屬性:
redis:
keys:
cardInfo:
timeToLive: PT5M
Controller
讓我們?yōu)樵摲仗砑?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 來實現。我們需要創(chuàng)建一個 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 定義了邏輯應用的切入點。換句話說,它決定了觸發(fā)邏輯執(zhí)行的時機。deleteCard 方法定義了具體的邏輯,它通過 CardInfoRepository 按照 ID 刪除 cardInfo 實體。@AfterReturning 注解表明該方法會在 value 屬性中定義的方法成功返回后執(zhí)行。
此外,我還使用了 @ConditionalOnExpression 注解來根據配置屬性開啟或關閉這一功能。
測試
我們將使用 MockMvc 和 Testcontainers 來編寫 test case。
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 邏輯可以在配置文件中關閉,而該注解則用于決定是否要運行該測試。
以上就是使用Spring和Redis創(chuàng)建處理敏感數據的服務的示例代碼的詳細內容,更多關于Spring Redis處理敏感數據的資料請關注腳本之家其它相關文章!
相關文章
java客戶端Jedis操作Redis Sentinel 連接池的實現方法
下面小編就為大家?guī)硪黄猨ava客戶端Jedis操作Redis Sentinel 連接池的實現方法。小編覺得挺不錯的,現在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-03-03
SpringMVC的處理器攔截器HandlerInterceptor詳解
這篇文章主要介紹了SpringMVC的處理器攔截器HandlerInterceptor詳解,SpringWebMVC的處理器攔截器,類似于Servlet開發(fā)中的過濾器Filter,用于處理器進行預處理和后處理,需要的朋友可以參考下2024-01-01
SpringBoot+WebSocket實現即時通訊的方法詳解
這篇文章主要為大家詳細介紹了如何利用SpringBoot+WebSocket實現即時通訊功能,文中示例代碼講解詳細,對我們學習或工作有一定參考價值,需要的可以參考一下2022-05-05

