《SpringBoot项目实战》第三篇—留下用户调用接口的痕迹

news2024/11/15 19:27:30

系列文章导航
第一篇—接口参数的一些弯弯绕绕
第二篇—接口用户上下文的设计与实现
第三篇—留下用户调用接口的痕迹
第四篇—接口的权限控制
第五篇—接口发生异常如何统一处理

本文参考项目源码地址:summo-springboot-interface-demo

前言

大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。

作为一名从业已达六年的老码农,我的工作主要是开发后端Java业务系统,包括各种管理后台和小程序等。在这些项目中,我设计过单/多租户体系系统,对接过许多开放平台,也搞过消息中心这类较为复杂的应用,但幸运的是,我至今还没有遇到过线上系统由于代码崩溃导致资损的情况。这其中的原因有三点:一是业务系统本身并不复杂;二是我一直遵循某大厂代码规约,在开发过程中尽可能按规约编写代码;三是经过多年的开发经验积累,我成为了一名熟练工,掌握了一些实用的技巧。

接口设计是整个系统设计中非常重要的一环,其中包括限流、权限、入参出参、切面等方面。设计一个好的接口可以帮助我们省去很多不必要的麻烦,从而提升整个系统的稳定性和可扩展性。作为接口设计经验分享的第三篇,我想分享一下如何在用户使用过程中留下操作痕迹。在实际开发中,我会采取一些手段来记录用户操作,例如使用日志记录用户行为,或者在数据库中保存用户操作记录。这些痕迹可以帮助我们快速定位和解决问题,同时也可以为后续数据分析和优化提供有价值的参考。

方法一、将接口的参数和结果打印在日志文件中

日志文件是我们记录用户使用痕迹的第一个地方,我之前写过一篇SpringBoot项目如何配置logback.xml的文章来实现系统日志输出,有兴趣的同学可以去看看。
这里我主要讲一下怎么方便将所有接口的出入参打印出来。

1、使用aop监控接口

依赖如下

<!-- aspectj -->
<dependency>
  <groupId>org.aspectj</groupId>
  <artifactId>aspectjweaver</artifactId>
  <version>1.9.5</version>
</dependency>

如果有同学不知道aspectj是啥的,可以看我这篇文章SpringBoot整合aspectj实现面向切面编程(即AOP)

关键代码如下

package com.summo.aspect;

import java.util.Objects;

import javax.servlet.http.HttpServletRequest;

import com.alibaba.druid.util.StringUtils;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Aspect
@Component
@Slf4j
public class ControllerLoggingAspect {

    /**
     * 拦截所有controller包下的方法
     */
    @Pointcut("execution(* com.summo.controller..*.*(..))")
    private void controllerMethod() {

    }

    @Around("controllerMethod()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        //获取本次接口的唯一码
        String token = java.util.UUID.randomUUID().toString().replaceAll("-", "").toUpperCase();
        MDC.put("requestId", token);

        //获取HttpServletRequest
        RequestAttributes ra = RequestContextHolder.getRequestAttributes();
        ServletRequestAttributes sra = (ServletRequestAttributes)ra;
        HttpServletRequest request = sra.getRequest();

        // 获取请求相关信息
        String url = request.getRequestURL().toString();
        String method = request.getMethod();
        String uri = request.getRequestURI();
        String params = request.getQueryString();
        if (StringUtils.isEmpty(params) && StringUtils.equals("POST", method)) {
            if (Objects.nonNull(joinPoint.getArgs())) {
                for (Object arg : joinPoint.getArgs()) {
                    params += arg;
                }
            }
        }
        // 获取调用方法相信
        Signature signature = joinPoint.getSignature();
        String className = signature.getDeclaringTypeName();
        String methodName = signature.getName();
        log.info("@http请求开始, {}#{}() URI: {}, method: {}, URL: {}, params: {}",
            className, methodName, uri, method, url, params);
        //result的值就是被拦截方法的返回值
        try {
            //proceed方法是调用实际所拦截的controller中的方法,这里的result为调用方法后的返回值
            Object result = joinPoint.proceed();
            long endTime = System.currentTimeMillis();
            //定义请求结束时的返回数据,包括调用时间、返回值结果等
            log.info("@http请求结束, {}#{}(), URI: {}, method: {}, URL: {}, time: {}ms ",
                className, methodName, uri, method, url, (endTime - startTime));

            return result;
        } catch (Exception e) {
            long endTime = System.currentTimeMillis();
            log.error("@http请求出错, {}#{}(), URI: {}, method: {}, URL: {}, time: {}ms",
                className, methodName, uri, method, url, (endTime - startTime), e);
            throw e;
        } finally {
            MDC.remove("requestId");
        }
    }
}

2、增加requestId

由于接口的调用都是异步的,所以一旦QPS上来,那么接口的调用就会很混乱,不加一个标识的话,就不知道哪个返回值属于那个请求的了。
这个时候我们则需要加一个requestId(或者叫traceId)用来标识一个请求。

也即这段代码

//获取本次接口的唯一码
String token = java.util.UUID.randomUUID().toString().replaceAll("-", "").toUpperCase();
MDC.put("requestId", token);

... ... 
MDC.remove("requestId");

同时logback.xml中也需要加一下requestId的打印,在logback.xml中可以使用%X{requestId}获取到MDC中添加的遍历。
完整的logback.xml配置文件如下:

<configuration>
    <!-- 默认的一些配置 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <!-- 定义应用名称,区分应用 -->
    <property name="APP_NAME" value="monitor-test"/>
    <!-- 定义日志文件的输出路径 -->
    <property name="LOG_PATH" value="${user.home}/logs/${APP_NAME}"/>
    <!-- 定义日志文件名称和路径 -->
    <property name="LOG_FILE" value="${LOG_PATH}/application.log"/>
    <!-- 定义警告级别日志文件名称和路径 -->
    <property name="WARN_LOG_FILE" value="${LOG_PATH}/warn.log"/>
    <!-- 定义错误级别日志文件名称和路径 -->
    <property name="ERROR_LOG_FILE" value="${LOG_PATH}/error.log"/>

    <!-- 自定义控制台打印格式 -->
    <property name="FILE_LOG_PATTERN" value="%green(%d{yyyy-MM-dd HH:mm:ss.SSS}) [%blue(requestId: %X{requestId})] [%highlight(%thread)] ${PID:- } %logger{36} %-5level - %msg%n"/>

    <!-- 将日志滚动输出到application.log文件中 -->
    <appender name="APPLICATION"
              class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 输出文件目的地 -->
        <file>${LOG_FILE}</file>
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
        <!-- 设置 RollingPolicy 属性,用于配置文件大小限制,保留天数、文件名格式 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 文件命名格式 -->
            <fileNamePattern>${LOG_FILE}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <!-- 文件保留最大天数 -->
            <maxHistory>7</maxHistory>
            <!-- 文件大小限制 -->
            <maxFileSize>50MB</maxFileSize>
            <!-- 文件总大小 -->
            <totalSizeCap>500MB</totalSizeCap>
        </rollingPolicy>
    </appender>

    <!-- 摘取出WARN级别日志输出到warn.log中 -->
    <appender name="WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${WARN_LOG_FILE}</file>
        <encoder>
            <!-- 使用默认的输出格式打印 -->
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
        <!-- 设置 RollingPolicy 属性,用于配置文件大小限制,保留天数、文件名格式 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 文件命名格式 -->
            <fileNamePattern>${LOG_PATH}/warn.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <!-- 文件保留最大天数 -->
            <maxHistory>7</maxHistory>
            <!-- 文件大小限制 -->
            <maxFileSize>50MB</maxFileSize>
            <!-- 文件总大小 -->
            <totalSizeCap>500MB</totalSizeCap>
        </rollingPolicy>
        <!-- 日志过滤器,将WARN相关日志过滤出来 -->
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>WARN</level>
        </filter>
    </appender>

    <!-- 摘取出ERROR级别日志输出到error.log中 -->
    <appender name="ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${ERROR_LOG_FILE}</file>
        <encoder>
            <!-- 使用默认的输出格式打印 -->
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
        <!-- 设置 RollingPolicy 属性,用于配置文件大小限制,保留天数、文件名格式 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 文件命名格式 -->
            <fileNamePattern>${LOG_PATH}/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <!-- 文件保留最大天数 -->
            <maxHistory>7</maxHistory>
            <!-- 文件大小限制 -->
            <maxFileSize>50MB</maxFileSize>
            <!-- 文件总大小 -->
            <totalSizeCap>500MB</totalSizeCap>
        </rollingPolicy>
        <!-- 日志过滤器,将ERROR相关日志过滤出来 -->
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
    </appender>

    <!-- 配置控制台输出 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
    </appender>


    <!-- 配置输出级别 -->
    <root level="INFO">
        <!-- 加入控制台输出 -->
        <appender-ref ref="CONSOLE"/>
        <!-- 加入APPLICATION输出 -->
        <appender-ref ref="APPLICATION"/>
        <!-- 加入WARN日志输出 -->
        <appender-ref ref="WARN"/>
        <!-- 加入ERROR日志输出 -->
        <appender-ref ref="ERROR"/>
    </root>
</configuration>

3、效果如下图

4、接口监控遇到的一些坑

返回值数据量很大会刷屏,尽量不要打印返回值。
文件上传接口会直接挂掉,所以上传的接口一般不会加入监控。

方法二、将风险高的操作保存到数据库中

虽然方法一能够记录每个接口的日志,但这些日志只存在于服务器上,并且有大小和时间限制,到期后就会消失。这种做法对所有请求或操作都一视同仁,不会对风险较高的请求进行特殊处理。为了解决危险操作带来的风险,我们需要将其持久化,以便在出现问题时能够快速找到原因。最常见的做法是将风险高的操作保存到数据库中。
实现原理还是使用方法一种的切面,不过这里使用的是注解切面,具体做法请见下文。

1、新建一张log表,存储风险操作

表结构如下:

建表语句我也贴出来

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for user_oper_log
-- ----------------------------
DROP TABLE IF EXISTS `user_oper_log`;
CREATE TABLE `user_oper_log` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '物理主键',
  `operation` varchar(64) DEFAULT NULL COMMENT '操作内容',
  `time` bigint DEFAULT NULL COMMENT '耗时',
  `method` text COMMENT '操作方法',
  `params` text COMMENT '参数内容',
  `ip` varchar(64) DEFAULT NULL COMMENT 'IP',
  `location` varchar(64) DEFAULT NULL COMMENT '操作地点',
  `response_code` varchar(32) DEFAULT NULL COMMENT '应答码',
  `response_text` text COMMENT '应答内容',
  `gmt_create` datetime DEFAULT NULL COMMENT '创建时间',
  `gmt_modified` datetime DEFAULT NULL COMMENT '更新时间',
  `creator_id` bigint DEFAULT NULL COMMENT '创建人',
  `modifier_id` bigint DEFAULT NULL COMMENT '更新人',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4  COMMENT='用户操作日志表';

SET FOREIGN_KEY_CHECKS = 1;

核心字段为操作方法、参数内容、IP、操作地点、应答码、应答内容、创建人这些,其中IP和操作地址这两个是推算的,不一定很准。这些字段也不是非常全面,如果大家还有自己想记录的字段信息也可以加进来。

2、新建@Log注解和切面处理类LogAspect

注解类

package com.summo.log;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
    /**
     * 接口功能描述
     *
     * @return
     */
    String methodDesc() default "";
}

切面处理类

package com.summo.log;

import java.io.Serializable;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;

import com.alibaba.fastjson.JSONObject;

import com.summo.entity.UserOperInfoDO;
import com.summo.repository.UserOperInfoRepository;
import com.summo.util.HttpContextUtil;
import com.summo.util.IPUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Aspect
@Component
public class LogAspect {

