蒼時弦也
蒼時弦也
資深軟體工程師
發表於

三天做一個論壇 - Part 1

###前言

上次挑戰三十分鐘完成留言板雖然不幸(?)失敗,不過這次我成功的在三天的限制內完成了(簡易)論壇。

不過,大概也是時間放的比較寬鬆,所以也比較順利在時間內完成。這次跟上次留言板一樣,是使用 PHP + MongoDB 進行開發。

規劃結構


其實和上次大致上沒有什麼變化,不過架構稍微又更加的細分了一些。 螢幕快照 2012\-01\-25 下午10\.25\.44

初始化系統


首先先建立 index.PHP 檔案在根目錄,而其他一些Library檔案就請各位自己複製摟(參考上次的 30 分鐘留言板)

 1<?PHP
 2/**
 3 * 3Day Fourum
 4 * 
 5 * @package 3day-fourm
 6 * @author Aotoki
 7 * @version 1.0
 8 */
 9
10/* 載入設定檔 */
11 
12require_once('config.inc.PHP');
13 
14/**
15 * 載入必要函式庫 
16 */
17
18//Slim Framework 
19require_once(ABSPATH . 'lib/Slim/Slim.PHP');
20 
21//ActiveMongo
22require_once(ABSPATH . 'lib/ActiveMongo/ActiveMongo.PHP');
23
24//Facebook API
25require_once(ABSPATH . 'lib/Facebook/facebook.PHP');
26
27/* 載入起動器 */
28require_once('bootstrap.PHP');
29
30?>

這次的 index.PHP 我們只針對設定檔、函式庫載入,並且呼叫 bootstrap.PHP 這個檔案初始畫整個網站。

接下來,我們來看看 bootstrap.PHP 檔案

 1<?PHP
 2/**
 3 * Bootstrap
 4 * 
 5 * @package 3day-forum
 6 * @author Aotoki
 7 * @version 1.0
 8 */
 9
10/* 修正時區 */
11date_default_timezone_set("Asia/Taipei");
12 
13/* 初始化 Slim Framework */
14$app = new Slim(array(
15	'mode' => 'development',
16	'http.version' => '1.1',
17	'debug' => DEBUG,
18	'templates.path' => ABSPATH . 'vendor/themes',
19	'cookies.secret_key' => COOKIE_SECRET_KEY,
20));
21
22/* 初始化資料庫 */
23if(DB_USER || DB_PASS){
24	ActiveMongo::connect(DB_NAME, DB_HOST, DB_USER, DB_PASS);
25}else{
26	ActiveMongo::connect(DB_NAME, DB_HOST);
27}
28
29/* 載入基本資訊 */
30$basePath = str_replace('/index.PHP', '', $app->request()->getRootUri()) . '/';
31$baseURL = "https://{$_SERVER['HTTP_HOST']}/{$basePath}";
32
33$app->view()->setData('basePath', $basePath);
34$app->view()->setData('baseURL', $baseURL);
35$app->view()->setData('app', $app);
36
37/* 讀取  App 邏輯 */
38require_once(ABSPATH . 'app/Template.PHP');
39
40/* 讀取 App 模型 */
41require_once(ABSPATH . 'app/models/Users.PHP');
42require_once(ABSPATH . 'app/models/Forums.PHP');
43require_once(ABSPATH . 'app/models/Thread.PHP');
44require_once(ABSPATH . 'app/models/Posts.PHP');
45
46/* 讀取 App 介面 */
47require_once(ABSPATH . 'app/web/Post.PHP');
48require_once(ABSPATH . 'app/web/User.PHP');
49require_once(ABSPATH . 'app/web/Home.PHP');
50
51/* 運行 */
52$app->run();

首先,為了避免時間顯示不正確,先將時區設定為 亞洲/台北 (各位可以依照自己的需求設定時區)

