C 控制器
终于到了乐队指挥的时候了。控制器代表了应用程序中的一层,它可以在收到请求时与模型对话并构建视图。他们就像一个团队的管理者:根据情况决定使用哪些资源。
正如我们在解释模型时所说的,有时很难决定某些逻辑应该放在控制器还是模型中。归根结底,MVC 是一种模式,就像指导你的食谱,而不是你需要一步步遵循的精确算法。在有些情况下,答案并不直接明了,这就要靠你了;在这种情况下,尽量保持一致即可。以下是一些可能难以本地化的常见情况:
-
请求指向我们不支持的路径。这种情况在我们的应用程序中已经涉及,应该由路由器而不是控制器来处理。
-
请求试图访问不存在的元素,例如数据库中不存在的图书 ID。在这种情况下,控制器应询问模型该书是否存在,并根据响应渲染一个包含该书内容的模板或另一个包含 "未找到 "信息的模板。
-
用户试图执行一项操作,如购买图书,但请求中的参数无效。这是一个棘手的问题。一种方法是从请求中获取所有参数,而不对其进行检查,直接将其发送到模型,并将净化信息的任务留给模型。另一种方法是,控制器检查所提供的参数是否合理,然后将其发送给模型。还有其他解决方案,比如构建一个类来检查参数是否有效,这个类可以在不同的控制器中重复使用。在这种情况下,这将取决于参数的数量和净化所涉及的逻辑。对于接收大量数据的请求,第三种方案看起来是最好的,因为我们可以在不同的端点中重复使用代码,而且不会编写太长的控制器。但对于用户只发送一两个参数的请求,在控制器中对它们进行消毒可能就足够了。
现在,我们已经完成了基础设置,让我们准备好让应用程序使用控制器。要做的第一件事就是更新我们的 index.php
,因为它一直在强迫应用程序渲染相同的模板。相反,我们应该把这项任务交给路由器,它将以字符串的形式返回响应,我们只需用 echo 打印即可。用以下内容更新 index.php
文件:
<?php
use Bookstore\Core\Router;
use Bookstore\Core\Request;
require_once __DIR__ . '/vendor/autoload.php';
$router = new Router();
$response = $router->route(new Request());
echo $response;
你可能还记得,路由器会实例化一个控制器类,将请求对象发送给构造函数。但控制器也有其他依赖关系,如模板引擎、数据库连接或配置读取器。尽管这并不是最好的解决方案(在下一节介绍依赖注入后,我们将改进这一方案),但我们可以创建一个抽象控制器(AbstractController
),作为所有控制器的父类,并设置这些依赖关系。将以下内容复制到 src/Controllers/AbstractController.php
:
<?php
namespace Bookstore\Controllers;
use Bookstore\Core\Config;
use Bookstore\Core\Db;
use Bookstore\Core\Request;
use Monolog\Logger;
use Twig_Environment;
use Twig_Loader_Filesystem;
use Monolog\Handler\StreamHandler;
abstract class AbstractController {
protected $request;
protected $db;
protected $config;
protected $view;
protected $log;
public function __construct(Request $request) {
$this->request = $request;
$this->db = Db::getInstance();
$this->config = Config::getInstance();
$loader = new Twig_Loader_Filesystem(
__DIR__ . '/../../views'
);
$this->view = new Twig_Environment($loader);
$this->log = new Logger('bookstore');
$logFile = $this->config->get('log');
$this->log->pushHandler(
new StreamHandler($logFile, Logger::DEBUG)
);
}
public function setCustomerId(int $customerId) {
$this->customerId = $customerId;
}
}
在实例化控制器时,我们将设置一些在处理请求时有用的属性。我们已经知道如何实例化数据库连接、配置阅读器和模板引擎。第四个属性 $log
将允许开发人员在必要时将日志写入指定文件。为此,我们将使用 Monolog
库,但也有许多其他选择。请注意,为了实例化日志记录器,我们需要从配置中获取 log
的值,也就是日志文件的路径。惯例是使用 /var/log/
目录,因此要创建 /var/log/bookstore.log
文件,并添加 "log":"/var/log/bookstore.log"
。
另一个对某些控制器(并非所有控制器)有用的信息是执行操作的用户信息。由于该信息只适用于某些路由,因此我们在构建控制器时不应设置该信息。相反,我们为路由器提供了一个设置器,以便在可用时设置客户 ID;事实上,路由器已经在这样做了。
最后,我们还可以使用一个方便的辅助方法,那就是渲染带有参数的给定模板,因为所有控制器最终都会渲染一种或另一种模板。让我们在 AbstractController
类中添加以下保护方法:
protected function render(string $template, array $params): string {
return $this->view->loadTemplate($template)->render($params);
}
error 控制器
让我们先创建最简单的控制器:ErrorController
。这个控制器的作用不大,它只是渲染 error.twig
模板,发送 "页面未找到!" 消息。你可能还记得,当路由器无法将请求匹配到任何其他已定义的路由时,它就会使用该控制器。在 src/Controllers/ErrorController.php
中保存以下类:
<?php
namespace Bookstore\Controllers;
class ErrorController extends AbstractController {
public function notFound(): string {
$properties = ['errorMessage' => 'Page not found!'];
return $this->render('error.twig', $properties);
}
}
login 控制器
我们要添加的第二个控制器是管理客户登录的控制器。如果我们考虑一下用户要进行身份验证时的流程,我们会遇到以下情况:
-
用户希望获取登录表单,以便提交必要信息并登录。
-
用户尝试提交表单,但我们无法获取电子邮件地址。我们应该再次渲染表单,让用户了解问题所在。
-
用户使用电子邮件提交表单,但该电子邮件无效。在这种情况下,我们应再次显示登录表单,并在错误信息中说明情况。
-
用户提交了有效的电子邮件,我们设置了 cookie,并显示了图书列表,这样用户就可以开始搜索了。这完全是任意的;你可以选择将他们发送到借书页面、销售页面等等。重要的是,我们将把请求重定向到另一个控制器。
可能的路径最多有四种。我们将使用 request
对象来决定在每种情况下使用哪种路径,并返回相应的响应。让我们在 src/Controllers/CustomerController.php
中创建带有登录方法的 CustomerController
类,如下所示:
<?php
namespace Bookstore\Controllers;
use Bookstore\Exceptions\NotFoundException;
use Bookstore\Models\CustomerModel;
class CustomerController extends AbstractController {
public function login(string $email): string {
if (!$this->request->isPost()) {
return $this->render('login.twig', []);
}
$params = $this->request->getParams();
if (!$params->has('email')) {
$params = ['errorMessage' => 'No info provided.'];
return $this->render('login.twig', $params);
}
$email = $params->getString('email');
$customerModel = new CustomerModel($this->db);
try {
$customer = $customerModel->getByEmail($email);
} catch (NotFoundException $e) {
$this->log->warn('Customer email not found: ' . $email);
$params = ['errorMessage' => 'Email not found.'];
return $this->render('login.twig', $params);
}
setcookie('user', $customer->getId());
$newController = new BookController($this->request);
return $newController->getAll();
}
}
正如您所看到的,四种不同的情况有四种不同的返回值。控制器本身不做任何事情,但会协调其他组件并做出决定。首先,我们会检查请求是否为 POST,如果不是,我们会假设用户想要获取表单。如果是,我们将检查参数中是否有电子邮件,如果没有,则返回错误信息。如果有,我们将尝试使用我们的模型找到使用该电子邮件的客户。如果出现异常,说明没有这样的客户,我们就会在表单中显示 "未找到" 的错误信息。如果登录成功,我们将用该客户的 ID 设置 cookie,并执行 BookController
的 getAll
方法(仍待编写),返回书籍列表。
至此,您应该可以通过浏览器端对端测试应用程序的登录功能了。尝试访问 http://localhost:8000/login 查看表单,添加随机电子邮件以获取错误信息,添加有效电子邮件(检查 MySQL 中的 customer
表)以成功登录。之后,您应该会看到包含客户 ID 的 cookie。
book 控制器
BookController
类将是我们最大的控制器,因为大部分应用程序都依赖于它。让我们先添加最简单的方法,也就是从数据库中获取信息的方法。将其保存为 src/Controllers/BookController.php
:
<?php
namespace Bookstore\Controllers;
use Bookstore\Models\BookModel;
class BookController extends AbstractController {
const PAGE_LENGTH = 10;
public function getAllWithPage($page): string {
$page = (int)$page;
$bookModel = new BookModel($this->db);
$books = $bookModel->getAll($page, self::PAGE_LENGTH);
$properties = [
'books' => $books,
'currentPage' => $page,
'lastPage' => count($books) < self::PAGE_LENGTH
];
return $this->render('books.twig', $properties);
}
public function getAll(): string {
return $this->getAllWithPage(1);
}
public function get(int $bookId): string {
$bookModel = new BookModel($this->db);
try {
$book = $bookModel->get($bookId);
} catch (\Exception $e) {
$this->log->error(
'Error getting book: ' . $e->getMessage()
);
$properties = ['errorMessage' => 'Book not found!'];
return $this->render('error.twig', $properties);
}
$properties = ['book' => $book];
return $this->render('book.twig', $properties);
}
public function getByUser(): string {
$bookModel = new BookModel($this->db);
$books = $bookModel->getByUser($this->customerId);
$properties = [
'books' => $books,
'currentPage' => 1,
'lastPage' => true
];
return $this->render('books.twig', $properties);
}
}
到目前为止,前面的代码并没有什么特别之处。getAllWithPage 和 getAll 方法做着同样的事情,其中一个方法将用户提供的页码作为 URL 参数,另一个方法将页码设置为 1—默认情况。它们要求模型提供要显示的图书列表并传递给视图。当前页面的信息—以及我们是否在最后一页—也会被发送到模板,以便添加 "上一页" 和 "下一页" 链接。
get 方法将获取客户感兴趣的图书的 ID。它将尝试使用模型获取。如果模型出现异常,我们将在错误模板中显示 "未找到图书" 信息。相反,如果图书 ID 有效,我们将按照预期渲染图书模板。
getByUser 方法将返回已通过身份验证的客户借阅的所有图书。我们将使用在路由器中设置的 customerId 属性。这里没有合理性检查,因为我们不是要获取特定的图书,而是获取一个列表,如果用户尚未借阅任何图书,该列表可能是空的,但这不是问题。
另一个获取控制器是根据书名和/或作者搜索图书的控制器。该方法将在用户提交布局模板中的表单时触发。表单会同时发送标题和作者字段,因此控制器会询问这两个字段。模型已准备好使用为空的参数,因此我们不会在此执行任何额外的检查。将该方法添加到 BookController 类中:
public function search(): string {
$title = $this->request->getParams()->getString('title');
$author = $this->request->getParams()->getString('author');
$bookModel = new BookModel($this->db);
$books = $bookModel->search($title, $author);
$properties = [
'books' => $books,
'currentPage' => 1,
'lastPage' => true
];
return $this->render('books.twig', $properties);
}
您的应用程序无法执行任何操作,但至少您最终可以浏览书籍列表,并单击其中任何一本来查看详细信息。我们终于在这里得到一些东西了!
借书
借书和还书可能是涉及逻辑最多的操作,还有买书,这将由另一个控制器来处理。这是开始记录用户操作的好地方,因为这对以后的调试很有用。让我们先看看代码,然后再进行简要讨论。在 BookController
类中添加以下两个方法:
public function borrow(int $bookId): string {
$bookModel = new BookModel($this->db);
try {
$book = $bookModel->get($bookId);
} catch (NotFoundException $e) {
$this->log->warn('Book not found: ' . $bookId);
$params = ['errorMessage' => 'Book not found.'];
return $this->render('error.twig', $params);
}
if (!$book->getCopy()) {
$params = [
'errorMessage' => 'There are no copies left.'
];
return $this->render('error.twig', $params);
}
try {
$bookModel->borrow($book, $this->customerId);
} catch (DbException $e) {
$this->log->error(
'Error borrowing book: ' . $e->getMessage()
);
$params = ['errorMessage' => 'Error borrowing book.'];
return $this->render('error.twig', $params);
}
return $this->getByUser();
}
public function returnBook(int $bookId): string {
$bookModel = new BookModel($this->db);
try {
$book = $bookModel->get($bookId);
} catch (NotFoundException $e) {
$this->log->warn('Book not found: ' . $bookId);
$params = ['errorMessage' => 'Book not found.'];
return $this->render('error.twig', $params);
}
$book->addCopy();
try {
$bookModel->returnBook($book, $this->customerId);
} catch (DbException $e) {
$this->log->error(
'Error returning book: ' . $e->getMessage()
);
$params = ['errorMessage' => 'Error returning book.'];
return $this->render('error.twig', $params);
}
return $this->getByUser();
}
正如我们之前提到的,这里的新功能之一就是记录用户的操作,比如尝试借阅或归还无效图书。Monolog 允许你编写不同优先级的日志:错误、警告和通知。您可以调用错误、警告或通知等方法来引用每一种日志。当发生意外但非关键的事情时,我们会使用警告,例如,试图借阅一本不存在的书。当出现无法挽回的未知问题时,比如数据库出错,我们就会使用错误。
这两种方法的工作方式如下:我们从 3d 数据库中获取具有给定图书 ID 的图书对象。与往常一样,如果没有这样的书,我们会返回一个错误页面。获得图书域对象后,我们使用 addCopy 和 getCopy 助手更新图书的库存,并将其与客户 ID 一起发送给模型,以便将信息存储到数据库中。在借书时,我们还会进行合理性检查,以防没有更多的书可用。在这两种情况下,我们都会将用户借阅的图书列表作为控制器的响应返回。
sales 控制器
我们来到最后一个控制器:SalesController
。有了不同的模型,它最终要做的事情与借书相关的方法基本相同。但我们需要在控制器中创建销售域对象,而不是从模型中获取。让我们添加以下代码,其中包含一个购买图书的方法 add
和两个获取器:一个获取给定用户的所有销售信息,另一个获取特定销售信息,即分别为 getByUser
和 get
。按照惯例,该文件将是 src/Controllers/SalesController.php
:
<?php
namespace Bookstore\Controllers;
use Bookstore\Domain\Sale;
use Bookstore\Models\SaleModel;
class SalesController extends AbstractController {
public function add($id): string {
$bookId = (int)$id;
$salesModel = new SaleModel($this->db);
$sale = new Sale();
$sale->setCustomerId($this->customerId);
$sale->addBook($bookId);
try {
$salesModel->create($sale);
} catch (\Exception $e) {
$properties = [
'errorMessage' => 'Error buying the book.'
];
$this->log->error(
'Error buying book: ' . $e->getMessage()
);
return $this->render('error.twig', $properties);
}
return $this->getByUser();
}
public function getByUser(): string {
$salesModel = new SaleModel($this->db);
$sales = $salesModel->getByUser($this->customerId);
$properties = ['sales' => $sales];
return $this->render('sales.twig', $properties);
}
public function get($saleId): string {
$salesModel = new SaleModel($this->db);
$sale = $salesModel->get($saleId);
$properties = ['sale' => $sale];
return $this->render('sale.twig', $properties);
}
}