Đơn giản hóa Data Mapping với PHP 8 Attributes

· 4 min read

Trong phát triển PHP hiện đại, các cấu trúc dữ liệu cụ thể (DTO) được ưu tiên hơn các mảng associative lỏng lẻo. Đối với blog này, chúng ta chuyển đổi "frontmatter" Markdown (YAML) thành object Post có cấu trúc.

Một cách phổ biến (và hơi lộn xộn) để làm điều này là truyền một array vào constructor và gán các key thủ công. Hôm nay, hãy khám phá cách tiếp cận sạch sẽ hơn sử dụng PHP 8 Attributes.

Vấn đề: Array Spaghetti

// Cách cũ
class Post {
    public function __construct(array $data) {
        $this->title = $data['title'] ?? null;
        $this->date = isset($data['date']) ? Carbon::parse($data['date']) : null;
        // ... và tiếp tục
    }
}

Cách này hoạt động, nhưng logic mapping bị hardcode bên trong constructor.

Giải pháp: Attributes

Chúng ta có thể định nghĩa một attribute #[MapFrom] để cho hydrator biết nơi tìm dữ liệu.

1. Định nghĩa Attribute

namespace App\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
class MapFrom
{
    public function __construct(
        public string $key
    ) {}
}

2. Annotate DTO

Bây giờ class Post của chúng ta trở nên hoàn toàn declarative.

namespace App\DTO;

use App\Attributes\MapFrom;
use Carbon\Carbon;

class PostDTO
{
    #[MapFrom('title')]
    public string $title;

    // Sử dụng key khác trong YAML so với tên Property
    #[MapFrom('description')]
    public string $summary;

    #[MapFrom('date')]
    public ?Carbon $publishedAt;

    public function __construct() {}
}

3. Mapper Service

Chúng ta cần một mapper linh hoạt đọc các attribute này sử dụng Reflection.

namespace App\Services;

use ReflectionClass;
use ReflectionProperty;
use App\Attributes\MapFrom;

class DataMapper
{
    public static function map(string $class, array $source): object
    {
        $reflection = new ReflectionClass($class);
        $instance = $reflection->newInstance();

        foreach ($reflection->getProperties() as $property) {
            $attributes = $property->getAttributes(MapFrom::class);

            if (empty($attributes)) {
                continue;
            }

            $attribute = $attributes[0]->newInstance();
            $key = $attribute->key;

            if (array_key_exists($key, $source)) {
                $value = $source[$key];
                
                // Tùy chọn: thêm logic type casting ở đây dựa trên kiểu property
                // ví dụ: chuyển đổi string date sang Carbon
                
                $property->setValue($instance, $value);
            }
        }

        return $instance;
    }
}

Xử lý nâng cao: Casting

Bạn có thể nhận thấy sự phức tạp với date. Chúng ta có thể mở rộng attribute hoặc logic mapping để xử lý các kiểu.

// Vòng lặp mapping nâng cao
$type = $property->getType();

if ($type && $type->getName() === 'Carbon\Carbon' && is_string($value)) {
    $value = \Carbon\Carbon::parse($value);
}

Tại sao làm điều này?

  1. Decoupling: Logic về cách construct một object được tách biệt khỏi định nghĩa object.
  2. Tái sử dụng: DataMapper có thể được sử dụng cho bất kỳ DTO nào trong ứng dụng của bạn (Markdown frontmatter, API response, CSV row).
  3. Dễ đọc: Nhìn vào PostDTO, bạn ngay lập tức thấy dữ liệu đến từ đâu mà không cần đọc code parsing.

Pattern này được sử dụng nhiều trong các thư viện như spatie/laravel-data, nhưng việc tự triển khai một phiên bản nhẹ là cách tuyệt vời để hiểu sức mạnh của PHP Reflection.

Bình luận