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,并执行 BookControllergetAll 方法(仍待编写),返回书籍列表。

至此,您应该可以通过浏览器端对端测试应用程序的登录功能了。尝试访问 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 和两个获取器:一个获取给定用户的所有销售信息,另一个获取特定销售信息,即分别为 getByUserget。按照惯例,该文件将是 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);
    }
}