Springboot多数据源及事务实现方案

Springboot多数据源及事务实现方案

文章目录

    • Springboot多数据源及事务实现方案
        • 背景
        • 问题分析
        • 实现原理
          • 1. 数据源抽象与动态路由
          • 2. 线程本地存储(ThreadLocal)
          • 3. 面向切面编程(AOP)
          • 4. 自定义注解
        • 实现流程
          • 1. 设置数据源标识
          • 2. 开始数据库操作
          • 3. 获取数据库连接
          • 4. 执行数据库操作
          • 5. 操作完成
        • 多数据源事务
          • 1. 多数据源事务简介
          • 2. 多数据源事务实现方案对比:
        • 代码实现
          • 1. 数据源切换控制器
          • 2. 多数据源配置
          • 3. 动态数据源
          • 4. 自定义注解和切面
          • 5. 切面
          • 6. 事务
        • 项目地址

背景

因业务需要,现要将当前系统的业务数据拆分为两部分 A 和 B,分别在不同的数据库中存储。本次方案在不借助中间件的情况下,在代码层面进行实现多数据源及事务。

业务系统技术栈:springboot、mybatis、mybatis-plus、Druid、MySQL。


问题分析
  1. 本次变动是发生在数据层,即业务逻辑不变,只是数据的走向发生了变化。

  2. 本次变动影响的维度有前端界面操作、定时任务以及第三方接口。

针对以上两点,所以在本次技术实现中,要做到:

  1. 支持手动切换数据源,即自定义注解控制数据源切换。
  2. 支持用户操作前端页面时,携带默认的数据源,即用切面实现数据源的控制。
  3. 改动要小,不改动业务逻辑,少改动业务代码。(这个目标就注定要用 AOP。。。)

实现原理
1. 数据源抽象与动态路由

​ Spring 框架提供了AbstractRoutingDataSource,这是一个数据源路由抽象类,允许动态切换数据源。它内部维护了一个数据源映射(Map) resolvedDataSources ,可以根据某个键值(通常是当前线程的某种状态)来决定实际使用哪个数据源。在每次数据库操作前通过覆写 determineCurrentLookupKey 方法来提供这个键值,获取不同的 connection(数据库连接),从而实现动态数据源的选择。

AbstractRoutingDataSource 类图如下:
在这里插入图片描述

2. 线程本地存储(ThreadLocal)

​ 为了在整个请求处理过程中保持数据源的一致性,通常会使用ThreadLocal来存储当前线程所选择的数据源标识。ThreadLocal提供了线程局部变量,确保每个线程只能访问自己的数据源标识,这样就可以在并发环境下安全地改变和存取当前线程的数据源选择。

3. 面向切面编程(AOP)

​ 通过使用AOP(面向切面编程),可以在不修改业务代码的前提下,实现数据源的动态切换。具体做法是定义一个切面(Aspect),在这个切面中拦截特定的方法调用(比如,使用自定义注解标记的方法)。在方法执行前,根据方法上的注解或其他逻辑来设置 ThreadLocal 中的数据源标识,方法执行后清除这个标识。这样,当执行数据库操作时,就会根据当前线程的数据源标识来选择相应的数据源。

4. 自定义注解

​ 为了更灵活地控制数据源的选择,通常会定义一个或多个自定义注解(如@DataSource)。在业务方法上使用这些注解来指明该方法应当使用的数据源。结合AOP,可以在方法执行前读取这些注解的值,据此动态切换数据源。


实现流程

​ 通过AOP切面,在方法执行前后分别设置和清除ThreadLocal中的数据源标识。这一步骤是通过拦截带有@TargetDataSource注解的方法来实现的。

1. 设置数据源标识

​ 通过AOP,在方法执行前分别设置 ThreadLocal中的数据源标识。

2. 开始数据库操作

​ 当开始执行一个数据库操作(如查询、更新)时,MyBatis 会尝试通过 SqlSessionFactoryBean 中配置的 DataSource 获取数据库连接。

3. 获取数据库连接

