Open edX Learning Sequences:与 ModuleStore 解耦的课程大纲数据服务解析
【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform
导读
learning_sequences是 Open edX 平台中一个旨在构建ModuleStore 无关(ModuleStore-independent)学习序列(Learning Sequence,即 Studio 中的 "subsection")数据层的 Django 应用,其首个落地的 API 就是计算Course Outline(课程大纲)。本文以 learning_sequences/README.rst 为骨架,结合仓库内源码、模型、管理命令与测试,系统讲解该应用的设计动机、数据流入链路、公共 API、个性化大纲机制以及三类典型扩展方式,帮助你理解 Open edX 如何逐步把课程结构与导航的职责从 XBlock 运行时中剥离出来。
一、Learning Sequences 是什么
Learning Sequences 包的核心使命是:创建一种不依赖 ModuleStore 的学习序列表示,并描述这些序列如何组装成课程。在 Studio 中,学习序列对应的概念就是 "subsection"(小节);在课程层面,若干小节组成 Section(章节),若干章节组成一门课的大纲。
该应用的主要服务对象是 LMS 的最终用户——它负责向浏览器端交付课程大纲元数据;同时它也向 Studio 开放写入能力,用于在发布课程时把大纲数据"推"进系统。其实现的第一个 API 就是 Course Outline 的计算。
一条硬性约束:不直接依赖 ModuleStore
原文档用important提示块强调了一个架构红线:
This package shouldnotdepend on the modulestore directly.
这条约束在 models.py 的模块级 docstring 中体现得更加具体:
- 公共 API 承诺的内容应能通过这些模型高效查询,偶尔可以触达其他为"快速课程级查询"而构建的系统(如 grading、scheduling),但绝不能触碰 ModuleStore 或 Block Transformers;
- 模型层允许做基础校验(如唯一性约束),但真正的业务逻辑必须放在
api包内; - 尽量少用 JSON 字段这类"blob 实体",把数据推进规范化的关系表;
- 模型保持"薄而笨"的持久化层定位,缓存策略由
api包决定。
也就是说,这是一个"发布时一次性写入、运行时快速读取"的架构:昂贵的课程结构遍历只发生在发布那一刻,运行时通过细粒度的数据库行完成毫秒级查询。
二、动机:为什么在 ModuleStore 与 Block Transformers 之外再造一层
README 开篇就替读者提出了质疑:我们已经有了 ModuleStore 和 Block Transformers,为什么还要再做一个访问课程结构数据的途径?这不是会像移动端 API 那样因为访问规则需要重新实现而引入 bug 吗?原文档给出了三条理由:
- 支撑更动态的课程体验:平台正把课程结构与导航的职责从 ModuleStore/XBlock 中迁移出来。继续保持 OLX 兼容性,但未来 LMS 的 XBlock 运行时只会在 Unit 层级及以下被调用——这意味着课程骨架(章节/小节)的呈现不再依赖 XBlock 渲染链路。
- Block Transformers 缺乏细粒度模型:Block Transformers 一次性处理整门课程,缺少"为单个序列做快速元数据查询"所需的粒度化数据库模型。
- 复杂性与性能权衡:Block Transformers 功能强大但复杂且慢。为了本用例去优化它们成本高昂且会引入更多复杂度;而序列与大纲相关的元数据量小得多,可以做出简化假设——把大纲视为树而非 DAG(即每个序列只属于一个章节),从而大幅降低建模与查询难度。
这一动机还可以从数据结构的实现中得到印证:data.py 中CourseOutlineData.__attrs_post_init__会在初始化时校验"同一个序列出现在多个章节"的情况并直接抛出ValueError,这正是"大纲是树而非 DAG"这一假设在代码层面的落地。
三、数据如何流入:发布信号与管理命令双通道
README 明确指出,把课程大纲数据喂入 Learning Sequence 模型的主路径是Studio 课程发布时触发的信号处理器(signal handler);此外也可以通过update_course_outline管理命令手动播种数据。
3.1 主路径:Studio 发布
Studio 侧的接入点在 cms/djangoapps/contentstore/outlines.py:其中update_outline_from_modulestore(course_key)负责把 ModuleStore 中"最近发布"的课程内容转换为CourseOutlineData,核心流程为:
get_outline_from_modulestore(course_key)遍历课程块的 children,逐章节构建CourseSectionData与CourseLearningSequenceData,并收集内容错误;- 构造
CourseOutlineData,其中published_at取course.subtree_edited_on(统一为 UTC),published_version取str(course.course_version)(BSON 对象转字符串,避免 MongoDB 专用对象进入公共数据结构); - 调用
replace_course_outline(course_outline_data, content_errors=content_errors)将数据落库。
异步批量场景则由 cms/djangoapps/contentstore/tasks.py 中的update_all_outlines_from_modulestore_task/update_outline_from_modulestore_taskCelery 任务承载,可对一批课程逐个刷新大纲。
3.2 手动路径:update_course_outline 管理命令
该命令位于 cms/djangoapps/contentstore/management/commands/update_course_outline.py,用于调试、错误恢复或回填(backfilling):
python manage.py cms update_course_outline <course_key>其handle方法把字符串 course key 解析为CourseKey后直接调用update_outline_from_modulestore(course_key)。注意命令说明中强调:应在 Studio 进程(cms)中调用,因为正常发布流程由 Studio 完成,LMS 进程不会主动写这份数据。
四、公共 API 与数据契约
4.1 导入纪律:只从顶层 api 包导入
README 对使用方式给出了三条严格的契约:
- 允许在自己的应用里对 learning_sequence 模型建立外键(但参见下文模型章节的限制);
- 允许引用
openedx.djangoapps.content.learning_sequences.api.data中定义的数据结构; - 除此之外,只能从顶层包
openedx.djangoapps.content.learning_sequences.api导入并使用函数,不得从包内其他位置(包括api的子模块)导入。
顶层 API 实际导出的函数见 api/init.py:
| 函数 | 用途 |
|---|---|
key_supports_outlines(opaque_key) | 判断某 course key 类型是否支持大纲(见 4.2) |
get_course_keys_with_outlines() | 返回所有已具备大纲的 LearningContext key 的惰性 QuerySet |
get_course_outline(course_key) | 获取某课程 run 的大纲,不含任何用户个性化数据与权限过滤 |
get_user_course_outline(course_key, user, at_time) | 针对某用户在某个时刻定制的大纲 |
get_user_course_outline_details(course_key, user, at_time) | 用户大纲 + 补充信息(日程 schedule、特殊考试记录等) |
get_content_errors(course_key) | 获取最近一次发布产生的内容错误列表 |
replace_course_outline(course_outline, content_errors) | 用新的CourseOutlineData替换落库的大纲模型数据 |
业务逻辑全部集中在 api/outlines.py(其 docstring 同样告诫"不要直接从此模块导入,请经由顶层 api 包")。
4.2 key_supports_outlines 支持范围
key_supports_outlines的实现明确划分了支持边界:
- 先排除
LibraryLocator(v1 图书馆虽然继承自 CourseKey,但不应支持); - 其余
CourseKey只要deprecated为 False 即支持,包括普通的 SplitMongo 课程(course-v1:)与 CCX 课程(ccx-v1:); - 旧式斜杠分隔课程 ID(
Org/Course/Run)与 Old Mongo 课程不支持。
相应地,_get_course_context_for_outline对course_key.deprecated会直接抛出ValueError;若LearningContext尚不存在(例如课程还没发布过),则抛出CourseOutlineData.DoesNotExist。
4.3 公共数据结构(api/data.py)
api/data.py 定义了整个应用的公共数据契约,全部基于attr的frozen=True不可变类,便于调试与安全共享。其编写准则包括:数据结构尽量不可变;本模块不得反向导入应用其他部分;数据类保持"笨",业务逻辑归api包;数据类只允许做完全自包含的校验,绝不能发起数据库调用、网络请求或触发昂贵计算。
核心类型:
CourseVisibility枚举:private/public_outline/public,控制匿名访问模式。其中public_outline模式下匿名用户能看到大纲但无法访问内部内容(在UserCourseOutlineData.accessible_sequences的注释中有说明)。CourseOutlineData:课程级大纲,字段包括course_key、title、published_at、published_version、days_early_for_beta、sections、self_paced、course_visibility、entrance_exam_id。约束:course_key不得为 deprecated;days_early_for_beta不允许为负;序列总数上限MAX_SEQUENCE_COUNT = 1000;sequences字段由sections派生(init=False),并在后置钩子中校验"一个序列不得出现在多个章节"。它还提供remove(usage_keys)方法,返回一个移除了指定序列/章节后的新大纲副本(移除章节会连带移除其全部序列;若某章节因此变空,章节本身也会被移除)。CourseSectionData/CourseLearningSequenceData:章节与小节数据,各自携带visibility(hide_from_toc、visible_to_staff_only)、exam(is_practice_exam、is_proctored_enabled、is_time_limited)与user_partition_groups(UserPartition ID 到 Group ID 集合的映射)。user_partition_groups_not_empty校验器保证"内容若与某个 User Partition 关联,则必须至少关联一个 Group"。UserCourseOutlineData:继承CourseOutlineData,追加base_outline(未被裁剪的完整大纲,便于回溯 staff-only 内容)、user、at_time、accessible_sequences(用户可访问的序列集合,用户可能"知道其存在"但无法交互,例如已关闭的考试)。UserCourseOutlineDetailsData:outline+schedule+special_exam_attempts,未来还会扩展到 Completion 等其他系统。ScheduleItemData/ScheduleData/SpecialExamAttemptData:日程(start / effective_start / due)与特殊考试尝试数据。
4.4 数据库模型(models.py)
models.py 中的模型刻意保持"薄而笨",并遵循若干约定:模型与data.py结构不必 1:1,但数据类统一加...Data后缀(如LearningContext↔LearningContextData);强烈区分"序列本身固有属性"与"序列在课程语境下的属性"。
主要表结构:
LearningContext:序列的聚合容器,context_key唯一索引。之所以不用外键指向 CourseOverview,是因为该表未来要容纳非课程实体(如 Content Libraries、Pathways)。允许外部应用对它建外键。CourseContext:与LearningContext一对一,保存课程特有信息:course_visibility、days_early_for_beta、self_paced、entrance_exam_id。LearningSequence:序列本体(usage_key+title,标题最长 1000 字符)。允许外部应用对它建外键。它刻意不直接外键到CourseSection,为未来课程之外的序列形态留出空间。CourseSection(映射 chapter 块)与CourseSectionSequence(join+排序表,ordering全课程连续编号 0..N-1,含inaccessible_after_due与继承自CourseContentVisibilityMixin的hide_from_toc、visible_to_staff_only):CourseSectionSequence会在每次课程发布时被清空重建,README 与 docstring 都明确警告不要对这张表建外键。UserPartitionGroup(partition_id + group_id 唯一)及两张 through 表SectionPartitionGroup/SectionSequencePartitionGroup:内容可以关联多个 Group(如同时关联 Verified 与 Masters),而单个用户在每个 Partition 中只属于一个 Group。CourseSequenceExam:特殊考试标记(练习考、监考启用、限时)。PublishReport与ContentError:记录每次发布时的错误数、章节数、序列数,以及面向课程团队和支持人员的人类可读错误消息(例如 OLX 导入产生的畸形结构)。
五、用户个性化大纲:OutlineProcessor 机制
get_course_outline返回的是不包含任何用户信息的全量大纲;而 LMS 最终看到的大纲,需要根据用户身份与时间裁剪。get_user_course_outline/get_user_course_outline_details会先取全量大纲,再串行执行一组处理器。
5.1 基类契约
api/processors/base.py 定义了OutlineProcessor基类,README 提示"请阅读api/processors/base.py的 docstring 了解如何编写"。处理器在请求大纲期间被同步调用,生命周期固定为三步:
__init__(course_key, user, at_time)——只做初始化,不做任何实际工作;load_data(full_course_outline)——抓取所需的课程与用户数据;禁止在此触碰 ModuleStore 或 Block Structures,docstring 要求该方法在数百个序列的课程上也应控制在几十毫秒内;inaccessible_sequences(...)与usage_keys_to_remove(...)——返回被标记为不可访问、或被整体移除的 UsageKey 集合,二者之间没有顺序保证,也不应假设与其他处理器的执行顺序(未来可能并行执行)。
基类注释还说明:这两个过滤方法不会对 staff 用户运行(staff 可以访问一切,无需在此检查 staff 权限)。
5.2 内置处理器清单
_get_user_course_outline_and_processors中按序注册了 9 个处理器:
| 处理器 | 职责 |
|---|---|
ContentGatingOutlineProcessor | 内容门控(如按前置条件放行内容) |
MilestonesOutlineProcessor | 里程碑 / 前置条件序列过滤 |
ScheduleOutlineProcessor | 按发布时间线(start/due)过滤,并额外提供schedule_data供 details API 使用 |
SpecialExamsOutlineProcessor | 特殊考试相关可见性,额外提供exam_data |
VisibilityOutlineProcessor | 基于hide_from_toc/visible_to_staff_only等可见性标记过滤 |
EnrollmentOutlineProcessor | 选课状态相关过滤 |
EnrollmentTrackPartitionGroupsOutlineProcessor | 按学习轨道(如 Verified/Masters)分区过滤 |
CohortPartitionsOutlineProcessor | 按分组(cohort)分区过滤 |
TeamPartitionsOutlineProcessor | 按团队分区过滤 |
其中ContentGatingOutlineProcessor、MilestonesOutlineProcessor、ScheduleOutlineProcessor等位于 api/processors/ 目录下,各自文件都带有说明其用途的顶层 docstring。
5.3 权限捷径与缓存
api/permissions.py 的can_see_all_content(user, course_key)借助lms.djangoapps.courseware.access.has_access判断用户是否为 global staff、课程 staff 或 instructor;若是,则跳过全部处理器的过滤逻辑(代码注释明确"无需为这些用户运行处理器以裁剪结果",同时兼容 masquerade 伪装身份的场景)。
性能方面,get_course_outline使用edx_django_utils.cache.TieredCache分层缓存,缓存 key 由context_key + published_version构成(learning_sequences.api.get_course_outline.v2.{context_key}.{version}),TTL 为 300 秒;查询时对CourseSectionSequence做了select_related('sequence', 'exam')与prefetch_related('new_user_partition_groups'),并显式说明"空章节也要保留,因此单独查询CourseSection而不能只依赖 join 表"。函数级function_trace与set_custom_attribute则把调用频率、耗时、用户维度等指标暴露给监控系统。
六、消费端:REST 视图与运行时服务
6.1 REST API
views.py 被刻意设计为"薄层",只负责用户输入/输出的翻译,业务逻辑全部在api包。路由注册于 urls.py:
v1/course_outline/<path:course_key_str>对应CourseOutlineView,认证方式为JwtAuthentication与SessionAuthenticationAllowInactiveUser(未来还打算放开匿名访问)。其内联的UserCourseOutlineDataSerializer故意写在视图内部以避免共享序列化器带来的"意外回归";序列化时会把内部的 UsageKey 翻译为对外暴露的 "ids",并在accessible_sequences之外补充schedule、exam_information等合并后的扁平字段。
6.2 运行时服务注入
apps.py 的ready()在settings.ENABLE_SPECIAL_EXAMS开启时,会把 services.py 中的LearningSequencesRuntimeService(暴露get_user_course_outline与get_user_course_outline_details)通过edx_proctoring.runtime.set_runtime_service('learning_sequences', ...)注入监考运行时,供考试子系统查询用户可见性与考试信息。
七、如何扩展:三类典型场景
README 的 "How to Extend?" 一节提醒:本应用正在尝试一些新约定,请先阅读决策文档(docs/decisions)——对应仓库内路径为 learning_sequences/docs/decisions/,其中 0001-extensions-to-inter-app-apis.rst 记录了"跨应用 API 扩展"的决策;同时许多模块的顶层 docstring 都在说明"它应该被用于什么场景",务必通读。这些约定旨在保证行为可预期,即使是小规模的破坏也会显著削弱其效果。
7.1 想给公共 API 增加更多数据
- 公共数据类型放在 api/data.py;
- 数据库持久化照常放在
models.py,但模型保持"极薄且笨"; - 所有真正的业务逻辑放在
api包内的某个模块中。目前既有逻辑都在 api/outlines.py 并通过 api/init.py 顶层再导出。如果你的新功能与大纲相关,请遵循此约定;否则可在api/下新建模块(例如api/sequences.py)承载逻辑,并在顶层api/__init__.py中再导出。
7.2 想新增一条影响序列在大纲中展示的规则
应当创建或修改一个OutlineProcessor(位于 api/processors/)。该接口目前尚不可插拔,但设计上已为未来可插拔做好准备(outlines.py 的注释指出处理器注册列表就是未来加入 pluggability 的位置)。编写细节以 api/processors/base.py 的 docstring 为准;新增后还需在_get_user_course_outline_and_processors的processor_classes列表中登记名称与类。
7.3 想从 ModuleStore 或 Block Structures 拉取数据
禁止。对这些系统的任何同步调用都会破坏本应用的性能目标。如果你确实需要这些系统的数据,请在课程发布时把数据推入更小的模型(这正是发布信号处理器 +replace_course_outline的设计初衷:在发布时刻完成昂贵的遍历与转换,运行时刻只做廉价查询)。这也解释了 models.py 中对CourseSectionSequence的警告——它是发布时重建的 join 表,任何依赖它的同步查询都可能随时面对数据被整体删除的情况。
八、小结与最佳实践清单
Learning Sequences 代表了 Open edX 在课程结构数据访问上的一次架构转向:以"发布时推入、运行时读取"的细粒度模型替代"运行时全量遍历"的 Block Transformer 方案,并严格限定公共 API 边界。落地使用时值得记住的实践:
- 导入纪律:只从顶层
openedx.core.djangoapps.content.learning_sequences.api导入函数,只引用api.data的数据结构; - 外键纪律:可以外键到
LearningContext与LearningSequence,但不要对CourseSectionSequence等发布时重建的表建外键; - 性能纪律:任何新逻辑都不得在运行期同步访问 ModuleStore / Block Structures,数据必须随发布推入小模型;
- 扩展纪律:新增展示规则优先以
OutlineProcessor形式实现,业务逻辑留在api包,模型保持薄持久层定位; - 运维手段:需要手动回填或修复某门课程的大纲时,在 Studio 进程执行
python manage.py cms update_course_outline <course_key>,或调用cms/djangoapps/contentstore/tasks.py中的批量 outline 任务。
通过阅读 learning_sequences 应用目录、其 迁移历史(从初始建表到UserPartitionGroup去重与唯一约束,再到PublishReport索引优化),可以进一步追踪该架构的演进脉络;决策文档 0001-extensions-to-inter-app-apis.rst 则记录了这一设计的关键取舍。
【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考