From ef61ecc2dfd2b54e72f898c8e73a4e26dfa60d5f Mon Sep 17 00:00:00 2001 From: ZhouXY108 Date: Sun, 12 Oct 2025 03:13:43 +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?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 `SemVer` 类用于解析和比较语义版本号,支持版本号的解析、比较、序列化等功能。 该实现遵循语义化版本规范(SemVer 2.0.0),包括主版本号、次版本号、修订号、先行版本号和构建元数据的处理。 同时添加了基础的单元测试,验证版本号之间的排序逻辑是否符合预期。 后续需完善正则表达式优化、补充完整单元测试以及相关文档说明。 --- cspell.json | 1 + .../zhouxy/plusone/commons/model/SemVer.java | 209 ++++++++++++++++++ .../plusone/commons/model/SemVerTests.java | 55 +++++ 3 files changed, 265 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..011efad --- /dev/null +++ b/plusone-commons/src/main/java/xyz/zhouxy/plusone/commons/model/SemVer.java @@ -0,0 +1,209 @@ +/* + * 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.Arrays; +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; + +// TODO [优化] 优化正则表达式 +// TODO [补充] 完善单元测试 +// TODO [doc] javadoc、README.md + +/** + * SemVer 语义版本号 + * + * @author ZhouXY108 + * @since 1.1.0 + */ +public class SemVer implements Comparable, Serializable { + private static final long serialVersionUID = 458265121025514002L; + + private final String value; + + private final int[] versionNumbers; + private final String[] preReleaseVersion; + 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*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"; + private static final String BUILD_METADATA = "(?:\\+(?[0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?"; + + private static final Pattern PATTERN = Pattern.compile( + "^" + VERSION_NUMBERS + PRE_RELEASE_VERSION + BUILD_METADATA + "$"); + + private SemVer(String value, int[] versionNumbers, @Nullable String[] preReleaseVersion, @Nullable String buildMetadata) { + this.value = value; + this.versionNumbers = versionNumbers; + this.preReleaseVersion = preReleaseVersion; + this.buildMetadata = buildMetadata; + } + + 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(); + + final String[] preReleaseVersion = preReleaseVersionPart != null + ? Splitter.on('.').splitToStream(preReleaseVersionPart).toArray(String[]::new) + : null; + + return new SemVer(value, versionNumbers, preReleaseVersion, buildMetadataPart); + } + + public int getMajor() { + return this.versionNumbers[0]; + } + + public int getMinor() { + return this.versionNumbers[1]; + } + + public int getPatch() { + return this.versionNumbers[2]; + } + + public String getBuildMetadata() { + return buildMetadata; + } + + @Override + public int compareTo(@Nullable SemVer that) { + if (that == null) { + return 1; + } + int result = compareVersionNumbers(that); + if (result != 0) { + return result; + } + return comparePreReleaseVersion(that); + } + + public String getValue() { + return value; + } + + + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(versionNumbers); + result = prime * result + Arrays.hashCode(preReleaseVersion); + result = prime * result + Objects.hash(value, buildMetadata); + return result; + } + + @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) && Arrays.equals(versionNumbers, other.versionNumbers) + && Arrays.equals(preReleaseVersion, other.preReleaseVersion) + && Objects.equals(buildMetadata, other.buildMetadata); + } + + @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) { + final String[] preReleaseVersionOfThis = this.preReleaseVersion; + final String[] preReleaseVersionOfThat = that.preReleaseVersion; + byte thisWithoutPreReleaseVersionFlag = preReleaseVersionOfThis == null ? (byte) 1 : (byte) 0; + byte thatWithoutPreReleaseVersionFlag = preReleaseVersionOfThat == null ? (byte) 1 : (byte) 0; + if ((thisWithoutPreReleaseVersionFlag | thatWithoutPreReleaseVersionFlag) == 1) { + return thisWithoutPreReleaseVersionFlag - thatWithoutPreReleaseVersionFlag; + } + @SuppressWarnings("null") + 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(@Nullable String str) { + if (str == null || str.isEmpty()) { + return false; + } + + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } +} 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..8430a4f --- /dev/null +++ b/plusone-commons/src/test/java/xyz/zhouxy/plusone/commons/model/SemVerTests.java @@ -0,0 +1,55 @@ +package xyz.zhouxy.plusone.commons.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Arrays; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SemVerTests { + @Test + void testCompareTo() { + + SemVer[] versions = { + SemVer.of("1.0.0-alpha.beta"), + SemVer.of("1.0.1"), + SemVer.of("1.0.0-beta.11"), + SemVer.of("1.0.0-beta"), + SemVer.of("1.0.0-alpha"), + SemVer.of("1.0.0-beta.2"), + SemVer.of("1.0.0-rc.1"), + SemVer.of("1.0.0"), + SemVer.of("1.0.0-alpha.1"), + + // SemVer.of("10.20.30.40.50-RC2.20250904"), + // SemVer.of("10.20.30.40.50-RC1.20250801"), + // SemVer.of("10.20.30.40.50-M1.2"), + // SemVer.of("10.20.30.40.50-M1"), + // SemVer.of("10.3.30-50"), + // SemVer.of("10.20.30-50"), + // SemVer.of("10.20.30-beta"), + // SemVer.of("10.20.30-alpha"), + // SemVer.of("10.20.30-2a"), + // SemVer.of("10.20.30-RC1"), + // SemVer.of("10.20.30-RC2"), + // SemVer.of("10.20.30-M2"), + // SemVer.of("10.20.30-M1"), + // SemVer.of("10.20.30-20"), + // SemVer.of("10.20.30.40-10"), + }; + + String compareResult = Arrays.stream(versions) + .sorted() + .map(SemVer::getValue) + .collect(Collectors.joining(" < ")); + log.info(compareResult); + + assertEquals( + "1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0 < 1.0.1", + compareResult); + } +}