Python, kodowanie JSON

JSON JavaScript Object Notation to lekki i prosty format zapisu danych chętnie wykorzystywany do ich wymiany. Jego forma i czytelność jest przystępna zarówno dla ludzi jak i maszyn, co więcej jest on obsługiwany pomiędzy różnymi językami programowania. Czynniki te ewidentnie zadecydowały o jego popularności.

Standard JSON został szczegółowo opisany w krótkim dokumentach:

W języku Python obsługa standardu JSON jest zaimplementowana w module json w ramach standardowej biblioteki. Oficjalna dokumentacja modułu, json — JSON encoder and decoder, stanowi najlepsze źródło wiedzy i przykładów stosowania.

Kodowanie typów prostych

Współpracę Python’a z obsługą danych w formacie JSON i prostotę obsługi tak zakodowanych, danych - składających się z typów podstawowych - możemy prześledzić na prostym kodu znajdującym się poniżej:

import json
from pprint import pprint

if __name__ == '__main__':
    original = {'imię':     'Jan', 
                'nazwisko': 'Nowak', 
                'adres':    {   'miasto': 'Łódź', 
                                'ulica': 'Piotrkowska', 
                                'numer_domu': 17, 
                                'numer_lokalu': None} }
    dump = json.dumps(original, sort_keys=True, indent=4)
    loaded = json.loads(dump)

    print("Print dumped original of type (%s)" % (type(dump), ) )
    print(dump)

    print("Print loaded dump: (%s)" % (type(loaded), ) )
    pprint(loaded)

Zakodowane w formacie utf-8 dane, umieszczone w słowniku original, migrujemy do formatu JSON po czym ponownie przywracamy. Zwróć uwagę na kilka istotnych rzeczy widocznych na po uruchomieniu skryptu:

  • sposób konwersji, kodowania, polskich znaków diaktrycznych,
  • estetykę z jaką zwykły print wyświetla zakodowane dane,
  • typy danych źródłowych, zakodowanych i ponownie odkodowanych.

Dane w kroku Print dumped original of type (<class "str">):

{
    "adres": {
        "miasto": "\u0141\u00f3d\u017a",
        "numer_domu": 17,
        "numer_lokalu": null,
        "ulica": "Piotrkowska"
    },
    "imi\u0119": "Jan",
    "nazwisko": "Nowak"
}

Dane w kroku Print loaded dump: (<class "dict">):

{'adres': {'miasto': 'Łódź',
           'numer_domu': 17,
           'numer_lokalu': None,
           'ulica': 'Piotrkowska'},
 'imię': 'Jan',
 'nazwisko': 'Nowak'}

Poruszanie się w obrębie typów prostych nie wymaga dodatkowych kroków. Sytuacja nieznacznie komplikuje się, gdy zamierzamy pracować z obiektami.

Kodowanie typów złożonych, własnych

Domyślny koder i dekoder zawarty w pakiecie json przystosowany jest do radzenia sobie z podstawowymi typami danych. Umożliwia jednakże swoją rozbudowę dzięki czemu z jego pomocą można zapisać w formacie JSON dowolny obiekt, czy typ danych.

Dla przykładu posłużymy się dwiema klasami. Person reprezentuje osobę dokonującą transakcję, natomiast Transaction opisuję dokonywaną między osobami transakcję. Jak już wiesz kodowanie i dekodowanie typów prostych jak str, czy float jest domyślnie zaimplementowane w pakiecie json. Problem jaki wystąpi przy kodowaniu poniższego kodu związany jest z obsługą obiektów Person, oraz datetime.datetime.

from dataclasses import dataclass
import datetime
import json
from typing import Any

@dataclass(frozen=True)
class Person():
    name: str 
    surename: str 
    email: str
    
@dataclass(frozen=True)
class Transaction():
    """ Object holding paylaod data. """
    sender: Person
    recipient: Person
    data: datetime.datetime
    amount: float

Wykonując poniższy kod otrzymamy wyjątek TypeError: Object of type datetime is not JSON serializable kodera pakietu json związany z nieobsługiwanym typem danych.

p1 = Person(name="John", surename="Wick", email="john.wick@email.com")
p2 = Person(name="Bowery", surename="King", email="bowery.king@email.com")
t1 = AdvancedTransaction(sender=p1, 
                         recipient=p2, 
                         data=datetime.datetime.now(), 
                         amount=1.61)

t1_json = json.dumps(t1.__dict__, sort_keys=True) # Exception: Type Error

Poradzenie sobie z tym błędem wymaga od nas implementacji kodera danych mówiącego w jaki sposób należy przechować nieobsługiwane dotąd klasy. Nowa implementacja kodera musi dziedziczyć z klasy json.JSONEncoder i definiować metodę default zwracającą tekst z reprezentacją dodawanych przez nas obiektów. Przykładowa implementacja:

class JsonEncoder(json.JSONEncoder):
    """ Class serializing project specific data into JSON format. """

    DATE_FORMAT = "%Y-%m-%d"
    TIME_FORMAT = "%H:%M:%S"
    def default(self, obj:Any) -> str:
        if isinstance(obj, Person):
            _j = {}
            for k, v in obj.__dict__.items():
                _j[k] = v
            return {'_type': 'Person',
                    'value': _j}
        elif isinstance(obj, datetime.datetime):
            return {  "_type": "datetime",
                      "_format": "%s %s" % (self.DATE_FORMAT, self.TIME_FORMAT),
                      "value": obj.strftime( "%s %s"% ( self.DATE_FORMAT,
                                                        self.TIME_FORMAT)) }
        else:
            raise ValueError("Not supported object type")

Korzystając z klasy JsonEncoder będziemy w stanie bez trudu przenieść do formatu JSON sprawiającą nam problem instancję Transaction. By tego dokonać metodzie json.dumps podajemy atrybut cls:

t1_json = json.dumps(t1.__dict__, cls=JsonEncoder, sort_keys=True, indent=2)
# t1_json = 
# {
#   "amount": 1.61,
#   "data": {
#     "_format": "%Y-%m-%d %H:%M:%S",
#     "_type": "datetime",
#     "value": "2020-11-02 15:17:59"
#   },
#   "recipient": {
#    "_type": "Person",
#      "value": {
#      "email": "bowery.king@email.com",
#      "name": "Bowery",
#      "surename": "King"
#     }
#   },
#   "sender": {
#     "_type": "Person",
#     "value": {
#       "email": "john.wick@email.com",
#       "name": "John",
#       "surename": "Wick"
#     }
#   }
# }

Teoretycznie możemy na tym poprzestać, jednak uważam, że przydatne będzie dodanie do naszego kodu możliwości odtworzenia zakodowanych w formacje JSON obiektów. Jak się domyślasz nie stanie się to auto-magicznie i potrzebne jest dopisanie do kodu dekodera dziedziczącego po json.JSONDecoder i implementującego metodę object_hook.

class JsonDecoder(json.JSONDecoder):
    """ JSON decoder prepared to handle project specific data. """

    def __init__(self, *args, **kwargs):
        super(JsonDecoder, self).__init__( object_hook=self.object_hook,
                                                *args)

    def object_hook(self, obj):
        if "_type" not in obj:
            return obj
        elif obj["_type"] == "Person":
            return Person(  name    =obj["value"]["name"],
                            surename=obj["value"]["surename"],
                            email   =obj["value"]["email"])
        elif obj["_type"] == "datetime":
            return datetime.datetime.strptime( obj['value'], obj['_format'] )
        else:
            msg = "Unsupported object type '%s'" % obj["_type"]
            raise json.JSONDecoderError(msg)

Korzystnie z dekodera sprowadza się do wykonania kodu:

decoder = JsonDecoder()
t1_loaded = decoder.decode(t1_json))

Teraz obiekt t1_loaded będzie posiadał tę samą zawartość co oryginalny obiekt t1.

Podsumowanie

Liczę na to, że ta krótka prezentacja uzmysłowiła Ci jak wygodnym formatem jest JSON i dlaczego warto go stosować. Przede wszystkim jest on czytelny zarówno dla maszyn jak i dla ludzi, co czyni go wyjątkowo praktycznym rozwiązaniem.

Jeżeli zamierzasz do tego, żeby żyło Ci się wygodnie, prawdopodobnie nigdy nie będziesz bogaty. Lecz jeśli zmierzasz do tego, by być bogatym, prawdopodobnie będzie Ci w końcu niesamowicie wygodnie. 'Bogaty albo biedny' T. Harv Eker