Open edX 架构决策解析:如何在 LMS 中限制 Modulestore 的使用(ADR 0011)
【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform
本文基于 Open edX(openedx-platform)仓库中的架构决策记录 ADR 0011:Limit LMS Modulestore access to the courseware app 展开。该文档规定了 LMS 侧 Django 应用访问课程内容数据的边界原则,并给出了从 Modulestore 迁移到 CourseOverviews、Learning Sequences 等高性能替代 API 的完整转换指南。读完本文,你将掌握 LMS 应用脱离 Modulestore 的改造路径、"发布时推数据"的架构模式,以及 Learning Sequences 这一参考实现中信号、Celery 任务与管理命令的完整调用链。
背景:为什么 LMS 要限制 Modulestore 的访问
Open edX 的 LMS 中部署了众多 Django 应用,它们常常需要查询由课程团队编写的内容,例如 Sequence(单元分组)、Unit 或 Problem(习题)。历史上,这些应用惯用的手段是直接调用 Modulestore——它可以返回整棵由 XBlock 组成的课程图。但如 ADR 所述,Modulestore 是一个庞大且复杂的共享系统,长期以来是大量 Bug 和性能问题的来源;过去数年 Modulestore 的性能虽有逐步改善,但代价是引入了更多复杂性。
唯一被豁免的是 courseware 应用本身:它必须借助 XBlock runtime 渲染 Unit,因此必须访问 Modulestore,且在可预见的未来抽取这部分逻辑的成本过高。
ADR 给出的核心结论是:除 courseware 外,LMS 中的新功能不应再调用 Modulestore。下文将完整继承原文档的五条决策、五项目标,并结合仓库源码逐层展开转换指南。
五项核心决策
ADR 的 "Decisions" 章节给出五条明确规定:
- 新功能不得在 LMS 中调用 Modulestore。所有新增功能必须在不访问 Modulestore 的前提下实现。
- 存量功能应"顺手清理":各团队在修改已有功能时,应 opportunistically(借机)移除其中的 LMS Modulestore 依赖。
- Studio 进程不受此限:应用仍然可以从 Studio 进程(rpro)访问 Modulestore——Modulestore 访问被允许存在,只是被限制在了 Studio 一侧。
- 优先使用更新、更受限的 LMS API:包括用于课程配置元数据的 CourseOverviews,以及用于课程大纲的 Learning Sequences。
- 其他课程内容数据应通过"发布时推送"模式处理:应用应监听
course_published信号,启动 Celery 任务在 Studio 进程中查询 Modulestore,并把数据推入自己的数据模型。
目标:这套约束带来什么收益
ADR 用五个目标论证了这一约束的价值,每一点对应的源码证据都可以在仓库中找到。
1. 应用正确性更容易推理。Modulestore 存在大量隐蔽的边界情况:非标准的课程层级、Studio 通常不提供选项却可以被设置的继承属性、Section 级 A/B 实验等。而面向 LMS 的关系型数据表(如course_overviews、learning_sequences)对课程数据做的是"有意的、有文档的"假设。应用开发者可以放心构建在这些更简单的模型之上,而不必惦记 Modulestore 数据中复杂的灵活性。
2. 应用行为更可预测。Modulestore 会一次性抓取课程的大块内容,导致大课与小课、启用高级功能与未启用的课程之间性能差异巨大。从更简单的应用数据模型提供 LMS 请求,运行行为会可预测得多。ADR 的终极目标是:只在编写(authoring)和发布(publishing)阶段访问 Modulestore,绝不在向学员提供内容时访问。
3. 测试更容易写、跑得更快。CourseOverview和UserCourseOutlineData对象比一棵 XBlock 树容易创建和 mock,也不受复杂发布规则的困扰。ADR 特别指出:使用 Modulestore 会给课程创建和修改带来显著的性能惩罚,使 Modulestore 访问成为 edx-platform 测试套件运行时间中的主要占比。
4. 应用对用户可见故障更 resilient。许多功能今天至少部分实现在 Modulestore 及其返回的 XBlock 中,这些功能的变更可能引发波及完全无关功能的 Bug。如果一个 LMS 应用在响应用户请求时查询 Modulestore,共享 Modulestore 代码中的意外故障会直接变成用户可见的错误;而"发布时读一次 Modulestore 并推入自己数据模型"的功能,即使 Modulestore 出现 Bug,结果也只是数据过期(stale data),而不是整个体验崩溃。
5. 有助于缩小 edx-platform 巨石。像course_overviews、learning_sequences这样的小应用未来可能从 edx-platform 中抽取为独立仓库,独立应用可以把它们作为依赖引入,简化测试环境搭建。Modulestore 已被证明很难被这样抽取——任何依赖 Modulestore 的外部应用都被迫使用更容易随 edx-platform 演进而破坏的依赖反转机制。这也服务于将 Studio 与 LMS 拆分为更独立系统的长期规划。
转换指南(一):课程配置改用 CourseOverviews
适用场景:应用只是查询存储在根CourseBlock上的课程配置。
这类应用应改为查询 CourseOverviews,其公开入口是 course_overviews 的 api 模块。需要说明的是,ADR 成文时提及的get_course_overview/get_course_overviews函数,在当前仓库源码中对应的实际实现包括get_course_overview_or_none、get_course_overview_or_404以及get_course_overviews等函数(批量版本返回序列化后的数据)。
如果所需配置字段还没有被CourseOverview模型捕获,ADR 给出的操作步骤是:
- 给
CourseOverview模型添加字段,并设置默认值; - 生成迁移文件(migration);
- 将
CourseOverview.VERSION加一; - 更新
CourseOverview._create_or_update,让它从 CourseBlock 对象(来自 modulestore)正确加载数据并写入CourseOverview。两个类中的属性通常同名,直接对应。
仓库源码印证了这套机制。在 course_overviews/models.py 中:
- 第 68 行定义了
VERSION = 19,其上方注释明确要求:"IMPORTANT: Bump this whenever you modify this model and/or add a migration."(每次修改模型或新增迁移都必须递增)。类的 docstring 也解释了后果:提升 VERSION 会使所有已缓存的课程概览失效,触发大量 Modulestore 读以重新缓存每个课程。 - 第 158 行定义了
CourseOverview._create_or_update(cls, course),即 ADR 第 4 步要求更新的方法。 - 第 421 行附近的版本校验逻辑
if course_overview.version < cls.VERSION:就是 ADR 所描述的"即时重新生成"机制:当存储记录的版本号小于当前CourseOverview.VERSION时,API 会强制重新生成该 overview,防止读到旧数据。
因此 ADR 提醒的"首次上线会有性能惩罚"是刻意为之:部署后几分钟内,随着各课程按需(just-in-time)重算,惩罚会自然消失。
转换指南(二):课程大纲与 Sequence 元数据改用 Learning Sequences
适用场景:应用需要课程大纲(outline)数据以及 sequence 的元数据。
应使用 Learning Sequences 的公开 API,入口包为 learning_sequences 的 api。ADR 还特别给出两条注意事项:
- 不支持旧式 Mongo 课程——即课程 key 形如
Org/Course/Run、即将被移除的课程。所有以course-v1:或ccx-v1:开头的课程 key 均受支持。仓库源码中的key_supports_outlines函数(见 learning_sequences/api/outlines.py)正是这一约束的实现:除已废弃的 v1 Library(其 Locator 继承自 CourseKey 但不该支持 outline)外,所有非废弃 CourseKey 都支持——即 SplitMongo 普通课程和 CCX 课程可用,Library、Pathways 和 Old Mongo 课程不可用。 - 这是一个新 API,未来会持续增加新的 API 函数和数据。
转换指南(三):进阶用例——"发布时推数据"架构模式
当应用所需的数据无法以高性能方式在 LMS 获得时,需要为自己的应用建一个数据模型,并在课程发布过程中把新数据推入其中。ADR 以 Learning Sequences 本身作为示范实现走查了整个模式。从源码结构看,这套实现分布在 Studio 进程(./cms/源码树)中,由五个部件构成。
数据抽取:get_outline_from_modulestore
抽取代码位于 cms/djangoapps/contentstore/outlines.py。get_outline_from_modulestore(第 324 行)及其辅助函数负责把课程结构和内容数据从 Modulestore 中提取出来,也是必须处理各种怪异边界情况(如畸形课程结构)的地方。当前实现的 docstring 明确了三个要点:没有副作用(只读取、生成数据,不推送)、只操作 published 分支、不支持 Old Mongo 课程。
其核心代码与 ADR 中给出的示例完全一致:
store = modulestore() with store.branch_setting(ModuleStoreEnum.Branch.published_only, course_key): # Pull course with depth=3 so we prefetch Section -> Sequence -> Unit course = store.get_course(course_key, depth=3)这里有两个 ADR 强调的要点:
- 必须只从 published 分支读取。保存草稿(saving a draft)同样会触发
course_published事件,若不显式锁定published_only分支,抽取到的可能是草稿数据; depth=3用于预取 Section → Sequence → Unit 三层结构,减少逐层拉取 Modulestore 的次数。
由于该函数无副作用,其测试类OutlineFromModuleStoreTestCase只需准备 Modulestore 课程结构,然后断言生成了预期的CourseOutlineData。
容错与反腐层的权衡:这段代码在用户点击发布按钮或运行课程导入之后异步执行,因此对输入要有一定宽容度,不能因个别坏数据而让整个流程失败;但同时它必须保持为一层"强的反腐层"(anti-corruption layer),不能把不必要的复杂性和隐蔽的数据配置泄漏进应用的核心数据模型。ADR 总结的原则是:对自己的应用用严格/简单的数据模型,对来自 Modulestore 的数据用宽容的转换。
Learning Sequences 采取的具体折中方案是把"内容错误"提升为一等公民概念:get_outline_from_modulestore的返回类型是Tuple[CourseOutlineData, List[ContentErrorData]]——既返回大纲数据,也返回一个ContentErrorData对象列表。这两个数据结构定义在 learning_sequences/data.py(ContentErrorData在第 50 行,CourseOutlineData在第 166 行)。
ADR 举的例子很典型:Learning Sequences 假设一个 Sequence 只属于一个 Section。这个简化假设被写进了learning_sequences应用的数据模型和 URL 结构里,但 Modulestore 并不对课程施加这一约束。于是策略是:每发现一次违反就记录一条ContentErrorData,并跳过该 Sequence 除第一次之外的所有出现。数据模型保持简单,同时保留了一份可供课程团队或支持人员事后诊断的记录。
写入应用模型:update_outline_from_modulestore
update_outline_from_modulestore 是一个短函数:调用get_outline_from_modulestore生成CourseOutlineData,再通过learning_sequences暴露的 API 方法replace_course_outline(见 learning_sequences/api/outlines.py)把数据推入learning_sequences。该函数还会设置自定义属性(set_custom_attribute)以便监控性能问题与错误——当前源码中记录的是num_sequences、num_content_errors等计数。
ADR 还特别提到:写入内容包括课程的version,这对排查写入故障很有价值。版本号取自根CourseBlock的course_version属性,并转换为字符串存储(因为它是 BSON 对象)。
Celery 任务:update_outline_from_modulestore_task
Celery 任务是 cms/djangoapps/contentstore/tasks.py 中的@shared_taskupdate_outline_from_modulestore_task,它包装了对update_outline_from_modulestore的调用。ADR 强调两点:
- 必须用 Celery 异步执行。即使代码"看起来"够快可以进程内同步跑,课程往往启用了各种冷门功能,会显著拉长数据抽取时间,而这些情况几乎不可能被全面测试覆盖;
- 必须对任务失败激进地告警("You must be aggressive about alerting on task failures")。发布足够不频繁,某些内容相关的错误不会触发常规错误率告警;而你的任务可能阻塞一个课程的发布,因此必须对彻底失败保持极高敏感度。
从当前源码看,任务对不支持 outline 的课程 key 会记录 warning 并直接返回;对真正的异常则会记录后raise重新抛出——"so that errors are noted in reporting",正好呼应了激进告警的要求。
信号处理器:listen_for_course_publish
信号处理器位于 cms/djangoapps/contentstore/signals/handlers.py。它是 Studio 做发布后数据推送的集中入口,但 ADR 也说明你完全可以另写一个处理器监听同一个course_published信号。它的主要职责是:做一些日志记录,然后入队 Celery 任务。当前源码中的listen_for_course_publish会注册特殊考试、推送学习序列大纲(在key_supports_outlines(course_key)为真时调用update_outline_from_modulestore_task.delay(course_key_str))并触发课程搜索索引等任务。
ADR 还给出了一个源码中同样存在的实战提醒(见该文件第 137 行附近的 DEVELOPER README 注释):部分任务应使用transaction.on_commit以避免读到未提交的旧数据;有些团队改用等待策略(waiting strategy)。如果你在调试"任务读到了旧数据"的问题,要考虑 Celery 在进程内运行时不会复现该错误——需要配合 devstack_with_worker 配置,必要时在信号发送处加入time.sleep。
管理命令:backfill 与单次更新
ADR 列出的两个管理命令在当前仓库中均已实现:
- backfill_course_outlines:为一批缺失大纲的课程批量回填,支持
--dry(只显示将回填的课程,不做修改)和--force(强制为所有课程重新生成,而不只是缺失的)两个参数。从源码看,它通过CourseOverview.objects.values_list('id', flat=True)与get_course_keys_with_outlines()做差集找出缺失大纲的课程,然后为每个课程单独启动一个新的 Celery 任务。ADR 解释了这样做的双重原因:控制内存使用(跨课程连续访问 Modulestore 会泄漏大量内存),以及便于观察哪些课程耗时更长或引发错误。 - update_course_outline:针对单个特定课程更新其大纲。
ADR 对管理命令还有三点注意:
- 这些命令位于 Studio 进程,因为它们调用的是查询 Modulestore 的代码;
- backfill 命令为每个课程单独发任务(如上所述);
- 长期来看,希望有一种方式可以从 Django admin 触发 backfill,避免每次都要提支持工单。这一规划已在 contentstore 的 admin.py 中落地——admin 中已能直接
update_outline_from_modulestore_task.delay(str(course_key))。
LMS 进程侧:零 Modulestore 依赖
ADR 对 LMS 进程的要求非常强硬:你的功能完全不应使用 Modulestore。你的 LMS 应用代码应彻底摆脱 Modulestore 依赖;上述所有面向 Modulestore 的代码都应位于./cms/源码树中、运行于 Studio 进程。当 LMS 请求到来时,你的应用只看自己的数据模型,或看上述某个高性能的 Modulestore 替代 API。
此外有两条边界规则:
- LMS 进程不得覆盖课程发布流程写入的模型,更不能把数据推回 Modulestore;
- 如果应用需要覆盖来自发布的数据,就建两个模型:一个只由课程内容发布更新,另一个在 LMS 侧读写。查询时同时看两个模型。ADR 以 edx-when 应用为例:它从 Modulestore 捕获开始时间与截止时间,然后在 LMS 提供请求时应用学生级别的覆盖(student-specific overrides)。关于这一主题的更多背景,参见 ADR 5:Studio 与 LMS 的 Subdomain 边界。
Django Admin:只读的运维视图
ADR 最后说明了learning_sequences应用的 Django admin 定位:只读,目的是让支持团队和工程团队更便捷地查看生产环境的数据状态。规划中的方案是:在 contentstore Studio 应用中新增一个 Django admin 页面,把 backfill 任务作为 action 加入,并通过对 CourseOverview 使用代理模型(proxy model)来获得课程列表。从源码结构看,CourseOutlineRegenerate代理模型与update_all_outlines_from_modulestore_task这类"批量再生成"任务(tasks.py)已经在仓库中落地,与这一规划方向一致。
小结:改造 Checklist
综合 ADR 全文与仓库源码,一个 LMS 应用摆脱 Modulestore 的完整改造路径可以归纳为:
- 判断数据类别:仅课程根配置 → 走 CourseOverviews(加字段、生成迁移、递增
VERSION、更新_create_or_update);课程大纲/Sequence 元数据 → 走 Learning Sequences API(注意course-v1:/ccx-v1:课程 key 支持范围);其他数据 → 自建模型 + 发布时推送; - 把 Modulestore 访问全部收进 Studio 进程(
./cms/树),按"纯函数抽取 → 写入应用模型 → Celery 任务 → 信号处理器 → 管理命令"五件套组织,抽取层对数据宽容但保留ContentErrorData式错误记录; - LMS 侧只读自己的模型,如需学员级覆盖则用"发布模型 + LMS 读写模型"的双模型设计;
- 对任务失败激进告警,并提供
--dry/--force式的管理命令与 admin 入口用于回填和运维。
这条路线的最终收益是:LMS 请求路径上不再触碰 Modulestore,行为更可预测、测试更快、故障面更窄,并且为 course_overviews、learning_sequences 这类小应用未来独立成仓铺平了道路。
【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考