Prawda przede wszystkim, czyli dlaczego system powinien być projektowany z uwzględnieniem struktury bazy danych

Cześć, Habr!

Kontynuujemy badanie tematu Java i Spring, w tym na poziomie baz danych. Dziś proponujemy przeczytać o tym, dlaczego przy projektowaniu dużych aplikacji to właśnie struktura bazy danych, a nie kod Java, powinna mieć kluczowe znaczenie, jak to zrobić i jakie są wyjątki od tej zasady.

W tym dość spóźnionym artykule wyjaśnię, dlaczego uważam, że w praktycznie wszystkich przypadkach model danych w aplikacji powinien być projektowany „na podstawie bazy danych”, a nie „na podstawie możliwości Java” (lub innego języka klienckiego, z którym pracujesz). Wybierając drugie podejście, wkraczasz na długą drogę bólu i cierpienia, gdy tylko twój projekt zaczyna rosnąć.

Artykuł został napisany na podstawie jednego pytania, zadawanego na Stack Overflow.

Interesujące dyskusje na reddicie w sekcjach /r/java i /r/programming.

Generowanie kodu

Byłem bardzo zdziwiony, że jest tak niewielka grupa użytkowników, którzy po zapoznaniu się z jOOQ są oburzeni faktem, że podczas pracy z jOOQ poważnie polega się na generacji kodu. Nikt nie zabrania korzystania z jOOQ w sposób, jaki uważasz za stosowny, i nie zmusza do używania generacji kodu. Ale domyślnie (jak opisano w dokumentacji) praca z jOOQ przebiega tak: zaczynasz z (odziedziczoną) schemą bazy danych, wykonujesz jej odwrotne projektowanie przy pomocy generatora kodu jOOQ, aby uzyskać zestaw klas reprezentujących twoje tabele, a następnie piszesz typowo bezpieczne zapytania do tych tabel:

	for (Record2 record : DSL.using(configuration)
//   ^^^^^^^^^^^^^^^^^^^^^^^ Informacje o typach są wyprowadzane na
//   podstawie wygenerowanego kodu, na który odnosi się poniższy warunek SELECT

       .select(ACTOR.FIRST_NAME, ACTOR.LAST_NAME)
//           vvvvv ^^^^^^^^^^^^  ^^^^^^^^^^^^^^^ wygenerowane nazwy
       .from(ACTOR)
       .orderBy(1, 2)) {
    // ...
}

Kod jest generowany ręcznie poza kompilacją lub ręcznie przy każdej kompilacji. Na przykład taka regeneracja może nastąpić bezpośrednio po migracji bazy danych Flyway, którą również można wykonać ręcznie lub automatycznie..

Generacja kodu źródłowego

Podejścia do generowania kodu – ręczne i automatyczne – wiążą się z różnymi filozofiami, zaletami i wadami, które nie zamierzam szczegółowo omawiać w tym artykule. Jednak ogólnie rzecz biorąc, istota generowanego kodu polega na tym, że pozwala on odwzorować w Javie tę "prawdę", którą przyjmujemy za daną, zarówno w ramach naszego systemu, jak i poza nim. W pewnym sensie to samo robią kompilatory, generując bytecode, kod maszynowy lub inny rodzaj kodu na podstawie źródeł – otrzymujemy przedstawienie naszej "prawdy" w innym języku, niezależnie od konkretnych powodów.

Istnieje wiele takich generatorów kodu. Na przykład, XJC może generować kod Java na podstawie plików XSD lub WSDL.Zasada zawsze jest taka sama:

  • Istnieje jakaś prawda (wewnętrzna lub zewnętrzna) – na przykład specyfikacja, model danych, itd.
  • Potrzebujemy lokalnego przedstawienia tej prawdy w naszym języku programowania.

Generowanie takiego przedstawienia jest niemal zawsze celowe – aby uniknąć nadmiarowości.

Dostawcy typów i przetwarzanie adnotacji

Do notatki: innego, bardziej nowoczesnego i specyficznego podejścia do generowania kodu dla jOOQ wiąże się z wykorzystaniem dostawców typów, w takim wydaniu, jakie realizowane jest w F#.W takim przypadku kod jest generowany przez kompilator na etapie kompilacji. W formie źródeł taki kod zasadniczo nie istnieje. W Javie istnieją podobne, chociaż nieco mniej eleganckie narzędzia – to procesory adnotacji, na przykład, Lombok..

W pewnym sensie zachodzą tutaj te same procesy, co w pierwszym przypadku, z jednym wyjątkiem:

  • Nie widzisz wygenerowanego kodu (może dla niektórych taka sytuacja nie wydaje się tak nieprzyjemna?)
  • Musisz zapewnić, że typy mogą być dostarczane, to znaczy, "prawda" musi być zawsze dostępna. To jest łatwe w przypadku Lombok, który adnotuje "prawdę". Trochę trudniej z modelami baz danych, których działanie zależy od stale dostępnego aktywnego połączenia.

Jaki jest problem z generowaniem kodu?

Oprócz podchwytliwego pytania o to, w jaki sposób lepiej uruchomić generację kodu – ręcznie czy automatycznie, muszę również wspomnieć o tym, że są ludzie, którzy uważają, że generowanie kodu w ogóle nie jest potrzebne. Uzasadnienie tego poglądu, z którym spotkałem się najczęściej, polega na tym, że wtedy trudno skonfigurować proces budowy. Tak, faktycznie jest to trudne. Powstają dodatkowe wydatki związane z infrastrukturą. Jeśli dopiero zaczynasz pracować z określonym produktem (niezależnie czy to jOOQ, JAXB, Hibernate itp.), czas poświęcony na konfigurację środowiska roboczego to czas, który chciałbyś przeznaczyć na naukę samego API, aby później móc wydobywać z niego wartość.

Jeśli koszty związane ze zrozumieniem działania generatora są zbyt wysokie – to rzeczywiście, w API nie zadbano o użyteczność generatora (a później okazuje się, że również użytkownik ma trudności z jego konfiguracją). Wygoda użytkowania powinna być najwyższym priorytetem dla każdego takiego API. Ale to tylko jeden argument przeciwko generowaniu kodu. Poza tym, konieczne jest pisanie lokalnej reprezentacji wewnętrznej lub zewnętrznej rzeczywistości całkowicie ręcznie.

Wielu powie, że nie mają czasu, aby się tym zajmować. Mają napięte terminy oddania swojego Super-Produkt. Później jakoś poprawimy procesy budowy, zdążymy. Odpowiem im:

Prawda przede wszystkim, czyli dlaczego system powinien być projektowany z uwzględnieniem struktury bazy danych
Oryginał, Alan O’Rourke, Audience Stack

Jednak w Hibernate/JPA tak łatwo jest pisać kod "pod Javę".

Zgadza się. Dla Hibernate i jego użytkowników to jednocześnie błogosławieństwo i przekleństwo. W Hibernate można po prostu napisać kilka encji, tak:

	@Entity
class Book {
  @Id
  int id;
  String title;
}

I prawie wszystko jest gotowe. Teraz zadaniem Hibernate jest generowanie skomplikowanych "detali" tego, w jaki sposób ta encja będzie definiowana w DDL twojego "dialektu" SQL:

	CREATE TABLE book (
  id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  title VARCHAR(50),

  CONSTRAINT pk_book PRIMARY KEY (id)
);

CREATE INDEX i_book_title ON book (title);

... i zaczynamy uruchamiać aplikację. To naprawdę świetna opcja, aby szybko rozpocząć pracę i eksperymentować z różnymi rzeczami.

Jednak pozwólcie, że się przyznam.

  • Czy Hibernate rzeczywiście zastosuje definicję tego nazwanego klucza podstawowego?
  • Czy Hibernate utworzy indeks w TITLE? – dokładnie wiem, że będzie nam potrzebny.
  • Czy Hibernate na pewno uczyni ten klucz identyfikującym w specyfikacji Identity?

Prawdopodobnie nie. Jeśli tworzysz swój projekt od podstaw, to zawsze wygodnie po prostu obrócić starą bazę danych i wygenerować nową, jak tylko dodasz potrzebne adnotacje. W ten sposób encja Book ostatecznie przyjmie postać:

	@Entity
@Table(name = "book", indexes = {
  @Index(name = "i_book_title", columnList = "title")
})
class Book {
  @Id
  @GeneratedValue(strategy = IDENTITY)
  int id;
  String title;
}

Super. Wygeneruj ponownie. Znowu, w takim przypadku na początku będzie to bardzo łatwe.

Ale później trzeba za to zapłacić.

Prędzej czy później trzeba wyjść na produkcję. To wtedy ten model przestanie działać. Ponieważ:

Na produkcji nie będzie już można w razie potrzeby obrócić starej bazy danych i zacząć od nowa. Twoja baza danych stanie się dziedziczona.

Od teraz i na zawsze będziesz musiał pisać skrypty migracyjne DDL, na przykład przy pomocy Flyway.. A co się wtedy stanie z twoimi encjami? Możesz je albo dostosować ręcznie (i tym samym podwoić sobie objętość pracy), albo kazać Hibernate wygenerować je na nowo (jakie są szanse, że tak wygenerowane będą odpowiadać twoim oczekiwaniom?) W każdym razie przegrywasz.

W ten sposób, gdy tylko przejdziesz do produkcji, będziesz potrzebować gorących poprawek. A te musisz wprowadzić do produkcji bardzo szybko. Ponieważ nie byłeś przygotowany i nie zorganizowałeś gładkiego przepływu migracji dla produkcji, wszystko będziesz dziko poprawiać. A potem już nie zdążysz wszystko zrobić poprawnie. I winić będziesz Hibernate, ponieważ zawsze winny jest ktoś inny, nie ty...

Zamiast tego, od samego początku można było wszystko robić zupełnie inaczej. Na przykład założyć na rower okrągłe koła.

Najpierw baza danych.

Prawdziwa „prawda” w schemacie twojej bazy danych i „suwerenność” nad nią kryje się wewnątrz bazy danych. Schemat jest definiowany wyłącznie w samej bazie danych i nigdzie indziej, a każdy z klientów ma kopię tego schematu, dlatego całkowicie zasadnym jest narzucenie przestrzegania schematu i jego integralności, robienie tego bezpośrednio w bazie danych – tam, gdzie przechowywane są informacje.
To stara, wręcz oklepana mądrość. Klucze główne i unikalne to dobrze. Klucze obce – to dobrze. Weryfikacja ograniczeń – to dobrze. Twierdzenia – to dobrze.

Co więcej, to jeszcze nie wszystko. Na przykład, używając Oracle, prawdopodobnie zechcesz określić:

  • W którym przestrzeni tabel znajduje się twoja tabela
  • Jaką ma wartość PCTFREE
  • Jaki jest rozmiar pamięci podręcznej w twojej sekwencji (za identyfikatorem)

Może to wszystko nie ma dużego znaczenia w małych systemach, ale nie trzeba czekać na przejście do obszaru „big data” – można znacznie wcześniej zacząć korzystać z udoskonaleń dostarczanych przez dostawcę, takich jak wspomniane powyżej. Żadne ORM, które miałem okazję zobaczyć (w tym jOOQ), nie zapewniają dostępu do pełnego zestawu opcji DDL, które być może zechcesz wykorzystać w swojej bazie danych. ORM oferują pewne narzędzia, które pomagają pisać DDL.

Ale w końcu dobrze zaprojektowany schemat jest ręcznie napisany w DDL. Każdy wygenerowany DDL jest jedynie jego przybliżeniem.

A co z modelem klienta?

Jak wspomniano wcześniej, po stronie klienta będziesz potrzebować kopii schematu swojej bazy danych, widoku klienta. Nadmiarowo wspominać, że ten widok klienta musi być zsynchronizowany z rzeczywistym modelem. Jak najlepiej to osiągnąć? Przy pomocy generatora kodu.

