Important 本文基于 spring-jdbc-6.1.13、mybatis-spring-boot-starter-3.0.3
本文主要是一些总结性的结论,不会大量展示源码,建议自己打断点边跟边看或者后面回顾的时候看。
首先来一个非常现实的问题,当 Mybatis 离开了 Spring,你还会用吗?
首先我们来看一下没有 Spring 该怎么用:
DataSource dataSource = BlogDataSourceFactory.getBlogDataSource();
TransactionFactory transactionFactory = new JdbcTransactionFactory();
Environment environment = new Environment("development", transactionFactory, dataSource);
Configuration configuration = new Configuration(environment);
configuration.addMapper(BlogMapper.class);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration);
try (SqlSession session = sqlSessionFactory.openSession(true)) {
BlogMapper blogMapper = session.getMapper(BlogMapper.class);
blogMapper.doSth();
// ...
}
java
有没有发现这里很违背我们的"常识"?在 Spring 中,我们都是直接注入 Mapper 然后直接就开始用了。而在这里,我们还需要自己开 SqlSession 来获取 Mapper。
接下来,本文将带你详细了解 Spring 是如何管理 Mybatis 资源的。
在 Mybatis 文档的 作用域(Scope)和生命周期 中提到: 每个线程都应该有它自己的 SqlSession 实例。每次收到 HTTP 请求,就可以打开一个 SqlSession,返回一个响应后,就关闭它。所以 SqlSession 是不能在多线程复用的, 而 Mapper 是由 SqlSession 创建的,也是不能复用的。
那么在 Spring (实际是 Mybatis-Spring)为什么可以直接复用呢?我们先来打个断点看一下我们的 Mapper 类:

通过上图可以发现,Mapper 中的用的 SqlSession 是 SqlSessionTemplate,而它的内部又代理了另外一个 SqlSession,这里就可以肯定它用了 代理模式,我们来看一下它的代理是怎么处理的:
private class SqlSessionInterceptor implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 获取 sqlSession
SqlSession sqlSession = getSqlSession(SqlSessionTemplate.this.sqlSessionFactory,
SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator);
try {
// 调用**真正的** SqlSession 的方法
Object result = method.invoke(sqlSession, args);
// 判断是否开启了事务
if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
// force commit even on non-dirty sessions because some databases require
// a commit/rollback before calling close()
// 没开,就直接提交
sqlSession.commit(true);
}
return result;
} catch (Throwable t) {
Throwable unwrapped = unwrapThrowable(t);
if (SqlSessionTemplate.this.exceptionTranslator != null && unwrapped instanceof PersistenceException) {
// release the connection to avoid a deadlock if the translator is no loaded. See issue #22
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
sqlSession = null;
Throwable translated = SqlSessionTemplate.this.exceptionTranslator
.translateExceptionIfPossible((PersistenceException) unwrapped);
if (translated != null) {
unwrapped = translated;
}
}
throw unwrapped;
} finally {
// 关闭
if (sqlSession != null) {
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
}
}
}
}
java
这里方法第一行 getSqlSession,这里就不进去看了,这个方法的作用是创建一个真正的 SqlSession,并且如果开启了事务,就把这个 SqlSession 绑定到线程变量里面。
好!这里就很清楚了:我 Mapper 用的 SqlSession 是一个假的(代理类) SqlSession,里面没有任何真正的数据库连接,当你真的去执行数据库操作需要创建连接的时候,我就给你创建一个。你要是开了事务,我还会给你把连接绑定到线程变量里面,你要是下次又要获取连接,我就直接从线程变量里面拿;要是没开事务,用完我就给你自动提交并且关了。
那么绑到线程变量里面的事务又由谁来关闭呢?这里最合适的做法就是:事务在哪里开启,你就在哪里关闭。
什么意思?例如我们一般就是在服务类中调用 Mapper,当方法上加上 @Transactional 注解就可以开启事务了。OK! 你要是用了这个注解,我就在执行方法前,也往线程变量里面写一个标识,表示我开启了一个事务。后面在假的 SqlSession 里面,我去查这个线程变量标识来判断你开了事务没有。同理,我在这里可以往线程变量里面绑,我也可以在方法结束后获取创建的连接,然后直接在这里关闭。
总结一下就是:
@Transactional, 就往线程变量里面写一个标识,这里我们叫 T。写完标识后,正常调用入口方法。Mapper 里面的方法时,里面的假 SqlSession 会判断线程变量有没有标识 T,如果有,返回之前在线程变量中缓存的、真正的 SqlSession,这里我们叫 S,如果 S 中没有数据,就创建一个新的连接并且保存到 S 中。如果没有标识 T,即没有开启事务,就创建一个新的数据库连接,并且在用完后关闭。S 是否有值,如果有,就将其提交并关闭。现在你已经知道 Mybatis-Spring 的基础原理了,现在你可以:
如果你选择自己看源码,这里给你两个入口:
SqlSessionTemplateorg.springframework.transaction.interceptor.TransactionAspectSupport,这个类就是我们前面提到的"入口类"的动态代理。Important 再次声明,源码强烈推荐自己打断点看,本文后面的东西大部分只提供总结性的内容!。 到目前为止,我真的很少见到有人能把枯燥的源码详解写的很有意思的,因为这种很难避免贴上大部分源码上去,所以我强烈推荐自己打断点, 然后写下自己的总结方便未来复习!所以我这篇文章后半部分就全是我的总结。
没错,我是个标题党,标题写了 详解 就是为了把你骗进来...
在继续看前,需要了解一个非常重要的类:TransactionSynchronizationManager:
public abstract class TransactionSynchronizationManager {
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
new NamedThreadLocal<>("Transaction synchronizations");
private static final ThreadLocal<String> currentTransactionName =
new NamedThreadLocal<>("Current transaction name");
private static final ThreadLocal<Boolean> currentTransactionReadOnly =
new NamedThreadLocal<>("Current transaction read-only status");
private static final ThreadLocal<Integer> currentTransactionIsolationLevel =
new NamedThreadLocal<>("Current transaction isolation level");
private static final ThreadLocal<Boolean> actualTransactionActive =
new NamedThreadLocal<>("Actual transaction active");
// snip
}
java
这个类就是用于管理我们线程变量的绑定的:
resources: 保存相关的资源synchronizations: 保存事务同步的回调,例如 beforeCommit、afterCompletion 等currentTransactionName:保存事务的名称currentTransactionReadOnly:标识当前事务是否只读currentTransactionIsolationLevel: 当前事务隔离级别actualTransactionActive: 当前线程是否真的开启了事务例如调用 org.springframework.transaction.support.TransactionSynchronizationManager#isActualTransactionActive 就可以判断当前线程是否开启了事务。
关于 resources 这里需要再讲一下,还是在这个类中,我们可以看到相关的使用方法: public static Object getResource(Object key)。这个方法的入参和出参都是 Object,那么怎么知道什么类型的 key 对应什么类型的资源呢?难道靠死记硬背硬编码吗?
这里我也是看了很久才突然领悟到,这里并不是死记硬背或者硬编码,这里我们举几个调用该方法的例子看一下:
(SqlSessionHolder) TransactionSynchronizationManager.getResource((SqlSessionFactory)sessionFactory)(ConnectionHolder) TransactionSynchronizationManager.getResource((DataSource)this.obtainDataSource());可以发现,我们的入参都是类似于连接池的结构,而出参都是这些 "连接池" 对应的资源,例如 SqlSessionFactory 的资源是 SqlSession,由于还需要存储一些额外信息,所以 spring 使用 SqlSessionHolder 将其包裹起来了,对于 DataSource 也是同样的效果。
在第一次尝试获取 SqlSession 时,就会尝试绑定相关的 ThreadLocal。
org.mybatis.spring.SqlSessionTemplate.SqlSessionInterceptor#invoke -> org.mybatis.spring.SqlSessionUtils#getSqlSession -> org.mybatis.spring.SqlSessionUtils#registerSessionHolder(部分无关紧要的代码被省略):
private static void registerSessionHolder(SqlSessionFactory sessionFactory, ExecutorType executorType,
PersistenceExceptionTranslator exceptionTranslator, SqlSession session) {
SqlSessionHolder holder;
// 判断是否需要开启事务.
if (TransactionSynchronizationManager.isSynchronizationActive()) {
Environment environment = sessionFactory.getConfiguration().getEnvironment();
if (environment.getTransactionFactory() instanceof SpringManagedTransactionFactory) {
LOGGER.debug(() -> "Registering transaction synchronization for SqlSession [" + session + "]");
holder = new SqlSessionHolder(session, executorType, exceptionTranslator);
// 绑定 resources 到 ThreadLocal
TransactionSynchronizationManager.bindResource(sessionFactory, holder);
// 绑定 synchronizations
TransactionSynchronizationManager
.registerSynchronization(new SqlSessionSynchronization(holder, sessionFactory));
// 标识当前事务已经绑定了 synchronizations
holder.setSynchronizedWithTransaction(true);
// 引用计数加一
holder.requested();
} else {
// 判断当前是不是使用 SpringManagedTransactionFactory 来管理事务,如果不是则直接报错
}
} else {
// 啥都不做
}
}
java
至此,事务就成功开启了。后面的 Mapper 想要获取 SqlSession 就可以直接复用了:
// org.mybatis.spring.SqlSessionUtils#getSqlSession
public static SqlSession getSqlSession(SqlSessionFactory sessionFactory, ExecutorType executorType,
PersistenceExceptionTranslator exceptionTranslator) {
notNull(sessionFactory, NO_SQL_SESSION_FACTORY_SPECIFIED);
notNull(executorType, NO_EXECUTOR_TYPE_SPECIFIED);
// 获取绑定的资源
SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory);
// 获取对应的 SqlSession,并将引用计数加一
SqlSession session = sessionHolder(executorType, holder);
if (session != null) {
return session;
}
// 线程还没有绑定,创建新的并判断是否需要绑定,也就是我们上面看到的代码
LOGGER.debug(() -> "Creating a new SqlSession");
session = sessionFactory.openSession(executorType);
registerSessionHolder(sessionFactory, executorType, exceptionTranslator, session);
return session;
}
java
在自动提交的情况下,SqlSession 用完就会被释放,在 org.mybatis.spring.SqlSessionTemplate.SqlSessionInterceptor#invoke 中的 finally 代码块就可以看到,最后调用了 org.mybatis.spring.SqlSessionUtils#closeSqlSession 来释放 SqlSession:
public static void closeSqlSession(SqlSession session, SqlSessionFactory sessionFactory) {
notNull(session, NO_SQL_SESSION_SPECIFIED);
notNull(sessionFactory, NO_SQL_SESSION_FACTORY_SPECIFIED);
SqlSessionHolder holder = (SqlSessionHolder) TransactionSynchronizationManager.getResource(sessionFactory);
if ((holder != null) && (holder.getSqlSession() == session)) {
LOGGER.debug(() -> "Releasing transactional SqlSession [" + session + "]");
// 引用计数减一
holder.released();
} else {
LOGGER.debug(() -> "Closing non transactional SqlSession [" + session + "]");
// 自动提交,直接关闭
session.close();
}
}
java
可以发现在开启事务的情况下,这里仅仅是将引用计数减一,那么真正的关闭在哪呢?
其实也不难猜到,我们在哪个方法上的 @Transactional,这个方法结束后肯定就会去关闭 SqlSession。这里是通过动态代理实现的,具体的类为:org.springframework.transaction.interceptor.TransactionAspectSupport,直接从 invokeWithinTransaction 开始看就可以了。
可以在里面找到 commitTransactionAfterReturning 这个方法,很显然,这个方法就是用来提交并关闭连接的:
protected void commitTransactionAfterReturning(@Nullable TransactionInfo txInfo) {
if (txInfo != null && txInfo.getTransactionStatus() != null) {
if (logger.isTraceEnabled()) {
logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() + "]");
}
txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
}
java
可以发现,如果没有开启事务,则不会做任何事。否则将会获取 TransactionManager 然后调用 commit 方法。
点进去继续追 org.springframework.transaction.support.AbstractPlatformTransactionManager#commit -> org.springframework.transaction.support.AbstractPlatformTransactionManager#processCommit(这里方法很长,只保留部分代码):
private void processCommit(DefaultTransactionStatus status) throws TransactionException {
try {
boolean beforeCompletionInvoked = false;
boolean commitListenerInvoked = false;
try {
boolean unexpectedRollback = false;
prepareForCommit(status);
triggerBeforeCommit(status);
triggerBeforeCompletion(status);
beforeCompletionInvoked = true;
if (status.hasSavepoint()) {
// snip
}
else if (status.isNewTransaction()) {
if (status.isDebug()) {
logger.debug("Initiating transaction commit");
}
unexpectedRollback = status.isGlobalRollbackOnly();
this.transactionExecutionListeners.forEach(listener -> listener.beforeCommit(status));
commitListenerInvoked = true;
doCommit(status);
}
else if (isFailEarlyOnGlobalRollbackOnly()) {
unexpectedRollback = status.isGlobalRollbackOnly();
}
// Throw UnexpectedRollbackException if we have a global rollback-only
// marker but still didn't get a corresponding exception from commit.
if (unexpectedRollback) {
throw new UnexpectedRollbackException(
"Transaction silently rolled back because it has been marked as rollback-only");
}
}
catch (UnexpectedRollbackException ex) {
// snip
}
catch (TransactionException ex) {
// snip
}
catch (RuntimeException | Error ex) {
// snip
}
// Trigger afterCommit callbacks, with an exception thrown there
// propagated to callers but the transaction still considered as committed.
try {
triggerAfterCommit(status);
}
finally {
triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED);
if (commitListenerInvoked) {
this.transactionExecutionListeners.forEach(listener -> listener.afterCommit(status, null));
}
}
}
finally {
cleanupAfterCompletion(status);
}
}
java
正常情况下(没有异常、事务正常提交),主要是这样的流程(TransactionManager 的实现类默认是 JdbcTransactionManager):
prepareForCommit: 在提交前进行一些准备工作。triggerBeforeCommit: 调用同步器(TransactionSynchronization)的 beforeCommit 方法triggerBeforeCompletion: 调用同步器(TransactionSynchronization)的 beforeCompletion 方法doCommit: 获取到 Connection 并提交triggerAfterCommit: 调用同步器(TransactionSynchronization)的 afterCommit 方法triggerAfterCompletion: 调用同步器(TransactionSynchronization)的 afterCompletion 方法cleanupAfterCompletion: 清除绑定的 ThreadLocal,恢复 Connection 的状态等(设置自动提交、隔离级别等),如果有被挂起的事务,则恢复对应的事务。在之前我们可以看到,默认的同步器是 SqlSessionSynchronization。而在 org.mybatis.spring.SqlSessionUtils.SqlSessionSynchronization#beforeCommit 中我们可以发现,这里又调用了 SqlSession 的 commit 方法,所以这个事务一共提交了两次 !?

没错,第一次看到这里我确实被迷惑住了。但是其实上面的注释已经说的很清楚了,吃了不懂英语的坑😢。
这段大致意思如下:
Connection的 提交 或者 回滚 将会被ConnectionSynchronization或DataSourceTransactionManager处理。但是,请清理
SqlSession/Executor,包括 批处理 操作,以确保它们实际被执行过。
SpringManagedTransaction不会真的在 jdbc 连接的层面上 提交 。
还是不太诗人话,简单来说就是你在同步器里只需要确保刷新 SqlSession、 Executor 和批处理操作就行了,提交的事情不用你管。
那么实际是怎么样的呢,在继续前我们需要再回到没有 Spring 的 mybatis。
还记得前面我们不使用 Spring 来配置 mybatis 的时候吗?我们需要手动配置一个 TransactionFactory,在文档中直接使用了 JdbcTransactionFactory,我们来看看它的实现。
既然是工厂类,就只需要关注它返回的类型了,这里它返回的是 JdbcTransaction,我们接着看。
这里主要关注它的 commit 方法:
@Override
public void commit() throws SQLException {
if (connection != null && !connection.getAutoCommit()) {
if (log.isDebugEnabled()) {
log.debug("Committing JDBC Connection [" + connection + "]");
}
connection.commit();
}
}
java
可以发现它的 commit 方法是真的直接调用 jdbc 连接提交了。还记得我们之前翻译的吗,在 Spring 里面:SpringManagedTransaction不会真的在 jdbc 连接的层面上 *提交* 。 我们来看 Spring 实现里的commit`:
@Override
public void commit() throws SQLException {
if (this.connection != null && !this.isConnectionTransactional && !this.autoCommit) {
LOGGER.debug(() -> "Committing JDBC Connection [" + this.connection + "]");
this.connection.commit();
}
}
java
可以发现它加了一个最关键的判断:!this.isConnectionTransactional。那么可以说明,如果当前开启了事务,那么调用 SqlSession#commit 就不会真正的提交。
Note 这里省略了部分上下文。
SqlSession#commit会调用Executor#commit(org.apache.ibatis.executor.BaseExecutor#commit),最终会调用org.apache.ibatis.transaction.Transaction#commit
所以在这里调用 SqlSession 的 commit,只是为了清除缓存而已,并没有真正提交的意思。
在 Spring 中可以设置事务的传播级别(TransactionDefinition):
PROPAGATION_REQUIRED(默认): 如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。PROPAGATION_REQUIRES_NEW: 创建一个新的事务,如果当前存在事务,则把当前事务挂起。PROPAGATION_NESTED:创建一个子事务,如果子事务回滚,对应的父事务也会回滚(如果有)。PROPAGATION_MANDATORY:如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。那么事务在创建时,是如何根据不同的传播级别来创建事务的呢?
在 org.springframework.transaction.interceptor.TransactionAspectSupport#createTransactionIfNecessary -> org.springframework.transaction.support.AbstractPlatformTransactionManager#getTransaction 就可以找到相关开启事务的代码。
这里代码很长,但不管怎么样,最终都是返回一个 TransactionStatus,默认的实现是 org.springframework.transaction.support.DefaultTransactionStatus:
public class DefaultTransactionStatus extends AbstractTransactionStatus {
@Nullable
private final String transactionName;
@Nullable
private final Object transaction;
private final boolean newTransaction;
private final boolean newSynchronization;
private final boolean nested;
private final boolean readOnly;
private final boolean debug;
@Nullable
private final Object suspendedResources;
// snip
}
java
这些字段的意思分别是:
transactionName: 事务的名称,一般是被代理的方法全限定名称,例如 foo.bar.TestService.doService。transaction: 保存了 org.springframework.transaction.support.AbstractPlatformTransactionManager#doGetTransaction 的返回值,默认类型为 org.springframework.jdbc.datasource.DataSourceTransactionManager.DataSourceTransactionObject。newTransaction: 是不是一个新的事务。例如多个 Transactional 方法嵌套,第一个开启事务的方法该值为 true,其它的均为 false。newSynchronization: 当前事务是否创建了一个新的事务同步器。nested: 是否嵌套(子事务)。readOnly: 是否只读。debug: 用于 debug。suspendedResources: 被挂起的资源,例如在 PROPAGATION_REQUIRES_NEW 的传播级别下,会创建一个新的事务,旧的事务将会在这里被挂起。关于 newSynchronization 这个属性,一开始可能还挺懵逼的。如果不想管太多,只需要记住该值为 true 时,后面的事务获取 SqlSession 时才会创建相关同步器。
Note 当该值为
true时org.springframework.transaction.support.AbstractPlatformTransactionManager#prepareSynchronization将会绑定相关线程变量,后面线程在获取SqlSession时,org.mybatis.spring.SqlSessionUtils#registerSessionHolder会判断当前是否激活同步器,然后再去绑定相关资源。
那么这个值具体有什么用呢?这里就不得不再回到我们前面的传播级别了。例如 PROPAGATION_NOT_SUPPORTED 级别,它会挂起已有的事务,并且以非事务的状态继续执行,所以这里很明显就不需要绑定同步器。
newSynchronization 的值由 org.springframework.transaction.support.AbstractPlatformTransactionManager#transactionSynchronization 的值决定,而这个值默认为 SYNCHRONIZATION_ALWAYS。也就是永远都会创建同步器,即使没有开启事务。
一共有三个可用的值:
SYNCHRONIZATION_ALWAYS(默认): 永远创建同步器,即使没有事务。SYNCHRONIZATION_ON_ACTUAL_TRANSACTION:仅在有事务的情况下创建。SYNCHRONIZATION_NEVER:永远不创建。在 Spring 是可以嵌套事务的,例如我们使用 REQUIRES_NEW 的传播级别,就能够在已有事务的前提下开启一个完全隔离的新事务。那么旧的事务在这里会怎么处理呢?
眼见的大伙已经可以看到了,在 org.springframework.transaction.support.AbstractPlatformTransactionManager#getTransaction 中,有一个判断是否已经存在事务的代码:
@Override
public final TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException {
// snip
if (isExistingTransaction(transaction)) {
// Existing transaction found -> check propagation behavior to find out how to behave.
return handleExistingTransaction(def, transaction, debugEnabled);
}
// snip
}
java
具体的代码就不细看了,大致流程如下:
suspend 方法(org.springframework.transaction.support.AbstractPlatformTransactionManager#suspend)。org.springframework.transaction.support.AbstractPlatformTransactionManager#suspend)。SuspendedResourcesHolder),开启新的事务,并将新事务 status#suspendedResources 设置为旧的 Holder。事务恢复的代码在 org.springframework.transaction.support.AbstractPlatformTransactionManager#cleanupAfterCompletion 里面的最后一段,它会调用 resume 方法来恢复事务:
protected final void resume(@Nullable Object transaction, @Nullable SuspendedResourcesHolder resourcesHolder)
throws TransactionException {
if (resourcesHolder != null) {
Object suspendedResources = resourcesHolder.suspendedResources;
if (suspendedResources != null) {
doResume(transaction, suspendedResources);
}
List<TransactionSynchronization> suspendedSynchronizations = resourcesHolder.suspendedSynchronizations;
if (suspendedSynchronizations != null) {
TransactionSynchronizationManager.setActualTransactionActive(resourcesHolder.wasActive);
TransactionSynchronizationManager.setCurrentTransactionIsolationLevel(resourcesHolder.isolationLevel);
TransactionSynchronizationManager.setCurrentTransactionReadOnly(resourcesHolder.readOnly);
TransactionSynchronizationManager.setCurrentTransactionName(resourcesHolder.name);
doResumeSynchronization(suspendedSynchronizations);
}
}
}
java
代码很简单,其实就是调用同步器的 resume, 然后再重新绑定相关的线程变量。
下面就直接贴代码了:
@Component
class Test {
@Autowired
private PlatformTransactionManager txManager;
public void test() {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
TransactionStatus status = txManager.getTransaction(def);
try {
// ...
txManager.commit(status);
} catch (Exception e) {
txManager.rollback(status);
}
}
}
java
在非 Spring 环境中用 mybatis 一定会碰到这个问题:怎么修改 Mapper 的路径?
在 Spring 中可以通过下面的配置实现:
mybatis:
mapper-locations: classpath:mapper/*.xml
yaml
但是很遗憾,Mybatis 本身是没有提供任何配置项来修改 xml 存放位置的。也就是说默认情况下,你只能把 XML 和 接口 放到一个文件夹里面。
这里我们来看一下 Spring 是怎么做到自定义 mapper 位置的(org.mybatis.spring.SqlSessionFactoryBean#buildSqlSessionFactory):
Configuration targetConfiguration;
// ...
for (Resource mapperLocation : this.mapperLocations) {
// ...
XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(mapperLocation.getInputStream(),
targetConfiguration, mapperLocation.toString(), targetConfiguration.getSqlFragments());
xmlMapperBuilder.parse();
}
java
这里只需要获取到 xml 的输入流,就可以自定义解析 Mapper了。
由于我之前想开发一个 IDEA 插件,需要用到 sqlite, 所以上了 Mybatis 并且顺便照着 Mybatis-Spring 封装了一遍。虽然插件我已经弃坑了,但是这份代码封装的还是没有问题的。
最终封装的效果是:支持声明式主键,但是不支持传播级别等其它高级特性。
直接看代码(kotlin 写的,但没有用很高级的特性,不会的话也能看懂):Github
推荐从下面的类开始看:
Ansible 不支持在 windows 上作为控制节点使用,虽然可以安装,但是运行不了: Why no Ansible controller for Windows?。
但是 windows 可以作为被控制的节点来使用。
创建虚拟环境并安装:
python3 -m venv ansible source ansible/bin/activate python3 -m pip install ansible-coreshell
如果是 python3.6 最高只能装 2.11 更高的版本需要升级 python 版本。
ansible 中有下面几种常用的特殊名词:
Task 组成。其中每个 Play 都会指定 Inventory 中的一组服务器。Play 类似,但是在声明时不需要指定 Inventory,所以一般不会直接写 Play,而是直接使用 Role 来编写,方便多次复用。可以这样理解: 一个 Play 代表已经编辑好的一部电影。通过指定好角色(Inventory)和剧情(Task),构成一部电影(Play)。
所以通常一个 Play 文件包含了 Inventory 和 Task。由于 Inventory 在这里直接写死了,一般会直接使用 Role 来代替 Play,在实际运行的时候指定对应的 Inventory。
创建一个 Inventory(inventory.ini):
[myhosts] 192.0.2.50 192.0.2.51 192.0.2.52ini
测试连接:
# verify
ansible-inventory -i inventory.ini --list
# ping
ansible myhosts -m ping -i inventory.ini
bash
一个常用的目录结构如下:
. ├── env ├── inventory └── project └── roles └── my_role ├── handlers ├── tasks ├── templates └── varstext
想要创建一个 filter,首先在任意目录中创建一个 python 文件:
def greet(name):
return f"Hello, {name}!"
class FilterModule(object):
def filters(self):
return {
'greet': greet,
}
python
上面的代码就实现了一个 filter,然后使用环境变量来指向对应的目录:
export ANSIBLE_FILTER_PLUGINS=/path/to/custom/filter_plugins
bash
使用:
# playbook.yml
---
- hosts: localhost
tasks:
- name: Use global custom greet filter
debug:
msg: "{{ 'World' | greet }}"
yaml
输出:
TASK [Use global custom greet filter] ********************************* ok: [localhost] => { "msg": "Hello, World!" }text
注意,这么调用是错误的:
- name: Debug debug: msg: "{{ greet('World') }}"text
必须使用前一种类似管道符的语法。
上面的代码中,我们使用 filter 传递了一个参数进去,然后返回一个值。但是如果要传递多个参数该怎么办?
解决方法如下:
# filter_plugins/custom_filters.py
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
class FilterModule(object):
def filters(self):
return {
'greet': greet,
}
python
使用:
# playbook.yml
- hosts: localhost
tasks:
- name: Use custom greet filter with multiple arguments
debug:
msg: "{{ 'World' | greet('Good morning') }}"
yaml
巨奇怪有木有...
在前面我们说过可以通过 ansible-runner 来提前获取好参数来提供给 ansible 使用,但是 ansible 自己也可以主动通过调用 Python 脚本来动态获取外部参数。
和 filter 插件一样,创建一个 Python 文件:
# lookup_plugins/my_custom_lookup.py
from ansible.plugins.lookup import LookupBase
class LookupModule(LookupBase):
def run(self, terms, variables=None, **kwargs):
# Custom logic here
return [f"Hello, {terms[0]}!"]
python
然后使用环境变量指向这个目录:
export ANSIBLE_LOOKUP_PLUGINS=/path/to/custom/filter_plugins
bash
使用:
# playbook.yml
- hosts: localhost
tasks:
- name: Use custom lookup plugin
debug:
msg: "{{ lookup('my_custom_lookup', 'World') }}"
yaml
输出:
TASK [Use custom lookup plugin] ************************************* ok: [localhost] => { "msg": "Hello, World!" }text
这里文档非常🌿🥚,完全没讲每个参数是什么意思,这里就详细记一下,防止以后忘了。
terms 代表在使用 lookup 时后面的列表参数。
使用时这样传:
# In a playbook or template
{{ lookup('my_custom_lookup', 'argument1', 'argument2') }}
yaml
terms 就是 ['argument1', 'argument2']。
这个很好理解,就是可以获取到上下文中的参数:
# In the lookup plugin
def run(self, terms, variables=None, **kwargs):
# 获取上下文中的 my_var 参数
value_from_var = variables.get('my_var')
return [f"{value_from_var}, {terms[0]}"]
python
这个可以理解为具名参数,类型是一个字典:
# In a playbook or template
{{ lookup('my_custom_lookup', 'term', option1='value1', option2='value2') }}
yaml
对于 option1 和 option2 就可以直接在 kwargs 通过字典的方式获取到。
例如在上面一个 role 的目录中,我们有一个 templates 模板,一般这个文件夹里面放的都是配置文件,如果我们想要一口气全部发送到远程服务器里面, 除了可以一个一个写,还可以这样写:
- name: Transfer Template
with_fileglob:
- "templates/*.j2"
ansible.builtin.template:
src: "{{ item }}"
dest: "/dest/{{ item | template_glob_path_to_dest }}"
yaml
这里需要声明一个 filter 来去掉多余的路径:
def template_glob_path_to_dest(string: str):
target = 'templates/'
pos = string.rfind(target)
if pos == -1:
raise RuntimeError('Could not find template relative path')
return string[pos + len(target):-3]
class FilterModule(object):
def filters(self):
return {
'template_glob_path_to_dest': template_glob_path_to_dest
}
python
在 task 中注入参数需要使用 set_fact,而不是 vars:
- name: My play
hosts: localhost
tasks:
- name: Ping my hosts
set_fact:
who: world
- name: Print message
debug:
msg: "hello {{ who }}"
yaml
对于 vars 声明的参数,仅在当前任务中有效。
一般在多个 role 中,可能会出现通用的逻辑,例如多个 Tomcat 应用,每个应用都需要单独的 Tomcat 目录,如果每个服务都写一遍会导致十分臃肿,所以我们完全可以将通用的 role 抽离出来,供其它的 role 使用。
假设我们已经有了一个安装 Tomcat 的 role:roles/common/tasks/main.yaml, 详细代码见 安装 tomcat。
假设我们有服务 A 和 B 都需要安装 Tomcat,分别编辑 roles/A/meta/main.yaml 和 roles/B/meta/main.yaml:
dependencies:
- { role: common, service_root: "{{ Values.metadata.rootPath }}/xxx" }
yaml
上面的内容两个应用需要指定不同的 service_root 参数,否则对应的 role 只会执行一遍。
common具体的代码可以看下面的 安装 tomcat
这个例子会在本地缓存一份 tomcat 包,只要文件名称满足 apache-tomcat-*.tar.gz 就可以被自动获取,并安装到远程服务器。
如果本地不存在任何包时,将会自动从远程服务器中下载。
需要提供下面两个参数:
ansible_cache_directory: 存放 tomcat 包的位置service_root: 远程服务器的应用根路径创建文件 roles/common/tasks/main.yaml:
- name: Check Tomcat Exist
stat:
path: "{{ service_root }}/tomcat"
register: tomcat
- name: Init Tomcat
when: not tomcat.stat.exists
import_tasks: install.yaml
- name: Fail if tomcat occupied
when:
- tomcat.stat.exists
- not tomcat.stat.isdir
fail:
msg: "Tomcat directory '{{ tomcat_directory }}' exist, but it's a file!"
yaml
具体的安装逻辑(roles/common/tasks/install.yaml):
- name: Search local Tomcat
vars:
search_path: "{{ ansible_cache_directory }}/apache-tomcat-*.tar.gz"
set_fact:
tomcat_files: "{{ lookup('ansible.builtin.fileglob', search_path, wantlist = True ) }}"
- name: Download tomcat
delegate_to: localhost
when: tomcat_files.__len__() == 0
block:
- shell:
cmd: "mkdir -p {{ ansible_cache_directory }}"
- vars:
dest: "{{ ansible_cache_directory }}/apache-tomcat-10.1.28.tar.gz"
get_url:
url: 'https://mirrors.huaweicloud.com/apache/tomcat/tomcat-10/v10.1.28/bin/apache-tomcat-10.1.28.tar.gz'
checksum: sha512:b3177fb594e909364abc8074338de24f0441514ee81fa13bcc0b23126a5e3980cc5a6a96aab3b49798ba58d42087bf2c5db7cee3e494cc6653a6c70d872117e5
dest: "{{ dest }}"
- vars:
dest: "{{ ansible_cache_directory }}/apache-tomcat-10.1.28.tar.gz"
set_fact:
tomcat_files: "{{ [dest] }}"
rescue:
- name: Tip how to fix
fail:
msg: 'Failed to download Tomcat. You need to download Tomcat manually and then place it in `{{ ansible_cache_directory }}`. Please ensure that the file name follows the pattern `apache-tomcat-*.tar.gz`.'
- name: Fail if multi package
fail:
msg: 'Multiply Tomcat packages found: {{ tomcat_files }}. Either rename it to not follow the pattern `apache-tomcat-*.tar.gz` or keep only one file there.'
when: tomcat_files.__len__() > 1
- name: Send and unzip file.
unarchive:
src: "{{ tomcat_files[0] }}"
dest: "{{ service_root }}"
- name: Adjust folder name
vars:
zip_name: "{{ tomcat_files[0] | to_file_name }}"
shell:
cmd: >
cd {{ service_root }} &&
rm -f {{ service_root }}/{{ zip_name }} &&
mv {{ zip_name[:-7] }} tomcat
yaml
install.yaml 每一步具体的功能如下:
Search local Tomcat:使用 ansible.builtin.fileglob 模块搜索管理节点的缓存目录中的 tomcat 文件,注意需要提供wantlist = True参数,否则返回的将会是一个用逗号分割的字符串,而不是数据。
Download tomcat:首先使用 when 判断上一步中搜素到的 tomcat 文件列表是否为空,如果为空,则从远程下载。这里使用 block 将具体的下载任务组合为一个整体,任意一个步骤发生错误都会触发 rescue 中的代码。同时这里使用了 delegate_to: localhost 来将这个任务交给管理节点处理,而不是远程节点。
2.1. 这是一个脚本,确保远程服务器的目录存在
2.2. 从远程下载 tomcat
2.3. 覆盖 tomcat_files 变量,以便后续运行
Fail if multi package: 判断 tomcat 文件是否有多个,如果有,发出提示并报错返回。
Send and unzip file:将 tomcat 发送到远程服务器并解压
Adjust folder name:删除多余的压缩包并且重命名 tomcat 目录以便于后续升级
这里还用到了一个 filter:to_file_name。代码如下:
import os
def to_file_name(path: str) -> str:
return os.path.basename(path)
class FilterModule(object):
def filters(self):
return {
'to_file_name': to_file_name,
}
python
在这里自定义一个模块,用于递归创建文件夹,如果文件夹已经存在,返回 Unchanged 状态。
这里实际 ansible 已经提供了响应的模块:
- name: Recrusion create directory ansible.builtin.file: path: /opt/app/work state: directoryyaml
# recursion_mkdir.py
import os.path
from ansible.module_utils.basic import AnsibleModule
def run_module():
module_args = dict(
path=dict(type='list', required=True)
)
result = dict(
changed=False
)
module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True
)
paths = module.params['path']
if isinstance(paths, str):
paths = [paths]
for path in paths:
if not os.path.isdir(path):
os.makedirs(path, exist_ok=True)
result['changed'] = True
module.exit_json(**result)
def main():
run_module()
if __name__ == '__main__':
main()
python
上面的代码中,虽然指定了 path 的类型为 list,但实际上是可以直接传一个字符串进来的,所以在代码中要做兼容。
之后使用环境变量指定模块目录:
ANSIBLE_LIBRARY=/your/module/directory/bash
使用模块:
- name: Create required directory
recursion_mkdir:
path:
- "/opt/app/home"
- "/opt/app/configuration"
yaml
起因是我打算使用 shell 模块来启动 tomcat 服务:
- name: 'Restart Tomcat'
shell:
chdir: "{{ service_root }}/{{ tomcat_directory_name }}/bin"
cmd: sh startup.sh
yaml
结构执行后,ansible 没保存,tomcat 这里没有运行,也没有日志...
最后查了一下,这里是需要用 nohup 直接在外面启动服务:
- name: 'Restart Tomcat'
shell:
chdir: "{{ service_root }}/{{ tomcat_directory_name }}/bin"
cmd: nohup sh startup.sh 2>&1 > last-boot-log.log &
yaml
ansible-runner 可以帮助我们通过 Python 代码来调用 ansible 的 API,当需要从外部传入非常多的参数时可以考虑使用这个库。
安装依赖:
# python latest
python3 -m pip install ansible-runner
# python 3.6
python3 -m pip install ansible-runner==2.2.2
shell
运行一个 role:
import ansible_runner
ansible_runner.interface.run(
inventory=inventory_str,
private_data_dir='./',
playbook=play_yaml,
extravars={
'USERNAME': data.username,
'PASSWORD': data.password,
'HOST': data.host
}
)
python
所有的参数需要自己点开 run 方法看里面的注释。
详见:Introduction to Ansible Runner
在上面,我们有一个 private_data_dir,只需要将其指向目录结构的根目录,就可以不输入目录,直接使用文件名称就可以读取到相关的文件了。
目前是有一套 webpack 的 vue2 环境,打生产包需要 2 ~ 3 分钟左右,开发启动需要 1 分钟左右。
迁移后,打生产包仅需 1 分钟,开发启动 10 秒左右。
注意,由于 vite v5 版本需要 node18 或者 node20+,所以一般只能升级到 v4,等稳定后再升 v5:从 v4 迁移
vite v4 版本只需要 node14 就可以了,我自己目前的环境是 node12,可以直接升级,不会有很多坑。
安装下面的依赖:
npm install @vitejs/plugin-vue2 vite vite-plugin-html -Dbash
在项目根目录创建 vite-config 目录,然后依次创建下面的文件:
// config.ts
import { UserConfig } from 'vite'
import vue from '@vitejs/plugin-vue2'
import { createHtmlPlugin } from 'vite-plugin-html'
import { resolve } from 'path'
export const env = {
// 页面 context path
base: process.env.NODE_ENV === 'production' ? '/app' : ''
}
const config: UserConfig = {
plugins: [
vue(),
createHtmlPlugin({
entry: 'src/main.js',
template: 'index.html',
inject: {
data: {
base: env.base
}
}
})
],
publicDir: 'static',
resolve: {
alias: {
'@': resolve(__dirname, '../src'),
'~@': resolve(__dirname, '../src')
},
extensions: ['.mjs', '.js', '.ts', '.vue']
}
}
export default config
typescript
// dev.config.js
import { defineConfig } from 'vite'
import baseConfig, { env } from './config'
const PROXY_TARGET = 'https://abc.com'
export default defineConfig({
...baseConfig,
base: env.base,
define: {
'process.env': {
NODE_ENV: 'development',
BASE_API: '/app-api'
}
},
server: {
port: 1002,
proxy: {
'/app-api': {
target: PROXY_TARGET,
changeOrigin: true,
secure: false,
headers: {
host: new URL(PROXY_TARGET).host,
Referer: `${PROXY_TARGET}/app-api/`,
Origin: PROXY_TARGET
}
}
}
},
})
typescript
// prod.config.ts
import { defineConfig } from 'vite'
import baseConfig, { env } from './config'
export default defineConfig({
...baseConfig,
base: env.base,
define: {
'process.env': {
NODE_ENV: 'production',
BASE_API: '/app-api'
}
},
esbuild: {
drop: ['debugger']
},
build: {
outDir: "app",
assetsDir: 'static',
cssCodeSplit: true,
emptyOutDir: true,
}
})
typescript
不用多说,既然都来搞 vite 升级,肯定都能一眼看懂。
之后修改 pakcage.json 的启动配置:
{
// snip
"scripts": {
"dev": "vite --config vite-config/dev.config.ts",
"start": "npm run dev",
"build": "vite --config vite-config/prod.config.ts build",
"lint": "eslint --fix --ext .js --ext .vue src/"
},
// snip
}
json
主要是修改 vue 创建的方式:
new Vue({
router,
store,
i18n,
render: h => h(App)
}).$mount('#app')
javascript
然后修改我们的入口 html 文件:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>App</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="renderer" content="webkit">
<meta http-equiv="X-UA-Compatible" content="ie=edge,chrome=1">
<link rel="stylesheet" type="text/css" href="/css/reset.css">
</head>
<body>
<div id="app"></div>
<script>
// 可以注入属性.
window.base = '<%- base %>'
</script>
</body>
</html>
html
至此理论上就可以直接启动了,启动后就可以根据编译的报错一个一个改了,后面就讲一下我碰到的坑。
首先 vite 专门有个静态资源目录,如果你用的是我上面的配置,那么静态资源目录就在项目根目录下的 static 目录中。
假设有这样一个文件 static/img/hello.png,如果想要使用,直接使用根路径引用即可:
<img src="/img/hello.png">
html
而不是:
<img src="/static/img/hello.png">
<img src="<context path>/static/img/hello.png">
<img src="<context path>/img/hello.png">
html
上面几种写法均为错误写法!
对于第一种和第二种,不需要加上静态资源目录的名称,对于第三种,不需要加上 <context path>,在打生产包时,如果你在配置文件中配置了 base 属性,vite 会自动给你加上!
此外下面的写法会让 vite 的自动添加路径失效:
<div style="background-image: url('/img/hello.png')"></div>plaintext
这里直接将文件路径写在了 style 中,如果在生产模式下配置了 base,就会导致生产模式无法读取到图片,对于这种写法必须要以 css 的形式写,不能用内联样式!
这里 webpack 有两种用途:
require.context 动态导入模块或文件对于前者,我建议赶快把 nodejs 模块全都换掉,别想着适配了,一般这种情况经常会出现在一些加密算法的库里,直接找到一个适合前端的库替换就行了。
而对于后者,就比较麻烦了...
我的项目里是这样的一个操作:
const requireAll = requireContext => requireContext.keys().map(requireContext)
const req = require.context('./svg', false, /\.svg$/)
requireAll(req)
javascript
上面的代码,会将 svg 目录下的所有文件注册为一个组件,然后放在页面上,然后使用 svg 的 use 来引用:
<svg>
<use :xlink:href="iconName"></use>
</svg>
html
这里建议直接改造,将图片直接封装成组件使用:
import { defineAsyncComponent } from 'vue'
const components = {}
{
const files = import.meta.glob('./svg/*.svg', {
query: 'component'
})
for (const filesKey in files) {
// remove prefix './svg/' and suffix '.svg'
const key = filesKey.substring(6, filesKey.length - 4)
components[key] = defineAsyncComponent(files[filesKey])
}
}
const getIcon = (name) => {
const entity = components[name]
if (!entity) {
console.warn('Icon not found: ' + name)
}
return entity
}
export default getIcon
javascript
这里很容易理解,就是 getIcon 方法只需要传入文件的名称就会返回一个异步组件,然后在外部直接使用就可以了。
然后另外一个问题就来了,defineAsyncComponent 是 vue3 的 API (如果报错了,请把你的 vue2 升级到 2 的最后一个版本),那么我使用也得使用 vue3 的写法 (至少我是没找到怎么用 vue2 的写法来渲染这个组件的...):
<template>
<div
:class="svgClass"
aria-hidden="true"
>
<Icon
width="100%"
height="100%"
/>
</div>
</template>
<script setup>
import getIcon from './index'
const props = defineProps({
iconClass: {
type: String,
required: true
},
className: {
type: String,
default: undefined
}
})
const Icon = getIcon(props.iconClass)
const svgClass = props.className ? 'svg-icon ' + props.className : 'svg-icon'
</script>
html
如果直接用 vue3 setup 写法,eslint 可能会报错,但是仍然能够通过编译并使用,这里也需要一起升级一下 eslint。
安装/更新依赖:
npm i eslint@^8 eslint-plugin-vue@^9 vue-eslint-parser@^9 -Dbash
修改 eslint 配置:
module.exports = {
root: true,
parser: 'vue-eslint-parser',
parserOptions: {
sourceType: 'module'
},
env: {
browser: true,
node: true,
es6: true
},
extends: ['eslint:recommended', 'plugin:vue/recommended'],
rules: {
// snip
}
}
javascript
主要是注意 parser 那个配置,别的根据自己的需求修改。
虽然是个很奇葩的需求,但是还是写一下。这里要求以 base64 格式导入 src (非静态资源目录)下的某个文件,默认情况下 vite 肯定是做不到的,这里要求我们自己定义插件:
// base64Loader.ts
import type { Plugin } from 'rollup'
import * as fs from 'fs'
const base64Loader: Plugin = {
name: 'base64-loader',
transform(_: any, id: string) {
const [path, query] = id.split('?')
if (query !== 'base64') return null
const data = fs.readFileSync(path)
const base64 = data.toString('base64')
return `export default '${base64}';`
}
}
export default base64Loader
typescript
这段代码的作用是,如果在导入一个模块时加上了 ?base,则会以 base 格式进行导入。
然后在 vite 配置文件中配置:
// vite.config.ts
import base64Loader from './base64Loader'
const config: UserConfig = {
plugins: [
// snip
base64Loader
]
// snip
}
typescript因为自己偶尔才会用用 Python 写写脚本,但是每次想写的时候就要查半天的语法...所以在这里记录一下 Python 基础的快速入门的一些东西。
python -m venv <project_name>/.venv source <project_name>/.venv/bin/activateshell
如果你去官网看的话这里是直接将虚拟环境创建在了项目目录中,也就是直接调用了
python -m venv <project_name>,这样也可以, 但是会导致项目根目录会多出很多虚拟环境的配置文件,并且这些文件都是不会进版本控制的,所以在这里创建一个子文件夹.venv来管理会更好!
运行完后命令左边会出现<env_name>:
(my_env) [root@192.168.0.1 my_env]#shell
在这种状况下,所有 Python 环境均与外界隔离,包括 pip 的版本。
使用下面的指令退出虚拟环境:
deactivateshell
由于 .venv 目录一般不会进入版本控制系统,所以如果想要想生产,则需要所有需要的依赖。
使用下面的指令导出/导入依赖:
# 导出
pip freeze >requirements.txt
# 导入
pip install -r requirements.txt
bash
更新 pip:
python3 -m pip install --upgrade pippython
def main():
print("hello")
if __name__ == "__main__":
main()
python
from typing import Mapping
def hello(val: str, map: Mapping[str, str]) -> str:
pass
python
from enum import Enum
class Server(Enum):
SERVER_1 = ('192.168.0.1', 'root', '123456')
SERVER_2 = ('192.168.0.2', 'root', '123456')
def __init__(self, host: str, user: str, password: str):
self.host = host
self.user = user
self.password = password
printf(Server.SERVER_1.host)
python
from typing import Protocol
from typing import List
# 任何拥有 close 方法的实例都可以被推进去
closeable_list: List[Closeable] = []
class Closeable(Protocol):
def close(self) -> None:
pass
python
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('192.168.0.1', username='root', password='123456')
# 这里需要看情况决定是否执行 source /etc/profile,这里不会主动加载环境变量,在外面传 env 参数也没用...
_, stdout, _ = ssh.exec_command('source /etc/profile; env')
print(''.join(stdout.readlines()))
python
使用前要给用户生成一个 token。
from jenkinsapi.jenkins import Jenkins
JOB_NAME = 'xxx'
SERVER = 'http://192.168.0.1:8080'
jenkins = Jenkins('http://192.168.0.1:8080', 'user', 'token')
params = {
'Branch': 'master'
}
jenkins.build_job(JOB_NAME, params)
job = jenkins[JOB_NAME]
qi = job.invoke(build_params=params)
if qi.is_queued() or qi.is_running():
print('等待任务构建完成...')
qi.block_until_complete()
build = qi.get_build()
if not build.is_good():
raise RuntimeError(f'Build failed, check {server}/job/{JOB_NAME}/{build.buildno}/pipeline-graph/ for more details.')
python
import zipfile
import os
import sys
def zip_dir(directory_path: str, output_path=None):
# Get the base name of the directory to include in the zip
base_name = os.path.basename(directory_path)
# Create a zip file at the specified output path
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Walk through the directory and add each file or directory to the zip file
for root, dirs, files in os.walk(directory_path):
# Create an archive name with the top-level directory included
arcname_root = os.path.join(base_name, os.path.relpath(root, start=directory_path))
# Add directory entries
if not files and not dirs:
# Handle the case of empty directories
zipf.write(root, arcname=arcname_root + '/')
for file in files:
# Get the full path of the file
full_path = os.path.join(root, file)
# Create the relative path for the file in the zip
arcname = os.path.join(arcname_root, file)
zipf.write(full_path, arcname)
python
def input_bool(text: str, default: bool = False):
tip: str
exp: str
if default:
tip = '(Y/n)'
else:
tip = '(y/N)'
out = input(f'{text} {tip}')
if out == '':
return default
elif out == 'Y' or out == 'y':
return True
elif out == 'N' or out == 'n':
return False
else:
return input_bool(text, default)
python