目录
3、我们获取到当前线程要执行sql的dao类的信息后放在ThreadLocal对象里面,等到选择路由的时候拿出来使用。
一、我们为什么要这么做?
- 在实际的高并发项目中,单库的压力非常大。这个时候需要引入数据库主从结构。(如果是分库分表或者是数据库集群,又另一说了)。
- 由于微服务没有拆分完全,或者压根就一个单应用,需要访问多个数据
二、我们该怎么做?
以前的做法可能是,在我们配置文件定义多个datasource,然后在dao中根据增、删、改、查去选择不同的datasource。这样做的话,可能就需要我们在代码里面硬编码选择数据源的过程,这样做显然不够友好,会产生很多冗余,重复的代码。
那有没有可以让我们的程序自动去选择路由数据源,而我代码中还是像以前那样,只关心业务逻辑,至于怎么选,选什么全都交给框架去实现。这样做的话,是不是瞬间感觉到代码清晰了很多了。那下面我们就来一步一步的实现看看。
三、用到的技术
- spring提供的 AbstractRoutingDataSource 类。该类就是在多数据源下,会根据determineCurrentLookupKey() 这个方法返回的路由key,在动态选择数据源
- spring AOP。需要在执行sql的方法前拦截请求,把该线程请求的方法,类名,参数等信息设置到线程局部变量(ThreadLocal)里面,然后determineCurrentLookupKey这个方法就可以根据线程的数据动态的选择数据源了。
- spring-boot-autoConfiguration。可以自动装载配置的bean。不需要再手工的去写配置文件
四、使用
1、首先定义一个拦截器,在我们数据库操作之前进行拦截
一下完整的所有代码都在我的github上,详见:https://github.com/terry2870/hp-springboot
定义 类 DAOMethodInterceptorHandle 实现 MethodInterceptor接口,完整代码如下
package com.hp.springboot.database.interceptor;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.hp.springboot.database.bean.DAOInterfaceInfoBean;
import com.hp.springboot.database.bean.DAOInterfaceInfoBean.DBDelayInfo;
import com.hp.springboot.threadprofile.profile.ThreadProfile;
/**
*
* 描述:执行数据库操作之前拦截请求,记录当前线程信息
* 之所以用抽象类,是因为可以扩展选择持久层框架。可以选择mybatis或jdbcTemplate,又或者hibernate
* 作者:黄平
* 时间:2018年4月11日
*/
public abstract class DAOMethodInterceptorHandle implements MethodInterceptor {
private static Logger log = LoggerFactory.getLogger(DAOMethodInterceptorHandle.class);
/**
* 存放当前执行线程的一些信息
*/
private static ThreadLocal<DAOInterfaceInfoBean> routeKey = new ThreadLocal<>();
/**
* 最大数据库查询时间(超过这个时间,就会打印一个告警日志)
*/
private static final long MAX_DB_DELAY_TIME = 10L;
/**
* 获取dao操作的对象,方法等
* @param invocation
* @return
*/
public abstract DAOInterfaceInfoBean getDAOInterfaceInfoBean(MethodInvocation invocation);
/**
* 获取当前线程的数据源路由的key
*/
public static DAOInterfaceInfoBean getRouteDAOInfo() {
return routeKey.get();
}
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
//获取dao的操作方法,参数等信息,并设置到线程变量里
this.setRouteDAOInfo(getDAOInterfaceInfoBean(invocation));
//设置进入查询,记录线程执行时长
entry();
Object obj = null;
try {
//执行实际方法
obj = invocation.proceed();
return obj;
} catch (Exception e) {
throw e;
} finally {
//退出查询
exit();
//避免内存溢出,释放当前线程的数据
this.removeRouteDAOInfo();
}
}
/**
* 进入查询
*/
private void entry() {
DAOInterfaceInfoBean bean = getRouteDAOInfo();
//加入到我们的线程调用堆栈里面,可以统计线程调用时间
ThreadProfile.enter(bean.getMapperNamespace(), bean.getStatementId());
DBDelayInfo delay = bean.new DBDelayInfo();
delay.setBeginTime(System.currentTimeMillis());
bean.setDelay(delay);
}
/**
* 结束查询
*/
private void exit() {
DAOInterfaceInfoBean bean = getRouteDAOInfo();
DBDelayInfo delay = bean.getDelay();
delay.setEndTime(System.currentTimeMillis());
ThreadProfile.exit();
//输出查询数据库的时间
if (delay.getEndTime() - delay.getBeginTime() >= MAX_DB_DELAY_TIME) {
log.warn("execute db expire time. {}", delay);
}
}
/**
* 绑定当前线程数据源路由的key 使用完成后必须调用removeRouteKey()方法删除
*/
private void setRouteDAOInfo(DAOInterfaceInfoBean key) {
routeKey.set(key);
}
/**
* 删除与当前线程绑定的数据源路由的key
*/
private void removeRouteDAOInfo() {
routeKey.remove();
}
}
这里通过getDAOInterfaceInfoBean这个方法获取当前线程调用的方法,参数,签名等一些信息。我这里提供了mybatis的实现。如下
/**
*
*/
package com.hp.springboot.mybatis.interceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.apache.commons.lang3.ArrayUtils;
import org.springframework.util.ClassUtils;
import com.hp.springboot.database.bean.DAOInterfaceInfoBean;
import com.hp.springboot.database.interceptor.DAOMethodInterceptorHandle;
/**
* @author huangping
* Jul 14, 2020
*/
public class MyBatisDAOMethodInterceptorHandle extends DAOMethodInterceptorHandle {
@Override
public DAOInterfaceInfoBean getDAOInterfaceInfoBean(MethodInvocation invocation) {
DAOInterfaceInfoBean bean = new DAOInterfaceInfoBean();
// 获取当前类的信息(由于我们使用的mybatis,这里获取到的是spring的代理类信息)
Class<?> clazz = invocation.getThis().getClass();
// 这里获取的才是我们定义的dao接口对象
Class<?>[] targetInterfaces = ClassUtils.getAllInterfacesForClass(clazz, clazz.getClassLoader());
// 获取该类的父类(该操作暂时没用使用到)
Class<?>[] parentClass = targetInterfaces[0].getInterfaces();
if (ArrayUtils.isNotEmpty(parentClass)) {
bean.setParentClassName(parentClass[0]);
}
// 设置类名信息
bean.setClassName(targetInterfaces[0]);
// 设置方法的类信息
bean.setMapperNamespace(targetInterfaces[0].getName());
// 设置方法名
bean.setStatementId(invocation.getMethod().getName());
// 设置方法参数
bean.setParameters(invocation.getArguments());
return bean;
}
}
2、然后在xml配置文件里面定义切面
这样,拦截器就生效了
3、我们获取到当前线程要执行sql的dao类的信息后放在ThreadLocal对象里面,等到选择路由的时候拿出来使用。
好,我们定义一个自动路由,执行步骤如下
- 服务启动时,加载数据库配置信息,读取主从数据库配置信息
- 所有数据源信息加载到 AbstractRoutingDataSource 的 targetDataSources中,以供后面动态选择
- 执行sql前,动态选择数据源路由
先选择是哪个数据库,再选择主从。动态选择路由步骤:
- 获取前面拦截器拦截的当前线程执行的方法信息
- 按照dao的className,从数据源中获取数据源(就是databases.yml里面有没有单独为该dao配置数据源)
- 如果有,则使用指定的数据源;如果没有,则使用默认(也就是第一个数据源)数据源
- 由于前面获取的只是数据源的前缀,下面我们还要获取真正的数据源对应的key
- 根据查询的方法名称,判断走读库,还是写库。(这里面会考虑方法前有没有加ForceMaster的注解)
- 返回最终真实的数据源的key,获取到真实的Datasource
对应代码如下:
/**
*
*/
package com.hp.springboot.database.datasource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.RandomUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import com.hp.springboot.database.bean.DAOInterfaceInfoBean;
import com.hp.springboot.database.bean.DatabaseConfigProperties;
import com.hp.springboot.database.bean.DatabaseConfigProperties.DatabaseConfig;
import com.hp.springboot.database.bean.DynamicDatasourceBean;
import com.hp.springboot.database.datasource.pool.AbstConnectionPoolFactory;
import com.hp.springboot.database.enums.ConnectionPoolFactoryEnum;
import com.hp.springboot.database.exception.DataSourceNotFoundException;
import com.hp.springboot.database.exception.DynamicDataSourceRouteException;
import com.hp.springboot.database.interceptor.DAOMethodInterceptorHandle;
import com.hp.springboot.database.interceptor.ForceMasterInterceptor;
/**
* 描述:动态路由选择数据源
* 作者:黄平
* 时间:2018年4月1日
*/
public class DynamicDatasource extends AbstractRoutingDataSource {
private static Logger log = LoggerFactory.getLogger(DynamicDatasource.class);
/**
* 存放所有的dao对应的数据源的key
* key=dao名称,value=databaseName
* 多数据源时,根据database.yml中的配置,先找有没有该dao指定的数据源,如果有,则使用指定的数据源,如果找不到,则使用第一个(也就是主数据源)数据源
*/
private static Map<String, String> databaseNameMap = new HashMap<>();
/**
* 存放所有的数据源主从的个数
* master_databaseName,10
* slave_databaseName,20
*/
private static Map<String, Integer> databaseIPCountMap = new HashMap<>();
/**
* 默认的数据源名称
*/
private static String DEFAULT_DATABASE_NAME = "";
/**
* master数据源名称的前缀
*/
private static final String MASTER_DS_KEY_PREX = "master_";
/**
* slave数据源名称的前缀
*/
private static final String SLAVE_DS_KEY_PREX = "slave_";
/**
* 匹配查询语句
*/
private static Pattern select = Pattern.compile("^select.*");
/**
* 匹配更新语句
*/
private static Pattern update = Pattern.compile("^update.*");
/**
* 匹配插入语句
*/
private static Pattern insert = Pattern.compile("^insert.*");
/**
* 匹配删除语句
*/
private static Pattern delete = Pattern.compile("^delete.*");
/**
* 数据库配置信息
*/
private DatabaseConfigProperties databaseConfigProperties;
public DynamicDatasource() {}
public DynamicDatasource(DatabaseConfigProperties databaseConfigProperties) {
this.databaseConfigProperties = databaseConfigProperties;
}
@Override
public void afterPropertiesSet() {
//设置targetDataSources 值
if (databaseConfigProperties == null || CollectionUtils.isEmpty(databaseConfigProperties.getDatabaseConfigList())) {
// 没有数据库配置信息,直接抛异常,启动失败
log.error("set DynamicDatasource error. with databaseConfigProperties is null.");
throw new DynamicDataSourceRouteException("DynamicDatasource route error. with databaseConfigProperties is null");
}
try {
Map<Object, Object> targetDataSources = new HashMap<>();
// 使用哪种类型的连接池(可以dbcp,Druid等等)
AbstConnectionPoolFactory connectionPool = ConnectionPoolFactoryEnum.getConnectionPoolFactory(databaseConfigProperties.getPoolName());
DynamicDatasourceBean dynamicDatasourceBean = null;
String databaseName = null;
// 循环遍历数据库配置信息
for (DatabaseConfig databaseConfig : databaseConfigProperties.getDatabaseConfigList()) {
if (databaseConfig.getServers() == null || CollectionUtils.isEmpty(databaseConfig.getServers().getMaster())) {
// 没有配置数据库ip信息或没有配置主库,直接抛异常,启动失败
log.error("init database error. with masterUrls is empty.");
throw new DynamicDataSourceRouteException("masterUrls is empty. with databaseConfig is: " + databaseConfig);
}
databaseName = databaseConfig.getDatabaseName();
dynamicDatasourceBean = connectionPool.getDynamicDatasource(databaseConfig);
if (dynamicDatasourceBean == null || CollectionUtils.isEmpty(dynamicDatasourceBean.getMasterDatasource())) {
log.error("init database error. with masterUrls is empty.");
throw new DynamicDataSourceRouteException("masterUrls is empty. with databaseConfig is: " + databaseConfig);
}
//设置master
for (int i = 0; i < dynamicDatasourceBean.getMasterDatasource().size(); i++) {
// 设置到自动路由的map中
targetDataSources.put(buildMasterDatasourceKey(databaseName, i), dynamicDatasourceBean.getMasterDatasource().get(i));
}
//设置master有几个数据源
databaseIPCountMap.put(buildMasterDatasourceKey(databaseName, -1), dynamicDatasourceBean.getMasterDatasource().size());
//设置slave
if (CollectionUtils.isNotEmpty(dynamicDatasourceBean.getSlaveDatasource())) {
for (int i = 0; i < dynamicDatasourceBean.getSlaveDatasource().size(); i++) {
targetDataSources.put(buildSlaveDatasourceKey(databaseName, i), dynamicDatasourceBean.getSlaveDatasource().get(i));
}
//设置slave有几个数据源
databaseIPCountMap.put(buildSlaveDatasourceKey(databaseName, -1), dynamicDatasourceBean.getSlaveDatasource().size());
}
//默认数据源
if (StringUtils.isEmpty(DEFAULT_DATABASE_NAME)) {
// databases.yml的节点 databaseConfigList 下的第一个数据源就是主数据源
DEFAULT_DATABASE_NAME = databaseName;
}
//处理dao(这里就是多数据源自动路由使用)
dealDAOS(databaseConfig.getDaos(), databaseName);
}
super.setTargetDataSources(targetDataSources);
super.afterPropertiesSet();
} catch (Exception e) {
log.error("deal DynamicDatasource error.", e);
}
}
@Override
protected Object determineCurrentLookupKey() {
// 获取当前线程的信息
DAOInterfaceInfoBean daoInfo = DAOMethodInterceptorHandle.getRouteDAOInfo();
if (daoInfo == null) {
//如果没有获取到拦截信息,则取主数据库
log.warn("determineCurrentLookupKey error. with daoInfo is empty.");
//return null;
// 由于上面没有设置defaultTargetDataSource,所以这里需要new一个对象出来空对象,下面会自动在主库中随机选择一个
daoInfo = new DAOInterfaceInfoBean();
}
//按照dao的className,从数据源中获取数据源
String mapperNamespace = daoInfo.getMapperNamespace();
String databaseName = databaseNameMap.get(mapperNamespace);
if (StringUtils.isEmpty(databaseName)) {
//如果没有,则使用默认数据源
databaseName = DEFAULT_DATABASE_NAME;
}
// 根据数据源的key前缀,获取真实的数据源的key
// 这里考虑了在代码里面的注解(ForceMaster)
String result = getDatasourceByKey(databaseName, getForceMaster(daoInfo));
log.debug("-------select route datasource with statementId={} and result is {}", (daoInfo.getMapperNamespace() + "." + daoInfo.getStatementId()), result);
return result;
}
/**
* @Title: getForceMaster
* @Description: 获取是否 forceMaster
* @param daoInfo
* @return
*/
private boolean getForceMaster(DAOInterfaceInfoBean daoInfo) {
if (ForceMasterInterceptor.getForceMaster()) {
//有设置forceMaster
return true;
}
return getMasterOrSlave(daoInfo);
}
/**
* @Title: getMasterOrSlave
* @Description: 根据方法名,判断走读库还是写库
* 这里约定我们的dao里面的方法命名
* 查询方法:selectXXX
* 新增方法:insertXXX
* 更新方法:insertXXX
* 删除方法:deleteXXX
* 如果不符合这个规范,则默认路由到master库
* @param daoInfo
* @return
*/
private boolean getMasterOrSlave(DAOInterfaceInfoBean daoInfo) {
//根据方法名称去判断
boolean fromMaster = false;
//获取用户执行的sql方法名
String statementId = daoInfo.getStatementId();
if (StringUtils.isEmpty(statementId)) {
//没有获取到方法,走master
return true;
}
statementId = statementId.toLowerCase();
if (select.matcher(statementId).matches()) {
// 如果是查询语句,这里,随机取主从
int i = RandomUtils.nextInt(0, 2);
fromMaster = BooleanUtils.toBoolean(i);
} else if (update.matcher(statementId).matches() || insert.matcher(statementId).matches() || delete.matcher(statementId).matches()) {
// 更新,插入,删除使用master数据源
fromMaster = true;
} else {
//如果statemenetId不符合规范,则告警,并且使用master数据源
log.warn("statement id {}.{} is invalid, should be start with select*/insert*/update*/delete*. ", daoInfo.getMapperNamespace(), daoInfo.getStatementId());
fromMaster = true;
}
return fromMaster;
}
/**
* @Title: getDatasourceByKey
* @Description: 随机获取路由
* @param databaseName
* @param fromMaster
* @return
*/
private String getDatasourceByKey(String databaseName, boolean fromMaster) {
String datasourceKey = null;
Integer num = null;
if (fromMaster) {
datasourceKey = buildMasterDatasourceKey(databaseName, -1);
num = databaseIPCountMap.get(datasourceKey);
if (num == null) {
//没找到,直接抛出异常
log.error("datasource not found with databaseName= {}", databaseName);
throw new DataSourceNotFoundException(databaseName);
}
} else {
datasourceKey = buildSlaveDatasourceKey(databaseName, -1);
num = databaseIPCountMap.get(datasourceKey);
if (num == null) {
//没有配置从库,则路由到主库
return getDatasourceByKey(databaseName, true);
}
}
int random = 0;
if (num == 1) {
//如果就只有一个数据源,则就选择它
random = 0;
} else {
//随机获取一个数据源
random = RandomUtils.nextInt(0, num);
}
return fromMaster ? buildMasterDatasourceKey(databaseName, random) : buildSlaveDatasourceKey(databaseName, random);
}
/**
* @Title: dealDAOS
* @Description: dao处理
* @param daoList
* @param databaseName
*/
private void dealDAOS(List<String> daoList, String databaseName) {
if (CollectionUtils.isEmpty(daoList)) {
return;
}
for (String dao : daoList) {
databaseNameMap.put(dao, databaseName);
}
}
/**
* @Title: buildMasterDatasourceKey
* @Description: 获取主数据源的key
* @param databaseName
* @param index
* @return
*/
private String buildMasterDatasourceKey(String databaseName, int index) {
StringBuilder sb = new StringBuilder(MASTER_DS_KEY_PREX).append(databaseName);
if (index >= 0) {
sb.append("_").append(index);
}
return sb.toString();
}
/**
* 获取从数据源的key
* @param databaseName
* @param index
* @return
*/
private String buildSlaveDatasourceKey(String databaseName, int index) {
StringBuilder sb = new StringBuilder(SLAVE_DS_KEY_PREX).append(databaseName);
if (index >= 0) {
sb.append("_").append(index);
}
return sb.toString();
}
}
这样,我们就根据方法名称,自动路由到对应的数据库中。我们的业务代码还是跟原来一样写,对我们业务代码几乎没有侵入性。
你以为这样就大功告成了吗?或许上面在大部分情况下都已经没有问题了。但是,敲黑板,敲黑板,我们还没有考虑到事务的情况
我们正常如果要加事务的时候,就会在方法前加上@Transactional注解。如下所示:
由于Transactional是加在方法上的,所以在进入这个test()方法之前,spring就要选择好数据源,但是我们DAOMethodInterceptorHandle拦截器拦截的是DAO的方法,dao的方法在service里面,所以这个时候你会发现,spring执行determineCurrentLookupKey()这个方法会获取不到数据源(当然,我这里面写了,默认返回了第一个数据源),因为我们还没有执行拦截器设置线程变量。所以,这个时候如果再执行sql,就会报错(准确的说,执行非默认数据库下的sql会报错)。
这个时候如果我们想使用非默认数据库下,而且又要有事务控制,这里就会报错。那问题来了,怎么解决呢?
我们正常使用自动路由的时候,是首先进入拦截器设置线程变量,再进入动态路由选择数据源。但是加了Transactional注解后,选择动态路由的步骤被强行提前到了service方法前了。所以这个时候是获取不到线程变量的。所以,在执行非默认数据库下,而且又要有事务控制,就会报错。
既然我们已经知道了执行逻辑和顺序,那问题就迎刃而解了。只要我们再加一个注解(跟Transactional平行),强制设置数据库。(不要妄想在一个事务下查询不同的数据库)
好,那我们加个注解 @UseDatabase
package com.hp.springboot.database.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 描述:强制使用数据库
* 作者:黄平
* 时间:2021-1-7
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(value = {ElementType.METHOD})
public @interface UseDatabase {
/**
* @Title: value
* @Description: 数据名称
* @return
*/
String value() ;
}
解析UseDatabase注解:
package com.hp.springboot.database.interceptor;
import java.lang.reflect.Method;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import com.hp.springboot.database.annotation.UseDatabase;
import com.hp.springboot.database.exception.DatabaseNotSetException;
/**
* 描述:强制使用数据
* 作者:黄平
* 时间:2021-1-7
*/
@Aspect
@Order(Ordered.HIGHEST_PRECEDENCE)
public class UseDatabaseInterceptor {
private static Logger log = LoggerFactory.getLogger(UseDatabaseInterceptor.class);
private static final ThreadLocal<String> USE_DATABASE = new InheritableThreadLocal<>();
/**
* @Title: around
* @Description: 设置强制走的数据库
* @param joinPoint
* @return
* @throws Throwable
*/
@Around("@annotation(com.hp.springboot.database.annotation.UseDatabase)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
log.debug("start before");
//方法签名
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
// 获取UseDatabase注解
UseDatabase useDatabase = method.getAnnotation(UseDatabase.class);
// 设置的数据库名称
String databaseName = useDatabase.value();
if (StringUtils.isEmpty(databaseName)) {
// 没有指定数据库名称,报错
log.error("UseDatabase error. with databaseName is empty");
throw new DatabaseNotSetException();
}
// 设置到线程变量中
USE_DATABASE.set(databaseName);
Object obj = null;
try {
obj = joinPoint.proceed();
return obj;
} catch (Exception e) {
throw e;
} finally {
log.debug("start after");
USE_DATABASE.remove();
}
}
/**
* @Title: getForceMaster
* @Description: 获取设置的哪个数据库
* @return
*/
public static String getDatabaseName() {
return USE_DATABASE.get();
}
}
该拦截器类来解析我们加了UseDatabase注解的方法,并且把数据库名称设置到线程变量里面
注意这里加了
加了Order注解,并且设置注解顺序为最高优先级,来设置该拦截器执行顺序。不然这个拦截器如果在Transactional拦截器后面执行那就杯具了。
好,那我们的自动路由的determineCurrentLookupKey方法要做修改了
当然,我们可以把这些包装在一个 springboot-starter里面,这样外面使用的话,直接maven依赖一下就可以了
ok大功告成!也解决事务问题。但是有一点要特别注意的,在@Transactional注解的方法内,不要去访问不同的数据库。
最后配上对应的databases.yml文件,该文件默认放在src/main/resources下面
hp:
springboot:
database:
expression: "execution(* com.test.dal..*.*(..))"
poolName: DBCP
databaseConfigList:
- databaseName: test
servers:
master:
- ${database.test.master.url}
slave:
- 127.0.0.22:3307
- 127.0.0.33:3308
username: root
password: 123456
- databaseName: sys
servers:
master:
- 127.0.0.1:3306
slave:
- 127.0.0.2:3301
- 127.0.0.3:3302
username: root
password: 123456
daos:
- com.test.dal.ISysConfigDAO
完整的代码可以见我的github:https://github.com/terry2870/hp-springboot
第一次写博客,如果有写的不好的,或者建议,意见都欢迎大家给我留言!