Wszystkie bazy danych udostępniają swoją metainformację przez SQL. Oto jak uzyskać wszystkie tabele z twojej bazy danych w różnych dialektach SQL:

	-- H2, HSQLDB, MySQL, PostgreSQL, SQL Server
SELECT table_schema, table_name
FROM information_schema.tables
 
-- DB2
SELECT tabschema, tabname
FROM syscat.tables
 
-- Oracle
SELECT owner, table_name
FROM all_tables
 
-- SQLite
SELECT name
FROM sqlite_master
 
-- Teradata
SELECT databasename, tablename
FROM dbc.tables

Te zapytania (lub podobne, w zależności od tego, czy należy także uwzględniać widoki, zmaterializowane widoki, funkcje z wartością tabelaryczną) również są realizowane za pomocą wywołania DatabaseMetaData.getTables() z JDBC, albo przy pomocy modułu metadanych jOOQ.

Z wyników takich zapytań stosunkowo łatwo jest wygenerować dowolny widok modelu twojej bazy danych, niezależnie od technologii, którą wykorzystujesz po stronie klienta.

  • Jeśli używasz JDBC lub Spring, możesz stworzyć zbiór stałych stringów
  • Jeśli używasz JPA, możesz wygenerować same encje
  • Jeśli używasz jOOQ, możesz wygenerować metamodel jOOQ

W zależności od tego, jakie możliwości oferuje Twoje API klienckie (np. jOOQ lub JPA), wygenerowany model metadanych może być naprawdę bogaty i kompletny. Weźmy na przykład możliwość niejawnych połączeń, występującą w jOOQ 3.11, która opiera się na wygenerowanej metainformacji o relacjach kluczy obcych między Twoimi tabelami.

Teraz każde zwiększenie bazy danych automatycznie spowoduje aktualizację kodu klienckiego. Wyobraź sobie na przykład:

ALTER TABLE book RENAME COLUMN title TO book_title;

Czy naprawdę chciałbyś wykonywać tę pracę dwa razy? Absolutnie nie. Po prostu rejestrujemy DDL, uruchamiamy go przez Twój pipeline budowania i otrzymujemy zaktualizowaną encję:

@Entity
@Table(name = "book", indexes = {
 
  // Czy o tym myślałeś?
  @Index(name = "i_book_title", columnList = "book_title")
})
class Book {
  @Id
  @GeneratedValue(strategy = IDENTITY)
  int id;
 
  @Column("book_title")
  String bookTitle;
}

Lub zaktualizowaną klasę jOOQ. Większość zmian DDL wpływa także na semantykę, a nie tylko na składnię. Dlatego pomocne może być spojrzenie na skompilowany kod, aby zobaczyć, jaki kod będzie (lub może być) dotknięty zwiększeniem Twojej bazy danych.

Jedyna prawda

Bez względu na to, jakiej technologii używasz, zawsze istnieje jeden model, który jest jedynym źródłem prawdy dla danej podsystemu – a przynajmniej powinniśmy do tego dążyć i unikać takiego zbioru danych, gdzie „prawda” jest jednocześnie wszędzie i nigdzie. Może być znacznie łatwiej. Jeśli tylko wymieniasz pliki XML z jakimś innym systemem, po prostu używaj XSD. Zobacz na model metadanych INFORMATION_SCHEMA z jOOQ w formacie XML:
https://www.jooq.org/xsd/jooq-meta-3.10.0.xsd

  • XSD jest bardzo zrozumiała
  • XSD bardzo dobrze oznacza zawartość XML i umożliwia walidację we wszystkich językach klienckich
  • XSD dobrze wersjonuje i ma rozwiniętą kompatybilność wstecz
  • XSD można przekształcać w kod Java za pomocą XJC

Ostatni punkt jest ważny. Przy komunikacji z zewnętrznym systemem za pomocą wiadomości XML chcemy mieć pewność co do ważności naszych wiadomości. Jest to bardzo łatwe do osiągnięcia przy użyciu JAXB, XJC i XSD. Byłoby absolutnym szaleństwem zakładać, że przy podejściu do projektowania «najpierw Java», w którym tworzymy nasze wiadomości jako obiekty Java, można je jakoś rozsądnie odwzorować na XML i wysłać do innego systemu. XML wygenerowany w ten sposób byłby bardzo niskiej jakości, nieudokumentowany i trudny do rozwijania. Gdyby przy takim interfejsie istniała umowa o poziomie usług (SLA), od razu byśmy ją zepsuli.

Szczerze mówiąc, dokładnie to ciągle dzieje się z API w JSON, ale to już inna historia, następnym razem się do tego przyczepię...

Bazy danych: to to samo

Pracując z bazami danych, rozumiesz, że wszystkie one są w zasadzie podobne. Baza danych posiada swoje dane i powinna zarządzać schematem. Wszelkie zmiany w schemacie muszą być realizowane bezpośrednio w DDL, aby zaktualizować jeden źródło prawdy.

Kiedy aktualizacja źródła nastąpi, wszyscy klienci również muszą zaktualizować swoje kopie modelu. Niektórzy klienci mogą być napisani w Javie z użyciem jOOQ i Hibernate lub JDBC (lub wszystkiego naraz). Inne klienci mogą być napisani w Perl (pozostaje tylko życzyć im powodzenia), a jeszcze inne w C#. To nie ma znaczenia. Główny model znajduje się w bazie danych. Modele generowane przy użyciu ORM są zazwyczaj niskiej jakości, słabo udokumentowane i trudne w rozwijaniu.

Dlatego nie popełniaj błędów. Od samego początku nie popełniaj błędów. Pracuj z założeniem bazy danych. Zbuduj taki pipeline wdrożeniowy, który można zautomatyzować. Włącz generatory kodu, aby było wygodnie kopiować model swojej bazy danych i przekazywać go do klientów. I przestań martwić się o generatory kodu. Są dobre. Dzięki nim staniesz się bardziej produktywny. Wystarczy tylko na początku poświęcić trochę czasu na ich konfigurację – a potem czeka na Ciebie lata zwiększonej wydajności, z których ukształtuje się historia Twojego projektu.

Na razie nie dziękuj, później.

Wyjaśnienie

Dla jasności: Ten artykuł w żadnym wypadku nie propaguje, że model bazy danych powinien zdominować cały system (tj. obszar tematyczny, logika biznesowa itd.). W tym artykule mówię o tym, że kod kliencki, który współdziała z bazą danych, powinien działać w oparciu o model bazy danych, tak, aby sam nie reprodukował modelu bazy danych w statusie „pierwszej klasy”. Taka logika zwykle znajduje się na poziomie dostępu do danych po stronie klienta.

W architekturach dwupoziomowych, które wciąż gdzieś funkcjonują, taki model systemu może być jedynym możliwym. Jednak w większości systemów poziom dostępu do danych wydaje mi się „podsystemem”, inkapsulującym model bazy danych.

Wyjątki

Z każdego przepisu są wyjątki, i już mówiłem, że podejście z prymatą bazy danych i generowaniem kodu źródłowego może czasami być niewłaściwe. Oto kilka takich wyjątków (prawdopodobnie znajdą się także inne):

  • Kiedy schemat jest nieznany i należy go odkryć. Na przykład, jesteś dostawcą narzędzi, które pomagają użytkownikom orientować się w dowolnym schemacie. Uff. Tutaj trudno bez generacji kodu. Ale wciąż – baza danych przede wszystkim.
  • Kiedy schemat musi być generowany na bieżąco w celu rozwiązania pewnego zadania. Ten przykład wydaje się nieco wyrafinowaną wersją wzorca entity attribute valuetj. rzeczywiście nie masz ściśle określonego schematu. W takim przypadku często nie można nawet być pewnym, czy nadaje się do używania RDBMS.

Wyjątki z definicji są wyjątkowe. W większości przypadków związanych z używaniem RDBMS schemat jest znany z góry, znajduje się wewnątrz RDBMS i jest jedynym źródłem „prawdy”, a wszyscy klienci muszą zaopatrywać się w jego pochodne. W idealnym przypadku powinno się wtedy używać generatora kodu.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster