比框架文档中的示例代码稍微高级一些。

作为一名程序员,多年来我接手、管理、升级、开发和移交过无数项目。其中许多项目都涉及意大利面条式代码,或者说是“大泥球”。这个问题经常影响基于某些框架构建的项目,这些框架中的代码组织方式与框架文档中的示例类似。

不幸的是,MVC 框架的文档通常没有警告说,代码示例主要用于说明功能,并不适合实际应用。因此,实际项目通常会将所有层集成到控制器或演示器方法中(就 MVP 而言),这些方法处理请求(通常是 HTTP 请求)。如果框架包含组件对象模型(例如 Nette),则组件通常是控制器或演示器的一部分,这会使情况更加复杂。

非最优代码结构的问题

此类项目的代码长度和复杂性会迅速增加。在单个脚本中,数据库操作、数据操作、组件初始化、模板设置和业务逻辑混杂在一起。虽然作者偶尔会将部分功能提取到独立服务(通常是单例)中,但这几乎没有什么帮助。这样的项目变得难以阅读和维护。

根据我的经验,标准化设计模式很少使用,尤其是在较小的项目(5-5 万行代码)中,例如希望简化管理的小型企业的简单 CRUD 应用程序。然而,这些项目可以从 CQRS(命令查询职责分离)和 DDD(领域驱动设计)等模式中受益匪浅。

  • HTTP 请求 -> 控制器/演示器
  • 输入验证 -> 转换为请求
  • 请求 -> 命令
  • 命令 -> 渲染/响应
  • 我将演示如何使用 Nette 堆栈、Contributte(与 Symfony Event Dispatcher 集成)和 Nextras ORM 来实现这种方法。

    // Command definition
    final class ItemSaveCommand implements Command {
        private ItemSaveRequest $request;
    
        public function __construct(private Orm $orm) {
            //
        }
    
        /** @param ItemSaveRequest $request */
        public function setRequest(Request $request): void {
            $this->request = $request;
        }
    
        public function execute(): void {
            $request = $this->request;
    
            if ($request->id) {
                $entity = $this->orm->items->getById($request->id);
            } else {
                $entity = new Item();
                $entity->uuid = $request->uuid;
            }
    
            $entity->data = $request->data;
    
            $this->orm->persist($entity);
        }
    }
    
    // Command Factory
    interface ItemSaveCommandFactory extends FactoryService {
        public function create(): ItemSaveCommand;
    }
    
    // Request
    #[RequestCommandFactory(ItemSaveCommandFactory::class)]
    final class ItemSaveRequest implements Request {
        public int|null $id = null;
        public string $uuid;
        public string $data;
    }
    
    /**
     * Command execution service
     * Supports transactions and request logging
     */
    final class CommandExecutionService implements Service {
        private \DateTimeImmutable $requestedAt;
    
        public function __construct(
            private Orm $orm,
            private Container $container,
            private Connection $connection,
        ) {
            $this->requestedAt = new \DateTimeImmutable();
        }
    
        /** @throws \Throwable */
        public function execute(AbstractCommandRequest $request, bool $logRequest = true, bool $transaction = true): mixed {
            $factoryClass = RequestHelper::getCommandFactory($request);
            $factory = $this->container->getByType($factoryClass);
    
            $cmd = $factory->create();
            $clonedRequest = clone $request;
            $cmd->setRequest($request);
    
            try {
                $cmd->execute();
    
                if (!$transaction) {
                    $this->orm->flush();
                }
    
                if (!$logRequest) {
                    return;
                }
    
                $logEntity = RequestHelper::createRequestLog(
                    $clonedRequest,
                    $this->requestedAt,
                    RequestLogConstants::StateSuccess
                );
    
                if ($transaction) {
                    $this->orm->persistAndFlush($logEntity);
                } else {
                    $this->orm->persist($logEntity);
                }
    
                return;
            } catch (\Throwable $e) {
                if ($transaction) {
                    $this->connection->rollbackTransaction();
                }
    
                if (!$logRequest) {
                    throw $e;
                }
    
                $logEntity = RequestHelper::createRequestLog(
                    $clonedRequest,
                    $this->requestedAt,
                    RequestLogConstants::StateFailed
                );
    
                if ($transaction) {
                    $this->orm->persistAndFlush($logEntity);
                } else {
                    $this->orm->persist($logEntity);
                }
    
                throw $e;
            }
        }
    }
    
    // Listener for executing commands via Event Dispatcher
    final class RequestExecutionListener implements EventSubscriberInterface {
        public function __construct(
            private CommandExecutionService $commandExecutionService
        ) {
            //
        }
    
        public static function getSubscribedEvents(): array {
            return [
                RequestExecuteEvent::class => 'onRequest'
            ];
        }
    
        /** @param ExecuteRequestEvent $ev */
        public function onRequest(ExecuteRequestEvent $ev): void {
            $this->commandExecutionService->execute($ev->request, $ev->logRequest, $ev->transaction);
        }
    }
    
    // Event definition for command execution
    final class ExecuteRequestEvent extends Event {
        public function __construct(
            public Request $request,
            public bool $logRequest = true,
            public bool $transaction = true,
        ) {
            // Constructor
        }
    }
    
    // Event Dispatcher Facade
    final class EventDispatcherFacade {
        public static EventDispatcherInterface $dispatcher;
    
        public static function set(EventDispatcherInterface $dispatcher): void {
            self::$dispatcher = $dispatcher;
        }
    }
    
    // Helper function for simple event dispatching
    function dispatch(Event $event): object {
        return EventDispatcherFacade::$dispatcher->dispatch($event);
    }
    
    // Usage in Presenter (e.g., in response to a component event)
    final class ItemPresenter extends Presenter {
        public function createComponentItem(): Component {
            $component = new Component();
            $component->onSave[] = function (ItemSaveRequest $request) {
                dispatch(new ExecuteRequestEvent($request));
            };
    
            return $component;
        }
    }

    该解决方案有几个优点。与数据库相关的逻辑与 MVC/P 分离,有助于提高可读性和更易于维护。请求对象充当数据载体,非常适合记录到数据库中,例如事件日志。这可确保所有修改数据的用户输入都按时间顺序保存。如果出现错误,可以查看这些日志并在必要时重播。

    这种方法的缺点包括命令不应返回任何数据。因此,如果我需要进一步处理新创建的数据(例如,将其传递给模板),我必须使用其 UUID 检索它,这就是请求和实体都包含它的原因。另一个缺点是,对数据库架构的任何更改都需要更新所有请求以匹配新架构,这可能非常耗时。