手撸一个动态数据源的Starter!

文章目录

  • 手撸一个动态数据源的Starter!
  • 前言
  • 一、准备工作
    • 1,演示
    • 2,项目目录结构
    • 3,POM文件
  • 二、思路
  • 三、编写代码
    • 1,定义核心注解 Ds
    • 2,定义切面
    • 3,定义DataSourceContextHolder
    • 4,定义抽象动态数据源模板类及其实现子类
    • 5,定义application.yml配置类
    • 6,定义AutoConfiguration
    • 6,定义DataSourceCreator创建器
  • 四,实测准备
    • 1,数据库准备
    • 2,表准备
    • 3,项目准备
  • 五,实测
  • 六,实现双重切点匹配
  • 七,支持多数据源事务
  • 八,启动Logo
  • 总结

前言

该项目借鉴于苞米豆的开源项目dynamic-datasource-spring-boot-starter
码云地址
本项目地址:
码云地址

你是否已经做烦了公司里的CRUD。想要编写一些不一样的代码,比如编写一些SpringBoot中间件,还可以融入公司项目中,让别人也使用你自己的Starter。 直接进入正题。
涉及知识点:

  • SpringBoot自动装配
  • ThreadLocal的使用
  • ArrayDeque 后进先出栈的使用
  • Aop相关知识点
  • 事务

提示:以下是本篇文章正文内容

一、准备工作

1,演示

引入依赖启动项目
application.yml

spring:datasource:dynamic:datasource:one:driver-class-name: com.mysql.cj.jdbc.Driverusername: rootpassword: 123456url: jdbc:mysql://127.0.0.1:3306/test01?useUnicode=true&useSSL=false&characterEncoding=utf8second:driver-class-name: com.mysql.cj.jdbc.Driverusername: rootpassword: 123456url: jdbc:mysql://127.0.0.1:3306/test02?useUnicode=true&useSSL=false&characterEncoding=utf8druid:initialSize: 10maxActive: 50minIdle: 5maxWait: 60minEvictableIdleTimeMillis: 30000maxEvictableIdleTimeMillis: 30000primary: onetype: com.alibaba.druid.pool.DruidDataSource
mybatis-plus:mapper-locations: /mapper/*.xml

使用路由和事务注解

2,项目目录结构

3,POM文件

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><groupId>com.xzq</groupId><artifactId>dynamic-spring-boot-starter</artifactId><version>1.0-SNAPSHOT</version><properties><maven.compiler.source>8</maven.compiler.source><maven.compiler.target>8</maven.compiler.target><druid.version>1.2.8</druid.version><mybatis.plus.version>3.4.3</mybatis.plus.version></properties><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.5.6</version></parent><dependencies><dependency><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId><version>${druid.version}</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>${mybatis.plus.version}</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-configuration-processor</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency></dependencies><build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-source-plugin</artifactId><version>2.1.2</version><executions><execution><id>attach-sources</id><goals><goal>jar</goal></goals></execution></executions></plugin></plugins></build>
</project>

二、思路


整体的过程如上图所示。

  1. 我们通过核心注解Ds,将数据库路由Key标注Controller 或者Service或者Mapper的类或者方法之上。
  2. 然后通过Aop横切技术,在执行方法时进行拦截,获取到路由Key设置到ThreadLocal中。
  3. 最后由持久层获取Connection时,动态数据源会从ThreadLocal中获取路由Key,然后根据路由Key找到对应的数据源,由该数据源创建Connection连接。
  4. 通过Connection连接操作数据库

我们仔细思考会不会发现一些问题呢?
如果说是通过ThreadLocal存储当前的数据库路由Key,那么ThreadLocal中应该存储什么类型的数据呢?直接String?
直接String的话,当一个Service标注了Ds,假如我又调用了另外一个Service,另外的一个Service中也标注了Ds,既两个不同的Service是不同库的,如果ThreadLocal使用String类型的数据,我们使用Aop横切技术,第一个Service会被拦截,将路由Key设置到本地线程变量中,第二个Service执行,又被拦截又将当前路由Key设置到ThreadLocal中,或许有人问,这有什么问题?,那么我第一个Service调用完第二个Service后,数据库路由发生了改变,可是该Service中的方法还没有执行完毕,我后续的一些数据库操作实际上连接的都是第二个Service标注的数据源。这肯定会出问题的。这里先留下伏笔,可以思考使用什么样的数据结构可以避免。

三、编写代码

1,定义核心注解 Ds

com.xzq.dynamic.annotation.Ds

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE,ElementType.METHOD})
@Inherited
public @interface Ds {String value() default "";}

首先第一步,我们先自定义核心注解Ds,其中的value表示路由数据库的Key
通过元注解@Target的ElementType属性表示,该注解可作用在方法或者类,接口之上。

2,定义切面

package com.xzq.dynamic.aop;
@Aspect
public class DsAspect {private Logger logger = LoggerFactory.getLogger(DsAspect.class);@Pointcut("@annotation(com.xzq.dynamic.annotation.Ds)")public void aopPoint() {}@Around("aopPoint()")public Object dbRouter(ProceedingJoinPoint pjp) throws Throwable {Ds ds = findDsKey(pjp);if (ds == null) {return pjp.proceed();}String value = ds.value();if (StringUtils.hasLength(value)) {logger.info("拦截目标方法{},设置数据库路由Key: {}", ((MethodSignature) pjp.getSignature()).getMethod().getName(), value);DataSourceContextHolder.push(value);}try {return pjp.proceed();} finally {DataSourceContextHolder.poll();}}private Ds findDsKey(ProceedingJoinPoint pjp) {//获取目标对象Class<?> target = pjp.getTarget().getClass();//获取目标对象所有接口Class<?>[] interfaces = target.getInterfaces();//获取方法对象Method method = ((MethodSignature) pjp.getSignature()).getMethod();//Method优先级 > 类优先级 > 接口优先级Ds ds = null;if ((ds = getDs(method)) == null &&(ds = getDs(new Class[]{target})) == null &&(ds = getDs(interfaces)) == null) {return null;}return ds;}private Ds getDs(Class<?>[] targets) {for (Class<?> target : targets) {Ds ds = target.getAnnotation(Ds.class);if (ds != null) {return ds;}}return null;}private Ds getDs(Method method) {return method.getAnnotation(Ds.class);}
}

该切面的核心方法其实就是findDsKey(), 在该方法中获取切点的目标对象,方法对象,还有目标对象的实现接口。然后设置一个获取的优先级,方法上的Ds优先于类,实现类优先于接口。
这样可以避免在类上设置了路由,但是在该类中的某一个方法需要切换数据库也设置了路由,导致类覆盖方法的路由。
为什么还要获取接口的呢?我们一般都会设置到实现类上。但是如果用过Mybatis的小伙伴们肯定知道,Mybatis中并没有实现类,只有接口,实现类是在程序运行中创建的。通过JDK的动态代理。

但是这样写就行了吗?如果有对Aop比较熟悉的朋友肯定知道,这样其实是不行的,一开始我也是这样写的,后来测试的时候发现,如果将Ds注解标注在类上,切面并不会拦截该类的所有方法。
后面进行实际测试的时候会介绍如何绕开Aop的方法匹配器。
Spring中文文档

3,定义DataSourceContextHolder

package com.xzq.dynamic.core;public class DataSourceContextHolder {/*** 使用LIFO 队列,后进先出*/private static final ThreadLocal<Deque<String>> DB_KEY_HOLDER = new ThreadLocal<Deque<String>>() {@Overrideprotected Deque<String> initialValue() {return new ArrayDeque<>();}};/*** 获得当前线程数据源Key*      与poll的区别是,peek仅仅是获取队列尾的元素,poll是移除队列尾元素并返回* @return 数据源名称*/public static String peek(){return DB_KEY_HOLDER.get().peek();}/*** 设置当前线程数据源Key** @return 数据源名称*/public static void push(String key){DB_KEY_HOLDER.get().push(key);}/***    拉取队列尾中的路由Key*/public static void poll() {Deque<String> deque = DB_KEY_HOLDER.get();deque.poll();if (deque.isEmpty()) {clean();}}public static void clean() {DB_KEY_HOLDER.remove();}
}