    @Autowired
    private UserOperInfoRepository userOperInfoRepository;

    @Pointcut("@annotation(com.summo.log.Log)")
    public void pointcut() {
        // do nothing
    }

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Object result = null;
        //默认操作对象为-1L
        MethodSignature signature = (MethodSignature)joinPoint.getSignature();
        Method method = signature.getMethod();
        Log logAnnotation = method.getAnnotation(Log.class);
        UserOperInfoDO log = new UserOperInfoDO();
        if (logAnnotation != null) {
            // 注解上的描述
            log.setOperation(logAnnotation.methodDesc());
        }
        // 请求的类名
        String className = joinPoint.getTarget().getClass().getName();
        // 请求的方法名
        String methodName = signature.getName();
        log.setMethod(className + "." + methodName + "()");
        // 请求的方法参数值
        Object[] args = joinPoint.getArgs();
        // 请求的方法参数名称
        LocalVariableTableParameterNameDiscoverer u = new LocalVariableTableParameterNameDiscoverer();
        String[] paramNames = u.getParameterNames(method);
        if (args != null && paramNames != null) {
            StringBuilder params = new StringBuilder();
            params = handleParams(params, args, Arrays.asList(paramNames));
            log.setParams(params.toString());
        }
        log.setGmtCreate(Calendar.getInstance().getTime());
        long beginTime = System.currentTimeMillis();
        // 执行方法
        result = joinPoint.proceed();
        // 执行时长(毫秒)
        long time = System.currentTimeMillis() - beginTime;
        HttpServletRequest request = HttpContextUtil.getHttpServletRequest();
        // 设置 IP 地址
        String ip = IPUtil.getIpAddr(request);
        log.setIp(ip);
        log.setTime(time);

        //保存操作记录到数据库中
        userOperInfoRepository.save(log);
        return result;
    }

    /**
     * 参数打印合理化
     *
     * @param params     参数字符串
     * @param args       参数列表
     * @param paramNames 参数名
     * @return
     */
    private StringBuilder handleParams(StringBuilder params, Object[] args, List paramNames) {
        for (int i = 0; i < args.length; i++) {
            if (args[i] instanceof Map) {
                Set set = ((Map)args[i]).keySet();
                List<Object> list = new ArrayList<>();
                List<Object> paramList = new ArrayList<>();
                for (Object key : set) {
                    list.add(((Map)args[i]).get(key));
                    paramList.add(key);
                }
                return handleParams(params, list.toArray(), paramList);
            } else {
                if (args[i] instanceof Serializable) {
                    Class<?> aClass = args[i].getClass();
                    try {
                        aClass.getDeclaredMethod("toString", new Class[] {null});
                        // 如果不抛出 NoSuchMethodException 异常则存在 toString 方法 ,安全的 writeValueAsString ,否则 走 Object的
                        // toString方法
                        params.append(" ").append(paramNames.get(i)).append(": ").append(
                            JSONObject.toJSONString(args[i]));
                    } catch (NoSuchMethodException e) {
                        params.append(" ").append(paramNames.get(i)).append(": ").append(
                            JSONObject.toJSONString(args[i].toString()));
                    }
                } else if (args[i] instanceof MultipartFile) {
                    MultipartFile file = (MultipartFile)args[i];
                    params.append(" ").append(paramNames.get(i)).append(": ").append(file.getName());
                } else {
                    params.append(" ").append(paramNames.get(i)).append(": ").append(args[i]);
                }
            }
        }
        return params;
    }
}

3、使用方法

在需要监控的接口方法上加上@Log注解

@PostMapping("/saveRel")
@Log(methodDesc = "添加记录")
public Boolean saveRel(@RequestBody SaveRelReq saveRelReq) {
    return userRoleRelService.saveRel(saveRelReq);
}

@DeleteMapping("/delRel")
@Log(methodDesc = "删除记录")
public Boolean delRel(Long relId) {
    return userRoleRelService.delRel(relId);
}

调用一下测试功能

数据库中保存的记录

