odoo-089 是我最近在 Odoo 18 项目里领到的一个内部任务单号,需求一句话:给所有模块加上严格的版本 version 检查。刚开始我觉得这活儿太简单了,版本号不就是写一下完事吗?但等我把项目里十几个第三方模块的__manifest__.py翻了一遍,又对比了数据库ir_module_module里的记录之后,我意识到这事一点都不简单。缺 version 的、格式乱写的、前后版本对不上的、迁移脚本目录名跟 manifest 不匹配的,各种问题凑在一起,平时安装不报错,一旦做升级就会变成连环坑。
这篇文章我就围绕这个任务完整复盘一遍:Odoo 18 的版本机制到底怎么回事,为什么要把检查做得这么严,以及我最终实现的一套可落地的检查方案和踩坑记录。适合正在做 Odoo 实施、模块开发、升级维护的同行参考,尤其是被模块升级问题折磨过的人。
1. 需求背景:版本检查为什么需要“严格”
1.1 Odoo 模块升级中的“版本”到底有什么用
很多刚接触 Odoo 的同学会把模块的 version 当作一个展示字段,随手填个1.0就完事了。但实际上 Odoo 的模块版本号是驱动升级流程的核心标识。你可以把它理解成快递物流里的包裹编号,没有这个编号,仓库就不知道这件货是新的还是老的,该不该拆包、该走哪条流水线。
在 Odoo 18 中,模块的 manifest 与数据库表ir_module_module是联动的。数据库里存了这个模块的state(安装状态)和latest_version(数据库侧最新记录的版本号)。当你执行-u module_name更新模块时,Odoo 会先读取模块目录下的 manifest,再和数据库里的latest_version做比较。如果两者一致,Odoo 认为这个模块没有新版本,只重新载入代码,不会触发升级流程。如果版本号发生变化,Odoo 会把它标记为待升级状态,并进入迁移脚本(migrations)的执行流程。
再往下说一层,迁移脚本到底执行哪些版本目录,也是完全依赖版本号比较的。比如数据库旧版本是18.0.1.0.0,你新版本的 manifest 写的是18.0.1.2.0,Odoo 在执行升级时会把旧版本到新版本之间的迁移目录找出来,按顺序跑一遍。如果版本号写错、漏写、或者目录名跟版本号对不上,这个迁移脚本就会被静默跳过。数据不动、结构不变,升级完成后你以为一切都正常,等真正用到新字段的时候才发现表里根本没有,那时候再排查就晚了。
所以版本号不是“可写可不写”的元数据,它是 Odoo 决定“要不要升级、怎么升级”的文件依据。严格检查的第一层意义,就是保证这个依据是准确、可解析、可比较的。
1.2 版本管理混乱引发的三个典型故障
我在检查这个 Odoo 18 项目的存量模块时,很快就发现了几类常见的版本问题,每一个都对应着一个真实故障。
第一类:__manifest__.py里完全没有version字段,或者只写了一个1.0。这种情况下 Odoo 会按默认逻辑处理,但跨大版本升级时数据库里的latest_version会记录得非常混乱。比如某个模块从 Odoo 17 升上来,旧版本记录是17.0.1.0.0,新版本如果不带系列前缀写成1.0,Odoo 解析版本号时就会把它当成一个不完整的版本元组,与旧版本比较的时候结果完全不可预期,升级脚本可能不触发,也可能重复触发。
第二类:版本格式不规范。Odoo 官方推荐的格式是{odoo_series}.{major}.{minor}.{patch},比如18.0.1.2.0。但实际项目里我见到过18.0.1.2(少一段)、18-0-1-2-0(用横线连接)、18.0.1.2.0.1(多一段)这样的写法。这些问题在平时odoo-bin -u的时候不一定立刻报错,但一旦进入迁移脚本匹配阶段,Odoo 会把版本字符串按.切分成元组再逐个比较,缺一段少一段都会导致目录匹配失败。
第三类:迁移脚本目录名和模块版本号不一致。例如 manifest 声明版本18.0.1.5.0,但migrations目录下却建了18.0.1.4.0、18.0.1.6.0,或者干脆写成了18_0_1_5_0。Odoo 找迁移脚本的时候严格按版本目录名来枚举,目录名对不上号,脚本就静默跳过。这类问题最坑,因为升级没有任何报错,但业务数据层面已经悄悄地不一致了。
1.3 这次任务的目标
结合上面这些问题,这次任务的目标就变得很明确:我不能只解决某一个模块的版本问题,而是要建立一套“防再犯”的机制。具体来说有三层要求。
第一,存量模块要能一次性扫描出来,哪些缺版本、哪些格式错、哪些目录名不合法,全部列成清单,让开发逐个整改。第二,增量模块要能在合入代码之前就被拦截,也就是要在 CI 或部署前做静态检查,任何新提交的模块如果版本不规范,直接打回。第三,数据库层面也要做校验,已经安装的模块如果出现版本倒挂,也要能查出来。
弄清楚目标之后,我没有急着写检查脚本,而是先去把 Odoo 18 的版本声明机制和加载逻辑重新捋了一遍,毕竟检查规则要是跟框架机制理解有偏差,做出来的工具反而会误报。
2. Odoo 18 的版本声明机制与检查逻辑
2.1 manifest 里 version 字段的正确写法
先讲标准。Odoo 18 的模块版本号完整格式是五个数字段,形如:
18.0.1.0.0前两段18.0表示这是针对 Odoo 18 系列写的模块,后面三段1.0.0是模块自己的版本。约定俗成的理解是:1是大版本号,0是小版本号,最后一位是补丁号。比如18.0.2.1.3可以解读为这个模块在2.1功能版本上的第3次修复。
为什么要带系列前缀?因为同一个模块经常要同时维护多个 Odoo 系列的分支。比如你有一个自定义模块,既要跑在 17.0 也要跑在 18.0,两个分支的代码可能不同,版本演进也可能不同。如果版本号写成2.1.3,你就没法一眼看出它是给哪个系列用的。带上17.0和18.0前缀之后,分支归属非常清晰。
这里我给出一个推荐的版本命名规范表,直接照着做就不会错:
| 场景 | 推荐写法 | 含义 |
|---|---|---|
| 新模块首次发布 | 18.0.1.0.0 | Odoo 18 系列下的 1.0.0 |
| 功能性小版本迭代 | 18.0.1.1.0 | 加了新功能,patch 位归零 |
| 修复 bug 或优化 | 18.0.1.1.1 | 在 1.1.0 基础上的修复 |
| 不兼容的大改动 | 18.0.2.0.0 | 主版本升到 2,接口可能变 |
| 跨系列从 17 迁到 18 | 18.0.1.0.0 | 基于 18 重新开始,不要沿用17.0.* |
还有一个细节需要注意:Odoo 官方模块在发布时通常会维护__manifest__.py里的version和--upgrade到 pypi 等发布渠道的版本一致性。你自己的私有模块不需要跟 pypi 挂钩,但至少要保证 manifest 里的版本号和migrations目录里的版本目录严格对应。
2.2 depends 依赖与版本系列匹配
Odoo 模块的depends字段声明的是“依赖哪些模块”,它不直接声明依赖模块的版本。比如你依赖stock,那你只能写depends列表里加stock,没法写“依赖 stock 的 18.0.1.2.0”。这意味着 Odoo 的依赖解析是模块名级别的,而不是模块版本级别的。
那版本检查跟 depends 有什么关系?关系在于模块系列的匹配。Odoo 18 加载模块时,会把所有要加载的模块按依赖图排序,逐级加载。如果你的模块depends里写了一个还是17.0系列开发的旧模块,而这个旧模块在 18 下没有正确更新版本号,那么它在依赖图里的加载顺序和升级状态都会出问题。最典型的现象就是:你的模块在depends里依赖它,它却停留在旧版本逻辑里,新框架的 API 一调用就报错。
所以在严格的版本检查方案里,我会额外检查一个点:modules 之间的系列一致性。简单说就是扫描全部模块的 version,确认目标 Odoo 系列相同,不允许出现同一套依赖图里混着17.0.*和18.0.*的版本号(除非你明确知道某个第三方模块就是跨系列兼容的,但这种情况也应该在检查规则里配置白名单)。
另外,external_dependencies里的python和bin字段也需要纳入版本检查思路。Odoo 18 在加载含external_dependencies的模块时,默认只会检查对应 Python 库或二进制工具是否存在,并不会校验版本号。比如你声明依赖requests,系统只检查能不能导入requests,不会管它是 1.x 还是 2.x。如果你要保证严格版本检查,这一点必须自己补,我后面会讲实现方式。
2.3 模块加载时版本号的解析与比对
理解了版本写法之后,我们再看 Odoo 18 内部处理版本号的几个关键环节,这部分是排查问题的基础。
Odoo 在启动和更新模块时,核心流程大致是:扫描 addons 路径下每个安装包,读取__manifest__.py;然后load_modules根据数据库ir_module_module的记录判断模块状态;对要更新的模块,读取它当前的latest_version,与 manifest 里的新版本对比;不一致的进入升级队列;升级队列执行时会再对比迁移脚本目录版本,找到需要执行的迁移脚本。
版本号的比较逻辑其实非常简单,就是按.切分字符串,每段转成整数,组成元组,再按元组大小比较。例如18.0.1.2.0和18.0.1.10.0比较,Odoo 会把它们解析成(18, 0, 1, 2, 0)和(18, 0, 1, 10, 0),逐位比较后认为后者的版本更高。这也解释了为什么“缺一段、多一段”的版本号会出问题——切分出来的元组长度不一致,比较规则就变得不可预期。
迁移脚本目录的匹配逻辑也是基于同样的版本元组。Odoo 会枚举模块migrations目录下的所有子目录,把目录名解析成版本号,然后筛选出大于等于旧版本、小于等于新版本的目录,并按版本号排序依次执行。这要求目录名必须是一个能被完整解析的版本格式,哪怕只是多一个.0,目录名和版本号都匹配不上。
理解了这套机制,你就明白为什么“严格检查”的重点是保证版本号的可解析性和可比性。检查逻辑说白了就是提前模拟 Odoo 的解析逻辑,把它要做的事在开发阶段先做一遍。
3. 实现一套严格的版本检查逻辑
3.1 设计思路:在哪几个环节卡住非法版本
我最终实现的方案分为三个检查环节,每个环节解决不同的问题。
第一个环节是静态扫描,在代码进入测试环境之前执行。扫描所有 addons 目录下的模块,检查缺失 version、版本格式非法、系列不匹配、迁移目录命名非法等问题。这个环节不需要启动 Odoo 服务,执行速度快,适合挂在 CI 流水线或者 pre-commit hook 里。
第二个环节是数据库对比,在升级前执行。通过 Odoo shell 或者脚本读取ir_module_module中的latest_version,与模块当前 manifest 版本做对比。目的是发现版本倒挂的情况,也就是模块新代码版本比数据库里记录的还低,这种升级极其危险。
第三个环节是运行期钩子,在自定义模块的加载阶段做最终校验。静态扫描难免有遗漏,运行期钩子相当于最后一道防线,发现问题直接抛异常,宁可不让服务启动,也不能带病升级。
实际操作中我建议把静态扫描和数据库对比作为主力,运行期钩子要看项目情况决定要不要启用,因为对所有模块强制校验可能会拖慢启动速度,而且有些第三方模块确实不太规范,直接抛异常会影响业务上线。
3.2 编写 version_check.py 工具模块
下面是我写的静态检查脚本核心代码,可以直接拿去做二次开发。这个脚本不依赖 Odoo 框架,只依赖 Python 标准库,方便在任何环境里跑。
import re from pathlib import Path SERIES = (18, 0) VERSION_PATTERN = re.compile(r'^\d+\.\d+\.\d+\.\d+\.\d+$') MIGRATION_PATTERN = re.compile(r'^\d+\.\d+\.\d+\.\d+\.\d+$') def parse_version(version): return tuple(int(part) for part in version.split('.')) def load_manifest(module_path): manifest_path = module_path / '__manifest__.py' if not manifest_path.exists(): return None with open(manifest_path, encoding='utf-8') as f: content = f.read() sandbox = {} exec(content, sandbox) for value in sandbox.values(): if isinstance(value, dict): return value return {} def check_manifest_version(module_path): errors = [] manifest = load_manifest(module_path) if manifest is None: errors.append(f'{module_path.name}: 缺少 __manifest__.py') return errors version = manifest.get('version', '') if not version: errors.append(f'{module_path.name}: version 字段缺失') elif not VERSION_PATTERN.match(version): errors.append(f'{module_path.name}: version 格式不合法: {version}') else: parts = parse_version(version) if parts[:2] != SERIES: errors.append(f'{module_path.name}: 版本系列与当前 Odoo 不一致: {version}') migration_root = module_path / 'migrations' if migration_root.exists(): for child in migration_root.iterdir(): if not child.is_dir(): continue if not MIGRATION_PATTERN.match(child.name): errors.append(f'{module_path.name}/migrations/{child.name}: 目录命名不合法') return errors def scan_addons(addons_paths): error_count = 0 for base in addons_paths: root = Path(base) for manifest_file in root.glob('*/__manifest__.py'): module_path = manifest_file.parent module_name = module_path.name if module_name.startswith('.') or module_name.startswith('__'): continue errors = check_manifest_version(module_path) if errors: error_count += len(errors) for error in errors: print(f'[VERSION-CHECK] {error}') return error_count if __name__ == '__main__': import sys addons_paths = sys.argv[1:] if not addons_paths: print('usage: python version_check.py <addons_path1> <addons_path2> ...') sys.exit(2) failed = scan_addons(addons_paths) sys.exit(1 if failed else 0)这段代码的核心逻辑不复杂,但有三个地方我要特别说明。
第一,load_manifest用exec去读__manifest__.py,主要是为了兼容各种写法。有的模块文件里是manifest = {...},有的是直接把 dict 写在文件顶层,exec配合“取第一个 dict 值”能覆盖这两种情况。生产环境如果你有更严格的代码审计要求,建议改成ast.literal_eval加自定义解析,或者直接复用 Odoo 源码里读取 manifest 的函数。
第二,版本格式校验用的正则很简单,但注意不要过度放松。我见过有人写^\d+\.\d+\.\d+就想校验,那会放过缺段的问题。既然目标是严格检查,就严格到底,五位数字一段都不能少。
第三,迁移目录的命名规范要跟 Odoo 官方保持一致。特殊情况下有些模块会用18.0.1.2.0.post1这种带后缀的版本目录,这个方案会拦下来。如果项目里确实需要支持这种写法,可以把MIGRATION_PATTERN扩展成允许后缀,但我不建议,因为 Odoo 默认比较逻辑对带后缀的目录名处理起来非常容易踩坑,能用标准格式就别玩花的。
3.3 注册为 Odoo 18 的启动钩子
静态脚本扫描完了,接下来要解决“运行期怎么拦”的问题。我给一个基于 Odoo 模块的实现思路,适合作为兜底防线。
新建一个模块,比如叫version_guard,它的__init__.py里导出一个检查函数,并在 manifest 中把它设为项目里几乎所有业务模块的依赖,或者通过--load=version_guard显式加载。在这个模块被加载时,遍历 addons 路径,执行前面check_manifest_version的扫描逻辑,发现问题直接抛异常。
# addons/version_guard/__init__.py from odoo import api, SUPERUSER_ID def _check_module_versions(env): addons_path = env['ir.config_parameter'].get_param('version_guard.addons_path', '') if addons_path: paths = addons_path.split(',') failed = scan_addons(paths) if failed: raise RuntimeError(f'version check failed with {failed} errors, fix before upgrade') def post_init_hook(env): _check_module_versions(env) def post_load(): # 在加载完整模块图之后执行,作为兜底 import odoo from odoo.api import Environment env = Environment(odoo.registry(odoo.tools.config['db_name']).cursor(), SUPERUSER_ID) try: _check_module_versions(env) finally: env.cr.rollback()再强调一下,这个“运行期钩子”是双刃剑。它确实能在服务启动前卡住问题,但也会带来两个实际困扰。一是第三方模块如果版本本来就不规范,你的项目又必须依赖它,这个钩子直接拒绝启动会导致整个系统不可用,需要配白名单逻辑。二是每次启动都扫描全部 addons 文件系统,模块多的时候会有性能开销。所以我的建议是把它作为测试环境的强制卡点,生产环境可以改成只告警不阻断,或者干脆省略,依靠前两个环节保证质量。
3.4 验证:构造异常模块并观察拦截效果
工具写好之后一定要做验证,不能想当然。我构造了三个异常模块,放在一个临时的 addons 目录里跑了一遍。
第一个模块bad_missing_version的 manifest 里不写version字段,扫描结果直接报错:“bad_missing_version: version 字段缺失”。第二个模块bad_format_version写成'version': '18.0.1.2',少了一段,扫描报“格式不合法”。第三个模块wrong_series明明在 18 环境里,版本却写17.0.1.0.0,扫描报“版本系列与当前 Odoo 不一致”。
这三个案例基本覆盖了项目里最常见的三类问题。验证通过之后,我又专门造了一个迁移目录名错误的模块,migrations下放了一个18.0.1.2_0的目录,检查脚本同样准确报出“目录命名不合法”。说明这套逻辑可以稳定抓出黑盒之外的问题,具备上线条件。
4. 实操中的踩坑记录与版本升级建议
4.1 常见错误速查表
做这个项目期间我收集了不少版本相关的报错和故障现象,整理成一张速查表,方便你遇到问题时直接对号入座。
| 故障现象 | 根本原因 | 处理办法 |
|---|---|---|
| 模块更新后代码变化但迁移脚本没执行 | manifest 版本号没变或迁移目录名对不上 | 对比数据库 latest_version,修正版本号并核对 migrations 目录命名 |
odoo-bin -u报 version 解析异常 | 版本号切分后长度不一致 | 统一改成18.0.x.y.z五位格式 |
| 跨系列升级后老数据在新模块里查不到 | 升级时迁移脚本被静默跳过 | 强制用检查脚本扫描所有版本目录 |
| 模块之间加载顺序混乱 | 某个被依赖模块还停留在旧系列版本 | 检查 depends 列表中所有模块的版本系列 |
| Python 库依赖版本不满足 | external_dependencies只检查存在性 | 增加自定义版本范围校验 |
| 版本倒挂:manifest 版本低于数据库版本 | 分支合并时将高版本代码回退了 | 通过数据库对比脚本在升级前拦截 |
第一条和最后一条是实际踩坑最多的。尤其是“版本倒挂”,它发生的场景很常见:你做一个 hotfix,在一个旧的 release 分支上修复问题,不小心把版本号改成了比主分支还低的数字,然后合并到主分支。主分支的模块版本反而被拉低了,CI 和数据库都不报警,结果升级时迁移脚本不执行,数据整理逻辑就丢了。
4.2 从 17.0 迁移到 18.0 的版本号整改清单
如果你正好在做 Odoo 17 到 18 的升级,这里有一份我实际用过的整改清单,照着做能把版本问题拦在升级动作之前。
第一步,盘点存量模块版本。在旧 17.0 环境里执行数据库查询,把所有模块的latest_version导出来,跟新 18.0 代码仓库里的 manifest 版本做对比。这一步能快速找出哪些模块的版本号根本没有抬升,升级后可能不触发迁移脚本。
第二步,修改 manifest 版本号。把每个模块的 version 从17.0.x.y.z改成18.0.x.y.z。这里有个容易踩的坑:很多人以为只要把17.0改成18.0就行,其实后面三段也要审视。如果你在 17.0 里做过 5 次修复,版本是17.0.1.3.0,迁移到 18.0 后应该选择一个合理的起点,比如18.0.1.0.0,而不是继续沿用18.0.1.3.0。因为迁移目录要按版本排序,如果沿用旧的版本号,但迁移脚本目录里只放了一个18.0.1.0.0,Odoo 认为新版本是18.0.1.3.0,它会去找18.0.1.1.0到18.0.1.3.0的目录,找不到就跳过,而你的数据整理脚本可能在18.0.1.0.0目录里已经定义过了,两边就对不上。
第三步,检查迁移脚本目录。逐模块把migrations/下的目录名和 manifest 版本对照起来,不够的补齐,多余的要确认是否真的需要保留。Odoo 在执行升级时会把旧版本到新版本之间所有目录跑一遍,老版本目录放在那里虽然不会立刻报错,但会让你以后维护时更加混乱。
第四步,升级后验证。在测试环境先执行模块升级,然后查ir_module_module的latest_version是否已经同步成新版本,再抽查几个关键模块确认迁移脚本是否真实执行过,可以通过日志关键字或直接看数据表结构变化来判断。
4.3 版本管理最佳实践与经验总结
做完这个任务之后,我最大的感受是:版本检查必须前置到代码合入的环节,不能等到升级出故障再去补。后面我把version_check.py接进了 CI,任何 MR 提交的模块如果触发版本检查错误,流水线直接红。刚开始有两个同事因为版本号格式不规范被打回,还觉得这个检查太多事,后来他们自己在升级时遇到了一次迁移脚本没执行的问题,才意识到这个卡点的价值。
下面分享几条我自己沉淀下来的版本管理原则。
第一,版本号的语义要约定清楚。大版本变化代表不兼容或结构性调整,小版本变化代表新功能,最后一位只做修复和优化。这里的“不兼容”不单指接口变化,也包括数据迁移逻辑的加入。凡是加了迁移脚本的版本,建议小版本号递增,不要把迁移脚本塞进只改补丁号的版本里。
第二,每次升级必须同步检查迁移脚本目录。Odoo 的迁移脚本依赖版本目录名匹配,这是个很容易被忽略的强约束。我建议在 CI 检查中强制要求:只要 manifest 版本号变化,且存在migrations目录,就必须有一个与新版本号完全一致的子目录,否则检查不通过。
第三,数据库版本对比要纳入发布流程。静态扫描只能发现“格式对没对”,不能发现“版本倒没倒”。我在项目里写了一个 shell 的一键脚本,更新前先连数据库把库内版本 dump 出来,跑更新后再对比一次,两边的版本变化必须符合预期。这个动作多花一分钟,但能避免很严重的升级事故。
第四,允许有白名单机制。世界上不是所有第三方模块都遵守规范,有些模块的版本号就是随便写的,但你又不得不依赖它。严格的检查不代表死板,遇到这种模块可以配置一个白名单,跳过它的系列和格式校验,但要把跳过原因写清楚,留档备查。完全一刀切会把项目带入另一个极端。
最后再说一个我自己一直保持的习惯:升级模块前,先把模块目录的 manifest 版本号和数据库中latest_version抄出来,一遍一遍确认“旧版本 -> 新版本 -> 迁移目录”这条链路上没有断点。只要这条链路是通的,Odoo 升级出问题的概率就会低很多。版本检查这件事,看似只是加了几行正则,实际上是在给整个升级流程上保险。