创建了一个路由Key的持有类,其实也就是利用ThreadLocal来实现线程隔离。
在这里我们使用ArrayDeque当作ThreadLocal的数据类型,这是一个使用LIFO 队列,后进先出,其实也就是我们常说的栈结构。
通过使用这种数据类型,我们就可以实现,各个方法上定义的路由Key互不影响

4,定义抽象动态数据源模板类及其实现子类

动态路由抽象类

package com.xzq.dynamic.core;@Data
public abstract class AbstractRoutingDataSource extends AbstractDataSource {//多数据源容器private Map<String, DataSource> dataSourceMap = new ConcurrentHashMap<>();//默认主数据库private String primaryKey;@Overridepublic Connection getConnection() throws SQLException {return   determineDataSource().getConnection();}//抽象模板方法,交由子类实现protected abstract DataSource determineDataSource();@Overridepublic Connection getConnection(String username, String password) throws SQLException {return determineDataSource().getConnection(username, password);}protected DataSource getDataSource(String dbKey) {if (!StringUtils.hasLength(dbKey)) {return dataSourceMap.get(primaryKey);}return dataSourceMap.get(dbKey);}
}

动态路由实现类

package com.xzq.dynamic.core;public class DynamicRoutingDataSource extends AbstractRoutingDataSource{@Overrideprotected DataSource determineDataSource() {return getDataSource(DataSourceContextHolder.peek());}}

其实Spring-jdbc中提供了一个抽象动态数据源模板类

有兴趣的小伙伴可以看一下。
我们在这里自己去定义了一个动态路由抽象模板类。该类的作用是什么呢?
首先先解释一下为啥叫他模板类,这里其实是应用了设计模式中的模板模式。由抽象类定义方法的执行顺序,再有子类实现方法。这里就不多说了。
该类实现了AbstractDataSource,重写了getConnection()方法。
我们定义了一个抽象模板方法,determineDataSource() 其作用就是获取真正的数据源,这也是我们动态数据源的核心方法,其DynamicRoutingDataSource 子类重写了该方法,通过ThreadLocal获取到路由key,从dataSourceMap中拿到匹配的数据源。

5,定义application.yml配置类

package com.xzq.dynamic.spring;
/*** dynamic动态配置类*/
@ConfigurationProperties(prefix = DynamicProperties.PREFIX)
@Data
public class DynamicProperties {public static final String PREFIX = "spring.datasource.dynamic";/*** 每一个数据源*/private Map<String, DataSourceProperty> datasource = new LinkedHashMap<>();/*** 德鲁伊配置*/private DruidConfig druid;/*** Hikari配置*/private HikariCpConfig hikari;/*** 默认库*/private String primary;/***  数据源类型*/private Class<? extends DataSource> type;
}
package com.xzq.dynamic.spring;import lombok.Data;import java.util.Properties;@Data
public class DataSourceProperty {/*** JDBC driver*/private String driverClassName;/*** JDBC url 地址*/private String url;/*** JDBC 用户名*/private String username;/*** JDBC 密码*/private String password;}
package com.xzq.dynamic.spring.druid;
/*** Druid参数配置*/
@Data
public class DruidConfig {private Integer initialSize;private Integer maxActive;private Integer minIdle;private Integer maxWait;private Long minEvictableIdleTimeMillis;private Long maxEvictableIdleTimeMillis;String INITIAL_SIZE = "druid.initialSize";String MAX_ACTIVE = "druid.maxActive";String MIN_IDLE = "druid.minIdle";String MAX_WAIT = "druid.maxWait";String MIN_EVICTABLE_IDLE_TIME_MILLIS = "druid.minEvictableIdleTimeMillis";String MAX_EVICTABLE_IDLE_TIME_MILLIS = "druid.maxEvictableIdleTimeMillis";public Properties toPropertes() {Properties properties = new Properties();properties.setProperty(INITIAL_SIZE, String.valueOf(initialSize));properties.setProperty(MAX_ACTIVE, String.valueOf(maxActive));properties.setProperty(MIN_IDLE, String.valueOf(minIdle));properties.setProperty(MAX_WAIT, String.valueOf(maxWait));properties.setProperty(MIN_EVICTABLE_IDLE_TIME_MILLIS, String.valueOf(minEvictableIdleTimeMillis));properties.setProperty(MAX_EVICTABLE_IDLE_TIME_MILLIS, String.valueOf(maxEvictableIdleTimeMillis));return properties;}
}
package com.xzq.dynamic.spring.hikari;
@Data
public class HikariCpConfig {private String catalog;private Long connectionTimeout;private Long validationTimeout;private Long idleTimeout;private Long leakDetectionThreshold;private Long maxLifetime;private Integer maxPoolSize;private Integer minIdle;private Long initializationFailTimeout;private String connectionInitSql;private String connectionTestQuery;private String dataSourceClassName;private String dataSourceJndiName;private String transactionIsolationName;private Boolean isAutoCommit;private Boolean isReadOnly;private Boolean isIsolateInternalQueries;private Boolean isRegisterMbeans;private Boolean isAllowPoolSuspension;private Properties dataSourceProperties;private Properties healthCheckProperties;/*** 高版本才有*/private String schema;private String exceptionOverrideClassName;private Long keepaliveTime;private Boolean sealed;
}

