mallchat

102 阅读13分钟

前后端交互协议

  • JSON
  • {type: Integer , data: T }

线程

统一线程池配置

  1. 使用ThreadPoolTaskExecutor,是spring的线程池,可以优雅的调用jvm的shutdownhook,但需要把 setWaitForTasksToCompleteOnShutdown设置为true
  2. 默认的子线程是不打印异常日志的,且默认的thread无法setUncaughtExceptionHandler(),此处可以使用装饰器模式,自己实现一个class MyThreadFactory implements Thread Factory,再把ThreadFactory用组合的方式使其成为该类的成员变量,再设置thread.setUncaughtExceptionHandler(public class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler),最后在线程池配置的地方设置executor.setThreadFactory(new MyThreadFactory(executor))

登陆模块

Netty WebSocket

  1. IP处理器
  2. 握手认证处理器(把token认证机制放在websocket握手阶段,合二为一,优化了流程)
  3. 自定义的业务处理器

微信公众号OAuth2

  1. 接入微信的SDK使用
  2. WebSocket Channel与用户之间的关联字段存储器
      1. 用户在线列表:ONLINE_WS_MAP = ConcurrentHashMap<Channel, Uid>
      1. 等待登陆列表:WAIT_LOGIN_MAP = Caffeine.Cache<Code, Channel>
      1. 等待授权列表:WAIT_AUTHORIZE_MAP = ConcurrentHashMap<OpenId, Code>
  3. OAuth2流程
    • 客户端发起ws协议请求建立WebSocket连接,服务端在1中保存<Channel, Uid>(此时的UID为null)
    • 客户端发送{type:1},在服务端的NettyHandler事件解析器中解析为登陆,生成随机Code,并在2中保存<Code,Channel>,随后服务端携带Code向微信公众号请求访问链接,并返回给客户端展示为二维码
    • 用户扫码,此时服务端的微信Lisetner监听到订阅/扫码事件,并在监听返回的报文中获取到用户的OpenIdCode,随后根据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信息

  1. 在ws协议请求下,拼接token参数,然后在HTTP握手阶段,读取token参数的值,对token进行认证,如果认证失败则直接返回401,反之成功。注:在token校验后,需要把url的query部分去除,因为ws协议本身不支持query部分,不去掉的话无法进行后续的ws协议认证
  2. 每次用完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/**");
    }
}

线程池统一管理 + 线程异常打印

  1. 这里使用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定位获取

  1. 需要实现 @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());
            });
        }
    }
}

黑名单

  1. 持久化保存黑名单(IP、UID),在拦截器判断黑名单列表,如果无权返回401
  2. 黑名单可以用缓存来存储,当有新的黑名单,则清空当前缓存
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() {
    }
}

全局异常捕获 + 断言工具类

  1. 使用断言抛出异常,再由全局异常处理器拦截,这样可以免去代码中冗余的异常处理
  2. 可以自定义返回后台的异常,不会直接把异常内容暴露给前端
/**
 * 全局异常配置
 */
@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实现分布式锁(编程式)

  1. 配置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);

    }
}
  1. 原理是 在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;
    }

}
  1. 编程式的使用方法
// 加锁发放物品
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表达式

  1. 注解式 是需要配合 编程式 一起使用的,因为注解只是简化了代码,把参数提取了出来,具体的锁实现还是需要编程来解决
  2. 首先,创建一个注解 @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);
    }
}
  1. 使用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);
    }


}
  1. 在需要分布式锁的地方加上@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);
    }
  1. 两个注意事项
  2. 在同一个类中调用 注解方法,是无法触发注解的,需要循环注入该类,或者 使用当前类的代理来触发注解方法
  3. 如果使用当前类的代理来触发,在springboot的启动类中需要配置@EnableAspectJAutoProxy(exposeProxy = true) // 使用AopContext.currentProxy 必须开启

spring event 观察者模式

  1. 写一个事件类

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;
    }
}
  1. 写一个监听类
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());

    }
}

适配器模式

  1. 简单的例子,用适配器模式封装返回给前端的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;
    }
}

幂等设计

  1. 在数据库中设置一个 unique字段,在插入时上锁
  2. 如果该字段已经存在,则直接返回成功的业务结果

spring整合cacheable

  1. 继承 CachiConfigurerSupport, 重写CacheManager,这里使用caffeine
  2. 配合@Cacheable注解使用
  3. 原理:缓存中不存在就去数据库查,后续直接从缓存中取

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) {

    }