CodeGym /Kursy /Java Server /Zewnętrzny JSON i model aplikacji

Zewnętrzny JSON i model aplikacji

Java Server
Poziom 11, Lekcja 1
Dostępny

1. Dwa różne światy: kontrakt i obiekt wewnętrzny

Jeśli wyobrazić sobie aplikację jako sklep, to kontrakt JSON to witryna i kasa: to, co widzi klient i po czym ocenia, „jak to tu wszystko działa”. Model wewnętrzny to magazyn i kuchnia: tam zależy nam, żeby wszystko było wygodne dla pracowników, a nie dla klientów. A jak wystawisz magazyn na witrynę, będzie… nietypowo.

Kiedy mówimy „zewnętrzny kontrakt JSON”, mamy na myśli obietnicę, którą API daje klientowi: jakie pola może przesłać, jakie dostanie w odpowiedzi, jak te pola się nazywają, jakich typów i wartości oczekujemy. W świecie backendu to nie „mniej więcej tak”, tylko „albo tak, albo ból”. Ból przychodzi szybko: klienci się psują, kolekcje Postman stają się nieaktualne, integracje zamieniają się w zgadywankę.

A „wewnętrzny model aplikacji” żyje według innych praw. Zależy mu na tym, żeby był wygodny dla twoich algorytmów, sprawdzeń i przyszłych zmian. Tam jak najbardziej można mieć dodatkowe pola, metody, wartości wyliczane, służbowe oznaczenia. Model wewnętrzny może być „bogaty” — ale to bogactwo nie musi stać się publiczne.

Żeby się nie pogubić, warto trzymać w głowie prosty schemat:

flowchart TD
    C[Klient / Postman] -->|JSON request| R[Request DTO]
    R -->|tłumaczenie / mapping| M[Model wewnętrzny]
    M -->|tłumaczenie / mapping| S[Response DTO]
    S -->|JSON response| C

Nie ma tu magii: DTO — na granicy, model wewnętrzny — pośrodku. A naszym zadaniem jest sprawić, by te światy mogły rozwijać się niezależnie, bez podstawiania sobie nawzajem nogi.

2. Model wewnętrzny: czym jest i po co

Po słowie „model” u wielu od razu włącza się tryb „to coś strasznego z Enterprise-świata”. Uspokójmy mózg: model wewnętrzny to po prostu twoje obiekty robocze, z którymi wygodnie pisać kod. Nie dla klienta, nie dla JSON, nie „żeby było ładnie”, tylko żeby aplikacja nie rozsypała się przy pierwszej zmianie.

Wyobraźmy sobie przyszłą serwerową fazę ReadLater Starter (na razie nie piszemy serwera, uczymy się myśleć). Będziemy mieć encję „element listy czytania”. Wewnątrz aplikacji może być nam wygodnie przechowywać np. czas utworzenia albo notatkę służbową, której nigdy nie chcemy oddawać na zewnątrz. Albo mieć metodę, która sprawdza stan (isFinished()), żeby nie rozsmarowywać porównań po całym kodzie.

Przykład obiektu wewnętrznego (nie DTO), który „jest wygodny aplikacji”:

import java.time.Instant;

public class ReadingListItem {
    private final long id;
    private final String title;

    // Wewnątrz wygodniej pracować ze statusem jako enum, a nie jako stringiem z JSON-a.
    private ReadingStatus status;

    // Pole służbowe: ważne dla wewnętrznej logiki/audytu, ale nie musi być częścią API.
    private final Instant createdAt = Instant.now();

    // Zachowanie żyje w modelu: to zmniejsza duplikację sprawdzeń w całym kodzie.
    public boolean isFinished() {
        return status == ReadingStatus.FINISHED;
    }
}

Zwróć uwagę na dwie rzeczy. Po pierwsze, jest tu zachowanie (isFinished()), a nie tylko dane. Po drugie, jest pole służbowe createdAt, które jest przydatne nam, ale może w ogóle nie być potrzebne klientowi.

A teraz przykład DTO, który opisuje zewnętrzną odpowiedź (jego celem jest kontrakt):

// DTO dla odpowiedzi: minimum logiki, maksimum przewidywalności dla klienta.
public record ReadingItemResponse(
        long id,
        String title,
        String status // W zewnętrznym kontrakcie status może być stringiem (np. "FINISHED").
) { }

DTO zwykle jest prostsze: nie musi zawierać metod ani „wewnętrznych tajemnic”. Musi być przewidywalne dla klienta. Model wewnętrzny musi być przewidywalny dla twojego kodu.

Czasem ma się ochotę powiedzieć: „Po co dwie klasy, jeśli można jedną?”. Odpowiedź jest mniej więcej taka sama, jak na pytanie „po co dwie pary butów, jeśli można jedną na wszystkie okazje”. Można, jasne. Zwłaszcza jeśli nigdy nie wychodzisz na zewnątrz i nie spotykasz ludzi. Ale gdy tylko zaczyna się prawdziwe życie, oszczędność zamienia się w ciągle brudne adidasy i dziwne spojrzenia otoczenia.

3. Kiedy model wewnętrzny trafia do API

Pokusa „oddawania na zewnątrz tego, co i tak jest w środku” jest bardzo zrozumiała: mniej kodu, mniej klas, mniej powodów do myślenia. Ale to dokładnie ten przypadek, kiedy „mniej teraz” zamienia się w „więcej bólu potem”. I ból zwykle przychodzi nie w momencie pisania kodu, tylko gdy coś zmieniasz po tygodniu.

Wyobraź sobie, że zdecydowałeś się przechowywać wewnątrz ReadingListItem pole internalNote. Drobiazg: „dlaczego dodałem tę książkę” albo „kupić wersję papierową”. Wewnątrz jest ci wygodnie. Ale jeśli używasz obiektu wewnętrznego jako modelu response, to pole może nagle stać się częścią zewnętrznego kontraktu. Nawet jeśli osobiście tego nie planowałeś.

Problem nr 1 — kontrakt staje się kruchy. Zmieniłeś obiekt wewnętrzny (dodałeś pole, zmieniłeś nazwę, zmieniłeś typ), a klient nagle zaczyna dostawać inne ciało JSON albo nie może wysłać żądania „jak wcześniej”. Klient w tym momencie nie myśli: „O, programista ładnie refaktoruje!”. Klient myśli: „Dlaczego wasze API się popsuło?”.

Problem nr 2 — wyciek zbędnych danych. Nawet jeśli to nie są sekrety (mamy kurs bez bezpieczeństwa), mogą to być śmieci dla klienta: notatki służbowe, wewnętrzne flagi, wartości techniczne. Klient musi to ignorować, a potem pojawiają się pytania „czy to pole jest obowiązkowe?”, „czy można je zmieniać?”, „co będzie, jeśli je wyślę?”.

Problem nr 3 — tracisz swobodę wewnętrznej ewolucji. Model wewnętrzny chce się ulepszać: gdzieś zrobić enum, gdzieś dodać wyliczane pole, gdzieś zmienić strukturę. Ale jeśli model wewnętrzny = zewnętrzny kontrakt, każda wewnętrzna zmiana zamienia się w publiczną zmianę API. To bardzo kiepski układ.

Problem nr 4 — zaczynasz mylić „wygodne dla kodu” i „wygodne dla klienta”. Na przykład wygodnie ci przechowywać autora jako jeden string „Martin, Robert C.” albo w ogóle jako obiekt z polami. Klientowi może być wygodnie inaczej. Ale jeśli masz jeden model, albo męczysz klienta, albo męczysz siebie. Wybierasz, kto cierpi. Zwykle cierpią obaj.

Warto zobaczyć to w tabeli porównawczej — bez tragedii, po prostu jako fakt inżynierski:

Pytanie DTO (forma zewnętrzna) Model wewnętrzny
Dla kogo zrobiony dla klienta i kontraktu dla twojego kodu
Co jest ważne stabilne nazwy/typy pól wygoda logiki i niezmienników
Czy można dodawać „pola służbowe” zwykle nie trzeba często tak
Czy można zmieniać strukturę bez konsekwencji tylko jeśli gotów jesteś zmienić kontrakt tak, jeśli granica jest zachowana
Czy ma zachowanie (metody) prawie nigdy często tak

Mówiąc krótko, „wyciek” obiektu wewnętrznego na zewnątrz sprawia, że API wygląda jak pokój, w którym wszystko trzymasz na podłodze. Niby wszystko widać i „prosta logika”, ale spróbuj coś zmienić — i zaczyna się quest „nie nadepnij na Lego”.

4. Przykład na ReadLater: wewnętrzne pole poza API

Żeby granica nie była abstrakcyjną filozofią, zróbmy bardzo przyziemny przykład. Wyobraź sobie: dodałeś książkę do listy czytania i chcesz wewnątrz przechowywać notatkę służbową dla siebie jako programisty — np. „dodane automatycznie z katalogu” albo „zaimportowane z pliku”. Klientowi to niepotrzebne, a czasem nawet przeszkadza.

Obiekt wewnętrzny może wyglądać tak:

public class ReadingListItem {
    private final long id;
    private final String title;
    private final String author;

    // To pole istnieje dla wewnętrznych potrzeb aplikacji, a nie dla zewnętrznego kontraktu.
    private String internalNote;

    public ReadingListItem(long id, String title, String author) {
        this.id = id;

        // Wewnątrz można normalizować dane jak nam wygodnie (np. usunąć spacje z brzegów).
        this.title = title.strip();
        this.author = author.strip();
    }
}

Tutaj internalNote to czysto wewnętrzny szczegół. Nie musimy nawet teraz go wypełniać. Ale najważniejsze: chcemy mieć prawo go dodać i nie tłumaczyć światu, dlaczego w odpowiedzi nagle pojawiło się pole internalNote.

A oto zewnętrzna odpowiedź, której klient rzeczywiście potrzebuje, może wyglądać tak:

// Forma zewnętrzna: tylko to, co realnie potrzebne klientowi w scenariuszu.
public record ReadingItemResponse(
        long id,
        String title,
        String author
) { }

Pytanie „dlaczego nie oddajemy internalNote” ma bardzo nudną i prawidłową odpowiedź: bo klient nie prosił, bo to nie jest część scenariusza, bo kontrakt powinien być minimalnie wystarczający.

Czasem studenci pytają: „A co złego, jeśli oddam? Niech ignoruje”. Problem polega na tym, że ignorowanie szybko zamienia się w zależność. Dziś klient ignoruje, jutro inny klient zdecyduje „o, przydatne pole”, zacznie się na nim opierać — i nagle zostaniesz zakładnikiem swojego przypadkowego szczegółu.

Jest dobra zasada: zewnętrzne API powinno być jak menu w kawiarni. W menu nie piszą „czasem zapominamy soli” i „kucharz dziś smutny”. To prawda życia, ale nie powinna być częścią kontraktu.

5. Jeden obiekt — kilka form zewnętrznych

Kiedy po raz pierwszy słyszysz „obiekt wewnętrzny i DTO to różne rzeczy”, wydaje się, że to „po prostu dwie klasy”. Ale prawdziwa korzyść odsłania się, gdy zrozumiesz: jeden i ten sam obiekt wewnętrzny może mieć kilka form zewnętrznych pod różne scenariusze. I to nie komplikowanie dla komplikowania, tylko wręcz odwrotnie — sposób na utrzymanie kontraktu czytelnym.

Na przykład w lokalnym API listy czytania (które będzie później) logicznie będziesz mieć różne odpowiedzi: jedną dla listy, drugą dla szczegółowej kartki. Wewnątrz może przy tym być jeden ReadingListItem. Po prostu na zewnątrz w jednym scenariuszu oddajesz „krótko”, a w drugim — „nieco szczegółowiej”.

Krótki element listy (przykład umowny, nie finalny projekt):

// DTO dla listy: kompaktowy, bez zbędnych pól.
public record ReadingListItemShortResponse(
        long id,
        String title,
        String status
) { }

Szczegółowa odpowiedź, gdzie jest już komentarz:

// DTO dla szczegółów: rozszerzona forma, bo scenariusz wymaga więcej danych.
public record ReadingItemDetailsResponse(
        long id,
        String title,
        String author,
        String status,
        String comment
) { }

Z punktu widzenia klienta to super-logiczne. Z punktu widzenia modelu wewnętrznego nie musi to być dwóch różnych obiektów. Co więcej, jeśli spróbujesz zrobić jeden „uniwersalny response na wszystko”, zacznie on puchnąć od pól, które w połowie przypadków są null. W rezultacie kontrakt będzie wyglądał jak walizka, do której wepchnąłeś letnie ubrania, zimowe i komplet garnków — „a nuż się przyda”. Używać można, ale ciężko.

I tu ważne, by nie pomylić z tematem wykładu 1. W wykładzie 1 mówiliśmy, że request i response różnią się sensem. Tu mówimy, że nawet dwa scenariusze response mogą się różnić. To wciąż ta sama idea: forma danych zależy od tego, do czego jest potrzebna.

W rezultacie wychodzi bardzo zdrowy obraz: obiekt wewnętrzny „żyje swoim życiem”, a zewnętrzne DTO to „różne sposoby pokazania go światu” bez utraty kontroli.

6. Mapping DTO ↔ model wewnętrzny

Granica między zewnętrznym kontraktem a wewnętrznym modelem wydaje się oczywista, dopóki nie zaczniesz pisać kodu. A potem pojawia się pytanie: „OK, mam CreateReadingItemRequest i mam ReadingListItem. Kto kogo tworzy? Gdzie zachodzi ta transformacja? Kto odpowiada za tłumaczenie?”. Dziś nie potrzebujemy idealnej odpowiedzi architektonicznej, ale potrzebna jest zrozumiała zasada.

Wygodna metafora — tłumacz. DTO mówi w języku „granicy JSON”, model wewnętrzny mówi w języku „wygodne aplikacji”. Tłumacz powinien mieszkać dokładnie tam, gdzie te języki się spotykają. Nie wszędzie, nie w każdej klasie „po trochu”.

Dla przykładu zróbmy minimalny „mapper” — po prostu jako idea:

public class ReadingItemMapper {

    public static ReadingItemResponse toResponse(ReadingListItem item) {
        // Tu celowo kontrolujemy, jakie pola trafią do odpowiedzi (kontrakt).
        return new ReadingItemResponse(
                item.getId(),
                item.getTitle(),
                item.getAuthor()
        );
    }
}

Jest tu subtelność: ReadingListItem musi mieć gettery. To normalne, po prostu ich nie rozpisujemy, żeby nie utonąć w szablonowym kodzie.

W odwrotną stronę (z requestu do obiektu wewnętrznego) też można mieć tłumaczenie:

public class ReadingItemFactory {

    public static ReadingListItem fromRequest(long id, CreateReadingItemRequest req) {
        // id zwykle generuje serwer, więc nie przychodzi z żądania, tylko jako osobny parametr.
        return new ReadingListItem(id, req.title(), req.author());
    }
}

A sam request DTO może wyglądać tak:

// DTO żądania: to, co klient ma prawo przysłać.
public record CreateReadingItemRequest(
        String title,
        String author
) { }

Sens nie polega na tym, żeby już teraz zbudować idealny system mappingu. Sens w tym, by utrzymać miejsce, w którym zachodzi transformacja. Gdy tylko nie masz tego miejsca, przekształcenia zaczynają „pełzać” po kodzie i znów masz sytuację „niby działa, ale strach dotykać”.

I jeszcze jedna ważna kwestia. Dopóki nie piszemy kodu parsowania i serializacji JSON, wszystko wygląda niewinnie. Ale gdy tylko pojawi się automatyczne przekształcanie obiektów na JSON (a pojawi się później), wyciek modelu wewnętrznego na zewnątrz zaczyna się odbywać bardzo łatwo. Dlatego nawyk trzymania granicy potrzebny jest z wyprzedzeniem, póki projekt jest mały i miły.

7. Pole wewnątrz a pole kontraktu

W tym miejscu zwykle chce się dostać listę „rób raz, rób dwa”. Ale umówiliśmy się unikać list dla samych list, więc zrobimy inaczej: damy kilka pytań, które możesz sobie zadawać, gdy decydujesz o losie pola. Te pytania nie są formalne, ale świetnie działają jako filtr.

Pierwsze pytanie brzmi: „Czy to pole jest potrzebne klientowi, by wykonać scenariusz?”. Jeśli nie, prawie na pewno jest wewnętrzne. Na przykład createdAt może być wewnętrzne, jeśli nie pokazujesz go w odpowiedzi i nie budujesz wokół niego scenariuszy.

Drugie pytanie: „Czy klient powinien mieć możliwość ustawiania tego pola?”. Jeśli tak, jest kandydatem do request DTO. Jeśli nie, jest albo wyliczane, albo systemowe, albo generowane na serwerze. Typowy przykład — id: częściej generuje je serwer i pojawia się w response, ale nie musi występować w create-request.

Trzecie pytanie: „Jeśli jutro zmienię to pole wewnątrz, czy chcę ogłaszać światu, że zmienił się kontrakt?”. Jeśli odpowiedź brzmi „nie, nie chcę”, to pole powinno być oddzielone od kontraktu warstwą DTO.

Czwarte pytanie: „Czy to pole odzwierciedla sens biznesowy dla użytkownika, czy techniczny szczegół implementacji?”. Na przykład „status czytania” to sens biznesowy. „internalNote” albo „cacheKey” to techniczny szczegół. Techniczne szczegóły prawie zawsze nie są potrzebne na zewnątrz.

Można to zebrać w małej tabelce-podpowiedzi:

Cecha pola Raczej DTO Raczej model wewnętrzny
Potrzebne klientowi do scenariusza tak niekoniecznie
Klient ma prawo ustawiać tak (request) częściej nie
Zmienia się przy refaktoryzacji niepożądane często tak
To „sens”, a nie „technika” tak czasem nie
Chcemy ukryć przed światem zewnętrznym nie tak

