【问题标题】:How to separate database from model and extending model over multiple tables如何将数据库与模型分离并在多个表上扩展模型
【发布时间】:2017-07-15 17:05:30
【问题描述】:

我目前对模型使用自制的 ORM 方法,即对象的属性映射到表的字段。这似乎是处理简单网站的一种非常可行且直观的方式。

不过……

随着复杂性的增加,当我想从连接表中获取数据时,这种方法变得很麻烦。我的数据库抽象只针对单个表,使用连接进行查询并不自然。

我一直在调查 DMM,特别是在 https://github.com/codeinthehole/domain-model-mapper,但我在细节上迷失了方向。如果我能弄清楚我的问题是什么,我需要回答一些更宏观的问题:/

首先,MVC 有一个很好的描述结构:模型、视图和控制器。您可以分离出目录结构以分离关注点。但是,正如我正在学习的那样,该模型不是一个单一的类。您希望将存储与业务逻辑分开。

问题 1:这些不同的模型类型是否有命名约定和目录层次结构?

第二个问题与实际的数据库访问有关。根据 DMM 模式,您有一个内存中的对象,您可以通过调用 ->save() 方法来保存它。此内存对象不一定与任何数据库表 1:1 映射。这就是我迷路的地方...save 方法可能会将内存中的对象注入到数据访问对象中,从而将对象保存在数据库中。

通过数据库抽象,我可以用自己的findfindallinsertupdatedelete等方法创建一个类,这些方法可以针对每个数据库表进行扩展。但 DMM 似乎是一个完全不同的范式。 DAO 是否有抽象,还是必须为每个应用程序定制设计?

问题 2:如何将内存中的对象映射到跨多个表的数据访问对象?


对于这两个问题,我意识到它们都是抽象的问题。我不是要求有人为我调试代码;我想了解它背后的理论。因此,很难提出容易回答的问题。但我欢迎任何部分答案,只要它能让社区有机会与我一起学习。

【问题讨论】:

  • 太多的问题,基于意见和题外话。 SO 在这里帮助解决一个与显示一些代码的编程问题相关的问题(at time),它不是论坛。
  • 在 SO 上发布问题之前,您应该通过 the tour,然后转到 Help Section 阅读 What types of questions should I avoid asking?。最后,如果您确定您的问题符合规则,请阅读How to Ask a question on StackOverflow,以便能够提出一个有用、格式良好且主题相关的问题。
  • 感谢您解释否决票。我会将帖子编辑为一个问题。我并没有像我看到的大多数 php 问题那样寻求调试帮助;我问的是我的研究发现没有得到充分解释的设计模式。

标签: database design-patterns model-view-controller


【解决方案1】:

从一般的角度来看,基于 MVC 概念的 Web 应用程序由两层组成:模型层和表示层。它们的实现实现了关注点分离的目标。

model layer由三个子层组成:

  • domain layer (domain model),由域对象(内存中的对象)组成 - 也称为模型。它们是封装业务逻辑的实体。因此,通过它们的结构和相互依赖,它们是现实世界(业务)对象/实体的抽象。该层还可以包含诸如领域对象集合之类的结构。
  • 存储层,由负责将域对象传入/传出底层存储系统(可能是 RDBMS、会话、文件系统等)的类组成:repositories, (data) mappersadapters、数据访问抽象类(PDOMySQLi - 以及它们的包装器)等。使用这些结构也达到了使域对象(完全)不可知、不可知的目的存储类型及其寻址方式。
  • service layer 由执行涉及上两个子层结构的操作的类(例如服务)构建而成。例如,服务从存储系统中获取域对象,根据其状态(属性)进行一些验证并返回相应的结果。

表示层包括:

  • 观看次数
  • 控制器
  • [视图模型]

请注意,我没有完成对这一层的描述。我是故意这样做的,因为我认为您最好关注此链接,以获得对该主题的正确看法:


关于您的第二个问题:确实,ORM 自动化了域层和数据库之间的映射。它们很有用,但也有缺点,因为它们迫使您从业务逻辑 PLUS 数据库结构的角度进行思考。 "每个表一个类" 如 (Table Data Gateway),"protected $tableName;" 如 DMM 的父类 Mapper,"class用户扩展 ActiveRecord" 如Active Record 等,是灵活性限制的标志。例如,正如我在 DMM 代码中看到的,它强制您在 Mapper 构造函数中提供 $tableName$identityFields。这是一个很大的限制。

无论如何,如果您想在涉及(复杂)数据库查询的任务中真正灵活,那就保持简单:

  • 让域对象完全不知道存储系统。
  • 实现数据映射器模式不从任何父映射器继承特定映射器!在特定数据映射器的方法(保存、更新、插入、删除、查找、findByXXX 等)中,您可以使用纯 SQL,无限复杂。阅读PHP MVC: Data Mapper pattern: class design。当然,这样你会多写一点 sql... 并成为 SQL 大师! :-) 请注意,任何其他“解决方案”都会降低 sql 的灵活性。
  • 如果您确实需要从 sql 语言(SQL、T-SQL、PL/SQL 等)中抽象出来,您甚至可以实现自己的查询构建器类并在数据映射器方法中使用它的实例,而不是使用 sql陈述。阅读PHP MVC: Query builder class for Data Mapper layer
  • 实现一个适配器类,90% 类似于DMM 中的类。 tableName 之类的内容不应出现在那里。
  • 创建一个 PDO 连接并将其注入到 Adapter 对象的构造函数中。注意:不要像 DMM 那样在适配器内部创建 PDO,因为它们的适配器类与 PDO 紧密耦合。你的应该 - 正确 - 松散耦合。您正在通过依赖注入实现这一点 - 请参阅 The Clean Code Talks - Don't Look For Things!
  • 尝试使用依赖注入容器。喜欢Auryn。它将仅在应用程序的入口点(index.php 或 bootstrap.php)处理所有类的实例化和共享。最好的例子是 PDO 连接,它通过 Web MVC 的完整循环共享。它非常强大,非常容易学习和使用,并且会让你的 MVC 变得非常苗条。先看Dependency Injection and Dependency Inversion in PHP

稍后您还需要创建存储库和服务。

所以,结束您的第一个问题:有一个很好的解释文章系列,正是关于您感兴趣的内容。阅读它们后,您将不再怀疑所有模型层组件的工作原理一起。注意:您会看到与财产相同的$tableName,但现在您知道从哪个角度考虑它。所以:

这里是映射器的一个版本,灵感来自上述文章。注意没有从父/抽象类继承。要找出原因,请阅读PHP MVC: Data Mapper pattern: class design 的精彩回答。

数据映射类:

<?php

/*
 * User mapper.
 * 
 * Copyright © 2017 SitePoint
 * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 
 * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 
 * PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 
 * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

namespace App\Modules\Connects\Models\Mappers;

use App\Modules\Connects\Models\Models\User;
use App\Modules\Connects\Models\Models\UserInterface;
use App\Modules\Connects\Models\Mappers\UserMapperInterface;
use App\Modules\Connects\Models\Collections\UserCollectionInterface;
use App\Core\Model\Storage\Adapter\Database\DatabaseAdapterInterface;

/**
 * User mapper.
 */
class UserMapper implements UserMapperInterface {

    /**
     * Adapter.
     * 
     * @var DatabaseAdapterInterface
     */
    private $adapter;

    /**
     * User collection.
     * 
     * @var UserCollectionInterface
     */
    private $userCollection;

    /**
     * 
     * @param DatabaseAdapterInterface $adapter Adapter.
     * @param UserCollectionInterface $userCollection User collection.
     */
    public function __construct(DatabaseAdapterInterface $adapter, UserCollectionInterface $userCollection) {
        $this
                ->setAdapter($adapter)
                ->setUserCollection($userCollection)
        ;
    }

    /**
     * Find user by id.
     * 
     * @param int $id User id.
     * @return UserInterface User.
     */
    public function findById($id) {
        $sql = "SELECT * FROM users WHERE id=:id";
        $bindings = [
            'id' => $id
        ];

        $row = $this->getAdapter()->selectOne($sql, $bindings);

        return $this->createUser($row);
    }

