开源应用程序架构(第 2 卷)
Moodle

蒂姆·亨特

Moodle 是一款用于教育环境的 Web 应用程序。虽然本章将尝试概述 Moodle 工作原理的所有方面,但它侧重于 Moodle 设计特别有趣的部分

Moodle 提供了一个在线场所,学生和教师可以在这里一起教学和学习。Moodle 网站被划分为课程。课程有用户注册,并具有不同的角色,例如学生教师。每个课程都包含许多资源活动。资源可能是 PDF 文件、Moodle 中的 HTML 页面或指向 Web 上其他内容的链接。活动可能是论坛、测验或维基。在课程中,这些资源和活动将以某种方式进行结构化。例如,它们可以分组到逻辑主题中,或按日历分组到几周中。

图 13.1:Moodle 课程

Moodle 可以用作独立应用程序。如果您希望教授软件架构课程(例如),您可以将 Moodle 下载到您的 Web 主机,安装它,开始创建课程,并等待学生前来自行注册。或者,如果您是大型机构,Moodle 将只是您运行的众多系统之一。您可能还会拥有图 13.2中所示的基础设施。

图 13.2:典型的大学系统架构

Moodle 专注于提供一个用于教学和学习的在线空间,而不是教育机构可能需要的任何其他系统。Moodle 提供了其他功能的基本实现,以便它可以作为独立系统或与其他系统集成使用。Moodle 扮演的角色通常称为虚拟学习环境 (VLE) 或学习或课程管理系统 (LMS、CMS 或甚至 LCMS)。

Moodle 是开源或自由软件 (GPL)。它使用 PHP 编写。它可以在大多数常见的 Web 服务器和平台上运行。它需要一个数据库,并且可以与 MySQL、PostgreSQL、Microsoft SQL Server 或 Oracle 一起使用。

Moodle 项目由马丁·杜吉马斯于 1999 年启动,当时他正在澳大利亚科廷大学工作。1.0 版于 2002 年发布,当时 PHP4.2 和 MySQL 3.23 是可用的技术。这限制了最初可能的架构类型,但此后发生了很大变化。当前版本是 Moodle 2.2.x 系列。

13.1. Moodle 工作原理概述

Moodle 安装包含三个部分

  1. 代码,通常位于类似 /var/www/moodle~/htdocs/moodle 的文件夹中。Web 服务器不应对此进行写入。
  2. 数据库,由受支持的 RDMS 之一管理。实际上,Moodle 会为所有表名添加前缀,因此如果需要,它可以与其他应用程序共享数据库。
  3. moodledata 文件夹。这是一个 Moodle 用于存储上传和生成文件的文件夹,因此 Web 服务器需要对此进行写入。出于安全原因,它应该位于 Web 根目录之外。

这些都可以位于同一台服务器上。或者,在负载均衡设置中,每个 Web 服务器上都会有多个代码副本,但只有一个共享的数据库和 moodledata 副本,可能位于其他服务器上。

有关这三个部分的配置信息存储在 Moodle 安装时 moodle 文件夹根目录中的一个名为 config.php 的文件中。

请求分发

Moodle 是一个 Web 应用程序,因此用户使用其 Web 浏览器与之交互。从 Moodle 的角度来看,这意味着响应 HTTP 请求。因此,Moodle 设计的一个重要方面是 URL 命名空间以及 URL 如何分发到不同的脚本。

Moodle 使用标准的 PHP 方法来实现这一点。要查看课程的主页,URL 将为 .../course/view.php?id=123,其中 123 是数据库中课程的唯一 ID。要查看论坛讨论,URL 将类似于 .../mod/forum/discuss.php?id=456789。也就是说,这些特定的脚本,course/view.phpmod/forum/discuss.php,将处理这些请求。

这对开发人员来说很简单。要了解 Moodle 如何处理特定请求,您需要查看 URL 并从那里开始阅读代码。从用户的角度来看,它很丑陋。但是,这些 URL 是永久的。如果课程名称更改,或者版主将讨论移至不同的论坛,URL 不会更改。(正如蒂姆·伯纳斯-李在文章简洁的 URI 不会改变中所解释的那样,这是 URL 应该具有的一个好特性。)

