CodeGym /Kursy /Java Server /Operacje Safe i unsafe w HTTP

Operacje Safe i unsafe w HTTP

Java Server
Poziom 9, Lekcja 1
Dostępny

1. Idea operacji bezpiecznych

Gdy dopiero zaczynasz znajomość z HTTP, wydaje się, że wszystko jest dość proste: jest serwer, jest żądanie, jest odpowiedź, i jeśli status to 200, to wszystko jest OK. Ale w prawdziwym świecie backendu takie naiwne oczekiwania pękają szybko i z trzaskiem, jak ciasteczko w rękach głodnego testera. Żądania się powtarzają, przeglądarka może odświeżyć stronę, proxy może zacache'ować odpowiedź, a ty sam (albo twój kolega) możesz „przypadkiem” dwa razy szarpnąć ten sam endpoint — i już dane zmieniły się tam, gdzie się tego nie spodziewałeś.

Właśnie dlatego w HTTP istnieje idea operacji bezpiecznych. W bardzo ludzkim sensie oznacza to: „są takie wywołania, po których klient nie oczekuje zmiany danych na serwerze”. Nie chodzi o „uprzejmość” ani o „dobry ton”. Chodzi o to, że ogromna część narzędzi wokół HTTP (przeglądarki, cache, proxy, klienci, monitoring) po cichu zakłada: jeśli operacja jest bezpieczna, można ją powtórzyć, można ją zapytać z wyprzedzeniem, można ją zacache'ować — i świat nie eksploduje.

A teraz wyobraź sobie, że zrobiłeś „sprytny” GET, który w rzeczywistości dodaje książkę do listy czytania. Użytkownik odświeżył stronę — książka dodała się jeszcze raz. Proxy powtórzyło żądanie z powodu awarii sieci — dodała się jeszcze raz. Uruchamiasz smoke-test i „leciutko” klikasz jedno żądanie dwa razy — a potem dziwisz się, dlaczego w reading list masz dziesięć identycznych „Clean Code”. To ten moment, gdy backend zaczyna się mścić za złe kontrakty.

Żeby poczuć ideę na poziomie kodu (bez serwera, Postmana i całej reszty), wystarczy prosty przykład ze „stanem” i dwiema metodami — odczyt i modyfikacja:

import java.util.ArrayList;
import java.util.List;

class ReadingListState {
    // Wewnętrzny stan (to, co zmienia się przy operacjach "niebezpiecznych")
    private final List<String> items = new ArrayList<>();

    List<String> getItems() {               // operacja "bezpieczna" (odczyt)
        // Ważne, by zwracać kopię, żeby kod wywołujący nie mógł zmienić listy z zewnątrz
        return List.copyOf(items);
    }

    void addItem(String title) {            // operacja "niebezpieczna" (modyfikacja)
        // Każde dodanie/usunięcie/aktualizacja to zmiana stanu
        items.add(title);
    }
}

Metoda getItems() nic nie zmienia w stanie listy — zwraca tylko kopię. Metoda addItem(String title) zmienia stan. W świecie HTTP ta różnica zamienia się w regułę: „jeśli to odczyt — nie udawaj, że to modyfikacja; jeśli to modyfikacja — nie maskuj jej jako odczytu”.

I tu ważne, żeby nie skleić tego tematu ze stateless. Stateless odpowiada na pytanie, czy serwer może zrozumieć bieżące żądanie bez ukrytej historii dialogu. Safe/unsafe odpowiada na inne pytanie: czy to żądanie zmienia stan zasobu. Dlatego całkiem normalna jest operacja, która jest jednocześnie stateless i unsafe: na przykład poprawny POST, gdzie wszystkie dane przekazano jawnie, ale i tak wynikiem jest zmiana zasobu.

2. Cechy operacji bezpiecznej

Gdy pojawia się słowo „bezpieczna”, mózg początkującego często wykonuje logiczne salto i zaczyna słyszeć coś w stylu: „czyli ona zawsze się udaje” albo „czyli nie może wywołać błędu”. Niestety, nie. Safe nie chodzi o „sukces”, lecz o brak oczekiwanej zmiany danych.

Mówiąc ściśle praktycznie, operacja bezpieczna to taka, po której klient ma prawo oczekiwać: „nic nie zepsułem i niczego nie zmieniłem”. Przy tym serwer spokojnie może odpowiedzieć błędem, może nie znaleźć zasobu, może się wywalić — ale sama idea operacji bezpiecznej mówi o semantyce wywołania, a nie o gwarancji wyniku.

