M 模型

试想一下,我们的书店网站相当成功,因此我们想建立一个移动应用程序来扩大我们的市场。当然,我们希望使用与网站相同的数据库,因为我们需要同步人们在两个应用程序中借阅或购买的图书。我们不希望出现两个人购买的最后一本书相同的情况!

不仅是数据库,用于获取图书、更新图书等的查询也必须相同,否则就会出现意想不到的行为。当然,一个看似简单的办法是在两个代码库中复制查询,但这样做会带来很大的可维护性问题。如果我们更改了数据库中的一个字段怎么办?我们至少需要在两个不同的代码库中应用相同的更改。这似乎一点用处都没有。

业务逻辑在这里也扮演着重要角色。把它看作是你需要做出的影响业务的决定。在我们的案例中,高级客户可以借 10 本书,普通客户只能借 3 本,这就是业务逻辑。这个逻辑也应该放在一个普通的地方,因为如果我们想改变它,就会遇到和数据库查询一样的问题。

希望我们现在已经说服了你,数据和业务逻辑应与代码的其他部分分开,以便使其可重复使用。如果您很难确定哪些应该作为模型的一部分,哪些应该作为控制器的一部分,请不要担心;很多人都在为这种区分而苦恼。由于我们的应用程序非常简单,也没有很多业务逻辑,因此我们将只专注于添加与 MySQL 查询相关的所有代码。

可以想象,对于集成了 MySQL 或其他数据库系统的应用程序来说,数据库连接是模型的一个重要元素。我们选择使用 PDO 来与 MySQL 进行交互,你可能还记得,实例化这个类有点麻烦。让我们创建一个返回 PDO 实例的单例类,让事情变得更简单。将此代码添加到 src/Core/Db.php

<?php

namespace Bookstore\Core;

use PDO;

class Db {
    private static $instance;

    private static function connect(): PDO {
        $dbConfig = Config::getInstance()->get('db');
        return new PDO(
            'mysql:host=127.0.0.1;dbname=bookstore',
            $dbConfig['user'],
            $dbConfig['password']
        );
    }

    public static function getInstance(){
        if (self::$instance == null) {
            self::$instance = self::connect();
        }
        return self::$instance;
    }
}

在前面的代码片段中定义的这个类只是实现了单例模式,并封装了 PDO 实例的创建。从现在起,为了获取数据库连接,我们只需编写 Db::getInstance()

虽然并非所有模型都需要这样做,但在我们的应用程序中,它们总是需要访问数据库。我们可以创建一个抽象类,所有模型都可以在这个类中扩展。该类可以包含一个 $db 保护属性,该属性将在构造函数中设置。这样,我们就可以避免在所有模型中重复使用相同的构造函数和属性定义。将以下类复制到 src/Models/AbstractModel.php 中:

<?php

namespace Bookstore\Models;

use PDO;

abstract class AbstractModel {
    private $db;

    public function __construct(PDO $db) {
        $this->db = $db;
    }
}

最后,为了完成模型的设置,我们可以创建一个新的异常(就像我们在 NotFoundException 类中所做的),它代表了来自数据库的错误。它将不包含任何代码,但我们可以区分异常来自何处。我们将把它保存在 src/Exceptions/DbException.php

<?php

namespace Bookstore\Exceptions;

use Exception;

class DbException extends Exception {
}

现在我们已经奠定了基础,可以开始编写模型了。如何组织模型由您自己决定,但模仿领域对象结构是个好主意。在这种情况下,我们将有三个模型:客户模型(CustomerModel)、图书模型(BookModel)和销售模型(SalesModel)。在下面的章节中,我们将解释每个模型的内容。

customer 模型

让我们从最简单的开始。由于我们的应用程序还很原始,我们将不允许创建新的客户,而是使用我们手动插入到数据库中的客户。这意味着我们唯一需要做的就是查询客户。让我们在 src/Models/CustomerModel.php 中创建一个 CustomerModel 类,内容如下:

<?php

namespace Bookstore\Models;

use Bookstore\Domain\Customer;
use Bookstore\Domain\Customer\CustomerFactory;
use Bookstore\Exceptions\NotFoundException;

class CustomerModel extends AbstractModel {
    public function get(int $userId): Customer {
        $query = 'SELECT * FROM customer WHERE customer_id = :user';
        $sth = $this->db->prepare($query);
        $sth->execute(['user' => $userId]);

        $row = $sth->fetch();

        if (empty($row)) {
            throw new NotFoundException();
        }

        return CustomerFactory::factory(
            $row['type'],
            $row['id'],
            $row['firstname'],
            $row['surname'],
            $row['email']
        );
    }

    public function getByEmail(string $email): Customer {
        $query = 'SELECT * FROM customer WHERE email = :user';
        $sth = $this->db->prepare($query);
        $sth->execute(['user' => $email]);

        $row = $sth->fetch();
        if (empty($row)) {
            throw new NotFoundException();
        }

        return CustomerFactory::factory(
            $row['type'],
            $row['id'],
            $row['firstname'],
            $row['surname'],
            $row['email']
        );
    }
}

CustomerModel 类从 AbstractModel 类扩展而来,包含两个方法;这两个方法都返回一个 Customer 实例,其中一个方法在提供客户 ID 时返回,另一个方法在提供电子邮件时返回。由于我们已经有了作为 $db 属性的数据库连接,我们只需用给定的查询准备语句,执行带有参数的语句,并获取结果。如果用户提供的 ID 或电子邮件不属于任何客户,我们就需要抛出异常—​在这种情况下,抛出 NotFoundException 异常就可以了。如果找到了客户,我们就使用工厂创建对象并返回。

book 模型

我们的 BookModel 类给了我们更多的工作。客户有一个工厂,但不值得为书籍建立一个工厂。我们从 MySQL 行中创建书籍时使用的不是构造函数,而是 PDO 的获取模式,它允许我们将行映射到对象中。为此,我们需要对 Book 域对象进行一些调整:

  • 属性的名称必须与数据库中字段的名称相同

  • 没有必要使用构造器或设置器,除非我们出于其他目的需要使用它们

  • 为了实现封装,属性应该是私有的,因此我们需要为所有属性设置获取器

新的 Book 类应如下所示:

<?php

namespace Bookstore\Domain;

class Book {
    private $id;
    private $isbn;
    private $title;
    private $author;
    private $stock;
    private $price;

    public function getId(): int {
        return $this->id;
    }

    public function getIsbn(): string {
        return $this->isbn;
    }

    public function getTitle(): string {
        return $this->title;
    }

    public function getAuthor(): string {
        return $this->author;
    }

    public function getStock(): int {
        return $this->stock;
    }

    public function getCopy(): bool {
        if ($this->stock < 1) {
            return false;
        } else {
            $this->stock--;
            return true;
        }
    }

    public function addCopy() {
        $this->stock++;
    }

    public function getPrice(): float {
        return $this->price;
    }
}

我们保留了 getCopy 和 addCopy 方法,尽管它们不是获取器,因为我们稍后会用到它们。现在,当使用 fetchAll 方法从 MySQL 抓取一组记录时,我们可以发送两个参数:常量 PDO::FETCH_CLASS(告诉 PDO 将记录映射到一个类)和我们要映射到的类的名称。让我们创建一个带有简单 get 方法的 BookModel 类,该方法可以从数据库中获取具有给定 ID 的图书。该方法将返回一个 Book 对象,或者在 ID 不存在的情况下抛出一个异常。将其保存为 src/Models/BookModel.php

<?php

namespace Bookstore\Models;

use Bookstore\Domain\Book;
use Bookstore\Exceptions\DbException;
use Bookstore\Exceptions\NotFoundException;
use PDO;

class BookModel extends AbstractModel {
    const CLASSNAME = '\Bookstore\Domain\Book';

    public function get(int $bookId): Book {
        $query = 'SELECT * FROM book WHERE id = :id';
        $sth = $this->db->prepare($query);
        $sth->execute(['id' => $bookId]);

        $books = $sth->fetchAll(
            PDO::FETCH_CLASS, self::CLASSNAME
        );
        if (empty($books)) {
            throw new NotFoundException();
        }
        return $books[0];
    }
}

使用这种获取模式有利有弊。一方面,在从行创建对象时,我们可以避免大量枯燥的代码。通常,我们要么将行数组的所有元素发送给类的构造函数,要么为其所有属性使用设置器。如果我们在 MySQL 表中添加了更多字段,只需在域类中添加属性即可,而不必在实例化对象时到处更改。另一方面,你不得不在表和类的属性中使用相同的字段名,这意味着高度耦合(总是个坏主意)。这也会在遵循惯例时造成一些冲突,因为在 MySQL 中,通常使用 book_id,但在 PHP 中,属性是 $bookId

现在我们知道了这种获取模式是如何工作的,让我们添加另外三个从 MySQL 获取数据的方法。在模型中添加以下代码:

public function getAll(int $page, int $pageLength): array {
    $start = $pageLength * ($page - 1);

    $query = 'SELECT * FROM book LIMIT :page, :length';
    $sth = $this->db->prepare($query);
    $sth->bindParam('page', $start, PDO::PARAM_INT);
    $sth->bindParam('length', $pageLength, PDO::PARAM_INT);
    $sth->execute();

    return $sth->fetchAll(PDO::FETCH_CLASS, self::CLASSNAME);
}

public function getByUser(int $userId): array {
    $query = <<<SQL
SELECT b.*
FROM borrowed_books bb LEFT JOIN book b ON bb.book_id = b.id
WHERE bb.customer_id = :id
SQL;
    $sth = $this->db->prepare($query);
    $sth->execute(['id' => $userId]);

    return $sth->fetchAll(PDO::FETCH_CLASS, self::CLASSNAME);
}

public function search(string $title, string $author): array {
    $query = <<<SQL
SELECT * FROM book
WHERE title LIKE :title AND author LIKE :author
SQL;
    $sth = $this->db->prepare($query);
    $sth->bindValue('title', "%$title%");
    $sth->bindValue('author', "%$author%");
    $sth->execute();

    return $sth->fetchAll(PDO::FETCH_CLASS, self::CLASSNAME);
}

添加的方法如下:

  • getAll 返回一个数组,包含给定页面的所有书籍。请记住,LIMIT 允许您返回带有偏移量的特定行数,这可以用作分页器。

  • getByUser 返回给定客户借阅过的所有图书—​我们需要使用连接查询。请注意,我们返回的是 b.*,即只返回图书表中的字段,跳过其他字段。

  • 最后,有一种方法可以按书名或作者或两者进行搜索。我们可以使用操作符 LIKE 并用 % 括住模式。如果不指定其中一个参数,我们将尝试用 %% 匹配字段,这样就能匹配所有字段。

到目前为止,我们一直在添加获取数据的方法。让我们添加一些方法,以便修改数据库中的数据。对于图书模型,我们需要能够借书和还书。下面是这两个操作的代码:

public function borrow(Book $book, int $userId) {
    $query = <<<SQL
INSERT INTO borrowed_books (book_id, customer_id, start)
VALUES(:book, :user, NOW())
SQL;
    $sth = $this->db->prepare($query);
    $sth->bindValue('book', $book->getId());
    $sth->bindValue('user', $userId);
    if (!$sth->execute()) {
        throw new DbException($sth->errorInfo()[2]);
    }
    $this->updateBookStock($book);
}

public function returnBook(Book $book, int $userId) {
    $query = <<<SQL
UPDATE borrowed_books SET end = NOW()
WHERE book_id = :book AND customer_id = :user AND end IS NULL
SQL;
    $sth = $this->db->prepare($query);
    $sth->bindValue('book', $book->getId());
    $sth->bindValue('user', $userId);
    if (!$sth->execute()) {
        throw new DbException($sth->errorInfo()[2]);
    }
    $this->updateBookStock($book);
}

private function updateBookStock(Book $book) {
    $query = 'UPDATE book SET stock = :stock WHERE id = :id';
    $sth = $this->db->prepare($query);
    $sth->bindValue('id', $book->getId());
    $sth->bindValue('stock', $book->getStock());
    if (!$sth->execute()) {
        throw new DbException($sth->errorInfo()[2]);
    }
}

借书时,会在 borrower_books 表中添加一行。还书时,您不想删除该行,而是要设置结束日期,以便保留用户借书的历史记录。这两种方法都需要改变借阅图书的存量:借阅时,存量减少 1;归还时,存量增加。因此,在最后一段代码中,我们创建了一个私有方法来更新指定图书的存量,borrowreturnBook 方法都将使用该方法。

sales 模型

现在,我们需要为应用程序添加最后一个模型:SalesModel。使用与书籍相同的获取模式,我们还需要调整域类。在这种情况下,我们需要考虑得更多一些,因为我们要做的不仅仅是获取。我们的应用程序必须能够按需创建新的销售,其中包含客户的 ID 和书籍。通过当前的实现,我们已经可以添加书籍,但还需要为客户 ID 添加一个设置器。销售 ID 将由 MySQL 中的自动增量 ID 提供,因此无需为其添加设置器。最终实现如下

<?php

namespace Bookstore\Domain;

class Sale {
    private $id;
    private $customer_id;
    private $books;
    private $date;

    public function setCustomerId(int $customerId) {
        $this->customer_id = $customerId;
    }

    public function getId(): int {
        return $this->id;
    }

    public function getCustomerId(): int {
        return $this->customer_id;
    }

    public function getBooks(): array {
        return $this->books;
    }

    public function getDate(): string {
        return $this->date;
    }

    public function addBook(int $bookId, int $amount = 1) {
        if (!isset($this->books[$bookId])) {
            $this->books[$bookId] = 0;
        }
        $this->books[$bookId] += $amount;
    }

    public function setBooks(array $books) {
        $this->books = $books;
    }
}

SalesModel 将是最难编写的模型。这个模型的问题在于它包括操作不同的表:salesale_book。例如,在获取销售信息时,我们需要从 sale 表中获取信息,然后再从 sale_book 表中获取所有书籍的信息。您可以讨论是使用一个唯一的方法获取与销售相关的所有必要信息,还是使用两个不同的方法,一个获取销售信息,另一个获取书籍信息,然后让控制器决定使用哪个方法。

这实际上引发了一场非常有趣的讨论。一方面,我们想让控制器的工作更轻松—​使用一个唯一的方法来获取整个销售对象。这是有道理的,因为控制器无需了解销售对象的内部实现,从而降低了耦合度。另一方面,强迫模型总是获取整个对象(即使我们只需要销售表中的信息)是个坏主意。试想一下,如果销售表中包含大量书籍,那么从 MySQL 中获取这些书籍将不必要地降低性能。

你应该考虑一下你的控制器需要如何管理销售。如果您总是需要整个对象,您可以使用一个方法,而不必担心性能问题。如果有时只需要获取整个对象,也许可以同时添加两个方法。对于我们的应用程序,我们将使用一个方法来管理所有方法,因为这是我们始终需要的。

懒加载

与任何其他设计挑战一样,其他开发人员已经对这个问题进行了很多思考。他们提出了一种名为延迟加载的设计模式。这种模式基本上让控制器认为只有一种方法可以获取整个域对象,但实际上我们只会从数据库中获取我们需要的内容。

该模型获取对象最常用的信息,并将需要额外数据库查询的其余属性留空。一旦控制器使用空属性的 getter,模型就会自动从数据库中获取该数据。我们得到了两全其美的好处:控制器很简单,但我们不会花费超过必要的时间来查询未使用的数据。

添加以下内容作为您的 src/Models/SaleModel.php 文件:

<?php
namespace Bookstore\Models;

use Bookstore\Domain\Sale;
use Bookstore\Exceptions\DbException;
use PDO;

class SaleModel extends AbstractModel {
    const CLASSNAME = '\Bookstore\Domain\Sale';

    public function getByUser(int $userId): array {
        $query = 'SELECT * FROM sale WHERE s.customer_id = :user';
        $sth = $this->db->prepare($query);
        $sth->execute(['user' => $userId]);

        return $sth->fetchAll(PDO::FETCH_CLASS, self::CLASSNAME);
    }

    public function get(int $saleId): Sale {
        $query = 'SELECT * FROM sale WHERE id = :id';
        $sth = $this->db->prepare($query);
        $sth->execute(['id' => $saleId]);
        $sales = $sth->fetchAll(PDO::FETCH_CLASS, self::CLASSNAME);

        if (empty($sales)) {
            throw new NotFoundException('Sale not found.');
        }
        $sale = array_pop($sales);

        $query = <<<SQL
SELECT b.id, b.title, b.author, b.price, sb.amount as stock, b.isbn
FROM sale s
LEFT JOIN sale_book sb ON s.id = sb.sale_id
LEFT JOIN book b ON sb.book_id = b.id
WHERE s.id = :id
SQL;
        $sth = $this->db->prepare($query);
        $sth->execute(['id' => $saleId]);
        $books = $sth->fetchAll(
            PDO::FETCH_CLASS, BookModel::CLASSNAME
        );
        $sale->setBooks($books);
        return $sale;
    }
}

该模型中另一个棘手的方法是在数据库中创建销售。该方法必须在 sale 表中创建一个销售,然后将该销售的所有书籍添加到 sale_book 表中。如果在添加其中一本书时出现问题,会发生什么情况呢?我们会在数据库中留下一个损坏的销售。为了避免出现这种情况,我们需要使用事务,在模型或控制器方法的开头使用一个事务,并在出现错误时进行回滚,或在方法结束时提交事务。

在同一方法中,我们还需要注意销售的 ID。在创建 sale 对象时,我们不会设置销售 ID,因为我们依赖于数据库中的自动递增字段。但在向 sale_book 插入书籍时,我们确实需要销售的 ID。为此,我们需要使用 lastInsertId 方法向 PDO 请求最后插入的 ID。然后,让我们在 SaleModel 中添加 create 方法:

public function create(Sale $sale) {
    $this->db->beginTransaction();

    $query = <<<SQL
INSERT INTO sale(customer_id, date)
VALUES(:id, NOW())
SQL;
    $sth = $this->db->prepare($query);
    if (!$sth->execute(['id' => $sale->getCustomerId()])) {
        $this->db->rollBack();
        throw new DbException($sth->errorInfo()[2]);
    }

    $saleId = $this->db->lastInsertId();
    $query = <<<SQL
INSERT INTO sale_book(sale_id, book_id, amount)
VALUES(:sale, :book, :amount)
SQL;
    $sth = $this->db->prepare($query);
    $sth->bindValue('sale', $saleId);
    foreach ($sale->getBooks() as $bookId => $amount) {
        $sth->bindValue('book', $bookId);
        $sth->bindValue('amount', $amount);
        if (!$sth->execute()) {
            $this->db->rollBack();
            throw new DbException($sth->errorInfo()[2]);
        }
    }

    $this->db->commit();
}

这个方法最后要注意的一点是,我们要准备一条语句,绑定一个值(销售 ID),然后根据数组中书籍的数量绑定并执行同一条语句。一旦有了语句,就可以根据需要多次绑定值。此外,您还可以多次执行相同的语句,而且值保持不变。