CodeGym /Kursy /Java Server /main ( ) — to nie miejsce na cały kod

main ( ) — to nie miejsce na cały kod

Java Server
Poziom 18, Lekcja 0
Dostępny

1. Kiedy „klasa główna” zaczyna przeszkadzać

Dopóki projekt jest mały, main() wydaje się bardzo przytulnym miejscem: otwierasz plik — i wszystko masz przed oczami. To jak kawalerka studenta: jest tu i sypialnia, i kuchnia, i siłownia, i biuro. Ale gdy tylko aplikacja zyska kilka trybów, wywołania sieciowe, mapowanie JSON i jakąkolwiek logikę, kawalerka zamienia się w magazyn, w którym znaleźć skarpetki jest trudniej niż znaleźć sens życia w cudzym kodzie legacy.

Ważne, by uchwycić moment: problem nie polega na tym, że plików jest „za mało” albo „za dużo”. Problem polega na tym, że w kodzie znikają granice odpowiedzialności. W jednym miejscu jednocześnie rozwiązywane są kwestie uruchamiania, wyboru trybu, komunikacji z zewnętrznym API, obsługi błędów i wyświetlania wyniku. Dopóki wszystko to żyje w main(), zmiany stają się coraz straszniejsze: każde nowe if zaczyna zagrażać starym if, a prosta poprawka może niespodziewanie zepsuć inny scenariusz.

Odpowiedzialność klasy

Temat odpowiedzialności brzmi prawie filozoficznie, ale w praktyce jest bardzo przyziemny. Odpowiedzialność to proste pytanie: „Za co odpowiada ta klasa?” Jeśli odpowiedź wychodzi długa i zaczyna się od „no on tu… i jeszcze… i jeszcze…”, to znaczy, że klasa żyje już nie swoim życiem. To jak pracownik, który jest „trochę księgowym, trochę kierowcą, trochę grafikiem, trochę psychologiem” — zwykle kończy wypalony i wszystkich nienawidzi.

W Javie często przypomina się o zasadzie Single Responsibility (jedna przyczyna zmiany), ale nie będziemy zamieniać wykładu w klub miłośników skrótów. Wystarczy nam zdrowy rozsądek: jeśli jeden kawałek kodu odpowiada i za czytanie argumentów uruchomienia, i za wywołania HTTP, i za mapowanie JSON, to każda zmiana w jednej części będzie pociągać za sobą inne. Odpowiedzialność to nie „uroda architektury”, a sposób na zmniejszenie liczby nieoczekiwanych konsekwencji przy zmianach. Zwłaszcza przed serwerową fazą projektu, gdzie kodu będzie jeszcze więcej.

2. Przeciążony main() w ReadLater

Spójrzmy na „klasyczny” scenariusz, który pojawia się u nowicjusza po pierwszych sukcesach. Uczciwie zrobił wywołanie HTTP, uczciwie sparsował JSON, uczciwie wyświetlił wynik — i wszystko to uczciwie wrzucił do main(), bo „tak jest szybciej”. Tak, szybciej. Dokładnie do drugiego trybu i trzeciej komendy.

Przykład kodu, w którym main() robi od razu wszystko:

package com.example.readlater.app;

import com.fasterxml.jackson.databind.ObjectMapper;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public final class ReadLaterApplication {
    public static void main(String[] args) throws Exception {
        // Tutaj parsowanie argumentów uruchomienia jest „wszyte” prosto w main().
        // To szybkie na starcie, ale bardzo źle skaluje się przy pojawianiu się nowych komend.
        String query = args[2];

        // Tworzenie klienta HTTP prosto w main(): teraz punkt wejścia wie o infrastrukturze.
        HttpClient client = HttpClient.newHttpClient();

        // Budowa URL i konfiguracja zapytania też tutaj — main() zamienia się w „kombajn”.
        HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.example/books?q=" + query))
                .header("Accept", "application/json")
                .build();

        // Wysłanie zapytania HTTP i odczyt treści odpowiedzi — jeszcze jedna odpowiedzialność w main().
        String body = client.send(request, HttpResponse.BodyHandlers.ofString()).body();

        // Konfiguracja mapowania JSON też tutaj: teraz main() odpowiada i za JSON.
        ObjectMapper mapper = new ObjectMapper();

        // Wyświetlenie wyniku użytkownikowi — osobna odpowiedzialność, ale ona też w main().
        System.out.println(mapper.readTree(body)); // { ...json... }
    }
}