这里可以看到已经有记录保存在数据库中了,包括两次添加操作、一次删除操作,并且记录了操作人的IP地址(这里我使用的是localhost所以IP是127.0.0.1)和操作时间。但是这里有一个问题:没有记录操作人的ID,也即creator_id字段为空,如果不知道这条记录是谁的,那这个功能就没有意义了,所以在方法三我将会说一下如何记录每一行数据的创建者和修改者。

方法三、记录每一行数据的创建者和修改者

这个功能的实现需要用到一个非常关键的东西:用户上下文。如何实现请看:《优化接口设计的思路》系列:第二篇—接口用户上下文的设计与实现。

那么现在假设我已经有了GlobalUserContext.getUserContext()方法可以获取到用户上下文信息,如何使用呢?
方法二没有记录操作人的ID,现在可以可以通过下面这种方法获取当前操作人的ID:

log.setCreatorId(GlobalUserContext.getUserContext().getUserId());

但是!!!我这里的标题是:记录每一行数据的创建者和修改者,可不仅仅是只操作user_oper_log的每一行数据,而是系统中的每一张表的每一行数据!那现在问题来了,如何实现这个需求?
最笨的办法就是在每个新增、更新的代码下都加上setCreatorId和setModifierId这些代码,实现是可以实现,但是感觉太low了,所以我这里提供一个思路和一个例子来优化这些代码。

1、统一字段名和类型

在每张表中都加入gmt_create(datetime 创建时间)、gmt_modified(datetime 更新时间)、creator_id(bigint 创建人ID)、modifier_id(bigint 更新人ID),我们将所有表中的这些辅助字段统一命名、统一类型,这样给我们统一处理提供了基础。

2、将这些字段集成到一个抽象类中

这样做的好处有两个:

  • 其他表的DO类继承这个抽象类,那么DO中就不需要再定义以上4个字段
  • 统一处理的类只有抽象类一个了

tips:非常建议使用mybatis-plus来实现这个功能,maven依赖如下:

 <!-- mybatis-plus -->
<dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.3.2</version>
</dependency>
<dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-extension</artifactId>
            <version>3.3.2</version>
</dependency>

类名定义和代码如下
AbstractBaseDO.java

package com.summo.entity;

