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