0.前言
昨天解决了后管中用户模块的一些基础配置,今天来完成用户注册的功能。
1.判断用户是否存在
在我们设计的系统中,用户名是唯一的,需要在用户注册时就指定,另外为了用户有更好的体验,我们应该是在用户输入用户名的那一刻就在前端显示用户名是否已被注册,一些网页只在用户点击注册后才告知用户名是否存在,用户体验极差...这里我们单独定义一个接口,用于判断用户名是否存在。
- UserController.java
java
/**
* 查询用户名是否可用
*/
@GetMapping("/user/has-username")
Result<Boolean> hasUsername(@RequestParam("username") String username) {
return Results.success(userService.hasUsername(username));
}
- UserServiceImpl.java
java
/**
* 判断用户名是否存在
* @param username
* @return 用户名存在返回true,不存在返回false
*/
@Override
public Boolean hasUsername(String username) {
LambdaQueryWrapper<UserDO> queryWrapper = Wrappers.lambdaQuery(UserDO.class)
.eq(UserDO::getUsername, username);
UserDO userDO = baseMapper.selectOne(queryWrapper);
return userDO==null;
}
在apifox中用一个未注册过的username去试,返回数据为true,代表没有重复,此用户名可用:

2.用户注册的问题
在上述功能中,如果黑客使用脚本生成大量随机的、肯定不存在的用户名发起请求,那么大量请求直接打到数据库,很有可能导致数据库的崩溃。因此我们想到使用Redis来进行缓存。
但是我们使用Redis来缓存也有一个弊端,对于用户名这种随机性比较大的字段,我们如果不设置有效期,那么这些缓存的用户名在Redis中就会占据很大的内存空间,导致浪费。而如果设置了有效期,那么有效期一到,依然会导致上面的事故发生。
第二版方案就是使用布隆过滤器。
2.1 什么是布隆过滤器
布隆过滤器是一种空间效率极高的数据结构,用于判断一个元素是否存在于一个集合中。其核心逻辑是:
- 如果布隆过滤器说不存在,那一定不存在
- 如果布隆过滤器说存在,则可能存在(存在极小的误判率)
2.2 结合Redis的业务流程设计
我们需要在Redis前加一层布隆过滤器进行拦截,具体流程如下:
- 请求进入:用户输入用户名
- 前置校验:先进行正则校验,格式不对直接返回,不走后续逻辑
- 布隆过滤器检查:
- 查询布隆过滤器内是否有该用户名
- 情况A:不存在,返回false。说明未被注册,直接返回前端用户名可用
- 情况B:存在,返回true。有可能是误判,此时才去查询Redis/数据库确认用户名是否真的存在。
2.3 Java实现
- 引入Redisson依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
</dependency>
- 配置Redis远程连接
yaml
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: 994499
- 创建布隆过滤器实例
java
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 布隆过滤器配置
*/
@Configuration
public class RBloomFilterConfiguration {
/**
* 防止用户注册查询数据库的布隆过滤器
*/
@Bean
public RBloomFilter<String> userRegisterCachePenetrationBloomFilter(RedissonClient redissonClient) {
RBloomFilter<String> cachePenetrationBloomFilter = redissonClient.getBloomFilter("userRegisterCachePenetrationBloomFilter");
cachePenetrationBloomFilter.tryInit(100000L, 0.01);
return cachePenetrationBloomFilter;
}
}
tryInit有两个参数:
- expectedInsertions:预估布隆过滤器存储的元素长度。
- falseProbability:运行的误判率。
误判率越低,位组数越长,布隆过滤器占用的内存就越大;错误率越低,散列Hash函数越多,计算耗时越长。
使用布隆过滤器的话,直接注入即可
java
private final RBloomFilter<String> userRegisterCachePenetrationBloomFilter;
下面是修改后的判断用户存在和注册用户代码:
java
@Override
public Boolean hasUsername(String username) {
return userRegisterCachePenetrationBloomFilter.contains(username);
}
3.用户注册功能
用户注册功能也不复杂,用户发送请求后,去判断用户名是否存在,如果存在则直接返回,不存在就完成创建,并把数据新增至数据库和布隆过滤器中。
- UserController.java
java
/**
* 注册用户
*/
@PostMapping("/user")
Result<Void> register(@RequestBody UserRegisterReqDTO requestParam) {
userService.register(requestParam);
return Results.success();
}
- UserServiceImpl.java
java
/**
* 注册用户
* @param requestParam
*/
@Override
public void register(UserRegisterReqDTO requestParam) {
if (hasUsername(requestParam.getUsername())){
throw new ClientException(UserErrorCodeEnum.USER_NAME_EXIST);
}
int inserted = baseMapper.insert(BeanUtil.toBean(requestParam, UserDO.class));
if (inserted<1){
throw new ClientException(UserErrorCodeEnum.USER_SAVE_FAIL);
}
userRegisterCachePenetrationBloomFilter.add(requestParam.getUsername());
}
流程也很简单,直接判断用户名是否已存在,存在的话就抛出用户名存在的异常,然后进行插入数据,同时不要忘记在布隆过滤器中也插入数据。
tips:这边一定要理清逻辑,由于我上面写的函数是hasUsername,也就是用户名存在返回true,此时前端应该是抛用户名存在异常的,因此这里有个相反的逻辑(也就是说其实hasUsername报false才是可以添加用户名,感觉这么写不是很友好,建议改成usernameIsValid,这样逻辑更顺一点)。
4.一些后续的优化
4.1 自动填充
在企业开发中,DTO和DO的分离是非常表中且必要的做法,在注册用户的时候,我们接收前端传递过来的类为UserRegisterReqDTO ,而存入数据库表中的类为UserDO,它不仅包括了业务字段,还包含create_time,update_time,del_flag等。但在之前的代码中,我们并没有对这些参数进行设置,而且如果每次都要去设置的话,就会产生很多重复的代码,因此我们使用Mybatis-plus中的自动填充功能。
- UserDO.java
java
/**
* 用户持久层实体
*/
@TableName("t_user")
@Data
public class UserDO {
//前面的业务字段略!!!!!!!
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private Date createTime;
/**
* 修改时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
/**
* 删除标识 0:未删除 1:已删除
*/
@TableField(fill = FieldFill.INSERT)
private Integer delFlag;
}
- MyMetaObjectHandler.java
java
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
log.info("开始插入填充...");
this.strictInsertFill(metaObject, "createTime",Date::new, Date.class);
this.strictInsertFill(metaObject, "updateTime",Date::new, Date.class);
this.strictInsertFill(metaObject, "delFlag",() -> 0,Integer.class);
}
@Override
public void updateFill(MetaObject metaObject) {
log.info("开始更新填充...");
this.strictInsertFill(metaObject, "updateTime", Date::new, Date.class);
}
}
可以看到在Mybatis-plus的自动填充中,最核心的两个部分就是Fill方法和@TableField注解,这二者是配合使用的。
@TableField注解告诉框架什么时候需要填充,而Fill方法告诉框架如何填,填什么。
- @TableField注解
这个注解加在实体类的字段上,后面括号里的fill属性是自动填充的开关,其值是一个枚举类FieldFill,有以下4种选项:
- DEFAULT :默认值,不进行自动填充
- INSERT :仅插入时自动填充
- UPDATE :仅更新时自动填充
- INSERT_UPDATE :插入和更新时都填充
- strictInsertFill方法
这个方法在MyMetaObjectHandler类中调用,其方法签名为:
java
MetaObjectHandler strictInsertFill(MetaObject metaObject, String fieldName, Supplier<E> fieldVal, Class<T> fieldType);
- metaObject(元对象):这是MyBatis核心对象,它包装了我们正在操作的实体对象(UserDO);Handler通过它来反射读取或修改UserDO里的值;
- fieldName :这里填的是Java实体类的属性名,不是数据库表的字段名!!!!
- fieldVal:这是一个Supplier函数式接口,表示值的提供者。
- fieldType :告诉MP,要填充的属性是什么类,这里的类一定要与实体类里的类型完全对应。
4.2 如何防止用户名重复
前面提到了使用布隆过滤器加载所有用户名,就可以实现完全隔离数据库,但是布隆过滤器有误判风险,因此在数据库中,我们还需要将username的字段加上唯一索引,彻底确保没有重复用户名。