Skip to content

自己写 PHP 框架

给自己写一个 PHP 框架 (1)

2013-09

玩php玩到一定时间一般会去了解一下php框架,我是很久前接触了几个框架——其实只是看了看CI的使用方法(也没用它写过东西),其他的框架也就是知道名字而已。某天,我终于受不了总是去了解这些现成的有文档的东西,于是决心去写一个——这就是起因,很简单。

这篇文章分为两部分:

第一部分所描述的仍然是传统的php开发模式(体现在url上)。
第二部分以第一部分为基础,描述的是一个完整支持RESTful URL的MVC框架,见下一篇文章。

程序流程

如上图所示: 1、用户访问web app中某个页面; 2、该页面对应的内容是业务逻辑,业务逻辑中需要使用类库进行逻辑判断、数据库操作、缓存操作,如果用户的请求在缓存中,那么进入3,否则进4; 3、将缓存内容响应给用户; 4、业务逻辑将必须的数据传递给视图,之后将视图化后的内容响应给用户。

下面是该框架的基本结构:

tinierPHP
├── Config
│   ├── Config.php
│   ├── Loader.php
│   └── .htaccess
├── Corelib
│   ├── Medoo.php
│   ├── Rcache.php
│   ├── Render.php
│   └── .htaccess
├── Template
│   ├── header.php
│   ├── body.php
│   ├── footer.php
│   └── .htaccess
├── Userlib
│   └── .htaccess
├── 404.php
└── index.php

目录Config、Corelib、Template、Userlib是默认的。其中Config中Config.php是app的配置文件,Loader.php用来装载类(装载Corelib和Userlib中的类);Corelib目录包含的是作者定义的常用类,Userlib包含的是网站开发人员常用的类(需要自己添加)。Template保存的是视图文件。index.php的内容是开发人员控制的,在这儿主要是我用来测试。

安全起见,添加了.htaccess文件,相应的,web服务器应该开启rewrite功能。文件内容有两种选择:

选择1:

Deny from all

选择2:

RewriteEngine on
RewriteRule ^(.*)$ ../404.php/$1 [L]

第二种方法是向客户端提供上一级目录中404.php的内容,用户体验更好一些。

配置你的web app

有两个地方需要配置:

1、Config/Config.php 配置变量$_site_url,用来指明用户访问web app的基本url,例如$_site_url = 'http://127.0.0.1/tinierPHP/',这对于引用css、js、图片等特别有用。

2、Config/Loader.php 配置变量$_root_dir,指明web app所在目录在文件系统中的绝对路径。该变量在加载类的时候需要用到。

导入类库

类库被区分为核心类库(框架作者为框架添加的类库)、用户类库(开发人员的类库)。下面是导入自定义类库的示例: 我们在Userlib/下创建文件Test.php,其中定义Test类(类名和文件名必须相同):

php
<?php
class Test {
    /*
        * 这是一个测试
        */
    private $developer_name = 'letian';
    public function  __construct() {}
    public function get_developer_name() {
        return $this->developer_name;
    }
}
?>

之后我们在web app根目录下创建文件1.php,内容如下:

php
<?php
include_once './Config/Loader.php';
$test = Loader::get_instance()->load_user_class('Test'); //加载类
echo $test->get_developer_name();
?>

创建类库的原则:

类名和文件名必须相同; 构造函数必须有public属性,且不含参数; 每个类致力于解决一个方面的问题,不建议类之间互相调用。

数据持久化

在目录Corelib/中的Medoo.php提供了与数据库之间的交互。Medoo是一个轻量级的基于PDO的php数据库框架,具体使用请见其官方网站:http://medoo.in/。

不过这里对Medoo进行了些许的修改:将原有的构造函数改为set_options($options)函数,重写了一个不含参数的构造函数,具体请看源码。

使用模板

视图的实现依赖于模板文件,令人欣慰的是,php本身就是一个完美的模板实现。

首先应该说一下php中的流程控制。我们在流程控制中一般使用花括号{}来包含语句块,例如:

php
if(1===1){ echo "两者相等";}

不过也有一种替代语法,即:

php
if(1===1):
    echo "两者相等"
endif;

我们比较一下下面这两种写法:

php
<?php if (1===1){ ?>
    <h2>两者相等</h2>
<?php } ?>
php
<?php if (1===1): ?>
    <h2>两者相等</h2>
<?php endif; ?>

个人是觉得第二种方法更加直白一些,特别是在模板中使用流程控制的时候。相应的,还有endwhile、endfor、endforeach、endswitch。

如何使用模板:

在web app根目录下创建文件2.php,内容如下:

php
<?php
include_once './Config/Loader.php';
$render = Loader::get_instance()->load_core_class('Render');
$data1 = ["title" => "测试模板"];
$data2 = [
    "title"=>"这是一个测试",
    "lists"=>[1=>"一二三",2=>"四五六"]
];
$render->render("2/header",$data1);
$render->render("2/body",$data2);
$render->render("2/footer");
$render->show();
?>

在Template/目录下创建目录2,在目录2中添加文件header.php、body.php、footer.php。 header.php内容如下:

php
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title><?=$title;?></title>
</head>
<body>

body.php内容如下:

php
    <h1><?=$title?></h1>
    <?php foreach ($lists as $list): ?>
        <h2><?=$list?></h2>
    <?php endforeach;?>

footer.php内容如下:

</body>
</html>

访问2.php我们会看到:

这是如何实现的?

首先应该说一下extract函数,w3school这样说明该函数:

PHP extract() 函数从数组中把变量导入到当前的符号表中。 对于数组中的每个元素,键名用于变量名,键值用于变量值。 第二个参数 type 用于指定当某个变量已经存在,而数组中又有同名元素时,extract() 函数如何对待这样的冲突。 本函数返回成功设置的变量数目。
作为参数的数组中的key应该符合变量命名规范,例如extract(['this->aa' => 'aa', '1' =?'bb'])的返回值就为0。

下面是Render类中render方法:

php
public function render($template_path, $data = null) 
{  
    $this->_template_path = Loader::get_instance()->root_dir() .'Template/' . $template_path .'.php';
    if(is_array($data)) {
        extract($data);
    }
    ob_start();
    echo eval('?>'.preg_replace("/;*\s*\?>/", "; ?>", 
        str_replace('<?=', '<?php echo ', file_get_contents($this->_template_path))));
    $this->_buffer = ob_get_contents();
    @ob_end_clean();
    $this->_content = $this->_content . $this->_buffer;
}

由于在业务逻辑中已经将Loader类include进来,所以我们可以获得模板文件的绝对路径$this->_template_path;之后extract将$data分解用以向视图文件传递数据;eval方法用来使得模板文件生效;模板文件生成的视图内容追加到变量$this->_buffer中。很明显,在我们的模板文件中不应该对$this->_buffer进行操作。这里ob_start()ob_get_contents()ob_end_clean()用于控制缓冲区,具体请参考PHP文档。

render方法也为模板实现了一个简单的短标记语法(无论PHP本身有没有开启短标记),例如<?=$title;?>相当于<? echo $title;?>。 另,render方法的实现参考了CodeIgniter。

缓存

将需要缓存的内容持久化,在再次访问时候如果该缓存并未过期,则将该缓存的内容响应给用户。Corelib/Rcache.php实现了一个使用单个redis server作为缓存的类,该类适用于对视图内容进行缓存,我们将用户请求的url作为key,视图内容作为value。

该类没有测试过。

然后

该项目命名为tinierPHP,源码放在了https://github.com/letiantian/tinierPHP

给自己写一个 PHP 框架 (2)

2013-09

这次描述的是在给自己写一个 PHP 框架 (1)的基础上实现的支持RESTful url的版本。

该版本的实现目标是所有的访问(除了图片、css之类)都经过web app根目录下的index.php,而在url中又不会显示出index.php。姑且将该框架命名为tinierPHP2,源码在https://github.com/letiantian/tinierPHP2下载。

新版本的文件布局

tinierPHP2
├── Config
│   ├── Config.php
│   └── Loader.php
├── Controller
│   ├── Show.php
│   └── Welcome.php
├── Corelib
│   ├── Medoo.php
│   ├── Rcache.php
│   ├── Render.php
│   └── Test.php
├── static
├── Template
│   ├── 1.php
│   └── 2.php
├── Userlib
└── index.php

tinierPHP2比tinierPHP多了一个Controller目录,该目录用来存放控制器以实现业务逻辑,Welcome.php是默认的控制器,一般只需要实现其index()方法;Show.php是写来测试玩~的。

配置.htaccess

我们使用开启了rewrite模块的Apache服务器,在web app根目录下创建文件.htaccess,加入以下内容:

RewriteEngine on
RewriteCond $1 !^(index\.php|static|robots\.txt)   
RewriteRule ^(.*)$ index.php/$1 [L]

第二行指定能够基于文件系统结构访问的资源index.php、robots.txt以及名字以static开头的文件或者目录。当用户请求的资源不满足第二行的条件时候,第三行就起作用了。第三行用来将其他所有的请求交给index.php处理,这同时也实现了在url中隐藏index.php。
上文件配置好后,建议将其他子目录中的.htaccess内容清空。

index.php做了什么

index.php所做的主要工作就是根据用户的请求调用相应控制器的相应方法。

如何调用

在不自定义url映射的情况下,有以下情况: 用户访问“/”,则调用默认控制器Welcome的index方法; 用户访问“/aa”,则调用控制器Aa的index方法; 用户访问“/aa/bb”,则调用控制器Aa的bb方法; 用户访问“/aa/bb/cc”,则调用控制器Aa的bb方法,并将数组[0=>'cc']作为bb方法的参数; 用户访问“/aa/bb/cc/dd”,则调用控制器Aa的bb方法,并将数组[0=>'cc',1=>'dd']作为bb方法的参数。 如果定义了url映射,即在数组$url_maps中添加了内容,例如:

php
$url_maps = [
    '/hello/' => '/welcome/hello/'
];

如果用户请求的资源为/hello,则会去调用控制器Welcome的hello方法。

配置tinierPHP2

在tinierPHP2中,不需要在Config/Loader.php中配置变来那个$_root_dir。Loader会在根目录的index.php中实例化,这里使用了getcwd()获取web app的根目录,由于是单例模式,所以在之后调用的类中仍然可以使用同一个Loader,当然,千万别覆盖它。

Config/Config.php中的$_site_url变量还是需要配置的。

在index.php中有两个地方需要根据需要进行配置: $default_controller变量用来指定默认的控制器; $url_maps数组用来自定义url。

另外,Config/Loader.php中添加了load_controller()方法,这个方法开发人员一般用不到。另外如果找不到类或者控制器,Loader类会抛出异常。

怎样去写控制器

我们在Controller/目录中添加控制器,不建议有子目录。控制器名即类名需要与文件名相同。默认调用index()方法。控制器中的方法只允许一个参数,index.php会自动将用户请求的参数打包成数组,传递给指定控制器的指定方法。

在方法中加载类库有两种方法,第一种是使用Loader::get_instance()加载类库,第二种是使用index.php中的变量$TP_config。其实两者是一样的。
下面是一个示例:

在Template/目录下创建模板1.php,内容如下:

test template <br/>

修改在Controller/目录下的默认控制器Welcome.php:

php
class Welcome
{
    public function index()
    {
        global $TP_config;
        global $TP_loader;
        $render = $TP_loader->load_core_class('Render');
        $render ->render('1');
        $render ->show();
    }
    public function hello($args = null)
    {
        echo "hello , letian<br />";
        if ($args != null) {
            foreach($args as $arg) {
                echo $arg . '<br />';
            }
        }
    }
}

以下是访问结果:

404

当index.php找不到根据用户的请求找不到相应的类或者方法时候,会抛出异常,默认情况下index.php会将异常的信息显示出来。当然我们也可以指定转到404页面,只需要修改index.php的最后的catch语句块即可。

如何访问图片、css等静态文件

根据根目录下.htaccess的配置,我们可以再根目录下建立名称以“static”开头的目录,将图片、js、css等放在这个目录中,像平常那样去引用这些静态文件即可。

TPP:一个简单的PHP框架

2013-12

TPP是一个基于PHP的web框架,使用了MVC模式以支持快速开发。该框架源自于之前写的tinierPHP2 。tinierPHP2是web框架的一个实验项目,限于各种原因,没有进行优化。TPP是对tinierPHP2的重写,并使用了新的名称。TPP进行了大量的重新设计,架构更为清晰,代码也更加整洁。不过TPP仍然保持着简单的原则,以求给予开发者最大的灵活性。

项目地址:https://github.com/letiantian/TPP

新的文件布局

其实改动很小:

.
├── Controller
│   ├── error.php
│   └── welcome.php
├── Core
│   ├── tpp_config.php
│   ├── TPP_Index.php
│   ├── TPP_Loader.php
│   └── TPP.php
├── Corelib
│   ├── Medoo.php
│   └── Render.php
├── index.php
├── static
│   ├── bootstrap3
│   │   ├── css
│   │   │   ├── bootstrap.css
│   │   │   ├── bootstrap.min.css
│   │   │   ├── bootstrap-theme.css
│   │   │   └── bootstrap-theme.min.css
│   │   ├── fonts
│   │   │   ├── glyphicons-halflings-regular.eot
│   │   │   ├── glyphicons-halflings-regular.svg
│   │   │   ├── glyphicons-halflings-regular.ttf
│   │   │   └── glyphicons-halflings-regular.woff
│   │   └── js
│   │       ├── bootstrap.js
│   │       └── bootstrap.min.js
│   └── jquery-1.10.2.min.js
├── Template
│   └── error
│       └── 404.php
└── Userlib

新的变化

去除./Corelib/Rcache.php: Rcache.php是一个简单的用Reids作缓存的类,但是类实现的功能过于简单,且未经测试,故去除之。

去除tinierPHP2中的Config目录: 该目录被重命名为Core,其内的文件名也以TPP或者tpp开头。

新的配置方案: url路由(或者说url映射)在./Core/tpp_config.php中配置($url_maps),tpp_config.php中也可以配置基本url($tpp_base_url)、默认控制器($tpp_default_controller)、控制器的默认方法($tpp_default_method)、是否因为在浏览器中显示异常($tpp_show_error)、404页面位置($tpp_404_page)。 $tpp_base_url相当于tinierPHP2中./Config/Config.php文件中的$_site_url。现在的$tpp_base_url的值可以设为'http://127.0.0.1/'、'http://127.0.0.1/tinierPHP2/'、'/tinierPHP2/'、'/tinierPHP2' 、 'tinierPHP2' 、 'tinierPHP2/' 这些形式,./Core/TPP.php中的tpp_base_url()方法将根据配置而返回更使用的base_url,例如若配置$tpp_base_url为'tinierPHP2',tpp_base_url()将返回'/tinierPHP2/'。

控制器类 控制器类应继承TPP类,./Core/TPP类中实现了tpp_loader()方法,所以现在可以使用在控制器中使用this来使用类加载器(./Core/TPP_Loader)。 控制器的类名和文件名必须相同。TPP中不会修改根据url去加载控制器类的时候不会改变url的大小写,控制器的命名和url的关联更显而易见。我们可以看到./Controller/welcome.php内容如下:

php
    <?php
    /*
     * Default controller
     */
    class welcome extends TPP
    {

        public function index()
        {
            echo 'hello, I am TPP';
        }
        public function hello()
        {
            echo 'hello, I am TPP';
        }
    }

控制器中的函数的传参机制也进行了修改。假定./Core/tpp_config.php中$url_maps为空,$tpp_base_url为''(即TPP项目在web server的根目录里),如果url为http://127.0.0.1/welcome/hi/1/2,我们会创建控制器welcome的新的对象,调用hi方法的现行形式是hi('1','2'),而非hi(['1','2'])。

./Corelib/Medoo.php Medoo.php和Medoo项目中的版本更为接近,只是将构造函数更名为connect函数,构造函数被置空了。当然,TPP中Corelib和Userlib中的类不允许带参数的构造函数。

./Corelib/Render.php新增匿名函数 在Render类的render()方法中新增了匿名函数$tpp_base_url,这样就可以在视图中使用$tpp_base_url()获取基本url,方便开发。

实战:使用TPP做一个简单的登录系统

在mysql5中的test库下建立表users

sql
CREATE  TABLE `test`.`users` (
  `id` INT NOT NULL AUTO_INCREMENT ,
  `name` VARCHAR(45) NOT NULL ,
  `email` VARCHAR(45) NOT NULL ,
  `passwd` VARCHAR(45) NOT NULL ,
  PRIMARY KEY (`id`) ,
  UNIQUE INDEX `name_UNIQUE` (`name` ASC) )
DEFAULT CHARACTER SET = utf8
COLLATE = utf8_general_ci;

主键是id字段,name字段内容具有唯一性约束。

添加一组数据,用户名为a,email为aaa@163.com,密码aaa。图个方便,密码明文存储。

将TPP放在/var/www中,TPP的配置不变。

创建文件./Userlib/userstate.php:

php
<?php

class userstate extends TPP{

    public function is_login() {
        if (isset($_SESSION['user_name'])) {
            return true;
        }
        else {
            return false;
        }
    }

    public function login($user_name, $user_passwd) {
        $db = $this->tpp_loader()->load_core_class('Medoo');
        $db->connect([
            'database_type' => 'mysql',
            'database_name' => 'test',
            'server' => 'localhost',
            'username' => 'root',
            'password' => '123']);
        $sql = "select * from users where name =".$db->quote($user_name)." and passwd=".$db->quote($user_passwd).";";
        //$datas = $db->select("users","*");
        $datas = $db->query($sql)->fetchAll();
        if(count($datas) >= 1) {
            $_SESSION['user_name'] = $user_name;
            return true;
        }
        else {
            return false;
        }
    }

    public function logout() {
        unset($_SESSION['user_name']);
        return true;
    }

    public function  get_user_name() {
        return $_SESSION['user_name'];
    }
}

session会在控制器文件中启动,所以该文件中也就没有session_start()。SESSION只存储用户名user_nameis_login()函数判断是否已经登录。login($user_name, $user_passwd)函数提供登录功能,登录成功则返回true,否则false。logout()函数提供退出功能。get_user_name()函数获取登录用户的名字。

创建文件./Controller/usermanager.php:

php
<?php

class usermanage extends TPP {

    public function __construct() {
        session_start();  
        $this->userstate = $this->tpp_loader()->load_user_class('userstate');
    }

    public function index() {
        if($this->userstate->is_login()) {
            header("Location: /usermanage/show");
        }
        else {
            header("Location: /usermanage/login");
        }
    }

    public function login() {
        if($this->userstate->is_login()) {
            $this->show();
            return;
        }
        if(isset($_POST['user_name']) && isset($_POST['user_passwd'])) {
            if ($this->userstate->login($_POST['user_name'], $_POST['user_passwd'])) {
                $this->show();
                return ;
            }
        }

        $render = $this->tpp_loader()->load_core_class('Render');
        $render->render('usermanage/header',['title'=>'登录']);
        $render->render('usermanage/login_body',['welcome'=>'登录']);
        $render->render('usermanage/footer', null);
        $render->show();

    }

    public function logout() {
        $this->userstate->logout();
        header("Location: /usermanage/index"); 
        exit;
    }

    public function show() {
        if($this->userstate->is_login() === false) {
            header("Location: /usermanage/login");
            return;
        }
        $render = $this->tpp_loader()->load_core_class('Render');
        $render->render('usermanage/header',['title'=>'主页']);
        $render->render('usermanage/show_body',['user_name'=>$this->userstate->get_user_name()]);
        $render->render('usermanage/footer', null);
        $render->show();
    }
}

在构造函数中启动了session,同时生成./Userlib/userstate.php中userstate类的实例$this->userstate。login()函数为用户展示登录页。在登录成功后,跳转到show()生成的页面中。函数logout()提供退出功能。index()函数根据用户是否登录选择跳转到login()或者show()。

建立视图文件:

./Template/usermanager/header.php文件内容如下:

php
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <title><?=$title;?></title>

    <script src="<?=$tpp_base_url();?>static/jquery-1.10.2.min.js" type="text/javascript"></script>
    <script src="<?=$tpp_base_url();?>static/bootstrap3/js/bootstrap.min.js" type="text/javascript"></script>
    <link href="<?=$tpp_base_url();?>static/bootstrap3/css/bootstrap.min.css" rel="stylesheet">

    <style>
        .user_margin {
            height: 20px;
            width: 100%;
        }
    </style>
</head>
<body>

由于TPP配置不变,$tpp_base_url()的结果是'/'。

./Template/usermanager/login_body.php文件内容如下:

php
<div class="row">
    <div class="col-md-4"></div>
    <div class="col-md-4 ">
        <div class="user_margin"></div>

        <h2><?=$welcome;?></h2>

        <div class="user_margin"></div>
        <form role="form" method="POST" action="/usermanage/login">
            <div class="form-group">
                <label>用户名</label>
                <input type="text" class="form-control"  placeholder="Enter name" name="user_name">
            </div>
            <div class="form-group">
                <label>密码</label>
                <input type="password" class="form-control" placeholder="Password" name="user_passwd">
            </div>
            <button type="submit" class="btn btn-default">登录</button>
        </form>
        <div class="user_margin"></div>
    </div>
    <div class="col-md-4"></div>
</div>

./Template/usermanager/show_body.php文件内容如下:

php
<div class="row">
    <div class="col-md-4"></div>
    <div class="col-md-4 ">
        <div class="user_margin"></div>

        <h2>你好,<?=$user_name;?></h2>

        <a class="btn" href="/usermanage/logout">注销</a>

    </div>
    <div class="col-md-4"></div>
</div>

./Template/usermanager/footer.php文件内容如下:

php
</body>
</html>

效果图

输入http://127.0.0.1/usermanage/,则跳转到登录页面http://127.0.0.1/usermanage/login,如下:

填写用户名a和密码aaa,进入show页面: