CodeGym /Kursy /Kotlin SELF /Wprowadzenie do serializacji

Wprowadzenie do serializacji

Kotlin SELF
Poziom 47, Lekcja 0
Dostępny

1. Wprowadzenie

Gdy piszemy program, zwykle myslimy tak: „mam liste wydatkow, kazdy wydatek ma kwote i kategorie, moge policzyc sume”. To wszystko jest prawda, ale tylko dopoki program dziala. Obiekt w pamieci to struktura danych zyjaca wewnatrz procesu i nie jest przeznaczona do podrozy w prawdziwy swiat: do pliku, do sieci albo do innego programu. Gdy tylko aplikacja sie zamknie, wszystkie nasze List<Expense> odchodza w cyfrowy niebyt (prawie jak karty w przegladarce, ktore „na pewno byly wazne”).

Ustalmy prosty model. Dopoki program dziala, dane istnieja w RAM jako obiekty Kotlin/JVM. Ale zeby zachowac je miedzy uruchomieniami, potrzebujemy przenosnej postaci: zwykle tekstu albo bajtow. I tu pojawia sie kluczowe slowo dzisiejszego wykladu: serializacja.

Wyobrazmy sobie, ze mamy model wydatku:


data class Expense(
    val id: Int,
    val title: String,
    val amountCents: Int,
    val category: String
)

Takie obiekty swietnie zyja na liscie, sortuja sie, filtruja i biora udzial w obliczeniach. Ale „wlozyc obiekt bezposrednio do pliku” nie potrafimy tak latwo, jak file.writeText(expense) — plik rozumie tekst (albo bajty), a nie nasze typy Kotlin.

2. Serializacja i deserializacja

Gdy mowimy „zapisac dane”, tak naprawde chcemy wykonac konkretny trik: zamienic obiekt na przenosna postac i z powrotem. W programowaniu od dawna jest to sformalizowane dwoma terminami.

Serializacja — to przeksztalcenie obiektu (albo grupy obiektow) do formatu przechowywania/przesylania: tekstu lub zestawu bajtow.

Deserializacja — operacja odwrotna: odtworzenie obiektu z tego tekstu/bajtow.

W zyciu codziennym przypomina to przeprowadzke. Dopoki mieszkasz w mieszkaniu, rzeczy leza „jak wygodnie”: kubki na polce, skarpety w szufladzie, ladowarka zawsze ginie. Ale zeby przewiezc wszystko do innego mieszkania, pakujesz rzeczy do pudel wedlug zrozumialych zasad (i opisujesz je, jesli jestes optymista). Serializacja to pakowanie, a deserializacja — rozpakowywanie.

Schemat zwykle wyglada tak:

flowchart TB
    A["Obiekty Kotlin w pamieci
List⟨Expense⟩"] --> B["Serializacja
obiekt → tekst/bajty"] B --> C["Przechowywanie/przesyl
plik/siec/BD"] C --> D["Deserializacja
tekst/bajty → obiekt"] D --> E["Obiekty Kotlin w pamieci
List⟨Expense⟩"]

Dlaczego to wazne? Bo oddziela „dane jako znaczenie” od „danych jako reprezentacji”. Nasze Expense to znaczenie, a plik to reprezentacja.

3. Dlaczego toString() sie nie nadaje

W tym momencie prawie kazdy ma pokuse: „Chwila, przeciez println(expense) wypisuje cos zrozumialego. To znaczy, ze mozna to zapisac do pliku i potem wczytac z powrotem!”

Zobaczmy. Jesli mamy data class, Kotlin automatycznie daje sensowne toString():

data class Expense(val id: Int, val title: String, val amountCents: Int, val category: String)

fun main() {
    val e = Expense(1, "Kawa", 299, "food")
    println(e) // Expense(id=1, title=Kawa, amountCents=299, category=food)
}

Wyglada prawie jak „format danych”. Ale problem polega na tym, ze toString() to nie jest kontrakt przechowywania. To tekst dla czlowieka (przede wszystkim do debugowania), a nie obietnica, ze da sie go niezawodnie sparsowac z powrotem.

Jest kilka powodow, dla ktorych uzywanie toString() jako „formatu” jest niebezpieczne:

  • Po pierwsze, format toString() moze sie zmienic. Dzis Kotlin drukuje Expense(id=..., ...), jutro (albo Ty sam w przyszlosci) nadpiszesz toString() „dla ladnego wygladu” — i koniec, starych plikow nie da sie juz odczytac.
  • Po drugie, toString() nie dba o escapowanie. Jesli title zawiera przecinki, nawiasy, znak nowej linii — tekst stanie sie niejednoznaczny. I tak, predzej czy pozniej uzytkownik wpisze nazwe w stylu Ser (bardzo smaczny) i dowiesz sie wielu nowych rzeczy o parsowaniu.
  • Po trzecie, tekst toString() nie potrafi sensownie opisywac trudniejszych przypadkow: null, zagniezdzonych list, map, nowych pol i zgodnosci wersji.

Z kolekcjami sytuacja jest podobna: lista ma tekstowa reprezentacje, i domyslnie przypomina ona joinToString(). W dokumentacji Kotlin wprost zaznaczono, ze wywolanie joinToString() z domyslnymi ustawieniami daje wynik podobny do toString() kolekcji. Ale to nadal jest „czytelne”, a nie „da sie niezawodnie sparsowac”.

4. Dlaczego reczne skladanie JSON jest ryzykowne

Gdy ktos zrozumie, ze toString() sie nie nadaje, czesto przechodzi na kolejny poziom pewnosci: „Napisze funkcje, ktora zlozy tekst w potrzebnym formacie”. I gdzies w tym momencie programowanie zaczyna sie po cichu smiac pod nosem.

Przyklad „recznej serializacji” (mocno uproszczony):

data class Expense(val id: Int, val title: String, val amountCents: Int, val category: String)

fun expenseToJsonLike(e: Expense): String {
    return """{"id":${e.id},"title":"${e.title}","amountCents":${e.amountCents},"category":"${e.category}"}"""
}

fun main() {
    val e = Expense(1, "Kawa", 299, "food")
    println(expenseToJsonLike(e))
    // {"id":1,"title":"Kawa","amountCents":299,"category":"food"}
}

Dopoki w title i category nie ma cudzyslowow, znakow nowej linii i innych „radości zycia”, wydaje sie, ze wszystko dziala. Ale gdy tylko ktos wpisze nazwe:

  • Kawa "na wynos" (z cudzyslowami),
  • Sushi\nna dwoje (ze znakiem nowej linii),
  • albo po prostu C:\Temp (z odwrotnymi ukosnikami),

Twoj tekst przestanie byc poprawny, bo JSON wymaga escapowania.

I to dopiero pierwszy problem. Potem pojawiaja sie przecinki, spacje, obiekty zagniezdzone, tablice, null, wartosci domyslne, kolejnosc pol i „ups, zapomnielismy zamknac klamre”.

Moral jest prosta: recznie skladac teksty mozna, ale to jak proba zbudowania samolotu z tasmy klejacej. W pewnym momencie nawet wystartuje, ale nie chcesz byc pasazerem.

Dlatego w prawdziwych projektach serializacje prawie zawsze robi sie biblioteka, ktora:

  • poprawnie escapuje tekst,
  • scisle przestrzega zasad formatu,
  • potrafi kodowac kolekcje i zagniezdzone struktury,
  • potrafi zdekodowac z powrotem do typow.

Dzis nie podlaczamy biblioteki (to bedzie dalej w kursie), ale wazne jest zrozumienie motywacji: biblioteka to nie „moda”, tylko ochrona przed recznymi bledami.

5. JSON i kontrakt danych

Dlaczego akurat JSON

Gdy wybieramy format serializacji, wybieramy kompromis: miedzy wygoda, przenosnoscia, rozmiarem i scislymi zasadami. JSON stal sie popularny nie dlatego, ze jest idealny, tylko dlatego, ze calkiem dobrze rownowazy wszystko naraz.

JSON to format tekstowy, ktory:

  • Po pierwsze, latwo czyta sie oczami. To wazne nie tylko „dla ladnego wygladu”. Gdy Twoj plik sie zepsuje, mozliwosc otworzenia go w notatniku i zrozumienia „co tam w ogole lezy” — to supermoc.
  • Po drugie, jest wspierany prawie wszedzie. Kotlin, Java, JavaScript, Python — wszyscy potrafia czytac/pisac JSON. Dlatego JSON jest wygodny do wymiany danych miedzy systemami i do przechowywania „na dysku”.
  • Po trzecie, JSON jest wystarczajaco ustrukturyzowany, zeby nie zamienic sie w „plachte tekstu”. W przeciwienstwie do „zapiszmy wszystko jednym tekstem oddzielonym ;”, JSON od razu narzuca ksztalt: obiekt, pola, tablice. Tak, scisle zasady czasem irytuja (cudzyslowy, przecinki, null bez cudzyslowow), ale to wlasnie one ratuja nas przed chaosem.

I na koniec: JSON dobrze pasuje do naszego modelu danych — „obiekt z polami” i „lista obiektow” naturalnie mapuja sie na obiekty JSON {...} i tablice [...]. Strukture JSON szczegolowo omowimy w jednej z kolejnych lekcji, a dzis wystarczy nam intuicyjny obrazek.

Kontrakt danych

Gdy zapisujesz dane, tak naprawde zawierasz umowe z przyszlym soba. Czasem — z kolega z zespolu. Czasem — z innym programem. Ta umowa nazywa sie kontrakt danych.

Kontrakt to odpowiedz na pytania: jakie pola ma obiekt, jak sie nazywaja, jakie maja typy, ktore pola sa wymagane, a ktore moga nie wystapic.

Na przyklad dla naszego Expense kontrakt moglby brzmiec tak (prostymi slowami):

  • wydatek ma id (liczba),
  • title (tekst),
  • amountCents (liczba),
  • category (tekst).

Dlaczego to takie istotne? Bo deserializacja to nie „magia odtwarzania”. To scisle dopasowanie: czytamy tekst i probujemy zbudowac obiekt. Jesli kontrakt jest naruszony, odczyt moze sie wywalic albo zwrocic bledne dane.

Pokaze na zyciowym przykladzie. Zalozmy, ze zapisales wydatek tak:

val text = """{"id":1,"title":"Kawa","amountCents":299,"category":"food"}"""
println(text)

A potem po miesiacu postanowiles zmienic nazwe pola amountCents na amount (bo „tak krocej”). W kodzie jest ladniej, ale stare pliki nadal zawieraja amountCents, a nowy kod oczekuje amount. To konflikt kontraktu: dane „z przeszlosci” przestaly odpowiadac modelowi „z terazniejszosci”.

Dlatego kazdy format przechowywania danych wymaga dyscypliny. Serializacja to nie tylko technika „jak zapisac”, ale tez projekt „jakie dane uznajemy za poprawne”.

6. Co przechowywac w naszej aplikacji

Mini-szkic struktury pliku

Gdy po raz pierwszy dodajesz zapis do aplikacji, warto na chwile sie zatrzymac i sformulowac: co dokladnie chcemy przechowywac miedzy uruchomieniami. To pomaga nie mieszac „danych” z „wyswietlaniem na ekranie”.

Dla naszego trackera wydatkow sensownie jest przechowywac liste wydatkow i byc moze drobne ustawienia. Na przyklad walute, nastepne id albo liste kategorii (jesli robimy je konfigurowalne). Dzis nie wchodzimy w szczegoly, ale na poziomie pomyslu moze to wygladac tak:

Plik danych (np. data/expenses.json)
└── obiekt najwyzszego poziomu
    ├── nextId: liczba
    ├── expenses: tablica wydatkow
    └── currency: tekst (opcjonalnie)

W postaci JSON mogloby to wygladac mniej wiecej tak (to tylko przyklad formy, a nie „jedyny poprawny” wariant):

fun main() {
    val demoJson = """
        {
          "nextId": 3,
          "currency": "USD",
          "expenses": [
            { "id": 1, "title": "Kawa", "amountCents": 299, "category": "food" },
            { "id": 2, "title": "Bilet", "amountCents": 250, "category": "transport" }
          ]
        }
    """.trimIndent()

    println(demoJson)
}

Tutaj wazny jest nie skladniowy szczegol, tylko mysl: JSON pozwala przechowywac i liste obiektow, i dodatkowe pola obok, nie zamieniajac wszystkiego w papke.

Gdzie jest miejsce na biblioteke serializacji

Teraz, gdy mamy motywacje, mozna uczciwie powiedziec: „OK, JSON jest swietny, ale kto bedzie pisal kod, ktory zamienia List<Expense> w taki tekst i z powrotem?”

Odpowiedz: biblioteka serializacji.

W swiecie Kotlin popularna opcja to kotlinx.serialization, bo dobrze wspolpracuje z data class, wspiera typy Kotlin i dziala przewidywalnie. Ale sam fakt „bedzie biblioteka” jest dzis mniej wazny niz zrozumienie roli tej biblioteki.

Rola biblioteki to bycie Twoim „tlumaczem” miedzy dwoma swiatami:

  • swiatem obiektow Kotlin (scisle typy, Int, String, List<Expense>, pola nullable),
  • oraz swiatem tekstu (tekst JSON, gdzie wszystko musi byc poprawnie rozmieszczone, zaescapowane i ustrukturyzowane).

Jesli tlumacz jest dobry, piszesz mniej kodu, rzadziej sie mylisz, a Twoje pliki nie psuja sie przez cudzyslow w nazwie zakupu.

7. Typowe bledy

Blad nr 1: traktowanie toString() jako formatu przechowywania danych.
toString() faktycznie czesto wyglada „prawie jak dane”, zwlaszcza w data class. Ale to reprezentacja do czytania przez czlowieka, a nie stabilny kontrakt dla maszyny. Dzis jest jedna, jutro zmieniles model albo nadpisales metode — i stare pliki przestaly sie wczytywac.

Blad nr 2: „szybko skleic JSON w tekscie” bez escapowania.
Reczne skladanie JSON przez """...$value...""" prawie na pewno zepsuje sie na cudzyslowach, znakach nowej linii i odwrotnych ukosnikach. Na danych testowych wszystko wyglada OK, a potem uzytkownik wpisuje nazwe z "..." — i format sie rozpada. To jak testowanie spadochronu przez zrzucenie go ze stolka.

Blad nr 3: mieszanie „danych” i „ladnego wyswietlania”.
Czasem kusi, zeby trzymac w pliku gotowe teksty w stylu "Kawa — 2.99 USD". To wygodne do drukowania, ale niewygodne do obliczen i zmian. Warto przechowywac surowe wartosci (np. amountCents jako liczbe), a formatowac dla czlowieka — przy wyswietlaniu.

Blad nr 4: brak myslenia o kontrakcie i zmienianie nazw pol „jak popadnie”.
Gdy tylko dane zaczynaja zyc w pliku, nie obchodzi ich, ze zmieniles amountCents na amount. Plik pozostaje stary. Jesli kontrakt zmienia sie bez strategii, odczyt sie wywala albo zaczyna blednie interpretowac dane.

Blad nr 5: przekonanie, ze „skoro JSON jest tekstowy, to zawsze jest bezpieczny”.
JSON latwiej naprawic recznie niz format binarny, ale nie chroni przed bledami logicznymi: blednymi typami, brakujacymi polami, null tam, gdzie sie go nie spodziewales, albo dodatkowymi polami ze starych wersji. Format tekstowy ulatwia diagnostyke, ale nie zastepuje walidacji i ostroznego projektu.

1
Zadanie
Kotlin SELF, poziom 47, lekcja 0
Niedostępne
Kontrakt wydatku
Kontrakt wydatku
1
Zadanie
Kotlin SELF, poziom 47, lekcja 0
Niedostępne
Niebezpieczny JSON
Niebezpieczny JSON
1
Zadanie
Kotlin SELF, poziom 47, lekcja 0
Niedostępne
Ratunek dla cudzysłowów
Ratunek dla cudzysłowów
1
Zadanie
Kotlin SELF, poziom 47, lekcja 0
Niedostępne
Archiwum wydatków
Archiwum wydatków
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION