PHP – Mapowanie zagnieżdżonych struktur JSON za pomocą JsonMapper

W poprzednich artykułach poruszałem już tematykę związaną z obsługą formatu JSON w języku PHP. Było co nieco na temat serializacji obiektów oraz walidacji schematu.

Do zamknięcia krótkiej serii o JSONie, brakuje jeszcze przedstawienia sposobu „deserializacji” danych zapisanych za pomocą tego popularnego formatu do obiektów PHP.

Kontekst – JSON

W artykule posługiwał będę się następującym zestawem danych:

{
   "who": {
      "email": "[email protected]"
   },
   "orderTime": "2017-01-02 11:23:43",
   "comment": null,
   "items": [
      {
         "id": 786,
         "price": 59,
         "name": "The Clean Coder",
         "asGift": true
      },
      {
         "id": 927,
         "price": 79,
         "name": "Clean Code"
         "ssGift": false
      }
   ]
}

Jak można się domyślić – jest to bardzo okrojona wersja, zbioru danych, reprezentujących zamówienie. W tym przypadku dwóch książek szanownego Uncle Boba. Wykorzystany został każdy z dostępnych typów w formacie JSON –object, array, string, number, boolean oraz null. Z punktu widzenia samego języka PHP, number zostanie przekształcony na int, a format zapisu daty i czasu może być reprezentowany jako obiekt klasy DateTime.

Zakładam również, że dostarczony JSON spełnia założenia walidacji i wszystkie pola zostały dostarczone. O samej walidacji schematu wspominałem w artykule nt. JSON Schema.

Pierwsza implementacja – ręczna

W pierwszej kolejności chciałbym przestawić przykładową implementację w sposób ręczny, bez użycia „wspomagaczy” w postaci zewnętrznych bibliotek.

Sprowadza się ona do założenia, że dla każdej z klas implementujemy statyczną, metodę fabrykującą create. Mógłbym posłużyć się interfejsem z generycznym nagłówkiem metody, która musi zostać zaimplementowana przez każdą z klas. Jednak dla ułatwienia i jedynie zobrazowania nakładu pracy pominę ten etap.

Implementujemy najpierw klasę Order agregującą wszelkie informacje na temat zamówienia:

class Order
{    
    /**
     * @var Who
     */
    public $who;
    
    /**
     * @var Item[]
     */
    public $items = [];
    
    /**
     * @var DateTime
     */
    public $orderTime;
    
    private function __construct(Who $who, array $items, DateTime $orderTime) {
        $this->who = $who;
        $this->items = $items;
        $this->orderTime = $orderTime;
    }

    public static function create(array $order) : Order {
        $who = Who::create($order['who']);
        $orderTime = new DateTime($order['orderTime']);
        $items = array_map(function($item) {
            return Item::create($item);
        }, $order['items']);
        
        return new Order($who, $items, $orderTime);
    }
}

Następnie uzupełniamy klasę Who reprezentującą osobę realizującą zamówienie:

class Who
{
    /**
     * @var string
     */
    public $email;
    
    public static function create(array $whoData) : Who {
        $who = new Who();
        $who->email = $whoData['email'];
        
        return $who;
    }
}

Ostatnia klasa Item zawiera informacje na temat produktu wchodzącego w skład zamówienia:

class Item
{
    /**
     * @var int
     */
    public $id;
    
    /**
     * @var int
     */
    public $price;
    
    /**
     * @var string
     */
    public $name;
    
    public static function create(array $itemData) : Item {
        $item = new Item();
        $item->id = $itemData['id'];
        $item->price = $itemData['price'];
        $item->name = $itemData['name'];
        
        return $item;
    }
}

Dobra, uruchommy jeszcze całość aby zweryfikować output:

$order = json_decode(file_get_contents('order.json'), true);
var_dump(Order::create($order));

Wszystko się zgadza. Otrzymaliśmy obiekt klasy Order który posiada następującą formę:

object(Order)#3 (3) {
  ["who"] => object(Who)#1 (1) {
    ["email"] => string(16) "[email protected]"
  }
  ["items"] => array(2) {
    [0] => object(Item)#4 (3) {
      ["id"] => int(786)
      ["price"] => int(59)
      ["name"] => string(15) "The Clean Coder"
    }
    [1] => object(Item)#5 (3) {
      ["id"] => int(927)
      ["price"] => int(79)
      ["name"] => string(10) "Clean Code"
    }
  }
  ["orderTime"] => object(DateTime)#2 (3) {
    ["date"] => string(26) "2017-01-02 11:23:43.000000"
    ["timezone_type"] => int(3)
    ["timezone"] => string(13) "Europe/Berlin"
  }
}

Dla prostej struktury danych (np. klasy Who i Item), która nie posiada zagnieżdżonych elementów, implementacja jest prosta, ilość kodu też nie przeraża. Problemy pojawiają się gdy przekształcić chcemy bardziej skomplikowany obiekt, jakim jest Order, złożony z tablic czy kolejnych pod obiektów. Pomijając weryfikację czy poszczególne klucze istnieją w tablicy asocjacyjnej, widzimy, że nawet w takim przypadku należy wyprodukować sporą ilość kodu – a jest to mimo wszystko bardzo prosty przykład.

Na ratunek przybywa JsonMapper

… nagle otwierają się drzwi, pojawia się blask. Po środku on sam, w pełnej swojej okazałości – Dżejson Mapper. Super bohater.

~ „Opowieści z programistycznej piwnicy” ~

Aby zniwelować ręczną implementację, posłużę się pewną formą abstrakcji. W tym przypadku w formie zewnętrznej biblioteki JsonMapper – mappera formatu JSON na odpowiednie instancje klas w języku PHP. Natywnie język nie wspiera mapowania tego formatu do obiektów więc albo sami zapewnimy pełną realizację mapowania lub będziemy polegać na sprawdzonym, gotowym, zewnętrznym rozwiązaniu.

Instalacja jak przystało na bibliotekę w języku PHP odbywa się za pomocą Composer:

$: composer require netresearch/jsonmapper

Nasze klasy muszą nieco odchudzić swoją zawartość. Należy pozbyć się z każdej z nich, metody create. Pozostaje nam jedynie definicja pól publicznych wraz z adnotacją nt. typu.

class Who
{
    /**
     * @var string
     */
    public $email;
}

class Item
{
    /**
     * @var int
     */
    public $id;
    
    /**
     * @var int
     */
    public $price;
    
    /**
     * @var string
     */
    public $name;
}

class Order
{    
    /**
     * @var Who
     */
    public $who;
    
    /**
     * @var Item[]
     */
    public $items = [];
    
    /**
     * @var DateTime
     */
    public $orderTime;
}

Następnie musimy przekazać wszelkie niezbędne informacje do nowego mappera:

include_once 'vendor/autoload.php'; // (0)

$mapper = new JsonMapper(); // (1)
$mapper->bEnforceMapType = false; // (2)
$order = json_decode(file_get_contents('order.json')); // (3)

var_dump($mapper->map($order, new Order())); // (4)

(1) Tworzymy instancję JsonMapper, nie wymaga ona żadnych parametrów dostarczanych do konstruktora.

(2) Konfigurujemy JsonMapper tak aby był w stanie przetworzyć każdy rezultat wywołania json_decode. Domyślnie oczekiwany jest rezultat w postaci tablicy asocjacyjnej.

(3) Ładujemy strukturę JSON z pliku, a następnie deserializujemy ją za pomocą funkcji json_decodeJsonMapper wymaga rezultatu wywołania funkcji json_decode().

(4) Wykonujemy proces mapowania na obiekty odpowiednich klas.

Efektem końcowym jest identycznie zbudowany obiekt Order jak w przypadku mapowania realizowanego w sposób manualny. Różnica jest w kwestii wykorzystania – cały ręczny element implementacji znika, a JsonMapper robi za nas „brudną robotę” 🙂

Podsumowanie

W poprzednich dwóch wpisach opierałem się na następujących podejściach rozwiązywania problemu:

  • z użyciem wbudowanej obsługi JsonSerializable w języku PHP,
  • przy użyciu zewnętrznej biblioteki implementującej standard JSON Schema.

Każde z zaproponowanych rozwiązań, dotyczy pewnego kontekstu. W tym przypadku nie jest inaczej – jak już wspominałem w innym artykule – to dodatkowa zależność w Twoim projekcie. To kolejny kawałek kodu który należy dołożyć z zewnątrz. Z drugiej strony – przetestowanego, działającego i popularnego kodu.

Jednak podsumowując – jeżeli nadmiarowość ręcznej implementacji Cię przerasta, to JsonMapper może być dobrym wyborem. Tak, też było w moim przypadku. Warto przypomnieć, że mapowanie struktury na obiekty (znanych klas, a nie stdClass) sprawia, że autouzupełnianie w IDE – działa! Do tego, łatwość użycia i wspieranie Immutable Object jest dużym plusem.

Dodam jeszcze, że artykuł nie wyczerpuje pełnego zakresu dostępnych opcji biblioteki JsonMapper raczej wskazuje, że jest ona jedną z ciekawszych, które można zastosować. Jeżeli staniesz przed wyzwaniem JSON => PHP Object to polecam Ci serdecznie JsonMapper oraz zapoznanie się z dokumentacją biblioteki w której opisano wszystkie dostępne opcje.

Na co dzień programujący CTO w Emphie Solutions. Projektuje, tworzy oraz wdraża rozwiązania oparte o ekosystem JavaScript. Rozwija swoje umiejętności z zakresu Cloud / DevOps / SRE. Fascynat programowania, architektury, chmury i dobrych praktyk w szerokim ujęciu. Na temat technologii publikuje materiały w ramach projektu DevEnv, którego jest założycielem.
PODZIEL SIĘ