Tu jednocześnie żyją parsowanie argumentów (args[2]), budowanie URL, tworzenie klienta HTTP, konfiguracja zapytania, wysyłka zapytania, otrzymanie odpowiedzi, tworzenie ObjectMapper, parsowanie JSON i wyświetlenie wyniku. Nawet jeśli każde działanie jest poprawne, w sumie wychodzi „jedna klasa, by rządzić wszystkimi”.

Żeby to stało się jeszcze bardziej realistyczne, wyobraź sobie, że wspierasz już catalog search i catalog details, a wewnątrz main() pojawia się rozgałęzienie. Gdzieś dodaje się if ("search".equals(...)), gdzieś jeszcze if ("mock".equals(...)), a gdzieś jeszcze pojawia się obsługa błędów. I w pewnym momencie plik staje się podobny do busa w godzinach szczytu: formalnie wszyscy jadą, ale oddychać się nie da.

3. Problemy grubego main()

Przejście od „działa” do „wygodnie zmieniać” — to najważniejsze przejście w karierze programisty. I zaczyna się od tego, że przestajesz uważać za sukces sytuację „raz uruchomiłem i zobaczyłem oczekiwany wynik”. Gdy projekt żyje dłużej niż jeden wieczór, zaczyna wymagać dyscypliny. A main() zapchany wszystkim po brzegi właśnie tę dyscyplinę łamie.

Pierwszy ból to czytanie kodu. Otwierasz ReadLaterApplication, żeby zrozumieć, jak działa catalog details, a zamiast tego widzisz połowę kodu o search, ćwierć kodu o mock, parę fragmentów o obsłudze błędów i jeszcze trochę o „wyświetlmy ładnie”. Mózg musi trzymać w pamięci zbyt wiele kontekstu i męczysz się szybciej, niż zdążysz coś zrozumieć.

Drugi ból to zmiany. Powiedzmy, że chcesz zmienić format wyświetlania wyników wyszukiwania albo dodać jeszcze jeden parametr zapytania. Jeśli wszystko żyje w main(), nieuchronnie wchodzisz w ten sam plik, w którym tworzy się klient, parsuje JSON i wybiera scenariusz uruchomienia. Ryzyko „zepsuję coś obok” gwałtownie rośnie, bo obok jest dosłownie wszystko.

Trzeci ból to powtarzalność i duplikacja. Gdy tylko pojawiają się dwa scenariusze (wyszukiwanie i szczegóły), zauważasz: klient HTTP tworzony jest dwa razy, ObjectMapper tworzony jest dwa razy, nagłówki konfigurowane są dwa razy. Zaczynają się próby „wynieść to do wspólnej metody”, potem „wynieść do Utils”, potem „dlaczego w Utils jest 25 metod, a połowa się nie używa”. To nie droga do porządku, to droga do nowej formy chaosu.

Czwarty ból to ukryte zależności. Gdy kod w main() robi „po trochu wszystkiego”, staje się niejasne, co rzeczywiście jest potrzebne aplikacji do działania. Na przykład HttpClient i ObjectMapper nagle okazują się potrzebne nawet tam, gdzie wydaje ci się, że ich nie ruszasz. W efekcie tracisz poczucie struktury: wydaje się, że wszystko zależy od wszystkiego.

Mały schemat tego, co się dzieje, gdy main() zamienia się w „superklasę”:

flowchart TD
    Main["ReadLaterApplication.main"] --> Args["Parsowanie argumentów"]
    Main --> Mode["Wybór trybu real/mock"]
    Main --> Http["Zapytania HTTP: HttpClient"]
    Main --> Json["Mapowanie JSON: ObjectMapper"]
    Main --> Output["Wyświetlanie wyniku"]
    Main --> Errors["Obsługa błędów"]

Gdy wszystkie strzałki wychodzą z jednego bloku, to prawie zawsze znak: pora ciąć granice odpowiedzialności.

4. Cienki main() i warstwa wejściowa

Dobra wiadomość: „odciążyć main()” nie znaczy pilnie budować statek kosmiczny ze wzorców architektonicznych. Nie piszemy frameworka, nie rysujemy UML na 40 stron i nie wymyślamy własnego systemu DI. Po prostu robimy tak, żeby punkt wejścia pozostawał punktem wejścia. Czyli drzwiami. A nie jednocześnie kuchnią, spiżarnią, sypialnią i salą konferencyjną.

Warstwa wejściowa to kod, który przyjmuje zewnętrzne wejście (w naszym przypadku argumenty wiersza poleceń) i przekłada je na zrozumiałe działania aplikacji. Nie musi wiedzieć, jak działa wywołanie HTTP, jak parsuje się JSON i jak konkretnie są zbudowane DTO. Jej zadaniem jest „zrozumieć, czego od nas chcą” i przekazać dalej.

Minimalny i bardzo użyteczny krok — zrobić main() cienkim i delegującym:

