forked from plusone/plusone-commons
feat(model): 添加 SemVer 表示语义版本号
新增 `SemVer` 类用于解析和比较语义版本号,支持版本号的解析、比较、序列化等功能。 该实现遵循语义化版本规范(SemVer 2.0.0),包括主版本号、次版本号、修订号、先行版本号和构建元数据的处理。 同时添加了基础的单元测试,验证版本号之间的排序逻辑是否符合预期。 后续需完善正则表达式优化、补充完整单元测试以及相关文档说明。
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
"aliyun",
|
"aliyun",
|
||||||
"baomidou",
|
"baomidou",
|
||||||
"Batis",
|
"Batis",
|
||||||
|
"buildmetadata",
|
||||||
"Consolas",
|
"Consolas",
|
||||||
"cspell",
|
"cspell",
|
||||||
"databind",
|
"databind",
|
||||||
|
|||||||
@@ -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 <luquanlion@outlook.com>
|
||||||
|
* @since 1.1.0
|
||||||
|
*/
|
||||||
|
public class SemVer implements Comparable<SemVer>, 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 = "(?<numbers>(?<major>0|[1-9]\\d*)\\.(?<minor>0|[1-9]\\d*)\\.(?<patch>0|[1-9]\\d*)(\\.(0|[1-9]\\d*)){0,2})";
|
||||||
|
private static final String PRE_RELEASE_VERSION = "(?:-(?<prerelease>(?: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 = "(?:\\+(?<buildmetadata>[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user