    /**
     * Find users by criteria.
     * 
     * @param array $filter [optional] WHERE conditions.
     * @return UserCollectionInterface User collection.
     */
    public function find(array $filter = array()) {
        $conditions = array();
        foreach ($filter as $key => $value) {
            $conditions[] = $key . '=:' . $key;
        }
        $whereClause = implode(' AND ', $conditions);

        $sql = sprintf('SELECT * FROM users %s'
                , !empty($filter) ? 'WHERE ' . $whereClause : ''
        );
        $bindings = $filter;

        $rows = $this->getAdapter()->select($sql, $bindings);

        return $this->createUserCollection($rows);
    }

    /**
     * Insert user.
     * 
     * @param UserInterface $user User.
     * @return UserInterface Inserted user (saved data may differ from initial user data).
     */
    public function insert(UserInterface $user) {
        $properties = get_object_vars($user);

        $columnsClause = implode(',', array_keys($properties));

        $values = array();
        foreach (array_keys($properties) as $column) {
            $values[] = ':' . $column;
        }
        $valuesClause = implode(',', $values);

        $sql = sprintf('INSERT INTO users (%s) VALUES (%s)'
                , $columnsClause
                , $valuesClause
        );
        $bindings = $properties;

        $this->getAdapter()->insert($sql, $bindings);

        $lastInsertId = $this->getAdapter()->getLastInsertId();

        return $this->findById($lastInsertId);
    }

    /**
     * Update user.
     * 
     * @param UserInterface $user User.
     * @return UserInterface Updated user (saved data may differ from initial user data).
     */
    public function update(UserInterface $user) {
        $properties = get_object_vars($user);

        $columns = array();
        foreach (array_keys($properties) as $column) {
            if ($column !== 'id') {
                $columns[] = $column . '=:' . $column;
            }
        }
        $columnsClause = implode(',', $columns);

        $sql = sprintf('UPDATE users SET %s WHERE id = :id'
                , $columnsClause
        );
        $bindings = $properties;

        $this->getAdapter()->update($sql, $bindings);

        return $this->findById($user->id);
    }

    /**
     * Delete user.
     * 
     * @param UserInterface $user User.
     * @return bool TRUE if user successfully deleted, FALSE otherwise.
     */
    public function delete(UserInterface $user) {
        $sql = 'DELETE FROM users WHERE id=:id';
        $bindings = array(
            'id' => $user->id
        );

        $rowCount = $this->getAdapter()->delete($sql, $bindings);

        return $rowCount > 0;
    }

    /**
     * Create user.
     * 
     * @param array $row Table row.
     * @return UserInterface User.
     */
    public function createUser(array $row) {
        $user = new User();
        foreach ($row as $key => $value) {
            $user->$key = $value;
        }
        return $user;
    }

    /**
     * Create user collection.
     * 
     * @param array $rows Table rows.
     * @return UserCollectionInterface User collection.
     */
    public function createUserCollection(array $rows) {
        $this->getUserCollection()->clear();
        foreach ($rows as $row) {
            $user = $this->createUser($row);
            $this->getUserCollection()->add($user);
        }
        return $this->getUserCollection()->toArray();
    }

    /**
     * Get adapter.
     * 
     * @return DatabaseAdapterInterface
     */
    public function getAdapter() {
        return $this->adapter;
    }

    /**
     * Set adapter.
     * 
     * @param DatabaseAdapterInterface $adapter Adapter.
     * @return $this
     */
    public function setAdapter(DatabaseAdapterInterface $adapter) {
        $this->adapter = $adapter;
        return $this;
    }

    /**
     * Get user collection.
     * 
     * @return UserCollectionInterface
     */
    public function getUserCollection() {
        return $this->userCollection;
    }

    /**
     * Set user collection.
     * 
     * @param UserCollectionInterface $userCollection User collection.
     * @return $this
     */
    public function setUserCollection(UserCollectionInterface $userCollection) {
        $this->userCollection = $userCollection;
        return $this;
    }

}

数据映射接口:

<?php

/*
 * User mapper interface.
 */

namespace App\Modules\Connects\Models\Mappers;

use App\Modules\Connects\Models\Models\UserInterface;

/**
 * User mapper interface.
 */
interface UserMapperInterface {
    /**
     * Find user by id.
     * 
     * @param int $id User id.
     * @return UserInterface User.
     */
    public function findById($id);

    /**
     * Find users by criteria.
     * 
     * @param array $filter [optional] WHERE conditions.
     * @param string $operator [optional] WHERE conditions concatenation operator.
     * @return UserCollectionInterface User collection.
     */
    public function find(array $filter = array(), $operator = 'AND');

    /**
     * Insert user.
     * 
     * @param UserInterface $user User.
     * @return UserInterface Inserted user (saved data may differ from initial user data).
     */
    public function insert(UserInterface $user);

    /**
     * Update user.
     * 
     * @param UserInterface $user User.
     * @return UserInterface Updated user (saved data may differ from initial user data).
     */
    public function update(UserInterface $user);

    /**
     * Delete user.
     * 
     * @param UserInterface $user User.
     * @return bool TRUE if user successfully deleted, FALSE otherwise.
     */
    public function delete(UserInterface $user);

    /**
     * Create user.
     * 
     * @param array $row Table row.
     * @return UserInterface User.
     */
    public function createUser(array $row);

    /**
     * Create user collection.
     * 
     * @param array $rows Table rows.
     * @return UserCollectionInterface User collection.
     */
    public function createUserCollection(array $rows);
}

适配器类:

<?php

namespace App\Core\Model\Storage\Adapter\Database\Pdo;

use PDO;
use PDOStatement;
use PDOException as Php_PDOException;
use App\Core\Exception\PDO\PDOException;
use App\Core\Exception\SPL\UnexpectedValueException;
use App\Core\Model\Storage\Adapter\Database\DatabaseAdapterInterface;

abstract class AbstractPdoAdapter implements DatabaseAdapterInterface {

    /**
     * Database connection.
     * 
     * @var PDO
     */
    private $connection;

    /**
     * Fetch mode for a PDO statement. Must be one of the PDO::FETCH_* constants.
     * 
     * @var int
     */
    private $fetchMode = PDO::FETCH_ASSOC;

    /**
     * Fetch argument for a PDO statement.
     * 
     * @var mixed 
     */
    private $fetchArgument = NULL;

    /**
     * Constructor arguments for a PDO statement when fetch mode is PDO::FETCH_CLASS.
     * 
     * @var array 
     */
    private $fetchConstructorArguments = array();

    /**
     * For a PDOStatement object representing a scrollable cursor, this value determines<br/>
     * which row will be returned to the caller.
     * 
     * @var int 
     */
    private $fetchCursorOrientation = PDO::FETCH_ORI_NEXT;

    /**
     * The absolute number of the row in the result set, or the row relative to the cursor<br/>
     * position before PDOStatement::fetch() was called.
     * 
     * @var int 
     */
    private $fetchCursorOffset = 0;

    /**
     * @param PDO $connection Database connection.
     */
    public function __construct(PDO $connection) {
        $this->setConnection($connection);
    }

    /**
     * Fetch data by executing a SELECT sql statement.
     * 
     * @param string $sql Sql statement.
     * @param array $bindings [optional] Input parameters.
     * @return array An array containing the rows in the result set, or FALSE on failure.
     */
    public function select($sql, array $bindings = array()) {
        $statement = $this->execute($sql, $bindings);
        $fetchArgument = $this->getFetchArgument();
        if (isset($fetchArgument)) {
            return $statement->fetchAll(
                            $this->getFetchMode()
                            , $fetchArgument
                            , $this->getFetchConstructorArguments()
            );
        }
        return $statement->fetchAll($this->getFetchMode());
    }

    /**
     * Fetch the next row from the result set by executing a SELECT sql statement.<br/>
     * The fetch mode property determines how PDO returns the row.
     * 
     * @param string $sql Sql statement.
     * @param array $bindings [optional] Input parameters.
     * @return array An array containing the rows in the result set, or FALSE on failure.
     */
    public function selectOne($sql, array $bindings = array()) {
        $statement = $this->execute($sql, $bindings);
        return $statement->fetch(
                        $this->getFetchMode()
                        , $this->getFetchCursorOrientation()
                        , $this->getFetchCursorOffset()
        );
    }

    /**
     * Store data by executing an INSERT sql statement.
     * 
     * @param string $sql Sql statement.
     * @param array $bindings [optional] Input parameters.
     * @return int The number of the affected records.
     */
    public function insert($sql, array $bindings = array()) {
        $statement = $this->execute($sql, $bindings);
        return $statement->rowCount();
    }

    /**
     * Update data by executing an UPDATE sql statement.
     * 
     * @param string $sql Sql statement.
     * @param array $bindings [optional] Input parameters.
     * @return int The number of the affected records.
     */
    public function update($sql, array $bindings = array()) {
        $statement = $this->execute($sql, $bindings);
        return $statement->rowCount();
    }

    /**
     * Delete data by executing a DELETE sql statement.
     * 
     * @param string $sql Sql statement.
     * @param array $bindings [optional] Input parameters.
     * @return int The number of the affected records.
     */
    public function delete($sql, array $bindings = array()) {
        $statement = $this->execute($sql, $bindings);
        return $statement->rowCount();
    }

    /**
     * Prepare and execute an sql statement.
     * 
     * @todo I want to re-use the statement to execute several queries with the same SQL statement 
     * only with different parameters. So make a statement field and prepare only once!
     * See: https://www.sitepoint.com/integrating-the-data-mappers/
     * 
     * @param string $sql Sql statement.
     * @param array $bindings [optional] Input parameters.
     * @return PDOStatement The PDO statement after execution.
     */
    protected function execute($sql, array $bindings = array()) {
        // Prepare sql statement.
        $statement = $this->prepareStatement($sql);

        // Bind input parameters.
        $this->bindInputParameters($statement, $bindings);

        // Execute prepared sql statement.
        $this->executePreparedStatement($statement);

        return $statement;
    }

    /**
     * Prepare and validate an sql statement.<br/>
     * 
     * ---------------------------------------------------------------------------------
     * If the database server cannot successfully prepare the statement,
     * PDO::prepare() returns FALSE or emits PDOException (depending on error handling).
     * ---------------------------------------------------------------------------------
     * 
     * @param string $sql Sql statement.
     * @return PDOStatement If the database server successfully prepares the statement,
     * return a PDOStatement object. Otherwise return FALSE or emit PDOException 
     * (depending on error handling).
     * @throws Php_PDOException
     * @throws PDOException
     */
    private function prepareStatement($sql) {
        try {
            $statement = $this->getConnection()->prepare($sql);
            if (!$statement) {
                throw new PDOException('The sql statement can not be prepared!');
            }
        } catch (Php_PDOException $exc) {
            throw new PDOException('The sql statement can not be prepared!', 0, $exc);
        }
        return $statement;
    }

    /**
     * Bind the input parameters to a prepared PDO statement.
     * 
     * @param PDOStatement $statement PDO statement.
     * @param array $bindings Input parameters.
     * @return $this
     */
    private function bindInputParameters($statement, $bindings) {
        foreach ($bindings as $key => $value) {
            $statement->bindValue(
                    $this->getInputParameterName($key)
                    , $value
                    , $this->getInputParameterDataType($value)
            );
        }
        return $this;
    }

    /**
     * Get the name of an input parameter by its key in the bindings array.
     *  
     * @param int|string $key The key of the input parameter in the bindings array.
     * @return int|string The name of the input parameter.
     */
    private function getInputParameterName($key) {
        return is_int($key) ? ($key + 1) : (':' . ltrim($key, ':'));
    }

    /**
     * Get the PDO::PARAM_* constant, e.g the data type of an input parameter, by its value.
     *  
     * @param mixed $value Value of the input parameter.
     * @return int The PDO::PARAM_* constant.
     */
    private function getInputParameterDataType($value) {
        $dataType = PDO::PARAM_STR;
        if (is_int($value)) {
            $dataType = PDO::PARAM_INT;
        } elseif (is_bool($value)) {
            $dataType = PDO::PARAM_BOOL;
        }
        return $dataType;
    }

    /**
     * Execute a prepared PDO statement.
     * 
     * @param PDOStatement $statement PDO statement.
     * @return $this
     * @throws UnexpectedValueException
     */
    private function executePreparedStatement($statement) {
        if (!$statement->execute()) {
            throw new UnexpectedValueException('The statement can not be executed!');
        }
        return $this;
    }

    /**
     * Get the ID of the last inserted row or of the sequence value.
     * 
     * @param string $sequenceObjectName [optional] Name of the sequence object<br/>
     * from which the ID should be returned.
     * @return string The ID of the last row, or the last value retrieved from the specified<br/>
     * sequence object, or an error IM001 SQLSTATE If the PDO driver does not support this.
     */
    public function getLastInsertId($sequenceObjectName = NULL) {
        return $this->getConnection()->lastInsertId($sequenceObjectName);
    }

    public function getConnection() {
        return $this->connection;
    }

    public function setConnection(PDO $connection) {
        $this->connection = $connection;
        return $this;
    }

    public function getFetchMode() {
        return $this->fetchMode;
    }

    public function setFetchMode($fetchMode) {
        $this->fetchMode = $fetchMode;
        return $this;
    }

    public function getFetchArgument() {
        return $this->fetchArgument;
    }

    public function setFetchArgument($fetchArgument) {
        $this->fetchArgument = $fetchArgument;
        return $this;
    }

    public function getFetchConstructorArguments() {
        return $this->fetchConstructorArguments;
    }

    public function setFetchConstructorArguments($fetchConstructorArguments) {
        $this->fetchConstructorArguments = $fetchConstructorArguments;
        return $this;
    }

    public function getFetchCursorOrientation() {
        return $this->fetchCursorOrientation;
    }

    public function setFetchCursorOrientation($fetchCursorOrientation) {
        $this->fetchCursorOrientation = $fetchCursorOrientation;
        return $this;
    }

    public function getFetchCursorOffset() {
        return $this->fetchCursorOffset;
    }

    public function setFetchCursorOffset($fetchCursorOffset) {
        $this->fetchCursorOffset = $fetchCursorOffset;
        return $this;
    }

}

关于你的第一个问题:关于你应该在哪里存储你的类没有约定。选择您想要的任何文件系统结构。但请确保:

1) 您正在使用自动加载器和命名空间,正如 PSR-4 Autoloading Standard 中所建议的那样。

2) 您可以随时唯一标识每个组件类。您可以通过两种方式实现此目的:通过为每个类应用相应的后缀(UserControllerUserMapperUserView 等),或者通过在 use 语句中定义相应的类别名,例如:

namespace App\Controllers;

use App\Models\DomainObjects\User;
use App\Models\Mappers\User as UserMapper;
use App\Models\Repositories\User as UserRepository;

文件系统结构可能如下所示 - 这是我的项目中使用的结构,如果乍一看太复杂,请见谅:

App/Core:

App/:

祝你好运!

【讨论】:

  • 感谢您的详细解答和丰富的资源。这会让我忙一阵子;)
  • @TimMorton Tim,最后我用真正的 SQL 替换了数据映射器中的查询构建器外观。当然,可以优化它们的结构以满足您的项目需求。通过这种方式,您可以更清楚地了解如何在多个表上应用多个条件,通过迭代域对象类成员,例如特性。请注意,我没有将它们命名为“字段”,以避免混淆“字段=数据库字段”。对象属性就是这样!它们不是数据库字段。但它们被映射到数据库字段。
猜你喜欢
  • 1970-01-01
  • 2012-08-02
  • 2011-07-12
  • 1970-01-01
  • 1970-01-01
  • 2012-07-29
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多