qinghuan
← 回到知识探索

后端现场 / 动手实验

版本 1.10.0,为什么被排在 1.9.0 前面?

同一组版本号,分别按文字与 SemVer 规则排序;再把排序优先级、完整版本标识和能否升级分开。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先确定版本遵循哪套约定
  4. 把每一段数字比较清楚,别转成小数
  5. 预发布部分仍有自己的结构
  6. 排序一样,不等于文件内容相同
  7. 运行后留下两列顺序和一份拆分记录
  8. Maven 的版本比较不能直接替换成这个函数
  9. 排到最后的一项,也不是自动升级许可
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 会读 Python 的列表、元组与条件判断。
  • 了解依赖文件里会写版本号,但不需要修改任何真实依赖。
  • Python 3.10 或更新版本;主实验只用标准库。

发布列表里同时出现 1.9.0 与 1.10.0。你把它们当文字升序排列,后者跑到了前面;把版本号当小数,又会在第二个小数点处无法解释。这里没有神秘的新旧判断:排序程序只是按你交给它的规则比较字符。

本篇制作一张版本对照表。输入原文、拆分后的部分、文字顺序与版本优先级都保留下来,最后再观察两个“排序相等”的版本为什么仍不能合并成同一个文件。实验不安装软件,也不替你选择升级目标。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本;制作时实测 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • version_order.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的观察记录
  • verification.json:对应源码的验证范围
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B version_order.py

解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 有限长度输入的 SemVer 2.0.0 教学比较器;不执行依赖安装,也不证明升级兼容或文件内容一致。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

同一份版本清单,为什么排出两种顺序?

切换三组固定示例,先看原始清单,再并排比较文字升序与 SemVer 2.0.0 优先级升序。两列都保留完整版本标识。

每一段都按整数比较

6 条记录,6 个不同完整标识,6 组 SemVer 优先级。本组没有同优先级的条目。

按文字排序

完整字符串,按 ASCII 字符升序

  1. 0.10.0
    原始第 6 项
  2. 1.10.0
    原始第 2 项
  3. 1.10.1
    原始第 5 项
  4. 1.2.0
    原始第 4 项
  5. 1.9.0
    原始第 1 项
  6. 2.0.0
    原始第 3 项

按 SemVer 2.0.0 排序

优先级从低到高,同组保留输入次序

  1. 0.10.0
    原始第 6 项
  2. 1.2.0
    原始第 4 项
  3. 1.9.0
    原始第 1 项
  4. 1.10.0
    原始第 2 项
  5. 1.10.1
    原始第 5 项
  6. 2.0.0
    原始第 3 项

文字从左到右比字符;SemVer 依次比较主版本、次版本、补丁版本的整数大小。观察 1.9.0 与 1.10.0 的位置。

此面板只在浏览器比较固定字符串,规则与下载的 Python 实验一致。数字段保留原文,按位数和字符比较,不经过 Number 舍入;解析器的 200 字符限制是教学约定。稳定排序不表示发布日期,不证明升级兼容性;这里不执行依赖解析,也不运行 Maven 比较器。

先确定版本遵循哪套约定

版本号是一段带有约定的文字。日期式发布编号、数据库内部递增版本、Git 提交标识和 SemVer 不是同一种东西。看到几个点,只能说明它们长得相似;比较器必须先知道负责解释它的规则。

本实验明确选择 SemVer 2.0.0,并把输入限定为最多二百个字符。这项长度限制只是为了让课堂程序容易检查,不是规范对所有版本号规定的上限。我们保留完整原文,同时拆出三段核心数字、可选的预发布部分和构建信息。

原有工具兼容文章检查请求与响应是否仍满足使用方的要求;这里新增的是另一层基础工作:如何读懂版本字符串并给出可解释的顺序。即使排序完全正确,兼容性矩阵仍须单独验证。

把每一段数字比较清楚,别转成小数

文字比较从左到右看字符。1.10.0 与 1.9.0 的前两个字符 1 与 . 相同,接着比较字符 1 与 9,于是文字顺序与次版本数字的大小相反。解决办法不是加更多小数位,而是先识别结构,再把核心三段分别作为整数比较。

