energylab/gacela

PHP的响应式数据映射器

1.0.0 2013-05-16 01:09 UTC

This package is not auto-updated.

Last update: 2024-09-23 14:27:15 UTC


README

大多数最有用的应用程序以某种形式与数据进行交互。存在多种存储数据的方法,对于这些解决方案中的每一个,有时还有多种数据存储格式。在使用面向对象PHP时,相同的数据将在类中进行存储、修改和访问。

假设您正在创建自己的博客系统。我们将假设您需要创建帖子,并允许多个用户撰写文章。

使用XML的分层格式存储数据相当直接。每个'user'都由一个名为'user'的节点表示,它包含一个子节点'contents'来包含用户的博客文章。

<xml version="1.0>
	<user id="1" first="Bobby" last="Mcintire" email="bobby@gacela.com" />
	<user id="2" first="Frankfurt" last="McGee" email="sweetcheeks@gacela.com">
		<contents>
			<content id="id" title="Beginners Guide to ORMs" published="2013-05-22 15:31:00">
                In order to start, you need to read the rest of this user's guide.
            </content>
		</contents>
	</user>
</xml>

使用关系数据库,我们会创建两个表,一个用于存储每个用户的基本信息,另一个用于存储他们的帖子。

‘users’表

‘contents’表

在PHP中,相同的数据将以如下类的方式存储

class User {

	protected $data = [
		'id' => 1,
		'name' => 'Bobby Mcintire',
		'email' => 'bobby@kacela.com'
	];

	protected $contents = [];

}

class User {

	protected $data = [
		'id' => 2,
		'name' => 'Frankfurt McGee',
		'email' => 'sweetcheeks@kacela.com',
		'phone' => '9876543214'
	];

	protected $contents = [
        [
            'id' => 1,
            'userId' => 2,
            'title' => 'Beginners Guide to ORMs',
            'content' => 'Read this guide all the way to the end'
        ]
	];

}

如您所见,数据的存储方式与我们在应用程序代码中与数据交互的方式可能会有很大的不同。

这被称为对象阻抗不匹配。一个常见的模式已经出现,用于隐藏应用程序代码和数据存储之间差异的复杂性,称为对象关系映射。

这个设计模式是为了处理将关系数据库记录映射到代码中的对象而开发的,但许多相同的原理也适用于处理任何形式的原始数据,因为几乎总是存在某种不匹配。

常见解决方案

对象关系映射(简称ORM)最常见的方法是Active Record模式。

在Active Record中,一个类实例代表数据源中的一个记录。使用Active Record实例,业务逻辑和数据访问逻辑包含在单个对象中。一个基本的Active Record类可能看起来像这样

class Model_User extends ORM
{

}

并且可以这样访问

$user = ORM::find('User', 1);

// echo's Bobby Mcintire to the screen
echo $user->name;

$user->email = 'new.user@gacela.com'

$user->save();

Gacela的基本理念

与数据映射器一起工作可能比与Active Record等更基本的方法困难得多,但Gacela提供了大量的回报,如果您一开始就应对复杂性。在开发Gacela时,以下是我们认为每个ORM都应该具备的顶级特性

  • 自动发现类之间的关系以及类中包含的数据的规则。
  • 将数据存储活动与业务逻辑活动分开,以便我们的类只有一个责任。
  • 易于设置和使用,但可以处理业务对象和底层数据存储之间的复杂映射。

安装和配置

如何安装

Gacela可以通过Composer安装。

在您的composer.json文件中定义以下要求

{
    "require": {
        "energylab/gacela": "dev-develop"
    }
}

配置

数据源设置

Gacela假定在任何一个应用程序中,都可能会有多个数据源,即使只需要使用多个数据库。

目前Gacela支持两种类型的数据源:数据库和Salesforce。我们计划添加对Xml、RESTful Web Services和SOAP Web Services的支持,并完全支持MySQL、MSSQL、Postgres和SQLite之间的差异。

Gacela 提供了一种方便的方法,可以从配置参数中创建 DataSource 对象。一旦创建了一个 DataSource 对象,它就可以轻松地与 Gacela 实例注册,使其在任何地方都可以使用。

关系型数据库

$source = \Gacela\Gacela::createDataSource(
    [
        'name' => 'db',
        'type' => 'mysql' // Other types would be mssql, postgres, sqllite
        'host' => 'localhost',
        'user' => 'gacela',
        'password' => '',
        'schema' => 'gacela'
    ]
);

\Gacela\Gacela::instance()->registerDataSource($source);

Gacela 默认将数据库表名称命名为复数形式(users,contents)。尽管这可以被轻松覆盖。我们稍后会看到一个覆盖表名称的例子。

例如

CREATE TABLE users (
    id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
    `name` VARCHAR(150) NOT NULL,
    `email` VARCHAR(200) NOT NULL
) ENGINE=Innodb;

CREATE TABLE contents (
    id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
    userId INT UNSIGNED NOT NULL,
    title VARCHAR(255) NOT NULL,
    content LONGTEXT NOT NULL,
    published TIMESTAMP NULL,
    CONSTRAINT `fk_user_contents` FOREIGN KEY (userId) REFERENCES users(id) ON DELETE RESTRICT
) ENGINE=Innodb;

Salesforce

$source = \Gacela\Gacela::createDataSource(
    [
        'name' => 'salesforce',
        'type' => 'salesforce',
        'soapclient_path' => MODPATH.'sf/vendor/soapclient/',
			/**
			 * Specify the full path to your wsdl file for Salesforce
			 */
		'wsdl_path' => APPPATH.'config/sf.wsdl',
		'username' => 'salesforceuser@domain.com.sandbox',
		'password' => 'SecretPasswordWithSalesforceHash',
		/**
		 * Specifies which Salesforce objects are available for use in Gacela
		 */
         'objects' => []
    ]
);

注册命名空间

Gacela 包含它自己的自动加载器,并在构建 Gacela 实例时注册它。 Gacela 还为其使用注册了它自己的命名空间。即使你只计划为你的项目创建 Mappers 和 Models,你也希望注册一个自定义命名空间。

/*
 * Assuming that you are bootstrapping from the root of your project and that you want to put your 
 * custom application code in an app directory
 */
\Gacela\Gacela::instance()->registerNamespace('App', __DIR__.'/app/');

/*
 * A handy trick if you want to put your Mappers/Models or other custom extensions for Gacela in the global 
 * namespace
 */
\Gacela\Gacela::instance()->registerNamespace('', __DIR__.'/app/');

将这两个命名空间注册到相同的目录中,我可以这样声明一个新的 Mapper(User)

/*
 * __DIR__.'/app/Mapper/User.php'
 */

<?php

namespace Mapper;

class User extends \Gacela\Mapper\Mapper {}

或者这样

/*
 * __DIR__.'/app/Mapper/User.php'
 */

<?php

namespace App\Mapper;

use \Gacela\Mapper\Mapper as M;

class User extends M {}

更有趣的是, Gacela 允许级联命名空间,这样你可以轻松地覆盖默认的 Gacela 类,而无需修改依赖于修改后类的方方法和类。所以,假设你想要创建一个自定义的 Model 类,在这个类中你可以为所有模型使用一些默认的功能。

/*
 * __DIR__.'/app/Model/Model.php'
 */
<?php

namespace Model;

class Model extends \Gacela\Model\Model {}

?> // This breaks a PSR standard but is shown here to clarify the end of the file

/*
 * __DIR__'/app/Model/User.php'
 */

 <?php

namespace Model;

class User extends Model {}

?>

我个人在项目中总是扩展基本 Model 和 Mapper 类,即使不是为了其他原因,这也是简化我的类声明的方法。

使用缓存

Gacela 在两个级别上支持缓存,第一个是缓存它用于确定关系、列数据类型等元数据。第二个是缓存请求的数据。为了使用这两个级别之一,必须在 Gacela 实例中启用缓存。

Gacela 将使用支持 get()、set() 和 increment() 的任何缓存库

$cache = new \Cache;

\Gacela\Gacela::instance()->enableCache($cache);

基本用法

正如我们之前所提到的,任何给定的 ORM 都提供了两个独立的函数;

  • 数据访问
  • 业务逻辑

大多数 ORM 将这两个职责合并到一个类中,该类将包含处理业务或应用逻辑问题的自定义方法,以及用于在数据库中查找或保存数据的自定义方法。 Gacela 采取不同的方法,将这两个功能分为两个独立的、不同的类

  • Mappers(数据访问)
  • Models(业务或应用逻辑)

为了使我们的基本应用程序运行,我需要以下文件和类定义

/*
 * Again assume that we have created an 'app' directory and registered it into the global namespace with Gacela.
 * 
 * As I mentioned before, I prefer to always override the default Model and Mapper classes in my application so 
 * I will that first.
 * app/Mapper/Mapper
 */
<?php 

namespace Mapper;

class Mapper extends \Gacela\Mapper\Mapper {}

?>

/*
 * app/Model/Model
 */
<?php

namespace Model;

class Model extends \Gacela\Model\Model {}

?>

/*
 * app/Mapper/User
 */
<?php

namespace Mapper;

class User extends Mapper {}

?>

/*
 * The underlying database table is named contents, but perhaps we decided after the fact that we'd rather
 * use Post as the name by which we reference records in this table.
 *
 * app/Mapper/Post
 */
<?php

namespace Mapper;

class Post extends Mapper {

    /*
     * Easy peasy. To manually specify the underlying table or as we call it in Gacela, resource, name just set
     * the $_resourceName property in the mapper. This also works great if your table names are singular rather than 
     * the default plural.
     */
    protected $_resourceName = 'contents';
}

?>

/*
 * app/Model/User
 */
<?php

namespace Model;

class User extends Model {}

?>

/*
 * app/Model/Post
 */
<?php

namespace Model;

class Post extends Model {}

?>

现在我们可以加载现有的用户,创建一个新的帖子,或者删除一个用户或帖子。

/*
 * You can also easily override the \Gacela\Gacela class by creating a shorthand class in the app/ directory
 * that extends \Gacela\Gacela. To simplify calls, I like to create a extended class 'G'. Future examples all
 * assume that this extended class exists.
 */
$user = \G::instance()->find('User', 1);

// echos Bobby Mcintire to the screen
echo $user->name;

$user->email = 'different@gacela.com'

// Saves the updated record to the database
$user->save();

/*
 * The required argument when using new Model\Model() specifies the name of the Mapper to use from the Model.
 */
$post = new \Model\Post('Post');

$post->setData(
    [
        'userId' => 1,
        'title' => 'A new blog post',
        'content' => 'Lorem ipsum dolor fakey fake content'
    ]
);

/*
 * Will output TRUE because the id column is assigned by the database engine at insert in our case.
 */
echo $post->validate();

$post->save(['title' => 'A better title']);

现在你可能正在想,“等等!这几乎和我用过的每个 ORM 都一样,为什么我需要创建两个文件,而我之前只创建了一个呢?”

到目前为止,我们看到的只是最基本的情况——一个数据库表和一个提供简单、默认 find() 和 findAll() 方法的 Mapper,以及没有自定义业务逻辑的 Model。

我们将首先探索自定义 Mapper 函数。

使用 Mappers 获取数据

与 Mapper 类关联的 Model 类的名称默认与 Mapper 类的名称相同。这可以被覆盖

<?php

namespace Mapper;

class Note extends Mapper {

    protected $_modelName = 'Comment'
}

获取单个记录

$bobby = \Gacela\Gacela::instance()->find('User', 1);

/*
 * Outputs Bobby Mcintire
 */
echo $bobby->email;

使用简单标准获取多个记录

/*
 * The \Gacela\Criteria object allows users to specify simple rules for filtering, sorting and limiting data without all of the complexity of
 * a full built-in query builder.
 * \Gacela\Gacela::instance()->findAll() returns an instance of \Gacela\Collection\Arr
*/

$criteria = \Gacela\Gacela::criteria()
    ->equals('userId', 1);

\Gacela\Gacela::instance()->findAll('Post', $criteria);

使用复杂标准获取记录

<?php

namespace Mapper;

class User extends Mapper
{

	/**
	 * Fetches a Collection of all users with no posts
	*/
	public function findUserWithNoPosts()
	{
		/**
		 * Mapper::_getQuery() returns a Query instance specific to the Mapper's data source.
		 * As such, the methods available for each Query instance will vary.
		*/
		$query = $this->_getQuery()
			->from('users')
			->join(array('p' => 'contents'), "users.id = p.userId, array(), 'left')
			->where('p.published IS NULL');

		/**
		 * For the Database DataSource, returns an instance of PDOStatement.
		 * For all others, returns an array.
		*/
		$data = $this->_runQuery($query)->fetchAll();

		/**
		 * Creates a Gacela\Collection\Collection instance based on the internal type of the data passed to Mapper::_collection()
		 * Currently, two types of Collections are supported, Arr (for arrays) and PDOStatement
		*/
		return $this->_collection($data);
	}
}

定制返回给 Model 的数据

有时,你可能希望向模型传递的数据并不严格表示在特定模型的基本表中。

假设我们想要跟踪每个用户的所有登录尝试。我们可以添加以下数据库表

CREATE TABLE logins (
    `userId` INT UNSIGNED NOT NULL,
    `timestamp` TIMESTAMP NOT NULL,
    `succeeded` BOOL NOT NULL DEFAULT 0,
    PRIMARY KEY(`userId`, `timestamp`),
    CONSTRAINT `fk-user-logins` FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=Innodb;

然而,我们希望知道每个用户尝试登录的次数以及他们成功登录的次数。所以我们要修改 User Mapper 的 find() 和 findAll() 查询,以便始终包括登录尝试次数和成功登录次数。

<?php

namespace Mapper;

use Gacela as G;

class User extends Mapper
{
	public function find($id)
	{
		$criteria = \G::instance()criteria()->equals('id', $id);

		$rs = $this->_runQuery($this->_base($criteria))->fetch();

		if(!$rs)
		{
			$rs = new \stdClass;
		}

		return $this->_load($rs);
	}

	public function findAll(\G\Criteria $criteria = null)
	{
		/**
		 * Returns an instance Gacela\Collection\Statement
		**/
		return $this->_runQuery($this->_base($criteria));
	}

	/**
	 * Allows for a unifying method of fetching the custom data set for find() and find_all()
	**/
	protected function _base(\G\Criteria $criteria = null)
	{
		$attempts = $this->_getQuery()
            ->from('logins', 'attempts' => 'COUNT(*)')
            ->where('logins.userId = users.id');

        $logins = $this->_getQuery()
            ->from('logins', 'logins' => 'COUNT(*)')
            ->where('logins.userId = users.id')
            ->where('succeeded = 1');

        return $this->_getQuery($criteria)
			->from('users', [
                'users.*',
                'attempts' => $attempts,
                'logins' => $logins
            ]);
	}
}

使用模型控制业务逻辑

到目前为止,我们使用模型所做的一切都很标准化

$user = new \Model\User('\Mapper\User');

$user->name = 'Noah Dingermeister';
$user->email = 'noahmeister@gacela.com';

if($user->validate()) {
    $user->save();
} else {
    print_r($user->errors);
}

获取器、设置器和其他魔法

但是,关于所有那些你需要管理的花哨的业务逻辑呢?

在'users'表中,我们将全名存储在一个单独的字段中,但如果我们想分别使用名字和姓氏怎么办?例如,当用户登录时,你可以输出'Hello Bobby'给用户。

在用户模型中,我们可以这样做

<?php

namespace Model;

class User extends Model {

    protected function _getFirstName()
    {
        $first = explode(' ', $this->name);

        return current($first);
    }

    protected function _getLastName()
    {
        $last = explode(' ', $this->name);

        return end($last);
    }
}

?>

$user = \G::instance()->find('User', 1);

echo 'Hello '.$user->firstName;

假设每次设置Post模型的内容时,我们希望将某些单词(lame、boring、stupid)翻译成不同的单词(AMAZING)

<?php

namespace Model;

class Post extends Model {

    protected function _setContent($val)
    {
        $val = str_replace(['lame', 'boring', 'stupid'], 'AMAZING', $val);

        /**
         * The _set() method verifies that the new value is distinct from the current value
         * and then moves the old value into the _originalData array, sets the new value into _data
         * and adds the specified key to the _changed array.
         */
        $this->_set('content', $val);
    }
}
?>

<?php

$post = new \Model\Post('\Mapper\Post', [
    'userId' => 2,
    'title' => 'Another Great Post',
    'content' => 'This post is all about how lame and stupid I think these docs are. In fact this is so boring I
        am falling asleep.'
]);

/*
 * Outputs: This post is all about how AMAZING and AMAZING I think these docs are. In fact this is so AMAZING
 * I am falling asleep.
 */
echo $post->content;

模型的另一个常见情况是验证值是否为空。因此,就像与__get()和__set()以及_get和_set一样,isset()和empty()方法将调用对象上的__isset()魔法方法。同样,_isset允许你为虚拟属性定义自定义行为。

验证

这里似乎是一个很好的地方,花点时间解释一下Gacela中的验证是如何工作的。

默认情况下,Gacela使用映射器中的字段元数据来执行验证。例如,如果你在底层资源中声明了一个字段为整数,那么只有整数值在模型中的该字段上才会验证为true。

Gacela目前支持以下原生数据类型

  • 二进制
  • 布尔
  • 日期
  • 十进制
  • 枚举
  • 浮点
  • GUID
  • 整数
  • 集合
  • 字符串
  • 时间

确保输入数据与底层数据类型匹配的验证是很好的,但是,对于复杂的业务规则,如何验证数据呢?例如,如果我们只想允许来自gacela.com域的用户邮箱地址?

在这种情况下,最简单的解决方案是简单地扩展模型中的validate()方法。

<?php

namespace Model;

class User extends Model {

    public function validate(array $data = null)
    {
        if($data) {
            $this->setData($data);
        }

        if(strpos($this->email, 'gacela.com') === false) {
            $this->_errors['email'] = 'not_gacela_domain';
        }

        return parent::validate();
    }
}