​ MyBatis 通过 SqlSessionFactoryBean 配置的 DataSource 获取数据库连接,确定使用的数据源之后,再去 Druid 连接池中获取相应的连接。

  1. 获取当前数据源标识(determineCurrentLookupKey)

    我们配置的DataSourceDynamicDataSource,则会触发DynamicDataSourcegetConnection 方法(因为没有重写该方法,所以实际使用的还是 AbstractRoutingDataSource 中的getConnection方法)。

    getConnection 方法内部会调用determineTargetDataSource方法,该方法又会调用determineCurrentLookupKey

    determineCurrentLookupKey方法中,通过DynamicDataSourceContextHolder.peek() 获取当前线程设置的数据源标识。

  2. 确定数据源

    根据上一步中获取的数据源标识,AbstractRoutingDataSource从其维护的目标数据源映射中选择相应的 DataSource

  3. 获取数据库连接

    根据上一步选定的 DataSource 从 Druid 连接池中获取一个池子分配的现有连接,或者在没有可用连接时建立一个新的数据库连接相应的数据库连接。

4. 执行数据库操作

​ 有了数据库连接后,MyBatis 就可以执行相应的数据库操作了,以下操作 Mybatis 已经实现自动化,无需编写代码实现。

  1. 获取连接(getConnection

    使用上一步获取到的连接。

  2. 执行SQL语句

    一旦获取到数据库连接,系统就可以使用这个连接来创建一个或多个SQL语句(Statement、PreparedStatement等),并通过这些语句执行具体的SQL操作(如查询、更新、删除等)。

  3. 处理结果

    对于查询操作,执行SQL之后会得到一个结果集(ResultSet),接下来需要对这个结果集进行处理,提取出需要的数据。对于更新、删除等操作,通常会返回一个表示受影响行数的整数。

  4. 关闭连接

    在SQL操作完成后,应当关闭ResultSet、Statement以及Connection,释放资源。在使用连接池的情况下,关闭连接通常意味着将连接返回给连接池,以便再次使用,而不是真正关闭物理连接。

5. 操作完成

​ 数据库操作完成后,通过AOP切面在方法执行后清除 ThreadLocal 中的数据源标识,保证下一次操作不会受到影响。

流程图如下:

在这里插入图片描述


多数据源事务
1. 多数据源事务简介

​ 在多数据源的情况下,具体来说是在一个方法中既使用了数据源 A 又使用了数据源 B 的情况下,事务会失效,即无法实现数据源 A 和数据源 B 同时提交和回滚。

​ 事务的具体原理不在本篇作过多的介绍(后面有时间就补一篇),本篇会对比集中实现多数据源事务的方案。

​ **多数据源情况下事务失效简单来说就是 Spring 默认的事务只是针对同一个数据库链接实现的,不支持多个数据库链接。**由上面的实现原理和实现流程可知,在执行 SQL 语句时会获取数据库链接,Spring 的事务会缓存第一个数据库链接,当第二个SQL 是不同的数据源时,不会重新获取链接,导致事务失效。

2. 多数据源事务实现方案对比:
  1. 通过改变传播机制,即通过新开事务的方式。

    该方案需要对所有使用到事务的业务代码进行重构,费时费力。

  2. 配置多套 Mapper,使用不同的事务管理器

    该方案需要将现有的 Mapper 拆分为多套,从而实现事务控制。该方案维护成本以及后续开发成本高,更适合将读写拆分为不同 Mapper 的项目。

  3. XA 二阶段提交

    MySQL 支持 XA 二阶段提交,但该方式比较重,会存在一定的性能问题。且该方式使用 tkmybatis 时,tkmybatis 框架的方法失效;mybatis-plus 框架正常。

  4. 自定义事务管理器

    采用该方案。从数据源失效的原因可知,实现多数据源事务需要将多个数据库链接放到同一个事务中,所以自定义事务管理器,实现多个数据库链接的提交和回滚。


代码实现
1. 数据源切换控制器

​ 用于进行数据源的切换和清除。


public final class DynamicDataSourceContextHolder {

    /**
     * 为什么要用链表存储(准确的是栈)
     * <pre>
     * 为了支持嵌套切换,如ABC三个service都是不同的数据源
     * 其中A的某个业务要调B的方法,B的方法需要调用C的方法。一级一级调用切换,形成了链。
     * 传统的只设置当前线程的方式不能满足此业务需求,必须使用栈,后进先出。
     * </pre>
     */
    private static final ThreadLocal<Deque<String>> LOOKUP_KEY_HOLDER = new NamedThreadLocal<>("dynamic-datasource") {
        @Override
        protected Deque<String> initialValue() {
            return new ArrayDeque<>();
        }
    };

    private DynamicDataSourceContextHolder() {
    }

    /**
     * 获得当前线程数据源
     *
     * @return 数据源名称
     */
    public static String peek() {
        return LOOKUP_KEY_HOLDER.get().peek();
    }

    /**
     * 设置当前线程数据源
     * <p>
     * 如非必要不要手动调用,调用后确保最终清除
     * </p>
     *
     * @param dataSource 数据源名称
     */
    public static void push(String dataSource) {
        String dataSourceStr = StrUtil.isEmpty(dataSource) ? "" : dataSource;
        LOOKUP_KEY_HOLDER.get().push(dataSourceStr);
    }

    /**
     * 清空当前线程数据源
     * <p>
     * 如果当前线程是连续切换数据源 只会移除掉当前线程的数据源名称
     * clear() 会清空所有的数据源, 导致后续的 Service 调用都会使用默认的数据源
     * </p>
     */
    public static void poll() {
        Deque<String> deque = LOOKUP_KEY_HOLDER.get();
        deque.poll();
        if (deque.isEmpty()) {
            clear();
        }
    }

    /**
     * 强制清空本地线程
     * <p>
     * 防止内存泄漏,如手动调用了push可调用此方法确保清除
     * </p>
     */
    public static void clear() {
        LOOKUP_KEY_HOLDER.remove();
    }

    /* copilot 对该类的一些解释
    这个类是一个Java的工具类,它被定义为final,这意味着这个类不能被继承。
    它的构造函数是私有的,这意味着你不能在类的外部创建这个类的实例。
    这是一种常见的设计模式,叫做单例模式,用于确保一个类只有一个实例。

    这个类中的主要变量是一个名为`LOOKUP_KEY_HOLDER`的`ThreadLocal`对象。
    `ThreadLocal`是Java中的一个类,它提供了线程局部变量。这些变量与线程的生命周期相同,每个线程都保持其自己的独立副本。

    `LOOKUP_KEY_HOLDER`被初始化为一个`Deque<String>`(双端队列)的线程局部变量。
    这是通过`ThreadLocal`的匿名子类实现的,该子类覆盖了`initialValue`方法来提供`ThreadLocal`变量的初始值,即一个新的`ArrayDeque`实例。

    在应用运行期间,每个线程都会有自己的`LOOKUP_KEY_HOLDER`副本,每个副本都是一个独立的`Deque<String>`实例。
    这意味着,尽管`DynamicDataSourceContextHolder`类本身只有一个实例,但`LOOKUP_KEY_HOLDER`变量在每个线程中都有一个独立的副本。

    `push`方法用于将数据源名称添加到当前线程的`LOOKUP_KEY_HOLDER`双端队列的顶部,
    `peek`方法用于查看当前线程的`LOOKUP_KEY_HOLDER`双端队列的顶部元素(即最近添加的数据源名称),
    而`poll`方法用于移除当前线程的`LOOKUP_KEY_HOLDER`双端队列的顶部元素。
    如果双端队列为空,`clear`方法会被调用,它会清除当前线程的`LOOKUP_KEY_HOLDER`变量。


    `DynamicDataSourceContextHolder`类,包括其静态变量和方法,都存储在Java虚拟机(JVM)的方法区中。
    方法区是JVM的一部分,用于存储已被加载的类信息、常量、静态变量以及即时编译器编译后的代码等数据。

    具体来说,`DynamicDataSourceContextHolder`类的定义,包括其方法的字节码,都存储在方法区。
    类的静态变量,如`LOOKUP_KEY_HOLDER`,也存储在方法区。

    然而,`LOOKUP_KEY_HOLDER`变量指向的`ThreadLocal<Deque<String>>`对象实例并不存储在方法区,而是存储在堆区。
    堆区是JVM的另一部分,用于存储所有的对象实例。每个线程的`ThreadLocal`变量副本存储在各自线程的线程栈中。

    至于方法的调用,例如`push`、`peek`、`poll`和`clear`,它们在被调用时会创建一个栈帧存储在调用线程的Java栈中。
    Java栈是用于存储局部变量、操作数栈、动态链接和方法出口等信息的区域。
     */

}
2. 多数据源配置

读取配置文件中的多数据源配置并初始化,怎么读取配置不过多介绍。

@Configuration
public class DruidDataSourceDynamicConfig {

    @Resource
    private DruidCommonProperties druidCommonProperties;

    /***
     *  主数据源
     *  initMethod = "init", 其中 init 调用 DruidDataSource 中的 init 方法;
     *  指定该属性, 可在应用启动时控制台看到初始化日志; 若不指定, 则在使用时进行初始化, 且不会打印初始化日志.
     *
     * @return DataSource
     * Author: lzhch 2023/12/6 17:36
     * Since: 1.0.0
     */
    @Bean(name = "masterDataSource", initMethod = "init")
    @ConfigurationProperties("spring.datasource.druid.master")
    public DataSource masterDataSource() {
        return druidCommonProperties.dataSource();
    }

    /**
     * 进行多数据源设置
     * 默认注入多数据源, 所以 @Primary 加在多数据源 Bean 上
     *
     * @return DynamicDataSource
     * Author: lzhch 2023/12/6 17:38
     * Since: 1.0.0
     */
    @Primary
    @Bean(name = "dynamicDataSource")
    public DynamicDataSource dataSource() throws SQLException {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DataSourceType.MASTER, masterDataSource());

        // 获取Druid配置的从数据库映射Map
        Map<String, DataSourceProperties> allDataSourcesMap = druidCommonProperties.getSlaveDataSourcesMap();

        // 遍历从数据库映射Map
        for (Map.Entry<String, DataSourceProperties> entry : allDataSourcesMap.entrySet()) {

            // 获取键值
            String datasourceName = entry.getValue().getName();
            DataSourceProperties value = entry.getValue();

            // 创建Druid数据源对象
            DruidDataSource druidDataSource = druidCommonProperties.dataSource();
            // 设置数据源名称,数据库连接URL,用户名,密码,驱动类名
            druidDataSource.setName(datasourceName);
            druidDataSource.setUrl(value.getUrl());
            druidDataSource.setUsername(value.getUsername());
            druidDataSource.setPassword(value.getPassword());
            druidDataSource.setDriverClassName(value.getDriverClassName());
            // 初始化数据源
            druidDataSource.init();

            // 将数据源添加到targetDataSources中
            targetDataSources.put(datasourceName, druidDataSource);
        }

        // 返回动态数据源
        return new DynamicDataSource(masterDataSource(), targetDataSources);
    }

}
3. 动态数据源

进行多数据源的初始化,继承 AbstractRoutingDataSource 并且重写 determineCurrentLookupKey

public class DynamicDataSource extends AbstractRoutingDataSource {

    /**
     * 默认数据源
     */
    private final DataSource defaultTargetDataSource;

    /**
     * 所有数据源(包含默认数据源)
     */
    private final Map<Object, Object> targetDataSources;

    /**
     * 设置所有的数据源
     * 构造方法中只完成属性的赋值, 构造方法执行完还有其他操作,所以不进行 Bean 的初始化;
     * 重写 afterPropertiesSet() 方法让 spring 进行 Bean 的初始化
     *
     * @param defaultTargetDataSource 默认数据源
     * @param targetDataSources       所有数据源(包含默认数据源)
     * @return: void
     * Author: lzhch 2023/12/6 16:16
     * Since: 1.0.0
     */
    public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources) {
        this.defaultTargetDataSource = defaultTargetDataSource;
        this.targetDataSources = targetDataSources;
    }

    /**
     * 获取当前数据源的标识
     * 应用通过 ORM 框架(mybatis/JPA等) 和数据库建立链接(Connection), 通过在 AbstractRoutingDataSource 中调用 getConnection 实现
     * 在 getConnection 中最终会调用到 determineCurrentLookupKey 方法, 该方法返回的是数据源的标识
     * 所以多数据源的关键就在于 determineCurrentLookupKey 方法, 多数据源就是建立了多个数据库连接(应用程序和不同数据库的链接)
     * spring 事务就是通过在一个数据库连接中执行全部 SQL 来控制提交和回滚
     * 在多数据源情况下, 每个数据库连接会有自己的事务, 互不影响, 所以这也是多数据源情况下事务失效(异常情况下不能全部回滚)的原因
     *
     * @return Object
     * Author: lzhch 2023/12/6 16:16
     * Since: 1.0.0
     */
    @Override
    protected Object determineCurrentLookupKey() {
        String ds = DynamicDataSourceContextHolder.peek();
        return StrUtil.isBlank(ds) ? DataSourceType.MASTER : ds;
    }

    /**
     * 重写 afterPropertiesSet() 方法让 spring 进行 Bean 的初始化
     */
    public void afterPropertiesSet() {
        super.setDefaultTargetDataSource(defaultTargetDataSource);
        super.setTargetDataSources(targetDataSources);
        super.afterPropertiesSet();
    }

}
4. 自定义注解和切面

自定义注解搭配 AOP 实现手动控制多数据源切换

// @Inherited 注解的作用是:允许子类继承父类中的注解。
// 添加在类上, 子类会继承该注解, 即父类上添加了 MultiDataSourceTransactional 注解, 子类会默认继承该注解;
// 添加在接口上, 实现类不会继承该注解;
// 添加在方法上, 子类不会继承该注解
// @Inherited
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface DataSource {

    String value() default DataSourceType.MASTER;

}
5. 切面

在切面中定义切点(Pointcut),对切点进行不同的设置即可实现不同场景的需求。下面的代码是实现拦截自定义注解的示例,也可以拦截包路径、方法等,从而可以实现根据登录的用户信息切换不同的数据源。

@Slf4j
@Aspect
@Component
public class DataSourceAspect {

    /*
     * @annotation 匹配指定注解的方法
     * @within 匹配指定注解的类
     * 注意:这里只拦截所注解的类,如果调用的是父类的方法,那么不会拦截,除非父类方法在子类中被覆盖。
     */
    @Pointcut("@annotation(com.lzhch.practice.dynamic.annotation.DataSource) || @within(com.lzhch.practice.dynamic.annotation.DataSource)")
    public void dataSourcePointCut() {
    }

    @Around("dataSourcePointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        Class<?> dataClass = Class.forName(signature.getDeclaringTypeName());

        DataSource dsMethod = method.getAnnotation(DataSource.class);
        DataSource dsClass = dataClass.getAnnotation(DataSource.class);
        if (dsMethod != null) {
            //方法优先,如果方法上存在注解,则优先使用方法上的注解
            DynamicDataSourceContextHolder.push(dsMethod.value());
            log.info("method first, set datasource is " + dsMethod.value());
        } else if (dsClass != null) {
            //其次类优先,如果类上存在注解,则使用类上的注解
            DynamicDataSourceContextHolder.push(dsClass.value());
            log.info("class second, set datasource is " + dsClass.value());
        } else {
            //如果都不存在,则使用默认
            DynamicDataSourceContextHolder.push(DataSourceType.MASTER);
            log.info("default, set datasource is " + DataSourceType.MASTER);
        }

        try {
            return point.proceed();
        } finally {
            // 不使用 clear 方法, 否则后面的数据源切换会被清空, 造成数据源切换失败
            // DynamicDataSourceContextHolder.clear();
            DynamicDataSourceContextHolder.poll();
            log.info("clean datasource");
        }
    }

}
6. 事务
  1. 多数据源事务控制器
    用于控制事务的提交和回滚