package com.example.readlater.app;

public final class ReadLaterApplication {
    public static void main(String[] args) {
        // Punkt wejścia powinien tylko „przejąć sterowanie” i przekazać je dalej.
        // Tutaj main() nie wie nic o HTTP, JSON ani logice biznesowej.
        CommandRouter router = new CommandRouter();
        router.run(args);
    }
}

Teraz main() robi dokładnie to, co powinien: uruchamia aplikację i oddaje sterowanie komponentowi, który zajmuje się wejściem. Ważne: na razie nie musimy robić CommandRouter idealnym. Może być prosty, a nawet trochę naiwny. Najważniejsze — odciążyć main() i przestać zamieniać go w „centrum wszechświata”.

Na przykład minimalna logika routingu może wyglądać tak:

package com.example.readlater.app;

public final class CommandRouter {
    public void run(String[] args) {
        // Walidacja wejścia — odpowiedzialność warstwy wejściowej:
        // sprawdzamy args zanim wejdziemy w prawdziwą logikę aplikacji.
        if (args.length < 2) {
            // Komunikat dla użytkownika o formacie uruchomienia — też część warstwy wejściowej.
            System.out.println("Usage: catalog search <query>"); // Usage: catalog search <query>
            return;
        }

        // Na razie zamiast prawdziwego wykonania po prostu pokazujemy, że komenda została rozpoznana.
        System.out.println("Command: " + args[0] + " " + args[1]); // Command: catalog search
    }
}

Ten kod jeszcze nie robi pożytecznej pracy, ale wyznacza ważny nawyk: wejście i logika aplikacyjna to różne rzeczy. W kolejnych wykładach zaczniemy rozkładać projekt na bardziej stabilne części, ale teraz utrwalamy fundament: main() nie musi być miejscem, w którym „wszystko się dzieje”.

5. Klasa Utils nie rozwiązuje problemu

Gdy programista po raz pierwszy zauważa, że w main() robi się ciasno, ręka sięga po koło ratunkowe z napisem Utils. To bardzo ludzka reakcja: „Wynieśmy powtarzający się kod gdzieś… gdzieś… no do AppUtils!” I pojawia się klasa, która potrafi od razu wszystko: budować URL, drukować tekst, tworzyć ObjectMapper, formatować wynik, a czasem jeszcze wysyłać zapytanie HTTP (czemu nie, skoro już impreza się rozkręciła).

Oto typowy Utils, który wygląda na użyteczny, ale w istocie zbiera niepowiązane obowiązki w jeden stos:

package com.example.readlater;

import com.fasterxml.jackson.databind.ObjectMapper;

public final class AppUtils {
    private AppUtils() {
        // Zakazujemy tworzenia instancji: to klasa „użytkowa” ze statycznymi metodami.
        // (I tak, takie klasy często stają się „wysypiskiem obowiązków”.)
    }

    public static String buildSearchUrl(String query) {
        // „Budowanie URL” — osobna odpowiedzialność.
        // Jeśli żyje w Utils razem z JSON i HTTP, granice odpowiedzialności się zacierają.
        return "https://api.example/books?q=" + query;
    }

    public static ObjectMapper createMapper() {
        // Tworzenie ObjectMapper — odpowiedzialność infrastrukturalna.
        // Jeśli wszystko to zwalać do jednego Utils, zacznie on zależeć „od wszystkiego”.
        return new ObjectMapper();
    }
}

Problem nie polega na tym, że metody są złe. Problem polega na tym, że klasa nie odpowiada za nic konkretnego. Staje się „pudłem na wszystko, co niewygodnie kłaść gdzie indziej”. Najpierw trafia tam jedna metoda. Potem druga. Potem dziesiąta. A potem nagle rozumiesz, że połowa aplikacji zależy od AppUtils, i teraz już AppUtils jest twoim nowym „grubym main”, tylko w innym pliku.

Jeśli mówić zupełnie prosto: Utils to nie architektura. To kompresor, który ściska chaos, żeby zajmował mniej miejsca na ekranie. Ale chaos pozostaje chaosem.

6. Porządek: nazwy i granice

W tym momencie chce się zapytać: „Dobrze, main() odciążamy, Utils nie robimy. A co robimy?” Robić trzeba to, co najnudniejsze i najpożyteczniejsze: nazwać różne rodzaje pracy. Bo dopóki nie umiesz słowami oddzielać jednego od drugiego, nie potrafisz tego oddzielić również w kodzie.

Spróbujmy rozłożyć to, co zwykle miesza się w main(), na części znaczeniowe. Jest logika uruchamiania i rozumienia komendy. Jest logika aplikacyjna „co zrobić” (wyszukiwanie, szczegóły). Jest logika infrastrukturalna „jak zrobić” (wywołanie HTTP, mapowanie JSON). Jest wyświetlanie wyniku użytkownikowi (to nie logowanie, a właśnie wyświetlanie). I jest obsługa błędów, która powinna być przewidywalna, a nie „wyleciało stack trace’em — znaczy wszystko ok”.

Poniżej — mała tabela‑ściąga. To nie „prawda objawiona”, a sposób, żeby zmusić mózg do zobaczenia różnych obowiązków:

Co dzieje się w kodzie Jak to brzmi po ludzku Dlaczego to osobna odpowiedzialność
Parsowanie args „Zrozumieć, czego od nas chcą” Zmienia się przy dodawaniu nowych komend i formatu uruchomienia
Wybór scenariusza „Zdecydować, co właściwie wykonać” Zmienia się przy rozszerzaniu funkcjonalności aplikacji
Wywołanie HTTP „Pójść do zewnętrznego katalogu” Zmienia się przy zmianie kontraktu dostawcy, timeoutów, URL
Mapowanie JSON „Zamienić JSON w DTO” Zmienia się przy zmianie struktury JSON/DTO
Wyświetlanie wyniku „Pokazać człowiekowi rezultat” Zmienia się przy zmianie formatu wyświetlania, ale nie powinno psuć warstwy HTTP

Zauważ, że wciąż nie budujemy wielkiego pałacu architektonicznego. Po prostu przestajemy udawać, że to wszystko — to samo zadanie. Gdy tylko zaczynasz tak myśleć, kolejny krok staje się prawie automatyczny: wynieść chociaż część logiki do osobnych klas, żeby main() przestał być „uniwersalnym wykonawcą”.

7. Typowe błędy przy odciążaniu main()

Błąd nr 1: po prostu przenieść chaos z main() do AppUtils albo Helper.
Z zewnątrz wygląda to jak ulepszenie: main() stał się czystszy, krótszy, schludniejszy. Ale faktycznie problemu nie rozwiązałeś — zmieniłeś mu nazwę. Jeśli w jednej „użytkowej” klasie wymieszane żyją zapytania HTTP, budowanie URL, JSON, logowanie i wyświetlanie — to to samo wysypisko, tylko teraz ukryte pod neutralną nazwą.

Błąd nr 2: dzielić kod po długości, a nie po znaczeniu.
„Plik za duży — wyniosę połowę metod” brzmi logicznie, ale to pułapka. Jeśli nowa klasa nie ma jasnej odpowiedzialności, po prostu robisz dwa losowe pojemniki na kod. Dobre kryterium: powinieneś umieć jednym zdaniem wyjaśnić, za co odpowiada każda klasa. Jeśli nie wychodzi — znaczy, że podział był mechaniczny.

Błąd nr 3: zostawiać wybór trybu (mock/real) wewnątrz klas biznesowych.
Gdy CatalogService sam decyduje, skąd brać dane, zaczyna wiedzieć zbyt wiele o uruchamianiu aplikacji. W efekcie sterowanie rozłazi się po kodzie i tracisz kontrolę nad scenariuszem. Właściwa granica: warstwa wejściowa (np. main() albo konfiguracja) wybiera tryb, a wewnętrzne komponenty po prostu pracują w ramach już wybranego źródła.

Błąd nr 4: dzielić wszystko na małe klasy „dla architektury”.
Czasem pojawia się odwrotna skrajność: najważniejsze — porozdzielać po plikach. Pojawiają się dziesiątki klas, ale żadna nie ma jasnej roli. Jeśli klasy nie da się opisać jednym zdaniem („ta klasa robi X”) — albo jest zbędna, albo źle wydzielona. Dekompozycja dla dekompozycji nie czyni kodu bardziej zrozumiałym.

Błąd nr 5: ignorować strukturę, dopóki „i tak działa”.
W małym projekcie prawie każda struktura wydaje się robocza. Ale gdy tylko pojawia się drugi scenariusz, trzeci tryb albo dodatkowe API — chaos zaczyna gwałtownie drożeć. Struktura to nie „enterprise dla enterprise”, a kontrola nad zmianami. Im wcześniej wytyczysz granice odpowiedzialności, tym taniej będzie rozwijać kod dalej.

1
Zadanie
Java Server, poziom 18, lekcja 0
Niedostępne
Cienki `main()` i routing komend
Cienki `main()` i routing komend
1
Zadanie
Java Server, poziom 18, lekcja 0
Niedostępne
Podział odpowiedzialności bez `Utils`
Podział odpowiedzialności bez `Utils`
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION