近况

这周主要在看若依的微服务后台管理系统,看了别人的项目,感觉自己很多地方不太懂,
多线程方面知识严重欠缺,某些技术学的不扎实,包括一些代码设计思路吧,总之,我太菜了
12月份的主要目标,学习若依微服务系统,可以让我学习很多东西,B站上也有相关视频,
12月份看完项目代码+视频,能完成后端部分就已经很满足了。

进入正题,来看看本篇博客具体内容

系统日志

若依项目中通过注解的方式来做日志,并将日志持久化到数据库中,方便后面展示到页面中

若依系统中把日志分为操作日志和登录日志,来看看具体是怎么实现的吧

操作日志

  • 基本执行流程

    操作日志指的是当请求调用某个方法的时候,我们记录调用方法时的形参,返回值,用户数据信息
    所以操作日志实现是通过配置自定义注解,标注在方法上,然后通过AOP拦截请求,从而获取到请求的一些信息,并持久化数据库中。
    日志注解及AOP切面都在ruoyi-common-log模块中,在该模块的AsyncLogService类中,
    使用ruoyi-api-system模块中的RemoteLogService接口,feign调用ruoyi-modules-system模块的controller
    如果是登录日志,则调用该模块的SysLogininforController,如果是操作日志,调用的是SysOperlogController
    然后执行之后的service,dao层进行持久化,持久化到数据库便可从中获取并前端展示出来。

    当然,我们看的是具体实现,而不是大致流程,操作日志配置都放在ruoyi-common-log模块中

  • 自定义Log注解

    /**
     * 自定义操作日志记录注解
     */
    @Target({ ElementType.PARAMETER, ElementType.METHOD })
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface Log
    {
        /**
         * 模块
         */
        public String title() default "";
    
        /**
         * 功能
         */
        public BusinessType businessType() default BusinessType.OTHER;
    
        /**
         * 操作人类别
         */
        public OperatorType operatorType() default OperatorType.MANAGE;
    
        /**
         * 是否保存请求的参数
         */
        public boolean isSaveRequestData() default true;
    
        /**
         * 是否保存响应的参数
         */
        public boolean isSaveResponseData() default true;
    }
  • 自定义AOP切面,LogAspect

    package com.ruoyi.common.log.aspect;
    
    /**
     * 操作日志记录处理
     */
    @Aspect
    @Component
    public class LogAspect
    {
        private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
        
        @Autowired
        private AsyncLogService asyncLogService;
    
        /**
         * 处理完请求后执行
         *
         * @param joinPoint 切点
         */
        @AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
        public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult){
            handleLog(joinPoint, controllerLog, null, jsonResult);
        }
    
        /**
         * 拦截异常操作
         * 
         * @param joinPoint 切点
         * @param e 异常
         */
        @AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
        public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e){
            handleLog(joinPoint, controllerLog, e, null);
        }
    
        protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult){
            try{
                // *========数据库日志=========*//
                SysOperLog operLog = new SysOperLog();  //数据库表的实体类
                operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
                // 请求的地址
                String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
                operLog.setOperIp(ip);
                operLog.setOperUrl(ServletUtils.getRequest().getRequestURI());
                String username = SecurityUtils.getUsername();
                if (StringUtils.isNotBlank(username)){
                    operLog.setOperName(username);
                }
    
                if (e != null){
                    operLog.setStatus(BusinessStatus.FAIL.ordinal());
                    operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
                }
                // 设置方法名称
                String className = joinPoint.getTarget().getClass().getName();
                String methodName = joinPoint.getSignature().getName();
                operLog.setMethod(className + "." + methodName + "()");
                // 设置请求方式
                operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
                // 处理设置注解上的参数
                getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
                // 保存数据库
                asyncLogService.saveSysLog(operLog);
            }
            catch (Exception exp){
                // 记录本地异常日志
                log.error("==前置通知异常==");
                log.error("异常信息:{}", exp.getMessage());
                exp.printStackTrace();
            }
        }
    
        /**
         * 获取注解中对方法的描述信息 用于Controller层注解
         * 
         * @param log 日志
         * @param operLog 操作日志
         * @throws Exception
         */
        public void getControllerMethodDescription(JoinPoint joinPoint, Log log, 
            SysOperLog operLog, Object jsonResult) throws Exception{
            // 设置action动作
            operLog.setBusinessType(log.businessType().ordinal());
            // 设置标题
            operLog.setTitle(log.title());
            // 设置操作人类别
            operLog.setOperatorType(log.operatorType().ordinal());
            // 是否需要保存request,参数和值
            if (log.isSaveRequestData()){
                // 获取参数的信息,传入到数据库中。
                setRequestValue(joinPoint, operLog);
            }
            // 是否需要保存response,参数和值
            if (log.isSaveResponseData() && StringUtils.isNotNull(jsonResult)){
                operLog.setJsonResult(StringUtils.substring(JSON.toJSONString(jsonResult), 0, 2000));
            }
        }
    
        /**
         * 获取请求的参数,放到log中
         * 
         * @param operLog 操作日志
         * @throws Exception 异常
         */
        private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog) throws Exception{
            String requestMethod = operLog.getRequestMethod();
            if (HttpMethod.PUT.name().equals(requestMethod) || 
                  HttpMethod.POST.name().equals(requestMethod)){
                String params = argsArrayToString(joinPoint.getArgs());
                operLog.setOperParam(StringUtils.substring(params, 0, 2000));
            }
        }
    
        /**
         * 参数拼装
         */
        private String argsArrayToString(Object[] paramsArray){
            String params = "";
            if (paramsArray != null && paramsArray.length > 0){
                for (Object o : paramsArray){
                    if (StringUtils.isNotNull(o) && !isFilterObject(o)){
                        try{
                            Object jsonObj = JSON.toJSON(o);
                            params += jsonObj.toString() + " ";
                        }
                        catch (Exception e){
                        }
                    }
                }
            }
            return params.trim();
        }
    
        /**
         * 判断是否需要过滤的对象。
         * 
         * @param o 对象信息。
         * @return 如果是需要过滤的对象,则返回true;否则返回false。
         */
        @SuppressWarnings("rawtypes")
        public boolean isFilterObject(final Object o){
            Class<?> clazz = o.getClass();
            if (clazz.isArray()){
                return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
            }
            else if (Collection.class.isAssignableFrom(clazz)){
                Collection collection = (Collection) o;
                for (Object value : collection){
                    return value instanceof MultipartFile;
                }
            }
            else if (Map.class.isAssignableFrom(clazz)){
                Map map = (Map) o;
                for (Object value : map.entrySet()){
                    Map.Entry entry = (Map.Entry) value;
                    return entry.getValue() instanceof MultipartFile;
                }
            }
            return o instanceof MultipartFile || o instanceof HttpServletRequest || 
               o instanceof HttpServletResponse  || o instanceof BindingResult;
        }
    }

    该类中需要关注以下几点

    • 切入点的annotation注解

      @AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
      public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult){
        handleLog(joinPoint, controllerLog, null, jsonResult);
      }

      @annotation是匹配拥有指定注解的方法的,也就是说只要标注了该注解的方法,都能匹配到。
      上述:pointcut = "@annotation(controllerLog)",表示将匹配的方法上的注解赋值给参数controllerLog
      这样我们就拿到了,自定义注解上的一些属性值,例如Log注解上有title,businessType等属性值。

    • 获取当前请求及IP地址

      IpUtils.getIpAddr(ServletUtils.getRequest());

      如何在任何地方都能拿到当前请求中的一些信息,例如请求类型,请求路径,请求ip地址?
      通过RequestContextHolder就可以完成,上面工具类底层就是使用了该类,工具类具体内容已放在博客最后面。

    • 枚举类的ordinal方法

      operLog.setBusinessType(log.businessType().ordinal());

      该方法用于获取枚举属性的索引值,索引从0开始。

    • 字节码对象的getComponentType方法,isAssignableFrom方法的使用

      clazz.getComponentType().isAssignableFrom(MultipartFile.class);
      Map.class.isAssignableFrom(clazz)

      getComponentType方法用来获取数组中元素的Class对象,如果不是Class对象那么返回null

      String [] arr = new String[10];
      String str = "";
      System.out.println(arr.getClass().getComponentType()); // String类
      System.out.println(str.getClass().getComponentType()); // 得到null值,因为str不是数组

      isAssignableFrom方法

      isAssignableFrom()方法与instanceof关键字的区别总结为以下两个点:
        isAssignableFrom()方法是从类继承的角度去判断,instanceof关键字是从实例继承的角度去判断。
        isAssignableFrom()方法是判断是否为某个类的父类,instanceof关键字是判断是否某个类的子类。
      
      使用方式:
        父类.class.isAssignableFrom(子类.class)
        子类实例 instanceof 父类类型
      isAssignableFrom()方法的调用者和参数都是Class对象,调用者为父类,参数为本身或者其子类。
      instanceof关键字两个参数,前一个为类的实例,后一个为其本身或者父类的类型。
  • 三个表示状态的枚举类

    package com.ruoyi.common.log.enums;
    /**
     * 操作状态
     */
    public enum BusinessStatus
    {
        //成功
        SUCCESS,
    
        //失败
        FAIL,
    }
    //**************************************************************
    package com.ruoyi.common.log.enums;
    /**
     * 业务操作类型
     */
    public enum BusinessType
    {
        //其它
        OTHER,
    
        //新增
        INSERT,
    
        //修改
        UPDATE,
    
        //删除
        DELETE,
    
        //授权
        GRANT,
    
        //导出
        EXPORT,
    
        //导入
        IMPORT,
    
        //强退
        FORCE,
    
        //生成代码
        GENCODE,
    
        //清空数据
        CLEAN,
    }
    //**************************************************************
    package com.ruoyi.common.log.enums;
    /**
     * 操作人类别
     */
    public enum OperatorType
    {
        //其它
        OTHER,
    
        //后台用户
        MANAGE,
    
        //移动用户
        MOBILE
    }
  • 持久化的Service,AsyncLogService

    /**
     * 异步调用日志服务
     */
    @Service
    public class AsyncLogService
    {
        @Autowired
        private RemoteLogService remoteLogService;
    
        /**
         * 保存系统日志记录
         */
        @Async
        public void saveSysLog(SysOperLog sysOperLog)
        {
            remoteLogService.saveLog(sysOperLog, SecurityConstants.INNER);
        }
    }

    以上便是ruoyi-common-log模块的主要内容,AsyncLogService类中使用了ruoyi-api-system模块中的
    RemoteLogService接口远程调用ruoyi-modules-system模块的SysOperlogController,
    后续便是进入service,进入dao层使用mybatis持久化。

  • 操作日志表结构

    CREATE TABLE `sys_oper_log` (
      `oper_id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志主键',
      `title` varchar(50) DEFAULT '' COMMENT '模块标题',
      `business_type` int DEFAULT '0' COMMENT '业务类型(0其它 1新增 2修改 3删除)',
      `method` varchar(100) DEFAULT '' COMMENT '方法名称',
      `request_method` varchar(10) DEFAULT '' COMMENT '请求方式',
      `operator_type` int DEFAULT '0' COMMENT '操作类别(0其它 1后台用户 2手机端用户)',
      `oper_name` varchar(50) DEFAULT '' COMMENT '操作人员',
      `dept_name` varchar(50) DEFAULT '' COMMENT '部门名称',
      `oper_url` varchar(255) DEFAULT '' COMMENT '请求URL',
      `oper_ip` varchar(128) DEFAULT '' COMMENT '主机地址',
      `oper_location` varchar(255) DEFAULT '' COMMENT '操作地点',
      `oper_param` varchar(2000) DEFAULT '' COMMENT '请求参数',
      `json_result` varchar(2000) DEFAULT '' COMMENT '返回参数',
      `status` int DEFAULT '0' COMMENT '操作状态(0正常 1异常)',
      `error_msg` varchar(2000) DEFAULT '' COMMENT '错误消息',
      `oper_time` datetime DEFAULT NULL COMMENT '操作时间',
      PRIMARY KEY (`oper_id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=104 DEFAULT CHARSET=utf8 COMMENT='操作日志记录'
  • 操作日志表结构对应的实体类

    package com.ruoyi.system.api.domain;
    
    /**
     * 操作日志记录表 oper_log
     */
    public class SysOperLog extends BaseEntity
    {
        private static final long serialVersionUID = 1L;
    
        /** 日志主键 */
        @Excel(name = "操作序号", cellType = ColumnType.NUMERIC)
        private Long operId;
    
        /** 操作模块 */
        @Excel(name = "操作模块")
        private String title;
    
        /** 业务类型(0其它 1新增 2修改 3删除) */
        @Excel(name = "业务类型", readConverterExp = "0=其它,1=新增,2=修改,3=删除,4=授权,
              5=导出,6=导入,7=强退,8=生成代码,9=清空数据")
        private Integer businessType;
    
        /** 业务类型数组 */
        private Integer[] businessTypes;
    
        /** 请求方法 */
        @Excel(name = "请求方法")
        private String method;
    
        /** 请求方式 */
        @Excel(name = "请求方式")
        private String requestMethod;
    
        /** 操作类别(0其它 1后台用户 2手机端用户) */
        @Excel(name = "操作类别", readConverterExp = "0=其它,1=后台用户,2=手机端用户")
        private Integer operatorType;
    
        /** 操作人员 */
        @Excel(name = "操作人员")
        private String operName;
    
        /** 部门名称 */
        @Excel(name = "部门名称")
        private String deptName;
    
        /** 请求url */
        @Excel(name = "请求地址")
        private String operUrl;
    
        /** 操作地址 */
        @Excel(name = "操作地址")
        private String operIp;
    
        /** 请求参数 */
        @Excel(name = "请求参数")
        private String operParam;
    
        /** 返回参数 */
        @Excel(name = "返回参数")
        private String jsonResult;
    
        /** 操作状态(0正常 1异常) */
        @Excel(name = "状态", readConverterExp = "0=正常,1=异常")
        private Integer status;
    
        /** 错误消息 */
        @Excel(name = "错误消息")
        private String errorMsg;
    
        /** 操作时间 */
        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        @Excel(name = "操作时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
        private Date operTime;
        
        //已省略getter,setter,@Excel注解是自定义注解,用来做导入导出功能的。
    }

    以上便是若依微服务操作日志功能的具体实现,只要将@Log注解标注在方法上,便可以收集一些数据。

登录日志

  • 基本执行流程

    请求最先来到网关模块,之后被转发到ruoyi-auth认证模块,在该模块的SysLoginService类
    中有一个recordLogininfor方法,该方法专门做登录日志的持久化操作,即将登录日志保存在数据库中
    该方法内仍然是使用ruoyi-api-system模块中的RemoteLogService接口,调用ruoyi-modules-system模块的controller,并执行后续操作。
  • ruoyi-auth认证模块的SysLoginService类

    package com.ruoyi.auth.service;
    
    /**
     * 登录校验方法
     */
    @Component
    public class SysLoginService
    {
        //ruoyi-api-system模块中的feign接口
        @Autowired
        private RemoteLogService remoteLogService;
    
        @Autowired
        private RemoteUserService remoteUserService;
    
        /**
         * 登录
         */
        public LoginUser login(String username, String password)
        {
            // 用户名或密码为空 错误
            if (StringUtils.isAnyBlank(username, password))
            {
                recordLogininfor(username, Constants.LOGIN_FAIL, "用户/密码必须填写");
                throw new ServiceException("用户/密码必须填写");
            }
            // 密码如果不在指定范围内 错误
            if (password.length() < UserConstants.PASSWORD_MIN_LENGTH
                    || password.length() > UserConstants.PASSWORD_MAX_LENGTH)
            {
                recordLogininfor(username, Constants.LOGIN_FAIL, "用户密码不在指定范围");
                throw new ServiceException("用户密码不在指定范围");
            }
            // 用户名不在指定范围内 错误
            if (username.length() < UserConstants.USERNAME_MIN_LENGTH
                    || username.length() > UserConstants.USERNAME_MAX_LENGTH)
            {
                recordLogininfor(username, Constants.LOGIN_FAIL, "用户名不在指定范围");
                throw new ServiceException("用户名不在指定范围");
            }
            // 查询用户信息
            R<LoginUser> userResult = remoteUserService.getUserInfo(username, SecurityConstants.INNER);
    
            if (R.FAIL == userResult.getCode())
            {
                throw new ServiceException(userResult.getMsg());
            }
    
            if (StringUtils.isNull(userResult) || StringUtils.isNull(userResult.getData()))
            {
                recordLogininfor(username, Constants.LOGIN_FAIL, "登录用户不存在");
                throw new ServiceException("登录用户:" + username + " 不存在");
            }
            LoginUser userInfo = userResult.getData();
            SysUser user = userResult.getData().getSysUser();
            if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
            {
                recordLogininfor(username, Constants.LOGIN_FAIL, "对不起,您的账号已被删除");
                throw new ServiceException("对不起,您的账号:" + username + " 已被删除");
            }
            if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
            {
                recordLogininfor(username, Constants.LOGIN_FAIL, "用户已停用,请联系管理员");
                throw new ServiceException("对不起,您的账号:" + username + " 已停用");
            }
            if (!SecurityUtils.matchesPassword(password, user.getPassword()))
            {
                recordLogininfor(username, Constants.LOGIN_FAIL, "用户密码错误");
                throw new ServiceException("用户不存在/密码错误");
            }
            recordLogininfor(username, Constants.LOGIN_SUCCESS, "登录成功");
            return userInfo;
        }
    
        public void logout(String loginName)
        {
            recordLogininfor(loginName, Constants.LOGOUT, "退出成功");
        }
    
        /**
         * 注册
         */
        public void register(String username, String password)
        {
            // 用户名或密码为空 错误
            if (StringUtils.isAnyBlank(username, password))
            {
                throw new ServiceException("用户/密码必须填写");
            }
            if (username.length() < UserConstants.USERNAME_MIN_LENGTH
                    || username.length() > UserConstants.USERNAME_MAX_LENGTH)
            {
                throw new ServiceException("账户长度必须在2到20个字符之间");
            }
            if (password.length() < UserConstants.PASSWORD_MIN_LENGTH
                    || password.length() > UserConstants.PASSWORD_MAX_LENGTH)
            {
                throw new ServiceException("密码长度必须在5到20个字符之间");
            }
    
            // 注册用户信息
            SysUser sysUser = new SysUser();
            sysUser.setUserName(username);
            sysUser.setNickName(username);
            sysUser.setPassword(SecurityUtils.encryptPassword(password));
            R<?> registerResult = remoteUserService.registerUserInfo(sysUser, SecurityConstants.INNER);
    
            if (R.FAIL == registerResult.getCode())
            {
                throw new ServiceException(registerResult.getMsg());
            }
            recordLogininfor(username, Constants.REGISTER, "注册成功");
        }
    
        /**
         * 记录登录信息
         * 
         * @param username 用户名
         * @param status 状态
         * @param message 消息内容
         * @return
         */
        public void recordLogininfor(String username, String status, String message)
        {
            //登录日志表的实体类
            SysLogininfor logininfor = new SysLogininfor();
            logininfor.setUserName(username);
            logininfor.setIpaddr(IpUtils.getIpAddr(ServletUtils.getRequest()));
            logininfor.setMsg(message);
            // 日志状态
            if (StringUtils.equalsAny(status, Constants.LOGIN_SUCCESS, Constants.LOGOUT, Constants.REGISTER))
            {
                logininfor.setStatus("0");
            }
            else if (Constants.LOGIN_FAIL.equals(status))
            {
                logininfor.setStatus("1");
            }
            remoteLogService.saveLogininfor(logininfor, SecurityConstants.INNER);
        }
    }
  • 登录日志表结构

    CREATE TABLE `sys_logininfor` (
      `info_id` bigint NOT NULL AUTO_INCREMENT COMMENT '访问ID',
      `user_name` varchar(50) DEFAULT '' COMMENT '用户账号',
      `ipaddr` varchar(128) DEFAULT '' COMMENT '登录IP地址',
      `status` char(1) DEFAULT '0' COMMENT '登录状态(0成功 1失败)',
      `msg` varchar(255) DEFAULT '' COMMENT '提示信息',
      `access_time` datetime DEFAULT NULL COMMENT '访问时间',
      PRIMARY KEY (`info_id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=123 DEFAULT CHARSET=utf8 COMMENT='系统访问记录'
  • 登录日志表实体类,SysLogininfor

    package com.ruoyi.system.api.domain;
    
    /**
     * 系统访问记录表 sys_logininfor
     */
    public class SysLogininfor extends BaseEntity
    {
        private static final long serialVersionUID = 1L;
    
        /** ID */
        @Excel(name = "序号", cellType = ColumnType.NUMERIC)
        private Long infoId;
    
        /** 用户账号 */
        @Excel(name = "用户账号")
        private String userName;
    
        /** 状态 0成功 1失败 */
        @Excel(name = "状态", readConverterExp = "0=成功,1=失败")
        private String status;
    
        /** 地址 */
        @Excel(name = "地址")
        private String ipaddr;
    
        /** 描述 */
        @Excel(name = "描述")
        private String msg;
    
        /** 访问时间 */
        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        @Excel(name = "访问时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
        private Date accessTime;
    }

相关文章