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

2025-05-14 07:39:42 102
魁首哥

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

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

我们将创建一个管理用户全名和卡详细信息(作为敏感数据的示例)的应用程序。卡详细信息将以加密字符串的形式通过 post 请求传递给应用程序。数据将仅在数据库中存储五分钟。在通过 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 的连接。application.yml中的 spring data redis 属性如下:

spring:
  data:
    redis:
      host: localhost
      port: 6379

领域模型

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

  • 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是作为一个单独的对象传递的。注意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;
}

cardinfocardinforequestdto 相同,只是 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 为实体创建哈希键,需要添加@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 创建仓库。服务中的 cardinfo 通过其 id 检索,因此不需要定义自定义方法,代码如下所示:

@repository
public interface cardinforepository extends crudrepository {
}

redis 配置

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

这两种方法都有些缺点。在第一种情况下,我们需要引入一个与业务逻辑无关的字段。在第二种情况下,值是硬编码的。还有另一种选择:实现 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 删除实体,需要在 @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

让我们为该服务添加 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 方法定义了具体的逻辑,它通过 cardinforepository 按照 id 删除 cardinfo 实体。@afterreturning 注解表明该方法会在 value 属性中定义的方法成功返回后执行。

此外,我还使用了 @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创建处理敏感数据的服务的示例代码的详细内容,更多关于spring redis处理敏感数据的资料请关注代码网其它相关文章!

分享
海报
102
上一篇:java代码如何实现存取数据库的blob字段 下一篇:新建一个springboot单体项目的教程

忘记密码?

图形验证码