• 日志审计设计-结合spring-aop实现


    日志审计设计

    设计原则和思路:

    元注解方式结合AOP,灵活记录操作日志
    能够记录详细错误日志为运营以及审计提供支持
    日志记录尽可能减少性能影响
    操作描述参数支持动态获取,其他参数自动记录。
    1.定义日志记录元注解,

    根据业务情况,要求description支持动态入参。例:新增应用{applicationName},其中applicationName是请求参数名。

    /**
     * 自定义注解 拦截Controller
     * 
     * @author jianggy
     *
     */
    @Target({ ElementType.PARAMETER, ElementType.METHOD })
    @Retention(RetentionPolicy.RUNTIME)
    public @interface SystemControllerLog {
    	/**
    	 * 描述业务操作 例:Xxx管理-执行Xxx操作
    	 * 支持动态入参,例:新增应用{applicationName},其中applicationName是请求参数名
    	 * @return
    	 */
    	String description() default "";
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    2.定义用于记录日志的实体类

    package com.guahao.wcp.core.dal.dataobject;
    
    import com.guahao.wcp.core.utils.StringUtils;
    import java.io.Serializable;
    import java.util.Date;
    import java.util.Map;
    
    /**
     * 日志类-记录用户操作行为
     *
     * @author lin.r.x
     */
    public class OperateLogDO extends BaseDO implements Serializable {
        private static final long serialVersionUID = -4000845735266995243L;
    
        private String userId;           //用户ID
        private String userName;         //用户名
        private String desc;            //日志描述
        private int isDeleted;           //状态标识
    
        private String menuName;         //菜单名称
        private String remoteAddr;       //请求地址
        private String requestUri;       //URI
        private String method;           //请求方式
        private String params;           //提交参数
        private String exception;        //异常信息
        private String type;             //日志类型
    
    
        public String getType() {
            return StringUtils.isBlank(type) ? type : type.trim();
        }
    
        public void setType(String type) {
            this.type = type;
        }
    
        public String getDesc() {
            return StringUtils.isBlank(desc) ? desc : desc.trim();
        }
    
        public void setDesc(String desc) {
            this.desc = desc;
        }
    
        public String getRemoteAddr() {
            return StringUtils.isBlank(remoteAddr) ? remoteAddr : remoteAddr.trim();
        }
    
        public void setRemoteAddr(String remoteAddr) {
            this.remoteAddr = remoteAddr;
        }
    
        public String getRequestUri() {
            return StringUtils.isBlank(requestUri) ? requestUri : requestUri.trim();
        }
    
        public void setRequestUri(String requestUri) {
            this.requestUri = requestUri;
        }
    
        public String getMethod() {
            return StringUtils.isBlank(method) ? method : method.trim();
        }
    
        public void setMethod(String method) {
            this.method = method;
        }
    
        public String getParams() {
            return StringUtils.isBlank(params) ? params : params.trim();
        }
    
        public void setParams(String params) {
            this.params = params;
        }
    
        /**
         * 设置请求参数
         *
         * @param paramMap
         */
        public void setMapToParams(Map paramMap) {
            if (paramMap == null) {
                return;
            }
            StringBuilder params = new StringBuilder();
            for (Map.Entry param : ((Map) paramMap).entrySet()) {
                params.append(("".equals(params.toString()) ? "" : "&") + param.getKey() + "=");
                String paramValue = (param.getValue() != null && param.getValue().length > 0 ? param.getValue()[0] : "");
                params.append(StringUtils.abbr(StringUtils.endsWithIgnoreCase(param.getKey(), "password") ? "" : paramValue, 100));
            }
            this.params = params.toString();
        }
    
        public String getException() {
            return StringUtils.isBlank(exception) ? exception : exception.trim();
        }
    
        public void setException(String exception) {
            this.exception = exception;
        }
    
        public String getUserName() {
            return StringUtils.isBlank(userName) ? userName : userName.trim();
        }
    
        public void setUserName(String userName) {
            this.userName = userName;
        }
    
        public String getUserId() {
            return userId;
        }
    
        public void setUserId(String userId) {
            this.userId = userId;
        }
    
        public String getMenuName() {
            return menuName;
        }
    
        public void setMenuName(String menuName) {
            this.menuName = menuName;
        }
    
        public int getIsDeleted() {
            return isDeleted;
        }
    
        public void setIsDeleted(int isDeleted) {
            this.isDeleted = isDeleted;
        }
    
        @Override
        public String toString() {
            return "OperateLogDO{" +
                    "userId='" + userId + '\'' +
                    ", userName='" + userName + '\'' +
                    ", desc='" + desc + '\'' +
                    ", isDeleted=" + isDeleted +
                    ", menuName='" + menuName + '\'' +
                    ", remoteAddr='" + remoteAddr + '\'' +
                    ", requestUri='" + requestUri + '\'' +
                    ", method='" + method + '\'' +
                    ", params='" + params + '\'' +
                    ", exception='" + exception + '\'' +
                    ", type='" + type + '\'' +
                    '}';
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152

    3.定义日志AOP切面类,通过logManager.insert(log)往数据库写入日志。

    项目pom.xml中增加spring-boot-starter-aop

      
                org.springframework.boot  
                spring-boot-starter-aop  
            
    
    • 1
    • 2
    • 3
    • 4

    具体的日志切点类实现

    package com.guahao.wcp.gops.home.aop;
    
    import com.greenline.guser.biz.service.dto.UserInfoDTO;
    import com.greenline.guser.client.utils.GuserCookieUtil;
    import com.guahao.wcp.gops.home.annotation.SystemControllerLog;
    import com.guahao.wcp.gops.home.service.DubboService;
    import com.guahao.wcp.core.manager.operatelog.LogManager;
    import com.guahao.wcp.core.dal.dataobject.OperateLogDO;
    import org.aspectj.lang.JoinPoint;
    import org.aspectj.lang.annotation.*;
    import org.aspectj.lang.reflect.MethodSignature;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.core.NamedThreadLocal;
    import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
    import org.springframework.stereotype.Component;
    import javax.servlet.http.HttpServletRequest;
    import java.lang.reflect.Method;
    import java.util.ArrayList;
    import java.util.List;
    import java.util.Map;
    import java.util.regex.Matcher;
    import java.util.regex.Pattern;
    
    
    /**
     * 系统日志切点类
     *
     * @author jianggy
     */
    @Aspect
    @Component
    public class SystemLogAspect {
        private static final Logger logger = LoggerFactory.getLogger(SystemLogAspect.class);
    //    private static final ThreadLocal beginTimeThreadLocal = new NamedThreadLocal("ThreadLocal beginTime");
        private static final ThreadLocal logThreadLocal = new NamedThreadLocal("ThreadLocal log");
        private static final ThreadLocal currentUserInfo = new NamedThreadLocal("ThreadLocal userInfo");
    
        @Autowired(required = false)
        private HttpServletRequest request;
        @Autowired
        private ThreadPoolTaskExecutor threadPoolTaskExecutor;
        @Autowired
        private LogManager logManager;
        @Autowired
        private DubboService dubboService;
    
        /**
         * Controller层切点 注解拦截
         */
        @Pointcut("@annotation(com.guahao.wcp.gops.home.annotation.SystemControllerLog)")
        public void controllerAspect() {
        }
    
        /**
         * 方法规则拦截
         */
        @Pointcut("execution(* com.guahao.wcp.gops.home.controller.*.*(..))")
        public void controllerPointerCut() {
        }
    
        /**
         * 前置通知 用于拦截Controller层记录用户的操作的开始时间
         *
         * @param joinPoint 切点
         * @throws InterruptedException
         */
        @Before("controllerAspect()")
        public void doBefore(JoinPoint joinPoint) throws InterruptedException {
    //        Date beginTime = new Date();
    //        beginTimeThreadLocal.set(beginTime);
            //debug模式下 显式打印开始时间用于调试
    //        if (logger.isDebugEnabled()) {
    //            logger.debug("开始计时: {}  URI: {}", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS")
    //                    .format(beginTime), request.getRequestURI());
    //        }
            //读取GuserCookie中的用户信息
            String loginId = GuserCookieUtil.getLoginId(request);
            UserInfoDTO userInfo = dubboService.userInfoService.getUserInfoByLoginId(loginId).getDataResult();
            currentUserInfo.set(userInfo);
        }
    
        /**
         * 后置通知 用于拦截Controller层记录用户的操作
         *
         * @param joinPoint 切点
         */
        @After("controllerAspect()")
        public void doAfter(JoinPoint joinPoint) {
            UserInfoDTO userInfo  = currentUserInfo.get();
            //登入login操作 前置通知时用户未校验 所以session中不存在用户信息
            if (userInfo == null) {
                String loginId = GuserCookieUtil.getLoginId(request);
                userInfo = dubboService.userInfoService.getUserInfoByLoginId(loginId).getDataResult();
                if (userInfo == null) {
                    return;
                }
            }
            Object[] args = joinPoint.getArgs();
            System.out.println(args);
    
            String desc = "";
            String type = "info";                       //日志类型(info:入库,error:错误)
            String remoteAddr = request.getRemoteAddr();//请求的IP
            String requestUri = request.getRequestURI();//请求的Uri
            String method = request.getMethod();        //请求的方法类型(post/get)
            Map paramsMap = request.getParameterMap(); //请求提交的参数
            try {
                desc = getControllerMethodDescription(request,joinPoint);
            } catch (Exception e) {
                e.printStackTrace();
            }
            // debug模式下打印JVM信息。
    //        long beginTime = beginTimeThreadLocal.get().getTime();//得到线程绑定的局部变量(开始时间)
    //        long endTime = System.currentTimeMillis();    //2、结束时间
    //        if (logger.isDebugEnabled()) {
    //            logger.debug("计时结束:{}  URI: {}  耗时: {}   最大内存: {}m  已分配内存: {}m  已分配内存中的剩余空间: {}m  最大可用内存: {}m",
    //                    new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(endTime),
    //                    request.getRequestURI(),
    //                    DateUtils.formatDateTime(endTime - beginTime),
    //                    Runtime.getRuntime().maxMemory() / 1024 / 1024,
    //                    Runtime.getRuntime().totalMemory() / 1024 / 1024,
    //                    Runtime.getRuntime().freeMemory() / 1024 / 1024,
    //                    (Runtime.getRuntime().maxMemory() - Runtime.getRuntime().totalMemory() + Runtime.getRuntime().freeMemory()) / 1024 / 1024);
    //        }
    
            OperateLogDO log = new OperateLogDO();
            log.setDesc(desc);
            log.setType(type);
            log.setRemoteAddr(remoteAddr);
            log.setRequestUri(requestUri);
            log.setMethod(method);
            log.setMapToParams(paramsMap);
            log.setUserName(userInfo.getName());
            log.setUserId(userInfo.getLoginId());
    //        Date operateDate = beginTimeThreadLocal.get();
    //        log.setOperateDate(operateDate);
    //        log.setTimeout(DateUtils.formatDateTime(endTime - beginTime));
    
            //1.直接执行保存操作
            //this.logService.createSystemLog(log);
    
            //2.优化:异步保存日志
            //new SaveLogThread(log, logService).start();
    
            //3.再优化:通过线程池来执行日志保存
            threadPoolTaskExecutor.execute(new SaveLogThread(log,logManager));
            logThreadLocal.set(log);
        }
    
        /**
         * 异常通知
         *
         * @param joinPoint
         * @param e
         */
        @AfterThrowing(pointcut = "controllerAspect()", throwing = "e")
        public void doAfterThrowing(JoinPoint joinPoint, Throwable e) {
            OperateLogDO log = logThreadLocal.get();
            if (log != null) {
                log.setType("error");
                log.setException(e.toString());
                new UpdateLogThread(log,logManager).start();
            }
        }
    
        /**
         * 获取注解中对方法的描述信息 用于Controller层注解
         *
         * @param joinPoint 切点
         * @return 方法描述
         */
        public static String getControllerMethodDescription(HttpServletRequest request,JoinPoint joinPoint) throws IllegalAccessException, InstantiationException {
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            Method method = signature.getMethod();
            SystemControllerLog controllerLog = method
                    .getAnnotation(SystemControllerLog.class);
            String desc = controllerLog.description();
            List list = descFormat(desc);
            for (String s : list) {
                //根据request的参数名获取到参数值,并对注解中的{}参数进行替换
                String value=request.getParameter(s);
                desc = desc.replace("{"+s+"}", value);
            }
            return desc;
        }
    
        /**
         * 获取日志信息中的动态参数
         * @param desc
         * @return
         */
        private static List descFormat(String desc){
            List list = new ArrayList();
            Pattern pattern = Pattern.compile("\\{([^\\}]+)\\}");
            Matcher matcher = pattern.matcher(desc);
            while(matcher.find()){
                String t = matcher.group(1);
                list.add(t);
            }
            return list;
        }
        /**
         * 保存日志线程
         *
         * @author lin.r.x
         */
        private static class SaveLogThread implements Runnable {
            private OperateLogDO log;
            private LogManager logManager;
    
            public SaveLogThread(OperateLogDO log, LogManager logManager) {
                this.log = log;
                this.logManager = logManager;
            }
    
            @Override
            public void run() {
                logManager.insert(log);
            }
        }
    
        /**
         * 日志更新线程
         *
         * @author lin.r.x
         */
        private static class UpdateLogThread extends Thread {
            private OperateLogDO log;
            private LogManager logManager;
    
            public UpdateLogThread(OperateLogDO log, LogManager logManager) {
                super(UpdateLogThread.class.getSimpleName());
                this.log = log;
                this.logManager = logManager;
            }
    
            @Override
            public void run() {
                this.logManager.update(log);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220
    • 221
    • 222
    • 223
    • 224
    • 225
    • 226
    • 227
    • 228
    • 229
    • 230
    • 231
    • 232
    • 233
    • 234
    • 235
    • 236
    • 237
    • 238
    • 239
    • 240
    • 241
    • 242
    • 243
    • 244

    4.实现AsyncConfigurer接口并重写AsyncConfigurer方法,并返回一个ThreadPoolTaskExecutor,这样我们就得到了一个基于线程池的TaskExecutor.

    在Executor配置类中增加@EnableAsync注解,开启异步支持。

    package com.guahao.wcp.gops.home.configuration;
    
    import com.alibaba.dubbo.common.logger.Logger;
    import com.alibaba.dubbo.common.logger.LoggerFactory;
    import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.scheduling.annotation.AsyncConfigurer;
    import org.springframework.scheduling.annotation.EnableAsync;
    import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
    import java.lang.reflect.Method;
    import java.util.concurrent.Executor;
    
    /**
     * @program: wcp
     * @description: 配置类实现AsyncConfigurer接口并重写AsyncConfigurer方法,并返回一个ThreadPoolTaskExecutor
     * @author: Cay.jiang
     * @create: 2018-03-12 17:27
     **/
    
    //声明这是一个配置类
    @Configuration
    //开启注解:开启异步支持
    @EnableAsync
    public class TaskExecutorConfigurer implements AsyncConfigurer {
        private static final Logger log = LoggerFactory.getLogger(TaskExecutorConfigurer.class);
        @Bean
        //配置类实现AsyncConfigurer接口并重写AsyncConfigurer方法,并返回一个ThreadPoolTaskExecutor
        //这样我们就得到了一个基于线程池的TaskExecutor
        @Override
        public Executor getAsyncExecutor() {
            ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
            //如果池中的实际线程数小于corePoolSize,无论是否其中有空闲的线程,都会给新的任务产生新的线程
            taskExecutor.setCorePoolSize(5);
            //连接池中保留的最大连接数。Default: 15 maxPoolSize
            taskExecutor.setMaxPoolSize(10);
            //线程池所使用的缓冲队列
            taskExecutor.setQueueCapacity(25);
            //等待所有线程执行完
            taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
            taskExecutor.initialize();
            return taskExecutor;
        }
        @Override
        public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
            return new WcpAsyncExceptionHandler();
        }
        /**
         * 自定义异常处理类
         * @author hry
         *
         */
        class WcpAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
            //手动处理捕获的异常
            @Override
            public void handleUncaughtException(Throwable throwable, Method method, Object... obj) {
                System.out.println("-------------》》》捕获到线程异常信息");
                log.info("Exception message - " + throwable.getMessage());
                log.info("Method name - " + method.getName());
                for (Object param : obj) {
                    log.info("Parameter value - " + param);
                }
            }
    
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66

    5.logManager调用日志DAO操作,具体的mybatis实现就不写了。

    package com.guahao.wcp.core.manager.operatelog.impl;
    
    import com.guahao.wcp.core.dal.dataobject.OperateLogDO;
    import com.guahao.wcp.core.dal.mapper.OperateLogMapper;
    import com.guahao.wcp.core.manager.operatelog.LogManager;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    
    @Service("logManager")
    public class LogManagerImpl implements LogManager {
        
    
        @Autowired
        private OperateLogMapper operateLogDAO;
        
        @Override
        public int insert(OperateLogDO log) {
    
            System.out.println("新增操作日志:"+log);
            return operateLogDAO.insert(log);
        }
        
        @Override
        public int update(OperateLogDO log) {
            //暂不实现
            //return this.logDao.updateByPrimaryKeySelective(log);
            System.out.println("更新操作日志:"+log);
            return 1;
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    6.使用范例ApplicationController方法中添加日志注解

    @RequestMapping(value = "/add.json", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"})
        @ResponseBody
        @SystemControllerLog (description = "【应用管理】新增应用{applicationName}")
        public BaseJson add(@ModelAttribute("application") ApplicationDO applicationDO, @ModelAttribute("team") TeamDO teamDO) {
    
    .......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    7.日志数据入库结果

    8.日志结果展示,grafana配置,vue编写日志查询界面

  • 相关阅读:
    想要你的祝福与众不同就赶紧用起来吧!
    一般将来时练习题
    cmake 学习使用笔记(五)手动编译
    【面经,数据分析岗】某头部汽车外企,简称车企;某医疗科技外企,简称医企
    语法基础(判断语句)
    Spring注解解析 | P/C命名空间
    数据库事务、连接与java线程之间的关系
    Nacos注册中心8-Server端(处理注册请求)
    前端基础建设与架构21 如何利用 JavaScript 实现经典数据结构?
    实例介绍基于项目依赖包选择具体实现类
  • 原文地址:https://blog.csdn.net/u011405698/article/details/133091392