接下來,就是初始化 Slim Framework 以及 ActiveMongo 了! 基本上和上一次無異,不過在 Slim Framework 的 Mode 部分,因為弦也忘記定義一個常數,所以各位務必記得在 Deploy 的時候將其改為 production 以免錯誤訊息露出來了!

在 Slim Framework 會統一處理錯誤訊息,所以會有統一錯誤頁面。而 DEBUG + Development 的狀況下,則可以看到詳細的錯誤訊息,但在 Production 下,則只會顯示 Error 以及一段訊息說明發生錯誤了!

不過,在這之外,還是要注意「在非 Slim Framework 作用區外的錯誤還是會被顯示」

接下來,我稍微設定了幾個常用的數值,並且以 $app->view()->setData() 的方式設定,往後所有使用 render() 方法的佈景都可以使用這些預置的變數。

接著,我們依序載入 App, Model, Web App 的部份。

APP 這邊是指原生屬於系統,而非之後以 Plugin 加入的部份。

另外,我們要注意 app/web/Home.PHP 是最後一個,一開始可能不會發現有什麼問題,不過當我們製作 Profile 頁面讓使用者修改 暱稱 時,就會發現 Router 在處理網址時發生了判斷問題。

###建立Model

因為 MongoDB 不需要另外建立資料表,所以我們就安心的直接建立 Model 檔案。

因為是論壇,所以會需要有記錄會員用的Model(Users.PHP)還有討論版(Forums.PHP)以及主題(Thread.php)跟文章(Posts.php)

首先,我們先來看會員的 Model 長怎樣。

 1<?PHP
 2/**
 3 * User Model
 4 * 
 5 * @package 3day-forum
 6 * @author Aotoki
 7 * @version 1.0
 8 */
 9
10class Users extends ActiveMongo
11{
12	//資料表欄位
13	public $userID; //使用者編號(Facebook ID)
14	public $Nickname; //使用者膩稱
15	public $Type; //使用者類型(1 = Admin, 0 = User)
16	
17	/**
18	 * Get User
19	 * 
20	 * @author Aotoki
21	 * @return object|bool 成功傳回 User 物件,失敗則傳回 FALSE
22	 */
23	
24	static public function getUser( $fromUserID = NULL )
25	{
26		$userID = $fromUserID;
27		if(!$userID){
28			$FB = new Facebook(array(
29				'appId' => FB_APP_ID,
30				'secret' => FB_SECRET,
31			));
32			
33			$userID = $FB->getUser();
34		}
35		
36		if(!$userID){
37			$app = Slim::getInstance();
38			$app->redirect($FB->getLoginUrl());
39		}else{
40			$user = new Users;
41			$user->findOne(array('userID' => $userID));
42			if(!$user->valid()){
43				$user->userID = $userID;
44				$user->save();
45			}
46			
47			if(!$fromUserID){
48				$app = Slim::getInstance();
49				$app->view()->setData('user', $user);
50			}
51			return $user;
52		}	
53	}
54}

欄位很簡單,只有 會員編號、暱稱、類型 三個。而類型部分,因為並沒有安裝論壇的部份,需要手動操作資料庫去設定會員類型,該如何初始化,以及如何處理,就交給各位發揮創意摟!某人超懶所以就變成這樣

接下來,會看到一個 static 的方法叫做 getUser() 這是用於取得使用者的方法。

為什麼要用 static 方法呢?因為弦也認為這些方法都是直接產生一個實例傳回,而非改動物件設定值後一併傳回,所以決定以 static 的方法來做處理。

這個 getUser 的方法也非常簡單,如果有指定 userID 那麼就跳過 Facebook 登入並且繼續執行,反之則進行 Facebook 登入,取得 Facebook 的使用者編號,並且查詢系統內使用者,如果無使用者,則新建一個。

最後,再將使用者物件傳回。

接下來是 Forums.PHP 這個檔案,我想是全部 Model 中最為複雜的部份,也是整個論壇最複雜的檔案。

  1<?PHP
  2/**
  3 * Fourums
  4 * 
  5 * @package 3day-forum
  6 * @author Aotoki
  7 * @version 1.0
  8 */
  9
 10class Forums extends ActiveMongo
 11{
 12	//資料表欄位
 13	public $Name; //論壇名稱
 14	public $Parent; //父論壇
 15	
 16	/**
 17	 * Get Forum
 18	 * 
 19	 * @author Aotoki
 20	 * @param string 論壇ID
 21	 * @return object|bool
 22	 */
 23	
 24	static public function getForum( $ID, $forumArgs = array() )
 25	{
 26		$forum = new Forums;
 27		$forum->findOne(new MongoId($ID));
 28		
 29		array_push($forumArgs, $forum);
 30		if(isset($forum->Parent)){
 31			$forumArgs = self::getForum($forum->Parent, $forumArgs);
 32		}
 33		sort($forumArgs, SORT_DESC);
 34		return $forumArgs;
 35	}
 36	
 37	/**
 38	 * Get Forums
 39	 * 
 40	 * @author Aotoki
 41	 * @param string 父論壇ID
 42	 * @return object|bool 成功傳回 Forum 物件,失敗傳回 FALSE
 43	 */
 44	
 45	static public function getForums( $parentID = NULL)
 46	{
 47		$forums = new Forums;
 48		$forums->find(array('Parent' => $parentID));
 49		
 50		$result = array();
 51		
 52		foreach ($forums as $ID => $forum) {
 53			$lastPost = new Thread;
 54			$lastPost->sort('timestamp DESC');
 55			$lastPost->where('forumID',(string) $forum->getID());
 56			$lastPost->limit(1);
 57			
 58			if(!$lastPost->valid()){
 59				$lastPost = array();
 60			}else{
 61				$lastPost = $lastPost->getArray();
 62			}
 63			
 64			$result[] = array(
 65				'forum' => $forum->getArray(),
 66				'lastPost' => $lastPost,
 67			);
 68			
 69			unset($lastPost);
 70		}
 71		
 72		return $result;
 73	}
 74	
 75	/**
 76	 * Create Forum
 77	 * 
 78	 * @author Aotoki
 79	 * @param string 論壇名稱
 80	 */
 81	
 82	static public function createForum($Name, $Parent = NULL)
 83	{
 84		$forum = new Forums;
 85		$forum->Name = $Name;
 86		if($Parent){
 87			$forum->Parent = $Parent;
 88		}
 89		$forum->save();
 90		unset($forum);
 91	}
 92	
 93	/**
 94	 * Delete Forum
 95	 * 
 96	 * @author Aotoki
 97	 * @param string 論壇ID
 98	 */
 99	
100	static public function deleteForum($forumID)
101	{
102		
103		$parentID = NULL;
104		
105		$forum = new Forums;
106		$forum->findOne(new MongoId($forumID));
107		if(isset($forum->Parent)){
108			$parentID = $forum->Parent;
109		}
110		$forum->delete();
111		
112		$subForums = self::getForums($forumID);
113		foreach($subForums as $ID => $forum){
114			self::deleteForum($ID);
115		}
116		
117		$topics = new Thread;
118		$topics->find(array('forumID' => $forumID));
119		foreach($topics as $ID => $topic){
120			Thread::deleteTopic($ID);
121		}
122		
123		return $parentID;
124	}
125	
126}

欄位非常簡單,就只有 Name 以及 Parent 兩個值。代表的意義就是 討論版 的名字,以及其父討論版的 ID (如果沒有父討論版則是 NULL)

接下來,就是本次最複雜的部分,論壇的各個方法。選用 static 的理由已經說明了,因此先從 getForum() 開始介紹起。

####getForum 從原始碼可以得知,這是一個遞迴函式,每當所在論壇層級越低,遞迴次數就會越多(不斷的追溯父論壇)

首先,我們要先找出目前論壇,與 MySQL 這類關聯式資料庫不同,在 MongoDB 下沒有可以自動遞增的欄位屬性,所以我們只好借用每個物件都會存在的 _id 欄位,來當做識別標準。

在 MongoDB 內儲存的是名為 ObjectId 物件的格式,無法直接以字串方式查詢,但是 ActiveMongo 也沒有自動轉換的方式,所以我們使用 Mongo 的 PHP Driver 內建的 MongoId 物件來轉換(不過直接輸出他,是會自動轉換回字串格式的)因為操作中有兩種查詢方式(字串跟物件)希望大家不會搞混。

找到論壇後,則塞入 $forumArgs 變數,並且檢查是否有父論壇,如果有,那麼就繼續遞迴,反之則傳回整理好的 $forumArgs 函式。

傳回前做 sort(排序) 處理的原因主要是因為 FIFO (First Input First Output) 會讓原本是最低層級的論壇出現在第一個,違反常理,應該是要 Parent > Child 才會正確,所以才這樣處理。

說實在的,把這個函式叫做 getForumTree() 搞不好會比較貼切。

####getForums 接著,是 getForums 這個方法。邏輯上就比起前面的還簡單多了!

假設沒有 Parent 的傳入,那麼就單純查詢論壇(無 Parent 的論壇,也就是最頂層的論壇)假設有,則查詢 Parent 與之相符的論壇。

這邊的 Parent 因為在儲存時已經以 string(字串) 方式儲存,所以不需要用 MongoId 物件來轉換成 ObjectId

實際上,其實只要這樣就足夠了!不過我們還希望得知這個討論版最後一次有新文章是什麼時候,所以決定對 Thread 查詢。

後面會提到 Thread 這個 Model 我們用來儲存每篇主題與討論版的關聯性,以及這篇主題是什麼時候被建立的。

因為不單純只有一個討論版,所以放入迴圈,依序取出每個討論版後,在做查詢。首先,我們先用 sort 這個方法指定依照時間排序,接著用 where 方法找出在該討論版的主題,最後用 limit 限制只傳回一筆資料。

扣除 sort 方法,其實可以直接用 findOne() 方法,但是我們需要排序,所以改用這樣的方式查詢(也許我們可以用關聯式的查詢,不過弦也對這部份操作還不清楚,所以土法煉鋼一下~)

接著,我們將其放入 $result 陣列中,傳回。

$result[] = array() 是讓陣列自動產生 Key 和 array_psuh() 類似,而為什麼要對 $forum 進行 getArray() 指令產出陣列呢?這是因為弦也開發時發現如果直接傳入物件,取出時會變回空的 Forums 物件而非一個指定某個討論版的物件,為了保持資料,所以就這樣做處理。

####createForum 這個方法相較之下,就筆其他簡單許多。

僅是單純的建立一個 Forums 物件,並且將 Name 以及 Parent 存入而已。最後的 unset() 動作是釋放記憶體,雖然函式呼叫完應該也會自動釋放,不過還是手動釋放避免該擾吧!

其實 getForums 的迴圈釋放 $lastPost 用意也一樣,但是這個動作可以確保下次迴圈運行時不會不小心用到上一筆資料。

####deleteForum 終於,我們到了最後一個方法,這個方法可以視為 getForum 跟 getForums 混合後的修改版。

首先找出應該刪除的討論版,並且加以刪除(順便記錄其父討論版) 接著找出子討論版,並且刪除(使用遞迴方式,確保子討論版下的子討論版也都會被刪除)

接著刪除討論版下的所有主題(這邊使用 Thread 的 deleteTopic 方法,是為了一併刪除相關的文章) 最後傳回父論壇的 ID 以方便重新定向時可以轉跳到其父討論版,而不會跳回首頁。

###總結

我想一次吸收這麼多資訊應該很難消化,所以先在這邊做一個段落。 (不是因為我想偷懶喔,因為之後的 Thread Model 也有不少於 Forums Model 的方法要解釋)

下一篇文章除了將剩下的 Model 解釋完之外,還會繼續解說其餘的 Web App 部分。