把核心部分表示为元组,先比较第一项,相同再看第二项,最后看第三项。这样 1.9.0 会排在 1.10.0 前;2.0.0 的第一项已经更大,后面两项为零也不会把它变小。代码使用整数,不经过浮点数转换,因此不会把很长的合法数字先舍入。

解析与比较分成两个函数,也让失败更容易解释:不符合约定的字符串在入口拒绝;比较器只处理已经拆好的结构。不能为了让排序继续,就把缺少的一段默默补成零,除非你已经另行约定了这种宽松输入。

预发布部分仍有自己的结构

在核心数字相同时,有预发布部分的版本排在对应正式版之前。本例把 rc.2 与 rc.11 分成两个标识,数字部分按整数比较;如果直接比较完整后缀文字,就会再次把 11 放到 2 前面。

这段比较还要区分纯数字标识与含字母或连字符的标识:纯数字按数值比较,并排在非纯数字之前,例如 2 < alpha。非纯数字按 ASCII 字符顺序比较,因此 A < a。已比较部分相同,则标识较少的排前面,例如 alpha < alpha.1。代码逐段检查,遇到第一个差异就返回;纯数字的前导零也会单独校验。

下载程序保留了规范提供的预发布顺序作为一条外部预期,再额外检查大小写、纯数字与非数字的交界。只拿自己打印的顺序作为“正确答案”会形成自证;对照公开约定和独立边界,才能发现比较器把规则写反的情况。

排序一样,不等于文件内容相同

SemVer 的优先级比较忽略加号后面的构建信息。因此 1.0.0+z 与 1.0.0+a 在这套比较里相等,但它们仍是两段不同的完整标识。代码同时保存 text 与拆分结构,避免用比较结果为零就覆盖其中一条记录。

Python 稳定排序会保留这些同优先级项在原输入中的相对顺序。本例特意先放 +z 再放 +a,排序后仍如此。这只是展示顺序的来源,没有证明 z 更新、文件更可靠,或这两个构建能互换。把输入顺序倒过来,显示也可以倒过来。

若发布界面需要确定的显示次序,可以另加明确的展示规则;如果部署系统需要准确选中一个包,则应记录完整坐标与内容校验依据。优先级适合回答如何排序,完整标识帮助选中候选,文件摘要帮助核对字节;三者承担的工作不同。

运行后留下两列顺序和一份拆分记录

先在纸上排八个版本,再运行下载包内的脚本。程序会生成 version-order-report.json,其中保留输入原文、文字排序、SemVer 排序、拆分字段、相同优先级示例和被拒绝的输入。它只在当前练习目录写入这一个报告。

除了示例顺序,断言还检查交换左右后比较符号相反,以及三项连起来的顺序保持一致。这些检查覆盖的是给定观察集合;它们不是对所有字符串的形式证明。本文也没有把这个教学解析器发布成生产依赖库。

不要只截取最后一行 PASS。把两列顺序中的第一个差异标出来,再从报告里的 core 与 prerelease 找到决定顺序的那一段。对同优先级项目,额外保留完整 text,说明为什么仍有多条不同记录。

展开收起代码 · 小实验python · 120 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""A bounded SemVer 2.0.0 precedence lesson, not a dependency resolver.

Accepts at most 200 ASCII characters per input. This size limit is local
teaching policy, not a length restriction imposed by the SemVer specification.
"""
from dataclasses import dataclass
from functools import cmp_to_key
from itertools import product
from pathlib import Path
import json
import re


PATTERN = re.compile(
    r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
    r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
    r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
)


@dataclass(frozen=True)
class Version:
    text: str
    core: tuple
    pre: tuple
    build: tuple


def parse(text):
    if not isinstance(text, str) or not 1 <= len(text) <= 200:
        raise ValueError("use a version string of 1..200 characters")
    match = PATTERN.fullmatch(text)
    if match is None:
        raise ValueError("invalid SemVer 2.0.0 syntax")
    major, minor, patch, pre, build = match.groups()
    pre = tuple(pre.split(".")) if pre else ()
    if any(part.isdigit() and len(part) > 1 and part[0] == "0" for part in pre):
        raise ValueError("numeric prerelease identifiers cannot have leading zeroes")
    return Version(text, (int(major), int(minor), int(patch)), pre,
                   tuple(build.split(".")) if build else ())


def sign_compare(left, right):
    return (left > right) - (left < right)


def compare(left, right):
    if left.core != right.core:
        return sign_compare(left.core, right.core)
    if not left.pre or not right.pre:
        return sign_compare(not left.pre, not right.pre)
    for a, b in zip(left.pre, right.pre):
        if a == b:
            continue
        a_numeric, b_numeric = a.isdigit(), b.isdigit()
        if a_numeric and b_numeric:
            return sign_compare(int(a), int(b))
        if a_numeric != b_numeric:
            return -1 if a_numeric else 1
        return sign_compare(a, b)  # ASCII input was enforced by the parser.
    return sign_compare(len(left.pre), len(right.pre))


def main():
    # Spec rule 11's published precedence chain is an external expected order.
    expected = ["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"]
    assert [v.text for v in sorted(map(parse, reversed(expected)), key=cmp_to_key(compare))] == expected
    assert all(compare(parse(a), parse(b)) == -1 for a, b in zip(expected, expected[1:]))
    assert compare(parse("1.9.0"), parse("1.10.0")) == -1
    assert compare(parse("1.0.0-2"), parse("1.0.0-alpha")) == -1
    assert compare(parse("1.0.0-A"), parse("1.0.0-a")) == -1
    assert compare(parse("1.0.0-1a"), parse("1.0.0-01a")) == 1
    assert compare(parse("1.0.0+build.2"), parse("1.0.0+build.11")) == 0
    assert compare(parse("1.0.0+001"), parse("1.0.0+1")) == 0
    assert parse("1.0.0+001").text != parse("1.0.0+1").text

    rejected = ["1.2", "v1.2.3", "01.2.3", "1.2.3-01", "1.2.3-", "1.2.3+",
                "1.2.3-alpha..1", "1.2.3+two+three", "1.2.3", " 1.2.3", "1.2.3\n"]
    for text in rejected:
        try:
            parse(text)
        except ValueError:
            pass
        else:
            raise AssertionError("invalid fixture accepted: " + repr(text))
    assert parse("1.2.3-01a+001").build == ("001",)

    examples = ["1.9.0", "1.10.0", "1.10.0-rc.11", "1.10.0-rc.2",
                "1.10.0+build.11", "1.10.0+build.2", "2.0.0-alpha", "0.10.0"]
    versions = list(map(parse, examples))
    # Check order properties on the observed set, independently of one printed sort.
    for a, b in product(versions, repeat=2):
        assert compare(a, b) == -compare(b, a)
    for a, b, c in product(versions, repeat=3):
        if compare(a, b) <= 0 and compare(b, c) <= 0:
            assert compare(a, c) <= 0
    ordered = sorted(versions, key=cmp_to_key(compare))
    tied_input = ["1.0.0+z", "1.0.0+a"]
    tied_output = [v.text for v in sorted(map(parse, tied_input), key=cmp_to_key(compare))]
    assert tied_input == tied_output  # Stable display order, not build priority.
    report = {
        "scheme": "SemVer 2.0.0 precedence; teaching input length <= 200",
        "input": examples, "text_sort": sorted(examples),
        "precedence_sort": [v.text for v in ordered],
        "parts": [{"text": v.text, "core": v.core, "prerelease": v.pre, "build": v.build} for v in versions],
        "same_precedence_different_text": {"input": tied_input, "stable_output": tied_output,
                                          "comparison": compare(*map(parse, tied_input))},
        "rejected_inputs": rejected,
        "scope": "No installation, Maven/npm resolution, compatibility proof or artifact integrity check.",
    }
    Path("version-order-report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
    print("text order:", ", ".join(report["text_sort"]))
    print("SemVer precedence:", ", ".join(report["precedence_sort"]))
    print("same precedence, different text:", tied_output)
    print("PASS: published chain, syntax edges, antisymmetry, transitivity and stable ties")


if __name__ == "__main__":
    main()

怎样运行:下载后解压,在练习目录运行 python -B version_order.py。无需第三方依赖;会写入 version-order-report.json。 2026-10-13 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。有限长度输入的 SemVer 2.0.0 教学比较器;不执行依赖安装,也不证明升级兼容或文件内容一致。

运行后对照结果
text order: 0.10.0, 1.10.0, 1.10.0+build.11, 1.10.0+build.2, 1.10.0-rc.11, 1.10.0-rc.2, 1.9.0, 2.0.0-alpha
SemVer precedence: 0.10.0, 1.9.0, 1.10.0-rc.2, 1.10.0-rc.11, 1.10.0, 1.10.0+build.11, 1.10.0+build.2, 2.0.0-alpha
same precedence, different text: ['1.0.0+z', '1.0.0+a']
PASS: published chain, syntax edges, antisymmetry, transitivity and stable ties

Maven 的版本比较不能直接替换成这个函数

Apache Maven 的 POM 文档明确区分自身的版本排序算法与 SemVer 2.0.0。它如何处理加号、限定词和大小写,需要按 Maven 的约定检查。Java 项目中出现了版本字符串,并不意味着解析它的工具正在使用本文规则。

制作本文时,我另外用本机 Maven 3.9.11 自带的 maven-artifact JAR 与 JDK 17 运行官方比较入口。对 1.0.0+build.2 和 1.0.0+build.11,工具输出前者小于后者;对 1.0.0-A 和 1.0.0-a,工具输出相等。本文 SemVer 程序对前一对判断同优先级,对后一对按区分大小写的规则比较。

这份额外记录只证明本机该版本比较器的结果,没有执行 Maven 依赖解析或修改 pom.xml。想复核时,可在已安装 Maven 的 lib 目录使用文档提供的 java -jar maven-artifact-版本.jar 入口。主下载包无需 Maven;不要把缺少该环境误认为 Python 主实验失败。

排到最后的一项,也不是自动升级许可

排序器只拿到版本字符串,看不到修改过哪些 API、当前工程依赖什么行为,以及发布者是否遵守了声明的约定。它也没有获取发布日期。因此“优先级更高”“发布得更晚”“我的项目能升级”不能直接写成同一句话。

若要设计真正的升级流程,应把候选排序放在合适的位置,再检查来源、版本说明、兼容约束和项目验证结果。本文到排序报告为止,没有联网选包、安装依赖、处理版本范围,也没有把预发布标签当作效果或安全性的评分。

最终交付一份能解释的比较记录:使用哪套规则,哪些输入不合法,哪里第一次出现差异,哪些版本只是同优先级而非同标识。相比只返回一个“最新版本”,这份记录更容易让下一位维护者发现规则用错了。

记住这三点

  • 先识别版本方案,再解析组成部分;不要按小数理解多段版本。
  • 比较结果为零只表示同优先级,完整版本标识与文件内容仍需分别记录。
  • SemVer 排序、Maven 比较与项目升级判断是不同工作。

别把例子用过头

  • 先转浮点数再比较,丢掉版本结构。
  • 自动删掉前缀或补零,却没有说明输入规则已经改变。
  • 把稳定排序保留的输入次序当成构建新旧证据。
  • 把自写教学比较器直接用于所有包管理器,或据此自动安装最高版本。

自己试一小步

给一个发布列表设计“按优先级分组,但保留不同构建”的展示,怎样避免把两份包合并?

  1. 自拟八个版本,至少包含一次次版本跨两位数、两个预发布版本和两个只在构建信息上不同的版本。
  2. 在运行前写下排序预测与预期相等的分组,不使用文中已有的八项输入。
  3. 用脚本副本生成拆分与排序报告,再反转同优先级两项的输入顺序观察结果。
  4. 另加三个不合法输入,说明是规范规则还是本实验的长度策略拒绝它们。

做到这些,就算完成

  • 完整原文一直保留,同优先级的不同构建仍有独立记录。
  • 每个排序差异能指出具体组成部分,没有凭标签猜发布日期。
  • 区分 SemVer 规则、Python 稳定排序和自己新增的展示规则。
  • 没有将排序结果写成已验证的升级兼容性或内容一致性。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。