@ConfigurationProperties(prefix = DynamicProperties.PREFIX)该注解表明该类是一个Properties配置类。该注解需要@EnableConfigurationProperties(DynamicProperties.class)配合才能使用,我们会在后面的自动装配类中使用@EnableConfigurationProperties注解。
对boot比较熟悉的朋友们肯定知道这个注解,有时候我们会将application.yml中信息通过配置类得到。
该类配置完之后,我们就可以在application.yml中设置我们的动态数据源配置,如下所示

spring:datasource:dynamic:datasource:one:driver-class-name: com.mysql.cj.jdbc.Driverusername: rootpassword: 123456url: jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&useSSL=false&characterEncoding=utf8second:driver-class-name: com.mysql.cj.jdbc.Driverusername: rootpassword: 123456url: jdbc:mysql://127.0.0.1:3306/test2?useUnicode=true&useSSL=false&characterEncoding=utf8druid:initialSize: 10maxActive: 50minIdle: 5maxWait: 60minEvictableIdleTimeMillis: 30000maxEvictableIdleTimeMillis: 30000primary: onetype: com.alibaba.druid.pool.DruidDataSource

这里数据库类型就仅仅支持德鲁伊跟HikariCp两种连接池

这里提供一个小知识,application.yml有提示功能,那么我们自己定义的有没有提示功能的,这个是可以有的。我们可以引入一个依赖,自动生成yml文件的提示JSON文件。

        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-configuration-processor</artifactId></dependency>

这样在项目打包后,会根据配置类创建一个元数据JSON文件


这样我们在引入自己的Strater之后,就可以使用yml的自动提示功能。

6,定义AutoConfiguration