Te zasady nie gwarantują idealnego projektu, ale chronią przed najczęstszym błędem początkującego: „dodałem pole, bo mi tak wygodnie wewnątrz, czyli niech będzie w JSON”. Nie. Niech będzie wewnątrz. W JSON trafi tylko wtedy, gdy stało się częścią umowy.

8. Spring: co się automatyzuje, a co nie

Teraz wszystko robimy „ręcznie w głowie”: rozdzielamy DTO i obiekty wewnętrzne jako koncept. Później w świecie Spring i innych narzędzi pojawi się dużo automatyzacji: wygodne wiązanie request JSON z obiektami, wygodna serializacja response, wygodne adnotacje itd. Ale to nie zniesie najważniejszego: wciąż musisz zdecydować, gdzie przebiega granica.

Spring (i nie tylko Spring) zdejmuje rutynę, ale nie zdejmuje odpowiedzialności za kontrakt. Nie potrafi za ciebie zrozumieć, że internalNote nie powinno być w API. Nie zgadnie, że klientowi w liście potrzebna jest krótka kartka, a w szczegółach — rozszerzona. Uczciwie serializuje to, co mu dasz. Jeśli dasz mu obiekt wewnętrzny — uczyni obiekt wewnętrzny częścią świata zewnętrznego. W tym sensie Spring jest bardzo dobrze wychowany: nie spiera się, po prostu wykonuje.

Dlatego nawyk rozdzielania zewnętrznego kontraktu i wewnętrznego modelu to nie „do Spring-a, a potem zapomnimy”. To „do Spring-a, żeby w Spring-u nie popełniać tych samych błędów, tylko szybciej”.

9. Typowe błędy przy mieszaniu modeli

Błąd nr 1: „Po prostu oddawajmy obiekt wewnętrzny, a potem się zorientujemy”.
Wydaje się to oszczędnością czasu, ale w praktyce po prostu odkładasz spłatę kredytu na wysoki procent. Gdy tylko obiekt wewnątrz się zmieni, albo psujesz kontrakt, albo zaczynasz bać się zmieniać wewnętrzny kod. I jedno, i drugie to zły scenariusz.

Błąd nr 2: dodawać do DTO pola „na wszelki wypadek”.
Gdy nie jesteś pewien, czy pole jest potrzebne klientowi, mózg proponuje „niech będzie”. W rezultacie DTO zamienia się w „wysypisko możliwości”, a kontrakt staje się niejasny. Jeśli pole realnie nie uczestniczy w scenariuszu klienta, lepiej go nie dodawać. Dodać później jest łatwiej, niż tłumaczyć, dlaczego „zbędne pole” nagle przestało działać albo zmieniło sens.

Błąd nr 3: mylić „wygodne do serializacji” z „wygodne w użyciu”.
Czasem model wewnętrzny robi się wygodny dla kodu, a potem próbuje się zmusić klienta do życia w tej logice: dziwne nazwy, dziwne struktury, dziwne typy. Pamiętaj: DTO istnieje dla klienta. Nawet jeśli teraz „klientem” jesteś sam w Postman-ie, za tydzień klientem może stać się inny serwis albo inny programista.

Błąd nr 4: uzależniać zewnętrzny JSON od przypadkowej struktury klas.
Jeśli forma odpowiedzi to po prostu „jakie pola znalazły się w klasie”, jesteś w trybie przypadkowego kontraktu. Kontrakt powinien być projektowany jak umowa, a nie powstawać jako efekt uboczny. Dziś dodałeś pole dla wygody — i przypadkiem stało się publiczne. Jutro usunąłeś pole — i przypadkiem zepsułeś integrację.

Błąd nr 5: próbować jedną klasą pokryć wszystko: request, response, model wewnętrzny i „jeszcze trochę”.
„Uniwersalne DTO” prawie zawsze najpierw wygląda jak zwycięstwo, a potem jak kara. Miesza kryteria żądania, dane odpowiedzi, wewnętrzne pola służbowe i zaczyna żyć własnym życiem. W rezultacie dostajesz najnieprzyjemniejszy efekt: kod jest, ale nie da się go czytać.

1
Zadanie
Java Server, poziom 11, lekcja 1
Niedostępne
Obiekt wewnętrzny i odpowiedź zewnętrzna
Obiekt wewnętrzny i odpowiedź zewnętrzna
1
Zadanie
Java Server, poziom 11, lekcja 1
Niedostępne
Dwie zewnętrzne formy tego samego wewnętrznego obiektu
Dwie zewnętrzne formy tego samego wewnętrznego obiektu
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION