前后端交互协议
- JSON
- {type: Integer , data: T }
线程
统一线程池配置
- 使用ThreadPoolTaskExecutor,是spring的线程池,可以优雅的调用jvm的shutdownhook,但需要把
setWaitForTasksToCompleteOnShutdown设置为true - 默认的子线程是不打印异常日志的,且默认的thread无法setUncaughtExceptionHandler(),此处可以使用装饰器模式,自己实现一个
class MyThreadFactory implements Thread Factory,再把ThreadFactory用组合的方式使其成为该类的成员变量,再设置thread.setUncaughtExceptionHandler(public class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler),最后在线程池配置的地方设置executor.setThreadFactory(new MyThreadFactory(executor))
登陆模块
Netty WebSocket
- IP处理器
- 握手认证处理器(把token认证机制放在websocket握手阶段,合二为一,优化了流程)
- 自定义的业务处理器
微信公众号OAuth2
- 接入微信的SDK使用
- WebSocket Channel与用户之间的关联字段存储器
-
- 用户在线列表:ONLINE_WS_MAP = ConcurrentHashMap<Channel, Uid>
-
- 等待登陆列表:WAIT_LOGIN_MAP = Caffeine.Cache<Code, Channel>
-
- 等待授权列表:WAIT_AUTHORIZE_MAP = ConcurrentHashMap<OpenId, Code>
-
- OAuth2流程
- 客户端发起
ws协议请求建立WebSocket连接,服务端在1中保存<Channel, Uid>(此时的UID为null) - 客户端发送
{type:1},在服务端的NettyHandler事件解析器中解析为登陆,生成随机Code,并在2中保存<Code,Channel>,随后服务端携带Code向微信公众号请求访问链接,并返回给客户端展示为二维码 - 用户扫码,此时服务端的
微信Lisetner监听到订阅/扫码事件,并在监听返回的报文中获取到用户的OpenId与Code,随后根据OpenId到数据库中查询是否存在且是否授权用户信息- 如果已存在且已授权(即保存了用户头像等信息),则直接跳转至【登陆成功】
- 反之下一步
- 通知用户正在授权,在
3中保存<OpenId, Code>,编辑微信公众号指定的回调URLhttps://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect",其中redirect_uri为用户授权成功后的服务器回调接口地址 - 用户授权成功,服务器的回调接口接收到响应,获取到access_token,随后服务器使用access_token再次调用微信获取用户信息接口,获取用户信息、OpenId,并补全数据库,同时在
3中获取code并移除<OpenId, Code>,最后进入【登陆成功】 - 【登陆成功】:使用Code在
2中获取Channel并移除<Code, Channel>,更新1中的UID,生成Token,最后通过channel返回token,通知用户登陆成功
- 客户端发起
HTTP握手与TOKEN认证的整合 + ThreadLocal保存UID、IP信息
- 在ws协议请求下,拼接token参数,然后在HTTP握手阶段,读取token参数的值,对token进行认证,如果认证失败则直接返回401,反之成功。注:在token校验后,需要把url的query部分去除,因为ws协议本身不支持query部分,不去掉的话无法进行后续的ws协议认证
- 每次用完ThreadLocal,一定要执行threadLocal.remove()
import cn.hutool.core.net.url.UrlBuilder;
import com.kieran.mallchat.common.websocket.NettyUtil;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpHeaders;
import org.apache.commons.lang3.StringUtils;
import java.net.InetSocketAddress;
import java.util.Optional;
/**
* 客户端请求是独立的,这里不存在线程安全问题,不需要设置为@sharable
*
* 这里不能使用SimpleChannelInboundHandler<FullHttpRequest>, 因为会这个实现类会自动释放资源,无法继续向后传递
*/
public class HttpRequestHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof FullHttpRequest) {
FullHttpRequest request = (FullHttpRequest) msg;
UrlBuilder urlBuilder = UrlBuilder.ofHttp(request.uri());
// 获取token参数,
// 这一步是把websocket连接和token认证,合二为一了
String token = Optional.ofNullable(urlBuilder.getQuery()).map(k->k.get("token")).map(CharSequence::toString).orElse("");
NettyUtil.setAttr(ctx.channel(), NettyUtil.TOKEN, token);
// 获取请求路径,再次设置,因为websocket协议是/结尾,没有?后续的参数
request.setUri(urlBuilder.getPath().toString());
HttpHeaders headers = request.headers();
String ip = headers.get("X-Real-IP");
if (StringUtils.isEmpty(ip)) {//如果没经过nginx,就直接获取远端地址
InetSocketAddress address = (InetSocketAddress) ctx.channel().remoteAddress();
ip = address.getAddress().getHostAddress();
}
NettyUtil.setAttr(ctx.channel(), NettyUtil.IP, ip);
ctx.pipeline().remove(this);
ctx.fireChannelRead(request);
}else
{
ctx.fireChannelRead(msg);
}
}
}
import com.kieran.mallchat.common.common.exception.HttpErrorEnum;
import com.kieran.mallchat.common.user.service.LoginService;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Objects;
import java.util.Optional;
@Component
public class TokenInterceptor implements HandlerInterceptor {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String AUTHORIZATION_SCHEMA = "Bearer ";
public static final String ATTRIBUTE_UID = "uid";
@Resource
private LoginService loginService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = getToken(request);
Long validUid = loginService.getValidUid(token);
if (Objects.nonNull(validUid)) {
request.setAttribute(ATTRIBUTE_UID, validUid);
} else {
// 未登陆,判断当前接口是否需要登陆
String[] split = request.getRequestURI().split("/");
boolean ifPublic = split.length > 3 && "public".equals(split[3]);
if (!ifPublic) {
HttpErrorEnum.ACCESS_DENIED.sendHttpErrorResp(response);
return false;
}
}
return true;
}
private String getToken(HttpServletRequest request) {
String authorization = request.getHeader(AUTHORIZATION_HEADER);
return Optional.ofNullable(authorization)
.filter(z -> z.startsWith(AUTHORIZATION_SCHEMA))
.map(z -> z.replaceFirst(AUTHORIZATION_SCHEMA, ""))
.orElse(null);
}
}
import cn.hutool.extra.servlet.ServletUtil;
import com.kieran.mallchat.common.common.domain.dto.RequestInfo;
import com.kieran.mallchat.common.common.utils.RequestHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;
@Component
public class CollectorInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
RequestInfo requestInfo = new RequestInfo();
Long uid = Optional.ofNullable(request.getAttribute(TokenInterceptor.ATTRIBUTE_UID)).map(Object::toString)
.map(Long::parseLong).orElse(null);
// 这里处理HTTP请求时,用户的IP地址,用于IP归属地查询,websocket和http都需要获取IP
String ip = ServletUtil.getClientIP(request);
requestInfo.setUid(uid);
requestInfo.setIp(ip);
RequestHolder.set(requestInfo);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
RequestHolder.remove();
}
}
import com.kieran.mallchat.common.common.interceptor.BlackInterceptor;
import com.kieran.mallchat.common.common.interceptor.CollectorInterceptor;
import com.kieran.mallchat.common.common.interceptor.TokenInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Resource
private TokenInterceptor tokenInterceptor;
@Resource
private CollectorInterceptor collectorInterceptor;
@Resource
private BlackInterceptor blackInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenInterceptor)
.addPathPatterns("/capi/**");
registry.addInterceptor(collectorInterceptor)
.addPathPatterns("/capi/**");
registry.addInterceptor(blackInterceptor)
.addPathPatterns("/capi/**");
}
}
线程池统一管理 + 线程异常打印
- 这里使用spring的线程池
ThreadPoolTaskExecutor, 因为spring支持jvm的优雅停机
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 继承 AsyncConfigurer ,可以重写@Async注解的默认调用
*/
@Configuration
@EnableAsync
public class ThreadPoolConfig implements AsyncConfigurer {
/**
* # @Async的重写方法
*/
@Override
public Executor getAsyncExecutor() {
return mallChatExecutor();
}
public static final String MALLCHAT_EXECUTOR = "mallchatExecutor";
public static final String WEBSOCKET_EXECUTOR = "webSocketExecutor";
@Primary
@Bean(MALLCHAT_EXECUTOR)
public static ThreadPoolTaskExecutor mallChatExecutor() {
// Executors使用的是无界队列,有oom的风险,禁止使用
// Executors.newScheduledThreadPool();
int coreNum = Runtime.getRuntime().availableProcessors();
// 这个是Spring的线程池,用spring的理由是包含jvm的shutdownHook,可以优雅停机
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(coreNum);
executor.setMaxPoolSize(coreNum * 2);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("mallchat-executor");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 根据源码,发现的重点配置,关注DisposableBean的destroy方法
executor.setWaitForTasksToCompleteOnShutdown(true); // 默认是false,如果不设置为true 优雅停机就无效
executor.setAwaitTerminationSeconds(30); // 线程30秒超时未关闭,则强制关闭
// 装饰器模式,给子线程加上日志打印
executor.setThreadFactory(new MyThreadFactory(executor));
executor.initialize();
return executor;
}
@Bean(WEBSOCKET_EXECUTOR)
public static ThreadPoolTaskExecutor webSocketExecutor() {
int coreNum = Runtime.getRuntime().availableProcessors();
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(coreNum);
executor.setMaxPoolSize(coreNum * 2);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("websocket-executor");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); // 完成不了就抛弃
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.setThreadFactory(new MyThreadFactory(executor));
executor.initialize();
return executor;
}
}
不使用spring线程池,使用自定义线程池 + IP定位获取
- 需要实现 @interface DisposableBean,以实现线程池的销毁
import cn.hutool.core.lang.TypeReference;
import cn.hutool.core.thread.NamedThreadFactory;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONUtil;
import com.kieran.mallchat.common.common.config.thread.MyUncaughtExceptionHandler;
import com.kieran.mallchat.common.user.dao.UserDao;
import com.kieran.mallchat.common.user.domain.dto.IpResult;
import com.kieran.mallchat.common.user.domain.entity.IpDetail;
import com.kieran.mallchat.common.user.domain.entity.IpInfo;
import com.kieran.mallchat.common.user.domain.entity.User;
import com.kieran.mallchat.common.user.service.IpService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* 实现了一个自定义的线程池,并没有交给Spring管理这个线程池,所以不会自动销毁,需要手动销毁
*/
@Slf4j
@Service
public class IpServiceImpl implements IpService, DisposableBean {
private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(500),
new NamedThreadFactory("refresh-ipDetail", null, false,
new MyUncaughtExceptionHandler()));
@Resource
private UserDao userDao;
@Override
public void refreshIpAsync(Long uid) {
EXECUTOR.execute(() -> {
User user = userDao.getById(uid);
IpInfo ipInfo = user.getIpInfo();
if (Objects.isNull(ipInfo)) {
return;
}
String ip = ipInfo.needRefreshIp();
if (StrUtil.isBlank(ip)) {
return;
}
// 异步调用淘宝的IP解析接口
IpDetail ipDetail = tryGetIpAsyncThreeTimes(user.getIpInfo().getUpdateIp());
if (Objects.nonNull(ipDetail)) {
ipInfo.refreshIpDetail(ipDetail);
User update = new User();
update.setId(uid);
update.setIpInfo(ipInfo);
userDao.updateById(update);
// userCache.userInfoChange(uid);
} else {
log.error("get ip detail fail ip:{},uid:{}", ip, uid);
}
});
}
private static IpDetail tryGetIpAsyncThreeTimes(String ip) {
String url = String.format("https://ip.taobao.com/outGetIpInfo?ip=%s&accessKey=alibaba-inc", ip);
for (int i = 0; i < 3; i++) {
String data = HttpUtil.get(url);
IpResult<IpDetail> result = JSONUtil.toBean(data, new TypeReference<IpResult<IpDetail>>() {}, false);
if (result.isSuccess() && Objects.nonNull(result.getData())) {
return result.getData();
} else {
try {
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
}
}
}
return null;
}
@Override
public void destroy() throws Exception {
EXECUTOR.shutdown();
if (EXECUTOR.awaitTermination(3000, TimeUnit.MILLISECONDS)) { // 等30秒,超时就结束
if (log.isErrorEnabled()) {
log.error("Timed out while waiting for executor [{}] to terminate", EXECUTOR);
}
}
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
final int fi = i;
EXECUTOR.execute(() -> {
IpDetail ipDetail = tryGetIpAsyncThreeTimes("163.43.245.73");
System.out.println(String.format("第%d次成功", fi));
System.err.println(ipDetail.toString());
});
}
}
}
黑名单
- 持久化保存黑名单(IP、UID),在拦截器判断黑名单列表,如果无权返回401
- 黑名单可以用缓存来存储,当有新的黑名单,则清空当前缓存
import cn.hutool.core.collection.CollectionUtil;
import com.kieran.mallchat.common.common.domain.dto.RequestInfo;
import com.kieran.mallchat.common.common.exception.HttpErrorEnum;
import com.kieran.mallchat.common.common.utils.RequestHolder;
import com.kieran.mallchat.common.user.domain.enums.BlackTypeEnum;
import com.kieran.mallchat.common.user.service.cache.UserCache;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Component
public class BlackInterceptor implements HandlerInterceptor {
@Resource
private UserCache userCache;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 判断当前uid / ip 是否在黑名单,如果在就返回401
Map<Integer, Set<String>> blackMap = userCache.getBlackMap();
RequestInfo requestInfo = RequestHolder.get();
if (inBlack(requestInfo.getUid(), blackMap.get(BlackTypeEnum.UID.getType()))
|| inBlack(requestInfo.getIp(), blackMap.get(BlackTypeEnum.IP.getType()))) {
HttpErrorEnum.ACCESS_DENIED.sendHttpErrorResp(response);
return false;
}
return true;
}
public boolean inBlack(Object target, Set<String> blackSet) {
if (Objects.isNull(target) || CollectionUtil.isEmpty(blackSet)) {
return false;
}
return blackSet.contains(target.toString());
}
}
import com.kieran.mallchat.common.user.dao.BlackDao;
import com.kieran.mallchat.common.user.dao.UserRoleDao;
import com.kieran.mallchat.common.user.domain.entity.Black;
import com.kieran.mallchat.common.user.domain.entity.UserRole;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Component
public class UserCache {
@Resource
private UserRoleDao userRoleDao;
@Resource
private BlackDao blackDao;
@Cacheable(cacheNames = "user", key = "'roleId:' + #uid")
public Set<Long> getRoleSet(Long uid) {
List<UserRole> userRoles = userRoleDao.listByUid(uid);
return userRoles.stream().map(UserRole::getRoleId).collect(Collectors.toSet());
}
@Cacheable(cacheNames = "user", key = "'blackMap'")
public Map<Integer, Set<String>> getBlackMap() {
Map<Integer, List<Black>> collect = blackDao.list().stream().collect(Collectors.groupingBy(Black::getType));
Map<Integer, Set<String>> set = new HashMap<>(collect.size());
collect.forEach((i, j) -> set.put(i, j.stream().map(Black::getTarget).collect(Collectors.toSet())));
return set;
}
@CacheEvict(cacheNames = "user", key = "'blackMap'")
public void evictBlackMap() {
}
}
全局异常捕获 + 断言工具类
- 使用断言抛出异常,再由全局异常处理器拦截,这样可以免去代码中冗余的异常处理
- 可以自定义返回后台的异常,不会直接把异常内容暴露给前端
/**
* 全局异常配置
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ApiResult<?> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
StringBuilder msg = new StringBuilder();
e.getBindingResult().getFieldErrors().forEach(z -> msg.append("[").append(z.getField()).append("]")
.append(z.getDefaultMessage()).append(","));
String errorMsg = msg.substring(0, msg.length() - 1);
log.error("validation error:{}", errorMsg);
return ApiResult.fail(ExceptionErrorNum.PARAM_VALID.getErrorCode(), errorMsg);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(value = BusinessException.class)
public ApiResult<?> businessExceptionHandler(BusinessException e) {
log.error("businessException, {}", e.getErrorMsg());
return ApiResult.fail(e.getErrorCode(), e.getErrorMsg());
}
/**
* 最后一道防线
*/
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(value = Throwable.class)
public ApiResult<?> throwableHandler(Throwable t) {
log.error(t.getMessage());
return ApiResult.fail(ExceptionErrorNum.SYSTEM_ERROR);
}
}
import cn.hutool.core.util.ObjectUtil;
import com.kieran.mallchat.common.common.exception.BusinessException;
import com.kieran.mallchat.common.common.exception.ErrorEnum;
import com.kieran.mallchat.common.common.exception.ExceptionErrorNum;
import org.hibernate.validator.HibernateValidator;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import java.text.MessageFormat;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 校验工具类
*/
public class AssertUtil {
/**
* 校验到失败就结束
*/
private static Validator failFastValidator = Validation.byProvider(HibernateValidator.class)
.configure()
.failFast(true)
.buildValidatorFactory().getValidator();
/**
* 全部校验
*/
private static Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
/**
* 注解验证参数(校验到失败就结束)
*
* @param obj
*/
public static <T> void fastFailValidate(T obj) {
Set<ConstraintViolation<T>> constraintViolations = failFastValidator.validate(obj);
if (constraintViolations.size() > 0) {
throwException(ExceptionErrorNum.PARAM_VALID, constraintViolations.iterator().next().getMessage());
}
}
/**
* 注解验证参数(全部校验,抛出异常)
*
* @param obj
*/
public static <T> void allCheckValidateThrow(T obj) {
Set<ConstraintViolation<T>> constraintViolations = validator.validate(obj);
if (constraintViolations.size() > 0) {
StringBuilder errorMsg = new StringBuilder();
Iterator<ConstraintViolation<T>> iterator = constraintViolations.iterator();
while (iterator.hasNext()) {
ConstraintViolation<T> violation = iterator.next();
//拼接异常信息
errorMsg.append(violation.getPropertyPath().toString()).append(":").append(violation.getMessage()).append(",");
}
//去掉最后一个逗号
throwException(ExceptionErrorNum.PARAM_VALID, errorMsg.toString().substring(0, errorMsg.length() - 1));
}
}
/**
* 注解验证参数(全部校验,返回异常信息集合)
*
* @param obj
*/
public static <T> Map<String, String> allCheckValidate(T obj) {
Set<ConstraintViolation<T>> constraintViolations = validator.validate(obj);
if (constraintViolations.size() > 0) {
Map<String, String> errorMessages = new HashMap<>();
Iterator<ConstraintViolation<T>> iterator = constraintViolations.iterator();
while (iterator.hasNext()) {
ConstraintViolation<T> violation = iterator.next();
errorMessages.put(violation.getPropertyPath().toString(), violation.getMessage());
}
return errorMessages;
}
return new HashMap<>();
}
public static void notZero(Integer num, String msg) {
if (num == 0) {
throwException(msg);
}
}
//如果不是true,则抛异常
public static void isTrue(boolean expression, String msg) {
if (!expression) {
throwException(msg);
}
}
public static void isTrue(boolean expression, ErrorEnum errorEnum, Object... args) {
if (!expression) {
throwException(errorEnum, args);
}
}
//如果是true,则抛异常
public static void isFalse(boolean expression, String msg) {
if (expression) {
throwException(msg);
}
}
//如果是true,则抛异常
public static void isFalse(boolean expression, ErrorEnum errorEnum, Object... args) {
if (expression) {
throwException(errorEnum, args);
}
}
//如果不是非空对象,则抛异常
public static void isNotEmpty(Object obj, String msg) {
if (isEmpty(obj)) {
throwException(msg);
}
}
//如果不是非空对象,则抛异常
public static void isNotEmpty(Object obj, ErrorEnum errorEnum, Object... args) {
if (isEmpty(obj)) {
throwException(errorEnum, args);
}
}
//如果不是非空对象,则抛异常
public static void isEmpty(Object obj, String msg) {
if (!isEmpty(obj)) {
throwException(msg);
}
}
public static void equal(Object o1, Object o2, String msg) {
if (!ObjectUtil.equal(o1, o2)) {
throwException(msg);
}
}
public static void notEqual(Object o1, Object o2, String msg) {
if (ObjectUtil.equal(o1, o2)) {
throwException(msg);
}
}
private static boolean isEmpty(Object obj) {
return ObjectUtil.isEmpty(obj);
}
private static void throwException(String msg) {
throwException(null, msg);
}
private static void throwException(ErrorEnum errorEnum, Object... arg) {
if (Objects.isNull(errorEnum)) {
errorEnum = ExceptionErrorNum.BUSINESS_ERROR;
}
throw new BusinessException(errorEnum.getErrorCode(), MessageFormat.format(errorEnum.getErrorMsg(), arg));
}
}
使用redisson实现分布式锁(编程式)
- 配置redisson
@Configuration
public class RedissonConfig {
@Resource
private RedisProperties redisProperties;
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort())
.setPassword(redisProperties.getPassword())
.setDatabase(redisProperties.getDatabase());
return Redisson.create(config);
}
}
- 原理是 在redis设置上锁key后,在锁内执行业务代码,这里可以把业务代码抽取,用Supplier类来实现
import com.kieran.mallchat.common.common.exception.BusinessException;
import com.kieran.mallchat.common.common.exception.ExceptionErrorNum;
import lombok.SneakyThrows;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Service
public class LockService {
@Resource
private RedissonClient redissonClient;
/**
* 优化1
* function 既有出参数又有入参
* supplier 只有出参没有入参
*/
@SneakyThrows
public <T> T executeWithLock(String key, long waitTime, TimeUnit timeUnit, Supplier<T> supplier) {
RLock lock = redissonClient.getLock(key);
boolean success = lock.tryLock(waitTime, timeUnit);
if (!success) {
throw new BusinessException(ExceptionErrorNum.LOCK_LIMIT);
}
try {
// 执行锁内代码逻辑mallchat
return supplier.get();
} finally {
lock.unlock();
}
}
/**
* 优化2,优化掉【等待上锁时间】
*/
public <T> T executeWithLock(String key, Supplier<T> supplier) {
return executeWithLock(key, -1, TimeUnit.MILLISECONDS, supplier);
}
/**
* 优化3,优化掉 supplier的return
*/
public <T> T executeWithLock(String key, Runnable runnable) {
return executeWithLock(key, -1, TimeUnit.MILLISECONDS, () -> {
runnable.run();
return null;
});
}
/**
* 自带的supplier不抛异常,这里需要重写一份
*/
@FunctionalInterface
public interface Supplier<T> {
/**
* Gets a result.
*
* @return a result
*/
T get() throws Throwable;
}
}
- 编程式的使用方法
// 加锁发放物品
lockService.executeWithLock(idempotent, () -> {
UserBackpack ifExist = userBackpackDao.getByIdempotent(idempotent);
if (Objects.nonNull(ifExist)) {
return true;
}
UserBackpack insert = UserBackpack.builder()
.uid(uid)
.itemId(itemId)
.status(YesOrNoEnum.NO.getStatus())
.idempotent(idempotent)
.build();
return userBackpackDao.save(insert);
});
分布式锁(注解式)+ Spring EL表达式
- 注解式 是需要配合 编程式 一起使用的,因为注解只是简化了代码,把参数提取了出来,具体的锁实现还是需要编程来解决
- 首先,创建一个注解 @RedissonLock
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RedissonLock {
/**
* key的前缀,默认取方法全限定名,除非我们在不同方法上对同一个资源做分布式锁,就自己指定
*
* @return key的前缀
*/
String prefixKey() default "";
/**
* springEl 表达式
*
* @return 表达式
*/
String key();
/**
* 等待锁的时间,默认-1,不等待直接失败,redisson默认也是-1
*
* @return 单位秒
*/
long waitTime() default -1;
/**
* 等待锁的时间单位,默认毫秒
*
* @return 单位
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
}
3.用【切面】来拦截注解被触发的时刻,这里必须要加上@Order(0) 排序,上锁必须在事务的外层
import cn.hutool.core.util.StrUtil;
import com.kieran.mallchat.common.common.annotation.RedissonLock;
import com.kieran.mallchat.common.common.service.LockService;
import com.kieran.mallchat.common.common.utils.SpElUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.lang.reflect.Method;
@Aspect
@Component
@Order(0) // 确保比事务注解先执行,分布式锁在事务外
public class RedissonLockAspect {
@Resource
private LockService lockService;
// /**
// * 第一种写法
// * @param joinPoint
// * @return
// * @throws Throwable
// */
// @Around("@annotation(com.kieran.mallchat.common.common.annotation.RedissonLock)")
// public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
// RedissonLock redissonLock = method.getAnnotation(RedissonLock.class);
// // 写法通用
// }
@Around("@annotation(redissonLock)")
public Object around(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) throws Throwable {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
String prefix = StrUtil.isBlank(redissonLock.prefixKey()) ? SpElUtils.getMethodKey(method)
: redissonLock.prefixKey();
String key = SpElUtils.parseSpEl(method, joinPoint.getArgs(), redissonLock.key());
return lockService.executeWithLock(prefix + ":" + key, redissonLock.waitTime(), redissonLock.timeUnit(),
joinPoint::proceed);
}
}
- 使用Spring EL表达式,来获取注解上的内容
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import java.lang.reflect.Method;
import java.util.Optional;
public class SpElUtils {
private static final ExpressionParser PARSER = new SpelExpressionParser();
private static final DefaultParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new DefaultParameterNameDiscoverer();
public static String getMethodKey(Method method) {
return method.getDeclaringClass() + "#" + method.getName();
}
public static String parseSpEl(Method method, Object[] args, String spEl) {
//解析参数名
String[] params = Optional.ofNullable(PARAMETER_NAME_DISCOVERER.getParameterNames(method)).orElse(new String[]{});
//el解析需要的上下文对象
//所有参数都作为原材料扔进去
EvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < params.length; i++) {
context.setVariable(params[i], args[i]);
}
Expression expression = PARSER.parseExpression(spEl);
return expression.getValue(context, String.class);
}
}
- 在需要分布式锁的地方加上@RedissonLock注解
@Override
public void acquireItem(Long uid, Long itemId, IdempotentEnum idempotentEnum, String businessId) {
String idempotent = getIdempotent(itemId, idempotentEnum, businessId);
// 这里直接调用acquireItem是无法触发注解上的内容
// 第一种:需要在本类中注入自己@Resource UserBackpackServiceImpl,但会有循环依赖的问题出行,但可以用@Lazy解决
// userBackpackServiceImpl.doAcquireItem(uid, itemId, idempotent);
// 第二种:使用AOP代理
((UserBackpackServiceImpl)AopContext.currentProxy()).doAcquireItem(uid, itemId, idempotent);
}
/**
* 发放用户物品,此处需要加锁
*
*
* @param uid 用户ID
* @param itemId 物品ID
* @param idempotent 幂等号
*/
@RedissonLock(key = "#idempotent", waitTime = 5000)
public void doAcquireItem(Long uid, Long itemId, String idempotent) {
/**
* 使用分布式锁注解,就不需要在此处加入分布式锁的编程了
*/
// // 加锁发放物品
// lockService.executeWithLock(idempotent, () -> {
// UserBackpack ifExist = userBackpackDao.getByIdempotent(idempotent);
// if (Objects.nonNull(ifExist)) {
// return true;
// }
// UserBackpack insert = UserBackpack.builder()
// .uid(uid)
// .itemId(itemId)
// .status(YesOrNoEnum.NO.getStatus())
// .idempotent(idempotent)
// .build();
// return userBackpackDao.save(insert);
// });
UserBackpack ifExist = userBackpackDao.getByIdempotent(idempotent);
if (Objects.nonNull(ifExist)) {
return;
}
UserBackpack insert = UserBackpack.builder()
.uid(uid)
.itemId(itemId)
.status(YesOrNoEnum.NO.getStatus())
.idempotent(idempotent)
.build();
userBackpackDao.save(insert);
}
- 两个注意事项
- 在同一个类中调用 注解方法,是无法触发注解的,需要循环注入该类,或者 使用当前类的代理来触发注解方法
- 如果使用当前类的代理来触发,在springboot的启动类中需要配置@EnableAspectJAutoProxy(exposeProxy = true) // 使用AopContext.currentProxy 必须开启
spring event 观察者模式
- 写一个事件类
import com.kieran.mallchat.common.user.domain.entity.User;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
@Getter
public class UserOnlineEvent extends ApplicationEvent {
private final User user;
public UserOnlineEvent(Object source, User user) {
super(source);
this.user = user;
}
}
- 写一个监听类
import com.kieran.mallchat.common.common.event.UserOnlineEvent;
import com.kieran.mallchat.common.user.dao.UserDao;
import com.kieran.mallchat.common.user.domain.entity.User;
import com.kieran.mallchat.common.user.domain.enums.ActiveStatusEnum;
import com.kieran.mallchat.common.user.service.IpService;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import javax.annotation.Resource;
@Component
public class UserOnlineListener {
@Resource
private UserDao userDao;
@Resource
private IpService ipService;
/**
* phase = TransactionPhase.AFTER_COMMIT 在事务提交后,触发
* fallbackExecution = true 如果被监听的event不执行事务,那么此处是否触发,默认不触发
*/
@Async
@TransactionalEventListener(value = UserOnlineEvent.class, phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void saveDB(UserOnlineEvent event) {
User user = event.getUser();
User update = new User();
update.setId(user.getId());
update.setIpInfo(user.getIpInfo()); // 这一步只保存了IP,并没有解析最新的IP数据
update.setLastOptTime(user.getLastOptTime());
update.setActiveStatus(ActiveStatusEnum.ONLINE.getStatus());
userDao.updateById(update);
// 用户IP解析
ipService.refreshIpAsync(user.getId());
}
}
适配器模式
- 简单的例子,用适配器模式封装返回给前端的VO
/**
* 通过多态的形式返回VO
*
* 适配器模式
*/
public class WebSocketAdapter {
/**
* 返回登陆链接
*/
public static WSBaseResp<?> buildLoginUrlResp(WxMpQrCodeTicket wxMpQrCodeTicket) {
WSBaseResp<WSLoginUrl> resp = new WSBaseResp<>();
resp.setType(WSRespTypeEnum.LOGIN_URL.getType());
resp.setData(new WSLoginUrl(wxMpQrCodeTicket.getUrl()));
return resp;
}
/**
* 扫码成功,等待授权
*/
public static WSBaseResp<?> buildScanSuccessResp() {
WSBaseResp<?> resp = new WSBaseResp<>();
resp.setType(WSRespTypeEnum.LOGIN_SCAN_SUCCESS.getType());
return resp;
}
/**
* 登陆成功
*/
public static WSBaseResp<WSLoginSuccess> buildLoginSuccessResp(User user, String token, boolean hasPower) {
WSBaseResp<WSLoginSuccess> wsBaseResp = new WSBaseResp<>();
wsBaseResp.setType(WSRespTypeEnum.LOGIN_SUCCESS.getType());
WSLoginSuccess wsLoginSuccess = WSLoginSuccess.builder()
.avatar(user.getAvatar())
.name(user.getName())
.token(token)
.uid(user.getId())
.power(hasPower ? YesOrNoEnum.YES.getStatus() : YesOrNoEnum.NO.getStatus())
.build();
wsBaseResp.setData(wsLoginSuccess);
return wsBaseResp;
}
/**
* 无效的TOKEN
*/
public static WSBaseResp<?> buildInvalidTokenResp() {
WSBaseResp<?> resp = new WSBaseResp<>();
resp.setType(WSRespTypeEnum.INVALID_TOKEN.getType());
return resp;
}
/**
* 拉黑用户
* @param uid
* @return
*/
public static WSBaseResp<Long> buildUserBlackResp(User user) {
WSBaseResp<Long> resp = new WSBaseResp<>();
resp.setType(WSRespTypeEnum.BLACK.getType());
resp.setData(user.getId());
return resp;
}
}
幂等设计
- 在数据库中设置一个 unique字段,在插入时上锁
- 如果该字段已经存在,则直接返回成功的业务结果
spring整合cacheable
- 继承 CachiConfigurerSupport, 重写CacheManager,这里使用caffeine
- 配合@Cacheable注解使用
- 原理:缓存中不存在就去数据库查,后续直接从缓存中取
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import java.util.concurrent.TimeUnit;
/**
* 自定义缓存机制 配合@Cacheable使用
*/
@EnableCaching
@Configuration
public class CacheConfig extends CachingConfigurerSupport {
@Bean("caffeineCacheManager")
@Primary
@Override
public CacheManager cacheManager() {
CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
caffeineCacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.initialCapacity(100)
.maximumSize(200));
return caffeineCacheManager;
}
}
import com.kieran.mallchat.common.user.dao.ItemConfigDao;
import com.kieran.mallchat.common.user.domain.entity.ItemConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
@Component
public class ItemCache {
@Resource
private ItemConfigDao itemConfigDao;
@Cacheable(cacheNames = "item", key = "'itemById:' + #itemId" )
public ItemConfig getById(Long itemId) {
return itemConfigDao.getById(itemId);
}
@Cacheable(cacheNames = "item", key = "'itemByType:' + #itemType")
public List<ItemConfig> getByType(Integer itemType) {
return itemConfigDao.getByType(itemType);
}
@CacheEvict(cacheNames = "item", key = "'itemByType:' + #itemType")
public void evictType(Integer itemType) {
}