Ważne jeszcze jedno: bezpieczeństwo określa efekt, a nie „nazwa” czy „atmosfera” metody. Można napisać metodę getSomethingVeryImportant(), która w środku usunie połowę danych — i formalnie będzie to get…, ale w istocie to niszczyciel światów. Tak samo w HTTP: jeśli wziąłeś GET i w środku naprawdę zmieniasz stan zasobu — złamałeś sens, nawet jeśli zwracasz piękny status.

Mini-reguła, którą warto powtarzać jak mantrę: „bezpieczeństwo określa to, czy zmienia się stan zasobu, a nie to, jaką metodą to nazwałeś”.

Spójrz na małą „kontrolę zdrowego rozsądku” w kodzie — nie chodzi o realną walidację, lecz o to, jak głowa powinna myśleć:

class OperationSemantics {

    static boolean isSafe(boolean changesServerState) {
        // Safe == nie zmienia stanu na serwerze
        return !changesServerState;
    }

    public static void main(String[] args) {
        // W przykładzie po prostu pokazujemy logikę: zmieniamy => unsafe, nie zmieniamy => safe
        System.out.println(isSafe(false));  // true
        System.out.println(isSafe(true));   // false
    }
}

Oczywiście w rzeczywistości nie przekazujesz flagi changesServerState w żądaniu HTTP. Ale ty, jako programista, musisz umieć uczciwie odpowiedzieć sobie na pytanie: „czy ten endpoint zmienia dane, czy nie?”. Jeśli tak — to unsafe. Jeśli nie — to safe.

I tu pojawia się subtelny niuans, który często myli: „a co z logami, metrykami, licznikami, czasem ostatniego dostępu?”. Logi i metryki prawie zawsze uważa się za dopuszczalny „techniczny” efekt uboczny: nie zmieniają biznesowego stanu zasobu i nie powinny wpływać na to, co klient zobaczy w odpowiedzi. A zmiana statusu książki na IN_PROGRESS przy podglądzie — to już zmiana biznesowa, i ona łamie sens operacji safe. W piątym rozdziale do tego wrócimy szerzej, bo właśnie tam zwykle żyją najbardziej obraźliwe bugi.

3. Safe a metody HTTP

Już znamy podstawowe metody HTTP i może ci się wydawać, że safe/unsafe to po prostu „synonim GET i nie-GET”. W pierwszym przybliżeniu rzeczywiście jest to bliskie prawdy i właśnie dlatego temat jest pożyteczny: pomaga szybko wybierać metodę pod kątem sensu. Ale ważne jest zrozumienie, że safe/unsafe to semantyka, a metody — narzędzie wyrażenia tej semantyki.

W klasycznym praktycznym myśleniu HTTP zwykle uważa się: GET powinien być bezpieczny, bo wyraża odczyt. POST, PUT, PATCH, DELETE odnoszą się do operacji zmiany danych, dlatego są niebezpieczne. To nie „kaprys standardu”, lecz sposób, żeby uczynić API przewidywalnym dla klientów i dla całej infrastruktury wokół.

Zafiksujmy to w krótkiej tabeli — nie „po całej Wikipedii”, ale na naszym poziomie kursu:

Metoda HTTP Oczekiwana rola Safe? Komentarz znaczeniowy
GET odczyt tak operacja nie powinna zmieniać stanu zasobu
POST utworzenie/akcja nie zwykle tworzy zasób lub uruchamia zmianę
PUT pełna podmiana nie zmienia stan zasobu (ale często jest idempotentna)
PATCH częściowa zmiana nie zmienia stan zasobu
DELETE usunięcie nie zmienia stan zasobu (często jest idempotentna)

Teraz powiążmy to z naszą domeną ReadLater Starter (nawet jeśli na razie nie piszemy serwera). Wyobraź sobie, że będziemy mieli lokalne reading list API. Jakie operacje tam są po ludzku?

Jeśli użytkownik chce obejrzeć listę książek, to odczyt i powinien być safe. Jeśli użytkownik chce dodać książkę, to zmiana stanu i jest unsafe. Jeśli użytkownik chce zmienić status książki, to też unsafe. Czyli „podgląd” i „zmiana” powinny być wyrażone różnymi metodami HTTP, żeby klient nie żył w świecie niespodzianek.

Bardzo wymowny jest antyprzykład — „zły GET, który dokonuje zmiany”. Prawie zawsze wygląda jak „wygodny hack”, dopóki nie przyjdzie rzeczywistość:

class BadApiIdea {
    // Emulacja stanu "na serwerze"
    private int booksCount = 0;

    String getAddBook() {
        // Problem: GET wedle sensu powinien czytać, a tu jest efekt uboczny (modyfikacja)
        booksCount++;
        return "200 OK, count=" + booksCount;
    }
}

Z zewnątrz wygląda to jak „po prostu pobrać odpowiedź”. Ale w środku zmieniasz stan. Jeśli ktoś powtórzy wywołanie — stan zmieni się jeszcze raz. I tu często rodzą się „upiorne” bugi: niby nikt celowo nic nie dodawał, ale książki się pojawiły.

Na poziomie routingu (znów bez prawdziwego serwera) różnicę można przedstawić nawet tak:

class RouterDemo {

    String route(String method, String path) {
        // W prawdziwym serwerze byłaby tu analiza żądania i wywołanie handlera.
        // Nam ważne jest zobaczyć, że sens operacji wyznacza para method + path.
        if ("GET".equals(method) && "/reading-list".equals(path)) return "list items"; // safe (odczyt)
        if ("POST".equals(method) && "/reading-list".equals(path)) return "add item";  // unsafe (modyfikacja)
        return "404 Not Found"; // nieprawidłowa trasa
    }
}

Nie piszemy tu prawdziwego HttpServer, nie czytamy body, nie parsujemy JSON. To jeszcze nie czas. Teraz ważne jest co innego: sens wyraża się już na poziomie „method + path”, i to bezpośrednio wiąże się z tym, czy operacja jest bezpieczna, czy nie.

4. Safe a idempotencja

Wczoraj (i wcześniej) już dotknęliśmy idempotencji i tu pojawia się typowy zamęt: „jeśli można powtórzyć — znaczy bezpieczne”. Nie. I odwrotnie: „jeśli bezpieczne — znaczy można powtórzyć”. Też nie zawsze. Żeby się nie pogubić, warto trzymać w głowie, że safe i idempotent odpowiadają na różne pytania.

Safe odpowiada na pytanie: „Czy operacja powinna zmieniać stan zasobu, czy nie?”. Idempotent odpowiada na pytanie: „Jeśli powtórzę to samo żądanie kilka razy, efekt będzie taki sam jak po jednym razie?”. To jak rozmowa o staranności i powtarzalności: można być starannym (nic nie zmieniać), a można zmieniać, ale powtarzalnie (za każdym razem doprowadzać do tego samego stanu).

Klasyczna ilustracja — DELETE. Usuwanie to zmiana stanu, więc operacja jest unsafe. Ale często jest idempotentna: jeśli zasobu już nie ma, powtórny DELETE w idealnym świecie „nic nowego nie robi” (choć odpowiedź może się różnić). Tak samo PUT zwykle jest unsafe, ale idempotentny: ustawiasz pełny stan zasobu, i powtórne wywołanie prowadzi do tego samego stanu.

Sprowadźmy to do tabeli, która zwykle „leczy mózg” z zamętu:

Metoda Safe? Idempotentna (zwykle)? Jak to czytać po ludzku
GET tak tak czytamy, powtarzanie nie powinno nic zmieniać
POST nie nie często przy powtórzeniu tworzy „jeszcze jeden” zasób
PUT nie tak doprowadzamy zasób do zadanego stanu, powtórzenie nie zmienia wyniku
PATCH nie zależy efekt zależy od tego, jak go zdefiniowano
DELETE nie tak usuwamy zasób, powtórzenie zwykle nie daje nowego efektu

I mała demonstracja „na palcach” w Javie, żeby poczuć różnicę między „zmienia/nie zmienia” a „powtórzeniem”:

class IdempotencyToyExample {
    // Proste "stan zasobu"
    private String status = "PLANNED";

    void patchToFinished() {            // zmiana stanu (unsafe)
        // Każde wywołanie zmienia (albo potwierdza) stan: to nie jest safe
        status = "FINISHED";
    }

    String getStatus() {                // odczyt (safe)
        // Odczyt nie powinien zmieniać status
        return status;
    }
}

Jeśli wywołasz getStatus() 10 razy — stan się nie zmienia: safe. Jeśli wywołasz patchToFinished() 10 razy — stan się zmienia (choć po pierwszym razie już jest „taki sam”, dlatego efekt powtórzenia często uważa się za idempotentny). To właśnie powód, dla którego safe i idempotent to różne pojęcia: jedno o zmianie, drugie o powtarzalności wyniku.

5. Ukryte zmiany w operacjach safe

Najbardziej podstępna część tematu safe/unsafe polega na tym, że niebezpieczeństwo często kryje się nie w metodzie, lecz w „no dodaliśmy tu odrobinkę…”. Właśnie tak powstają API, które niby „czytają”, ale po cichu zmieniają stan. Potem programista przysięga, że „niczego takiego nie robił” i, technicznie, nawet nie kłamie — po prostu „leciutko poprawił licznik”. Problem polega na tym, że dla klienta to już zmiana, a dla infrastruktury — naruszenie oczekiwań.

Rozdzielmy dwa typy efektów ubocznych. Są efekty czysto techniczne: logowanie, metryki, śledzenie, limity, korelacyjne ID. Klient zwykle nie widzi ich jako zmiany zasobu. Jeśli zrobiłeś GET /reading-list, a serwer zapisał log „ktoś zajrzał do listy czytania”, to nie łamie semantyki odczytu.

A są efekty, które zmieniają biznesowy stan zasobu albo wpływają na to, co klient zobaczy. Na przykład robisz GET /reading-list/1, a serwer ustawia status książki IN_PROGRESS, bo „skoro otworzyłeś, znaczy zacząłeś czytać”. To już biznesowa logika i jest niespodziewana. Albo zwiększasz viewsCount i pokazujesz tę liczbę klientowi — wtedy powtórny GET już zmienia dane, i wywołanie przestaje być bezpieczne w sensie kontraktu.

Zły przykład, który wygląda „niewinnie”, ale łamie sens:

class SneakyGet {
    // Licznik wyświetleń — to już część obserwowanego stanu, jeśli zwracamy go klientowi
    private int views = 0;

    String getReadingList() {
        // Kluczowy problem: żądanie GET zaczyna zmieniać stan zasobu
        views++;
        return "views=" + views;
    }
}

Dlaczego to źle? Bo teraz powtórne żądanie zmienia obserwowany stan. Klient może dostawać różne odpowiedzi tylko dlatego, że ktoś odświeżył stronę. Nawet jeśli wydaje ci się, że to „pożyteczna statystyka”, musisz rozumieć: z punktu widzenia semantyki operacji safe uczyniłeś ją unsafe. Czasem biznes naprawdę chce liczników wyświetleń — to normalne. Ale wtedy uczciwie przyznajesz: „odczyt ma efekt”, i musisz bardzo ostrożnie żyć z tym, że powtarzanie już nie „nic nie zmienia”.

Jeszcze jeden klasyczny przypadek — „GET, który coś naprawia”. Na przykład „w locie” poprawiasz niespójne dane, usuwasz śmieci, uruchamiasz migrację, sprzątasz kolekcje. Wszystko to są zmiany. A jeśli robisz to przy odczycie, tworzysz system, w którym jeden klient przypadkowo staje się „adminem, który naprawia bazę”, po prostu otwierając stronę. Wesoło ruwno do pierwszej dużej awarii.

Najbardziej praktyczna reguła brzmi nie bardzo romantycznie, ale ratuje od chaosu: jeśli piszesz handler GET, trzymaj w głowie pytanie „jakie pola/kolekcje/stany realnie zmieniam w tym kodzie?”. Jeśli odpowiedź brzmi „coś zmieniam” — zatrzymaj się i zastanów, czy to naprawdę powinien być odczyt.

6. Scenariusz ReadLater: odczyt i modyfikacja

Na razie nie implementujemy naszego ReadLater Starter jako serwera i tym bardziej nie piszemy pełnoprawnego CRUD-a. Ale to świetny moment, żeby z góry ustawić właściwe „tory” w głowie, bo później będzie pokusa zrobić „szybciej i prościej”, a szybko i prosto zwykle kończy się tym, że debugujesz własny kod o 2 w nocy i filozoficznie patrzysz w okno.

W naszej domenie są bardzo zrozumiałe akcje: obejrzeć listę książek, obejrzeć jedną książkę, dodać książkę, zaktualizować książkę, zmienić status, usunąć książkę. Jeśli mentalnie rozłożysz je na safe i unsafe, łatwiej ci od razu wybrać metodę i nie próbować „oszczędzać” na semantyce.

Oto jak to zwykle wygląda w zdrowym API (bez detali, po prostu sens):

Akcja użytkownika Przykład endpointu Safe? Dlaczego
Zobaczyć listę GET /api/v1/reading-list tak odczyt, nie zmieniamy stanu listy
Zobaczyć jeden wpis GET /api/v1/reading-list/1 tak odczyt konkretnego zasobu
Dodać wpis POST /api/v1/reading-list nie tworzymy nowy zasób, zmieniamy stan
Całkowicie zaktualizować PUT /api/v1/reading-list/1 nie zmieniamy zasób (nawet jeśli powtarzalnie)
Zmienić status PATCH /api/v1/reading-list/1/status nie częściowo zmieniamy zasób
Usunąć wpis DELETE /api/v1/reading-list/1 nie usuwamy zasób

Żeby na poziomie „kodu w głowie” to nie było abstrakcją, można znów wrócić do prostego stanu i operacji:

import java.util.ArrayList;
import java.util.List;

class ReadingList {
    // Przechowujemy stan listy "na serwerze"
    private final List<String> items = new ArrayList<>();

    List<String> list() {                 // safe: tylko odczyt
        // Zwracamy kopię, żeby odczyt na pewno nie dawał na zewnątrz możliwości modyfikacji
        return List.copyOf(items);
    }

    void add(String title) {              // unsafe: modyfikacja
        // Każde dodanie zmienia stan listy
        items.add(title);
    }
}

Jeśli ktoś wywołuje list() nawet sto razy — lista się nie zmienia, i to odpowiada oczekiwaniu od GET. Jeśli ktoś wywołuje add(...) — lista się zmienia, i to odpowiada oczekiwaniu od POST. Wydaje się to banalne, ale właśnie na takich „banałach” buduje się przewidywalność API.

A teraz — mała „kontrola mentalna” na przyszłość. Wyobraź sobie, że bardzo chcesz zrobić „wygodny” endpoint typu: GET /api/v1/reading-list/add?title=Clean%20Code. Z perspektywy człowieka brzmi to jak „no przecież po prostu szarpię URL w przeglądarce, wygodnie”. Z punktu widzenia kontraktu HTTP to katastrofa: odświeżenie strony zamienia się w powtórne dodanie. Każde narzędzie, które uważa GET za bezpieczne i może go powtórzyć (lub zacache'ować w dziwny sposób), będzie działać przeciwko tobie. Dlatego „wygodnie” jest tu pułapką, a nie zaletą.

7. Safe nie równa się udana odpowiedź

Jeszcze jedna częsta pułapka — oczekiwanie, że operacje safe muszą być „zawsze zielone”. Mol, jeśli GET, to powinno być 200, inaczej „coś jest nie tak z serwerem”. Ale HTTP jest urządzone uczciwiej: odczyt może nie znaleźć zasobu, odczyt może być źle uformowany, odczyt może upaść z powodu wewnętrznego błędu. I to wszystko normalne (choć nieprzyjemne) sytuacje.

Jeśli użytkownik prosi o książkę o id 999, której nie ma, serwer może zwrócić 404 Not Found. To nie czyni operacji unsafe — nadal jest odczytem. Po prostu odczyt nie znalazł danych. Jeśli użytkownik przekazał w id nie liczbę, można zwrócić 400 Bad Request. To znów nie o bezpieczeństwie, lecz o poprawności żądania. I nawet 500 nie zamienia odczytu w „modyfikację” — mówi o tym, że serwer nie zdołał obsłużyć żądania.

Na poziomie kodu „operacja bezpieczna może się pomylić” wygląda tak:

class SafeButNotAlwaysSuccessful {

    String getItemById(long id) {
        // Walidacja danych wejściowych: to nie jest modyfikacja zasobu, ale żądanie może być nieprawidłowe
        if (id <= 0) {
            return "400 Bad Request";     // żądanie niepoprawne
        }
        // Negatywny scenariusz "zasób nie znaleziony" — to nadal odczyt
        if (id == 404) {
            return "404 Not Found";       // zasób nie znaleziony (warunkowo)
        }
        // Pozytywny scenariusz "znaleźliśmy zasób"
        return "200 OK";                  // zasób znaleziony
    }
}

To oczywiście zabawkowy przykład, ale fiksuje ważną myśl: safe/unsafe to o efekcie na dane, a statusy — o rezultacie obsługi.

I tu wypływa jeszcze jedna ważna rzecz: status istnieje tylko dla żądania, które w ogóle doczekało się odpowiedzi. Dla odczytu brak odpowiedzi zwykle oznacza, że po prostu nie dostaliśmy wyniku. Dla operacji modyfikującej obraz jest bardziej nieprzyjemny: klient już nie wie, czy serwer nic nie zrobił, czy zdążył zmienić dane, a odpowiedź zgubiła się po drodze. Dlatego safe/unsafe to nie tylko o pięknej semantyce metody, ale i o tym, jak bolesna staje się nieokreśloność, gdy sieć zachowuje się źle.

8. Typowe błędy przy pracy z safe/unsafe

Ten temat wygląda prosto dokładnie do momentu, gdy zaczynasz projektować lub implementować realne endpointy. Wtedy nagle okazuje się, że „bezpieczeństwo operacji” to nie teoria, lecz codzienna dyscyplina: za każdym razem, gdy wybierasz metodę lub dodajesz „mały efekt uboczny”, albo umacniasz kontrakt, albo czynisz przyszłe debugowanie ciekawszym (w złym sensie). Poniżej — błędy, które najczęściej spotyka się u początkujących.

Błąd nr 1: używać GET dla operacji, które zmieniają dane, „bo tak prościej szarpać z przeglądarki”.
Takie rozwiązanie wygląda wygodnie na starcie, ale bardzo szybko zamienia powtórne żądanie w „powtórną modyfikację”. Odświeżenie strony, powtórzenie po awarii sieci, przypadkowy podwójny klik — i dane są już nie tam, gdzie się spodziewano. W świecie backendu „prościej” prawie zawsze oznacza „drożej później”.

Błąd nr 2: uważać, że operacja safe musi zwracać tylko 200 OK.
Operacja bezpieczna może zwrócić 404, jeśli zasobu nie ma, może zwrócić 400, jeśli żądanie jest niepoprawne, i może zwrócić 500, jeśli serwer jest zepsuty. Safe nie oznacza success. Jeśli zaczynasz myśleć „GET = zawsze 200”, będziesz źle interpretował normalne scenariusze negatywne.

Błąd nr 3: mylić bezpieczeństwo i idempotencję.
DELETE zwykle jest idempotentny, ale jest niebezpieczny, bo zmienia stan. PUT często jest idempotentny, ale też niebezpieczny. A POST zwykle jest niebezpieczny i nieidempotentny. Jeśli zmieszasz te pojęcia, zaczniesz podejmować złe decyzje o powtórnych żądaniach i o tym, której metody „można” użyć.

Błąd nr 4: dodawać „niewinne” biznesowe efekty uboczne do odczytu.
Logi i metryki to jedno, a zmiana statusu, liczniki wyświetleń, autonaprawa danych i inne biznesowe zmiany — co innego. Gdy GET zaczyna zmieniać obserwowany stan zasobu, klientowi trudniej zrozumieć system, a infrastruktura wokół HTTP przestaje ci pomagać i zaczyna przeszkadzać.

Błąd nr 5: „maskować” operację niebezpieczną pod bezpieczną pięknym statusem.
Czasem programista robi operację unsafe, ale zwraca 200 OK i myśli, że wszystko jest OK. Status odpowiada za rezultat obsługi, a nie za semantykę operacji. Jeśli zmieniłeś stan — to unsafe, nawet jeśli „wszystko poszło pomyślnie”. Klientowi liczy się przewidywalność, a nie kosmetyka.

1
Zadanie
Java Server, poziom 9, lekcja 1
Niedostępne
Odczytywanie rozmiaru kolejki bez efektu ubocznego
Odczytywanie rozmiaru kolejki bez efektu ubocznego
1
Zadanie
Java Server, poziom 9, lekcja 1
Niedostępne
Audyt semantyki operacji
Audyt semantyki operacji
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION