PHP/MVC/PDO - 在数据库外开始事务 class

PHP/MVC/PDO - beginTransaction outside of Database class

有人可以帮我解决这个问题吗?我有以下 classes(所有功能,为了便于阅读,此处缩写):

  class Database {
    private $host = DB_HOST;
    // etc...

    public function __construct() {
     $dsn = 'mysql:host=' . $this->host . ';dbname=' . $this->dbname;
     $options = array(PDO::ATTR_PERSISTENT => true, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION);

     try {
       $this->dbh = new PDO($dsn, $this->user, $this->pass, $options);

     } catch (PDOException $e) {
       $this->error = $e->getMessage();
       echo $this->error;
     }
   }

   public function beginTransaction() {
     $this->stmt = $this->dbh->beginTransaction();
   }

和一个 class 比方说书籍;

  class Books extends Controller {
    public function __construct() {
      $this->model = $this->loadModel('BookModel');
    }

    // etc.
    $this->model->beginTransaction();

BookModel 看起来像:

class BookModel {
  protected $db;

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

  public function beginTransaction() {
    $this->db->beginTransaction();
  }

我知道我只能访问数据库内部的PDO beginTransaction class,但是有没有其他方法,或者我必须使用这个复杂的路径,调用调用PDO的方法的方法方法?

我有一种感觉,我正在做一些非常愚蠢的事情。也许将 BookModel 扩展到数据库 class,但感觉也不对。

谢谢!

一些建议:

[a] 你不应该在 class 方法中创建对象(使用 "new")。相反,您应该将现有实例注入 constructors/setters。这被命名为 dependency injection 并且可以与 dependency injection container.

一起应用

[b] 正如@YourCommonSense 指出的那样,Database 将从在构造函数中注入的单个 PDO 实例中受益匪浅。注入任务将是 DI 容器的工作。例如,如果您使用 PHP-DI, there would be a definition entry 创建数据库连接:

return [
    'database-connection' => function (ContainerInterface $container) {
        $parameters = $container->get('database.connection');

        $dsn = $parameters['dsn'];
        $username = $parameters['username'];
        $password = $parameters['password'];

        $connectionOptions = [
            PDO::ATTR_EMULATE_PREPARES => false,
            PDO::ATTR_PERSISTENT => false,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ];

        $connection = new PDO($dsn, $username, $password, $connectionOptions);

        return $connection;
    },
];

和另一个 定义 条目将其注入 Database:

return [
    Database::class => autowire()
        ->constructorParameter('connection', get('database-connection')),
];

Database 构造器看起来像:

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

[c] model 不是 class(像 BookModel)。它是一层(模型层,或domain model), composed of multiple components: entities (or domain objects), value objects, data mappers, repositories领域服务。你的BookModel是一个组合顺便说一句。实体和数据映射器(至少)。注意:从 Database 继承它是错误的,因为模型不能是数据库。

[d] 你不应该将模型注入控制器。相反,控制器应该使用所谓的application services(也称为用例,或actions,或interactors).这些服务包含所谓的应用程序逻辑,并且是解耦表示层(或交付机制) - 除其他组件外,还包括域模型中的控制器和视图。顺便说一下,应用程序服务还可以确保通信。两层。注意:也可能有 域服务,特定于域并与特定于应用程序的应用程序服务分开。

[e] Database class 根本不需要!您已经拥有非常优雅和强大的 PDO 来处理数据库操作。

[f]其实调用调用PDO方法的方法并没有错。此链中的每个方法都封装了特定于当前对象的特定行为。但是,每种方法的功能都应该增加一些附加值。否则,拥有这条链就没有意义了。一个例子:在一个应用服务中,你可以直接使用一个data mapper从数据库中通过id获取一本书:

class FindBooksService {

    public function __construct(
        private BookMapper $bookMapper
    ) {

    }

    public function findBookById(?int $id = null): ?Book {
        return $this->bookMapper->fetchBookById($id);
    }

}


class BookMapper {

    public function __construct(
        private PDO $connection
    ) {
        
    }

    public function fetchBookById(?int $id): ?Book {
        $sql = 'SELECT * FROM books WHERE id = :id LIMIT 1';

        // Fetch book data from database; convert the record to a Book object ($book).
        //...

        return $book;
    }

}

现在,您可以使用 存储库 来隐藏查询数据来自数据库的事实。这是有道理的,因为存储库对象被其他组件视为特定类型(此处 Book)的对象集合。因此,其他组件认为存储库是书籍的集合,而不是某个数据库中的一堆数据,并相应地向存储库请求它们。存储库将反过来询问数据映射器以查询数据库。所以,之前的代码变成:

class FindBooksService {

    /**
     * @param BookCollection $bookCollection The repository: a collection of books, e.g. of Book instances.
     */
    public function __construct(
        private BookCollection $bookCollection
    ) {

    }

    public function findBookById(?int $id = null): ?Book {
        return $this->bookCollection->findBookById($id);
    }

}

class BookCollection {

    private array $books = [];

    public function __construct(
        private BookMapper $bookMapper
    ) {
        
    }

    /**
     * This method adds a plus value to the omolog method in the data mapper (fetchBookById):
     * - caches the Book instances in the $books list, therefore reducing the database querying operations;
     * - hides the fact, that the data comes from a database, from the external world, e.g. other components.
     * - provides an elegant collection-like interface.
     */
    public function findBookById(?int $id): ?Book {
        if (!array_key_exists($id, $this->books)) {
            $book = $this->bookMapper->fetchBookById($id);
            
            $this->books[id] = $book;
        }

        return $this->books[$id];
    }

}

class BookMapper {

    // the same...

}

[g] 一个“真正的”错误是将一个对象传递给其他对象,只供最后一个对象使用。

替代示例代码:

我写了一些代码来替代你的代码。我希望它能帮助您更好地理解基于 MVC 的应用程序的组件如何协同工作。

重要:注意命名空间SampleMvc/Domain/Model/:那是域模型。请注意,应用程序服务,例如来自 SampleMvc/App/Service/ 的所有组件应该只与域模型组件通信,例如使用来自 SampleMvc/Domain/Model/ 的组件(主要是接口),而不是来自 SampleMvc/Domain/Infrastructure/。反过来,您选择的 DI 容器将负责从 SampleMvc/Domain/Infrastructure/ 为应用程序服务使用的 SampleMvc/Domain/Model/ 接口注入正确的 class 实现。

注意 SampleMvc/Domain/Infrastructure/Book/PdoBookMapper.php 中的方法 updateBook()。我在其中包含了一个交易代码,以及两个很棒的链接。玩得开心。

项目结构:

SampleMvc/App/Controller/Book/AddBook.php:

<?php

namespace SampleMvc\App\Controller\Book;

use Psr\Http\Message\{
    ResponseInterface,
    ServerRequestInterface,
};
use SampleMvc\App\Service\Book\{
    AddBook as AddBookService,
    Exception\BookAlreadyExists,
};
use SampleMvc\App\View\Book\AddBook as AddBookView;

/**
 * A controller for adding a book.
 * 
 * Let's assume the existence of this route definition:
 * 
 * $routeCollection->post('/books/add', SampleMvc\App\Controller\Book\AddBook::class);
 */
class AddBook {

    /**
     * @param AddBookView $view The view for presenting the response to the request back to the user.
     * @param AddBookService $addBookService An application service for adding a book to the model layer.
     */
    public function __construct(
        private AddBookView $view,
        private AddBookService $addBookService
    ) {
        
    }

    /**
     * Add a book.
     * 
     * The book details are submitted from a form, using the HTTP method "POST".
     * 
     * @param ServerRequestInterface $request A server request.
     * @return ResponseInterface The response to the current request.
     */
    public function __invoke(ServerRequestInterface $request): ResponseInterface {
        $authorName = $request->getParsedBody()['authorName'];
        $title = $request->getParsedBody()['title'];

        try {
            $book = $this->addBookService($authorName, $title);
            $this->view->setBook($book);
        } catch (BookAlreadyExists $exception) {
            $this->view->setErrorMessage(
                $exception->getMessage()
            );
        }

        $response = $this->view->addBook();

        return $response;
    }

}

SampleMvc/App/Controller/Book/FindBooks.php:

<?php

namespace SampleMvc\App\Controller\Book;

use Psr\Http\Message\ResponseInterface;
use SampleMvc\App\View\Book\FindBooks as FindBooksView;
use SampleMvc\App\Service\Book\FindBooks as FindBooksService;

/**
 * A controller for finding books.
 * 
 * Let's assume the existence of this route definition:
 * 
 * $routeCollection->post('/books/find/{authorName}', [SampleMvc\App\Controller\FindBooks::class, 'findBooksByAuthorName']);
 */
class FindBooks {

    /**
     * @param FindBooksView $view The view for presenting the response to the request back to the user.
     * @param FindBooksService $findBooksService An application service for finding books by querying the model layer.
     */
    public function __construct(
        private FindBooksView $view,
        private FindBooksService $findBooksService
    ) {
        
    }

    /**
     * Find books by author name.
     * 
     * The author name is provided by clicking on a link of some author name 
     * in the browser. The author name is therefore sent using the HTTP method 
     * "GET" and passed as argument to this method by a route dispatcher.
     * 
     * @param string|null $authorName (optional) An author name.
     * @return ResponseInterface The response to the current request.
     */
    public function findBooksByAuthorName(?string $authorName = null): ResponseInterface {
        $books = $this->findBooksService->findBooksByAuthorName($authorName);

        $response = $this->view
            ->setBooks($books)
            ->findBooksByAuthorName()
        ;

        return $response;
    }

}

SampleMvc/App/Service/Book/Exception/BookAlreadyExists.php:

<?php

namespace SampleMvc\App\Service\Book\Exception;

/**
 * An exception thrown if a book already exists.
 */
class BookAlreadyExists extends \OverflowException {
    
}

SampleMvc/App/Service/Book/AddBook.php:

<?php

namespace SampleMvc\App\Service\Book;

use SampleMvc\Domain\Model\Book\{
    Book,
    BookMapper,
};
use SampleMvc\App\Service\Book\Exception\BookAlreadyExists;

/**
 * An application service for adding a book.
 */
class AddBook {

    /**
     * @param BookMapper $bookMapper A data mapper for transfering books 
     * to and from a persistence system.
     */
    public function __construct(
        private BookMapper $bookMapper
    ) {
        
    }

    /**
     * Add a book.
     * 
     * @param string|null $authorName An author name.
     * @param string|null $title A title.
     * @return Book The added book.
     */
    public function __invoke(?string $authorName, ?string $title): Book {
        $book = $this->createBook($authorName, $title);

        return $this->storeBook($book);
    }

    /**
     * Create a book.
     * 
     * @param string|null $authorName An author name.
     * @param string|null $title A title.
     * @return Book The newly created book.
     */
    private function createBook(?string $authorName, ?string $title): Book {
        return new Book($authorName, $title);
    }

    /**
     * Store a book.
     * 
     * @param Book $book A book.
     * @return Book The stored book.
     * @throws BookAlreadyExists The book already exists.
     */
    private function storeBook(Book $book): Book {
        if ($this->bookMapper->bookExists($book)) {
            throw new BookAlreadyExists(
                    'A book with the author name "' . $book->getAuthorName() . '" '
                    . 'and the title "' . $book->getTitle() . '" already exists'
            );
        }

        return $this->bookMapper->saveBook($book);
    }

}

SampleMvc/App/Service/Book/FindBooks.php:

<?php

namespace SampleMvc\App\Service\Book;

use SampleMvc\Domain\Model\Book\{
    Book,
    BookMapper,
};

/**
 * An application service for finding books.
 */
class FindBooks {

    /**
     * @param BookMapper $bookMapper A data mapper for transfering books 
     * to and from a persistence system.
     */
    public function __construct(
        private BookMapper $bookMapper
    ) {
        
    }

    /**
     * Find a book by id.
     * 
     * @param int|null $id (optional) A book id.
     * @return Book|null The found book, or null if no book was found.
     */
    public function findBookById(?int $id = null): ?Book {
        return $this->bookMapper->fetchBookById($id);
    }

    /**
     * Find books by author name.
     * 
     * @param string|null $authorName (optional) An author name.
     * @return Book[] The found books list.
     */
    public function findBooksByAuthorName(?string $authorName = null): array {
        return $this->bookMapper->fetchBooksByAuthorName($authorName);
    }

}

SampleMvc/App/View/Book/AddBook.php:

<?php

namespace SampleMvc\App\View\Book;

use SampleMvc\{
    App\View\View,
    Domain\Model\Book\Book,
};
use Psr\Http\Message\ResponseInterface;

/**
 * A view for adding a book.
 */
class AddBook extends View {

    /** @var Book The added book. */
    private Book $book = null;

    /**
     * Add a book.
     * 
     * @return ResponseInterface The response to the current request.
     */
    public function addBook(): ResponseInterface {
        $bodyContent = $this->templateRenderer->render('@Templates/Book/AddBook.html.twig', [
            'activeNavItem' => 'AddBook',
            'book' => $this->book,
            'error' => $this->errorMessage,
        ]);

        $response = $this->responseFactory->createResponse();
        $response->getBody()->write($bodyContent);

        return $response;
    }

    /**
     * Set the book.
     * 
     * @param Book $book A book.
     * @return static
     */
    public function setBook(Book $book): static {
        $this->book = $book;
        return $this;
    }

}

SampleMvc/App/View/Book/FindBooks.php:

<?php

namespace SampleMvc\App\View\Book;

use SampleMvc\{
    App\View\View,
    Domain\Model\Book\Book,
};
use Psr\Http\Message\ResponseInterface;

/**
 * A view for finding books.
 */
class FindBooks extends View {

    /** @var Book[] The list of found books. */
    private array $books = [];

    /**
     * Find books by author name.
     * 
     * @return ResponseInterface The response to the current request.
     */
    public function findBooksByAuthorName(): ResponseInterface {
        $bodyContent = $this->templateRenderer->render('@Templates/Book/FindBooks.html.twig', [
            'activeNavItem' => 'FindBooks',
            'books' => $this->books,
        ]);

        $response = $this->responseFactory->createResponse();
        $response->getBody()->write($bodyContent);

        return $response;
    }

    /**
     * Set the books list.
     * 
     * @param Book[] $books A list of books.
     * @return static
     */
    public function setBooks(array $books): static {
        $this->books = $books;
        return $this;
    }

}

SampleMvc/App/View/View.php:

<?php

namespace SampleMvc\App\View;

use Psr\Http\Message\ResponseFactoryInterface;
use SampleLib\Template\Renderer\TemplateRendererInterface;

/**
 * View.
 */
abstract class View {

    /** @var string The error message */
    protected string $errorMessage = '';

    /**
     * @param ResponseFactoryInterface $responseFactory Response factory.
     * @param TemplateRendererInterface $templateRenderer Template renderer.
     */
    public function __construct(
        protected ResponseFactoryInterface $responseFactory,
        protected TemplateRendererInterface $templateRenderer
    ) {
        
    }

    /**
     * Set the error message.
     * 
     * @param string $errorMessage An error message.
     * @return static
     */
    public function setErrorMessage(string $errorMessage): static {
        $this->errorMessage = $errorMessage;
        return $this;
    }

}

SampleMvc/Domain/Infrastructure/Book/PdoBookMapper.php:

<?php

namespace SampleMvc\Domain\Infrastructure\Book;

use SampleMvc\Domain\Model\Book\{
    Book,
    BookMapper,
};
use PDO;

/**
 * A data mapper for transfering Book entities to and from a database.
 * 
 * This class uses a PDO instance as database connection.
 */
class PdoBookMapper implements BookMapper {

    /**
     * @param PDO $connection Database connection.
     */
    public function __construct(
        private PDO $connection
    ) {
        
    }

    /**
     * @inheritDoc
     */
    public function bookExists(Book $book): bool {
        $sql = 'SELECT COUNT(*) as cnt FROM books WHERE author_name = :author_name AND title = :title';

        $statement = $this->connection->prepare($sql);
        $statement->execute([
            ':author_name' => $book->getAuthorName(),
            ':title' => $book->getTitle(),
        ]);

        $data = $statement->fetch(PDO::FETCH_ASSOC);

        return ($data['cnt'] > 0) ? true : false;
    }

    /**
     * @inheritDoc
     */
    public function saveBook(Book $book): Book {
        if (isset($book->getId())) {
            return $this->updateBook($book);
        }
        return $this->insertBook($book);
    }

    /**
     * @inheritDoc
     */
    public function fetchBookById(?int $id): ?Book {
        $sql = 'SELECT * FROM books WHERE id = :id LIMIT 1';

        $statement = $this->connection->prepare($sql);
        $statement->execute([
            'id' => $id,
        ]);

        $record = $statement->fetch(PDO::FETCH_ASSOC);

        return ($record === false) ?
            null :
            $this->convertRecordToBook($record)
        ;
    }

    /**
     * @inheritDoc
     */
    public function fetchBooksByAuthorName(?string $authorName): array {
        $sql = 'SELECT * FROM books WHERE author_name = :author_name';

        $statement = $this->connection->prepare($sql);
        $statement->execute([
            'author_name' => $authorName,
        ]);

        $recordset = $statement->fetchAll(PDO::FETCH_ASSOC);

        return $this->convertRecordsetToBooksList($recordset);
    }

    /**
     * Update a book.
     * 
     * This method uses transactions as example.
     * 
     * Note: I never worked with transactions, but I 
     * think the code in this method is not wrong.
     * 
     * @link https://phpdelusions.net/pdo#transactions (The only proper) PDO tutorial: Transactions
     * @link https://phpdelusions.net/pdo (The only proper) PDO tutorial
     * @link https://phpdelusions.net/articles/error_reporting PHP error reporting
     * 
     * @param Book $book A book.
     * @return Book The updated book.
     * @throws \Exception Transaction failed.
     */
    private function updateBook(Book $book): Book {
        $sql = 'UPDATE books SET author_name = :author_name, title = :title WHERE id = :id';

        try {
            $this->connection->beginTransaction();

            $statement = $this->connection->prepare($sql);

            $statement->execute([
                ':author_name' => $book->getAuthorName(),
                ':title' => $book->getTitle(),
                ':id' => $book->getId(),
            ]);

            $this->connection->commit();
        } catch (\Exception $exception) {
            $this->connection->rollBack();

            throw $exception;
        }

        return $book;
    }

    /**
     * Insert a book.
     * 
     * @param Book $book A book.
     * @return Book The newly inserted book.
     */
    private function insertBook(Book $book): Book {
        $sql = 'INSERT INTO books (author_name, title) VALUES (:author_name, :title)';

        $statement = $this->connection->prepare($sql);
        $statement->execute([
            ':author_name' => $book->getAuthorName(),
            ':title' => $book->getTitle(),
        ]);

        $book->setId(
            $this->connection->lastInsertId()
        );

        return $book;
    }

    /**
     * Convert the given record to a Book instance.
     * 
     * @param array $record The record to be converted.
     * @return Book A Book instance.
     */
    private function convertRecordToBook(array $record): Book {
        $id = $record['id'];
        $authorName = $record['author_name'];
        $title = $record['title'];

        $book = new Book($authorName, $title);

        $book->setId($id);

        return $book;
    }

    /**
     * Convert the given recordset to a list of Book instances.
     * 
     * @param array $recordset The recordset to be converted.
     * @return Book[] A list of Book instances.
     */
    private function convertRecordsetToBooksList(array $recordset): array {
        $books = [];

        foreach ($recordset as $record) {
            $books[] = $this->convertRecordToBook($record);
        }

        return $books;
    }

}

SampleMvc/Domain/Model/Book/Book.php:

<?php

namespace SampleMvc\Domain\Model\Book;

/**
 * Book entity.
 */
class Book {

    /**
     * @param string|null $authorName (optional) The name of an author.
     * @param string|null $title (optional) A title.
     */
    public function __construct(
        private ?string $authorName = null,
        private ?string $title = null
    ) {
        
    }

    /**
     * Get id.
     * 
     * @return int|null
     */
    public function getId(): ?int {
        return $this->id;
    }

    /**
     * Set id.
     * 
     * @param int|null $id An id.
     * @return static
     */
    public function setId(?int $id): static {
        $this->id = $id;
        return $this;
    }

    /**
     * Get the author name.
     * 
     * @return string|null
     */
    public function getAuthorName(): ?string {
        return $this->authorName;
    }

    /**
     * Set the author name.
     * 
     * @param string|null $authorName The name of an author.
     * @return static
     */
    public function setAuthorName(?string $authorName): static {
        $this->authorName = $authorName;
        return $this;
    }

    /**
     * Get the title.
     * 
     * @return string|null
     */
    public function getTitle(): ?string {
        return $this->title;
    }

    /**
     * Set the title.
     * 
     * @param string|null $title A title.
     * @return static
     */
    public function setTitle(?string $title): static {
        $this->title = $title;
        return $this;
    }

}

SampleMvc/Domain/Model/Book/BookMapper.php:

<?php

namespace SampleMvc\Domain\Model\Book;

use SampleMvc\Domain\Model\Book\Book;

/**
 * An interface for various data mappers used to 
 * transfer Book entities to and from a persistence system.
 */
interface BookMapper {

    /**
     * Check if a book exists.
     * 
     * @param Book $book A book.
     * @return bool True if the book exists, false otherwise.
     */
    public function bookExists(Book $book): bool;

    /**
     * Save a book.
     * 
     * @param Book $book A book.
     * @return Book The saved book.
     */
    public function saveBook(Book $book): Book;

    /**
     * Fetch a book by id.
     * 
     * @param int|null $id A book id.
     * @return Book|null The found book, or null if no book was found.
     */
    public function fetchBookById(?int $id): ?Book;

    /**
     * Fetch books by author name.
     * 
     * @param string|null $authorName An author name.
     * @return Book[] The found books list.
     */
    public function fetchBooksByAuthorName(?string $authorName): array;
}