企业🤖AI智能体构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
> 作者:[Tim Hunt](http://www.aosabook.org/en/intro2#hunt-tim) > 译者:[Li Shijian](http://lishijian.com/)(李诗剑) Moodle是一款为教育系统设计的Web应用。我会对Moodle各个部分如何运作做一个综述,同时我将专注于介绍几个我认为特别有趣的设计: 1. 用插件分割应用的方法; 2. 权限系统 —— 它控制着什么用户可以在系统不同的地方做什么事情; 3. 产生输出的方式 —— 它使得应用不同的主题来更改外观,并且把界面接口分离出来; 4. 数据库抽象层。 ## 什么是Moodle? [Moddle](http://moodle.org/) 提供了一个师生之间的在线教学平台。一个Moodle站点被划分为不同的课程。特定的用户可以以不同的身份参与到一门课程中去,比如学生或者老师。每一门课程都由一系列的资源和活动组成。一个资源可以是一份PDF文档,Moodle的一个HTML页面,或者干脆是一个指向网络上其他位置的链接。一个活动可能是一个论坛,一次测验,或者是一个Wiki。在一门课程中,这些资源和活动以某种方式被组织起来。例如,它们可以按照逻辑上的话题,或者是日程上特定的周目被分配到一起。 ![course.png](http://box.kancloud.cn/2015-08-20_55d58d5dd8c36.png) 图13.1: Moodle课程 Moodle可以作为一个单独的应用来使用。比如说,如果你只是想为软件架构课程构建一个网站,你只要在你的网站托管那里下载Moodle并且安装它,而后创建课程,等待学生自己来注册就可以了。或者说,你为一个庞大的机构工作,Moodle可以仅仅成为你所运行的众多系统中的一个。此时你很有可能已经拥有了: ![university.png](http://box.kancloud.cn/2015-08-20_55d58d6049014.png) 图13.2: 大学系统典型架构 * 一个管理跨系统的用户帐号身份认证服务(例如使用LDAP)。 * 一个学生信息系统。它就是一个庞大的数据库,里面记载着所有的学生信息,包括他们当前正在进行的课程,以及以后需要完成的课程;还有他们的笔记,这份笔记可以是他们对一门所完成课程的高度总结。当然,这个信息系统也可以提供其他的管理功能,比如跟踪一个学生是否上缴了学费。 * 一个文档库(比如使用 Alfresco)。它用来存储文件,以及跟踪用户合作维护文件时的工作流。 * 一个电子档案袋(ePortfolio)。 学生可以在这里存放他们自己的资料(assets)并且把它们组合起来形成其他文档。例如利用这些资料编写一篇CV(简历),或者用来证明档案所有者已经满足了一门实践课的选修条件。 Moodle专注于为所有参与到教学中的人提供一个在线平台,而不是为某一个教育组织特别设计的某个系统。Moodle仅仅为非主要功能提供了最基本的实现,所以它可以单独的作为一个应用,或者与其它系统进行集成。Moodle扮演的角色被正式地称为虚拟教学环境(VLE),或者是教学/课程管理系统(LMS,CMS,甚至LCMS)。 Moodle是一个用PHP编写的开源免费软件(GPL)。它可以在绝大多数的Web服务器和平台上运行。它需要一个数据库,目前支持MySQL,PostgreSQL,MS SQL Server以及Oracle。 ## Moodle从哪里来? Moodle项目由Martin Dougiamas在1999年开创,当时他正在澳大利亚的科廷大学工作。1.0版本于2002年发布,当时使用的语言和数据库版本是PHP 4.2和MySQL 3.23。那时的版本从一开始就限制了Moodle可能采用的框架。然而从那以后,整个软件发生了翻天覆地的变化。现在发布的版本是Moodle 2.2.x系列。 # 13.1\. Moodle运作方式综述 ## 安装Moodle的三个部分 Moodle安装由三部分组成: 1. 代码,通常在一个类似 `/var/www/moodle` 或者 `~/htdocs/moodle` 的目录里。Web服务器应该对这个目录具有写权限。 2. 数据库,由上面提到过的几种RDMS(RDBMS,关系性数据库管理系统)管理。实际上,Moodle给所有的表名增加了一个前缀。所以如果需要的话,它可以和其他应用共用一个数据库。 3. `moodledata` 目录。这个目录用于存储用户上传的文件以及系统生成的文件,同样Web服务器需要有对这个目录的写权限。出于安全考虑,这个目录应该设置于Web根目录之外。 以上三部分可以完全部署在一台服务器上。或者,采用负载均衡的设置,在每台Web服务器上都部署代码,但是仅仅共用一个数据库和一个很有可能在其他服务器上的`moodledata`目录。 当Moodle安装完毕后,这三个部分的配置信息被存储在`moodle`根目录下的`config.php`文件中。 ## 请求调度 Moodle是一个Web应用,所以用户通过浏览器来与之交互。从Moodle自己的视角来看,这就意味着它要响应HTTP请求。Moodle的一个重要设计考量就是URL的名字空间,以及URL如何被调度到不同的脚本上。 Moodle在这里采用PHP标准方法。浏览一个课程的主页时,URL可能像 `.../course/view.php?id=123`,这里`123`就是这门课程在数据库中的唯一标识。浏览一个论坛讨论时,URL可能是`.../mod/forum/discuss.php?id=456789`。也就是说,这些特定的脚本,`course/view.php` 或者`mod/forum/discuss.php` 会来处理这些请求。 这对于开发者来说是非常简单的。想要理解Moodle是怎么处理一个特定的请求,你只需要观察URL,从阅读那份php文件的代码开始。但是从用户的角度来看这是十分丑陋的,因为这些URL是永久不变的。比方说一个课程改了名字,或者一个管理员把一个讨论转移到另一个论坛中,这些URL都不会变。(这对于URL来说是一个非常好的性质,正如Tim Berners-Lee在他的文章[Cool URIs don't change](http://www.w3.org/Provider/Style/URI.html)中提到的) 另一种可以采用的方法是建立一个唯一入口 `.../index.php/[其他使请求唯一确定的信息]`。这个单独的`index.php`脚本会通过某种方式将请求进行调度。这个方法添加了一个大多数软件开发者都喜欢用的间接层。缺少了这个间接层并不会影响到Moodle的使用。 ## 插件 和许多其它成功的开源项目一样,Moodle由许多和系统内核协同工作的插件构建起来。这是一个绝妙的主意,因为它可以使用户按照他们定制的方法来增强Moodle的功能。一个开源系统的重要优势在于,你可以根据自己的特定需求来更改它。然而,为代码增加高可定制性的同时,会在系统升级的时候引入大麻烦,即使我们已经采用了很好的版本控制系统。Moodle的插件通过定义好的API与内核交互,所以在自包含的插件中,可以允许尽可能多的用户定制与新特性被开发出来。这也方便了用户根据需求定制自己的Moodle,分享这些定制内容,同时也便于对Moodle系统内核进行升级。 有许多不同的方法可以将一个系统构建成插件化的。Moodle具有一个相对庞大的内核,并且插件是强类型的。我所说的相对庞大的内核,指的是内核提供了大量的功能。这违反了那类,由一个小型的插件启动器进行引导,其余部分都是插件的架构设计。 当我提及插件是强类型的时候,我指的是根据你想要实现的具体功能,你可能需要写完全不同的插件,实现不同的API。比如,一个新的活动模块插件会与一个新的认证插件,或者是提问插件截然不同。根据最后统计,现在我们一共有35种不同的插件(这里有一个[Moodle插件类型完全列表](http://docs.moodle.org/dev/Plugins/))。这违背了那类,所有插件通过使用最基本的API,通过注册它们感兴趣的钩子和事件与内核进行交互的架构设计。 通常来说,Moodle现在有尝试把更多的功能移到插件中以减小内核的趋势。可是这并没有带来巨大的成功,因为当前一个逐渐增长的特性集趋于去扩展内核。另一个趋势是尽可能将不同种类的插件进行规范化。这样在许多公共功能上,比如安装和升级,所有类型的插件都能够按照统一的方式运行。 一个Moodle中的插件其实就是一个包含许多文件的目录。每一个插件都有一个类型和名字,这两个构成了这个插件的"Frankenstyle"组件名称。("Frankenstyle"这个单词出自于开发者Jabber频道的一次讨论,人人都爱它,所以这个单词就被固定下来了)插件的类型和名字决定了这个插件目录的路径。插件类型给定一个前缀,目录名称就是这个插件的名字。这里有一些例子: | 插件类型 | 插件名称 | Frankenstyle | 目录 | | mod (Activity module) | `forum` | `mod_forum` | `mod/forum` | | mod (Activity module) | `quiz` | `mod_quiz` | `mod/quiz` | | block (Side-block) | `navigation` | `block_navigation` | `blocks/navigation` | | qtype (Question type) | `shortanswer` | `qtype_shortanswer` | `question/type/shortanswer` | | quiz (Quiz report) | `statistics` | `quiz_statistics` | `mod/quiz/report/statistics` | 最后的一个例子表明了每一个活动模块被允许声明子插件类型。只有活动模块才能做到这个,出于两点原因。首先如果所有的插件都可以声明子插件类型,这或许会带来严重的性能问题。另外活动模块是Moodle中最重要的教育活动,也是插件中最重要的类型,所以它们应该具有特殊的权限。 ## 示例插件 我会以一个具体的插件实例来解释Moodle架构中的大量细节。作为惯例,我选择实现一个显示"Hello world"的插件。 这个插件实际上并不适合任何一种Moodle标准插件。它只是一个简单的脚本,和其他任何东西都没有联系,所以我选择把它制作成一个'local'类型的插件。这是一个catch-all的插件类型,专门处理一些杂乱的功能,所以在这里再适合不过了。我给我的插件命名为`greet`,所以它的Frankenstyle的名字是`local_greet`,路径为`local/greet`。([插件代码](https://github.com/timhunt/moodle-local_greet)下载) 每一个插件都必需包含一个叫做`version.php`的文件,这个文件定义了关于这个插件本身的元数据。Moodle的插件安装系统会使用它来对插件进行安装和升级。例如`local/greet/version.php`包含代码: ~~~ <?php $plugin->component = 'local_greet'; $plugin->version = 2011102900; $plugin->requires = 2011102700; $plugin->maturity = MATURITY_STABLE; ~~~ 因为可以从路径上显然地推导出插件的名字,所以乍看之下代码里面包含组件名(component name)略显多余。而实际上,安装器需要通过组件名来验证插件是否安装在正确的位置上。版本(Version)字段定义了这个插件的版本,成熟度(Maturity)是诸如ALPHA,BETA,RC(发布候选版, release candidate), 或者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 ~~~ ## Line 1:引导Moodle ~~~ require_once(dirname(__FILE__) . '/../../config.php'); // 1 ~~~ 这单独的一行是大多数工作都要首先完成的。我之前说过,`config.php`包含着Moodle如何连接数据库以及找到`metadata`目录的细节。然后,它以一行`require_once('lib/setup.php')`结束。这样: 1. 通过`require_once`加载所有Moodle标准库; 2. 开始处理会话; 3. 连接数据库; 4. 初始化一系列全局变量,我们一会就将看到它们。 ## Line 2:检查用户是否登录 ~~~ require_login(); ~~~ 这行使得Moodle利用管理员配置过的任何认证插件来判断,当前访问用户是否已经登录。 一个与Moodle整合性更好的插件会在这里传递更多的参数,比如这个页面属于哪个课程或者活动。然后调用的`require_login`仍然会检查是否当前用户是否参加了这门课程或者活动。如果是,用户就可以访问这门课程,或者观看这个活动;如果不是,那么适当的错误信息会被显示出来。 # 13.2\. Moodle中的角色和权限系统 接下来的两行代码显示出如何检查用户是否有做某件事的权限。正如你所见,从开发者的角度来说,这些API都十分的简单。但是,实际上在这下面是一个非常复杂的接入系统。这会给管理员很大的伸缩性,以控制什么人可以做什么。 ## Line 3: 获得上下文 ~~~ $context = context_system::instance(); // 3 ~~~ 在Moodle中,同一个人可能在不同的地方拥有不同的权限。比如说一个用户可能在某个课程上做一名老师,也可能是另一门课程的一位学生。这些地方被称作为上下文(context)。上下文在Moodle中构筑了一个特别像文件系统中目录结构那样的多层结构。 在系统的上下文中,有许多的上下文信息被构造出来,它们负责维护那些为了组织课程而被创建的不同分类(Category)。这些上下文可以是嵌套的,比如在一个分类里面包含有其他更多的分类。分类上下文同时也包含着课程上下文。最后,每一个课程中的活动也会拥有一个自己的Moodle上下文。 ![contexts.png](http://box.kancloud.cn/2015-08-20_55d58d63de28e.png) 图13.3:上下文 ## Line 4: 检查用户是否有权执行这个脚本 ~~~ require_capability('local/greet:begreeted', $context); // 4 ~~~ 现在我们获得了上下文 —— 与Moodle相关的领域 —— 就可以检查权限了。某个用户能否执行某个功能的信息被称作一个能力(Capability)。基于能力的检查可以提供比简单的`require_login`检查更加细粒度的访问检查。我们这个简单的插件,只有一个能力:`local/greet:begreeted`。 这个检查通过`require_capability`函数来完成,不过这需要这个能力的名字以及当前的上下文。就像其他`require_...`函数一样,如果用户没有这个能力,它不会正常返回,而是显示一个错误。在其他地方,非致命的`has_capability`函数,当可用的时候返回true,比如要不要在另一个网页上添加对于当前这个脚本的一个链接。 那么管理员是如何配置什么用户拥有什么权限的呢?这是在`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之后,你在系统上下文中就拥有了一个“Authenticated user”的角色。由于系统上下文是上下文层次结构中的根节点,所以这个角色会被应用到所有的地方。 在一个特定的课程中,你或许是一个学生,那么这个角色就会在这个课程的上下文以及其子模块的上下文中都有效。然而,再另一门课程中,你可能有一个不同的身份。例如,Gradgrind先生可以是”Facts,Facts,Facts“这门课的教师,但是他却是职业发展课程“Facts Aren't Everything”中的一名学生。最后,一个用户或许会在特定的论坛(模块上下文)中被指派为一个主持人(Moderator)的角色。 ## 权限 一个角色对每一种能力都规定了一个权限。例如,教师的角色很有可能被允许(ALLOW)`moodle/course:manage`,但是学生角色就不会。然而,教师和学生都会被允许`mod/forum:stardiscussion`。 角色通常是全局定义的,但是他们可以在每一个上下文中被重新定义。比方说,一个特定的wiki如果要对所有学生角色变成只读的,只需要将这个wiki(模块上下文)的上下文中对于学生的`mod/wiki:edit`能力覆盖成禁止(PREVENT)。 这里是四种权限: * 未设置/继承(默认) * 允许 * 禁止(PREVENT) * 戒绝(PROHIBIT) 在给定的上下文中,一个角色对每一个能力都有这四种权限之一。禁止和解决的一个重要区别是,戒绝在禁止的基础上还确保子上下文不能覆盖这个权限。 ## 权限整合 最后,一个用户在这个上下文中根据所有角色获得的权限会被整合起来。 * 如果任何角色对于一个能力给出的权限是戒绝,那么返回false。 * 否则,如果任何角色对于这个能力给出的权限是允许,那么返回true。 * 再否则,返回false 一个使用戒绝权限的用例如下:假设一个用户在许多的论坛中持续乱发帖子,我们想让这个家伙立刻闭嘴。那么我们可以建立一个叫做捣蛋鬼(Naughty)的角色,这个角色对于类似`mod/forum:post`这样的能力全部设置为戒绝。我们可以把这个捣蛋鬼的角色在系统上下文中分配给那个乱发帖子的用户。这样我们就能保证这个用户在所有的论坛里面都不能发帖了。(我们可以和这个学生好好谈谈,得到一个满意的答复,然后再把这个角色指派删除掉,这样他又能使用我们的系统了) 总而言之,Moodle的权限系统给了管理员很大的伸缩性。他们可以定义任何一个他们喜欢的角色,为这个角色的每一个能力指定不同权限;他们可以在子上下文中改变角色的定义;并且,他们还可以在不同的上下文中对用户赋予不同的角色。 # 13.3\. 回到样例脚本 脚本的下一个部分解释了一些繁杂的事情: ## Line 5:从请求中获得数据 ~~~ $name = optional_param('name', '', PARAM_TEXT); // 5 ~~~ 每个网络应用都会做的一件事情就是,把数据从请求中获取出来(GET或者POST变量),而不会产生SQL注入和跨站脚本攻击。Moodle提供了两种方法来完成这件事。 上面那行代码就是一个简单的方法。它通过一个参数名(在这里的`name`),一个缺省值,以及一个期望类型来获得一个单独的值。期望类型用来清理掉所有带有非法字符的输入。我们定义了许多类型,诸如`PARAM_INT`,`PARAM_ALPHANUM`,`PARAM_EMAIL`等等。 这里你也可以用类似`required_param`这样的函数。这些`require...`函数如果发现期望的参数没有找到时会停止执行并显示一个错误信息。 另一个Moodle从请求中获得数据的机制是一个非常成熟的库。它给PEAR的HTML QuickForm库包了一层。(对于非PHP程序员来说,PEAR在PHP中就相当于CPAN)这在刚开始下决定的时候似乎是一个好主意,但是现在我们已经不维护它了。或许在未来的某一天,我们会迁移到一个新形式的库中,就像我们中许多人一直想做的,因为QuickForm有一些恼人的设计问题。不过现在,这个机制就已经足够了。表单就是一个字段的集合(比如text box,select drop-down,date-selector),每一个字段可能具有不同的类型用于前端和后端的验证(包括使用`PARAM_...`类型)。 ## Line 6:全局变量 ~~~ if (!$name) { $name = fullname($USER); // 6 } ~~~ 这一小段代码展示了Moodle提供的第一个全局变量。`$USER`保存关于执行当前脚本的用户信息。其他的全局变量包括: * $CFG:保存常用的配置 * $DB:数据库连接 * $SESSION:封装了PHP会话(session) * $COURSE:当前请求相关的课程 还有一些其他的,我们会在下面见到它们。 你或许觉得“全局变量”这个词十分恐怖。然而,请注意,PHP每次只处理一个请求,所以这些变量根本就不是全局的。实际上,PHP的全局变量可以被视为线程安全的注册表模式(请参看Martin Fowler的《企业级应用架构模式》),这也是Moodle使用它们的原因。让最常用的对象始终可见是非常方便的,因为你不需要把它们作为参数传入到每一个函数和方法中去。这个方法很少被滥用。 ## 没那么简单 这一行同时也揭示出一点:任何事都不是那么简单。显示一个用户名远比轻易地把`$USER->firstname`,`~`,`$USER->lastname`拼接起来复杂的多。学校或许规定只允许显示名字其中的一部分,况且许多不同的文化对于名字显示的顺序也有不同的习惯。所以,根据这些规则,才会有针对它的不同配置和一个用来组装全名的函数。 日期也是一个类似的问题。不同的用户可能在不同的时区中。Moodle把所有的日期都存储成Unix时间戳,这种时间戳是一个整数,并且所有的数据库都支持它。所以,必须有一个`userdate`的函数根据用户所在的时区和本地设置来合理的显示时间。 ## Line 7:日志 ~~~ add_to_log(SITEID, 'local_greet', 'begreeted', 'local/greet/index.php?name=' . urlencode($name)); // 7 ~~~ Moodle所有的关键动作都会被日志记录下来。日志被写到数据库的一个表中。这是一个折中的办法。这个方法使得复杂的分析变得容易,而且Moodle基于这些日志也提供了很多翔实的报告。但是对一个大规模、高访问量的网站来说,这却带来了一个性能问题。日志表非常大,这使得数据库备份变得异常困难,而且对于日志的查询也非常的慢。在日志表上还存在者写入竞争。这些问题可以通过不同的方法得以缓解,比如批量写,存档或者删除旧的记录,把它们从主数据库中移除。 # 13.4\. 产生输出(页面生成) 输出主要通过两个全局对象来处理: ~~~ $PAGE->set_context($context); // 8 ~~~ ## Line 8:全局变量$PAGE `$PAGE`存储着要被输出的页面信息。这个信息在所有产生HTML的代码中都可以轻易获得。在这个脚本中,必须明确指明当前的上下文是什么。(在其他的情况下,它或许已经通过`require_login`被自动设置了)这个页面的URL也必须被明确。这看起来似乎很没必要,但是需要它的合理性在于你或许会使用不同的URL来获取同一个页面。如果你喜欢的话,传递给`set_url`的URL必须是一个这个页面的规范化URL —— 一个永久链接。页面的标题也要被设置。这样HTML的head元素就被构建出来了。 ## Line 9:Moodle URL ~~~ $PAGE->set_url(new moodle_url('/local/greet/index.php'), array('name' => $name)); // 9 ~~~ 顺便说一句,上面用过的`add_to_log`并没有使用这个辅助类。确实,日志API不能够接受`moodle_url`对象。这种不一致性是一个像Moodle一样老的code-base①的典型特征。 > ① 译者注:code-base通常是指那些由人力产生的代码,许多自动生成的代码不算,比如由配置文件通过工具生成的代码就不是code-base。一般code-base的代码才有用版本控制的价值。 ## Line 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]上管理,Moodle用它们制作了一个可定制插件([local_amos](http://docs.moodle.org/22/en/AMOS))。它使用Git和数据库作为存储语言文件的后端,保留了所有的历史版本。 ## Line 11:开始输出 ~~~ echo $OUTPUT->header(); // 11 ~~~ 这又是一个看似平淡无奇的一行,然而它所做的工作可比看起来多得多。这里最关键的一点在于,在任何的输出之前,页面所采用的主题(皮肤)必须被计算出来。这取决于页面上下文以及用户偏好的组合。然而,`$PAGE->context`只在第8行被设置,所以`$OUTPUT`全局变量不能在脚本的一开始就初始化。为了解决这个问题,我们使用了一些PHP的小技巧,根据`$PAGE`的信息,在第一次调用输出方法的时候才构造合适的`$OUTPUT`。 另一件需要考虑的事情是,Moodle中的每一个界面都有可能包含块(blocks)。这些块是一部分可以额外配置的内容,通常被显示在主要内容的左侧或者右侧。(它们是一类插件)同时,到底哪些特定的块需要被显示出来,通过一种弹性的方式(管理员可控),由页面的上下文和其他页面的标识来决定。所以,输出的另一个准备工作就是调用`$PAGE->blocks->load_block()`。 当所有必要的信息都被准备好了之后,主题插件(控制页面的整体外观)被调用以产生页面的整体布局,包括任何标准需要的头部和页脚。这个调用同时也负责在HTML中对应的位置填入块中的内容。在布局的中间,会有一个`div`,这个页面特定的内容会显示在这里。当HTML的布局产生之后,在主要内容的`div`上一切两半。在第一半完成后,其他的部分被存储起来,由`$OUTPUT->footer()`返回。 ## Line 12:输出页面Body ~~~ echo $OUTPUT->box(get_string('greet', 'local_greet', format_string($name))); // 12 ~~~ 这一行输出了整个页面的主体。这里仅仅把我们的问候显示在一个盒子里。这一句问候,同样,是一个本地化过的字符串,因为这时我们已经用了一个值替换掉了占位符。内核渲染器`$OUTPUT`提供了许多像`box`这样方便的方法,以高级术语来描述我们所需要的输出。不同的主题可以控制什么样的HTML元素真正地被用来构建这个盒子。 首先输出的内容是通过`format_string`函数处理过的用户的信息(`$name`)。这是XSS(Cross-Site Scripting,跨站脚本攻击)保护的另一部分。另外这也使文本过滤器产生作用。使用过滤器的一个例子就是LaTex过滤器,它把像`$$x + 1$$`这样的输入转换成一个公式的图片。我会简单的提到,但是不会进行详细的解释,实际上,这里有三个不同的函数(`s`,`format_string`和`format_text`)进行字符串处理。具体使用哪个取决于输出内容的具体类型。 ## Line 13:结束输出 ~~~ echo $OUTPUT->footer(); // 13 ~~~ 最后,页脚被输出。这个例子并没有显示出来,但是Moodle会记录所有这个页面需要的JS文件,然后把它们都添加到页脚上。这是一个经典的好实现。这样用户就可以先看到页面,而不必等待所有的JS加载完成。一个开发者可以像`$PAGE->requires->js('/local/greet/cooleffect.js')`这样用API添加JS 。 ## 这个脚本应该混杂逻辑和显示么? 很显然,把进行输出的代码直接写在`index.php`里面,即使这是一个高级的抽象,也会限制主题对于输出更改的灵活性。这是另一个Moodle老旧code-base的现象。全局变量`$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语句中。在某些场合,我们使用了底层数据库驱动的功能。在其他情况下,这些值必须通过转译,然后利用字符串操作将其插入到SQL语句中。库函数支持两种填充方法,一种使用命名占位符(就像上面那样),还有一种使用`?`作为占位符的匿名方式。 * 为了让查询在我们所有支持的数据库中都得以实现,只有标准SQL中的一个安全子集被筛选出来以供使用。比如,你能看到我使用了关键字`AS`来对列名作别名处理,但是从来没有对表名的别名。这两个用法规则都十分的重要。 * 即使是这样,仍然有一些情况,没有任何一个标准SQL的子集可以在所有我们需要支持的数据库上都能运作。比如说,每一种数据库都用完全不同的方式来处理字符串拼接。在这些时候,我们提供一些兼容功能来产生正确的SQL语句。 ## 定义数据库结构 另一个数据库系统之间差异很大的地方就是,创建表的SQL语法。为了克服这个问题,每一个Moodle插件(包括Moodle内核)都在一个XML文件中定义了需要的数据库表。Moodle安装系统会解析`install.xml`文件,并且利用它们包含的信息来创建所需的表和索引。有一个Moodle内建的叫做XMLDB的工具,它可以用来帮助开发者创建和编辑这些安装文件。 如果在两个不同的Moodle发布版(或者是一个插件)中数据库结构需要更改,那么开发者就需要负责编写代码(使用一个额外提供DDL方法的数据库对象)来更新这些数据库结构,同时必须保持所有的用户数据。所以,Moodle总是在版本升级的时候总是进行自我更新,简化了管理员的维护成本。 另一个有争议的地方是,鉴于Moodle最开始使用的是MySQL 3这个版本的事实,Moodle数据库没有使用外键。这就有可能使得许多容易产生BUG的行为很难被检测到,但是现代数据库却很容易检测到它们。困难在于,我们的用户不用外键使用Moodle站点已经很多年了,所以现在几乎肯定有数据不一致性存在。如果现在要添加这些键,不进行一次非常困难的清理工作是不可能的。尽管如此,自从XMLDB系统加入到Moodle 1.7(在2006年!)以来,这些`install.xml`文件已经规定了外键可以存在的定义。我们始终希望,总有一天,通过必要的工作,可以允许我们在安装过程中创建这些键。 # 13.6\. 本文未涉及到的 我希望你已经对Moodle如何工作有了一个比较清晰的了解。但是由于篇幅的限制,我忽略了一些有趣的主题,包括认证、注册和评分插件是如何使Moodle与学生信息系统交互的。当然还有Moodle存储上传文件所使用的基于内容寻址的方法。这些细节,以及Moodle设计的其他部分,你可以在[开发者文档](http://docs.moodle.org/dev/)中找到。 # 13.7\. 经验教训 一个关于Moodle非常有趣的话题是,Moodle实际上产生于一个科研项目。Moodle使得(但是并不强制)社会性构建教育法成为可能。它的意思是说,我们最好的学习方式是实实在在地创造一些东西,并且从社区中其他人那里获得知识。Martin Dougiamas的PhD答辩并没有回答这种模式对于教育是否有效,但是却肯定了它作为运作一个开源项目的方法是十分有效的。也就是说,我们可以把整个Moodle项目本身就看作一个建造和使用一个VLE的尝试。同时,我们尝试把Moodle作为一个社区来构建和使用,看看我们的教师、开发者、管理员和学生是否能在其中互相学习呢?我发现这是一个思考开源软件项目开发的绝妙模型。开发者和用户相互学习的主要场所,其实就是Moodle项目的论坛和BUG数据库。 或许关于学习方法更加重要的结论就是,你不应该在一开始就为实现一个最简单的可行方案而感到害怕。比如,Moodle的早期版本有许多像Teacher,Student以及Administrator这样硬编码的角色。它们在许多年间工作的都非常好,但是最终它们的局限性凸显了出来。在Moodle 1.7版本决定设计角色系统的时候,我们的社区里面已经积累了大量用户如何使用Moodle的经验。此时,许多小的功能性需求也显示出,我们需要一个弹性的访问控制系统来满足用户的需求。这些都帮助我们将角色系统设计地尽可能的简单,但是它的复杂度也足够完成必须的功能。(实际上角色系统的第一个版本完成的时候有一点过于复杂,所以在随后的Moodle 2.0版本中它被简化了一些。) 如果你认为编程是一个解决问题的活动,你可能会认为Moodle从最开始就被错误地设计了,而后必须要浪费大量的时间来修改它。我觉得这样的观点对于解决一个复杂的实用性问题毫无帮助。在Moodle最开始的时候,没有人能够像今天这样充分地认识到角色系统应该被设计出来。如果你是带着学习的观点来看待,你会发现Moodle在不同的阶段实际上只进行必要和不可避免的设计。 为了让上面的观点得以实施,那么在有了更深的理解之后,我们必须要有更改现有系统架构几乎任何一个部分的能力。我觉得Moodle的历程说明了,这是可能的。比如,我们找到一个优雅的重构方法,它把我们的代码从遗留代码中拯救出来,变成了更加干净的MVC架构。这需要大量的努力,但是必要的时候,一些其他的开源项目可以提供我们实现这些变化的资源。从用户的角度来看,整个系统随着每一次发布都在进行不断的进化。