CodeGym /Kursy /Spring REST & MVC /Zewnętrzna konfiguracja projektu

Zewnętrzna konfiguracja projektu

Spring REST & MVC
Poziom 28, Lekcja 0
Dostępny

1. Hardkod jako ukryta konfiguracja

Hardkod zwykle pojawia się cicho i niewinnie: „na razie po prostu zapiszemy pliki do /tmp, potem wyniesiemy". Potem mija tydzień, dodajesz kilka ficzerów i nagle zdajesz sobie sprawę, że „potem" stało się małą legendą. Problem z hardkodem nie polega na tym, że to „zły styl", ale na tym, że zamienia parametry środowiskowe w część kodu źródłowego.

Wyobraź sobie najbardziej niewinny przykład — lokalny magazyn plików dla załączników. Bardzo chce się napisać tak:

import java.nio.file.Path;

public class LocalAttachmentStorage {
    // Hardkod: ścieżka jest wpisana na stałe i będzie taka sama we wszystkich środowiskach
    private final Path root = Path.of("/tmp/task-tracker/uploads");
}

W tym momencie, nie zdając sobie z tego sprawy, przyjąłeś trzy założenia. Pierwsze — że aplikacja będzie uruchamiana na systemie, gdzie istnieje /tmp (Windows patrzy na to z lekkim zakłopotaniem). Drugie — że proces będzie miał dostęp do zapisu właśnie tam. Trzecie — że ta konkretna ścieżka zawsze ci odpowiada, nawet jeśli jutro zechcesz przechowywać pliki obok aplikacji, a nie w systemowym temp.

Hardkod podważa też odtwarzalność. Jeśli zmieniłeś ścieżkę — zmieniasz kod i budujesz nowy artefakt (jar). Oznacza to, że zachowanie „aplikacja A w środowisku B" zależy od tego, którą gałąź zbudowałeś i jakie ciągi znaków są w niej osadzone. Innymi słowy, konfiguracja rozlewa się po historii gita i w pewnym momencie zaczynasz debugować… nie logikę biznesową, ale archeologię commitów.

Osobny ból to limity. Powiedzmy, że hardkodowałeś „maksymalny rozmiar pliku" jako stałą. Na twoim laptopie wygląda to rozsądnie. A potem kolega uruchamia projekt w innym środowisku, gdzie reverse proxy ogranicza ciało żądania, i łapiesz dziwne błędy „czasem 413, czasem 400". I znowu nie chodzi o „zły kod", ale o to, że limity są częścią środowiska.

2. Zewnętrzna konfiguracja Spring Boot

Słowo „externalized configuration" w Spring Boot brzmi jak coś ze świata enterprise-magii, ale sens jest dość przyziemny. To sposób na powiedzenie: „ten sam kod powinien działać w różnych warunkach, a warunki zmieniają się bez rekompilacji". Zamiast edytować klasy Javy, zmieniasz wartości w konfiguracji lub środowisku.

W Spring Boot konfiguracja żyje w postaci właściwości (properties). Właściwość ma klucz (np. app.attachments.storage-dir) i wartość (np. ./data/attachments). Wartości mogą pochodzić z kilku źródeł: z application.yml, z konfiguracji profilowych, ze zmiennych środowiskowych, z argumentów uruchomieniowych. Dzisiaj nie będziemy zagłębiać się w pełną listę źródeł (to łatwo zamienić w osobny kurs), ale ważne jest zrozumienie idei: wartość może być „z zewnątrz", a kod po prostu ją pobiera.

Kluczowy efekt: jeśli prawidłowo oddzielisz „parametry środowiskowe" od „reguł systemu", twój jar staje się niemal uniwersalny. Uruchamiasz ten sam artefakt z różnymi ustawieniami i działa poprawnie dla konkretnego środowiska. To jak mieć ten sam rower, ale regulować wysokość siodełka pod konkretną osobę — rower pozostaje rowerem, a nie zamienia się w inny środek transportu.

3. Kryterium: zależność od środowiska

Najbardziej przydatna umiejętność tego dnia — nie zapamiętanie „jakie klucze ma Spring", ale nauczenie się zadawania właściwego pytania: czy ta wartość zmienia się z powodu środowiska uruchomienia, czy z powodu zmian w kontrakcie/logice? Jeśli zmienia się z powodu środowiska — to bardzo prawdopodobny kandydat do konfiguracji. Jeśli z powodu logiki — zostawiamy w kodzie.

Zacznijmy od oczywistego: ścieżka do katalogu załączników. Zależy od tego, gdzie uruchamiasz aplikację, jakie prawa ma proces, co jest wygodne w lokalnym developmencie i jak zorganizowane jest środowisko. W Task Tracker API jest to wprost zapisane jako wymagana właściwość: app.attachments.storage-dir. Taka wartość prawie nigdy nie powinna być hardkodowana. Nawet jeśli wydaje ci się, że „wszędzie pasuje /tmp" — to pułapka: po prostu nie uruchamiałeś jeszcze projektu na wystarczająco „nie swojej" maszynie.

Drugi typowy kandydat — limity multipart. W Spring Boot są konfigurowane przez spring.servlet.multipart.max-file-size i spring.servlet.multipart.max-request-size. Dlaczego to konfiguracja, a nie kod? Bo ograniczenia rozmiaru żądania to kompromis między UX, bezpieczeństwem i możliwościami infrastruktury. W środowisku deweloperskim możesz ustawić większe limity, żeby wygodnie testować. W bardziej restrykcyjnym środowisku limity mogą być niższe. I na pewno nie chcesz „aktualizować kodu", żeby umożliwić upload pliku na 2 MB zamiast 10 MB.

Trzeci przykład z naszego kursu — włączenie Problem Details w MVC. W projekcie zapisana jest właściwość spring.mvc.problemdetails.enabled=true. To ustawienie na poziomie frameworka: steruje zachowaniem Spring MVC w zakresie formowania odpowiedzi problemowych. I to też jest konfiguracja środowiska/zachowania platformy, a nie logika domenowa Task Trackera.

Aby poczuć różnicę, warto zobaczyć „kanoniczne minimum" konfiguracji środowiskowej:

spring:
  mvc:
    problemdetails:
      # Włączamy Problem Details na poziomie Spring MVC (to ustawienie frameworka)
      enabled: true
  servlet:
    multipart:
      # Limity multipart — to parametry środowiska/infrastruktury
      max-file-size: 10MB
      max-request-size: 12MB

app:
  attachments:
    # Ustawienie aplikacyjne: dokąd zapisywać załączniki w tym środowisku
    storage-dir: ./data/attachments

Tutaj spring.* — to „zarządzanie frameworkiem", a app.* — „zarządzanie naszą aplikacją". I tak, jeśli czytasz teraz YAML jak książkę — to dobry znak: konfiguracja jest czytelna.

Krótka tabela dla intuicji

Czasami łatwiej utrwalić myśl wizualnie. Oto mini-tabela „kandydatów do konfiguracji":

Co się zmienia Przykład Prawie zawsze konfiguracja? Dlaczego
Ścieżka/katalog storage-dir tak zależy od OS, uprawnień, struktury środowiska
Limity rozmiaru multipart max sizes tak zależy od infrastruktury i polityki środowiska
Włączenie/tryb frameworka Problem Details enabled tak to ustawienie MVC, nie logika domenowa
„Tryb deweloperski" logging.level tak w dev chcemy więcej logów niż w innych środowiskach

4. Kryterium: kontrakt API i reguły domenowe

W tym miejscu zwykle następuje „odchylenie wahadła". Student słyszy „wynosimy do konfiguracji", inspiruje się i zaczyna wynosić wszystko: długości ciągów, dozwolone wartości, przejścia statusów… a potem dziwi się, dlaczego projekt staje się kruchy i nieprzewidywalny. Ważna myśl: konfiguracja nie powinna zastępować projektu kontraktu.

Spójrzmy na reguły długości tytułu zadania: 3..120. To część kontraktu wejściowego API. Klient wysyła TaskCreateRequest i musimy go jednakowo walidować dziś, jutro i za tydzień. Jeśli wyniesiesz te liczby do YAML, kontraktem staje się „przypadkowo zmienialny": ktoś zmieni 120 na 200, bo „tak wygodniej", i zewnętrzni klienci nagle dostaną inne zachowanie. To już nie jest ustawienie środowiskowe — to zmiana API.

Oto przykład tego, co powinno pozostać w kodzie (request DTO + walidacja):

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record TaskCreateRequest(
        // Kontrakt API: pole jest obowiązkowe i walidowane zawsze tak samo (w każdym środowisku)
        @NotBlank
        // Kontrakt API: ograniczenia długości — część publicznego zachowania, a nie „ustawienie"
        @Size(min = 3, max = 120)
        String title
) {}

To samo dotyczy struktury DTO (jakie pola istnieją, jakie typy, jakie nazwy) — to twój publiczny kontrakt. Nie można go „trochę poprawić konfigiem". Kontrakt zmienia się przez kod, review, wersjonowanie i świadomą decyzję. Inaczej dostajesz sytuację, gdzie aplikacja „zachowuje się inaczej" tylko dlatego, że gdzieś leży inny YAML. Dla API to prawie zawsze zły pomysł.

Osobna strefa — reguły biznesowe, np. przejścia statusu zadania (TODO -> IN_PROGRESS, ale nie TODO -> ARCHIVED). To nie są „ustawienia środowiskowe". To logika dziedziny, której uczysz serwis. Jeśli wyniesiesz ją do konfiguracji, dostaniesz dwa problemy: po pierwsze, traci się czytelność i typebezpieczeństwo; po drugie, zamienia to model domenowy w „skrypt, który ładuje się przy starcie", i dowolna konfiguracja może przypadkowo zamienić Task Trackera w inny produkt.

Nawet w uproszczonej formie widać, że takie reguły żyją właśnie w kodzie:

import java.util.Set;

public class TaskStatusRules {

    public boolean canTransition(String from, String to) {
        // Uproszczony model: trzymamy dozwolone przejścia jako ciągi znaków
        // (w prawdziwym projekcie lepiej enum i jawny model przejść)
        return Set.of("TODO->IN_PROGRESS", "DONE->ARCHIVED")
                // Sklejamy „z" i „do" w klucz przejścia i sprawdzamy obecność na białej liście
                .contains(from + "->" + to);
    }
}

Ten przykład jest celowo uproszczony (w prawdziwym projekcie użyłbyś enuma i dokładniejszego modelu), ale myśl jest ważniejsza: reguła to kod, a nie YAML. Jeśli chcesz zmienić przejścia — to zmiana logiki. Wymaga omówienia tak samo jak zmiana endpointu czy DTO, a nie „podkręcenie konfiguracji".

Jeszcze jedna strefa, którą lepiej trzymać w kodzie — zestaw kodów błędów. Tak, „wyglądają jak lista ciągów znaków", ale są częścią zewnętrznej umowy. Jeśli dziś zwracasz TASK_NOT_FOUND, a jutro w innym środowisku przez YAML zwrócisz TASK_MISSING — klientowi będzie bardzo wesoło. Oznacza to, że kody błędów to nie parametr środowiskowy, a część API.

5. Rozdzielenie spring.* i app.*

Gdy projekt jest mały, pojawia się pokusa: „a jaka różnica, gdzie leży klucz, i tak jest jeden YAML". Różnica pojawia się dokładnie na tym etapie, gdzie jesteśmy teraz: projekt ma zarówno ustawienia frameworka (MVC, multipart), jak i ustawienia aplikacyjne (nasz storage-dir). Jeśli wszystko wymieszasz, YAML zamienia się w „kosz na śmieci", gdzie trudno odróżnić „co konfiguruje Spring" od „co konfiguruje nasza aplikacja".

Praktyka podziału jest prosta: wszystko dotyczące Spring Boot / Spring MVC / wbudowanych mechanizmów zostaje pod spring.*. Wszystko dotyczące naszej aplikacji (Task Tracker API) — pod jednym zrozumiałym prefiksem, np. app.*. Dzięki temu konfiguracja jest czytelna nawet dla osoby, która po raz pierwszy otworzyła repozytorium: widzi „część platformy" i „część aplikacji".

Jeszcze jeden plus: gdy później będziesz szukać „gdzie u nas jest ścieżka do załączników", nie będziesz skakać po YAML w poszukiwaniu przypadkowego klucza. Będzie tam, gdzie logicznie powinien być: w app.attachments.*. Oszczędza to czas na utrzymanie i zmniejsza ryzyko duplikacji. A duplikacja to taki cichy wróg, który najpierw wygląda jak „no po prostu skopiowałem", a potem zamienia się w „dlaczego w jednym miejscu 10 MB, a w innym 12 MB".

6. Przykłady: konfiguracja vs kod w Task Tracker API

Teraz zrobimy najbardziej przydatne ćwiczenie dla mózgu: spojrzymy na kilka konkretnych kawałków i powiemy „konfiguracja czy kod". Nie dlatego, że tak „poprawnie według podręcznika", ale dlatego, że inaczej projekt zacznie się zachowywać dziwnie.

Przykład A: ścieżka do katalogu załączników — konfiguracja

Hardkod:

import java.nio.file.Path;

public class LocalAttachmentStorage {
    // Hardkod: wpisana ścieżka sprawia, że artefakt jest nieprzenośny
    private final Path root = Path.of("/tmp/task-tracker/uploads");
}

To kandydat do konfiguracji. Ścieżka zależy od środowiska.

Minimalna idea „wyniesienia na zewnątrz" (na razie bez @ConfigurationProperties, to będzie w kolejnych lekcjach dnia):

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.nio.file.Path;

@Component
public class LocalAttachmentStorage {
    private final Path root;

    public LocalAttachmentStorage(@Value("${app.attachments.storage-dir}") String storageDir) {
        // Wartość przyszła „z zewnątrz" (z application.yml/env/argumentów uruchomieniowych)
        this.root = Path.of(storageDir);
    }
}

Sens tutaj nie polega na tym, że @Value jest idealną techniką (nie jest, poprawimy to później). Sens w tym, że kod przestał podejmować decyzję za środowisko: teraz środowisko mówi aplikacji, gdzie przechowywać pliki.

Przykład B: limity multipart — konfiguracja

Limity żądania nie powinny żyć w kontrolerze:

// Zły pomysł: "sprawdzimy rozmiar i zwrócimy 400"
// 1) To duplikuje/koliduje z tym, jak Spring już przycina multipart na wejściu
// 2) To zamienia limit infrastrukturalny w część kodu i wymaga ponownego budowania jar
if (file.getSize() > 10_000_000) {
    throw new IllegalArgumentException("File is too big");
}

Po pierwsze, będzie to kolidować z tym, jak Spring sam ogranicza multipart. Po drugie, to uczyni limit częścią kodu, choć jest to parametr środowiskowy. Poprawniej zarządzać tym tak:

spring:
  servlet:
    multipart:
      # Limit na jeden plik
      max-file-size: 10MB
      # Limit na całe żądanie (pliki + pola formularza)
      max-request-size: 12MB

I ważna kwestia: wygodnie myśleć o tym „parami". Jeśli max-request-size jest mniejszy niż max-file-size, tworzysz logiczną sprzeczność — plik „można", ale całe żądanie „nie można". To może działać dziwnie i nieprzewidywalnie (i na pewno będzie źle wyglądało dla człowieka).

Przykład C: reguła długości tytułu — część kontraktu, zostaje w kodzie

To nie jest konfiguracja środowiskowa:

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record TaskCreateRequest(
        // Kontrakt API: to pole nie może być puste
        @NotBlank
        // Kontrakt API: zakres długości jest ustalony jako część publicznego zachowania
        @Size(min = 3, max = 120)
        String title
) {}

Tak, liczby „mogą się zmieniać" i teoretycznie mógłbyś wynieść 3 i 120 do YAML. Ale wtedy uczynisz kontrakt płynnym. A kontrakt powinien być stabilny i sprawdzalny na poziomie kodu, testów i dokumentacji.

Przykład D: włączenie Problem Details — konfiguracja platformy

Nasz kontrakt błędów jest zbudowany wokół ProblemDetail, a Spring MVC umie z nim pracować „z pudełka", jeśli włączyć odpowiednie ustawienie:

spring:
  mvc:
    problemdetails:
      # Ustawienie zachowania Spring MVC, nie „logika naszej aplikacji"
      enabled: true

To nie jest „logika aplikacji", to przełącznik zachowania MVC. Dlatego żyje w konfiguracji.

Krótki schemat blokowy do podejmowania decyzji

Czasami wygodnie mieć „detektor" wprost w głowie:

flowchart TD
    %% Szybka weryfikacja mentalna: wartość to „środowisko" czy „kontrakt/logika"?
    A["Jest wartość, którą chce się 'gdzieś umieścić'"] --> B{"Zależy od środowiska uruchomienia?"}
    B -->|Tak| C["Konfiguracja (application.yml / env var)"]
    B -->|Nie| D{"To część kontraktu API lub reguł domenowych?"}
    D -->|Tak| E["Kod (DTO/validation/service rules)"]
    D -->|Nie| F["Najprawdopodobniej stała w kodzie lub refaktoryzacja"]

Niedoskonały detektor, ale ratuje przed dwiema skrajnościami: „wszystko hardkodujemy" i „wszystko wynosimy do YAML".

7. Typowe błędy przy wyborze: konfiguracja czy kod

W tym temacie błędy są szczególnie podstępne: nie zawsze natychmiast psują projekt. Czasem wszystko działa, dopóki nie spróbujesz uruchomić aplikacji na innej maszynie albo nie pojawi się drugi deweloper (co zwykle dzieje się niemal od razu po pierwszym sukcesie). Dlatego warto znać błędy z wyprzedzeniem, jak znaki drogowe: żeby nie uczyć się na własnych błędach, które potem trudno „cofnąć".

Błąd №1: wynosić do konfiguracji reguły kontraktu API i domeny.
Najczęstsze przegięcie — „skoro wartości mogą się zmieniać, wynieśmy wszystko". W efekcie ograniczenia walidacji, dozwolone przejścia statusów lub nawet format odpowiedzi zaczynają zależeć od YAML. To zamienia API w obiekt, który przypadkowo zmienia zachowanie między środowiskami. Właściwe pytanie przed wyniesieniem: czy to dotyczy środowiska (ścieżka, limit, przełącznik frameworka) czy sensu/kontraktu (walidacja, DTO, zakazy domenowe)?

Błąd №2: zostawiać ścieżki bezwzględne w kodzie lub w konfiguracji bazowej.
Bezwzględna ścieżka jak /home/alex/uploads jest wygodna dokładnie dla jednej osoby — Aleksa. W projekcie edukacyjnym bazowa konfiguracja powinna być przenośna, dlatego lepiej stosować ścieżkę względną (./data/attachments) i możliwość nadpisania w środowisku. Ścieżki bezwzględne są dopuszczalne jako override dla konkretnej maszyny, ale nie jako „domyślna prawda".

Błąd №3: mylić ustawienia frameworka z ustawieniami projektu, wrzucając wszystko pod spring.*.
Czasem z lenistwa chce się napisać spring.attachments.storage-dir. Wygląda logicznie przez sekundę, a potem zaczyna się chaos: nie możesz już odróżnić „kluczy Springa" od „kluczy aplikacji", a wyszukiwanie w projekcie zwraca dziesięć podobnych miejsc. Trzymanie własnych ustawień pod app.* to prosta dyscyplina, która bardzo oszczędza czas w przyszłości.

Błąd №4: duplikować tę samą wartość w kilku miejscach.
Na przykład hardkodujesz ścieżkę w LocalAttachmentStorage, ale też dodajesz ją do YAML „na przyszłość". I teraz masz dwa źródła prawdy, które spierają się o pierwszeństwo. Prędzej czy później zmienisz tylko jedno i dostaniesz zagadkę: „dlaczego zmieniłem konfigurację, a aplikacja nadal zapisuje do starego katalogu?". Po wprowadzeniu zewnętrznej konfiguracji stary hardkod trzeba usunąć, inaczej sam urządzasz sobie loterię błędów.

Błąd №5: traktować konfigurację jako „ukryty sposób zmiany sensu API".
Profile i nadpisania (będziemy o nich mówić dalej) to sposób zmiany zachowania pod środowisko, a nie „włączenia innego kontraktu". Jeśli profil zaczyna zmieniać np. ograniczenia walidacji lub zestaw pól DTO — uczyniłeś API nieprzewidywalnym. W takim projekcie trudno pisać testy, trudno dokumentować kontrakt i niemożliwe wyjaśnić klientowi „jak poprawnie".

1
Zadanie
Spring REST & MVC, poziom 28, lekcja 0
Niedostępne
Katalog archiwum w konfiguracji zewnętrznej
Katalog archiwum w konfiguracji zewnętrznej
1
Zadanie
Spring REST & MVC, poziom 28, lekcja 0
Niedostępne
Konfiguracja środowiska oddzielnie od walidacji DTO
Konfiguracja środowiska oddzielnie od walidacji DTO
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION