透视WebMVC

转自:http://www.jaceju.net/blog/archives/167/

以往我所开发的Web 专案,大部份都是把核心放在操作HTML ;就算后来使用了Smarty ,却还是迷失在视觉为重的设计观点里,使得后续开发与维护都变得非常麻烦。后来我自己归纳出问题发生的原因,绝大部份在于我接触的专案常常是「画面先行」。

「画面先行」是由视觉设计人员来主导专案的架构,而这个架构则通常是因应客户在网站流程上的要求而建立的;这也使得我在开发程式时就必须地采用他们决定好的页面来套用程式,最后的结果就是导致重复的业务逻辑遍布在整个专案里。

透视WebMVC_第1张图片以Smarty 为核心

不过就算使用了Smarty ,这种问题也还是没有得到更进一步的解决。原因就在于我只是把PHP 程式和HTML 页面拆开,而实际上同样的业务逻辑却没有能够包装起来重复使用。直到我接触了物件导向的观念后,我才惊觉这种设计真的是不堪一击。

于是我很认真地使用物件导向来封装我的业务逻辑,并天真地以为让它们能够reuse 后,程式的复杂度就会降低;但是逐渐地我发现到,我花在修改这些物件的时间竟然变得比以前长,我必须不断地为它加入不同的判断条件,好让它应付所有可能发生的变化。可是这不是我所希望发生的结果呀!到底为什么会变成这样呢?

我发现很多时候我让物件在发生错误时,自行丢出一个讯息给浏览器,或是透过Smarty 将这些讯息包装成画面。但是过了几个月之后的改版,这个物件的重用性变得越来越差,而且也变得越来越大。另外它跟Smarty 的耦合性也太高,毕竟有时候我根本不想在这个时候使用Smarty 丢出画面,而是想让上一层程式自己去决定!

后来我归纳出这一切问题的原因,都是我错把Smarty 当成主角- 也就是网站架构的核心!

刚接触Smarty 时,我同时也耳闻了MVC 这个设计模式。不过那时刚刚接触物件导向,也还不了解什么是设计模式;只知道使用Smarty 的前辈们倡导大型的应用项目都应该朝向MVC 的架构去设计。虽然我那时仍不清楚MVC 的核心概念,然而这个开发方式却带给我一个很大的思考空间:为什么要把一个应用程式分成三个部份?而在Web 上这种开发方式也行得通吗?在参考过一些书籍,这些问题仍似有若无的存在我的心中,所以我的程式架构依然没有什么改进。

直到在写「PHP Smarty样版引擎」这本书时,我也刚好读到了「深入浅出设计模式」这本好书;而该书对MVC模式的介绍,正好解开我长年以来的疑惑。

原来真正的MVC 是将「资料处理逻辑」、「程式流程」与「资料呈现」三者分离,而Smarty 只是扮演了「资料呈现」的角色而已!

不过懂是懂了,但要如何将这个概念融入Web开发中,这点让我觉得很头痛。虽然发现很早就有人把MVC带进PHP里了,只可惜用的人不多,文件也比较少。所以在我的书中的MVC架构用得非常简陋,只能说是WebMVC的雏形而已。而这时Ruby on Rails刚好在网路界萌芽,号称将MVC带入了Web开发中;并且首开先例,用影片来示范一个Blog系统的诞生,所以我也不可免俗地去抓回来玩看看(我想很多写Java的人大概就是这点所吸引) 。可惜我一看到Ruby那个奇怪的语法,就觉得看不下去了,所以只是照本宣科地把范例作一作,最后不了了之(后来想一想真是可惜) 。

直到又过了好一阵子,在CakePHP 、 Code Igniter 等号称PHP 界的Rails 开发框架出现后,我便兴致勃勃地去下载回来研究。而参考了它们的范例后,这时我才猛然醒悟!原来Web 上的MVC 架构是长成这个样子!后来为了专案需要,更把LifeType 的程式码追了一遍,这才发现WebMVC 竟是如此弹性而富有变化!

由于深刻体认到物件导向所带给我的冲击,我不得不承认以往我所向往的开发方式仍然有很大的改进空间。所以我想就从架构的改进开始,让自己重头去认识新的Web 开发方式。

传统的程式架构

以往我所见到的程式架构,大多都是一个功能一个页面。就拿留言板来说好了,以前的我大概会这样规划:

  • index.php (留言列表页)

  • add.php (新增留言表单)

  • do_add.php (处理新增留言)

  • rss.php (假设这个留言板有提供RSS 服务)

以上的规划方式很明显地是一个功能一支程式,这在小项目里面很常见。为了探讨这种架构的优缺点,以下我便采用这种方式来实作一个传统的留言板。

注:当然也可以将它们全部放在同一支程式中处理掉,不过我还是暂时不要变得那么「聪明」。

程式码

先以index.php 来说,以下是一个简单的实作:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
28 
29 
30
//载入设定
 require_once 'config.php';
 //从档案中取得所有留言
 $messages = (file_exists(APP_STORAGE))
           ? unserialize(file_get_contents(APP_STORAGE))
           : array ();
 //输出HTML
 header ('Content-Type: text/html; charset=utf-8');
 ?>"http://www.w3.org /TR/xhtml1/DTD/xhtml1-transitional.dtd">
 
 
 .... (略) ....
 
$message): ?>

< ?php echo $message['title']; ?>

By

没有任何文章

.... (略) ....

透视WebMVC_第2张图片PHP with HTML

主要在这里我是利用文字档来存放留言内容,然后利用程式取得留言后再将它们输到到HTML 。这样的写法把留言版的处理逻辑和HTML 混在一起,是PHP 常见的开发方式。

而add.php 纯粹是表单页,这里我把它略过。接下来是我在do_add.php 上的实作:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22
//载入设定
 require_once 'config.php';
 //取得表单变数
 $title = $_POST['title'];
 $author = $_POST['author'];
 $body = $_POST['body '];
 //从档案中取得所有留言
 $messages = (file_exists(APP_STORAGE))
           ? unserialize(file_get_contents(APP_STORAGE))
           : array ();
 //在所有留言的最后加上一笔
 $messages[] = array (
     'id' => count($messages) + 1,
     'title' => $title,
     'author' => $author,
     'body' => $body,
 );
 //将所有留言写回档案
 file_put_contents(APP_STORAGE , serialize($messages));
 //导回列表页
 header('Location: ./');

看起来do_add.php 做的事多了一点,不过我想还不至于太困难。这里的do_add.php 没有介面可让使用者操作,像这样的程式通常是为了处理表单变数而存在的。

注:以上用档案存取留言的方式是为了简单起见,所以写得非常偷懒,我想不值得学习。

最后来看看rss.php 的实作:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26
//载入设定
 require_once 'config.php';
 //从档案中取得所有留言
 $messages = (file_exists(APP_STORAGE))
           ? unserialize(file_get_contents(APP_STORAGE))
           : array ();
 //组合XML
 $ xml = '';
 $xml .= '<' . '?xml version="1.0" encoding="utf-8"?' . '>' . "\n";
 $xml .= '' . "\n";
 $xml .= '' . "\n";
 $xml .= 'TEST' . "\n";
 $xml .= ' TEST' . "\n";
 foreach ($messages as $message)
 {
     $xml .= '' . "\n";
     $xml .= '' . $ message['title'] . '' . "\n";
     $xml .= '' . $message['body'] . '' . "\n";
     $ xml .= '' . "\n";
 }
 $xml .= '' . "\n";
 $xml .= '';
 //输出XML
 header(' Content-Type: text/xml; charset=utf-8');
 echo $xml;

这里我遵循最简单的规范,把留言内容输出成可供读取的RSS 格式。

上面的程式其实花不到我几分钟,倒是CSS 的部份用的时间长了点(大概半小时) ,这也说明PHP 在小型应用程式上的开发效率是很高的(也许是因为我用了偷懒的方式:P ) 。然而我扪心自问:如果今天沿用这样的架构来开发大型专案的话,那么对团队合作会有什么样的影响呢?

面临的问题

我先简单把上面几支程式拆解开来分析,便发现以下的通则:

  1. 如果需要的话则取得表单变数(do_add.php) 。

  2. 存取资料并透过相关逻辑处理(index.php 、 rss.php 、 do_add.php) 。

  3. 输出资讯给浏览器或其他外部程式(index.php 、 rss.php 、 add.php) 。

注:虽然do_add.php 的header('Location') 指令也是会传送HTTP 的Header 资讯给浏览器,但是我把它视为流程控制而非资料呈现。

我手边在维护的大部份PHP 程式都是以类似的流程所写成的,当然这样写是非常直觉的。但是就拿上面留言板的例子来说好了,我就发现这样的架构会带来以下几个主要的缺点:

  1. 如果我想将文字档换成正式的资料库系统时,我得同时更改三支程式档。

  2. 如果在多人开发环境下,程式开发没办法和视觉设计并行;如果页面风格要更改的话可能会更惨。

  3. 重复的程式码太多(例如读取资料部份) ,一旦发现错误,必须把所有相关的程式找出来一个一个修改。

虽然在留言板这种小项目中,以上的问题并不会影响太大;可是我所维护或开发的应用程式通常都会包含数十支甚至上百支的页面,这时候上面的问题就会随着页面数量而呈现等比级数的成长。

我曾在「PHP Smarty样版引擎」提过,网站开发上绝对不是只有一个人的事情;除了专案企划人员的参与外,程式开发人员及视觉设计人员之间的协调更是重要。如果一个架构不能同时让这两方人马有良好的互动,那么这个架构就不值得团队采用。

当然我可以在这里导入样版引擎(例如Sm​​arty ) ,然而这样依旧无法有效解决后续维护上的问题;而且我先前也过于依赖以样版引擎为主干的开发方式,而没有认真去思考如何把相似的部份整合在一起。因此若是要一次解决以上的问题,我便得好好思考如何真正有效去切割并重组这样的程式架构。

转换思考模式

在前面的程式里面,除了PHP 和HTML 看起来是还算有明显的区隔,其他部份其实还满不容易看出它们之间的关系。在遇到这样的问题时,我个人非常喜欢使用图形化的思考模式,这会有助于对程式整体架构的分析,所以我便把以上四支程式所做的事情图形化。在图形化之后,我发现每支程式中都有相关的部份;因此我就把相关的功能对齐,如下图所示:

透视WebMVC_第3张图片传统架构

现在整个程式架构相当清楚了,像是读取留言或新增留言都是留言板的核心功能,已经对齐在同一个水平上;而输出资讯的部份也是一样,不论输出格式是HTML 还是RSS ,都可以一视同仁。然而比较特别的是,我决定把页面流程也看成是相类似的功能集合,换句话说就是浅蓝色的部份也算是相关的。

接着我把这些重复或相似的部份沿着虚线将它们切割开来并重新群组在一起:

透视WebMVC_第4张图片转换成MVC

现在我有留言板的核心部份(也就是取得所有留言以及新增留言) 、两种输出的形态(HTML 与RSS ) 以及相关的页面动作(index 、 add 、 do_add 、 rss) 三个群组,看起来好像很棒,但是有什么用呢?

回想一下前面的问题,首先我遇到了资料库会改变这个事实,不过看来我只需要把Guestbook 这个群组的实作方式抽换掉就可以解决。当然抽换的过程中也不会影响到其他两个群组的运作,因为我已经将留言板的核心部份排除在页面之外了。换句话说,不论我的留言讯息是以档案存放或是使用资料库存放,只要Guestbook 群组提供相同的操作介面,都能让其他程式要更动的部份减至最少。

而第二个问题我可以在Output 群组中找到解答,因为这里我只需要导入样版的概念,那么HTML 或是RSS 都可以独立在程式之外。而且HTML 的部份我可以搭配Smarty 或其他样版引擎,也可以使用单纯的PHP 套版;而RSS 的部份除了上面直接使用字串来输出的方式外,我也可以改用其他方便的第三方类别库来协助产生。这样一来不仅解决协同开发的问题,还能够让专案选用适当的方法来产生输出。

最后我再把页面的流程放在Actions 群组里,也就是说我可以把重复的部份提炼出来放在一起。而这样的调整就能使我在增加新功能或是修正某些错误时,能够避免相关的程式码重复;而且也能将相关功能放在同一个群组下以便管理。

程式码

上面的理论虽然感觉起来很棒,但是还缺少实务的支持,所以我必须把这些群组变成程式码。不过在实务上,「群组」并不是真正能够实作的东西,所以我想这时就应该引进物件导向的观念了。因为前面我把相类似的功能放在一起,所以它们就能形成一个物件导向中的类别。换句话说Guestbook 是一个类别,而Output 和Actions 也是一样,所以在新的架构里我就可以使用类别来实作程式。然而事实上我只是先把旧的程式码按照上面的逻辑重新组合在类别代码里,并求能正常执行。

注:事实上并非将相似功能放在一起就能够转换成类别,我个人只是为了简化思考,才会采用这样的概念。

首先是Guestbook.php ,它的类别代码如下:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
28 
29 
30 
31 
32 
33 
34 
35 
36 
37 
38
//留言版类别
 class Guestbook
 {
     //留言
     private $messages;
     //建构函式
     //预先从档案中取得所有留言
     public function __construct()
     {
         $this->messages = (file_exists(APP_STORAGE))
                         ? unserialize(file_get_contents(APP_STORAGE))
                         : array ();
     }
     //取得所有留言
     public function getAllMessages()
     {
         return $this->messages;
     }
     
    //新增留言
     //在所有留言的最后加上一笔后写回档案
     public function addMessage($title, $author, $body)
     {
         $this->messages[] = array (
             'id' => count($this->messages) + 1,
             'title' => $title,
             'author' => $author,
             'body' => $body,
         );
         file_put_contents(APP_STORAGE, serialize($this->messages));
     }
     //解构函式
     public function __destruct()
     {
         $this->messages = NULL;
     }
 }

在Guestbook 类别里,我把读取留言(getAllMessages) 和新增留言(addMessage) 的程式变成类别方法,在实作上还是采用原来档案存取的方式。在这里getAllMessages 和addMessage 两个方法,就是供外部呼叫用的统一介面。如果之后如果我需要更换成资料库,那么就可以只更改这些方法的实作即可。

接下来是Output.php ,它的类别代码如下:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
28 
29 
30 
31 
32 
33 
34 
35 
36 
37 
38 
39 
40 
41 
42 
43 
44 
45 
46 
47 
48 
49 
50 
51 
52 
53 
54 
55 
56 
57 
58 
59 
60 
61 
62 
63 
64 
65 
66 
67 
68 
69 
70 
71 
72 
73 
74 
75 
76 
77 
78 
79 
80 
81 
82 
83 
84 
85 
86 
87 
88 
89 
90 
91 
92 
93 
94
//输出类别
 class Output
 {
     //输出类型
     private $type;
     //样版变数
   private $vars = array ();
     //建构函式
     public function __construct($type)
     {
         $this->type = in_array($type, array('HTML', 'RSS'))
                     ? $type
                     : 'HTML';
     }
     //设定样版变数
     //这里不用__set是因为我不想在PHP程式里直接对物件指定属性
     public function setVar($tpl_var, $value = null)
     {
         if (is_array($tpl_var))
         {
             foreach ($tpl_var as $key => $val)
             {
                 if ($key != '')
                 {
                     $this->vars [$key] = $val;
                 }
             }
         }
         else
         {
             if ($tpl_var != '')
             {
                 $this->vars[$tpl_var] = $value;
             }
         }
     }
     //自动取得对应的样版变数
     //在样版里直接取得变数会比较方便
   private function __get($name)
   {
       return isset($this->vars[$name]) ? $this->vars[$name] : NULL;
   }
     //依照类型输出结果
     //输出前先用header函式让浏览器得知正确的输出型态与编码
     public function render($template_file = '')
     {
         if ('HTML' == $this->type)
         {
             //输出HTML
             header('Content-Type: text/html; charset=utf-8');
             echo $this->fetchHTML('templates/' . $template_file);
         }
         else
         {
             //输出RSS
             header('Content-Type: text/xml; charset=utf-8');
             echo $this->fetchRSS();
         }
     }
     //撷取HTML结果
     //透过Output Buffer来撷取结果,这样方便视觉套版
     private function fetchHTML($template_file )
     {
         $html = '';
         ob_start();
         include $template_file;
         $html = ob_get_contents();
         ob_end_clean();
         return $html;
     }
     
    //撷取RSS结果
     //因为RSS格式固定,所以直接使用字串串接
     private function fetchRSS()
     {
         $xml = '';
         $xml .= '<' . '?xml version="1.0" encoding="utf-8"?' . '>' . "\n";
         $ xml .= '';
         $xml .= '';
         $xml .= 'TEST';
         $xml .= 'TEST';
         foreach ($this->items as $item)
         {
             $xml .= '';
             $xml .= '' . $item['title'] . '' ;
             $xml .= '' . $item['body'] . '';
             $xml .= '';
         }
         $xml .= '';
         $ xml .= '';
         return $xml;
     }
 }

我让Output 负责HTML 和RSS 两种型态的输出,并在建立物件时就决定输出的型态。在Output 类别里的HTML 输出部份采用了样版的概念,也就是取得HTML 样版并代入相关的变数,最后才取得代入后的结果。而HTML 样版自己会决定要输出什么,我只需要呼叫正确的样版即可。不过值得注意的是HTML 样版的样版变数,因为我在Output 类别里使用了setVar() 方法来指定样版变数,而以__get() 这个魔术方法来取得变数内容;因此以列表页的HTML 样版(index.tpl.htm) 为例,原来的$messages 就会变成Output 物件的属性,因此要将$messages 改写为$this->messages :

1 
2 
3 
4 
5 
6
messages)): ?>
 messages as $id => $message): ?>
 

< ?php echo $message['title']; ?>

By

而RSS 输出的部份,我则是把原来的程式用类别方法包装起来,当然$items 也要改成$this->items 。

以上的动作会由fetchHTML 、 fetchRSS 及render 三个方法来帮我完成;我只要在建立物件时指定好要输出的型态, render 函式就会自行决定要呼叫fetchHTML 还是fetchRSS 。

然后是Actions.php ,它的类别代码如下:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
28 
29 
30 
31 
32 
33 
34 
35 
36 
37 
38 
39 
40 
41 
42 
43 
44 
45 
46 
47 
48 
49 
50 
51 
52 
53 
54 
55 
56 
57 
58 
59 
60 
61 
62 
63 
64 
65 
66 
67 
68
//动作类别
 class Actions
 {
     //共用的留言版物件
     private $guestbook = NULL;
     //使用者选择的动作
     private $action = 'index';
     //建构函式
     //初始化要执行的动作以及留言板物件
     public function __construct()
     {
         $this->action = isset($_GET['act'])
                       ? strtolower($_GET['act'])
                       : 'index';
         $this->guestbook = new GuestBook ;
     }
     //执行选择的动作
     public final function run()
     {
         $this->{$this->action}();
     }
     //重新导向
     //借用header函式来导向指定的网址
     private function redirectTo($ url)
     {
         header('Location: ' . $url);
     }
     //预设的列表功能
     //等同于原来的index.php
     private function index()
     {
         $output = new Output('HTML');
         $output ->setVar('messages', $this->guestbook->getAllMessages());
         $output->render('index.tpl.php');
     }
     //新增表单页
     //等同于原来的add. php
     private function add()
     {
         $output = new Output('HTML');
         $output->setVar('action', 'doAdd');
         $output->render('edit.tpl.php');
     }
     / /新增留言
     //等同于原来的do_add.php
     private function doAdd()
     {
         $title = $_POST['title'];
         $author = $_POST['author'];
         $body = $_POST['body' ];
         $this->guestbook->addMessage($title, $author, $body);
         $this->redirectTo('./');
     }
     //输出RSS
     //等同于原来的rss.php
     private function rss ()
     {
         $output = new Output('RSS');
         $output->setVar('items', $this->guestbook->getAllMessages());
         $output->render();
     }
     //解构函式
     public function __destruct()
     {
         $this->guestbook = NULL;
     }
 }

在Actions 类别里我把原来的页面全部改成方法,每个方法我称它为一个Action 。在建构程式里会解析$_GET['act'] 变数所拥有的内容当做Action 的名称,这样外部程式会就可以透过网址参数act 来决定要呼叫哪一个Action 。当然如果没有指定Action 名称的话,那么预设就是index (列表页) 。

每个Action 中会透过呼叫物件的方法(也可说成传递讯息) ,来达成原来页面里的流程动作。换句话说Actions 类别中的每个方法会去指导Guestbook 该怎么动作,例如取得所有留言或是新增留言;而如果需要输出的话,就会透过Output 物件的setVar 来拉取Guestbook 里的资料,以产生适当格式的输出。

比较特别的是在Actions 类别里的run 这个方法,我会透过它来执行act 网址参数所指定的Action 。换句话说, run 方法是唯一让外界操作Actions 物件的介面。

不过要能够正常运作这些流程的话,我想我还需要一个进入点(Bootstrap) ,负责建立一个Actions 物件来开始执行程式;当然这个工作就要交给index.php ,程式码改写如下:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11
//载入设定
 require_once 'config.php';
 //自动载入类别
 function __autoload($class_name)
 {
     require_once $class_name . '.php';
 }
 //执行对应的动作
 $actions = new Actions;
 $actions->run();

我发现改用新的架构后,不但index.php 的程式变得简单俐落许多,而且透过PHP5 自动载入类别的机制,我也不需要在一开始就载入所有的类别档案。

执行流程

透视WebMVC_第5张图片执行

光看上面程式的话,其实还满不容易懂它的执行方式,我还是一步步来分析这个架构的流程好了。

以列表页的流程为例,它的步骤如下:

  • 首先从index.php 进入程式, index.php 会建立一个Actions 物件。

  • 在建立Actions 物件的同时,因为没有act 这个网址参数,所以Actions 物件的action 属性会预设为index ;此时Actions 也会同时在它的内部建立一个Guestbook 物件。

  • Guestbook 物件建立的同时,会预先从档案中取得所有留言。

  • 回到index.php ,这时会执行Actions 物件的run 方法;在run 方法里会执行action 属性所指向的index 方法。

  • 在Actions 物件的index 方法里,会建立一个HTML 型态的Output 物件,并从刚刚​​建立的Guestbook 物件里抓出留言,再塞给Output 物件。最后用Output 物件的render 方法来显示样版。

  • Output 物件会依照刚刚给的留言内容,一一代换样版里的样版变数,最后呈现结果给浏览器。

当然同样的分析方法,也可以用在新增留言这个动作上面。虽然这样的流程看起来有点啰嗦,不过我想这就是物件交互的方式,这大概也是初学者很容易搞混的地方。

依照以上的架构,如果我想加入新动作的话,就会变得很直觉。而且我不用把相似的逻辑散放在各个页面里,而是独立在每个层级里。每个层级可以交由不同的成员去设计或开发,甚至可以加入第三方的函式库,一切看起来是这么的美好!

还是有缺点

虽然我觉得上面的架构已经很不错了,但是从我以前的经验来看,这样的实作方式还是有些许的缺点。

不适合初学者

「不知道要从哪里开始看懂程式码!」这是我刚接触到新架构的感想。其实我在一开始并没有透过上面的分析来推导出这个架构,而是用蛮干的方式加上似懂非懂的理论背景去死读别人的框架原始码。我身边有些开发者大多也是遇到了同样的问题,以致每个人所理解出来的架构都有些许不同。

另外因为新的架构是建构在物件导向的基础上,所以我如果没有物件导向的相关知识或经验的话,也很难想像这样的架构会如何执行。时下很多框架也是如此,如果初学者一开始没有正确的观念,直接照本宣科地把范例做完,可能还是会难以理解框架采用这种架构的好处。

注:架构与框架在这里我认为是不一样的概念;架构是指理论上程式交互的方式,并定义出明确而有层次的规范;而框架则是带有实作,且可以重复利用的程式架构。

没有静态页

传统架构的新增留言表单页原本只要是一个静态页面,但是现在改用样版技术而造成了些许的效能耗损。然而这点我个人认为是有利有弊,因为有时候我会需要统一化的版型,也就是所谓的Master Page 。利用样版技术可以达到这个目标,也是静态页所无法提供的。因此用一点点的效能来换取较有弹性的架构,我个人认为是值得的。

耦合度过高

从HTML 样版和Output 类别的fetchRSS 实作中可以看得出来,在输出留言的部份是以阵列元素的方式去获取留言里的内容。这表示其实Output 和Guestbook 还是有密切的关系,也就是所谓的高耦合;高耦合表示两个类别无法独立使用,这在物件导向原则里是需要尽可能避免的。

不过事实上这个问题也必须考量到专案的大小,像留言版这种等级的应用项目,目前这样的耦合程度还可以令人接受。

网址的易读性

改用新架构后,现在入口只会剩下index.php ;不过这样一来,原来的网址就得跟着改变。换句话说,原来如下的网址:

1 
2 
3 
4
http://localhost/webmvc/index.php
 http://localhost/webmvc/add.php
 http://localhost/webmvc/do_add.php
 http://localhost/webmvc/rss.php

就要改为:

1 
2 
3 
4
http://localhost/webmvc/index.php (不变)
 http://localhost/webmvc/index.php?act=add
 http://localhost/webmvc/index.php?act=doAdd
 http://localhost /webmvc/index.php?act=rss

有一些文章指出带有参数的网址不利人类记忆,而且在SEO (搜寻引擎最佳化) 上也会有不良的影响。当然我想如果要改善开发效率的话,就势必要有一点牺牲;不过还好这方面的困扰还能透过网址重写技术(URL Rewriting) 来弥补,因此不算是太严重的问题。

新的专案

采用新架构之后的留言版,我想应该已经比原来的架构更具有弹性,不过如果今天我有多个专案都想使用同样的架构时该怎么办呢?难道里面的程式码我还得再重写一次吗?或是复制现在这个程式然后做修改呢?

我想答案应该是否定的,因为所谓的框架不是把原来的已经稳定的功能变成另一个专案的垃圾。在我的经验里除了同性质的活动只需要更改样版以外,鲜少有两个专案的功能是会长得一模一样;换句话说,如果只是用复制并修改现有程式的方式来开发,那么可能就有碰到程式炸弹的危险性。减少甚至去除原有客制化的功能,保留基本所需的要素,我想这样的框架才能够拥有最大的弹性。

建立基本MVC 框架

从上面改良过的留言版来看,似乎有些功能是可以让往后的专案重复使用的;但是这些重复的部份,依旧参杂在每个独立的类别里。所以我决定把这些功能抽离出来,独立成一个简易的框架;但在此之前,我想我有必要先为自己厘清前面架构在MVC 上的关系。

在「深入浅出设计模式」里,对MVC 三个角色的描述如下:

  • Model -持有资料、状态、程式逻辑,并提供介面供人取得资料与状态。

  • View -用来呈现Model中的资料与状态。

  • Controller -取得使用者的输入后,并解读此输入以转换成Model对应的动作。

很清楚地,我发现前面的架构已经呈现出MVC 的基本雏形了,也就是Guestbook 、 Output 及Actions 其实可以一一对应到Model 、 View 和Controller 。

透视WebMVC_第6张图片转换成MVC

为什么Guestbook 是位于Model 的层级呢?因为我想Guestbook 这个类别主要是在操作留言讯息的存取,换句话说它掌控了资料与状态。而且getAllMessages 和addMessage 就是Guestbook 类别对外的介面,所以它就非常符合Model 的描述。

而在View 的层级来说,仔细研究桌面应用软体的MVC 实作就可以发现,大部份程式是利用视觉元件来组合出让使用者操作的画面。在Output 类别里虽然我是使用Template 的作法,不过这很适合Web 平台的设计与实作。因为要能够完全以物件来建构画面,对HTML 来说是很不方便也非常不直觉;而且如果要能够快速套用视觉设计人员所制作出来的范本,样版引擎就会是一个比较好的解决方案。

对应到Controller 层级的Actions 类别,所谓的使用者输入就可以看成是act 这个网址参数所代表的动作名称。在Actions 物件里会自动转换这个参数,以呼叫正确的动作来执行。而Controller 也必须明确地指示Model 与View 两者间的互动方式,而在Actions 类别里的确也是这么处理的。

知道了这层关系之后,我就可以想想哪些部份是可以提炼出来反覆利用的。

Model

从Guestbook 的程式来看,事实上我可以完全把档案操作从里面独立出来,然后只定义Guestbook 的抽象化操作方式。这样一来我可以直接以参数定义我想用的储存方式,让储存用的类别也变成共用的工具之一。

可是在这里我决定不这么做。

为什么呢?虽然我很明白抽象化的重要性,不过也不能任何事物件做抽象化,因为这样会陷入「过度设计」的泥淖中。在Guestbook 这种等级的应用项目里,我面对的是很简单的档案操作,就算有更动也不至于影响其他程式。换句话说,如果今天真的有需要切换搭配的储存方式时,我还是可以透过重构Guestbook 来因应环境上的变化。

当然如果今天面对的是大型的应用项目,而且已经确定有重复或相似的变化时,那么一开始就使用抽象化的设计就会是比较好的选择。总之,在设计时期就必须要考量专案的大小与变化,以采取不同的方式来因应。

因此这里我将保留Guestbook 类别的实作方式,另外留言项目采用阵列输出的方法也将加以保留。

View

不同于Guestbook ,我发现Output 分成了HTML 和RSS 两种输出格式;而且在未来可能会套用AJAX 的状况下,资料格式也可能加入JSON 。因此Output 就需要抽象化,将共用的部份提炼出来,架构图如下:

透视WebMVC_第7张图片转换成View

建立抽象类别

那些部份需要被抽离出来呢?从原来的Output 类别中,我发现setVar 和__get 这两个方法和输出格式无关,它们的实作我想就可以推到上一层去。至于render 方法则会因为输出格式而有不同的实作,那我就把它们变成抽象方法,并且把实作放在子类别里。比较特别的是fetchHTML 和fetchRSS 两个方法,因为到时候会有子类别来加以区分实作方式;所以这边我就改用fetch 这个抽象名称,让render 方法不必为了输出型态而个别呼叫。

至于抽出来的部份,我想我就用较正式的名称View 来做为类别名。所以View.php 程式如下:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
28 
29 
30 
31 
32 
33 
34 
35 
36 
37
//抽象视图类别
 abstract class View
 {
     //样版变数
     protected $vars = array ();
     //设定样版变数
     public function setVar($tpl_var, $value = null)
     {
         if (is_array($tpl_var ))
         {
             foreach ($tpl_var as $key => $val)
             {
                 if ($key != '')
                 {
                     $this->vars[$key] = $val;
                 }
             }
         }
         else
         {
             if ($tpl_var != '')
             {
                 $this->vars[$tpl_var] = $value;
             }
         }
     }
     //自动取得对应的样版变数
     private function __get($name)
     {
         return isset($this->vars[$name]) ? $this->vars[$name] : NULL;
     }
     //抽象:撷取结果
     public abstract function fetch();
     //抽象:输出结果
     public abstract function render();
 }

既然已经将共用的函式抽离出来了,子类别就可以用继承的方式来实作。

实作子类别

首先是用来输出HTML 格式内容的子类别,这里我把原来在Output 类别中属于HTML 的部份放到它身上。而这个子类别因为是View 的一种,只不过输出格式为HTML ,所以它的名称就以HtmlView 来表示。以下就是HtmlView.php 的内容:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27
//输出HTML格式内容
 class HtmlView extends View
 {
     //取得样版并解析
     public function fetch()
     {
         $args = func_get_args();
         $template_filename = $args[0];
         $html = '';
         ob_start ();
         include 'templates/' . $template_filename;
         $html = ob_get_contents();
         ob_end_clean();
         return $html;
     }
     //输出
     public function render()
     {
         //因为View类别的render函式没有参数
         //所以render要自行取得
         $args = func_get_args();
         $template_filename = $args[0];
         header('Content-Type: text/html; charset=utf-8');
         echo $this->fetch($template_filename) ;
     }
 }

而RSS 输出格式也比照办理:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
28 
29 
30 
31 
32
//输出RSS格式内容
 class RssView extends View
 {
   
  //转换成RSS内容
     public function fetch()
     {
         $xml = '';
         $xml .= '<' . '?xml version="1.0" encoding= "utf-8"?' . '>' . "\n";
         $xml .= '';
         $xml .= '';
         $xml .= ' TEST';
         $xml .= 'TEST';
         foreach ($this->items as $item)
         {
             $xml .= '';
             $xml .= '< title>' . $item['title'] . '';
             $xml .= '' . $item['body'] . '';
             $xml .= '< /item>';
         }
         $xml .= '';
         $xml .= '';
         return $xml;
     }
     //输出
     public function render()
     {
         header('Content-Type: text /xml; charset=utf-8');
         echo $this->fetch();
     }
 }

现在View 的部份已经完成了抽象化,未来如果要加入新的输出格式,我只需要继承View 这个抽象类别,再实作fetch 和render 两个方法即可。

Controller

Actions 本身并没有对应到多种格式的问题,不过它还是有些部份是一些应用程式常会需要用到的;换句话说,我可以把与留言版无关的部份抽离出来。

建立抽象类别

仔细观察Actions 类别的实作,我发现可以抽离的部份有run 、 redirectTo 两个方法,所以我决定把这些部份抽离到新的Controller 类别里。另外我也希望预设的index 方法(动作) 一定要被子类别实作,所以我也把它变成抽象类别里的抽象方法。

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19
//抽象控制类别
 abstract class Controller
 {
     //动作名称
     protected $action = '';
     //抽象:预设动作
     protected abstract function index();
     //执行动作
     public final function run()
     {
         $this ->{$this->action}();
     }
     //页面重导向
     protected function redirectTo($url)
     {
         header('Location: ' . $url);
     }
 }

然而在Actions 类别的建构式中有段程式很特别,那就是解析$_GET['act'] 的部份,它似乎也是开发新的应用程式会使用到的功能。可是先前我自己说过使用GET 参数的方式,会让网址不容易让浏览者记忆,因此在新专案里我想使用网址重写的技术。但这又有另一个问题,因为现存的这个留言板专案已经提供浏览者固定的RSS 位址,所以想更动原有解析$_GET['act'] 的部份也变得不可行。

那么有没有什么方法可以让不同的专案使用不同的动作解析?而且还能不更动到抽象的Controller 类别呢?

注:在IIS 上,网址​​重写技术也必须依赖其他ISAPI 扩展才能实现。所以预设的情况下,还是得靠GET 参数来提供action 的名称。

可替换的路由器

如果说我把解析网址的动作抽离到一个独立的类别里,让Controller 能以透过更换类别的方式来因应不同专案的网址样式,这样不是很好吗?路由器就是在这样的想法下,所产生出来的类别。路由器可以让浏览者输入不同网址时,协助Controller 判断该调用哪个动作来执行。

有了这样的想法后,我便将解析GET 参数的程式码移到了Router 类别:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17
//预设的路由器
 class Router
 {
     //预设的动作
     protected $action = 'index';
     //在建构函式中解析GET变数
     public function __construct()
     {
         $this->action = isset( $_GET['act']) ? strtolower($_GET['act']) : 'index';
     }
     //取得解析后的动作名称
     public function getAction()
     {
         return $this->action;
     }
 }

然后在Controller 类别加入以下部份:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17
//抽象控制类别
 abstract class Controller
 {
     //略...
     //路由器
     protected $router = NULL;
     //设定路由器
     public function setRouter(Router $router)
     {
         if (method_exists($this, ($action = $router->getAction())))
         {
             $this->action = $action;
         }     
     }
     //略...
 }

这样一来,我就可以透过更换Router 的实作,来达到解析不同网址样式的目的。

建立自订的Controller

有了抽象的Controller 类别,现在我就需要把留言板原来动作的部份分离出来,一般框架都是用IndexController 来当做预设的名称,它是继承自Controller 的类别:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
28 
29 
30 
31 
32 
33 
34 
35 
36 
37 
38 
39 
40 
41 
42 
43 
44 
45 
46 
47 
48
//自订的Controller
 class IndexController extends Controller
 {
     //共用的留言板物件
     private $guestbook = NULL;
     //建构函式
     public function __construct()
     {
         $this->guestbook = new GuestBook;
     }
     //预设的列表功能
     protected function index()
     {
         $view = new HtmlView;
         $view->setVar('messages', $this->guestbook->getAllMessages());
         $view->render('index.tpl. php');
     }
     //新增表单页
     protected function add()
     {
         $view = new HtmlView;
         $view->setVar('action', 'doAdd');
         $view->render('edit.tpl.php ');
     }
     //新增留言
     protected function doAdd()
     {
         $title = $_POST['title'];
         $author = $_POST['author'];
         $body = $_POST['body'];
         
        $this ->guestbook->addMessage($title, $author, $body);
         $this->redirectTo('./');
     }
     //输出RSS
     protected function rss()
     {
         $view = new RssView;
         $view-> setVar('items', $this->guestbook->getAllMessages());
         $view->render();
     }
     //解构函式
     public function __destruct()
     {
         $this->guestbook = NULL;
     }
 }

从上面的程式可以看到,原来的Output 类别已经被代换成HtmlView 和RssView 两个类别了;而这两个类别用法和原来的Output 类别相差无几,但是扩充性上却好更多。

重整架构

现在我已经有了几个共用的抽象类别,还有继承自它们的子类别实作,但是我不希望把它们都放在一起,这样的网站架构看起来非常凌乱。而且如果后面需要建立一个新专案时,我希望能花费最小的更动就能从目前的专案中把共用的框架分离出来。

以下是我对整个档案结构的安排:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
23 
24 
25 
26 
27 
28 
29 
30 
31 
32 
33 
34 
35 
36 
37 
38 
39 
40 
41 
42 
43 
44 
45
Project (专案目录)
 |
 |- core (核心框架)
 | |
 | |- Controller.php
 | |
 | |- Router.php
 | |
 | |- View.php
 |
 |- controllers (自订的Controller存放目录)
 | |
 | |- IndexController.php
 |
 |- models (自订的Model存放目录)
 | |
 | |- Guestbook.php
 |
 |- views (自订的View存放目录)
 | |
 | |- HtmlView.php
 | |
 | |- RssView.php
 |
 |- database (资料库存放位置,非必要)
 | |
 | |- guestbook.txt
 |
 |- templates (HTML样版)
 | |
 | | - edit.tpl.php
 | |
 | | - index.tpl.php
 |
 |- theme (网站风格)
 | |
 | |- images
 | | |
 | | |- bg.png
 | |
 | |- main.css
 |
 |- config.php (网站设定)
 |
 |- index.php (进入点)

在使用了这个档案架构后,相关路径也需要做调整,例如样版CSS 的位置要改到theme 底下。不过框架的档案架构并不是一定要依照上面的规划方式,我看过有些框架把controllers 、 models 和views 三个资料夹,放在一个application 目录里,这样就可以在同一专案下,建立不同的应用项目。

假设现在我要建立一个新专案,那么我只需要把core 、 config.php 和index.php 复制到新专案的资料夹,然后再依照上面的结果把相关的目录建立出来就可以撰写新的程式了。当然HtmlView 在新专案里应该也是会用得到,因此也可以一并复制过去;不过在新的专案里,我也还是可以使用Smarty 或其他的Template 引擎来实作View 的部份,这就是抽象化的好处。

更新进入点的写法

不过事情没那么简单,换了新档案结构后,原来的index.php 在自动载入类别的功能就会失效了;这是因为我没有把include_path 设定好的关系。不过我不希望去修改php.ini ,也不想从.htaccess 设定档下手(因为IIS 上不能用) ;所以我得改用set_include_path 这个函式。

另外由于Controller 的实作已经交棒​​给IndexController ,因此我也必须把原来的Actions 给换掉。

新的index.php 如下:

1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20
//载入设定
 require_once 'config.php';
 $include_path = array ();
 //系统的include_path
 $include_path[] = get_include_path();
 //目前专案所需要的include_path
 $include_path[] = APP_REAL_PATH . '/core';
 $include_path[] = APP_REAL_PATH . '/controllers';
 $include_path[] = APP_REAL_PATH . '/models';
 $include_path[] = APP_REAL_PATH . '/views';
 //设定新的include_path
 set_include_path( join(PATH_SEPARATOR, $include_path));
 function __autoload($class_name)
 {
     require_once $class_name . '.php';
 }
 $controller = new IndexController;
 $controller->setRouter(new Router);
 $controller->run();

从上面的程式中可以看到index.php 的结构还是很简单,只差多了include_path 的设定与分离出路由器的实作而已。现在我能确保留言板能正常运作,而且大部份共用的功能可以拿到新专案上使用,从这里可以看到建构MVC 框架是非常值得的一件事情。

其他部份

上面的框架只是很简单的概念实作,因为除了MVC 以外,一个框架要考量到的部份其实还有很多。像是如何与资料库做结合、自动化测试、框架产生器等等,这些都会是一个好的框架所需要的部份。因此多吸取目前网路上一些公开框架的经验,才能对框架的应用有更深一层的了解。

不过倒也不用认为框架就一定非常庞大,像有些框架就只会定义一些基本的抽象类别(像上面我实作的这个版本) ,而有些框架则会提供很多类别库来让开发人员使用(像是.Net Framework或Zend Framework ) ;而有些框架则是有特定用途,它们有可能建构在另一个框架上面(就像CakePHP建构在PHP这个大框架上) 。所以框架的组成不会只局限在MVC之下,只是在这里MVC是我所想要厘清的部份而已。

注: PHP 本来就是基于Web 开发而出现的一套语言框架。

结论

其实学过写程式的人都知道,如何为一支程式起头是最困难的一件事;不过如果有一个好用的框架产生器再配合一个不错的入门文件的话,这个问题通常很容易被解决(不过对大部份复杂的专案来说还是天方夜谭) 。因此像Ruby on RailsCakePHP这类的程式框架,也就受到许多人的重视与推崇。

而使用框架最大的好处,就是它们隐藏了许多看起来很复杂的机制;换句话说,它提供了一个抽象化的设计架构,让开发人员可以依照自己的想法去建构出所需要的应用项目。这些抽象化的部份,像是Controller 、 View 等,都能够减少开发人员在处理底层程式的时间。而且透过框架产生器,就连复制基本框架的动作也都省了,不可谓不方便。

但是一个好的Web 开发者一定要了解这些框架背后运作的原理,不然如果拿掉这些所谓的框架后,我想要写出一个基本的留言板就不是那么容易的一件事了(想想看以前写CGI 的时代) 。虽然框架隐藏了很多细节,但不表示Web 开发者就可以完全忽略这些细节;很多进阶的功能都会需要熟悉这些原理后才有办法实作出来,所以千万不能因为会使用某个框架而自得意满。

范例下载

照惯例,我把上面的范例码放在底下:

  • 原始留言版

  • 改良后的MVC 留言板

  • 架构在MVC 框架上的留言板

你可能感兴趣的:(PHP,php,web开发,设计模式)