package com.xzq.dynamic;@EnableConfigurationProperties(DynamicProperties.class)
public class DynamicDataSourceAutoConfiguration implements InitializingBean {private final DynamicProperties properties;public DynamicDataSourceAutoConfiguration(DynamicProperties properties) {this.properties = properties;}@Beanpublic DsAspect dsAspect() {return new DsAspect();}@Bean@ConditionalOnMissingBeanpublic DataSource dataSource() {DynamicRoutingDataSource routingDataSource = new DynamicRoutingDataSource();routingDataSource.setPrimaryKey(properties.getPrimary());//routingDataSource.setDataSourceMap(dataSourceMap);return routingDataSource;}
}

springBoot有约定大于配置的说法,我们写完这个配置类之后,如何装载到spring容器中呢?
约定来了,springBoot约定会去/resource/META-INF/ 该目录下寻找一个spring.factories文件,该文件中定义了我们的配置类,spring就会默认将该配置类注入spring容器中。
所以我们需要自定义一个spring约定文件放在/resource/META-INF/ 下

这样当我们的Starter被引入时就会被spring扫描到并将配置类注册到容器中。

我们基本上已经完成了该项目的骨架了。但是还缺少了重要的一步,我们需要在自动装配类中将数据源注入容器。
那么如何创建数据源?
又如何注入到容器中呢?
创建数据源我们肯定需要注册驱动,拿到driver-Class-Name,url,username,password等一些信息。这些信息在哪里呢?欸,就是用户输入到application.yml中的数据库信息了,前面我们定义了一个Properties配置类,可以拿到application.yml中的信息。所以说我们可以通过DynamicProperties来创建数据源。
那么创建数据源的方式该怎么做呢?
有啥可做的,仅说点废话,直接New一个DataSource,给他配置好属性一个@Bean注入到容器中,不就这点事吗?楼主一开始也是这样想的,也是这样做的。
看了苞米豆的源码后发现,卧槽,还能这样。这才是OOP!怪不得楼主只是一个码农。
下面来介绍如何创建数据源

6,定义DataSourceCreator创建器

package com.xzq.dynamic.creator;public abstract class AbstractDataSourceCreator {protected final DynamicProperties properties;public AbstractDataSourceCreator(DynamicProperties properties) {this.properties = properties;}public DataSource createrDataSource(DataSourceProperty property) {return doCreateDataSource(property);}protected abstract DataSource doCreateDataSource(DataSourceProperty property);
}

这里还是老样子,定义抽象模板类,实际的创建方法交由子类实现

package com.xzq.dynamic.creator;public class DruidDataSourceCreator extends AbstractDataSourceCreator implements DataSourceCreator{private Logger logger = LoggerFactory.getLogger(DruidDataSourceCreator.class);private final DruidConfig config;public DruidDataSourceCreator(DynamicProperties properties) {super(properties);config = properties.getDruid();}@Overrideprotected DataSource doCreateDataSource(DataSourceProperty property) {DruidDataSource druidDataSource = new DruidDataSource();druidDataSource.setDriverClassName(property.getDriverClassName());druidDataSource.setUsername(property.getUsername());druidDataSource.setPassword(property.getPassword());druidDataSource.setUrl(property.getUrl());Properties properties = config.toPropertes();druidDataSource.configFromPropety(properties);try {druidDataSource.init();} catch (SQLException e) {logger.error("druid init fail");new RuntimeException("druid init fail", e);}return druidDataSource;}@Overridepublic boolean support(DataSourceProperty property) {Class<? extends DataSource> type = properties.getType();return (type != null && DRUID_DATASOURCE.equals(type.getName()));}}
package com.xzq.dynamic.creator;public class HikariDataSourceCreator extends AbstractDataSourceCreator implements DataSourceCreator{private HikariCpConfig config;public HikariDataSourceCreator(DynamicProperties properties) {super(properties);config = properties.getHikari();}@Overrideprotected DataSource doCreateDataSource(DataSourceProperty property) {HikariConfig config = new HikariConfig();BeanUtils.copyProperties(this.config, config);config.setUsername(property.getUsername());config.setPassword(property.getPassword());config.setJdbcUrl(property.getUrl());config.setDriverClassName(property.getDriverClassName());HikariDataSource hikariDataSource = new HikariDataSource(config);return hikariDataSource;}@Overridepublic boolean support(DataSourceProperty property) {Class<? extends DataSource> type = this.properties.getType();return (type!=null && DbConstants.HIKARI_DATASOURCE.equals(type.getName()));}
}

这里我只仅仅做了几个必要的连接池参数,其实德鲁伊跟hikariCp有很多连接池参数,有兴趣的小伙伴可以自己完善。

package com.xzq.dynamic.creator;
public class DefaultDataSourceCreator {private List<DataSourceCreator> creators;public DefaultDataSourceCreator(List<DataSourceCreator> creators) {this.creators = creators;}public DataSource createDataSource(DataSourceProperty dataSourceProperty) {DataSourceCreator dataSourceCreator = null;for (DataSourceCreator creator : creators) {if (creator.support(dataSourceProperty)) {dataSourceCreator = creator;break;}}if (dataSourceCreator == null) {//使用默认德鲁伊dataSourceCreator = creators.get(0);}return dataSourceCreator.createrDataSource(dataSourceProperty);}
}

这里我们在搞一个默认创建器,该创建器的作用其实也就是拿到所有类型的创建器之后,然后去匹配用户在application.yml中设置的连接池类型来进行匹配,匹配上了就用该连接池的创建器创建数据源。
那么我们该如何导入到spring容器中呢,这里我们可以再定义一个创建器的自动装配类

package com.xzq.dynamic;
@Configuration
@AllArgsConstructor
@EnableConfigurationProperties(DynamicProperties.class)
public class DynamicDataSourceCreatorAutoConfiguration {private final DynamicProperties properties;@Bean@ConditionalOnMissingBeanpublic DefaultDataSourceCreator defaultDataSourceCreator(List<DataSourceCreator> creatorList) {return new DefaultDataSourceCreator(creatorList);}@Bean@ConditionalOnMissingBeanpublic DruidDataSourceCreator druidDataSourceCreator() {return new DruidDataSourceCreator(properties);}@Bean@ConditionalOnMissingBeanpublic HikariDataSourceCreator hikariDataSourceCreator() {return new HikariDataSourceCreator(properties);}
}

然后就是完善我们的动态数据源装配类

package com.xzq.dynamic;@EnableConfigurationProperties(DynamicProperties.class)
@AutoConfigureBefore(value = DataSourceAutoConfiguration.class,name = "com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure")
@Import(DynamicDataSourceCreatorAutoConfiguration.class)
public class DynamicDataSourceAutoConfiguration implements InitializingBean {private final DynamicProperties properties;private final DefaultDataSourceCreator creator;private Map<String, DataSource> dataSourceMap = new ConcurrentHashMap<>();public DynamicDataSourceAutoConfiguration(DynamicProperties properties,DefaultDataSourceCreator creator) {this.properties = properties;this.creator = creator;}@Beanpublic DsAspect dsAspect() {return new DsAspect();}@Bean@ConditionalOnMissingBeanpublic DataSource dataSource() {DynamicRoutingDataSource routingDataSource = new DynamicRoutingDataSource();routingDataSource.setPrimaryKey(properties.getPrimary());routingDataSource.setDataSourceMap(dataSourceMap);return routingDataSource;}@Overridepublic void afterPropertiesSet() throws Exception {properties.getDatasource().forEach((k,v)->{DataSource dataSource = creator.createDataSource(v);dataSourceMap.put(k, dataSource);});}
}

OK ,到这里我们基本上已经完成了动态数据源的Starter,接下来让我们进行测试来看一下还有什么问题某有。

四,实测准备

1,数据库准备

首先创建两个不同的库

create DATABASE test01;
create DATABASE test02;

2,表准备

准备两种表放在这两个库中

CREATE TABLE `user` (`id` int NOT NULL AUTO_INCREMENT COMMENT '自增ID',`userId` varchar(9) DEFAULT NULL COMMENT '用户ID',`userNickName` varchar(32) DEFAULT NULL COMMENT '用户昵称',`userHead` varchar(16) DEFAULT NULL COMMENT '用户头像',`userPassword` varchar(64) DEFAULT NULL COMMENT '用户密码',`createTime` datetime DEFAULT NULL COMMENT '创建时间',`updateTime` datetime DEFAULT NULL COMMENT '更新时间',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb3;INSERT INTO `test01`.`user` (`id`, `userId`, `userNickName`, `userHead`, `userPassword`, `createTime`, `updateTime`) VALUES ('1', '184172133', '小熊哥', '01_50', '123456', '2021-11-13 00:00:00', '2021-11-13 00:00:00');
INSERT INTO `test01`.`user` (`id`, `userId`, `userNickName`, `userHead`, `userPassword`, `createTime`, `updateTime`) VALUES ('2', '980765512', '你滴寒王', '02_50', '123456', '2021-11-13 00:00:00', '2021-11-13 00:00:00');
INSERT INTO `test01`.`user` (`id`, `userId`, `userNickName`, `userHead`, `userPassword`, `createTime`, `updateTime`) VALUES ('3', '796542178', 'EDG牛逼', '03_50', '123456', '2021-11-13 00:00:00', '2021-11-13 00:00:00');CREATE TABLE `bs_city` (`id` varchar(40) NOT NULL,`name` varchar(255) NOT NULL,`create_time` datetime NOT NULL,PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;INSERT INTO `test02`.`bs_city` (`id`, `name`, `create_time`) VALUES ('1', '杭州', '2021-11-11 21:35:25');
INSERT INTO `test02`.`bs_city` (`id`, `name`, `create_time`) VALUES ('2', '郑州', '2021-11-11 21:35:36');
INSERT INTO `test02`.`bs_city` (`id`, `name`, `create_time`) VALUES ('3', '开封', '2021-11-11 21:35:42');

3,项目准备


创建一个springBoot项目,借助于苞米豆的代码生成器快速构建项目。
懒得搞的小伙伴可以去我的码云地址上拉下来直接用。顺便给个Star(那就更感激不尽了,哈哈)

五,实测

spring:datasource:dynamic:datasource:one:driver-class-name: com.mysql.cj.jdbc.Driverusername: rootpassword: 123456url: jdbc:mysql://127.0.0.1:3306/test01?useUnicode=true&useSSL=false&characterEncoding=utf8second:driver-class-name: com.mysql.cj.jdbc.Driverusername: rootpassword: 123456url: jdbc:mysql://127.0.0.1:3306/test02?useUnicode=true&useSSL=false&characterEncoding=utf8druid:initialSize: 10maxActive: 50minIdle: 5maxWait: 60minEvictableIdleTimeMillis: 30000maxEvictableIdleTimeMillis: 30000primary: onetype: com.alibaba.druid.pool.DruidDataSource

这里我们定义了两个数据源,one和second。默认是One
我们创建一个Controller并对其方法加上Ds注解

package com.xzq.controller;
@RestController
public class UserController {@Autowiredprivate UserMapper userMapper;@Autowiredprivate IUserService userService;@RequestMapping("/test2")@Ds("second")public Object test(String id) {return   userService.getById(id);}}

启动项目测试

似乎看上去已经大功告成。
但是如果我们将Ds注解加到类上后,切面是不会对该类的方法进行拦截的。
那么为什么会这样呢?
首先我们先回顾一下我们的切面

可以看到我们的切入点表达式是注解类型的表达式。
看一下官网的介绍


可以看到切点表达式是针对方法的匹配
那我们在类上加上Ds注解,那么我们的切入点实现会根据切入点表达式去匹配方法是否拦截,而我们的方法由于没有加注解所以拦截不到。
那么PointCut的具体实现到底是什么呢? 匹配的逻辑又是什么呢?
其实PointCut切点中有两个重要的属性,分别是ClassFilter和MethodMatch

这是springAop中切点的接口定义。
那么这两个属性是干什么的呢,顾名思义,类过滤器就是针对类进行匹配,方法匹配器针对方法进行匹配。这两个肯定是先判断类符合规则不,如果符合在进行方法的匹配,先明确这一点。
那么是如何进行匹配的呢?没错就是切入点表达式,比如我们的切入点表达式是这样的
“@annotation(com.xzq.dynamic.annotation.Ds)”
那么类过滤器就是根据我们的目标类有没有这个注解进行过滤,显然我们的Ds标注在类上是符合的。
其次方法匹配器开始根据切入点表达式匹配方法有没有这个注解啊,欸,发现没有,那么就不拦截了。
以上只是我个人的一些猜想,那么实际情况是不是这样的呢?我们跟踪一下spring的aop的源码

这里就不展开对Aop的一长串跟踪了
其逻辑是这样的,首先开启@Aspectj 的自动代理,在项目启动后,AnnotationAwareAspectJAutoPr oxyCreator被注入到容器中,该类实现了BeanPostProcess,也就是说实现了对Bean的增强功能。
在每一个Bean的初始化方法前后,会调用AnnotationAwareAspectJAutoProxyCreator的postProcessBeforeInitialization方法和postProcessAfterInitialization方法,在postProcessAfterInitialization方法中会去获取所有的增强器,也就是去获取标注了@Aspect注解的类,然后将该类转成Advisor增强器,然后在拿着这些增强器匹配当前的Bean。如何匹配的最终会调用到上图所示的CanApply()方法,也就是是否可以使用的方法。
在这里先进行类匹配然后在方法匹配,匹配逻辑就是根据切入点表达式。
而我么你的Ds注解只作用在类上,没有作用在方法上,方法匹配器匹会匹配失败,所以造成拦截不住。
好了,重点来了,如何绕过方法匹配的匹配,直接类匹配上就不走方法匹配器,默认拦截该类的所有方法呢?

六,实现双重切点匹配

首先我们需要考虑我们该如何拦截,会有那些情况

  1. 当类匹配时,方法全部匹配
  2. 当类不匹配时,检查方法是否有注解

两个切点要实现 or 操作即可完成Ds注解,即可以作用在类上实现方法全拦截,作用在方法上仅仅只拦截该方法。
那么我们使用advisor来实现。首先定义MethodInterceptor其实也就是实现一个增强Advice

逻辑跟DsAspect逻辑一致,只是我们使用advisor的方式直接注入容器。

public class DsAdvice implements MethodInterceptor {private Logger logger = LoggerFactory.getLogger(DsAspect.class);@Overridepublic Object invoke(MethodInvocation invocation) throws Throwable {Ds ds = findDsKey(invocation);if (ds == null) {return invocation.proceed();}String value = ds.value();if (StringUtils.hasLength(value)) {logger.info("拦截目标方法{},设置数据库路由Key: {}", invocation.getMethod().getName(), value);DataSourceContextHolder.push(value);}try {return invocation.proceed();} finally {DataSourceContextHolder.poll();}}private Ds findDsKey(MethodInvocation mi) {//获取目标对象Class<?> target = mi.getThis().getClass();//获取目标对象所有接口Class<?>[] interfaces = target.getInterfaces();//获取方法对象Method method = mi.getMethod();//Method优先级 > 类优先级 > 接口优先级Ds ds = null;if ((ds = getDs(method)) == null &&(ds = getDs(new Class[]{target})) == null &&(ds = getDs(interfaces)) == null) {return null;}return ds;}private Ds getDs(Class<?>[] targets) {for (Class<?> target : targets) {Ds ds = target.getAnnotation(Ds.class);if (ds != null) {return ds;}}return null;}private Ds getDs(Method method) {return method.getAnnotation(Ds.class);}

定义advisor 拦截顾问

public class DynamicDataSourceAdvisor extends AbstractPointcutAdvisor {private Pointcut pointcut;private Advice advice;@Overridepublic int getOrder() {return Integer.MAX_VALUE-1;}public DynamicDataSourceAdvisor(Advice advice) {this.advice = advice;this.pointcut = builderPointCut();}/*** 该注解可能作用在类也可能作用在方法中*  传统的默认AspectJExpressionPointCut只是针对方法的*      所以我们需要考虑两种情况*      1,类匹配上了,方法全部匹配*      2,类未匹配,方法匹配(但是这里需要注意,在判断的时候,是先匹配类在匹配方法,如果类都没有匹配上那么方法匹配都没有机会执行)*     所以需要配合一个联合切入点:*                  1,一个是当类匹配上时,方法直接匹配True*                  2,当类未匹配上,根据联合切点,执行or 操作,另一个切点将类匹配完全放开类匹配,只检查方法匹配* @return*/private Pointcut builderPointCut() {//spring提供的注解切点实现,里面的方法匹配器对方法一路放行,不需要在经过切入点表达式进行匹配AnnotationMatchingPointcut cpc = new AnnotationMatchingPointcut(Ds.class, true);Pointcut mpc = new AnnotationMethodPoint(Ds.class);return new ComposablePointcut(cpc).union(mpc);}@Overridepublic Pointcut getPointcut() {return pointcut;}@Overridepublic Advice getAdvice() {return advice;}/*** In order to be compatible with the spring lower than 5.0*/private static class AnnotationMethodPoint implements Pointcut {private final Class<? extends Annotation> annotationType;public AnnotationMethodPoint(Class<? extends Annotation> annotationType) {Assert.notNull(annotationType, "Annotation type must not be null");this.annotationType = annotationType;}@Overridepublic ClassFilter getClassFilter() {return ClassFilter.TRUE;}@Overridepublic MethodMatcher getMethodMatcher() {return new AnnotationMethodMatcher(annotationType);}private static class AnnotationMethodMatcher extends StaticMethodMatcher {private final Class<? extends Annotation> annotationType;public AnnotationMethodMatcher(Class<? extends Annotation> annotationType) {this.annotationType = annotationType;}@Overridepublic boolean matches(Method method, Class<?> targetClass) {if (matchesMethod(method)) {return true;}// Proxy classes never have annotations on their redeclared methods.if (Proxy.isProxyClass(targetClass)) {return false;}// The method may be on an interface, so let's check on the target class as well.Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass);return (specificMethod != method && matchesMethod(specificMethod));}private boolean matchesMethod(Method method) {return AnnotatedElementUtils.hasAnnotation(method, this.annotationType);}}}

可以由上面的代码看出advisor由两部分组成,一增强(通知) 二 切点
在这里我们使用了一个组合切入点,达到 or 的效果。
将该advisor注入容器

    @Beanpublic Advisor advisor() {DsAdvice dsAdvice = new DsAdvice();DynamicDataSourceAdvisor advisor = new DynamicDataSourceAdvisor(dsAdvice);return advisor;}

七,支持多数据源事务

事务我们都用过@Transactionnal注解,由spring提供基于AOP的事务注解。
我们需要考虑一些问题,事务的本质是什么?
本质上其实就是对JDBC Connection进行的操作

public static void main(String[] args) {Connection connection = null;try {//SPI 注册驱动Class.forName("com.mysql.cj.jdbc.Driver");connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);//取消MYSQL事务自动提交connection.setAutoCommit(false);PreparedStatement ps = connection.prepareStatement("UPDATE user set userNickName=? where id=?");ps.setString(1, "2222");ps.setString(2, "1");ps.executeUpdate();connection.commit();} catch (ClassNotFoundException | SQLException e) {e.printStackTrace();try {connection.rollback();} catch (SQLException ex) {ex.printStackTrace();}}

如上代码所示就是一个标准的JDBC事务控制,那么Spring的事务底层也是基于JDBC的,肯定也是逃不过这一套,所以事务的核心是复用同一个会话(Connection) 最后进行commit或者 rollback。
那么这时候我会想在Mybatis中,SqlSession维护一个Connection,该Connection肯定是从连接池中拿到的,那么是如何保证事务的Connection跟SqlSession的Connection是同一个呢?
这一块我也没追过具体的源码,但是简单说一下我得猜想:
在@Transactional注解修饰的方式执行前,事务管理器获取Connection并保存下来,在每一个SQL执行之前先去事务中检查是否有Connection如果有,那么就使用事务管理中的Connection,达到保证在一个事务中使用同一个连接。
那么Mybatis在整合Spring时,应该是做了相关的操作,将spring的事务管理器拿到并注入到自己的事务管理器中,实现与@Transactional的无感整合。
言归正传,下面说一下我们多数据源下事务的控制思路。

提到多数据源事务那么第一个想到的可能是分布式事务,XA协议,2PC,3PC,TCC。
多数据源下事务当然可以使用分布式事务完成,只是有些大材小用,先不说可能会引入一些第三方的组件例如seata增加系统的复杂性。就说分布式事务针对的也是不同应用服务之间的一个解决方案,在一般的单体项目中我们可以那多多数据源的连接可以进行统一管理。所以在这里我们采用本地控制的方式来完成事务。
那么如何构思多数据源事务呢?
无非就是将这些Connection统一管理起来,如果发生了异常,全部回滚,正常则全部提交。
并且要保证在一个事务下,获取相同数据源的Connection是同一个。

我们来实现一个ConnectionProxy

package com.xzq.dynamic.tx;public class ConnectionProxy implements Connection {private Connection connection;private String ds;public ConnectionProxy(Connection connection, String ds) {this.connection = connection;this.ds = ds;}public void notify(Boolean commit) {try {if (commit) {connection.commit();}else{connection.rollback();}} catch (Exception e) {log.error(e.getLocalizedMessage(), e);}}
}

由上图代码所示,我们自定义一个连接代理对象实现Connection接口,并绑定一个Connection和一个ds。
单独拉出来一个ConnectionProxy其实最重要的就是为了notify()方法给Connection提供一个统一的方法来进行回滚和提交的决议。

接下来我们实现连接工厂的实现

package com.xzq.dynamic.tx;public class ConnectionFactory {private static final ThreadLocal<Map<String,ConnectionProxy>> CONNECTION_HOLDER = new ThreadLocal<Map<String,ConnectionProxy>>() {@Overrideprotected Map<String,ConnectionProxy> initialValue() {return new ConcurrentHashMap<>();}};public static void putConnection(String ds,ConnectionProxy connection) {Map<String, ConnectionProxy> currentMap = CONNECTION_HOLDER.get();if (!currentMap.containsKey(ds)) {try {connection.setAutoCommit(false);} catch (SQLException e) {e.printStackTrace();}}currentMap.put(ds, connection);}public static ConnectionProxy getConnection(String ds) {return CONNECTION_HOLDER.get().get(ds);}public static void notify(Boolean state) {Map<String, ConnectionProxy> currentMap = CONNECTION_HOLDER.get();try {for (ConnectionProxy value : currentMap.values()) {value.notify(state);}}finally {CONNECTION_HOLDER.remove();}}
}

连接工厂就是我们前面所描述的将同一个事务下所有连接管理起来的工具。

package com.xzq.dynamic.tx;public class TransactionalContext {private static final ThreadLocal<String> XID_HOLDER = new ThreadLocal<>();public static String getXID() {String xid = XID_HOLDER.get();if (!StringUtils.isEmpty(xid)) {return xid;}return null;}public static String bind(String xid) {XID_HOLDER.set(xid);return xid;}public static void remove() {XID_HOLDER.remove();}}

这里我们定义一个全局的事务上下文管理,基于XID来定义事务的存在与否。

package com.xzq.dynamic.annotation;import java.lang.annotation.*;@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Inherited
public @interface Tx {}

事务注解没什么说的一个事务开启的标注

package com.xzq.dynamic.aop.advisor;public class TxAdvice implements MethodInterceptor {private Logger logger = LoggerFactory.getLogger(TxAdvice.class);@Overridepublic Object invoke(MethodInvocation invocation) throws Throwable {if (!StringUtils.isEmpty(TransactionalContext.getXID())) {return invocation.proceed();}logger.info("拦截目标方法,Begin 全局事务控制....");boolean state = true;String xid = UUID.randomUUID().toString();TransactionalContext.bind(xid);Object result = null;try {result=invocation.proceed();} catch (Exception e) {state = false;throw e;}finally {ConnectionFactory.notify(state);TransactionalContext.remove();}return result;}}

定义事务的增强,这里就体现出来了多数据源事务的处理逻辑。
首先我们来看第一行代码首先判断了XID不为空时直接执行目标方法。
这一句是什么意思呢?其实就是隐式的表示我们的事务传播行为是PROPAGATION_REQUIRED
熟悉spring事务的朋友应该知道,该传播行为就是,支持当前事务,如果当前没有事务就创建一个事务。这句话是不是就是说我们上述的代码呢?
我们给事务上下文绑定XID,其实也就是表示了开启了一个全局事务。
然后执行目标方法
当目标方法执行没有问题,则全部提交
当出现异常,全部回滚。

接下来就是配置Advisor顾问,这里跟Ds的Advisor 基本一致

package com.xzq.dynamic.aop.advisor;public class TransactionalAdvisor extends AbstractPointcutAdvisor {private Pointcut pointcut;private Advice advice;public TransactionalAdvisor( Advice advice) {this.pointcut = builderPointCut();this.advice = advice;}private Pointcut builderPointCut() {//spring提供的注解切点实现,里面的方法匹配器对方法一路放行,不需要在经过切入点表达式进行匹配AnnotationMatchingPointcut cpc = new AnnotationMatchingPointcut(Tx.class, true);Pointcut mpc = new AnnotationMethodPoint(Tx.class);return new ComposablePointcut(cpc).union(mpc);}@Overridepublic Pointcut getPointcut() {return pointcut;}@Overridepublic Advice getAdvice() {return advice;}/*** In order to be compatible with the spring lower than 5.0*/private static class AnnotationMethodPoint implements Pointcut {private final Class<? extends Annotation> annotationType;public AnnotationMethodPoint(Class<? extends Annotation> annotationType) {Assert.notNull(annotationType, "Annotation type must not be null");this.annotationType = annotationType;}@Overridepublic ClassFilter getClassFilter() {return ClassFilter.TRUE;}@Overridepublic MethodMatcher getMethodMatcher() {return new AnnotationMethodPoint.AnnotationMethodMatcher(annotationType);}private static class AnnotationMethodMatcher extends StaticMethodMatcher {private final Class<? extends Annotation> annotationType;public AnnotationMethodMatcher(Class<? extends Annotation> annotationType) {this.annotationType = annotationType;}@Overridepublic boolean matches(Method method, Class<?> targetClass) {if (matchesMethod(method)) {return true;}// Proxy classes never have annotations on their redeclared methods.if (Proxy.isProxyClass(targetClass)) {return false;}// The method may be on an interface, so let's check on the target class as well.Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass);return (specificMethod != method && matchesMethod(specificMethod));}private boolean matchesMethod(Method method) {return AnnotatedElementUtils.hasAnnotation(method, this.annotationType);}}}
}

定义完这些之后,我们现在是搭建好了一个全局事务的架子,还有关键的一点没有完成,那就是获取连接的时候,我们应该检查全局事务,如果存在,从连接工厂中获取连接。
下面改造获取连接的部分

package com.xzq.dynamic.core;@Data
public abstract class AbstractRoutingDataSource extends AbstractDataSource {private Map<String, DataSource> dataSourceMap = new ConcurrentHashMap<>();private String primaryKey;@Overridepublic Connection getConnection() throws SQLException {String xid = TransactionalContext.getXID();if (StringUtils.isEmpty(xid)) {return   determineDataSource().getConnection();}else{//进行连接代理String ds = DataSourceContextHolder.peek();ds = StringUtils.isEmpty(ds) ? "default" : ds;ConnectionProxy connection = ConnectionFactory.getConnection(ds);return connection == null ? getConnectionProxy(ds, determineDataSource().getConnection()) : connection;}}private Connection getConnectionProxy(String ds, Connection connection) {ConnectionProxy connectionProxy = new ConnectionProxy(connection, ds);ConnectionFactory.putConnection(ds, connectionProxy);return connectionProxy;}protected abstract DataSource determineDataSource();@Overridepublic Connection getConnection(String username, String password) throws SQLException {String xid = TransactionalContext.getXID();if (StringUtils.isEmpty(xid)) {return   determineDataSource().getConnection();}else{//进行连接代理String ds = DataSourceContextHolder.peek();ds = StringUtils.isEmpty(ds) ? "default" : ds;ConnectionProxy connection = ConnectionFactory.getConnection(ds);return connection == null ? getConnectionProxy(ds, determineDataSource().getConnection(username,password)) : connection;}}protected DataSource getDataSource(String dbKey) {if (!StringUtils.hasLength(dbKey)) {return dataSourceMap.get(primaryKey);}return dataSourceMap.get(dbKey);}
}

配置类配置TxAdvisor

    @Beanpublic TransactionalAdvisor transactionalAdvisor() {TxAdvice txAdvice = new TxAdvice();return new TransactionalAdvisor(txAdvice);}

到这里基本完成了我们的事务控制。

八,启动Logo

最后一步很简单给我们的工程添加启动Logo

package com.xzq.dynamic.logo;/*** the xzq logo.*/
@Order(LoggingApplicationListener.DEFAULT_ORDER + 1)
@Slf4j
public class DynamicLogo implements ApplicationListener<ApplicationStartedEvent> {private static final String LINE_SEPARATOR = System.getProperty("line.separator");private static final String VERSION = "1.0.1-SNAPSHOT";private static final String DYNAMIC_LOGO ="______                             _       ______      _        _____                          \n" +"|  _  \\                           (_)      |  _  \\    | |      /  ___|                         \n" +"| | | |_   _ _ __   __ _ _ __ ___  _  ___  | | | |__ _| |_ __ _\\ `--.  ___  _   _ _ __ ___ ___ \n" +"| | | | | | | '_ \\ / _` | '_ ` _ \\| |/ __| | | | / _` | __/ _` |`--. \\/ _ \\| | | | '__/ __/ _ \\\n" +"| |/ /| |_| | | | | (_| | | | | | | | (__  | |/ | (_| | || (_| /\\__/ | (_) | |_| | | | (_|  __/\n" +"|___/  \\__, |_| |_|\\__,_|_| |_| |_|_|\\___| |___/ \\__,_|\\__\\__,_\\____/ \\___/ \\__,_|_|  \\___\\___|\n" +"        __/ |                                                                                  \n" +"       |___/                                                                                   ";private final AtomicBoolean alreadyLog = new AtomicBoolean(false);private String buildBannerText() {return LINE_SEPARATOR+ LINE_SEPARATOR+ DYNAMIC_LOGO+ LINE_SEPARATOR+ " :: Xzq Dynamic DataSource API "+VERSION+" \n";}@Overridepublic void onApplicationEvent(ApplicationStartedEvent event) {if (!alreadyLog.compareAndSet(false, true)) {return;}log.info(buildBannerText());}
}

总结

这次一定一键三联!

手撸一个动态数据源的Starter 完整编写一个Starter及融合项目的过程 保姆级教程相关推荐

  1. 【手游】《少年三国志》完整修复全功能版-带GM后台和详细图文教程 亲测可编译运行

    [手游]<少年三国志>完整修复全功能版-带GM后台和详细图文教程 下载地址:http://www.51xyyx.com/3149.html 支持系统:WinXP/Win7/Win8  32 ...

  2. 保姆级教程!将 Vim 打造一个 IDE (Python 篇)

    从上周开始我就开始折腾 ,搞了一下 Vim IDE for Python & Go,我将整个搭建的过程整理成本篇文章分享出来,本篇是 Python 版本的保姆级教程,实际上我还写了 Go 版本 ...

  3. Java黑皮书课后题第7章:**7.34(对字符串中的字符排序)使用以下方法头编写一个方法,返回一个排序好的字符串。编写一个测试程序,提示用户输入一个字符串,显示排序好的字符串

    **7.34(对字符串中的字符排序)使用以下方法头编写一个方法,返回一个排序好的字符串.编写一个测试程序,提示用户输入一个字符串,显示排序好的字符串 题目 题目描述 破题 代码 运行实例 题目 题目描 ...

  4. Java黑皮书课后题第6章:6.12(显示字符)使用下面的方法头,编写一个打印字符的方法。编写一个测试程序、打印从‘1‘到‘Z‘的字符,每行打印10个,字符之间使用一个空格字符隔开

    6.12(显示字符)使用下面的方法头,编写一个打印字符的方法.编写一个测试程序.打印从'1'到'Z'的字符,每行打印10个,字符之间使用一个空格字符隔开 题目 题目描述 破题 补充:从生成随机字符窥探 ...

  5. Java黑皮书课后题第6章:*6.2(求一个整数各位数字之和)编写一个方法,计算一个整数各位数字之和。使用下面的方法头:public static int sumDigits(long n)

    6.2(求一个整数各位数字之和)编写一个方法,计算一个整数各位数字之和.使用下面的方法头:public static int sumDigits(long n) 题目 题目概述 槽点 代码 运行示例 ...

  6. SpringBoot整合多数据源,动态添加新数据源并切换(保姆级教程)

    前言 前段时间在项目的开发过程中,遇到了需要从数据库中动态查询新的数据源信息并切换到该数据源做相应的查询操作,翻阅了网上很多资料都是简单的对多数据源的整合,并没有涉及到动态添加新数据源并切换的案例,本 ...

  7. MMORPG传奇类手游《空空西游》完整源码(客户端cocos2d-js+服务端pomelo+cocosStudio工程+搭建教程)

    MMORPG传奇类手游<空空西游>完整源码,包括:客户端cocos2d-js+服务端pomelo+cocosStudio工程+搭建教程. 客户端:cocos2d-js 服务端:pomelo ...

  8. gif透明背景动画_iPad 手绘进阶指南!这份Procreate保姆级教程,自制动画只要 3 分钟...

    在之前的文章中,为大家介绍了<Procreate>这款神级绘画 App 的基础功能和一些简单的运用,即使是零基础小白,只要跟着动手就能画出自己喜欢的小贴纸小装饰.基础功能已经掌握,今天来进 ...

  9. 教你手写DMA传输数据(看完这篇你就会手动写啦,保姆级讲解)---- 2020.3.31

    关于DMA与串口原理方面的文章: 嵌入式stm32 复习(工作用)- USART(串口)通信原理知识 2020.3.23 添加链接描述 教你手写串口收发数据(看完这篇你就会手动写啦,保姆级讲解)--- ...

最新文章

  1. 如何优化UPS的工作模式为数据中心节省运营成本
  2. PAT_B_1060_Java(25分)
  3. 【LeetCode笔记】42. 接雨水(Java、动态规划)
  4. 参与 API 创新应用大赛,体验RDS费用管理 API
  5. 广义矩估计的一般步骤_【基本无害】动态理性预期理论与广义矩估计02
  6. js获取窗口宽度高度
  7. 谁都会做:简单易行的祛斑法 - 生活至上,美容至尚!
  8. rtrim()正确的理解啊
  9. mplab java失败_【超菜鸟求助】编译时失败,以下是显示内容。
  10. 链表初始化typedef struct LNode{}LNode,*linklist的理解
  11. PHP 使用GD库合成带二维码的海报步骤以及源码实现
  12. c语言链表next报错,C语言链表 - osc_w5x85e9u的个人空间 - OSCHINA - 中文开源技术交流社区...
  13. 方差、标准差、协方差概念与意义梳理
  14. 如何使用网页版Instagram来发布图片
  15. 使用AWS Comprehend进行情感分析
  16. 猿创征文|【算法入门必刷】数据结构-栈(二)
  17. 2021年中国家用咖啡研磨机市场趋势报告、技术动态创新及2027年市场预测
  18. 阿里云视觉智能API,核心技术一站共享!
  19. python 登陆网站图片验证,用python登录带弱图片验证码的网站
  20. python爬取阿里巴巴网站实现

热门文章

  1. DSLR-Quality Photos on Mobile Devices with Deep Convolutional Networks
  2. php怎么比较数组长度_PHP中如何获取数组的长度
  3. 一套让我成功拿下21k13薪offer的自动化测试常见面试题
  4. 【C++要笑着学】泛型编程 | 函数模板 | 函数模板实例化 | 类模板
  5. MRCTF-WP-wand1
  6. 深入折腾 Weex,知乎日报客户端开发
  7. css如何用ease in out,离开悬停状态CSS3时轻松一下(Ease out on when leaving hover state CSS3)...
  8. JavaScript进入网吧案例
  9. 【IoT 毕业设计】Ruff硬件+阿里云IoT+微信小程序构建环境监控系统
  10. matlab关于colorbar的整理(绘制不等间距colorbar, colorbar的大小位置调节, colorbar加单位等)