2014年12月

ORM对象

ORM概念介绍

ORM(Object Relational Mapping对象关系映射)技术能把PHP对象和数据库记录一一对应起来,这样可以把数据记录对象化,或者把运行时对象持久化。有了ORM的帮助,数据库的增、删、改、查操作就不再是使用SQL语句,而更像平时OOP的各种对象操作了。

xts\Orange直接完成ORM

xts\Orange类是xts框架中负责ORM的类,提供ORM的各项基本功能。这个类依赖xts\Queryxts\SqlBuilder完成数据库操作。它的配置如下:


'orange' => array(
    'class' => '\\xts\\Orange',
    'singleton' => false,
    'conf' => array(
        'tablePrefix' => '',
        'queryId' => 'db',
        'enableCacheByDefault' => false,
        'moldyConf' => array(
            'cacheId' => 'cache',
            'duration' => 3600,
        ),
        'schemaConf' => array(
            'schemaCacheId' => 'cc',
            'useSchemaCache' => true,
            'schemaCacheDuration' => 0,
        ),
    ),
),

配置中的queryId是xts\Query类的组件ID,默认是db。如果项目的数据表有统一的prefix,可以定义tablePrefix字段。moldyConf是交给xts\MoldyOrange使用的配置选项,后者是提供Orange缓存支持的类。schemaConf定义了数据库结构信息缓存的机制,默认使用编译缓存。

下面我们使用用户表作为例子说明:

字段名 数据类型 说明
id int 用户ID
email varchar 电子邮件
screen_name varchar 用户名
first_name varchar 名字
last_name varchar 姓氏

新建

$user = X::orange('user');

xts推荐的方法是用X::orange方法创建ORM对象实例。这个函数会检查是否存在名为User的Orange的派生类,如果有就返回它的实例,如果没有,会返回一个Orange类的实例。X::orange方法在XComponentFactory类中定义,没有使用标准的组件工厂实现。

xts会在内部建立一个数据表user的结构对象,用于记录表的字段和关系。因为表结构不会经常改变,所以默认情况下,xts会打开schemaCache并长期缓存这个信息。如果修改了表结构,可能需要手动删除protected/runtime/compile_cache目录里的缓存文件。

注意:如果定义了tablePrefix配置项,在新建ORM对象的时候只需要写表名称的有效部分,不需要加上前缀。

Orange会自动维护对象属性和数据表字段间的关系,此外还提供驼峰命名法和下划线命名法的转换,用户可以直接以字段名作为属性名,也可以以对应的驼峰命名作为属性名称。

$user = X::orange('user');
$user->name = "James Bond";
$user->screen_name = "007";
$user->firstName = "James";
$user->lastName = "Bond";
$user->save();

这个例子中,同时使用了直接字段名和驼峰命名的规则来访问对象的属性,这都是可以的。最后的save方法调用会把数据插入到MySQL中。这里没有给$user->id赋值,因为在数据库中定了了ID为主键自增的字段。当然也可以根据需要指定id的值,不过这样有可能和自增长的值发生冲突。如果没有启用自增主键,则问题不大。

加载

我们会时常需要从数据库中按id读取一条记录,这时可以使用Orange的load方法。

$user = X::orange('user')->load($_SESSION['user_id']);
if ($user instanceof xts\Orange) {
    X::view()->assign('currentUser', $user);
} else {
    X::apple()->redirect('/login')->end();
}

上面是一个登录检查的例子,load方法在找不到记录的时候会返回null,所以可以根据返回值类型来确定是否成功加载出数据库记录。

修改

使用ORM来修改数据库记录,遵循加载、赋值、保存的步骤。前面已经提供了加载的范例代码,修改操作的代码和新建时相似,也是直接对ORM对象赋值。不同点在于修改操作的对象不是新建出来的,而是加载出来的。xts的ORM机制支持按需生成UPDATE语句,这意味着实际操作数据库的时候只有被真正修改的字段会被更新。

保存

在新建的例子代码当中示范了最常见的保存方法——直接调save方法即可。

xts的ORM对象还支持一些更复杂的保存操作。比如有的时候我们不确定是否已经存在重复的记录,可能需要INSERT IGNORE语句,或者INSERT ... ON DUPLICATE KEY UPDATE ...语句,当然还有REPLACE语句。可以利用save方法的第一个参数来通知xts生成不同的查询语句,此参数默认是xts\Orange::INSERT_NORMAL,生成一条普通的INSERT语句。

$user->save(xts\Orange::INSERT_IGNORE); // 生成 INSERT IGNORE 语句
$user->save(xts\Orange::INSERT_UPDATE); // 生成 INSERT ... ON DUPLICATE KEY UPDATE ... 语句
$user->save(xts\Orange::INSERT_REPLACE); // 生成 REPLACE 语句

查询

xts的查询生成SELECT语句。和load总成用主键查询不同,这里的查询可以自定义条件。xts提供的查询方法包括单条查询和多条查询。分别使用onemany方法。

$user = X::orange('user')->one('email=:eml AND password=:pwd', array(
    ':eml' => $_POST['email'],
    ':pmd' => sha1($_POST['password'] . X::$conf['salt']),
));

上面是one方法的使用范例,此方法接收两个参数,条件和数据列表。如果有多个复合条件,需要直接写成字符串。相比one方法,many方法要多两个参数offsetlimit

$users = X::orange('user')->many('first_name LIKE :fn ORDER BY id ASC', array(
    ':fn' => $_GET['q'] . '%',
), 0, 20);

many方法返回一个以主键为Key,xts\Orange对像为Value组成的关联数组。

删除

remove方法提供删除功能,这个方法有两种调用方式,一种接收要删除数据的主键为参数,删除这个主键所对应的一行数据;另一种不用传入参数,删除当前对象在数据库中的映射。

X::orange('user')->remove(47);

上面的例子代码能直接删除ID为47的用户记录,不论他是否存在;下面的例子能把所有名字以Th开头的用户删除。

$users = X::orange('user')->many("first_name LIKE 'Td%'");
foreach ($users as $user) {
    $user->remove();
}

组装和拆包

有的时候我们会用X::db()->query进行复杂的SQL查询,但又想把查询结果以ORM对象的方式使用,这时候需要用到组装功能。

$userArray = X::db()->query("SELECT * FROM `user` WHERE `first_name`='Thomas'");
$users = array();
foreach ($userArray as $userArr) {
    $users[$userArr['id']] = X::orange('user')->setup($userArr);
}

前面是组装的例子,setup方法能把数组组装成ORM对象。

另一种相反的情况,我们可能需要把ORM对象转换为关联数组,这时候可以调用getProperties方法或者直接调properties属性。

$user = X::orange('user')->load(17);
$userArr = $user->properties;

自定义ORM对象

xts提供的ORM功能只有数据库核心的操作功能。如果需要把一些逻辑代码写到Model层里,也可以编写自己的ORM对象。只需要继承xts\Orange类就可以了。

class User extends xts\Orange {
    public function __construct() {
        parent::__construct('user');
    }
    // Your code here
}

特殊的表名称

自定义ORM对象中,一种常见的情况是需要定义特殊的表名称,比如带有特殊的前缀,这时候可以通过重载getTableName方法来达成目的。

public function getTableName() {
    return 'special_user';
}

上面的例子是直接简单地返回表名称的字符串,但其实可以进行一定的状态判断,并返回适当的表名称。

属性Getter

xts\Orange对象支持Getter和Setter方法,继承它的子类也会自动取得相应支持,Getter和Setter在属性取用时拥有最高的优先权。在xts框架中,Getter方法的名称使用get+属性名的驼峰命名格式,Setter方法名称使用set+属性名的驼峰命名格式。比如以下是一个Getter的实例

public function getAge() {
    list($bYear, $_) = explode('-', $this->birthday, 2);
    return date('Y') - $bYear;
}

有了这个Getter,我们就可以直接用$user->age的方式访问这个属性了。

JSON迭代器

xts\Orange类实现了JsonSerializable接口,所以可以直接被json_encode函数序列化。默认情况下,json_encode会输出所有ORM映射的字段。不过我们也可以根据需要将Getter或者Relation字段也输出出来。只需通过重载getExportProperties就可以。

protected function getExportProperties() {
    return array_merge(array_keys($this->_properties), array('age'));
}

上面的例子在基本的数据库字段外,加入了age字段一并进行json_encode序列化。

数据库操作

终于写到数据库操作。xts框架只支持MySQL一种关系型数据库,对PostgreSQL和sqlite等常用库没有提供支持。我会分三个部分来介绍数据库的相关内容,包括基本的SQL查询,ORM功能以及ORM的Relation-Load功能。

配置MySQL数据库

MySQL基本配置如下

'db' => array(
    'class' => '\\xts\\Query',
    'singleton' => true,
    'conf' => array(
        'host' => 'localhost',
        'port' => 3306,
        'schema' => 'xts',
        'charset' => 'utf8',
        'user' => 'xts',
        'password' => 'xtstest',
        'persistent' => false,
    ),
),

这个配置应该放在component配置中,db是作为组件ID。xts\Query类提供基本的数据库操作功能。可以在conf中指定数据库的主机名、端口号、数据库名、连接字符集、用户名、密码还有是否启用长连接。

执行SQL语句

可以使用xts\Query类的query方法可以运行一条SQL语句并返回执行结果。

X::db()->query(
    "Select * From `user` Where `id`=:id",
    array(':id' => 1)
);

query方法会返回整个结果集,相当于PDO的fetchAll方法。如果SQL运行时发生错误,会抛出xts\OrangeException异常。

xts\Query类另有一个execute方法来运行一条SQL语句。此方法不支持变量绑定,只能书写完整的SQL语句字串。这个方法会返回操作影响的行数,比较适合用来执行Insert等无需返回结果集的函数。

取得上次Insert语句的自增长ID

xts\Query类的getLastInsertId方法可以取得上次插入语句的生成的自增ID。这个方法符合属性getter的格式,也可以以属性的方式访问它。

X::db()->execute(
    "Insert Into `user` (`id`, `name`, `email`) Values ( NULL, 'foobar', 'foobar@example.com'"
);
$id = X::db()->lastInsertId;

SQL Builder

xts提供SQL Builder来帮助书写SQL语句,它会自动做一些检查,不过功能比较弱,没有支持Join语句,所以无法取代Query。

在select的时候,SqlBuilder支持自动生成Select字段表

$sb = new \xts\SqlBuilder(X::db());
$sb->select('id', 'name', 'email')
    ->from('user')
    ->where('name like :n', array(':n'=>'Smit%');
$result = $sb->query();

RainTPL模板

模板引擎

模板引擎是渲染生成HTML页面的工具程序,它可以把数据嵌入到html代码中,动态地生成完整的页面。许多人认为PHP本身就是模板引擎,因为它可以支持直接嵌入在html页面中书写代码。不过我认为还是有使用模板引擎的必要,因为直接嵌入的PHP代码如果不对数据做检查,就可能会被报出notice或者warning,如果加上检查数据类型的代码,又太难看了。

PHP老牌的模板引擎是Smarty,xts在选择模板引擎的时候也曾考虑使用它,不过由于Smarty性能相对比较差,就没有作为xts首选的模板引擎。xts选择了更轻更快的RainTPL作为模板引擎,为了更贴合xts中的使用现状,我将RainTPL加以修改,改写成了xts\HailStone类。

RainTPL模板语法

变量

变量的语法和Smarty很像,直接由花括号括起变量名就是。下面是Hello, world例子中的模板:

<h2>Hello, {$to}</h2>

RainTPL也支持对变量使用modifier:

<date>{$order.timestamp|date:%Y-%m-%d}</date>

可以输出带+、-、*、/、%运算符的变量表达式:

<div>{$a} + {$b} = {$a+$b}</div>

如果需要使用_SESSION, _GET, _POST, _SERVER等超全局变量,请使用{$GLOBALS}访问

注意:RainTPL不支持修改边界为其它字符,花括号是直接写死在代码里的

常量

好像模板引擎需要输出常量的情况不多,不过还是可以支持,用花括号加井号的语法:

{#X_PROJECT_ROOT#}

if语句

if语法和PHP的一样,可以直接地condition中使用PHP变量和表达式。

{if condition="$age < 20"}
    less than 20 years
{elseif condition="$age < 30"}
    less than 30 years
{else}
    30 or more
{/if}

循环

RainTPL只支持foreach循环

{loop="$items"}
    <a href="/show?id={$value.id}">{$value.name}</a>
{/loop}

在循环中,可以使用三个变量

  • $key 是当前数组元素的键
  • $value 是当前数组元素的值
  • $counter 是循环计数器,从0开始

引用其它模板

可以用include引入其它模板

{include="common/header_partial"}

引入其它模板的时候,模板文件扩展名可以省略,xts会默认使用.html当后缀

调用函数

可以在模板中调用PHP函数

{function="pagination($selected_page)"}

RainTPL会打印出函数运行的结果。

模板注释

模板注释的语法和Smarty一样

{* 这是注释 *}

还有一种写法更友善一些

{ignore}
    这里都是注释
{/ignore}

原样输出

写在{noparse}{/noparse}之间的东西会原样输出

<script type="text/javascript">
    {noparse}
    var f = {status: 5};
    {/noparse}
</script>

xts中的模板

xts中的模板需要实现xts\View接口。这个接口要求模板引擎实现setPagetTitle、setLayout等一系列方法。一般模板需要在每个页面include进来header和footer,xts的模板则反过来,在layout中include具体页面的模板,具体的模板名称通过render函数的参数传递。

X::view()
    ->setPageTitle('Hello world')
    ->render('index', array(
    'to' => 'world',
), '1');

在这个例子中,X::view()返回了模板引擎类,setPageTitle设置页面的标题。render函数第一个参数是模板名称,这里表示要渲染的模板名为index;第二个参数是赋给模板引擎的变量数组,这个数组里的Key会成为模板中的变量名称;第三个参数是模板渲染后缓存的key。

一般网站都会在各个页面共用header和footer,虽然模板引擎提供了include支持,但是在每个页面的模板上都include头尾非常不便。xts的模板采用了布局模板include内容模板的反向引用方式,在render函数中指定的模板名称是内容模板的名称,会在默认名为layout的模板中把它include进来。

<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
    <style media="handheld" type="text/css"></style>
    <link rel="stylesheet" href="/static/css/common.css">
    <title>{$_page_title|escape}</title>
</head>
<body>
{include="$_content_template"}
</body>
</html>

上面是布局模板的范例,在<body>标签中引用了内容模板,内容模板的例子在下面。

<h2>Hello, {$to}</h2>

更改Layout

有的时候,网站中会出现部分页面布局不同的情况,header和footer和其它页面有所不同,这时候可以定义一个新的layout,然后直接用setLayout函数更新layout模板的名字。

X::view()->setLayout('new_layout')
    ->render('special_page');

在内容模板中输出外部css和js

内容页面常常需要引入一些外部的css文件以及js文件,需要把这些css引用写到layout的首部,把js引用写到layout的尾部。因为这些都是属于纯前端的东西,如果在PHP里assign一个变量然后输出会非常不友好。在把RainTPL移植到xts之后,我扩展了一个剪辑定义和输出的功能来解决这个问题。

可以在layout中留下剪辑输出的代码:

<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
    <style media="handheld" type="text/css"></style>
    <link rel="stylesheet" href="/static/css/common.css">
    {clip="css"}
    <title>{$_page_title|escape}</title>
</head>
<body>
{include="$_content_template"}
{clip="js"}
</body>
</html>

在内容模板中可以定义剪辑的内容,clipdef可以支持多次定义,xts会追加处理:

{clipdef="css"}<link rel="stylesheet" href="/static/css/index.css">{/clipdef}
{clipdef="css"}<link rel="stylesheet" href="/static/css/nothing.css">{/clipdef}
<h2>Hello, {$to}</h2>
{clipdef="js"}
    <script src="/static/js/index.js"></script>
    <script src="/static/js/checker.js"></script>
{/clipdef}

clip和clipdef是HailStone扩展的语法,它们的位置可以在任何模板中使用。clipdef的内容会在HailStone渲染页面的时候处理生成,完成渲染后替换到同名的clip位置上。

注意:需要在配置文件里把enable_clip设置为true才能使用此功能。

取回渲染好的html

有的时候不并希望渲染好的页面直接输出到浏览器上,而是希望能把页面html以字符串的方式取回。可以调用renderFetch函数来达成这一目的

X::view()->renderFetch('index', array(
    'to' => 'world',
), '1');

renderFetch函数的参数和render函数完全相同,唯一的区别是返回值不同。

xts路由器

单入口PHP和路由

早期的PHP程序都使用CGI方式实现,根据Web服务器上定义的网站根目录找到对应的PHP文件开始执行,于是文件路径也就成为PHP程序的天然路由器。访问不同的URL,自然会运行不同的PHP程序,彼此隔离,互不干扰,就正是多入口程序系统。不过由于的确存在一些需要复用的代码,比如系统配置,公共函数等等,所以PHP提供了include或者require方式来引入另一个文件PHP文件。这样还是存在一个问题,就是几乎所有的PHP文件都要先include一下,非常不方便。后来PHP5时代提供了autoload方法,让大家可以不必四处写include了。不过autoload的性能实在不怎么样,每次调用它都需要扫描一遍inlcude_path,才能正确地把文件引入,再说它只能支持类引入,对函数就无能为力。

后来兴起的众多PHP框架都开始使用单入口方式。所有的请求都被Web服务器rewrite到一个PHP入口文件中,然后由它完成程序初始化工作,最后按照配置规则加载和运行开发人员编写代码。这样程序会有一个集中的地方加载需要的模块,而且能很自由地定义URL结构,甚至把一部分参数作为URL的一部分。

apple组件

xts提供了一个名为apple的组件来管理程序的路由入口。在protected/config/debug.php的组件配置部分可以找到如下片段:

'apple' => array(
    'class' => '\\xts\\Apple',
    'singleton' => true,
    'conf' => array(
        'actionDir' => X_PROJECT_ROOT.'/protected/action',
        'defaultAction' => '/index',
        'actionPrefix' => 'action_',
        'preAction' => '',
        'preActionFile' => '',
    ),
),

apple组件是一个xts\Apple类的单件实例,它除了提供路由功能之外,还提供程序应用程序终止、页面跳转以及JSON输出等支持功能。

action函数

我在为xts设计路由功能的时候,希望能尽可能简单一点,所以没有像其它MVC框架那样使用Controller/View两层结构,也没有提供基于正则表达式的路由规则配置。正如前面马上开始里Hello world的例子,xts路由会直接从protected/action目录里寻找最接近的匹配文件载入,并调用同名的函数作为action函数。这里的protected/action目录是被apple组件actionDir配置项定义的,如果修改此配置项的地址,可以将action函数文件放在其它地方。

xts路由功能会完成下面的几个步骤:

  1. 在actionDir中查找和引入匹配的PHP文件;
  2. 确定action函数
  3. 调用action函数

路由规则

xts的路由查找是根据$_SERVER['REQUEST_URI']变量进行的,在配置actionDir定义的目录里寻找,尽最大可能匹配目录和文件。xts在进行路由查找的时候,会去掉URL上的文件扩展名和参数,所以对于xts来说/a/b/c.html/a/b/c.htm以及/a/b/c.php?id=5的查找规则是一样的,都会当作/a/b/c来查找对应的文件。

我们以请求/a/b/c/d来说明xts的查找过程,xts会先尝试加载{{actionDir}}/a/b/c/d.php这个文件,如果此文件不存在,xts会再尝试{{actionDir}}/a/b/c.php,如果仍未找到,会再尝试{{actionDir}}/a/b.php,最后,xts会尝试{{actionDir}}/a.php。如果xts没有找到可以匹配的文件,就不会引入任何的文件。这意味着用户可以不把action函数写在路由规则对应的文件里。当这种情况发生时,xts会把URI最后一级的名称作为action函数的名称,前面的例子中会以d作为action函数名。

action函数前綴

在前面按规则路由的过程中,xts已经确定了action函数的基础名称,就是被引入文件的主文件名。不过有不少URL中常用的英文单词被列为PHP语言保留字,或者正好有同名的built-in函数存在。比如想在列表页使用list这个名字,可是list是PHP语言的保留字,用于将数组的值按位置赋给变量;再比如某个WebServiceAPI统计内容数量的接口想使用count,但count是PHP的内建函数,用于统计数组的元素个数。为了解决这一冲突的问题,xts允许为action函数在基础名称前面加一个前缀,路由的时候还是按前面的规则找匹配的文件,文件中的函数名则优先使用带前缀的版本。action函数的前缀在配置项actionPrefix中定义,默认是action_

有了action函数前缀的帮助,我们就可以在{{actionDir}}/article/list.php里定义action_list这个函数,xts会在用户访问/article/list的时候调用action_list函数。action函数前缀定义之后并不是非加不可,对于没有冲突的情况,依旧可以使用和文件名相同的基础名称作为函数名。比如用户访问URI /article/show的时候,还是可以在{{actionDir}}/article/show.php文件里定义名为show的函数,xts也会正确地调用它。带前缀的函数名优先于不带前缀的函数,所以如果同时定义了带前缀和不带前缀的两个函数,xts只会调用带前缀的那个。

不必担心用户会利用路由规则调用PHP的内建函数,xts会阻止所有的这种请求

智能参数映射

xts支持将GET参数映射到action函数的参数表中。比如设计用户请求的URI是/article/list?page=3&category=diary,那么action_list函数可以定义接收参数page和category。

function action_list($category='default', $page=3) {
    // 列出文章列表
}

这个例子中,在action_list函数内可以直接使用$page和$category参数,xts会把$_GET中的同名参数直接赋值进来。因为是按名字赋值,所以和用户访问时的参数顺序无关,请求URI改成/article/list?category=diary&page=3和前面是等价的。

如果action函数声明的参数不可省略,而请求的$_GET数组又没有同名参数,那么xts会返回一个400错误

除了命名参数可以自动赋值外,xts现在还支持另一种给action函数的参数赋值的方式——基于位置的参数。还是以例子说明,如果设计网站的用户个人主页URI是这样的格式/user/{{name}},其中{{name}}是用户的名字。那么可以在{{actionDir}}/user.php中定义user函数,接收name参数:

function user($name) {
    // 加载用户信息
}

基于位置的参数也可以支持多个参数,比如URI /article/list/diary/3可以对应到action_list($category, $page)函数上,diary是第一个参数,3是第二个参数。

注意:基于位置的参数和命名参数映射不能混合使用

fallback_action

如果依前面方法路由确定的action函数没有定义,xts会检查是否存在名为fallback_action的函数,如果存在就调用它。fallback_action可以用来输出自定义的404页面。或者根据请求给出一些提示。如果fallback_action也没有定义,xts会直接返回HTTP状态码404,并以404 Not Found作为页面内容输出。

preAction函数

经常会有一些需要在正式逻辑开始前的准备工作,比如检查用户的登录状态,检查权限什么的。preAction就是在所有action执行前运行的一个函数。这个函数存放的文件位置由preActionFile配置项定义,函数的名称由preAction配置项定义。这两个配置默认为空,表示不启用此功能。下面是一个检查用户登录的例子:

function check_login($action) {
    $guestAllowed = array('/index','/login','/register',);
    if (!in_array($action, $guestAllowed)) {
        $user = X::orange('user')->load($_SESSION['user_id']);
        if($user instanceof User)
            $_SESSION['current_user'] = $user;
        else
            X::apple()->redirect('/login')->end();
    }
}

页面跳转

页面跳转是很常用的功能,一般需要自己写一个header输出,xts提供了一个简单的封装,即xts\Apple::redirect函数。在前一小节preAction函数的例子中已经使用到了。除了可以指定跳转的地址外,redirect方法还可以接收第二个参数$statusCode。第二个是可选的参数,用于指定服务器的返回状态码,默认情况下是302。redirect函数返回Apple对象自身,支持链式调用。

终止程序运行

在action函数中可以直接return来终止程序的运行。如果在其它地方,可以调用X::apple()->end()方法。不建议使用PHP的die或者exit来结束程序,主要是出于兼容性和未来进行性能评估方便的考虑。调用end方法终止程序之后,index.php里后续的程序还是会运行的,比如设置性能评估终止标记,写入日志等等。