> 作者:[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。在一門課程中,這些資源和活動以某種方式被組織起來。例如,它們可以按照邏輯上的話題,或者是日程上特定的周目被分配到一起。

圖13.1: Moodle課程
Moodle可以作為一個單獨的應用來使用。比如說,如果你只是想為軟件架構課程構建一個網站,你只要在你的網站托管那里下載Moodle并且安裝它,而后創建課程,等待學生自己來注冊就可以了。或者說,你為一個龐大的機構工作,Moodle可以僅僅成為你所運行的眾多系統中的一個。此時你很有可能已經擁有了:

圖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上下文。

圖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架構。這需要大量的努力,但是必要的時候,一些其他的開源項目可以提供我們實現這些變化的資源。從用戶的角度來看,整個系統隨著每一次發布都在進行不斷的進化。
- 前言(卷一)
- 卷1:第1章 Asterisk
- 卷1:第3章 The Bourne-Again Shell
- 卷1:第5章 CMake
- 卷1:第6章 Eclipse之一
- 卷1:第6章 Eclipse之二
- 卷1:第6章 Eclipse之三
- 卷1:第8章 HDFS——Hadoop分布式文件系統之一
- 卷1:第8章 HDFS——Hadoop分布式文件系統之二
- 卷1:第8章 HDFS——Hadoop分布式文件系統
- 卷1:第12章 Mercurial
- 卷1:第13章 NoSQL生態系統
- 卷1:第14章 Python打包工具
- 卷1:第15章 Riak與Erlang/OTP
- 卷1:第16章 Selenium WebDriver
- 卷1:第18章 SnowFlock
- 卷1:第22章 Violet
- 卷1:第24章 VTK
- 卷1:第25章 韋諾之戰
- 卷2:第1章 可擴展Web架構與分布式系統之一
- 卷2:第1章 可擴展Web架構與分布式系統之二
- 卷2:第2章 Firefox發布工程
- 卷2:第3章 FreeRTOS
- 卷2:第4章 GDB
- 卷2:第5章 Glasgow Haskell編譯器
- 卷2:第6章 Git
- 卷2:第7章 GPSD
- 卷2:第9章 ITK
- 卷2:第11章 matplotlib
- 卷2:第12章 MediaWiki之一
- 卷2:第12章 MediaWiki之二
- 卷2:第13章 Moodle
- 卷2:第14章 NginX
- 卷2:第15章 Open MPI
- 卷2:第18章 Puppet part 1
- 卷2:第18章 Puppet part 2
- 卷2:第19章 PyPy
- 卷2:第20章 SQLAlchemy
- 卷2:第21章 Twisted
- 卷2:第22章 Yesod
- 卷2:第24章 ZeroMQ