import java.io.Serializable;
import java.util.Date;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class AbstractBaseDO<T extends Model<T>> extends Model<T> implements Serializable {

    /**
     * 创建时间
     */
    @TableField(fill = FieldFill.INSERT)
    private Date gmtCreate;

    /**
     * 修改时间
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Date gmtModified;

    /**
     * 创建人ID
     */
    @TableField(fill = FieldFill.INSERT)
    private Long creatorId;

    /**
     * 修改人ID
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long modifierId;

}

3、使用mybatis-plus的MetaObjectHandler全局拦截insert和update操作

自定义MetaObjectHandlerConfig继承MetaObjectHandler,代码如下
MetaObjectHandlerConfig.java

package com.summo.entity;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;

@Configuration
public class MetaObjectHandlerConfig implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {

    }

    @Override
    public void updateFill(MetaObject metaObject) {

    }
}

逻辑补全的代码如下

package com.summo.entity;

import java.util.Calendar;
import java.util.Date;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.summo.context.GlobalUserContext;
import com.summo.context.UserContext;
import org.apache.ibatis.reflection.MetaObject;

@Configuration
public class MetaObjectHandlerConfig implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        //获取用户上下文
        UserContext userContext = GlobalUserContext.getUserContext();
        //获取创建时间
        Date date = Calendar.getInstance().getTime();
        //设置gmtCreate
        this.fillStrategy(metaObject, "gmtCreate", date);
        //设置gmtModified
        this.fillStrategy(metaObject, "gmtModified", date);
        //设置creatorId
        this.fillStrategy(metaObject, "creatorId", userContext.getUserId());
        //设置modifierId
        this.fillStrategy(metaObject, "modifierId", userContext.getUserId());
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        //获取用户上下文
        UserContext userContext = GlobalUserContext.getUserContext();
        //获取更新时间
        Date date = Calendar.getInstance().getTime();
        //更新操作修改gmtModified
        this.setFieldValByName("gmtModified", date, metaObject);
        //更新操作修改modifierId
        this.setFieldValByName("modifierId", userContext.getUserId(), metaObject);
    }
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1143502.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【QT开发(17)】2023-QT 5.14.2实现Android开发

1、简介 搭建Qt For Android开发环境需要安装的软件有&#xff1a; JAVA SDK &#xff08;jdk 有apt install 安装&#xff09; Android SDK Android NDKQT官网的介绍&#xff1a; Different Qt versions depend on different NDK versions, as listed below: Qt versionNDK…

Python 算法高级篇:桶排序与基数排序

Python 算法高级篇&#xff1a;桶排序与基数排序 引言什么是桶排序&#xff1f;桶排序的基本步骤桶排序的示例 什么是基数排序&#xff1f;基数排序的基本步骤基数排序的示例 桶排序与基数排序的应用桶排序的应用基数排序的应用 Python 示例代码总结 引言 在算法高级篇的课程中…

css列表样式

html文件如下&#xff1a; <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><title>Title</title> <link href"css/style.css" rel"stylesheet" type"text/css">&…

(c语言进阶)字符串函数、字符分类函数和字符转换函数

一.求字符串长度 1.strlen() (1)基本概念 头文件&#xff1a;<string.h> (2)易错点&#xff1a;strlen()的返回值为无符号整形 #include<stdio.h> #include<string.h> int main() {const char* str1 "abcdef";const char* str2 "bbb&q…

如何公网远程访问OpenWRT软路由web界面

文章目录 1.openWRT安装cpolar2.配置远程访问地址3.固定公网地址 简单几步实现在公网环境下远程访问openWRT web 管理界面&#xff0c;使用cpolar内网穿透创建安全隧道映射openWRT web 界面面板443端口&#xff0c;无需公网IP&#xff0c;无需设置路由器。 1.openWRT安装cpola…

linux上java -jar方式运行项目及输出文件nohup.out的清理, linux上定时器的用法

linux上java -jar方式运行项目及输出文件nohup.out的清理&#xff0c; linux上定时器的用法 linux上java -jar方式运行定期自动清理nohup.out文件的内容**验证**定时器crontab使用时注意事项 linux上java -jar方式运行 参考&#xff1a;https://blog.csdn.net/qq_42169450/arti…

聚焦AIGC落地,八仙过海,谁更神通?

【科技明说 &#xff5c; 重磅专题开篇】 从AI高谈阔论的概念&#xff0c; 到AI真金白银的投资&#xff0c;再到AI因ChatGPT大模型的升温&#xff0c;每一次技术带动产业的革新&#xff0c;都离不开不了两样东西的驱动。一是此起彼伏的技术迭代&#xff0c;二是不计后果的资本…

前端重新部署如何通知用户更新

标题解决方案 常用的webSocket解决方案 webSocket; 大致逻辑思考应该是前端在部署好后向服务器发送一个状态变更通知&#xff1b;服务器接收后主动向前端push&#xff1b;前端通过心跳检测&#xff0c;接收到相关更新时弹出提示&#xff0c;让用户确认更新&#xff1b; 缺点&a…

IT新人如何在职场弯道超车?强推荐考取当下最有价值的云计算认证证书!

疯狂的裁员&#xff01;股价降低&#xff01;在美国&#xff0c;一股寒冷的创业寒流正在席卷而来。不只是硅谷进入了冬天&#xff0c;国内的传统互联网寒潮也凛冽地来了。在传统 IT体系结构逐渐式微、产业规模不断缩小的情况下&#xff0c;许多 IT工程师都面临着下岗、跳槽等问…

UVa524 Prime Ring Problem(素数环)

1、题目 2、题意 输入正整数 n n n&#xff0c;把整数1,2,3&#xff0c;…&#xff0c;n 组成一个环&#xff0c;使得相邻两个整数之和均为素数。输出时从整数 1开始逆时针排列。同一个环应恰好输出一次。 n ≤ 16 n \le 16 n≤16。 3、分析 由模型不难得到&#xff1a;每个…

Python异步编程小解一

Python异步编程 什么是异步&#xff0c;为什么是异步&#xff1f; 异步&#xff0c;意指你不用等到某个条件达成再去做某事&#xff0c;比如&#xff1a;你不用等到一切都准备好了才开始跳槽&#x1f415;.那么在 多线程 存在的情况下&#xff0c;我们为什么还要把目光投向 异…

Kafka - 3.x 文件存储不完全指北

文章目录 Topic数据的存储机制演示基本信息使用kafka-run-class.sh查看index内容使用kafka-run-class.sh查看log内容 index文件和log文件详解日志存储参数配置文件清理策略综述清理策略1&#xff09;delete策略2&#xff09;compact日志策略 高效读写的原因 Topic数据的存储机制…

Glide原理

本文基于Carson整理 1.简介 相比其他几种图片加载框架&#xff0c;Glide性能最好。这得益于其高效的图片缓存策略 其还有多样化的媒体格式加载&#xff1a;如GIF、Video&#xff0c;对于商城首页需展示丰富样式、信息的页面需求来说&#xff0c;也是必不可少的。 2.加载原理…

【数智化人物展】洞隐科技联合创始人兼CEO董志刚:数智化转型的核心是应用新技术打造新型的文化、组织和商业模式...

董志刚 本文由洞隐科技联合创始人兼CEO董志刚投递并参与《2023中国企业数智化转型升级先锋人物》榜单/奖项评选。 数据智能产业创新服务媒体 ——聚焦数智 改变商业 在数字化日益渗透的今天&#xff0c;“释放数字价值&#xff0c;驱动智能转型”已逐渐成为企业转型与创新的关…

Vue插件的使用

一、插件的定义 &#xff08;一&#xff09;创建plugin.js文件 文件名可以自定义&#xff0c;但是行业内默认使用plugin作为文件名&#xff0c;该文件和main.js是平级的。 &#xff08;二&#xff09;编写对象中的install方法 install方法能够接收一个参数&#xff0c;就是…

【go】两数求和

文章目录 题目代码解法2 代码仓库 题目 给定一个整数数组 nums 和一个整数目标值 target&#xff0c;请你在该数组中找出 和为目标值 target 的那 两个 整数&#xff0c;并返回它们的数组下标。 你可以假设每种输入只会对应一个答案。但是&#xff0c;数组中同一个元素在答案…

5款在线JavaScript加密混淆工具

5款常用、好用的在线JavaScript加密混淆工具&#xff0c;网址请从截图中查看。 1、jscrambler 2、JShaman 3、javascriptobfuscator 4、freejsobfuscator 5、jjencode

【PyQt】调整子控件的层级以调整绘制的先后顺序

简述 qt中貌似没有直接设置z序的函数&#xff0c;但对应的有其他调整z序的方法&#xff1a; QWidget.raise_()&#xff1a;置顶 QWidget.lower()&#xff1a;置底 QWidget.stackUnder(wid)&#xff1a;置于指定控件之下 其中关键函数是QWidget.stackUnder(wid)&#xff0c;利…

phpstorm2022.3.3和xdebug 3 调试代码记录

有鉴于之前使用log日志调试代码&#xff0c;或者var_dump()调试代码太慢了&#xff0c;系统出了问题排查效率低下。最终决定使用xdebug工具提高效率。总结如下&#xff1a; 1. xdebug版本要和phpstorm兼容&#xff0c; 这里使用xdeubug 3.1.6&#xff0c;phpstorm 2022.3.3 (破…

nvm的安装和使用

nvm用途 nvm是用来管理node版本的,安装成功之后可以去切换自己的node版本,就不需要通过安装卸载不同版本的node包 下载与安装 下载地址是https://github.com/coreybutler/nvm-windows/releases 下载nvm-setup.zip,然后安装就可以了 默认路径是C:\Users\wangjingtao\AppData\…