/**
 * 多数据源事务管理器
 * <p>
 * spring 原生的事务管理是只会获取一次连接, 并将连接缓存, 第二次获取时直接从缓存中获取
 * 所以导致了切换数据源失效, 因为第二次(不同数据源)并没有去重新获取数据库连接, 还是使用第一次的连接
 * 所以这里重写了事务管理器, 每次都会重新获取数据库连接, 并将连接缓存到 datasourceConnMap 中
 * 从而实现不同的数据源获取不同的连接, 从而开启不同的事务
 */
public class MultiDataSourceTransaction implements Transaction {

    private final DataSource dataSource;

    private final ConcurrentMap<String, Connection> datasourceConnMap;

    private boolean autoCommit;

    public MultiDataSourceTransaction(DataSource dataSource) {
        Assert.notNull(dataSource, "No DataSource specified");
        this.dataSource = dataSource;
        datasourceConnMap = MapUtil.newConcurrentHashMap();
    }

    /**
     * 获取数据库连接
     * 每次都根据数据源标识获取数据库连接, 并将连接缓存到 datasourceConnMap 中
     * 从而实现不同的数据源获取不同的连接, 从而开启不同的事务
     * spring 原生的只会获取一次连接, 所以会导致开启事务时切换数据源失效
     */
    @Override
    public Connection getConnection() throws SQLException {
        String ds = DynamicDataSourceContextHolder.peek();
        if (StrUtil.isBlank(ds)) {
            ds = DataSourceType.MASTER;
        }

        if (this.datasourceConnMap.containsKey(ds)) {
            return this.datasourceConnMap.get(ds);
        }

        Connection conn = this.dataSource.getConnection();
        autoCommit = false;
        conn.setAutoCommit(false);
        this.datasourceConnMap.put(ds, conn);
        return conn;
    }

    /**
     * 提交事务
     * 将所有的数据源连接分别进行事务的提交
     */
    @Override
    public void commit() throws SQLException {
        for (Connection conn : this.datasourceConnMap.values()) {
            if (!autoCommit) {
                conn.commit();
            }
        }
    }

    /**
     * 回滚事务
     * 将所有的数据源连接分别进行事务的回滚
     */
    @Override
    public void rollback() throws SQLException {
        for (Connection conn : this.datasourceConnMap.values()) {
            conn.rollback();
        }
    }

    /**
     * 关闭连接
     * 将所有的数据源连接分别进行关闭
     */
    @Override
    public void close() {
        for (Connection conn : this.datasourceConnMap.values()) {
            DataSourceUtils.releaseConnection(conn, dataSource);
        }
    }

    @Override
    public Integer getTimeout() {
        return null;
    }

}
  1. 多数据源事务工厂,使用自定义事务替换默认事务
public class MultiDataSourceTransactionFactory extends SpringManagedTransactionFactory {

    /**
     * 自定义事务管理器, 替换掉 spring 默认的 SpringManagedTransaction
     *
     * @param dataSource DataSource to take the connection from
     * @param level      Desired isolation level
     * @param autoCommit Desired autocommit
     * @return Transaction 新的事务管理器
     */
    @Override
    public Transaction newTransaction(DataSource dataSource, TransactionIsolationLevel level, boolean autoCommit) {
        return new MultiDataSourceTransaction(dataSource);
    }

}
  1. 多数据源事务配置,在 sqlSession 中设置自定义事务
@Configuration
@MapperScan(value = "com.lzhch.practice.business.mapper")
public class MultiDataSourceConfig {

    @javax.annotation.Resource
    private MybatisPlusProperties mybatisProperties;

    /**
     * 设置 SqlSessionFactory
     */
    @Bean
    @SneakyThrows
    public SqlSessionFactory sqlSessionFactory(@Qualifier("dynamicDataSource") DataSource dataSource) {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setTransactionFactory(new MultiDataSourceTransactionFactory());
        sqlSessionFactoryBean.setDataSource(dataSource);
        sqlSessionFactoryBean.setTypeAliasesPackage(mybatisProperties.getTypeAliasesPackage());
        List<Resource> resourceList = new ArrayList<>();
        for (String mapperLocation : mybatisProperties.getMapperLocations()) {
            resourceList.addAll(Arrays.asList(new PathMatchingResourcePatternResolver().getResources(mapperLocation)));
        }
        Assert.notEmpty(resourceList, "mapperLocations can't be empty");
        sqlSessionFactoryBean.setMapperLocations(resourceList.toArray(new org.springframework.core.io.Resource[resourceList.size()]));

        return sqlSessionFactoryBean.getObject();
    }

}

项目地址

https://github.com/lzhcccccch/SpringBoot3-Practice/tree/main/DynamicDataSource

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Godot3D学习笔记1——界面布局简介

创建完成项目之后可以看到如下界面&#xff1a; Godot引擎也是场景式编程&#xff0c;这里的一个场景相当于一个关卡。 这里我们点击左侧“3D场景”按钮创建一个3D场景&#xff0c;现在在中间的画面中会出现一个球。在左侧节点视图中选中“Node3D”&#xff0c;右键创建子节点…

