diff --git a/README.md b/README.md index 7ca305a..060abb7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ | `ordo-spring-boot-starter` | Spring Boot 自动装配(JDBC + Flyway) | | `ordo-example` | 内存引擎示例 | -存储实现通过 dialect 层支持 **PostgreSQL** 与 **MySQL**(测试可用 H2 PostgreSQL 兼容模式)。Starter **不携带** JDBC 驱动,宿主自行加入 `postgresql`、`mysql-connector-j` 或 `h2`。Spring Boot 4 还需额外引入 `spring-boot-starter-flyway`,否则迁移不会跑。 +存储实现通过 dialect 层支持 **PostgreSQL** 与 **MySQL**(测试可用 H2 PostgreSQL 兼容模式)。Starter **不携带** JDBC 驱动,宿主自行加入 `postgresql`、`mysql-connector-j` 或 `h2`。表结构由 Ordo 自己的 Flyway 迁移(`ordo_schema_history`),与宿主 `spring.flyway` 无关。 ## 能力 diff --git a/docs/usage.md b/docs/usage.md index 9cc78eb..17693b8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -34,7 +34,7 @@ Java 包(模块未变): | `com.jetlumen.ordo.api.exception` / `query` / `repository` | 异常、分页查询、存储端口 | | `com.jetlumen.ordo.core.spi` | `DispatchingActionHandler`、`DispatchingRoutingCondition`、`RegistryOrdoCatalog` | -Starter **不携带** JDBC 驱动。生产按库添加 `org.postgresql:postgresql` 或 `com.mysql:mysql-connector-j`。Spring Boot 4 还需 `spring-boot-starter-flyway`,否则 Flyway 迁移不会执行。 +Starter **不携带** JDBC 驱动。生产按库添加 `org.postgresql:postgresql` 或 `com.mysql:mysql-connector-j`。Ordo 用独立 Flyway 建表,不依赖宿主 `spring.flyway`。 先 `mvn install` 本仓库,宿主再依赖 `0.0.1-SNAPSHOT`。 @@ -72,6 +72,8 @@ ordo: enabled: true jdbc: dialect: # 可选 postgresql / mysql;空则按 DataSource 探测 + migrate: true + history-table: ordo_schema_history definitions: location: classpath*:ordo/*.json # 启动时对每个 JSON 调用 publish due: @@ -420,7 +422,7 @@ ACTION 成功事件发生在提交之后,因此排在同轮事务内写入的 ## 12. 存储 -Flyway 脚本按方言分目录:`db/postgresql/migration`、`db/mysql/migration`。未设置 `spring.flyway.locations` 时,starter 按探测到的方言指向对应目录。已有 `flyway_schema_history` 的开发库按版本追加迁移(PARALLEL 为 `V3__parallel_blocks.sql`)。表包括流程头 `ordo_process`、按 `(id, version)` 存储的定义/步骤(含 `parent_step_id` / `branch_id`)/候选人/转移、实例(含 `definition_version`)、任务、并行令牌 `ordo_instance_token`、`ordo_process_event`、`ordo_action_execution`。 +Flyway 脚本按方言分目录:`db/postgresql/migration`、`db/mysql/migration`。Starter 在启动时用独立 Flyway 执行这些脚本,历史表默认 `ordo_schema_history`,不修改 `spring.flyway.locations`。若宿主也启用了 Spring Boot Flyway,Ordo 会在宿主 `flywayInitializer` 之后再 migrate,避免非空 schema 导致宿主失败。已有 `ordo_*` 表但尚无该历史表时,会 baseline 到当前脚本最高版本后再 migrate。`ordo.jdbc.migrate=false` 时不执行。表包括流程头 `ordo_process`、按 `(id, version)` 存储的定义/步骤(含 `parent_step_id` / `branch_id`)/候选人/转移、实例(含 `definition_version`)、任务、并行令牌 `ordo_instance_token`、`ordo_process_event`、`ordo_action_execution`。 自定义方言:实现 `SqlDialect` 并用 `META-INF/services` 注册,或提供 `SqlDialect` Bean。新增列时每个已支持方言目录各加一条迁移。 diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java index ab9bd37..5daa736 100644 --- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoProperties.java @@ -78,6 +78,12 @@ public class OrdoProperties { /** Explicit dialect id ({@code postgresql}, {@code mysql}). Empty means detect from the DataSource. */ private String dialect; + /** When true, Ordo runs its own Flyway against dialect locations. */ + private boolean migrate = true; + + /** Flyway history table used only by Ordo (not {@code flyway_schema_history}). */ + private String historyTable = "ordo_schema_history"; + public String getDialect() { return dialect; } @@ -85,6 +91,23 @@ public class OrdoProperties { public void setDialect(String dialect) { this.dialect = dialect; } + + public boolean isMigrate() { + return migrate; + } + + public void setMigrate(boolean migrate) { + this.migrate = migrate; + } + + public String getHistoryTable() { + return historyTable; + } + + public void setHistoryTable(String historyTable) { + this.historyTable = historyTable == null || historyTable.isBlank() + ? "ordo_schema_history" : historyTable.trim(); + } } public static class Rest { diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoDatabaseInitializerDetector.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoDatabaseInitializerDetector.java new file mode 100644 index 0000000..75a2548 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoDatabaseInitializerDetector.java @@ -0,0 +1,14 @@ +package com.jetlumen.ordo.spring.jdbc; + +import org.springframework.boot.sql.init.dependency.AbstractBeansOfTypeDatabaseInitializerDetector; + +import java.util.Set; + +/** Treats {@link OrdoSchemaMigrator} as database initialization for {@code @DependsOnDatabaseInitialization}. */ +public class OrdoDatabaseInitializerDetector extends AbstractBeansOfTypeDatabaseInitializerDetector { + + @Override + protected Set> getDatabaseInitializerBeanTypes() { + return Set.of(OrdoSchemaMigrator.class); + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoFlywayAutoConfiguration.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoFlywayAutoConfiguration.java index a98e83c..87f07ba 100644 --- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoFlywayAutoConfiguration.java +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoFlywayAutoConfiguration.java @@ -1,34 +1,22 @@ package com.jetlumen.ordo.spring.jdbc; import com.jetlumen.ordo.spring.OrdoProperties; -import com.jetlumen.ordo.storage.jdbc.dialect.SqlDialect; -import com.jetlumen.ordo.storage.jdbc.dialect.SqlDialects; import org.flywaydb.core.Flyway; -import org.flywaydb.core.api.configuration.FluentConfiguration; -import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.BeanFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigureAfter; -import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; -import org.springframework.core.env.Environment; import javax.sql.DataSource; -import java.lang.reflect.Proxy; /** - * Points Flyway at the dialect migration directory before Flyway runs. - * Host {@code spring.flyway.locations} is left unchanged when set. - * - *

Does not inject {@link DataSource} or {@link SqlDialect} at bean-creation time - * (that would cycle with DataSource → Flyway → customizer). Dialect is resolved inside - * {@code customize} from the FluentConfiguration DataSource / {@code ordo.jdbc.dialect}. - * - *

FlywayConfigurationCustomizer moved between Boot 3 and Boot 4; a reflective - * {@link FactoryBean} supplies a proxy for whichever type is on the classpath. + * Migrates Ordo tables with a dedicated Flyway history table, independent of + * {@code spring.flyway}. Runs after the host {@code flywayInitializer} when present so a + * non-empty schema does not break host migrate. */ @AutoConfiguration @ConditionalOnProperty(prefix = "ordo", name = "enabled", havingValue = "true", matchIfMissing = true) @@ -36,89 +24,19 @@ import java.lang.reflect.Proxy; @ConditionalOnBean(DataSource.class) @AutoConfigureAfter(name = { "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration", - "org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration" -}) -@AutoConfigureBefore(name = { + "org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", "org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration", "org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration" }) @EnableConfigurationProperties(OrdoProperties.class) public class OrdoFlywayAutoConfiguration { - private static final String[] FLYWAY_CUSTOMIZER_TYPES = { - "org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer", - "org.springframework.boot.autoconfigure.flyway.FlywayConfigurationCustomizer" - }; - @Bean - @ConditionalOnClass(name = "org.flywaydb.core.api.configuration.FluentConfiguration") - public FactoryBean ordoFlywayConfigurationCustomizer( - OrdoProperties ordoProperties, Environment environment) { - Class customizerType = resolveFlywayCustomizerType(); - if (customizerType == null) { - return null; - } - return new FlywayLocationsCustomizerFactoryBean(customizerType, ordoProperties, environment); - } - - static Class resolveFlywayCustomizerType() { - ClassLoader classLoader = OrdoFlywayAutoConfiguration.class.getClassLoader(); - for (String name : FLYWAY_CUSTOMIZER_TYPES) { - try { - return Class.forName(name, false, classLoader); - } catch (ClassNotFoundException ignored) { - // Boot 3 vs Boot 4 - } - } - return null; - } - - private static final class FlywayLocationsCustomizerFactoryBean implements FactoryBean { - private final Class customizerType; - private final OrdoProperties ordoProperties; - private final Environment environment; - - private FlywayLocationsCustomizerFactoryBean(Class customizerType, OrdoProperties ordoProperties, - Environment environment) { - this.customizerType = customizerType; - this.ordoProperties = ordoProperties; - this.environment = environment; - } - - @Override - public Object getObject() { - return Proxy.newProxyInstance(customizerType.getClassLoader(), new Class[] {customizerType}, - (proxy, method, args) -> { - String name = method.getName(); - if ("customize".equals(name) && args != null && args.length == 1) { - applyLocations((FluentConfiguration) args[0]); - return null; - } - if ("equals".equals(name)) { - return proxy == args[0]; - } - if ("hashCode".equals(name)) { - return System.identityHashCode(proxy); - } - if ("toString".equals(name)) { - return "OrdoFlywayLocationsCustomizer"; - } - throw new UnsupportedOperationException(method.toString()); - }); - } - - private void applyLocations(FluentConfiguration configuration) { - if (environment.containsProperty("spring.flyway.locations")) { - return; - } - SqlDialect dialect = SqlDialects.resolve(configuration.getDataSource(), - ordoProperties.getJdbc().getDialect()); - configuration.locations(dialect.flywayLocations()); - } - - @Override - public Class getObjectType() { - return customizerType; + public OrdoSchemaMigrator ordoSchemaMigrator(DataSource dataSource, OrdoProperties ordoProperties, + BeanFactory beanFactory) { + if (beanFactory.containsBean("flywayInitializer")) { + beanFactory.getBean("flywayInitializer"); } + return new OrdoSchemaMigrator(dataSource, ordoProperties); } } diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoSchemaMigrator.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoSchemaMigrator.java new file mode 100644 index 0000000..fc25995 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/jdbc/OrdoSchemaMigrator.java @@ -0,0 +1,86 @@ +package com.jetlumen.ordo.spring.jdbc; + +import com.jetlumen.ordo.spring.OrdoProperties; +import com.jetlumen.ordo.storage.jdbc.dialect.SqlDialects; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationInfo; +import org.flywaydb.core.api.MigrationVersion; +import org.flywaydb.core.api.configuration.FluentConfiguration; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Locale; + +/** + * Runs Ordo Flyway scripts independently of the host {@code spring.flyway} instance. + * Presence of this bean marks Ordo schema initialization for + * {@code @DependsOnDatabaseInitialization}. + */ +public final class OrdoSchemaMigrator { + + private static final String PROCESS_TABLE = "ordo_process"; + + public OrdoSchemaMigrator(DataSource dataSource, OrdoProperties properties) { + OrdoProperties.Jdbc jdbc = properties.getJdbc(); + if (!jdbc.isMigrate()) { + return; + } + String[] locations = SqlDialects.resolve(dataSource, jdbc.getDialect()).flywayLocations(); + String historyTable = jdbc.getHistoryTable(); + FluentConfiguration configuration = new FluentConfiguration(OrdoSchemaMigrator.class.getClassLoader()) + .dataSource(dataSource) + .locations(locations) + .table(historyTable); + applyBaselineIfNeeded(configuration, dataSource, historyTable); + configuration.load().migrate(); + } + + private static void applyBaselineIfNeeded(FluentConfiguration configuration, DataSource dataSource, + String historyTable) { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metaData = connection.getMetaData(); + if (tableExists(metaData, connection, historyTable)) { + return; + } + // Host tables / flyway_schema_history make the schema non-empty; Flyway then requires + // baseline before migrate. Existing Ordo tables baseline to latest (skip scripts); + // otherwise baseline at 0 so V1+ still run. + if (tableExists(metaData, connection, PROCESS_TABLE)) { + MigrationVersion latest = latestVersion(configuration.load()); + configuration.baselineOnMigrate(true).baselineVersion(latest); + } else { + configuration.baselineOnMigrate(true).baselineVersion(MigrationVersion.fromVersion("0")); + } + } catch (SQLException e) { + throw new IllegalStateException("failed to inspect schema before Ordo Flyway migrate", e); + } + } + + private static boolean tableExists(DatabaseMetaData metaData, Connection connection, String table) + throws SQLException { + String catalog = connection.getCatalog(); + String schema = connection.getSchema(); + String[] names = {table, table.toLowerCase(Locale.ROOT), table.toUpperCase(Locale.ROOT)}; + for (String name : names) { + try (ResultSet tables = metaData.getTables(catalog, schema, name, new String[] {"TABLE"})) { + if (tables.next()) { + return true; + } + } + } + return false; + } + + private static MigrationVersion latestVersion(Flyway flyway) { + MigrationVersion latest = MigrationVersion.fromVersion("0"); + for (MigrationInfo info : flyway.info().all()) { + if (info.getVersion() != null && info.getVersion().compareTo(latest) > 0) { + latest = info.getVersion(); + } + } + return latest; + } +} diff --git a/ordo-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.sql.init.dependency.DatabaseInitializerDetector.imports b/ordo-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.sql.init.dependency.DatabaseInitializerDetector.imports new file mode 100644 index 0000000..31afa16 --- /dev/null +++ b/ordo-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.sql.init.dependency.DatabaseInitializerDetector.imports @@ -0,0 +1 @@ +com.jetlumen.ordo.spring.jdbc.OrdoDatabaseInitializerDetector diff --git a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java index 06c9f03..99a8ea3 100644 --- a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java +++ b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java @@ -73,7 +73,7 @@ class OrdoJdbcAutoConfigurationTest { @Test void honoursExplicitMysqlDialect() { - withDataSourceRunner.withPropertyValues("ordo.jdbc.dialect=mysql", "spring.flyway.enabled=false") + withDataSourceRunner.withPropertyValues("ordo.jdbc.dialect=mysql", "ordo.jdbc.migrate=false") .run(context -> assertThat(context.getBean(SqlDialect.class).id()).isEqualTo("mysql")); }