可以采用的另一种方法是拥有一个单一的入口点 …/index.php/[extra-information-to-make-the-request-unique]。然后,单个脚本 index.php 将以某种方式分发请求。这种方法添加了一层间接性,这是软件开发人员总是喜欢做的事情。缺乏这种间接性似乎并没有损害 Moodle。

插件

与许多成功的开源项目一样,Moodle 是由许多插件构建的,这些插件与系统的核心协同工作。这是一个不错的方法,因为它允许人们以定义的方式更改和增强 Moodle。开源系统的一个重要优势是您可以根据自己的特定需求对其进行定制。但是,对代码进行广泛的自定义可能会在升级时导致重大问题,即使使用良好的版本控制系统也是如此。通过允许尽可能多的自定义和新功能作为独立的插件实现,这些插件通过定义的 API 与 Moodle 核心交互,人们更容易根据自己的需求自定义 Moodle 并共享自定义,同时仍然能够升级核心 Moodle 系统。

系统可以作为核心围绕插件构建的方式有很多种。Moodle 具有相对较胖的核心,并且插件是强类型的。当我说一个胖核心时,我的意思是核心中有许多功能。这与几乎所有内容(除了一个小的插件加载器存根)都是插件的架构类型形成对比。

当我说插件是强类型时,我的意思是,根据您要实现的功能类型,您必须编写不同类型的插件并实现不同的 API。例如,新的活动模块插件将与新的身份验证插件或新的问题类型大不相同。最后一次统计大约有 35 种不同的插件类型。(Moodle 插件类型的完整列表。)这与所有插件基本上使用相同的 API,然后可能订阅它们感兴趣的挂钩或事件子集的架构类型形成对比。

通常,Moodle 的趋势一直是尝试缩减核心,通过将更多功能移至插件中。然而,这项工作仅取得了一定程度的成功,因为不断增长的功能集往往会扩展核心。另一个趋势是尝试尽可能标准化不同类型的插件,以便在常见功能领域(如安装和升级)中,所有类型的插件都以相同的方式工作。

Moodle 中的插件采用包含文件的文件夹的形式。插件具有类型和名称,它们共同构成了插件的“Frankensyle”组件名称。(“Frankensyle”一词源于开发人员 Jabber 频道中的一场争论,但每个人都喜欢它并且它保留了下来。)插件类型和名称确定插件文件夹的路径。插件类型提供前缀,文件夹名称是插件名称。以下是一些示例

插件类型插件名称Frankensyle文件夹
mod(活动模块)forummod_forummod/forum
mod(活动模块)quizmod_quizmod/quiz
block(侧边栏)navigationblock_navigationblocks/navigation
qtype(问题类型)shortanswerqtype_shortanswerquestion/type/shortanswer
quiz(测验报告)statisticsquiz_statisticsmod/quiz/report/statistics

最后一个示例表明,每个活动模块都可以声明子插件类型。目前只有活动模块可以这样做,原因有两个。如果所有插件都可以拥有子插件,这可能会导致性能问题。活动模块是 Moodle 中的主要教育活动,因此是最重要的插件类型,因此它们拥有特殊权限。

插件示例

我将通过考虑一个特定的插件示例来解释 Moodle 架构的许多细节。按照传统,我选择实现一个显示“Hello world”的插件。

此插件实际上并不自然地适合任何标准的 Moodle 插件类型。它只是一个脚本,与其他任何东西都没有连接,因此我将选择将其作为“本地”插件实现。这是一种用于不适合其他任何地方的杂项功能的通用插件类型。我将我的插件命名为 greet,以提供 local_greet 的 Frankensyle 名称,以及 local/greet 的文件夹路径。(插件代码可以下载。)

每个插件都必须包含一个名为 version.php 的文件,其中定义了有关插件的一些基本元数据。Moodle 的插件安装程序系统使用它来安装和升级插件。例如,local/greet/version.php 包含

