From 4ed6edd9b679b4e00f253bc2b82e490d9a20aaa5 Mon Sep 17 00:00:00 2001 From: ZhouXY108 Date: Fri, 17 Oct 2025 18:13:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(model):=20=E6=B7=BB=E5=8A=A0=20`SemVer`=20?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E8=AF=AD=E4=B9=89=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=20(!4=20@Gitee)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat: 添加 `SemVer` 表示语义版本号 - test: 完善 `SemVer` 的单元测试 --- cspell.json | 1 + .../zhouxy/plusone/commons/model/SemVer.java | 275 ++++++++++++++++++ .../plusone/commons/model/SemVerTests.java | 251 ++++++++++++++++ 3 files changed, 527 insertions(+) create mode 100644 plusone-commons/src/main/java/xyz/zhouxy/plusone/commons/model/SemVer.java create mode 100644 plusone-commons/src/test/java/xyz/zhouxy/plusone/commons/model/SemVerTests.java diff --git a/cspell.json b/cspell.json index 6482cd7..b72b881 100644 --- a/cspell.json +++ b/cspell.json @@ -9,6 +9,7 @@ "aliyun", "baomidou", "Batis", + "buildmetadata", "Consolas", "cspell", "databind", diff --git a/plusone-commons/src/main/java/xyz/zhouxy/plusone/commons/model/SemVer.java b/plusone-commons/src/main/java/xyz/zhouxy/plusone/commons/model/SemVer.java new file mode 100644 index 0000000..f8998d3 --- /dev/null +++ b/plusone-commons/src/main/java/xyz/zhouxy/plusone/commons/model/SemVer.java @@ -0,0 +1,275 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package xyz.zhouxy.plusone.commons.model; + + +import static xyz.zhouxy.plusone.commons.util.AssertTools.checkArgument; + +import java.io.Serializable; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nullable; + +import com.google.common.base.Splitter; + +import xyz.zhouxy.plusone.commons.util.StringTools; + +/** + * SemVer 语义版本号 + * + * @author ZhouXY108 + * @since 1.1.0 + * + * @see Semantic Versioning 2.0.0 + */ +public class SemVer implements Comparable, Serializable { + private static final long serialVersionUID = 458265121025514002L; + + private final String value; + + private final int[] versionNumbers; + @Nullable + private final String preReleaseVersion; + @Nullable + private final String buildMetadata; + + private static final String VERSION_NUMBERS = "(?(?0|[1-9]\\d*)\\.(?0|[1-9]\\d*)\\.(?0|[1-9]\\d*)(\\.(0|[1-9]\\d*)){0,2})"; + private static final String PRE_RELEASE_VERSION = "(?:-(?(?:0|[1-9]\\d{0,41}|\\d{0,18}[a-zA-Z-][0-9a-zA-Z-]{0,18})(?:\\.(?:0|[1-9]\\d{0,41}|\\d{0,18}[a-zA-Z-][0-9a-zA-Z-]{0,18})){0,18}))?"; + private static final String BUILD_METADATA = "(?:\\+(?[0-9a-zA-Z-]{1,18}(?:\\.[0-9a-zA-Z-]{1,18}){0,18}))?"; + + private static final Pattern PATTERN = Pattern.compile( + "^" + VERSION_NUMBERS + PRE_RELEASE_VERSION + BUILD_METADATA + "$"); + + /** + * 创建语义化版本号的值对象 + * + * @param value 字符串值 + * @param versionNumbers 主版本号、次版本号、修订号 + * @param preReleaseVersion 先行版本号 + * @param buildMetadata 版本编译信息 + */ + private SemVer(String value, + int[] versionNumbers, + @Nullable String preReleaseVersion, + @Nullable String buildMetadata) { + this.value = value; + this.versionNumbers = versionNumbers; + this.preReleaseVersion = preReleaseVersion; + this.buildMetadata = buildMetadata; + } + + /** + * 创建 SemVer 对象 + * + * @param value 语义化版本号 + * @return SemVer 对象 + */ + public static SemVer of(final String value) { + checkArgument(StringTools.isNotBlank(value), "版本号不能为空"); + final Matcher matcher = PATTERN.matcher(value); + checkArgument(matcher.matches(), "版本号格式错误"); + // 数字版本部分 + final String versionNumbersPart = matcher.group("numbers"); + // 先行版本号部分 + final String preReleaseVersionPart = matcher.group("prerelease"); + // 版本编译信息部分 + final String buildMetadataPart = matcher.group("buildmetadata"); + + final int[] versionNumbers = Splitter.on('.') + .splitToStream(versionNumbersPart) + // 必须都是数字 + .mapToInt(Integer::parseInt) + .toArray(); + return new SemVer(value, versionNumbers, preReleaseVersionPart, buildMetadataPart); + } + + /** + * 获取主版本号 + * + * @return 主版本号 + */ + public int getMajor() { + return this.versionNumbers[0]; + } + + /** + * 获取次版本号 + * + * @return 次版本号 + */ + public int getMinor() { + return this.versionNumbers[1]; + } + + /** + * 获取修订号 + * + * @return 修订号 + */ + public int getPatch() { + return this.versionNumbers[2]; + } + + /** + * 获取先行版本号 + * + * @return 先行版本号 + */ + @Nullable + public String getPreReleaseVersion() { + return this.preReleaseVersion; + } + + /** + * 获取版本编译信息 + * + * @return 版本编译信息 + */ + @Nullable + public String getBuildMetadata() { + return buildMetadata; + } + + /** {@inheritDoc} */ + @Override + public int compareTo(@SuppressWarnings("null") SemVer that) { + if (this == that) { + return 0; + } + int result = compareVersionNumbers(that); + if (result != 0) { + return result; + } + return comparePreReleaseVersion(that); + } + + /** + * 获取字符串值 + * + * @return 版本字符串 + */ + public String getValue() { + return value; + } + + /** {@inheritDoc} */ + @Override + public int hashCode() { + return Objects.hash(value); + } + + /** {@inheritDoc} */ + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) + return true; + if (!(obj instanceof SemVer)) + return false; + SemVer other = (SemVer) obj; + return Objects.equals(value, other.value); + } + + /** + * 获取 SemVer 的字符串表示。如 {@code v1.2.3-alpha.1+build.1234} + */ + @Override + public String toString() { + return 'v' + value; + } + + /** + * 比较主版本号、次版本号、修订号 + */ + private int compareVersionNumbers(SemVer that) { + final int minLength = Integer.min(this.versionNumbers.length, that.versionNumbers.length); + + for (int i = 0; i < minLength; i++) { + final int currentVersionNumberOfThis = this.versionNumbers[i]; + final int currentVersionNumberOfThat = that.versionNumbers[i]; + if (currentVersionNumberOfThis != currentVersionNumberOfThat) { + return currentVersionNumberOfThis - currentVersionNumberOfThat; + } + } + return this.versionNumbers.length - that.versionNumbers.length; + } + + /** + * 比较先行版本号 + */ + private int comparePreReleaseVersion(SemVer that) { + int thisWithoutPreReleaseVersionFlag = bool2Int(this.preReleaseVersion == null); + int thatWithoutPreReleaseVersionFlag = bool2Int(that.preReleaseVersion == null); + if (isTrue(thisWithoutPreReleaseVersionFlag | thatWithoutPreReleaseVersionFlag)) { + return thisWithoutPreReleaseVersionFlag - thatWithoutPreReleaseVersionFlag; + } + + Splitter splitter = Splitter.on('.'); + + final String[] preReleaseVersionOfThis = splitter + .splitToStream(this.preReleaseVersion) // NOSONAR + .toArray(String[]::new); + final String[] preReleaseVersionOfThat = splitter + .splitToStream(that.preReleaseVersion) // NOSONAR + .toArray(String[]::new); + final int minLength = Integer.min(preReleaseVersionOfThis.length, preReleaseVersionOfThat.length); + for (int i = 0; i < minLength; i++) { + int r = comparePartOfPreReleaseVersion(preReleaseVersionOfThis[i], preReleaseVersionOfThat[i]); + if (r != 0) { + return r; + } + } + return preReleaseVersionOfThis.length - preReleaseVersionOfThat.length; + } + + /** + * 比较先行版本号的组成部分 + */ + private static int comparePartOfPreReleaseVersion(String p1, String p2) { + boolean p1IsNumber = isAllDigits(p1); + boolean p2IsNumber = isAllDigits(p2); + + if (p1IsNumber) { + return p2IsNumber + ? Integer.parseInt(p1) - Integer.parseInt(p2) // 都是数字 + : -1; // p1 是数字,p2 是字符串 + } + // 如果 p1 是字符串,p2 是数字,则返回 1(字符串优先于纯数字) + return p2IsNumber ? 1 : p1.compareTo(p2); + } + + /** + * 判断字符串是否全为数字 + */ + private static boolean isAllDigits(String str) { + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + + private static int bool2Int(boolean expression) { + return expression ? 1 : 0; + } + + private static boolean isTrue(int b) { + return b != 0; + } +} diff --git a/plusone-commons/src/test/java/xyz/zhouxy/plusone/commons/model/SemVerTests.java b/plusone-commons/src/test/java/xyz/zhouxy/plusone/commons/model/SemVerTests.java new file mode 100644 index 0000000..993a8df --- /dev/null +++ b/plusone-commons/src/test/java/xyz/zhouxy/plusone/commons/model/SemVerTests.java @@ -0,0 +1,251 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xyz.zhouxy.plusone.commons.model; + +import static org.apache.commons.lang3.ObjectUtils.compare; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SemVerTests { + + @ParameterizedTest + @ValueSource(strings = { + "25.10.17", + "25.10.17.11", + "25.10.17.11.38", + "25.10.17-RC1", + "25.10.17.11-RC1", + "25.10.17.11.38-RC1", + "25.10.17+build.a20251017.1", + "25.10.17.11+build.a20251017.1", + "25.10.17.11.38+build.a20251017.1", + "25.10.17-RC1+build.a20251017.1", + "25.10.17.11-RC1+build.a20251017.1", + "25.10.17.11.38-RC1+build.a20251017.1", + }) + void test_of_success(String value) { + assertDoesNotThrow(() -> SemVer.of(value)); + } + + @ParameterizedTest + @ValueSource(strings = { + "25", + "25.10", + "x.10.17", + "25.x.17", + "25.10.x", + "25.10.17.11.38.20", + "025.10.17", + "25.010.17", + "25.10.017", + }) + void test_of_wrongValue(String value) { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> SemVer.of(value)); + assertEquals("版本号格式错误", e.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = { + "25.1.1", + "25.1.1-RC1", + "25.1.1+build.a20250101.1", + "25.1.1-RC1+build.a20250101.1", + }) + void compareTo_Major(String version_25_x_x) { // NOSONAR sonarqube(java:S117) + assertTrue(compare(SemVer.of(version_25_x_x), SemVer.of("24.12.30")) > 0); + assertTrue(compare(SemVer.of(version_25_x_x), SemVer.of("24.12.30.1")) > 0); + assertTrue(compare(SemVer.of(version_25_x_x), SemVer.of("24.12.30-RC2")) > 0); + assertTrue(compare(SemVer.of(version_25_x_x), SemVer.of("24.12.30+build.z20241230.1")) > 0); + assertTrue(compare(SemVer.of(version_25_x_x), SemVer.of("24.12.30-RC2+build.z20241230.1")) > 0); + } + + @ParameterizedTest + @ValueSource(strings = { + "25.10.1", + "25.10.1-RC1", + "25.10.1+build.a20251001.1", + "25.10.1-RC1+build.a20251001.1", + }) + void compareTo_Minor(String version_25_10_x) { // NOSONAR sonarqube(java:S117) + assertTrue(compare(SemVer.of(version_25_10_x), SemVer.of("25.9.30")) > 0); + assertTrue(compare(SemVer.of(version_25_10_x), SemVer.of("25.9.30.1")) > 0); + assertTrue(compare(SemVer.of(version_25_10_x), SemVer.of("25.9.30-RC2")) > 0); + assertTrue(compare(SemVer.of(version_25_10_x), SemVer.of("25.9.30+build.z20250930.1")) > 0); + assertTrue(compare(SemVer.of(version_25_10_x), SemVer.of("25.9.30-RC2+build.z20250930.1")) > 0); + } + + @ParameterizedTest + @ValueSource(strings = { + "25.10.17", + "25.10.17-RC1", + "25.10.17+build.a20251017.1", + "25.10.17-RC1+build.a20251017.1", + }) + void compareTo_Patch(String version_25_10_17) { // NOSONAR sonarqube(java:S117) + + assertTrue(compare(SemVer.of(version_25_10_17), SemVer.of("25.10.16")) > 0); + assertTrue(compare(SemVer.of(version_25_10_17), SemVer.of("25.10.16.1")) > 0); + assertTrue(compare(SemVer.of(version_25_10_17), SemVer.of("25.10.16-RC2")) > 0); + assertTrue(compare(SemVer.of(version_25_10_17), SemVer.of("25.10.16+build.z20251016.2")) > 0); + assertTrue(compare(SemVer.of(version_25_10_17), SemVer.of("25.10.16-RC2+build.z20251016.2")) > 0); + } + + @Test + void compareTo_MoreVersionNumber() { + + assertTrue(compare(SemVer.of("25.10.17.1"), SemVer.of("25.10.17")) > 0); + assertTrue(compare(SemVer.of("25.10.17.11"), SemVer.of("25.10.17.1")) > 0); + assertEquals(0, compare(SemVer.of("25.10.17.11"), SemVer.of("25.10.17.11"))); + + assertTrue(compare(SemVer.of("25.10.17.11.1"), SemVer.of("25.10.17.11")) > 0); + assertTrue(compare(SemVer.of("25.10.17.11.38"), SemVer.of("25.10.17.11.1")) > 0); + assertEquals(0, compare(SemVer.of("25.10.17.11.38"), SemVer.of("25.10.17.11.38"))); + } + + @Test + void compareTo_PreReleaseVersion() { + + // 先行版的优先级低于相关联的标准版本 + assertTrue(compare(SemVer.of("25.10.17"), SemVer.of("25.10.17-0")) > 0); + assertTrue(compare(SemVer.of("25.10.17"), SemVer.of("25.10.17-RC1")) > 0); + + // 只有数字的标识符以数值高低比较 + assertAll( + () -> assertTrue(compare("25.10.17-RC.11", "25.10.17-RC.2") < 0), + () -> assertTrue(compare(SemVer.of("25.10.17-RC.11"), SemVer.of("25.10.17-RC.2")) > 0) + ); + + // 纯数字优先级低于非数字 + assertAll( + () -> assertTrue(compare("25.10.17-999", "25.10.17-A") < 0), + () -> assertTrue(compare(SemVer.of("25.10.17-A.A"), SemVer.of("25.10.17-A.99")) > 0) + ); + + SemVer[] versions = { + SemVer.of("25.10.17-a"), + SemVer.of("25.10.17-aa"), + SemVer.of("25.10.17-A"), + SemVer.of("25.10.17-AA"), + + SemVer.of("25.10.17--"), + + SemVer.of("25.10.17-999"), + + SemVer.of("25.10.17-z"), + SemVer.of("25.10.17-zz"), + SemVer.of("25.10.17-Z"), + SemVer.of("25.10.17-ZZ"), + }; + + assertArrayEquals( + new SemVer[] { + // 纯数字优先级低于非数字 + SemVer.of("25.10.17-999"), + + // 有字母或连接号时逐字符以 ASCII 的排序比较 + SemVer.of("25.10.17--"), + + SemVer.of("25.10.17-A"), + SemVer.of("25.10.17-AA"), + SemVer.of("25.10.17-Z"), + SemVer.of("25.10.17-ZZ"), + SemVer.of("25.10.17-a"), + SemVer.of("25.10.17-aa"), + SemVer.of("25.10.17-z"), + SemVer.of("25.10.17-zz"), + }, + Arrays.stream(versions) + .sorted() + .toArray(SemVer[]::new)); + + // 若开头的标识符都相同时,栏位比较多的先行版本号优先级比较高 + assertAll( + () -> assertTrue(compare(SemVer.of("25.10.17-ABC.DEF.1"), SemVer.of("25.10.17-ABC.DEF")) > 0), + () -> assertTrue(compare(SemVer.of("25.10.17-ABC.DEF.G"), SemVer.of("25.10.17-ABC.DEF")) > 0)); + + } + + @Test + void compareTo_ignoreBuildMeta() { + + assertNotEquals(SemVer.of("25.10.17"), SemVer.of("25.10.17+build.20251017.01")); + assertTrue(compare(SemVer.of("25.10.17"), SemVer.of("25.10.17+build.20251017.01")) == 0); // NOSONAR sonarqube(java:S5785) + + assertNotEquals(SemVer.of("25.10.17+build.20251017.02"), SemVer.of("25.10.17+build.20251017.01")); + assertTrue(compare(SemVer.of("25.10.17+build.20251017.02"), SemVer.of("25.10.17+build.20251017.01")) == 0); // NOSONAR sonarqube(java:S5785) + + assertNotEquals(SemVer.of("25.10.17-ABC.DEF"), SemVer.of("25.10.17-ABC.DEF+build.20251017.01")); + assertTrue(compare(SemVer.of("25.10.17-ABC.DEF"), SemVer.of("25.10.17-ABC.DEF+build.20251017.01")) == 0); // NOSONAR sonarqube(java:S5785) + + assertNotEquals(SemVer.of("25.10.17-ABC.DEF+build.20251017.02"), SemVer.of("25.10.17-ABC.DEF+build.20251017.01")); + assertTrue(compare(SemVer.of("25.10.17-ABC.DEF+build.20251017.02"), SemVer.of("25.10.17-ABC.DEF+build.20251017.01")) == 0); // NOSONAR sonarqube(java:S5785) + } + + @ParameterizedTest + @ValueSource(strings = { + "25.10.17", + "25.10.17-ABC.DEF.1", + "25.10.17+build.20251017.a2", + "25.10.17-ABC.DEF.1+build.20251017.a2", + }) + void test_equals(String value) { + final SemVer v1 = SemVer.of(value); + final SemVer v2 = SemVer.of(value); + + assertTrue(v1.compareTo(v1) == 0); // NOSONAR sonarqube(java:S5785) + assertTrue(v1.compareTo(v2) == 0); // NOSONAR sonarqube(java:S5785) + + assertEquals(v1, v1); + assertEquals(v1.hashCode(), v2.hashCode()); + assertEquals(v1, v2); + } + + @Test + void test_equals_null() { + assertFalse(SemVer.of("25.10.17").equals(null)); // NOSONAR sonarqube(java:S5785) + } + + @Test + void details() { + final String s = "25.10.17-ABC.DEF.1+build.20251017.02"; + final SemVer v = SemVer.of(s); + assertEquals(25, v.getMajor()); + assertEquals(10, v.getMinor()); + assertEquals(17, v.getPatch()); + + assertEquals("ABC.DEF.1", v.getPreReleaseVersion()); + assertEquals(s, v.getValue()); + assertEquals("v" + s, v.toString()); + + assertEquals("build.20251017.02", v.getBuildMetadata()); + } +}