用户模块
功能分析
检查用户名是否存在
注册用户
修改用户
根据用户名查询用户
用户登陆
检查用户是否登陆
用户推出登陆
注销用户
CREATE TABLE `t_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户名',
`password` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '密码',
`real_name` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '真实姓名',
`phone` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '手机号码',
`mail` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '邮箱',
`deletion_time` bigint(20) DEFAULT NULL COMMENT '删除时间戳',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0: 未删除 1: 已删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
导入maven坐标
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
</dependencies>
持久层配置文件
spring:
datasource:
username: root
password: root
url: jdbc:mysql://127.0.0.1:3306/link?characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&serverTimezone=GMT%2B8
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
connection-test-query: select 1
connection-timeout: 20000
idle-timeout: 300000
maximum-pool-size: 5
minimum-idle: 5
构造器注入方式
package com.hayaizo.shortlink.admin.controller;
import com.hayaizo.shortlink.admin.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* 用户管理控制层
*/
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/**
* 根据用户名查询用户信息
*/
@GetMapping("/api/shortlink/v1/user/{username}")
public String getUserByUsername(@PathVariable("username") String username) {
return "hello "+username;
}
public UserController(UserService userService) {
this.userService = userService;
}
}
@RequiredArgsConstructor
给所有final字段添加了一个构造函数,比如在UserController
中,加上注解就会在UserController
中自动创建一个UserServicve
的构造函数。
等效于:
package com.hayaizo.shortlink.admin.controller;
import com.hayaizo.shortlink.admin.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* 用户管理控制层
*/
@RestController
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
}
Spring 会自动将 UserService
类型的 Bean 注入到这个构造器中,进而赋值给 userService
字段。
构造器注入的优点
不可变性:使用构造器注入时,依赖通常是
final
的,这意味着它们在对象创建后不能再被修改,从而保证了对象的不可变性。确保依赖完整性:通过构造器注入,依赖在对象创建时就已经完全注入,这意味着你总是拥有一个完全初始化的对象,不会出现依赖未被注入的情况。
容易进行单元测试:构造器注入的类可以很容易地在测试中创建,并且在测试时传入所需的 mock 对象,便于测试隔离。
动态导包
全局返回对象Result
package com.hayaizo.shortlink.admin.common.convention;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* 全局返回对象
*/
@Data
@Accessors(chain = true)
public class Result<T> implements Serializable {
private static final long serialVersionUID = 5679018624309023727L;
/**
* 正确返回码
*/
public static final String SUCCESS_CODE = "0";
/**
* 返回码
*/
private String code;
/**
* 返回消息
*/
private String message;
/**
* 响应数据
*/
private T data;
/**
* 请求ID
*/
private String requestId;
public boolean isSuccess() {
return SUCCESS_CODE.equals(code);
}
}
import com.nageoffer.shortlink.admin.common.convention.errorcode.BaseErrorCode;
import com.nageoffer.shortlink.admin.common.convention.exception.AbstractException;
import java.util.Optional;
/**
* 全局返回对象构造器
*/
public final class Results {
/**
* 构造成功响应
*/
public static Result<Void> success() {
return new Result<Void>()
.setCode(Result.SUCCESS_CODE);
}
/**
* 构造带返回数据的成功响应
*/
public static <T> Result<T> success(T data) {
return new Result<T>()
.setCode(Result.SUCCESS_CODE)
.setData(data);
}
/**
* 构建服务端失败响应
*/
public static Result<Void> failure() {
return new Result<Void>()
.setCode(BaseErrorCode.SERVICE_ERROR.code())
.setMessage(BaseErrorCode.SERVICE_ERROR.message());
}
/**
* 通过 {@link AbstractException} 构建失败响应
*/
public static Result<Void> failure(AbstractException abstractException) {
String errorCode = Optional.ofNullable(abstractException.getErrorCode())
.orElse(BaseErrorCode.SERVICE_ERROR.code());
String errorMessage = Optional.ofNullable(abstractException.getErrorMessage())
.orElse(BaseErrorCode.SERVICE_ERROR.message());
return new Result<Void>()
.setCode(errorCode)
.setMessage(errorMessage);
}
/**
* 通过 errorCode、errorMessage 构建失败响应
*/
public static Result<Void> failure(String errorCode, String errorMessage) {
return new Result<Void>()
.setCode(errorCode)
.setMessage(errorMessage);
}
}
异常码设计
IErrorCode
package com.hayaizo.shortlink.admin.common.convention.errorcode;
/**
* 平台错误码
*/
public interface IErrorCode {
/**
* 错误码
*/
String code();
/**
* 错误信息
*/
String message();
}
BaseErrorCode
package com.hayaizo.shortlink.admin.common.convention.errorcode;
/**
* 基础错误码定义
*/
public enum BaseErrorCode implements IErrorCode {
// ========== 一级宏观错误码 客户端错误 ==========
CLIENT_ERROR("A000001", "用户端错误"),
// ========== 二级宏观错误码 用户注册错误 ==========
USER_REGISTER_ERROR("A000100", "用户注册错误"),
USER_NAME_VERIFY_ERROR("A000110", "用户名校验失败"),
USER_NAME_EXIST_ERROR("A000111", "用户名已存在"),
USER_NAME_SENSITIVE_ERROR("A000112", "用户名包含敏感词"),
USER_NAME_SPECIAL_CHARACTER_ERROR("A000113", "用户名包含特殊字符"),
PASSWORD_VERIFY_ERROR("A000120", "密码校验失败"),
PASSWORD_SHORT_ERROR("A000121", "密码长度不够"),
PHONE_VERIFY_ERROR("A000151", "手机格式校验失败"),
// ========== 二级宏观错误码 系统请求缺少幂等Token ==========
IDEMPOTENT_TOKEN_NULL_ERROR("A000200", "幂等Token为空"),
IDEMPOTENT_TOKEN_DELETE_ERROR("A000201", "幂等Token已被使用或失效"),
// ========== 一级宏观错误码 系统执行出错 ==========
SERVICE_ERROR("B000001", "系统执行出错"),
// ========== 二级宏观错误码 系统执行超时 ==========
SERVICE_TIMEOUT_ERROR("B000100", "系统执行超时"),
// ========== 一级宏观错误码 调用第三方服务出错 ==========
REMOTE_ERROR("C000001", "调用第三方服务出错");
private final String code;
private final String message;
BaseErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
@Override
public String code() {
return code;
}
@Override
public String message() {
return message;
}
}
UserErrorCodeEnum
package com.hayaizo.shortlink.admin.common.enums;
import com.hayaizo.shortlink.admin.common.convention.errorcode.IErrorCode;
public enum UserErrorCodeEnum implements IErrorCode {
USER_NULL("B00200","用户记录不存在"),
USER_EXIT("B00201","用户记录已存在");
private final String code;
private final String message;
UserErrorCodeEnum(String code, String message) {
this.code = code;
this.message = message;
}
@Override
public String code() {
return code;
}
@Override
public String message() {
return message;
}
}
全局异常拦截器
AbstractException
package com.hayaizo.shortlink.admin.common.convention.exception;
import com.hayaizo.shortlink.admin.common.convention.errorcode.IErrorCode;
import lombok.Getter;
import org.springframework.util.StringUtils;
import java.util.Optional;
/**
* 抽象项目中三类异常体系,客户端异常、服务端异常以及远程服务调用异常
*
* @see ClientException
* @see ServiceException
* @see RemoteException
*/
@Getter
public abstract class AbstractException extends RuntimeException {
public final String errorCode;
public final String errorMessage;
public AbstractException(String message, Throwable throwable, IErrorCode errorCode) {
super(message, throwable);
this.errorCode = errorCode.code();
this.errorMessage = Optional.ofNullable(StringUtils.hasLength(message) ? message : null).orElse(errorCode.message());
}
}
ClientException
package com.hayaizo.shortlink.admin.common.convention.exception;
import com.hayaizo.shortlink.admin.common.convention.errorcode.BaseErrorCode;
import com.hayaizo.shortlink.admin.common.convention.errorcode.IErrorCode;
/**
* 客户端异常
*/
public class ClientException extends AbstractException {
public ClientException(IErrorCode errorCode) {
this(null, null, errorCode);
}
public ClientException(String message) {
this(message, null, BaseErrorCode.CLIENT_ERROR);
}
public ClientException(String message, IErrorCode errorCode) {
this(message, null, errorCode);
}
public ClientException(String message, Throwable throwable, IErrorCode errorCode) {
super(message, throwable, errorCode);
}
@Override
public String toString() {
return "ClientException{" +
"code='" + errorCode + "'," +
"message='" + errorMessage + "'" +
'}';
}
}
RemoteException
package com.hayaizo.shortlink.admin.common.convention.exception;
import com.hayaizo.shortlink.admin.common.convention.errorcode.BaseErrorCode;
import com.hayaizo.shortlink.admin.common.convention.errorcode.IErrorCode;
/**
* 远程服务调用异常
*/
public class RemoteException extends AbstractException {
public RemoteException(String message) {
this(message, null, BaseErrorCode.REMOTE_ERROR);
}
public RemoteException(String message, IErrorCode errorCode) {
this(message, null, errorCode);
}
public RemoteException(String message, Throwable throwable, IErrorCode errorCode) {
super(message, throwable, errorCode);
}
@Override
public String toString() {
return "RemoteException{" +
"code='" + errorCode + "'," +
"message='" + errorMessage + "'" +
'}';
}
}
ServiceException
package com.hayaizo.shortlink.admin.common.convention.exception;
import com.hayaizo.shortlink.admin.common.convention.errorcode.BaseErrorCode;
import com.hayaizo.shortlink.admin.common.convention.errorcode.IErrorCode;
import java.util.Optional;
/**
* 服务端异常
*/
public class ServiceException extends AbstractException {
public ServiceException(String message) {
this(message, null, BaseErrorCode.SERVICE_ERROR);
}
public ServiceException(IErrorCode errorCode) {
this(null, errorCode);
}
public ServiceException(String message, IErrorCode errorCode) {
this(message, null, errorCode);
}
public ServiceException(String message, Throwable throwable, IErrorCode errorCode) {
super(Optional.ofNullable(message).orElse(errorCode.message()), throwable, errorCode);
}
@Override
public String toString() {
return "ServiceException{" +
"code='" + errorCode + "'," +
"message='" + errorMessage + "'" +
'}';
}
}
拦截器
package com.hayaizo.shortlink.admin.common.web;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.hayaizo.shortlink.admin.common.convention.Result;
import com.hayaizo.shortlink.admin.common.convention.Results;
import com.hayaizo.shortlink.admin.common.convention.errorcode.BaseErrorCode;
import com.hayaizo.shortlink.admin.common.convention.exception.AbstractException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Optional;
/**
* 全局异常处理器
*
*/
@Component
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 拦截参数验证异常
*/
@SneakyThrows
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result validExceptionHandler(HttpServletRequest request, MethodArgumentNotValidException ex) {
BindingResult bindingResult = ex.getBindingResult();
FieldError firstFieldError = CollectionUtil.getFirst(bindingResult.getFieldErrors());
String exceptionStr = Optional.ofNullable(firstFieldError)
.map(FieldError::getDefaultMessage)
.orElse(StrUtil.EMPTY);
log.error("[{}] {} [ex] {}", request.getMethod(), getUrl(request), exceptionStr);
return Results.failure(BaseErrorCode.CLIENT_ERROR.code(), exceptionStr);
}
/**
* 拦截应用内抛出的异常
*/
@ExceptionHandler(value = {AbstractException.class})
public Result abstractException(HttpServletRequest request, AbstractException ex) {
if (ex.getCause() != null) {
log.error("[{}] {} [ex] {}", request.getMethod(), request.getRequestURL().toString(), ex.toString(), ex.getCause());
return Results.failure(ex);
}
log.error("[{}] {} [ex] {}", request.getMethod(), request.getRequestURL().toString(), ex.toString());
return Results.failure(ex);
}
/**
* 拦截未捕获异常
*/
@ExceptionHandler(value = Throwable.class)
public Result defaultErrorHandler(HttpServletRequest request, Throwable throwable) {
log.error("[{}] {} ", request.getMethod(), getUrl(request), throwable);
return Results.failure();
}
private String getUrl(HttpServletRequest request) {
if (StringUtils.isEmpty(request.getQueryString())) {
return request.getRequestURL().toString();
}
return request.getRequestURL().toString() + "?" + request.getQueryString();
}
}
用户敏感信息展示
利用Jackson序列化来做数据的脱敏
package com.hayaizo.shortlink.admin.common.serialize;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
public class EmailDesensitizationSerializer extends JsonSerializer<String> {
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value != null && value.contains("@")) {
String desensitized = value.replaceAll("(\\w{2})\\w*(\\w?@.*)", "$1****$2");
gen.writeString(desensitized);
} else {
gen.writeString(value);
}
}
}
package com.hayaizo.shortlink.admin.common.serialize;
import cn.hutool.core.util.DesensitizedUtil;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
/**
* 手机号脱敏反序列化
*/
public class PhoneDesensitizationSerializer extends JsonSerializer<String> {
@Override
public void serialize(String phone, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
String phoneDesensitization = DesensitizedUtil.mobilePhone(phone);
jsonGenerator.writeString(phoneDesensitization);
}
}
在类的成员上加上注解表示需要脱敏。
package com.hayaizo.shortlink.admin.dto.resp;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.hayaizo.shortlink.admin.common.serialize.EmailDesensitizationSerializer;
import com.hayaizo.shortlink.admin.common.serialize.PhoneDesensitizationSerializer;
import lombok.Data;
import java.util.Date;
/**
* 用户返回参数响应
*/
@Data
public class UserRespDTO {
/**
* 主键id
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 真实姓名
*/
private String realName;
/**
* 手机号码
*/
@JsonSerialize(using = PhoneDesensitizationSerializer.class)
private String phone;
/**
* 邮箱
*/
@JsonSerialize(using = EmailDesensitizationSerializer.class)
private String mail;
/**
* 删除时间戳
*/
private Long deletionTime;
/**
* 创建时间
*/
private Date createTime;
/**
* 修改时间
*/
private Date updateTime;
/**
* 删除标识 0: 未删除 1: 已删除
*/
private Integer delFlag;
}
检查用户名是否存在
海量用户如果说查询的用户名存在或不存在,全部请求数据库,会将数据库直接打满。
1.用户名加缓存
redis缓存太大了。
2.使用布隆过滤器
误差是可以接受的,如果当前用户名误判了,大不了用户重新换一个名字。
容量越大,误判越低
Redisson依赖
<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参数
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: 123456
创建布隆过滤器实例
package com.hayaizo.shortlink.admin.config;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
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("xxx");
cachePenetrationBloomFilter.tryInit(10000000, 0.01);
return cachePenetrationBloomFilter;
}
}
ryInit 有两个核心参数:
expectedInsertions:预估布隆过滤器存储的元素长度。
falseProbability:运行的误判率。
错误率越低,位数组越长,布隆过滤器的内存占用越大。
错误率越低,散列 Hash 函数越多,计算耗时较长。
一个布隆过滤器占用大小的在线网站:Bloom Filter Calculator
使用布隆过滤器的两种场景:
初始使用:注册用户时就向容器中新增数据,就不需要任务向容器存储数据了。
使用过程中引入:读取数据源将目标数据刷到布隆过滤器。
自定义可拓展布隆过滤器
ScalableBloomFilter
package com.hayaizo.shortlink.admin.config;
import org.redisson.api.RAtomicLong;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import java.util.ArrayList;
import java.util.List;
public class ScalableBloomFilter {
// RedissonClient用于和Redis进行交互
private final RedissonClient redissonClient;
// 每个布隆过滤器期望插入的元素数量
private final long expectedInsertions;
// 布隆过滤器的误报率
private final double falseProbability;
// 负载因子,超过负载因子后需要创建新的布隆过滤器
private final double loadFactorThreshold;
// 存储布隆过滤器的列表
private final List<RBloomFilter<String>> bloomFilters;
// 当前存在的布隆过滤器数量
private RAtomicLong currentFilterCount;
/**
* 构造函数,用于初始化可扩展的布隆过滤器。
*
* @param redissonClient Redisson客户端,用于与Redis交互。
* @param expectedInsertions 每个布隆过滤器预期插入的元素数量。
* @param falseProbability 布隆过滤器的误报率。
* @param loadFactorThreshold 负载因子阈值,超过该阈值时创建新的布隆过滤器。
*/
public ScalableBloomFilter(RedissonClient redissonClient, long expectedInsertions, double falseProbability, double loadFactorThreshold) {
this.redissonClient = redissonClient;
this.expectedInsertions = expectedInsertions;
this.falseProbability = falseProbability;
this.loadFactorThreshold = loadFactorThreshold;
this.bloomFilters = new ArrayList<>();
// 初始化计数器,从Redis中获取已存在的过滤器数量
this.currentFilterCount = redissonClient.getAtomicLong("currentFilterCount");
// 批量加载已存在的布隆过滤器
this.bloomFilters.addAll(batchLoadBloomFilter());
}
/**
* 批量加载已有的布隆过滤器。
* 该方法用于从Redis中加载现有的布隆过滤器,以支持系统重启后的恢复。
*
* @return 已加载的布隆过滤器列表
*/
private List<RBloomFilter<String>> batchLoadBloomFilter() {
List<RBloomFilter<String>> loadedFilters = new ArrayList<>();
long filterCount = currentFilterCount.get(); // 从Redis中获取当前过滤器数量
if(filterCount==0){
loadedFilters.add(createNewBloomFilter());
}
for (int i = 0; i < filterCount; i++) {
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("bloomFilter-" + (i+1));
if (bloomFilter.isExists()) {
loadedFilters.add(bloomFilter); // 只加载已存在的布隆过滤器
}
}
return loadedFilters;
}
/**
* 创建一个新的布隆过滤器并初始化。
* 每当现有布隆过滤器满载时,创建新的过滤器。
*
* @return 创建的布隆过滤器
*/
private RBloomFilter<String> createNewBloomFilter() {
// 为新的布隆过滤器生成唯一名称,基于过滤器数量
long filterIndex = currentFilterCount.incrementAndGet();
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("bloomFilter-" + filterIndex);
bloomFilter.tryInit(expectedInsertions, falseProbability); // 初始化布隆过滤器
// 为新布隆过滤器创建单独插入计数器
redissonClient.getAtomicLong("bloomFilterInsertions-" + filterIndex).set(0);
return bloomFilter;
}
/**
* 检查指定的布隆过滤器是否已达到负载因子上限。
* 当负载因子达到设定值时,布隆过滤器将被认为满载,需要创建新的过滤器。
*
* @param bloomFilter 当前布隆过滤器
* @param filterIndex 布隆过滤器的索引
* @return 布隆过滤器是否满载
*/
private boolean isBloomFilterFull(RBloomFilter<String> bloomFilter, long filterIndex) {
// 获取当前过滤器的插入计数器
RAtomicLong insertionCount = redissonClient.getAtomicLong("bloomFilterInsertions-" + filterIndex);
double currentLoadFactor = (double) insertionCount.get() / expectedInsertions; // 计算负载因子
return currentLoadFactor >= loadFactorThreshold; // 判断是否超过阈值
}
/**
* 向布隆过滤器中添加元素。如果当前过滤器已满,则创建新的过滤器。
*
* @param value 要添加的元素
*/
public synchronized void add(String value) {
// 获取最后一个布隆过滤器
long lastIndex = currentFilterCount.get();
RBloomFilter<String> currentBloomFilter = bloomFilters.get(bloomFilters.size()-1);
// 获取当前布隆过滤器的插入计数器
RAtomicLong insertionCount = redissonClient.getAtomicLong("bloomFilterInsertions-" + lastIndex);
// 检查当前布隆过滤器是否已满载
if (isBloomFilterFull(currentBloomFilter, lastIndex)) {
// 如果已满载,则创建新的布隆过滤器并添加到列表中
currentBloomFilter = createNewBloomFilter();
bloomFilters.add(currentBloomFilter);
}
// 如果元素不存在才添加到布隆过滤器中
if (!currentBloomFilter.contains(value)) {
currentBloomFilter.add(value);
insertionCount.incrementAndGet(); // 增加插入计数
}
}
/**
* 检查布隆过滤器是否可能包含指定的元素。
*
* @param value 要检查的元素
* @return 如果元素可能存在,则返回 true;否则返回 false。
*/
public boolean mightContain(String value) {
// 遍历所有布隆过滤器
for (RBloomFilter<String> bloomFilter : bloomFilters) {
if (bloomFilter.contains(value)) {
return true; // 如果元素在某个布隆过滤器中,返回 true
}
}
return false; // 如果所有过滤器都不包含,返回 false
}
}
BloomFilterService
package com.hayaizo.shortlink.admin.config;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class BloomFilterService {
// 逻辑拓展的布隆过滤器
private final ScalableBloomFilter scalableBloomFilter;
@Autowired
private RedissonClient redissonClient;
@Autowired
public BloomFilterService(RedissonClient redissonClient) {
// 创建可扩展的布隆过滤器,设置期望插入数量、误报率和负载因子阈值
this.scalableBloomFilter = new ScalableBloomFilter(redissonClient, 1000000L, 0.01, 0.75);
}
/**
* 添加元素到布隆过滤器中。
*
* @param value 要添加的元素。
*/
public void addValue(String value) {
scalableBloomFilter.add(value);
}
/**
* 检查元素是否可能存在于布隆过滤器中。
*
* @param value 要检查的元素。
* @return 如果元素可能存在于布隆过滤器中,则返回true;否则返回false。
*/
public boolean mightContainValue(String value) {
return scalableBloomFilter.mightContain(value);
}
}
RedissonClientConfig
package com.hayaizo.shortlink.admin.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonClientConfig {
// 从 application.yml 中读取配置
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Value("${spring.data.redis.password:}") // 如果没有设置密码,则使用默认空字符串
private String redisPassword;
/**
* 创建并配置 RedissonClient 实例。
*
* @return 配置好的 RedissonClient 实例。
*/
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
String redisAddress = String.format("redis://%s:%d", redisHost, redisPort);
config.useSingleServer()
.setAddress(redisAddress);
// 仅在密码不为空时设置密码
if (redisPassword != null && !redisPassword.isEmpty()) {
config.useSingleServer().setPassword(redisPassword);
}
return Redisson.create(config);
}
}
用户注册
采用MyBatis-Plus
来进行元数据的填充。
package com.hayaizo.shortlink.admin.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
// 插入时填充策略
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", Date.class, new Date()); // 插入时填充创建时间
this.strictInsertFill(metaObject, "updateTime", Date.class, new Date()); // 插入时填充更新时间
this.strictInsertFill(metaObject,"delFlag", Integer.class, 0);
}
// 更新时填充策略
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime",Date.class, new Date()); // 更新时填充更新时间
}
}
类型写错会导致数据填充失败
package com.hayaizo.shortlink.admin.dao.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* @description 用户持久层
* @author Hayaizo
* @date 2024-09-02
*/
@Data
@TableName("t_user")
public class UserDO implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
/**
* 主键id
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 真实姓名
*/
private String realName;
/**
* 手机号
*/
private String phone;
/**
* 邮箱
*/
private String mail;
/**
* 删除时间戳
*/
private Long deletionTime;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private Date createTime;
/**
* 修改时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
/**
* 删除标识 0: 未删除 1: 已删除
*/
@TableField(fill = FieldFill.INSERT)
private Integer delFlag;
public UserDO() {}
}
如何防止恶意注册
@Override
public void registry(UserRegisterReqDTO requestParam) {
// 判断在布隆过滤器中是否存在
if(!hasUsername(requestParam.getUsername())){
throw new ClientException(UserErrorCodeEnum.USER_NAME_EXIT);
}
RLock lock = redissonClient.getLock(LOCK_USER_REGISTER_KEY + requestParam.getUsername());
try{
if(lock.tryLock()){
int insert = baseMapper.insert(BeanUtil.toBean(requestParam, UserDO.class));
if(insert < 1){
throw new ClientException(UserErrorCodeEnum.USER_SAVE_ERROR);
}
bloomFilterService.addValue(requestParam.getUsername());
return;
}
throw new ClientException(UserErrorCodeEnum.USER_EXIT);
}finally {
lock.unlock();
}
}
如何分库分表
package MySQL;
public class User {
public static String sqlTemplate = "CREATE TABLE `t_user_%d` (\n" +
" `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',\n" +
" `username` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户名',\n" +
" `password` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '密码',\n" +
" `real_name` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '真实姓名',\n" +
" `phone` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '手机号码',\n" +
" `mail` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '邮箱',\n" +
" `deletion_time` bigint DEFAULT NULL COMMENT '删除时间戳',\n" +
" `create_time` datetime DEFAULT NULL COMMENT '创建时间',\n" +
" `update_time` datetime DEFAULT NULL COMMENT '修改时间',\n" +
" `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0: 未删除 1: 已删除',\n" +
" PRIMARY KEY (`id`),\n" +
" UNIQUE KEY `unique_username` (`username`) USING BTREE\n" +
") ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;";
public static void main(String[] args) {
for (int i = 0; i < 16; i++) {
String sql = String.format(sqlTemplate, i); // 替换表名中的编号
System.out.println(sql); // 输出每个表的创建语句
}
}
}
分片键
用于将数据库(表)水平拆分的数据库字段。
分库分表中的分片键(Sharding Key)是一个关键决策,它直接影响了分库分表的性能和可扩展性。以下是一些选择分片键的关键因素:
访问频率:选择分片键应考虑数据的访问频率。将经常访问的数据放在同一个分片上,可以提高查询性能和降低跨分片查询的开销。
数据均匀性:分片键应该保证数据的均匀分布在各个分片上,避免出现热点数据集中在某个分片上的情况。
数据不可变:一旦选择了分片键,它应该是不可变的,不能随着业务的变化而频繁修改。
用户名和用户ID选哪个作为分片键?
用户名,用户名可以登录。
引入依赖
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core</artifactId>
<version>5.3.2</version>
</dependency>
分片规则
spring:
datasource:
# ShardingSphere 对 Driver 自定义,实现分库分表等隐藏逻辑
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
# ShardingSphere 配置文件路径
url: jdbc:shardingsphere:classpath:shardingsphere-config.yaml
shardingsphere-config.yaml
# 数据源集合
dataSources:
ds_0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://127.0.0.1:3306/link?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
username: root
password: root
rules:
- !SHARDING
tables:
t_user:
# 真实数据节点,比如数据库源以及数据库在数据库中真实存在的
actualDataNodes: ds_0.t_user_${0..15}
# 分表策略
tableStrategy:
# 用于单分片键的标准分片场景
standard:
# 分片键
shardingColumn: username
# 分片算法,对应 rules[0].shardingAlgorithms
shardingAlgorithmName: user_table_hash_mod
# 分片算法
shardingAlgorithms:
# 数据表分片算法
user_table_hash_mod:
# 根据分片键 Hash 分片
type: HASH_MOD
# 分片数量
props:
sharding-count: 16
# 展现逻辑 SQL & 真实 SQL
props:
sql-show: true
用户敏感信息实现加密存储
加密配置
# 配置数据源,底层被 ShardingSphere 进行了代理
dataSources:
ds_0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://127.0.0.1:3306/link?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
username: root
password: root
rules:
# 数据加密存储规则
- !ENCRYPT
# 需要加密的表集合
tables:
# 用户表
t_user:
# 用户表中哪些字段需要进行加密
columns:
# 手机号字段,逻辑字段,不一定是在数据库中真实存在
phone:
# 手机号字段存储的密文字段,这个是数据库中真实存在的字段
cipherColumn: phone
# 身份证字段加密算法
encryptorName: common_encryptor
mail:
cipherColumn: mail
encryptorName: common_encryptor
# 是否按照密文字段查询
queryWithCipherColumn: true
# 加密算法
encryptors:
# 自定义加密算法名称
common_encryptor:
# 加密算法类型
type: AES
props:
# AES 加密密钥
aes-key-value: d6oadClrrb9A3GWo
props:
sql-show: true
双Token无感刷新
当用户进行Login登陆的时候,服务器会先判断用户有没有AccessToken以及AccessToken是否已经过期了,如果存在AccessToken的话,就抛异常(用户已存在),如果不存在,就去生成AccessToken和RefreshToken,RefreshToken的有效时长会比AccessToken长的多,如果AccessToken过期了,那么就通过username去redis获取到对应的RefreshToken,如果说RefreshToken还没有过期的话,那么久可以利用RefreshToken来对token进行刷新,AccessToken和RefreshToken都需要刷新,最后把AccessToken传递给前端。
为什么需要双Token?为什么不直接设置一个很长的Token,每次登陆就刷新一次有效期呢?
一个Token用的越久,那么被盗用的风险就越大,如果使用AccessToken的话,就算被盗用了,也只能临时上线一会,马上就会被RefreshToken给刷新了
RefreshToken是否会被盗用?
不会,因为RefreshToken不会返回给用户,用户只能拿到AccessToken,RefreshToken被服务端管理。
代码:
@PostMapping("/api/short-link/v1/user/login")
public Result<UserLoginRespDTO> login(@RequestBody UserLoginReqDTO requestParam) {
UserLoginRespDTO respDTO = userService.login(requestParam);
return Results.success(respDTO);
}
@PostMapping("/api/short-link/v1/user/refresh")
public Result<UserLoginRespDTO> refresh(HttpServletRequest request) {
String accessToken = request.getHeader("token"); // 提取请求头中的AccessToken
String username = request.getHeader("username"); // 提取请求头中的username
UserLoginRespDTO respDTO = userService.refreshAccessToken(accessToken, username);
return Results.success(respDTO);
}
@Override
public UserLoginRespDTO login(UserLoginReqDTO requestParam) {
LambdaQueryWrapper<UserDO> queryWrapper = Wrappers.lambdaQuery(UserDO.class)
.eq(UserDO::getUsername, requestParam.getUsername())
.eq(UserDO::getPassword, requestParam.getPassword())
.eq(UserDO::getDelFlag, 0);
UserDO userDO = baseMapper.selectOne(queryWrapper);
if(userDO==null){
throw new ClientException("用户登陆失败");
}
// 用户验证逻辑 (例如查询数据库验证用户名和密码)
String username = requestParam.getUsername();
String storeAccessToken = redisTokenStore.getAccessToken(username);
if(storeAccessToken!=null&&!jwtTokenUtil.isTokenExpired(storeAccessToken)){
throw new ClientException("用户已登陆");
}
// 生成访问Token和刷新Token
String accessToken = jwtTokenUtil.generateAccessToken(username);
String refreshToken = jwtTokenUtil.generateRefreshToken(username);
// 将Token存入Redis
redisTokenStore.storeAccessToken(username, accessToken);
redisTokenStore.storeRefreshToken(username, refreshToken);
UserLoginRespDTO response = new UserLoginRespDTO();
response.setToken(accessToken);
return response;
}
@Override
public UserLoginRespDTO refreshAccessToken(String accessToken, String username) {
String storedRefreshToken = redisTokenStore.getRefreshToken(username);
String storeAccessToken = redisTokenStore.getAccessToken(username);
// 首先判断传入的 AccessToken 是否过期
if (jwtTokenUtil.isTokenExpired(accessToken)||storeAccessToken==null) {
// AccessToken 已过期,检查 refreshToken 是否有效
if (storedRefreshToken != null && !jwtTokenUtil.isTokenExpired(storedRefreshToken)) {
// refreshToken 有效,刷新 AccessToken 和 RefreshToken
String newAccessToken = jwtTokenUtil.generateAccessToken(username);
String newRefreshToken = jwtTokenUtil.generateRefreshToken(username);
redisTokenStore.storeAccessToken(username, newAccessToken); // 更新 Redis 中的 accessToken
redisTokenStore.storeRefreshToken(username, newRefreshToken); // 更新 Redis 中的 refreshToken
UserLoginRespDTO response = new UserLoginRespDTO();
response.setToken(newAccessToken);
return response;
} else {
// refreshToken 也无效或已过期,抛出异常
throw new RuntimeException("Invalid or expired refresh token");
}
} else {
// AccessToken 未过期,返回当前的 AccessToken
UserLoginRespDTO response = new UserLoginRespDTO();
response.setToken(accessToken);
return response;
}
}
实现类
@Override
public UserLoginRespDTO login(UserLoginReqDTO requestParam) {
LambdaQueryWrapper<UserDO> queryWrapper = Wrappers.lambdaQuery(UserDO.class)
.eq(UserDO::getUsername, requestParam.getUsername())
.eq(UserDO::getPassword, requestParam.getPassword())
.eq(UserDO::getDelFlag, 0);
UserDO userDO = baseMapper.selectOne(queryWrapper);
if(userDO==null){
throw new ClientException("用户登陆失败");
}
// 用户验证逻辑 (例如查询数据库验证用户名和密码)
String username = requestParam.getUsername();
String storeAccessToken = redisTokenStore.getAccessToken(username);
if(storeAccessToken!=null&&!jwtTokenUtil.isTokenExpired(storeAccessToken)){
throw new ClientException("用户已登陆");
}
// 生成访问Token和刷新Token
String accessToken = jwtTokenUtil.generateAccessToken(username);
String refreshToken = jwtTokenUtil.generateRefreshToken(username);
// 将Token存入Redis
redisTokenStore.storeAccessToken(username, accessToken);
redisTokenStore.storeRefreshToken(username, refreshToken);
UserLoginRespDTO response = new UserLoginRespDTO();
response.setToken(accessToken);
return response;
}
@Override
public UserLoginRespDTO refreshAccessToken(String accessToken, String username) {
String storedRefreshToken = redisTokenStore.getRefreshToken(username);
String storeAccessToken = redisTokenStore.getAccessToken(username);
// 首先判断传入的 AccessToken 是否过期
if (jwtTokenUtil.isTokenExpired(accessToken)||storeAccessToken==null) {
// AccessToken 已过期,检查 refreshToken 是否有效
if (storedRefreshToken != null && !jwtTokenUtil.isTokenExpired(storedRefreshToken)) {
// refreshToken 有效,刷新 AccessToken 和 RefreshToken
String newAccessToken = jwtTokenUtil.generateAccessToken(username);
String newRefreshToken = jwtTokenUtil.generateRefreshToken(username);
redisTokenStore.storeAccessToken(username, newAccessToken); // 更新 Redis 中的 accessToken
redisTokenStore.storeRefreshToken(username, newRefreshToken); // 更新 Redis 中的 refreshToken
UserLoginRespDTO response = new UserLoginRespDTO();
response.setToken(newAccessToken);
return response;
} else {
// refreshToken 也无效或已过期,抛出异常
throw new RuntimeException("Invalid or expired refresh token");
}
} else {
// AccessToken 未过期,返回当前的 AccessToken
UserLoginRespDTO response = new UserLoginRespDTO();
response.setToken(accessToken);
return response;
}
}
JWTUtils
package com.hayaizo.shortlink.admin.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Component;
import java.util.Base64;
import java.util.Date;
@Component
public class JwtTokenUtil {
private final String SECRET_KEY = "gd9mxJUKoTnkcYdiLl9kqjR3XFIvRf4IymaCju0b1PQ="; // Base64 编码的密钥
// 将 Base64 编码的密钥解码为字节数组
private byte[] getSecretKeyBytes() {
return Base64.getDecoder().decode(SECRET_KEY);
}
// 生成访问Token
public String generateAccessToken(String username) {
return createToken(username, 1000 * 60 * 15); // 15分钟过期
}
// 生成刷新Token
public String generateRefreshToken(String username) {
return createToken(username, 1000 * 60 * 60 * 24); // 1天过期
}
// 验证Token
public Boolean validateToken(String token, String username) {
final String tokenUsername = extractUsername(token);
return (tokenUsername.equals(username) && !isTokenExpired(token));
}
// 从Token中提取用户名
public String extractUsername(String token) {
return extractAllClaims(token).getSubject();
}
// 检查Token是否过期
public Boolean isTokenExpired(String token) {
try{
return extractExpiration(token).before(new Date());
}catch (ExpiredJwtException e){
return true;
}
}
// 提取Token中的所有声明
private Claims extractAllClaims(String token) {
return Jwts.parser()
.setSigningKey(getSecretKeyBytes()) // 使用解码后的字节数组
.parseClaimsJws(token)
.getBody();
}
// 提取Token中的过期时间
private Date extractExpiration(String token) {
return extractAllClaims(token).getExpiration();
}
// 创建Token
private String createToken(String subject, long expiration) {
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(SignatureAlgorithm.HS256, getSecretKeyBytes()) // 使用解码后的字节数组进行签名
.compact();
}
}
RedisTokenStore
package com.hayaizo.shortlink.admin.util;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class RedisTokenStore {
@Autowired
private StringRedisTemplate redisTemplate;
// 存储访问Token
public void storeAccessToken(String username, String accessToken) {
redisTemplate.opsForValue().set("access_token:" + username, accessToken, 15, TimeUnit.MINUTES);
}
// 存储刷新Token
public void storeRefreshToken(String username, String refreshToken) {
redisTemplate.opsForValue().set("refresh_token:" + username, refreshToken, 1, TimeUnit.DAYS);
}
// 获取访问Token
public String getAccessToken(String username) {
return redisTemplate.opsForValue().get("access_token:" + username);
}
// 获取刷新Token
public String getRefreshToken(String username) {
return redisTemplate.opsForValue().get("refresh_token:" + username);
}
// 删除Token
public void deleteTokens(String username) {
redisTemplate.delete("access_token:" + username);
redisTemplate.delete("refresh_token:" + username);
}
}
用户是否登陆
判断redis中的access_token或access_token是否过期
@Override
public boolean checkLogin(String username, String token) {
String access_token = stringRedisTemplate.opsForValue().get("access_token:" + username);
if(access_token!=null||!jwtTokenUtil.isTokenExpired(access_token)){
return true;
}
return false;
}
用户登出功能
@Override
public void logout(String username, String token) {
// 首先判断是否是登陆状态
if(checkLogin(username,token)){
// 删除token
redisTokenStore.deleteTokens(username);
return;
}
throw new ClientException("用户未登陆");
}