医院手术室麻醉信息管理系统源码 自动生成麻醉的各种医疗文书(手术风险评估表、手术安全核查表)

目录 手术风险评估表 一、患者基本信息 二、既往病史 三、手术相关信息 四、风险评估因素 五、风险评估结果 手术安全核查表 一、患者身份与手术信息核对 二、术前准备核查 三、手术团队与职责确认 四、手术物品与设备核查 五、术中关键步骤核查 六、术后核查 七…

STM32中断实现旋转编码器计数

系列文章目录 STM32单片机系列专栏 C语言理论和实践总结专栏 文章目录 1. 旋转编码器 2. 中断代码编写 2.1 Interrupt.c 2.2 Interrupt.h 2.3 完整工程文件 1. 旋转编码器 旋转编码器主要用于测量轴的旋转位置、速度或者是角度的变化&#xff0c;它能够将转动的角度或者…

新兴游戏引擎Godot vs. 主流游戏引擎Unity和虚幻引擎,以及版本控制工具Perforce Helix Core如何与其高效集成

游戏行业出现一个新生事物——Godot&#xff0c;一个免费且开源的2D和3D游戏引擎。曾经由Unity和虚幻引擎&#xff08;Unreal Engine&#xff09;等巨头主导的领域如今迎来了竞争对手。随着最近“独特”定价模式的变化&#xff0c;越来越多的独立开发者和小型开发团队倾向于选择…

【数据结构】反转链表

给你单链表的头节点 head &#xff0c;请你反转链表&#xff0c;并返回反转后的链表。 Definition for singly-linked list.struct ListNode {int val;struct ListNode *next;};typedef struct ListNode ListNode; struct ListNode* reverseList(struct ListNode* head) {i…

JavaEE初阶——文件操作和IO

T04BF &#x1f44b;专栏: 算法|JAVA|MySQL|C语言 &#x1faf5; 小比特 大梦想 此篇文章与大家分享文件操作及IO的内容 如果有不足的或者错误的请您指出! 目录 *1.解释IO**2.关于文件的基本知识*2.1路径2.1.1绝对路径2.1.2相对路径 2.2文件分类 *3.通过Java代码操作文件*3.1针…

Arcpy入门笔记(三):数据属性的读取

Arcpy入门笔记&#xff08;三&#xff09;&#xff1a;数据属性的获取 文章目录 Arcpy入门笔记&#xff08;三&#xff09;&#xff1a;数据属性的获取常用的属性Describe对象属性&#xff08;部分&#xff09;数据集属性&#xff08;部分&#xff09;表属性&#xff08;部分&a…

python 脚本头(PyCharm+python头部信息、py头部信息、python头信息、py头信息、py文件头部)

文章目录 参考PyCharm设置脚本头头部信息 参考 https://developer.aliyun.com/article/1166544 https://blog.csdn.net/Dontla/article/details/131743495 https://blog.csdn.net/dongyouyuan/article/details/54408413 PyCharm设置脚本头 打开pycharm&#xff0c;点击file–…

5G赋能 扬帆未来|AGV无人仓成黑科技“顶流”

AGV 近年来&#xff0c;无人化这个概念逐渐被运用到了社会中的各个行业&#xff0c;而跟物流有关的就有无人分拣机器人、无人驾驶卡车、和无人叉车&#xff0c;越来越多的新装备也开始投入到实际运用中。 仓储管理在物流管理中占据着核心地位。传统的仓储管理中存在诸多的弊端…

怎样选购内衣洗衣机?2024年5款最新推荐机型种草

随着科技的不断发展&#xff0c;内衣洗衣机成为了家家户户必备的小家电之一&#xff0c;为我们的生活带来了极大的便利。但面对市场上众多的内衣洗衣机品牌&#xff0c;如何选择一款质量好的内衣洗衣机呢&#xff1f;本文将为您推荐5款最新的内衣洗衣机品牌&#xff0c;从而帮助…

一文解析golang中的协程与GMP模型

文章目录 前言1、线程实现模型1.1、用户级线程与内核级线程1.2、内核级线程模型1.3、用户级线程模型1.3、两级线程模型 2、GMP模型2.1、GMP模型概述2.1、GMP v1版本 - GM模型2.2、GMP v2版本 - GMP模型2.3、GMP相关源码2.4 调度流程2.5 设计思想 3.总结 前言 并发(并行&#x…

Babylon.js 程序化建模简明教程

Babylon.js 中的每个形状都是由三角形或小面的网格构建而成&#xff0c;如题图所示。 NSDT工具推荐&#xff1a; Three.js AI纹理开发包 - YOLO合成数据生成器 - GLTF/GLB在线编辑 - 3D模型格式在线转换 - 可编程3D场景编辑器 - REVIT导出3D模型插件 - 3D模型语义搜索引擎 - Th…

(二十九)加油站:面向对象重难点深入讲解【重点是元类】

目录&#xff1a; 每篇前言&#xff1a;0. Python中的元类&#xff1a;1. 本文引子&#xff1a;2. Python中的mro机制&#xff1a;3. Python中类的魔法属性dict&#xff1a;注意事项&#xff1a; 拓展——内建函数dir() 4. 正式谈一谈元类&#xff08;metaclass&#xff09;:&a…

IIR滤波器的设计与实现(内含设计IIR滤波器的高效方法)

写在前面&#xff1a;初学者学习这部分内容&#xff0c;要直接上手写代码可能会感到比较困难&#xff0c;我这里推荐一种高效快速的设计IIR,FIR滤波器的方法——MATLAB工具箱&#xff1a;filterDesigner。打开的方法很简单&#xff0c;就是在命令行键入&#xff1a;filterDesig…

virtio-wayland

CrosVM是Chrome操作系统中&#xff0c;用于创建虚拟机的应用。是一个Rust编写的轻量级的虚拟机。借助于CrosVM 用户可以很容易的在ChromeOS中运行Linux、Android以及Windows应用程序 概述 目前crosvm实现了virtio wayland协议&#xff0c;实现了对linux虚拟机wayland协议支持 …

动态规划——斐波那契数列模型:面试题08.01.三步问题

文章目录 题目描述算法原理1.状态表示2.状态转移方程3.初始化4.填表顺序5.返回值 代码实现CJava 题目描述 题目链接&#xff1a;面试题08.01.三步问题 如果n是0走法可能是1也可能是0&#xff0c;所以本题范围并不需要考虑直接从1开始即可 因为以3为结尾有直接从0到3的方式&a…

Kafka 3.x.x 入门到精通(04)——对标尚硅谷Kafka教程

Kafka 3.x.x 入门到精通&#xff08;04&#xff09;——对标尚硅谷Kafka教程 2. Kafka基础2.1 集群部署2.2 集群启动2.3 创建主题2.4 生产消息2.5 存储消息2.5.1 存储组件2.5.2 数据存储2.5.2.1 ACKS校验2.5.2.2 内部主题校验2.5.2.3 ACKS应答及副本数量关系校验2.5.2.4 日志文…

从哪些角度优化数据资产管理?详解如何将数据转化为企业持续竞争力

在上一篇文章中我们介绍了数据资产管理的诸多保障措施&#xff0c;上篇文章指路&#x1f449;如何保障数据资产管理有效开展&#xff1f;做好这几点就够了&#xff01; 本文重点将转向数据资产管理的实践。在当今这个数据驱动的时代&#xff0c;数据已成为企业最宝贵的资产之一…

使用Excel生成sql脚本(insert/update/delete)

目录 前言 一、Excel文件脚本变量 二、操作示例 前言 在系统使用初期&#xff0c;存在某种原因&#xff0c;需要对数据库数据进行批量处理操作。往往都是通过制定Excel表格&#xff0c;通过Excel导入到数据库中&#xff0c;所以就弄一个excel生成sql的导入脚本&#xff0c;希…

呼叫中心常用名词解释

ACD Automatic Call Distribution 自动呼叫分配&#xff0c;即排队。一般是用于呼叫中心的功能。在一个呼叫中心中&#xff0c;会有很多的座席来应答用户的来话&#xff0c;但是每个座席所具有的技能或者所承担的工作负荷是 不同的&#xff0c;如何根据一定的算法来保证所有的座…
最新文章