<?php
$plugin->component    = 'local_greet';
$plugin->version      = 2011102900;
$plugin->requires     = 2011102700;
$plugin->maturity     = MATURITY_STABLE;

包含组件名称可能看起来多余,因为可以通过路径推断出来,但安装程序使用它来验证插件是否安装在正确的位置。版本字段是此插件的版本。成熟度为 ALPHA、BETA、RC(候选版本)或 STABLE。Requires 是此插件兼容的 Moodle 的最低版本。如有必要,还可以记录此插件依赖的其他插件。

以下是此简单插件的主要脚本(存储在local/greet/index.php中)

<?php
require_once(dirname(__FILE__) . '/../../config.php');        // 1

require_login();                                              // 2
$context = context_system::instance();                        // 3
require_capability('local/greet:begreeted', $context);        // 4

$name = optional_param('name', '', PARAM_TEXT);               // 5
if (!$name) {
    $name = fullname($USER);                                  // 6
}

add_to_log(SITEID, 'local_greet', 'begreeted',
        'local/greet/index.php?name=' . urlencode($name));    // 7

$PAGE->set_context($context);                                 // 8
$PAGE->set_url(new moodle_url('/local/greet/index.php'),
        array('name' => $name));                              // 9
$PAGE->set_title(get_string('welcome', 'local_greet'));       // 10

echo $OUTPUT->header();                                       // 11
echo $OUTPUT->box(get_string('greet', 'local_greet',
        format_string($name)));                               // 12
echo $OUTPUT->footer();                                       // 13

第 1 行:引导 Moodle

require_once(dirname(__FILE__) . '/../../config.php');        // 1

此脚本中工作量最大的一行是第一行。我在上面说过config.php包含 Moodle 连接到数据库并查找 moodledata 文件夹所需的详细信息。但是,它以require_once('lib/setup.php')行结束。这

  1. 使用require_once加载所有标准的 Moodle 库;
  2. 启动会话处理;
  3. 连接到数据库;以及
  4. 设置许多全局变量,我们将在后面遇到。

第 2 行:检查用户是否已登录

require_login();                                              // 2

此行会导致 Moodle 检查当前用户是否已登录,使用管理员配置的任何身份验证插件。如果不是,用户将被重定向到登录表单,并且此函数将永远不会返回。

更集成到 Moodle 中的脚本会在此处传递更多参数,以说明此页面属于哪个课程或活动,然后require_login还会验证用户是否已注册或以其他方式被允许访问此课程,并且被允许查看此活动。如果不是,将显示相应的错误。

13.2. Moodle 的角色和权限系统

接下来的两行代码展示了如何检查用户是否有权执行某些操作。如您所见,从开发人员的角度来看,API 非常简单。但是,在幕后,存在一个复杂的访问系统,它使管理员能够灵活地控制谁可以做什么。

第 3 行:获取上下文

$context = context_system::instance();                        // 3

在 Moodle 中,用户可以在不同的地方拥有不同的权限。例如,用户可能在一个课程中是教师,而在另一个课程中是学生,因此在每个地方都有不同的权限。这些地方称为上下文。Moodle 中的上下文形成一个类似于文件系统中文件夹层次结构的层次结构。顶层是系统上下文(并且,由于此脚本没有很好地集成到 Moodle 中,因此它使用该上下文)。

在系统上下文中,是为创建用于组织课程的不同类别而创建的许多上下文。这些可以嵌套,一个类别包含其他类别。类别上下文也可以包含课程上下文。最后,课程中的每个活动都将拥有自己的模块上下文。

图 13.3:上下文

第 4 行:检查用户是否有权使用此脚本

require_capability('local/greet:begreeted', $context);        // 4

获取上下文(Moodle 的相关区域)后,可以检查权限。用户可能拥有或可能不拥有的每个功能位都称为功能。检查功能比require_login执行的基本检查提供了更细粒度的访问控制。我们的简单示例插件只有一个功能:local/greet:begreeted

检查是使用require_capability函数完成的,该函数采用功能名称和上下文。与其他require_…函数一样,如果用户没有该功能,它将不会返回。它将改为显示错误。在其他地方,将使用非致命has_capability函数(返回布尔值),例如,确定是否从另一个页面显示到此脚本的链接。

管理员如何配置哪个用户拥有哪个权限?以下是has_capability执行的计算(至少在概念上)

  1. 从当前上下文开始。
  2. 获取用户在此上下文中拥有的角色列表。
  3. 然后确定在此上下文中每个角色的权限是什么。
  4. 汇总这些权限以获得最终答案。

定义功能

如示例所示,插件可以定义与它提供的特定功能相关的新的功能。在每个 Moodle 插件中,代码中都有一个名为db的子文件夹。它包含安装或升级插件所需的所有信息。这些信息之一是一个名为access.php的文件,它定义了功能。以下是我们插件的access.php文件,它位于local/greet/db/access.php

<?php
$capabilities = array('local/greet:begreeted' => array(
    'captype' => 'read',
    'contextlevel' => CONTEXT_SYSTEM,
    'archetypes' => array('guest' => CAP_ALLOW, 'user' => CAP_ALLOW)
));

这提供了有关每个功能的一些元数据,这些元数据在构建权限管理用户界面时使用。它还为常见类型的角色提供了默认权限。

角色

Moodle 权限系统的下一部分是角色。角色实际上只是一组命名的权限。当您登录 Moodle 时,您将在系统上下文中拥有“已认证用户”角色,并且由于系统上下文是层次结构的根,因此该角色将应用于任何地方。

在特定课程中,您可能是学生,并且该角色分配将应用于课程上下文及其中的所有模块上下文。但是,在另一个课程中,您可能拥有不同的角色。例如,Gradgrind 先生可能是“事实、事实、事实”课程的教师,但在“事实并非一切”的专业发展课程中是学生。最后,用户可能会在某个特定的论坛(模块上下文)中被授予主持人角色。

权限

角色为每个功能定义一个权限。例如,教师角色可能会允许moodle/course:manage,但学生角色不会。但是,学生和教师都将允许mod/forum:startdiscussion

角色通常在全局定义,但可以在每个上下文中重新定义。例如,可以通过覆盖该 wiki(模块)上下文中学生角色的mod/wiki:edit功能的权限来使某个特定的 wiki 对学生只读,以防止。

有四个权限

在给定的上下文中,角色将对每个功能具有这四个权限之一。禁止和防止之间的区别在于,禁止不能在子上下文中被覆盖。

权限聚合

最后,聚合用户在此上下文中拥有的所有角色的权限。

禁止的一个用例是这样的:假设某个用户在多个论坛中发布了辱骂性帖子,我们希望立即阻止他们。我们可以创建一个淘气用户角色,该角色将mod/forum:post和其他此类功能设置为禁止。然后,我们可以在系统上下文中将此角色分配给辱骂性用户。这样,我们可以确保用户将无法在任何论坛中再发布任何帖子。(然后我们会与学生交谈,并在达成满意的结果后,删除该角色分配,以便他们可以再次使用系统。)

因此,Moodle 的权限系统为管理员提供了极大的灵活性。他们可以定义他们喜欢的任何角色,每个功能都有不同的权限;他们可以在子上下文中更改角色定义;然后他们可以在不同的上下文中将不同的角色分配给不同的用户。

13.3. 回到我们的示例脚本

脚本的下一部分说明了一些其他要点

第 5 行:从请求中获取数据

$name = optional_param('name', '', PARAM_TEXT);               // 5

每个 Web 应用程序都必须执行的操作是从请求(GET 或 POST 变量)中获取数据,而不会受到 SQL 注入或跨站点脚本攻击的影响。Moodle 提供了两种方法来做到这一点。

简单的方法是此处显示的方法。它获取给定参数名称(此处为name)的单个变量、默认值和预期类型。预期类型用于清除所有意外字符的输入。有许多类型,例如PARAM_INTPARAM_ALPHANUMPARAM_EMAIL等等。

还有一个类似的required_param函数,它与其他require_…函数一样,如果找不到预期的参数,则会停止执行并显示错误消息。

Moodle 用于从请求中获取数据的另一种机制是一个成熟的表单库。这是 PEAR 中 HTML QuickForm 库的包装器。(对于非 PHP 程序员,PEAR 是 PHP 等效于 CPAN 的东西。)当它被选中时,这似乎是一个不错的选择,但现在不再维护了。在将来的某个时候,我们将不得不处理迁移到一个新的表单库,我们中的许多人都期待着这一点,因为 QuickForm 有几个令人恼火的設計問題。但是,就目前而言,它已经足够了。表单可以定义为各种类型字段(例如文本框、选择下拉列表、日期选择器)的集合,并具有客户端和服务器端验证(包括使用相同的PARAM_…类型)。

第 6 行:全局变量

if (!$name) {
    $name = fullname($USER);                                  // 6
}

此代码段显示了 Moodle 提供的第一个全局变量。$USER使访问此脚本的用户的信息变得可访问。其他全局变量包括

以及其他一些,我们将在下面遇到其中一些。

您可能已经带着恐惧读到了“全局变量”这几个字。但是请注意,PHP 每次处理一个请求。因此,这些变量并不像那样全局。事实上,可以将 PHP 全局变量视为线程作用域注册表模式的实现(参见 Martin Fowler 的《企业应用架构模式》),而这正是 Moodle 使用它们的方式。它非常方便,因为它使常用对象在整个代码中都可用,而无需将它们传递给每个函数和方法。它只是很少被滥用。

没有什么是简单的

此行还旨在说明有关问题域的一个要点:没有什么事是简单的。显示用户姓名比简单地连接$USER->firstname'~'$USER->lastname要复杂得多。学校可能对显示这两个部分有任何政策,并且不同的文化在显示姓名的顺序方面有不同的约定。因此,有几个配置设置和一个函数可以根据规则组装全名。

日期也是类似的问题。不同的用户可能位于不同的时区。Moodle 将所有日期存储为 Unix 时间戳(即整数),因此可以在所有数据库中使用。然后有一个userdate函数,可以使用适当的时区和区域设置向用户显示时间戳。

第 7 行:日志记录

add_to_log(SITEID, 'local_greet', 'begreeted',
        'local/greet/index.php?name=' . urlencode($name));    // 7

Moodle 中的所有重要操作都记录在日志中。日志写入数据库中的一个表中。这是一个权衡。它使复杂的分析变得非常容易,实际上,各种基于日志的报告都包含在 Moodle 中。但是,在一个大型且繁忙的站点上,这是一个性能问题。日志表变得很大,这使得备份数据库更加困难,并且使日志表的查询速度变慢。日志表上也可能存在写冲突。这些问题可以通过多种方式缓解,例如通过批量写入或存档或删除旧记录以将其从主数据库中删除。

13.4. 生成输出

输出主要通过两个全局对象处理。

第 8 行:$PAGE全局变量

$PAGE->set_context($context);                                 // 8

$PAGE 存储要输出的页面信息。然后,这些信息可供生成 HTML 代码的代码轻松使用。此脚本需要显式指定当前上下文。(在其他情况下,这可能已由 require_login 自动设置。)此页面的 URL 也必须显式设置。这可能看起来是多余的,但要求它的理由是,您可能使用任意数量的不同 URL 访问特定页面,但传递给 set_url 的 URL 应该是页面的规范 URL——如果您愿意,则是一个良好的永久链接。页面标题也已设置。这将最终出现在 HTML 的 head 元素中。

第 9 行:Moodle URL

$PAGE->set_url(new moodle_url('/local/greet/index.php'),
        array('name' => $name));                              // 9

我只是想标记一下这个不错的辅助类,它使操作 URL 变得更容易。顺便说一句,回想一下,上面的 add_to_log 函数调用没有使用此辅助类。实际上,日志 API 无法接受 moodle_url 对象。这种不一致性是像 Moodle 这样古老的代码库的典型标志。

第 10 行:国际化

$PAGE->set_title(get_string('welcome', 'local_greet'));       // 10

Moodle 使用自己的系统允许将界面翻译成任何语言。现在可能存在良好的 PHP 国际化库,但在 2002 年首次实施时,没有一个可用的库是足够的。该系统基于 get_string 函数。字符串由键和插件 Frankenstyle 名称标识。如第 12 行所示,可以将值插入字符串中。(多个值使用 PHP 数组或对象处理。)

这些字符串在语言文件中查找,这些文件只是普通的 PHP 数组。这是我们插件的语言文件 local/greet/lang/en/local_greet.php

<?php
$string['greet:begreeted'] = 'Be greeted by the hello world example';
$string['welcome'] = 'Welcome';
$string['greet'] = 'Hello, {$a}!';
$string['pluginname'] = 'Hello world example';

请注意,除了我们在脚本中使用的两个字符串外,还有用于为功能命名以及插件在用户界面中显示的名称的字符串。

不同的语言由两位国家/地区代码(此处为 en)标识。语言包可能源自其他语言包。例如,fr_ca(加拿大法语)语言包将 fr(法语)声明为父语言,因此只需要定义与法语不同的字符串。由于 Moodle 起源于澳大利亚,因此 en 表示英国英语,而 en_us(美国英语)则派生自它。

同样,插件开发人员的简单 get_string API 隐藏了许多复杂性,包括计算当前语言(这可能取决于当前用户的偏好或他们当前所在的特定课程的设置),然后搜索所有语言包和父语言包以查找字符串。

生成语言包文件并协调翻译工作在 http://lang.moodle.org/ 上进行管理,它是具有自定义插件(local_amos)的 Moodle。它使用 Git 和数据库作为后端来存储语言文件,并提供完整的版本历史记录。

第 11 行:开始输出

echo $OUTPUT->header();                                       // 11

这是另一行看似无害的行,但它所做的远不止表面上的那样。关键是在进行任何输出之前,必须确定适用的主题(皮肤)。这可能取决于页面上下文和用户偏好的组合。但是,$PAGE->context 仅在第 8 行设置,因此 $OUTPUT 全局变量不可能在脚本开始时初始化。为了解决此问题,使用一些 PHP 魔术根据 $PAGE 中的信息在第一次调用任何输出方法时创建正确的 $OUTPUT 对象。

另一件需要考虑的事情是,Moodle 中的每个页面都可能包含。这些是通常显示在主要内容左侧或右侧的可配置内容片段。(它们是一种插件。)同样,要显示的确切块集合以灵活的方式(管理员可以控制)取决于页面上下文和页面标识的其他一些方面。因此,准备输出的另一部分是调用 $PAGE->blocks->load_blocks()

一旦计算出所有必要的信息,就会调用主题插件(控制页面的整体外观)以生成整体页面布局,包括所需的任何标准页眉和页脚。此调用还负责在 HTML 中适当的位置添加块的输出。在布局的中间将有一个 div,此页面特定内容将位于其中。生成此布局的 HTML,然后在主内容 div 的开头之后将其分成两半。返回前半部分,其余部分存储起来,以便由 $OUTPUT->footer() 返回。

第 12 行:输出页面主体

echo $OUTPUT->box(get_string('greet', 'local_greet',
        format_string($name)));                               // 12

此行输出页面的主体。在这里,它只是在一个框中显示问候语。问候语再次是本地化的字符串,这次将值替换到占位符中。核心渲染器 $OUTPUT 提供了许多便捷方法,例如 box,以相当高级别的术语描述所需的输出。不同的主题可以控制实际输出的 HTML 以制作该框。

最初来自用户的内容($name)通过 format_string 函数输出。这是提供 XSS 保护的另一部分。它还允许用户使用文本过滤器(另一种插件类型)。例如,过滤器可能是 LaTeX 过滤器,它将像 $$x + 1$$ 这样的输入替换为方程式的图像。我将提及但不解释,实际上有三个不同的函数(sformat_stringformat_text),具体取决于正在输出的特定内容类型。

第 13 行:完成输出

echo $OUTPUT->footer();                                       // 13

最后,输出页面的页脚。此示例未显示它,但 Moodle 会跟踪页面所需的所有 JavaScript,并在页脚中输出所有必要的脚本标签。这是标准的良好做法。它允许用户在不等待所有 JavaScript 加载的情况下查看页面。开发人员将使用 API 调用(例如 $PAGE->requires->js('/local/greet/cooleffect.js'))包含 JavaScript。

此脚本是否应混合逻辑和输出?

显然,即使在抽象级别很高的情况下,将输出代码直接放在 index.php 中,也会限制主题控制输出的灵活性。这是 Moodle 代码库年龄的另一个标志。$OUTPUT 全局变量于 2010 年引入,作为从旧代码(其中输出和控制器代码位于同一文件中)到所有视图代码都已正确分离的设计的垫脚石。这也解释了生成整个页面布局然后将其分成两半的相当丑陋的方式,以便脚本本身的任何输出都可以放置在页眉和页脚之间。一旦视图代码从脚本中分离出来,进入 Moodle 所谓的渲染器,主题就可以选择完全(或部分)覆盖给定脚本的视图代码。

一个小的重构可以将所有输出代码从我们的 index.php 移到渲染器中。index.php 的末尾(第 11 行到第 13 行)将更改为

$output = $PAGE->get_renderer('local_greet');
echo $output->greeting_page($name);

并且将有一个新的文件 local/greet/renderer.php

<?php
class local_greet_renderer extends plugin_renderer_base {
    public function greeting_page($name) {
        $output = '';
        $output .= $this->header();
        $output .= $this->box(get_string('greet', 'local_greet', $name));
        $output .= $this->footer();
        return $output;
    }
}

如果主题希望完全更改此输出,它将定义此渲染器的子类,该子类覆盖 greeting_page 方法。$PAGE->get_renderer() 根据当前主题确定要实例化的适当渲染器类。因此,输出(视图)代码与 index.php 中的控制器代码完全分离,并且插件已从典型的遗留 Moodle 代码重构为干净的 MVC 架构。

13.5. 数据库抽象

"Hello world" 脚本足够简单,不需要访问数据库,尽管使用的几个 Moodle 库调用确实执行了数据库查询。我现在将简要描述 Moodle 数据库层。

Moodle 曾经使用 ADOdb 库作为其数据库抽象层的基础,但对我们来说存在一些问题,并且额外的库代码层对性能产生了明显的影响。因此,在 Moodle 2.0 中,我们切换到自己的抽象层,它是各种 PHP 数据库库的薄包装器。

moodle_database

库的核心是 moodle_database 类。这定义了 $DB 全局变量提供的接口,该接口提供了对数据库连接的访问。典型用法可能是

$course = $DB->get_record('course', array('id' => $courseid));

这转换为 SQL

SELECT * FROM mdl_course WHERE id = $courseid;

并将数据作为具有公共字段的普通 PHP 对象返回,因此您可以访问 $course->id$course->fullname 等。

像这样的简单方法处理基本查询以及简单的更新和插入。有时需要执行更复杂的 SQL,例如运行报表。在这种情况下,有一些方法可以执行任意 SQL

$courseswithactivitycounts = $DB->get_records_sql(
   'SELECT c.id, ' . $DB->sql_concat('shortname', "' '", 'fullname') . ' AS coursename,
        COUNT(1) AS activitycount
   FROM {course} c
   JOIN {course_modules} cm ON cm.course = c.id
   WHERE c.category = :categoryid
   GROUP BY c.id, c.shortname, c.fullname ORDER BY c.shortname, c.fullname',
   array('categoryid' => $category));

那里需要注意的一些事项

定义数据库结构

数据库管理系统差异很大的另一个领域是定义表所需的 SQL 语法。为了解决此问题,每个 Moodle 插件(和 Moodle 核心)都在 XML 文件中定义所需的数据库表。Moodle 安装系统解析 install.xml 文件并使用其中包含的信息来创建所需的表和索引。Moodle 中内置了一个名为 XMLDB 的开发人员工具,可以帮助创建和编辑这些安装文件。

如果数据库结构需要在 Moodle(或插件)的两个版本之间发生变化,则开发人员负责编写代码(使用提供 DDL 方法的其他数据库对象)来更新数据库结构,同时保留所有用户数据。因此,Moodle 将始终从一个版本自更新到下一个版本,从而简化管理员的维护工作。

一个有争议的点,源于 Moodle 最初使用 MySQL 3 的事实,即 Moodle 数据库不使用外键。这允许一些错误行为无法被检测到,即使现代数据库能够检测到该问题。困难在于人们已经运行了没有外键的 Moodle 站点多年,因此几乎肯定存在不一致的数据。现在添加密钥是不可能的,除非进行非常困难的清理工作。即使这样,自从 XMLDB 系统添加到 Moodle 1.7(2006 年!)以来,install.xml 文件包含了应该存在的外键的定义,我们仍然希望有一天能够完成所有必要的工作,以便能够在安装过程中创建它们。

13.6. 未涵盖的内容

我希望我已经向您很好地概述了 Moodle 的工作原理。由于篇幅限制,我不得不省略了一些有趣的话题,包括身份验证、注册和评分插件如何允许 Moodle 与学生信息系统互操作,以及 Moodle 存储上传文件的有趣内容寻址方式。这些以及 Moodle 设计的其他方面的详细信息可以在 开发者文档 中找到。

13.7. 经验教训

Moodle 的一个有趣方面是它起源于一个研究项目。Moodle 促进了(但没有强制)一种社会建构主义教学法。也就是说,我们通过实际创造一些东西来学习得最好,并且我们作为一个社区互相学习。Martin Dougiamas 的博士论文问题并没有询问这是否是一种有效的教育模式,而是询问这是否是一种运行开源项目的有效模式。也就是说,我们可以将 Moodle 项目视为尝试学习如何构建和使用虚拟学习环境 (VLE) 的尝试,以及通过实际构建和使用 Moodle 作为教师、开发者、管理员和学生都互相教学和学习的社区来学习的尝试?我认为这是一个思考开源软件开发项目的良好模型。开发者和用户互相学习的主要场所是 Moodle 项目论坛中的讨论以及错误数据库。

也许这种学习方法最重要的结果是,你不应该害怕首先实现最简单的解决方案。例如,早期版本的 Moodle 只有几个硬编码的角色,如教师、学生和管理员。这对很多年来说已经足够了,但最终必须解决这些限制。当需要为 Moodle 1.7 设计角色系统时,社区积累了大量关于人们如何使用 Moodle 的经验,以及许多小的功能请求,这些请求表明人们需要能够使用更灵活的访问控制系统进行调整。所有这些都有助于设计角色系统,使其尽可能简单,但又尽可能复杂。(事实上,角色系统的第一个版本最终稍微复杂了一点,随后在 Moodle 2.0 中进行了简化。)

如果你认为编程是一个解决问题的练习,那么你可能会认为 Moodle 最初的设计错了,后来不得不浪费时间纠正它。我建议在尝试解决复杂的现实世界问题时,这是一种无益的观点。在 Moodle 启动时,没有人知道足够的信息来设计我们现在拥有的角色系统。如果你采取学习的观点,那么 Moodle 经历的各个阶段才能达到当前的设计是必要且不可避免的。

为了使这种观点有效,一旦你学到更多知识,就必须能够更改系统架构的几乎任何方面。我认为 Moodle 表明这是可能的。例如,我们找到了一种方法,可以将代码从旧版脚本逐步重构到更清晰的 MVC 架构。这需要付出努力,但似乎在必要时,可以在开源项目中找到实施这些更改的资源。从用户的角度来看,系统随着每个主要版本的发布逐渐发展。