djinorm/djin

Djin ORM 库

7.5.3 2019-08-15 10:07 UTC

README

轻量级 ORM,适用于与任何类型数据库交互,无论是关系型、文档型、列存储或键值存储。您可以完全控制数据的保存和检索方式。为此,只需创建模型的存储库即可。

安装

composer require djinorm/djin

前言

DjinORM 由一系列组件和接口组成,它们之间的交互可以封装其工作逻辑。

模型

模型是我们希望保存在数据库中的对象,是一个独立、完整的实体,可以包含任何嵌套对象和数组。任何实现 \DjinORM\Djin\Model\ModelInterface 接口的自定义类都可以作为模型。每个模型都必须有自己的唯一 Id,通过该 Id 将模型相互关联。

Id

Id 是包含模型唯一标识符的对象。每个模型都应该返回对象 \DjinORM\Djin\Id\Id,并且正是通过它将模型相互关联。在 PHP 中,所有对象都通过引用传递,因此我们可以为某个模型分配一个常量 Id,该 Id 将自动应用于所有其关联关系。示例

假设我们有两个简单的模型:User 和 Comment。用户可以发表评论,其中每个评论都应与用户关联。

用户模型

<?php
use DjinORM\Djin\Id\Id;
use DjinORM\Djin\Model\ModelInterface;

class User implements ModelInterface {
    
    /** @var Id */
    private $id;
    
    /** @var string */
    private $name;
    
    public function __construct(string $name) 
    {
        //Обратите внимание, мы создаем новый, пустой Id. Конкретное значение ему мы присвоим позже
        $this->id = new Id();
        $this->name = $name;
    }
    
    /**
    * Реализацию данного метода требует ModelInterface
    * @return Id
    */
    public  function getId(): Id
    {
        return $this->id;
    }   
    
    public  function getName(): string
    {
        return $this->name;
    }    
    
    /**
    * Реализацию данного метода требует ModelInterface
    * @return string
    */
    public static function getModelName() : string
    {
        return 'user';
    }    
}

评论模型

<?php
use DjinORM\Djin\Id\Id;
use DjinORM\Djin\Model\ModelInterface;

class Comment implements ModelInterface {
    
    /** @var Id */
    private $id;
    
    /** @var Id */
    private $userId;
    
    /** @var string */
    private $text;
    
    public function __construct(User $user, string $text) 
    {
        $this->id = new Id();
        $this->userId = $user->getId();
        $this->text = $text;
    }
    
    /**
    * Реализацию данного метода требует ModelInterface
    * @return Id
    */
    public  function getId(): Id
    {
        return $this->id;
    }
    
    public  function getUserId(): Id
    {
        return $this->userId;
    }
    
    public  function getText(): string
    {
        return $this->text;
    }
    
    /**
    * Реализацию данного метода требует ModelInterface
    * @return string
    */
    public static function getModelName() : string
    {
        return 'comment';
    }    
}

现在让我们看看如何关联用户和评论

<?php
$user = new User('Timur');
$comment = new Comment($user, 'Hello world!');

由于 PHP 中的对象是通过引用传递的,我们可以为我们的用户设置一个恒定的 Id,该 Id 将直接传递到评论中

<?php
// Проставляем Id = 10
$user->getId()->setPermanentId(10);

echo $user->getId()->toScalar(); //Выведет 10
echo $comment->getUserId()->toScalar(); //Также выведет 10

剩下的问题是谁以及如何设置 Id。关于这一点,我们稍后再谈。

IdGenerator

在 DjinORM 中,通过一个实现接口 \DjinORM\Djin\Id\IdGeneratorInterface 的单独组件设置永久 Id,该接口在将模型写入数据库之前直接分配 Id。

这里需要对那些只使用过 MySQL 并习惯了数据库自动分配 Id 的人来说说。在 DjinORM 中,这种方法是不可行的,但这并没有什么不好。相反,您可以使用任何计数器,例如 Redis、PostgreSQL 中的 sequences、UUID 字符串,或者甚至使用类似于 SELECT MAX(id) FROM table 的查询来为每个生成分配(尽管这样做并不好,因为它会导致锁定)。

ORM 包含 3 个现成的 Id-生成器(尽管您也可以创建自己的)

  • \DjinORM\Djin\Id\UuidGenerator - 不需要任何外部解决方案来生成 Id,返回一个随机的 128 位字符串,例如 550e8400-e29b-41d4-a716-446655440000。请谨慎使用,因为使用字符串作为 Id 会极大地增加数据库的大小。
  • \DjinORM\Djin\Id\RedisIdGenerator - 我们正是使用它。它需要 ext-redis 以及在 redis 失去数据时,使用类似于 SELECT MAX(id) FROM table 的机制来恢复计数器当前状态的机制。
  • \DjinORM\Djin\Id\MemoryIdGenerator - 非常适合用于测试。从指定的数字开始连续生成数字,将状态存储在内存中的变量中
  • \DjinORM\Djin\Id\IdGeneratorInterface - 生成器的接口。只有一个方法 IdGeneratorInterface::getNextId(ModelInterface $model),它接收一个模型作为输入,返回一个表示 Id 的标量值,同时不设置模型本身的 Id。通过这种方法,您可以根据模型的类和状态生成 Id

仓库

仓库中包含所有用于在数据库中查找、提取和保存模型的“脏活”。每个仓库都必须实现接口 \DjinORM\Djin\Repository\RepositoryInterface。正是仓库具有搜索、保存和删除模型的方法。它知道您正在与哪个数据库工作,以及如何将您的模型转换为用于保存到数据库的数据数组,以及如何将此数组转换回您的模型。正是仓库负责设置永久 Id,因此 IdGenerator 应通过构造函数作为依赖项传递。

假设为此目的使用了 反射,您可以使用它来访问类的私有属性,获取和修改它们的值,创建对象而不调用其构造函数,等等。为了简化 DjinORM 中通过反射处理模型的工作,存在一个特殊的辅助器 \DjinORM\Djin\Helpers\RepoHelper

使用它,我们可以轻松地将我们的模型 Comment 转换为普通数组

<?php
use \DjinORM\Djin\Helpers\RepoHelper;

$user = new User('Timur');
$comment = new Comment($user, 'Hello world!');

$data = [
    'id' => RepoHelper::getProperty($comment, 'id')->toScalar(),
    'userId' => RepoHelper::getProperty($comment, 'userId')->toScalar(),
    'text' => RepoHelper::getProperty($comment, 'text'),
];

以及将从数据库中提取的数组转换回模型

<?php
use \DjinORM\Djin\Helpers\RepoHelper;
use \DjinORM\Djin\Id\Id;

$data = [
    'id' => 1,
    'userID' => 10,
    'text' => 'Hello world!',
];

$comment = RepoHelper::newWithoutConstructor(Comment::class);
RepoHelper::setProperty($comment, 'id', new Id($data['id']));
RepoHelper::setProperty($comment, 'userId', new Id($data['userId']));
RepoHelper::setProperty($comment, 'text', $data['text']);

这种方法的优点是您可以完全控制数据的存储:您可以提取任何级别的嵌套对象,如何转换数据以记录到数据库,以及如何对数据进行操作。在某些情况下,这种方法是合理的,但需要大量的代码,尤其是在您需要转换复杂对象并检查某些值的存在时。此外,通常情况下,一些复杂的对象会作为嵌套在不同模型中。

关于如何解决这个问题,请参阅下一节。

Hydrator & Mapper

首先,我们应该考虑映射器。假设我们决定让上面的示例中的评论可以由匿名用户创建。也就是说,评论没有作者。在这种情况下,评论的 userId 字段可以是 Id,也可以是 null。在这种情况下,我们每次都必须检查 userId 是否为 null,并据此创建/提取 Id 的值或保持 null。现在想象一下,除了评论,我们还有像 Post、Role 以及许多其他模型,它们也使用 Id。因此,将转换 Id 的逻辑放到某个地方是合理的。这正是映射器出现的原因。

映射器

映射器可以将特定类型的数据转换为标量表示或数组,以及将标量数据转换为所需类型,在内部执行所有转换和 null 检查。每个映射器都必须实现接口 \DjinORM\Djin\Hydrator\Mappers\MapperInterface - 查看此接口的代码,这将使您了解很多

Djin 提供了一组典型的映射器,可以转换大多数常见的数据类型

实体解析器

因此,我们有映射器可以转换特定类型的数据,但它们本身是无用的。它们需要一个管理它们的组件。为此,有\DjinORM\Djin\Hydrator\Hydrator,它负责将复杂对象转换为简单数组以及相反。此外,实体解析器包含映射器的点符号方案,并可以根据其符号返回映射器的实例。这在需要在存储库中进一步处理数据时很有用。

例如,下面是针对User和Comment模型的实体解析器

<?php
use DjinORM\Djin\Hydrator\Hydrator;
use DjinORM\Djin\Hydrator\Mappers\IdMapper;
use DjinORM\Djin\Hydrator\Mappers\StringMapper;

$userHydrator = new Hydrator(User::class, [
    new IdMapper('id'),
    new StringMapper('name'),
]);

$commentHydrator = new Hydrator(Comment::class, [
    new IdMapper('id'),
    new IdMapper('userId'),
    new StringMapper('text'),
]);

因此,我们的存储库现在可能看起来像这样

<?php
use DjinORM\Djin\Hydrator\Hydrator;
use DjinORM\Djin\Hydrator\Mappers\IdMapper;
use DjinORM\Djin\Hydrator\Mappers\StringMapper;

class UserRepo implements \DjinORM\Djin\Repository\RepositoryInterface 
{
    
    const TABLE_NAME = 'users';
    
    private $hydrator;
    
    public function __construct() 
    {
        $this->hydrator = new Hydrator(User::class, [
            new IdMapper('id'),
            new StringMapper('name'),
        ]);
    }
    
    public function findById($id) : ?ModelInterface
    {
        $sql = "SELECT * FROM {$this::TABLE_NAME} WHERE id = {$id}"; //Это лишь пример. Всегда используйте биндинги!
        ...
        $data = some_function_that_fetch_data($sql);
        
        //Здесь простой массив из базы будет превращен в объект User
        $model = $this->hydrator->hydrate($data);
        return $model;
    }
    
    ...
    
    public function insert(ModelInterface $model)
    {
        //Здесь модель будет превращена в обычный массив
        $data = $this->hydrator->extract($model); //
        some_function_make_insert_sql($data);
    }
    
    ...
    
}

class CommentRepo implements \DjinORM\Djin\Repository\RepositoryInterface 
{
    
    const TABLE_NAME = 'comments';
    
    private $hydrator;
    
    public function __construct() 
    {
        $this->hydrator = new Hydrator(Comment::class, [
            new IdMapper('id'),
            new IdMapper('userId'),
            new StringMapper('text'),
        ]);
    }
    
    public function findById($id) : ?ModelInterface
    {
        $sql = "SELECT * FROM {$this::TABLE_NAME} WHERE id = {$id}"; //Это лишь пример. Всегда используйте биндинги!
        ...
        $data = some_function_that_fetch_data($sql);
        
        //Здесь простой массив из базы будет превращен в объект Comment
        $model = $this->hydrator->hydrate($data);
        return $model;
    }
    
    ...
    
    public function insert(ModelInterface $model)
    {
        //Здесь модель будет превращена в обычный массив
        $data = $this->hydrator->extract($model); //
        some_function_make_insert_sql($data);
    }
    
    ...
    
}

当然,在真实的项目中,您不仅可以使用SQL数据库,还可以使用任何其他数据库。您可以将通用逻辑放入抽象存储库中,并对代码进行优化。例如,您可以查看SQL存储库djin-repo-sql

在存储库中我们可以做任何事情:例如,在关系数据库的情况下,我们可以将嵌套对象保存到其他表,将它们转换为JSON或将它们展开为点符号表示的单独字段。您自己控制数据如何存储。

模型管理器

组件 \DjinORM\Djin\Manager\ModelManager 的任务是整合所有功能。正是它最大限度地简化了与模型的实际工作,将模型、仓库、协调数据保存等功能联系起来。

ModelManager 构造函数可以传递 4 个参数

  • PSR 兼容的容器(必需),它可以根据仓库类名称返回对象。例如,PHP-DI
  • callable onBeforeCommit(ModelManager $manager, array $modelsToSave, array $modelsToDelete),在其中可以开始您的数据库事务
  • callable onAfterCommit(ModelManager $manager, array $modelsToSave, array $modelsToDelete),在其中可以提交您的数据库事务
  • callable onCommitException(ModelManager $manager, array $modelsToSave, array $modelsToDelete),在其中可以回滚您的数据库事务

此外,我们需要配置 ModelManager,使其知道它将与之合作的仓库和模型。为此,我们传递给它仓库和模型或模型数组,仓库可以与之一起工作。当仓库可以保存多个不同模型,或继承自某个通用模型时,这很有用

<?php
$manager = new \DjinORM\Djin\Manager\ModelManager($container);
$manager->setModelRepository(UserRepo::class, User::class);
$manager->setModelRepository(CommentRepo::class, [Comment::class]);

如果我们的仓库只保存一个模型,则可以省略模型类指示。模型将根据 \DjinORM\Djin\Repository\RepositoryInterface::getModelClass 接口定义

<?php
$manager = new \DjinORM\Djin\Manager\ModelManager($container);
$manager->setModelRepository(UserRepo::class);
$manager->setModelRepository(CommentRepo::class);

现在我们可以以类似以下的方式处理模型

<?php

$manager = new \DjinORM\Djin\Manager\ModelManager($container);
...

$user = new User('Timur');
$comment = new Comment($user, 'Hello world');

//Подготовит модели для сохранения в БД, но не запишет их в БД
$manager->persists([$user, $comment]);

//Если вдруг мы передумали сохранять модель, то можно вызвать метод delete(),
//который отменит сохранение новой, только что созданной модели, либо удалит
//уже существующую в базе модель
$manager->delete($comment);

//Достанет подготовленные для сохранения модели, вызовет методы репозиториев,
//проставляющие перманентные Id, выполнит onBeforeCommit, запишет модели
//в БД, и вызовет onAfterCommit или onCommitException в зависимости от результата
$manager->commit();

//Если же мы хотим найти какую-то модель, то мы можем сначала достать ее репозиторий
// несколькими способами. Напримр так
$userRepo = $manager->getModelRepository(User::class);

//или так
$userRepo = $manager->getModelRepository($user);

//или так
$userRepo = $manager->getRepositoryByModelName(User::getModelName());

//Находим пользователя. Это будет объект User
$user = $userRepo->findById(1);

//Изменяем пользователя
$user->setName('Anonim');

//Сохраняем изменения
$manager->persists($user);
$manager->commit();

//Удаляем пользователя. Вызов delete(), как и persists() не осуществляет запись в БД.
//Для фактического удаления модели из БД нужно вызвать commit();
$manager->delete($user);

//Реально удаляем запись из БД
$manager->commit();

关系

使用 Id 对象进行关联操作快速且高效。但 Id 对象仅在创建记录时按引用传递。如果 User::$id 和 Comment::$userId 在创建时引用同一个 Id 对象,则在后续会话中从数据库提取它们时,它们将引用具有相同永久值的不同 Id 对象。通常,这不会造成问题,因为 Id 的值无法更改。但 Id 本身与模型没有任何关联。例如,在作者可能是 User 或 Bot 的情况下,我们如何确定评论的作者是谁呢?

正是为了解决这类问题,存在 \DjinORM\Djin\Model\Relation。让我们重写 Comment,使其可以作为作者接受任何人

<?php
use DjinORM\Djin\Id\Id;
use DjinORM\Djin\Model\ModelInterface;
use DjinORM\Djin\Model\Relation;

class Comment implements ModelInterface {
    
    /** @var Id */
    private $id;
    
    /** @var Relation */
    private $author;
    
    /** @var string */
    private $text;
    
    public function __construct(ModelInterface $author, string $text) 
    {
        $this->id = new Id();
        $this->author = Relation::link($author);
        $this->text = $text;
    }
    
    /**
    * Реализацию данного метода требует ModelInterface
    * @return Id
    */
    public  function getId(): Id
    {
        return $this->id;
    }
    
    public  function getAuthor(): Relation
    {
        return $this->author;
    }
    
    public  function getText(): string
    {
        return $this->text;
    }
    
    /**
    * Реализацию данного метода требует ModelInterface
    * @return string
    */
    public static function getModelName() : string
    {
        return 'comment';
    }    
}

关系包含模型 ModelInterface::getId() 的值和 ModelInterface::getModelName() 中的名称。对于关系,也有专门的映射器。

现在,通过关系,我们可以轻松地找到评论的作者,无论他是何种模型

<?php

$manager = new \DjinORM\Djin\Manager\ModelManager($container);

$commentRepo = $manager->getModelRepository(Comment::class);
$comment = $commentRepo->findById(10);

$author = $manager->findRelation($comment->getAuthor());