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;归还时,存量增加。因此,在最后一段代码中,我们创建了一个私有方法来更新指定图书的存量,borrow
和 returnBook
方法都将使用该方法。
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
将是最难编写的模型。这个模型的问题在于它包括操作不同的表:sale
和 sale_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),然后根据数组中书籍的数量绑定并执行同一条语句。一旦有了语句,就可以根据需要多次绑定值。此外,您还可以多次执行相同的语句,而且值保持不变。