ug-php / clean-architecture-core
PHP 清洁架构核心库
v1.2.0
2024-07-03 16:20 UTC
Requires
- php: >=8.2
- ramsey/uuid: ^4.7
Requires (Dev)
- beberlei/assert: ^3.3
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.5
- squizlabs/php_codesniffer: ^3.7
- symfony/var-dumper: ^7.0
- symplify/easy-coding-standard: ^12.0
README
介绍
本文档指导您如何使用核心库在PHP中实现清洁架构。我们将探讨创建自定义应用程序请求和使用案例,特别关注处理缺失和未经授权的字段。
提供了一些实际示例,使用代码片段来展示库在构建模块化和清洁PHP应用程序中的应用。
先决条件
请确保您有以下条件
PHP
已安装在您的机器上(版本8.2.0 或更高版本
)。Composer
已安装以进行依赖管理。
安装
要在项目中安装核心库,请在项目目录中运行以下命令
composer require ug-php/clean-architecture-core
核心概览
应用程序请求
请求作为输入对象,封装来自您的HTTP控制器的数据。在核心库中,使用 \Urichy\Core\Request\Request
类作为创建自定义应用程序请求对象的基石。使用 requestPossibleFields
属性定义预期的字段。
<?php declare(strict_types=1); use Urichy\Core\Request\Request; use Urichy\Core\Request\RequestInterface; use Assert\Assert; final class PatientRecordRequest extends Request { protected static array $requestPossibleFields = [ 'patient_name' => true, // required parameter 'old' => true, // required parameter 'medical_history' => [ 'allergies' => false, // optional parameter 'current_medications' => true, // required nested parameter 'past_surgeries' => [ 'surgery_name' => true, // required nested parameter 'surgery_date' => true, // required nested parameter ], ], ]; protected static function applyConstraintsOnRequestFields(array $requestData): void { Assert::that($requestData['patient_name'], '[patient_name] must not be an empty string.')->notEmpty()->string(); Assert::that($requestData['old'], '[old] must be an integer.')->integer()->greaterThan(0); Assert::that($requestData['medical_history']['current_medications'], '[current_medications] must not be an empty string.')->notEmpty()->string(); Assert::that($requestData['medical_history']['past_surgeries']['surgery_name'], '[surgery_name] must not be an empty string.')->notEmpty()->string(); Assert::that($requestData['medical_history']['past_surgeries']['surgery_date'], '[surgery_date] must be a valid date.')->date(); // Optional field constraint if (isset($requestData['medical_history']['allergies'])) { Assert::that($requestData['medical_history']['allergies'], '[allergies] must be a string.')->string(); } } }
处理未经授权的字段
<?php declare(strict_types=1); try { PatientRecordRequest::createFromPayload([ 'patient_name' => 'Jane Doe', 'old' => 45, 'medical_history' => [ 'current_medications' => 'aspirin', 'past_surgeries' => [ 'surgery_name' => 'Appendectomy', 'surgery_date' => '2022-01-01', ], 'extra_field' => 'unexpected', ], ]); } catch (BadRequestContentException $exception) { // Handle unauthorized fields dd($exception->getErrors()); // ["medical_history.extra_field"] }
处理缺失的字段
<?php declare(strict_types=1); try { PatientRecordRequest::createFromPayload([ 'patient_name' => 'Jane Doe', 'medical_history' => [ 'current_medications' => 'aspirin', 'past_surgeries' => [ 'surgery_name' => 'Appendectomy', ], ], ]); } catch (BadRequestContentException $exception) { // Handle missing fields dd($exception->getErrors()); // ["old" => "required", "medical_history.past_surgeries.surgery_date" => "required"] }
当请求成功创建时。
<?php declare(strict_types=1); $request = PatientRecordRequest::createFromPayload([ 'patient_name' => 'Jane Doe', 'old' => 45, 'medical_history' => [ 'current_medications' => 'aspirin', 'past_surgeries' => [ 'surgery_name' => 'Appendectomy', 'surgery_date' => '2022-01-01', ], ], ]); dd($request->getRequestId()); // 6d326314-f527-483c-80df-7c157acdb95b dd([ 'patient_name' => $request->get('patient_name'), 'current_medications' => $request->get('medical_history.current_medications'), 'unknown' => $request->get('unknown', 'default_value'), ]); // ['patient_name' => 'Jane Doe', 'current_medications' => 'aspirin', 'unknown' => 'default_value'] dd($request->toArray()); /* [ 'patient_name' => 'Jane Doe', 'old' => 45, 'medical_history' => [ 'current_medications' => 'aspirin', 'past_surgeries' => [ 'surgery_name' => 'Appendectomy', 'surgery_date' => '2022-01-01', ], ], ]*/
展示者
展示者处理使用案例的输出逻辑。扩展 \Urichy\Core\Presenter\Presenter
并实现 \Urichy\Core\Presenter\PresenterInterface
。
<?php declare(strict_types=1); use Urichy\Core\Presenter\Presenter; use Urichy\Core\Presenter\PresenterInterface; use Urichy\Core\Response\ResponseInterface; final class ArrayResponsePresenter extends Presenter { public function getResponse(): array { return $this->response->output(); } }
响应
响应封装了使用案例返回的数据。它们包括状态信息、消息和任何相关数据。使用 \Urichy\Core\Response\Response
创建使用案例响应。
<?php declare(strict_types=1); use Urichy\Core\Response\Response; // success response $response = Response::create( success: true, statusCode: StatusCode::OK->value, message: 'success.response', data: [ 'user_id' => '6d326314-f527-483c-80df-7c157acdb95b', ] ) // or failed response $response = Response::create( success: false, statusCode: StatusCode::NOT_FOUND->value, message: 'failed.response', data: [ 'field' => 'value', ] ) dd($response->isSuccess()); // true or false dd($response->getStatusCode()); // 200 or 404 dd($response->getMessage()); // 'success.response' or 'failed.response' dd($response->getData()); // ['field' => 'value'] or ['user_id' => '6d326314-f527-483c-80df-7c157acdb95b'] dd($response->get('field')); // 'value' dd($response->get('unknown_field')); // null
使用案例
使用案例封装业务逻辑并协调请求、实体和展示者之间的数据流动。扩展 \Urichy\Core\Usecase\Usecase
类并实现 \Urichy\Core\Usecase\UsecaseInterface
,使用 execute
方法。
@see 以下示例
异常
在处理过程中抛出异常时,您可以使用一些方法来处理异常数据。
如何创建异常?
- 创建一个扩展
\Urichy\Core\Exception\Exception
的异常类
<?php declare(strict_types=1); use Urichy\Core\Exception\Exception; final class BadRequestContentException extends Exception { } final class UserNotFoundException extends Exception { }
- 当发生错误时抛出异常并处理它。
<?php declare(strict_types=1); use Urichy\Core\Exception\Exception; use Urichy\Core\Exception\BadRequestContentException; use Urichy\Core\Exception\UserNotFoundException; try { //... throw new BadRequestContentException([ 'message' => 'bad.request.content', 'details' => [ 'email' => [ '[email] field is required.', '[email] must be a valid email.', ] ] // array with error contexts ]); // or throw new UserNotFoundException([ 'message' => 'user.not.found', 'details' => [ 'error' => 'User with [ulrich] username not found.' ] // array with error contexts ]); } catch(ExceptionInterface $exception) { // for exception, some method are available dd($exception->getErrors()); // print details [ 'details' => [ 'email' => [ '[email] field is required.', '[email] must be a valid email.', ] ], ] // or [ 'details' => [ 'error' => 'User with [ulrich] username not found.', ], ] dd($exception->getDetails()); // print error details [ 'email' => [ '[email] field is required.', '[email] must be a valid email.', ] ] // or [ 'error' => 'User with [ulrich] username not found.', ], dd($exception->getMessage()) // 'error.message' dd($exception->getDetailsMessage()) // 'User with [ulrich] username not found.', only if 'error' key is defined in details. dd($exception->getErrorsForLog()) // print error with more context [ 'message' => $this->getMessage(), 'code' => $this->getCode(), 'errors' => $this->errors, 'file' => $this->getFile(), 'line' => $this->getLine(), 'previous' => $this->getPrevious(), 'trace_as_array' => $this->getTrace(), 'trace_as_string' => $this->getTraceAsString(), ] dd($exception->format()); [ 'status' => 'success' or 'error', 'error_code' => 400, 'message' => 'throw.error', 'details' => [ 'email' => [ '[email] field is required.', '[email] must be a valid email.', ], 'lastname' => [ '[lastname] field is required.', ] ], ] }
示例用法
从头开始(无框架的PHP)
项目结构
├── src
│ ├── Controller
│ │ └── BookController.php
│ ├── Request
│ │ └── BookRecordRequest.php
│ ├── Presenter
│ │ └── JsonResponsePresenter.php
│ ├── UseCase
│ │ └── RegisterBookUsecase.php
│ └── Response
│ └── Response.php
├── public
│ └── index.php
└── composer.json
代码示例
public/index.php
<?php declare(strict_types=1); require '../vendor/autoload.php'; use App\Controller\BookController; use Symfony\Component\HttpFoundation\Request; $request = Request::createFromGlobals(); $controller = new BookController(); $response = $controller->registerBook($request); $response->send();
src/Controller/BookController.php
<?php declare(strict_types=1); namespace App\Controller; use App\Request\BookRecordRequest; use App\Presenter\JsonResponsePresenter; use App\UseCase\RegisterBookUsecase; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; use Symfony\Component\HttpFoundation\JsonResponse; final class BookController { public function registerBook(SymfonyRequest $request): JsonResponse { $bookRequest = BookRecordRequest::createFromPayload([ 'title' => $request->get('title'), 'publication' => [ 'date' => $request->get('published_date'), 'publisher' => $request->get('publisher'), ], 'isbn' => $request->get('isbn'), ]); // you can also use $request->toArray() (in createFromPayload method) to get request payload if POST request $presenter = new JsonResponsePresenter(); $useCase = new RegisterBookUsecase(); $useCase ->withRequest($bookRequest) ->withPresenter($presenter) ->execute(); return $presenter->getResponse(); } }
src/Request/BookRecordRequest.php
使用 beberlei/assert
验证库
<?php declare(strict_types=1); namespace App\Request; use Urichy\Core\Request\Request; use Urichy\Core\Request\RequestInterface; use Assert\Assert; // interface is optional. You can directly use the implementation interface BookRecordRequestInterface extends RequestInterface {} final class BookRecordRequest extends Request implements BookRecordRequestInterface { protected static array $requestPossibleFields = [ 'title' => true, // required parameters 'publication' => [ 'date' => true, 'publisher' => false, // optional parameters ], 'isbn' => true, ]; /** * @param array<string, mixed> $requestData * @return void */ protected static function applyConstraintsOnRequestFields(array $requestData): void { Assert::that($requestData['title'], '[title] must not be an empty string.')->notEmpty()->string(); Assert::that($requestData['publication']['date'], '[date] must be a valid date.')->date(); Assert::that($requestData['isbn'], '[isbn] must not be an empty string.')->notEmpty()->string(); if (isset($requestData['publication']['publisher'])) { Assert::that($requestData['publication']['publisher'], '[publisher] must be a string.')->string(); } } }
使用 Symfony Validator
库
<?php declare(strict_types=1); namespace App\Request; use Urichy\Core\Request\Request; use Urichy\Core\Request\RequestInterface; use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Constraints as SymfonyAssert; use Symfony\Component\Validator\ConstraintViolationListInterface; // interface is optional. You can directly use the implementation interface BookRecordRequestInterface extends RequestInterface {} final class BookRecordRequest extends Request implements BookRecordRequestInterface { protected static array $requestPossibleFields = [ 'title' => true, 'publication' => [ 'date' => true, 'publisher' => false, ], 'isbn' => true, ]; /** * @param array<string, mixed> $requestData * @return void */ protected static function applyConstraintsOnRequestFields(array $requestData): void { $validator = Validation::createValidator(); $constraints = [ 'title' => [ new SymfonyAssert\NotBlank(message: '[title] cannot be blank'), new SymfonyAssert\Type(type: 'string', message: '[title] must be a string'), ], 'publication' => new SymfonyAssert\Collection([ 'date' => [ new SymfonyAssert\NotBlank(message: '[date] cannot be blank'), new SymfonyAssert\Date(message: '[date] must be a valid date'), ] ]), 'isbn' => [ new SymfonyAssert\NotBlank(message: '[isbn] cannot be blank'), new SymfonyAssert\Type(type: 'string', message: '[isbn] must be a string'), ], ]; if (isset($requestData['publication']['publisher'])) { $constraints['publication']['publisher'] = [ new SymfonyAssert\Type(type: 'string', message: '[publisher] must be a string'), ]; } $violations = $validator->validate($requestData, new SymfonyAssert\Collection($constraints)); self::throwViolationsWhenErrors($violations); } private static function throwViolationsWhenErrors(ConstraintViolationListInterface $violations): void { $errors = []; foreach ($violations as $violation) { $propertyPath = $violation->getPropertyPath(); $errors[$propertyPath][] = $violation->getMessage(); } if (count($errors) > 0) { throw new BadRequestContentException($errors); } } }
src/Presenter/JsonResponsePresenter.php
<?php declare(strict_types=1); namespace App\Presenter; use Urichy\Core\Presenter\Presenter; use Urichy\Core\Presenter\PresenterInterface; use Symfony\Component\HttpFoundation\JsonResponse; final class JsonResponsePresenter extends Presenter implements PresenterInterface { public function getResponse(): JsonResponse { $responseData = $this->response->output(); return new JsonResponse($responseData, $responseData['code']); } }
src/Presenter/HtmlResponsePresenter.php
<?php declare(strict_types=1); namespace App\Presenter; use Urichy\Core\Presenter\Presenter; use Urichy\Core\Presenter\PresenterInterface; use Symfony\Component\HttpFoundation\Response; final class HtmlResponsePresenter extends Presenter implements PresenterInterface { public function getResponse(): Response { $responseData = $this->response->output(); $htmlContent = "<html><body><h1>{$responseData['message']}</h1><p>" . json_encode($responseData['data']) . "</p></body></html>"; return new Response($htmlContent, $responseData['code']); } }
src/UseCase/RegisterBookUsecase.php
<?php declare(strict_types=1); namespace App\UseCase; use Urichy\Core\Usecase\Usecase; use Urichy\Core\Usecase\UsecaseInterface; use Urichy\Core\Response\Response; use Urichy\Core\Response\StatusCode; interface RegisterBookUsecaseInterface extends UsecaseInterface {} final class RegisterBookUsecase extends Usecase implements RegisterBookUsecaseInterface { public function __construct( // inject your dependencies here (always use dependencie interface, not implementation) private BookRepositoryInterface $bookRepository ) {} public function execute(): void { $requestData = $this->getRequestData(); $requestId = $this->getRequestId(); $book = [ 'title' => $this->getField('title'), 'author' => $this->getField('publication.publisher'), 'publication_date' => $this->getField('publication.date'), 'isbn' => $this->getField('isbn'), ]; // process your business logic here try { $this->bookRepository->save(Book::from($book)) } catch (PersistenceException $e) { // handle persistence exception here or log it or send failed response. } $this->presentResponse(Response::create( success: true, statusCode: StatusCode::OK->value, message: 'book.registered.successfully.', data: $book )); } }
src/Response/Response.php
<?php declare(strict_types=1); namespace App\Response; use Urichy\Core\Response\Response as LibResponse; use Urichy\Core\Response\StatusCode; abstract class Response extends LibResponse { public static function createSuccessResponse(array $data, StatusCode $statusCode, ?string $message = null): self { return new self(true, $statusCode->value, $message, $data); } public static function createFailedResponse(array $errors = [], StatusCode $statusCode, ?string $message = null): self { return new self(false, $statusCode->value, $message, $errors); } }
带有Symfony的示例
项目结构
├── src
│ ├── Controller
│ │ └── BookController.php
│ ├── Request
│ │ └── BookRecordRequest.php
│ ├── Presenter
│ │ └── JsonResponsePresenter.php
| | └── HtmlResponsePresenter.php
│ ├── UseCase
│ │ └── RegisterBookUsecase.php
│ └── Response
│ └── Response.php
├── public
│ └── index.php
├── config
│ └── services.yaml
└── composer.json
代码示例
src/Controller/BookController.php
<?php declare(strict_types=1); namespace App\Controller; use App\Request\BookRecordRequest; use App\Presenter\JsonResponsePresenter; use App\Presenter\HtmlResponsePresenter; use App\UseCase\RegisterBookUsecase; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Routing\Annotation\Route; #[Route('/register-book', name: 'register_book', methods: 'POST')] final class BookController extends AbstractController { public function __construct( private readonly RegisterBookUsecase $registerBookUsecase ) {} public function __invoke(SymfonyRequest $request): JsonResponse { try { $bookRequest = BookRecordRequest::createFromPayload([ 'title' => $request->get('title'), 'author' => $request->get('author'), 'publication' => [ 'published_date' => $request->get('published_date'), 'publisher' => $request->get('publisher'), ], 'isbn' => $request->get('isbn'), ]); $presenter = $this-getPresenterAccordingToRequestContentType($request->getContentType()); $this->registerBookUsecase ->withRequest($bookRequest) ->withPresenter($presenter) ->execute(); $response = $presenter->getResponse()->output(); } catch (Exception $exception) { return $this->json($exception->format(), $exception->getCode()); } return $this->json($response, $response['code']); } // you can instanciate presenter according to the request context private function getPresenterAccordingToRequestContentType(string $contentType): PresenterInterface { switch ($contentType) { case 'text/html': return new HtmlResponsePresenter(); default: break; } return new JsonResponsePresenter(); } }
带有Laravel的示例
项目结构
├── app
│ ├── Http
│ │ └── Controllers
│ │ └── BookController.php
│ ├── Requests
│ │ └── BookRecordRequest.php
│ ├── Presenters
│ │ └── JsonResponsePresenter.php
│ ├── UseCases
│ │ └── RegisterBookUsecase.php
│ └── Responses
│ └── Response.php
├── public
│ └── index.php
└── composer.json
代码示例
app/Http/Controllers/BookController.php
使用请求和展示者
<?php declare(strict_types=1); namespace App\Http\Controllers; use App\Requests\BookRecordRequest; use App\Presenters\JsonResponsePresenter; use App\Presenters\HtmlResponsePresenter; use App\UseCases\RegisterBookUsecase; use Illuminate\Http\Request as LaravelRequest; use Illuminate\Http\JsonResponse; final class BookController extends Controller { public function __construct( private readonly RegisterBookUsecase $registerBookUsecase ) {} public function __invoke(LaravelRequest $request): JsonResponse { try { $bookRequest = BookRecordRequest::createFromPayload([ 'title' => $request->input('title'), 'author' => $request->input('author'), 'publication' => [ 'published_date' => $request->input('published_date'), 'publisher' => $request->input('publisher'), ], 'isbn' => $request->input('isbn'), ]); $jsonPresenter = new JsonResponsePresenter(); $this ->registerBookUsecase ->withRequest($bookRequest) ->withPresenter($jsonPresenter) ->execute(); $response = $jsonPresenter->getResponse()->output(); } catch (Exception $exception) { return response()->json($exception->format(), $exception->getCode()); } return response()->json($response, $response['code']); } }
无展示者,但有请求
<?php declare(strict_types=1); namespace App\Http\Controllers; use App\Requests\BookRecordRequest; use App\UseCases\RegisterBookUsecase; use Illuminate\Http\Request as LaravelRequest; use Illuminate\Http\JsonResponse; final class BookController extends Controller { public function __construct( private readonly RegisterBookUsecase $registerBookUsecase ) {} public function __invoke(LaravelRequest $request): JsonResponse { try { $bookRequest = BookRecordRequest::createFromPayload([ 'title' => $request->input('title'), 'author' => $request->input('author'), 'publication' => [ 'published_date' => $request->input('published_date'), 'publisher' => $request->input('publisher'), ], 'isbn' => $request->input('isbn'), ]); $this ->registerBookUsecase ->withRequest($bookRequest) ->execute(); } catch (Exception $exception) { return response()->json($exception->format(), $exception->getCode()); } return response()->json([]); } }
无请求和展示者
<?php declare(strict_types=1); namespace App\Http\Controllers; use App\Requests\BookRecordRequest; use App\UseCases\RegisterBookUsecase; use Illuminate\Http\Request as LaravelRequest; use Illuminate\Http\JsonResponse; final class BookController extends Controller { public function __construct( private readonly RegisterBookUsecase $registerBookUsecase ) {} public function __invoke(): JsonResponse { try { $this ->registerBookUsecase ->execute(); } catch (Exception $exception) { return response()->json($exception->format(), $exception->getCode()); } return response()->json([]); } }
单元测试
运行单元测试的命令
$ make tests
许可证
关于版权和MIT许可证的信息
- 由 Ulrich Geraud AHOGLA 编写并版权所有 ©2023-至今。 iamcleancoder@gmail.com
- Clean architecture core 是开源软件,受 MIT 许可证 许可。