Wszystko, o czym za mało mówi się w branży IT.
Prosto na Twoją skrzynkę.
Leave this field empty if you’re human:
Pattern Matching for Switch Expressions and Statements
Kolejny mechanizm, który wykorzystuje Pattern Matching to Pattern Matching for Switch Expressions and Statements. Mechanizm obecnie jest w fazie preview. W tym przypadku dopasowanie matched target do jednego z podanych wzorców (pattern ) odbywa się z wykorzystaniem instrukcji switch lub switch expressions. Przeanalizujmy poniższy kod:
Object o = "KM";
String result = switch (o) {
case Integer a -> "int %d".formatted(a);
case Double b -> "double %f".formatted(b);
case String c -> c.toLowerCase();
case Object ob -> "...";
};
Matched target w tym przypadku to selector expression instrukcji switch, czyli referencja o , podana w nawiasach przy słowie kluczowym switch. Każdy case określa kolejny pattern , do którego będzie dopasowany obiekt spod referencji o . W zależności od tego, jakiego typu obiekt wskazywany jest przez referencję o , wykonają się instrukcje przyporządkowane konkretnemu case. Mechanizm Pattern Matching for Switch nie tylko zwiększa przejrzystość kodu, ale również wydajność aplikacji. Instrukcja switch expressions w tej konfiguracji wykonuje się ze złożonością czasową O(1), podczas gdy podobne sprawdzanie z wykorzystaniem bloku instrukcji if – else if – else zwiększyłoby złożoność czasową do O(n).
Pattern Matching for Switch często współpracuje z innym nowym elementem języka Java, czyli Guarded Patterns. Połączenie tych dwóch rozwiązań pozwala na jeszcze bardziej precyzyjne definiowanie warunków, przyporządkowanych do konkretnego case-a. Tym bardziej, że teraz warunki, które umieszczamy w case możemy zapisywać z wykorzystaniem nowego słowa kluczowego when, co jeszcze bardziej upraszcza kod i zwiększa jego czytelność. Implementowane w ten sposób warunki umieszczane po case nazywamy guarded case label. Poniżej pokazano kod implementujący to podejście:
Object o = "KM";
String result = switch (o) {
case Integer a -> "int %d".formatted(a);
case String c when c.length() < 4 -> c.toLowerCase();
case Object ob -> "...";
};
JDK 20. Record Pattern
Rekord to typ wprowadzony w JDK 14 . Pozwala na proste i szybkie definiowanie struktury konkretnego bytu oraz automatyczne generowanie ważnych metod. Na rzecz rekordu możemy tworzyć obiekty, którymi możemy zarządzać bez możliwości ich mutowania. Dla rekordów również wdrożono koncepcję Pattern Matching, co doprowadziło do powstania mechanizmu Record Pattern, który obecnie znajduje się w fazie preview. Rozpatrzmy przykładowo record Address:
public record Address(String city, String street, int number) {}
Z rekordami możemy stosować operator instanceof i sprawdzać, czy konkretna referencja wskazuje na obiekt typu Address. Dodatkowo wobec obiektu, który uzyskamy po rzutowaniu, możemy zastosować mechanizm record deconstruction, co pozwala bardzo wygodnie wydobyć z obiektu wartości poszczególnych pól składowych. Zobacz kod z zastosowaniem tego podejścia:
Object a = new Address("C", "S", 1);
if (a instanceof Address(String c, String s, int n)) {
System.out.println(c + " " + s + " " + n);
}
W tym przypadku pattern to Address(String c, String s, int n) . Postać Record Pattern zawsze odnosi się do postaci konstruktora kanonicznego rekordu, na którym działa ten mechanizm. W przypadku rekordu Address generowany jest konstruktor kanoniczny trójargumentowy, który przyjmuje argumenty city , street oraz number . To zawsze w przypadku rekordu Address wymusi podanie w sekcji dekonstrukcji rekordu trzech elementów, które pozwolą przechwycić wartości i obiekty spod city , street oraz number . W analizowanym kodzie uda się to zrobić dzięki zmiennym c , s oraz n . Gdyby do rekordu Address dodano konstruktor z dwoma parametrami:
public record Address(String city, String street, int number) {
public Address(String city, String street) {
this(city, street, 1);
}
}
nic to nie zmienia. Mechanizm Record Pattern nie poradzi sobie z dopasowaniem struktury obiektu rekordu Address do proponowanej postaci record deconstruction, którą przedstawia kod poniżej:
Object a = new Address("C", "S", 1);
if (a instanceof Address(String c, String s) address) {
System.out.println(c + " " + s + " " + n);
System.out.println(address);
}
Kompilator zgłosi błąd niepoprawnej postaci kodu realizującego dekonstrukcję. Record Pattern potrafi rozpoznać typ dopasowanego obiektu i wspiera wykorzystywanie var. Dlatego możesz napisać jeszcze inną postać bloku dekonstrukcji rekordu:
Object a = new Address("C", "S", 1);
if (a instanceof Address(var c, var s, var n) address) {
System.out.println(c + " " + s + " " + n);
System.out.println(address);
}
Mechanizm Record Pattern współpracuje z mechanizmem Pattern Matching for Switch Expressions and Statements. Daje to niezwykle elastyczną strukturę w kodzie, która najpierw odpowiednio dopasuje typ sprawdzanego obiektu, a następnie zastosuje wobec niego dekonstrukcję i wydobędzie z niego poszczególne elementy. Jeżeli przyjmiemy, że zdefiniowano rekord, taki jak poniżej:
record Container(Object data) {}
możemy zastosować go w pracy z Pattern Matching for Switch, tak jak pokazuje to kod poniżej:
Container c = new Container("KM");
var res = switch (c) {
case Container(String s) -> "string: %s".formatted(s);
case Container(Integer i) -> "int: %d".formatted(i);
case Container(Object ob) -> "...";
};
System.out.println(res);
Kolejna struktura związana z Record Pattern to Nested Record Pattern i możliwość dekonstruowania rekordów zagnieżdżonych w innych rekordach. Dla przykładowego rekordu:
record Contact(String name, String surname, Address address) {}
gdzie Address to rekord o strukturze, którą przedstawiłem wcześniej, możemy zastosować Record Pattern, tak jak pokazano poniżej:
Object contact = new Contact(
"ADAM", "NOWAK", new Address("C", "S", 1));
if (contact instanceof Contact(
var name,
var surname,
Address (var city, var street, var number))) {
System.out.println(name);
System.out.println(surname);
System.out.println(city);
System.out.println(street);
System.out.println(number);
}
Record Pattern daje nam ogromne możliwości i sprawia, że odnoszenie się do poszczególnych elementów obiektów rekordu jest bardzo łatwe i elastyczne. Mechanizm posiada jednak pewne ograniczenie. Record Pattern nie wspiera boxing oraz unboxing. Jeżeli utworzysz rekord, tak jak w poniższym kodzie:
record Point(Double x, Double y) {}
czyli określisz typ jego pól przykładowo jako Double, nie możesz później podczas stosowania mechanizmu Record Pattern odwoływać się do tych pól poprzez zmienne typu double. Dostaniesz wtedy błąd kompilacji:
Object p = new Point(12.2, 23.4);
if (p instanceof Point(double x, double y)) {
}
W JDK 20 ulepszono rozpoznawanie typów przez mechanizm Record Pattern, podczas gdy pracuje z typami generycznymi. Rozpatrzmy przykładowo interfejs:
interface Multi {}
oraz dwa rekordy, które implementują ten interfejs:
record Tuple (T t1, T t2) implements Multi {}
record Triple (T t1, T t2, T t3) implements Multi {}
Kiedy utworzymy obiekt wskazywany przez rekord typu Multi z konkretnie sprecyzowanym typem T:
Multi multi = new Triple<>("A", "B", "C");
mechanizm Record Pattern samodzielnie wywnioskuje, co zostało podstawione pod typ T i nie musimy już tego nigdzie precyzować. W kodzie poniżej od początku kompilator zna typ referencji t1, t2 oraz t3, którym jest String.
if (multi instanceof Tuple(var t1, var t2)) {
System.out.println(t1.toLowerCase());
System.out.println(t2.toUpperCase());
} else if (multi instanceof Triple(var t1, var t2, var t3)) {
System.out.println(t1.strip());
System.out.println(t2.stripLeading());
System.out.println(t3.stripTrailing());
}
Podobne zachowanie uzyskamy podczas pracy z instrukcją switch:
switch (multi) {
case Tuple(var t1, var t2) -> {
System.out.println(t1.toLowerCase());
System.out.println(t2.toUpperCase());
}
case Triple(var t1, var t2, var t3) -> {
System.out.println(t1.strip());
System.out.println(t2.stripLeading());
System.out.println(t3.stripTrailing());
}
default -> {
// ...
}
}
Pattern Matching for Enhanced For Statement
Przegląd mechanizmów, w których wykorzystuje się Pattern Matching kończymy na nowej możliwości języka Java, która weszła od wersji JDK 20. Dzięki niej mechanizm Record Pattern można stosować podczas iterowania po kolekcji lub tablicy obiektów rekordu. Prezentuje to przykład poniżej:
List points = List.of(
new Point(12.2, 32.1),
new Point(22.2, 12.1)
);
for (Point(Double x, Double y) : points) {
System.out.println("x = " + x + " y = " + y);
}
W tym przypadku występuje jednak kilka ograniczeń. Elementy przeglądanej kolekcji nie mogą być null oraz kiedy jeden element nie pasuje do określonego wzorca rzucany jest wyjątek MatchException. Mimo to jest to kolejny mechanizm, który zachęca nas do używania rekordów w aplikacjach Java ze względu na duże możliwości i prostotę zarządzania strukturą obiektów tego rodzaju.
Jak widzisz koncepcja Pattern Matching na dobre zakorzeniła się w różnych elementach języka Java. Dzięki niej do Java wprowadza się nowsze konstrukcje programistyczne, znane z innych języków programowania. Jeżeli chcesz dowiedzieć się jeszcze więcej o mechanizmie Pattern Matching wejdź na stronę projektu Amber openjdk.org/projects/amber/ , który skupia się na rozwijaniu tych elementów języka Java, które wykorzystują między innymi podejście Pattern Matching.
JDK 20. Live Threads
Teraz skupimy się na ciekawych mechanizmach, które są rozwijane w ramach projektu Loom wiki.openjdk.org/display/loom . Chodzi o Virtual Threads (faza preview) oraz Structured Concurrency (faza incubator) wprowadzone w JDK 19. Z racji tego, że są to nowe byty, może okazać się, że kiedy będą wprowadzane do języka Java w wersji LTS, ich zachowanie może różnić się nieco od tego, które opisuję poniżej.
JVM jest środowiskiem wielowątkowym. W Java mamy specjalny typ java.lang.Thread, który pozwala tworzyć wątki w ramach aplikacji Java. Każdy wątek utworzony w ten sposób jest wątkiem systemowym, czyli inaczej Platform Thread. Z wątkami tego typu wiąże się kilka problemów. Za każdym razem kiedy wątek tego rodzaju jest tworzony, system operacyjny musi zaalokować bardzo dużą ilość pamięci na stosie, żeby przechować zasoby wątku. Później podczas pracy wątku, ten ogromny obszar pamięci liczony niekiedy w megabajtach, musi być zarządzany. Wiąże się z tym szereg operacji, które są kosztowne zarówno pod kątem pamięci, jak również czasu. Duża ilość pamięci zajmowana przez pojedynczy wątek systemowy nakłada dodatkowo ograniczenia związane z liczbą możliwych do utworzenia wątków. Przy przekroczeniu tej ilości otrzymamy błąd OutOfMemoryError.
Oferty pracy z
W przeszłości próbowano w Java rozwiązać ten problem poprzez wdrażanie różnych koncepcji. Jednak dopiero w ostatnim czasie wprowadzono mechanizm z projektu Loom o nazwie Virtual Threads. Wszystko wskazuje na to, że wreszcie doczekaliśmy się rozwiązania, które dobrze poradzi sobie z problemami wątków Java, a wszystko to ubrane będzie w przyjemną składnię tego języka. Virtual Threads to alternatywne podejście, w którym pamięć związana z tworzonym wątkiem przechowywana jest na stercie. Tworzony w takiej sytuacji wątek nie zajmuje od razu dużej ilości pamięci, tylko na początku dostaje pewną niezbędną do egzystencji ilość miejsca na stercie. Później w trakcie jego działania i potrzeby przechowania nowych zasobów, ten obszar jest powiększany. Tworzenie takiego wątku jest bardzo proste i prezentuje to kod poniżej:
Thread t = Thread.ofVirtual()
.name("Thread 1")
.start(() -> System.out.println("HELLO"));
Java dostarcza również nową implementację interfejsu ExecutorService o nazwie ThreadPerTaskExecutor. Dzięki niej możemy tworzyć Virtual Thread, za każdym razem, kiedy chcemy uruchomić task, wywołując metodę submit. Takie zachowanie uzyskamy, wywołując metodę Executors.newVirtualThreadPerTaskExecutor. Przykład kodu z wykorzystaniem tej metody umieściłem poniżej:
try (var executor
= Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(()
-> System.out.println("TASK"));
Istnieje również możliwość skorzystania z klasy ThreadFactory, która pozwoli między innymi skonfigurować dynamicznie nadawane nazwy tworzonych wątków wirtualnych. Ułatwia to później ich identyfikację oraz analizę działania. Zobaczysz to w kodzie poniżej. W ramach tworzenia obiektu typu ThreadFactory wykorzystuje się metodę name, której w tym przypadku jako pierwszy argument podałem “thread-”. Oznacza to, że nazwa każdego tworzonego w ten sposób wątku będzie zaczynać się od “thread-”. Drugim argumentem jest wartość początkowa licznika, który za każdym razem kiedy zostanie przydzielona nazwa do nowego wątku, zwiększy się o 1, a jego wartość będzie doklejana na koniec nazwy kolejnego tworzonego wątku. Kiedy chcesz skorzystać z obiektu typu ThreadFactory musisz zastosować metodę Executors.newThreadPerTaskExecutor.
ThreadFactory threadFactory = Thread.ofVirtual()
.name("thread-", 0)
.factory();
try (var executor
= Executors.newThreadPerTaskExecutor(threadFactory)) {
executor.submit(()
-> System.out.println("Action !"));
}
Jak dokładnie działają Virtual Threads? JVM zarządza pulą wątków systemowych, tworzoną w ramach dedykowanej implementacji ForkJoinPool. Na początku ilość Platform Threads wynosi tyle, ile liczba CPU, a potem może być zwiększana, ale nie może przekroczyć wartości 256. Dla każdego utworzonego Virtual Thread JVM planuje jego wykonanie z wykorzystaniem Platform Thread. Virtual threads, które są gotowe do działania umieszczane są w kolejce FIFO i planowane z wykorzystaniem domyślnego scheduler-a zdefiniowanego w klasie VirtualThread. W razie potrzeby możesz zastąpić domyślny scheduler inną implementacją.
Kiedy przychodzi pora na wykonanie wątku Virtual Thread w ramach przydzielonego mu Platform Thread, na początek następuje kopiowanie pamięci przydzielonej dla Virtual Thread ze sterty na stos związany z konkretnym Platform Thread. W tej sytuacji Platform Thread staje się wątkiem odpowiedzialnym za wykonanie przydzielonego mu Virtual Thread i wtedy nazywamy go Carrier Thread (wątek który przetwarza Virtual Thread). W trakcie wykonywania Virtual Thread może zdarzyć się tak, że rozpocznie się operacja, która blokuje jego działanie. Wtedy Carrier Thread jest zwalniany, ponieważ nie ma póki co żadnych operacji do wykonania na rzecz zawieszonego Virtual Thread.
Dodatkowo zawartość stosu zwolnionego Carrier Thread jest kopiowana do obszaru na stercie powiązanego z zablokowanym Virtual Thread. To całkowicie zwalnia Carrier Thread, który staje się ponownie Platform Thread i może teraz realizować operacje innego Virtual Thread, który jest gotowy do działania. Kiedy zablokowany Virtual Thread kończy operację, która spowodowała wstrzymanie jego działania, jest ponownie planowany do wykonania i wkrótce zostanie uruchomiony albo przez ten sam Platform Thread, który pracował z nim wcześniej, albo przez inny który akurat jest wolny i może przyjąć nowe zadanie do wykonania.
Poniżej zamieszczam kod prezentujący opisane zachowanie. Przy uruchomieniu aplikacji będziemy mieć tyle Platform Threads, ile CPU. Dlatego zleciłem wykonanie Virtual Threads w liczbie o jeden więcej niż ilość Platform Threads. To pozwoli zaobserwować, że przynajmniej jeden Platform Thread zostanie wykonany więcej niż jeden raz. Ilość dostępnych Platform Threads pozyskasz za pomocą metody Runtime.getRuntime().availableProcessors(). W kodzie wykorzystałem dodatkowo pomocniczą metodę sleep, żeby kod wewnątrz metody submit był przejrzysty i nie wymagał obsługi wyjątków.
static void sleep(int seconds) {
try {
TimeUnit.SECONDS.sleep(seconds);
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
ThreadFactory threadFactory = Thread
.ofVirtual()
.name("task-", 0)
.factory();
int cpus = Runtime.getRuntime().availableProcessors();
try (var executor
= Executors.newThreadPerTaskExecutor(threadFactory)) {
IntStream
.range(0, cpus + 1)
.forEach(i -> executor.submit(() -> {
System.out.println(Thread.currentThread());
sleep(1);
}));
}
Przykładowy wynik uruchomienia powyższego kodu może wyglądać następująco:
VirtualThread[#23,thread-0]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#25,thread-1]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#26,thread-2]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#28,thread-4]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#30,thread-6]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#31,thread-7]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#32,thread-8]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#27,thread-3]/runnable@ForkJoinPool-1-worker-4
VirtualThread[#29,thread-5]/runnable@ForkJoinPool-1-worker-3
Możemy zauważyć, że kolejne Virtual Threads o nazwach thread-1, thread-2, …, thread-8 są wykonywane przez Carrier Threads o nazwach kończących się na worker-1, worker-2, worker-3, worker-4.
Istnieją sytuacje, kiedy Virtual Thread, który jest zablokowany nie zostanie zwolniony przez Carrier Thread. Dzieje się tak, kiedy kod blokujący jest wykonywany wewnątrz bloku lub metody synchronicznej lub kiedy wywoływana jest funkcja natywna. W takiej sytuacji Carrier Thread musi poczekać aż Virtual Thread się skończy. W opisanym stanie oczekiwania Carrier Thread jest nazywany Pinned Thread. Należy unikać takich sytuacji (przykładowo zamiast synchronized stosować Lock API). Kiedy Platform Thread będzie zablokowany, JVM zaangażuje do wykonania Virtual Thread inny Platform Thread, jednak taka sytuacja zmniejsza elastyczność naszej aplikacji i powoduje generowanie nieoptymalnej ilości wątków systemowych.
Structured Concurrency
Model pracy wątków w oparciu o mechanizm Structured Concurrency jest podobny do koncepcji bloków kodu w programowaniu strukturalnym. Kiedy w języku takim jak Java wywołujesz metodę o przykładowej nazwie m2 wewnątrz innej wywołanej metody o przykładowej nazwie m1, wtedy metoda m2 musi zakończyć swoje działanie zanim zakończy się metoda m1. Jest to bardzo prosta koncepcja, która towarzyszy nam w zasadzie od początku nauki programowania. Mechanizm Structured Concurrency zakłada, że podobne zachowanie wdrażamy dla virtual threads, które uruchamiamy w naszym programie. Kiedy wewnątrz wątku v1 uruchamiany jest wątek v2, wątek v2 nie może działać dłużej niż wątek v1.
Kiedy w ramach uruchomionego wątku v1 rusza wątek v2 działają one niezależnie od siebie, ale kiedy wątek v1 będzie się kończył mamy kilka możliwych scenariuszy. Wątek v1 kończy działanie, ponieważ wątek v2 również wcześniej zakończył wykonywanie zdefiniowanych w nim operacji. Inny scenariusz ma miejsce, kiedy wątek v2 wciąż jeszcze działa. Wtedy wątek v1 musi poczekać na jego zakończenie i dopiero kiedy wątek v2 skończy wykonywanie swoich czynności, wątek v1 również przestanie działać. Możesz jeszcze zlecić zatrzymanie wątku nadrzędnego v1, wtedy zatrzymujesz również wątek v2 i nie musisz się martwić o to, że po zatrzymaniu wątku v1, mogą jeszcze w pamięci zostać działające inne wątki z nim związane. To ułatwia zarządzanie wątkami i sprawia, że kod jest łatwy i przejrzysty.
Implementacja mechanizmu Structured Concurrency w Java wymaga zastosowania typu StructuredTaskScope, który używamy wewnątrz bloku try-with-resources. Tworzony jest wtedy specjalny obiekt, który w kodzie poniżej wskazywany jest przez referencję scope . Właśnie ten obiekt zarządza Virtual Threads w opisany wcześniej sposób. Możemy zdefiniować różne rodzaje zakresów do zarządzania Virtual Threads. W pierwszym przykładzie zastosowano typ ShutdownOnFailure. Generuje on scope, który zamyka każdy uruchomiony w tym scope Virtual Thread, który w trakcie swojego działania rzuci wyjątek. Działanie tak zdefiniowanego zakresu kończy się, kiedy wszystkie utworzone w nim Virtual Threads zakończą się prawidłowo.
Kiedy jeden z Virtual Threads rzuci wyjątek, wtedy jego przechwycenie przez sekcję try-with-resources, w której zdefiniowano zakres, jest możliwe tylko wtedy, kiedy na rzecz referencji scope wywołamy metodę throwIfFailed. To wywołanie musi wystąpić po wywołaniu na rzecz scope metody join.
static String getMessage1() {
return "MESSAGE 1";
}
static String getMessage2() {
return "MESSAGE 2";
}
static String runScope() {
try (var scope
= new StructuredTaskScope.ShutdownOnFailure()) {
var msg1 = scope.fork(App::getMessage1);
var msg2 = scope.fork(App::getMessage2);
scope.join();
scope.throwIfFailed();
return msg1.resultNow() + " " + msg2.resultNow();
} catch (InterruptedException | ExecutionException e) {
throw new IllegalStateException(e);
}
}
public static void main(String[] args) {
System.out.println(runScope());
}
Inny rodzaj zakresu opiera się na konstruktorze ShutdownOnSuccess. W tej konfiguracji praca zakresu kończy się w momencie, kiedy pierwszy Virtual Thread zdefiniowany w tym zakresie kończy się i zwraca wartość, która jednocześnie staje się wartością zwracaną przez cały zakres.
static String getMessage1() {
return "MESSAGE 1";
}
static String getMessage2() {
return "MESSAGE 2";
}
static String runScope() {
try (var scope
= new StructuredTaskScope.ShutdownOnSuccess()) {
scope.fork(App::getMessage1);
scope.fork(App::getMessage2);
scope.join();
return scope.result();
} catch (InterruptedException | ExecutionException e) {
throw new IllegalStateException(e);
}
}
public static void main(String[] args) {
System.out.println(runScope());
}
Scoped Values
Scoped Values to kolejna ciekawa właściwość, która pojawiła się w JDK 20. Mechanizm rozwijany jest w ramach projektu Loom (razem z Virtual Threads oraz Structured Concurrency) i obecnie znajduje się w fazie incubator. Scoped Values umożliwiają łatwe współdzielenie niezmiennych danych w ramach jednego wątku oraz pomiędzy wątkami. Sposób ich działania przypomina obiekty ThreadLocal, które są obecne w Java prawie od samego początku. Scoped Values zostały zaprojektowane pod kątem pracy z Virtual Threads oraz Structured Concurrency, w związku z czym powinny być pierwszym wyborem programisty w najnowszych wersjach Java.
Mają one kilka zalet w stosunku do starszego podejścia. Scoped Values tworzone są tylko na czas życia wątku, w którym jest osadzono. Kiedy wątek kończy pracę, również obiekty typu ScopedValue są oznaczone do usunięcia, co gwarantuje brak wycieków pamięci. Poza tym obiekty ScopedValue są niezmienne i łatwiej zarządzać nimi, kiedy chcemy udostępniać je dla wielu wątków jednocześnie. Najczęściej Scoped Values definiowane są jako pola public static, dzięki czemu można się do nich łatwo odwoływać. Dzięki temu nie musimy przekazywać konkretnego obiektu jako argument wielu metod, co prowadzi do uproszczenia kodu. Poniżej prezentuję prosty przykład, który pokazuje, jak może wyglądać kod, w którym chcemy w kilku miejscach odwołać się do konkretnego obiektu.
public record Product(Long id, String name)
{}
public record User (Long id, String username, boolean isAdmin) {}
public interface ProductRepository {
List findAll(User user);
}
public class ProductRepositoryImpl
implements ProductRepository {
private final List products = List.of(
new Product(1L, "PR A"),
new Product(2L, "PR B")
);
@Override
public List findAll(User user) {
return user.isAdmin() ? products : List.of();
}
}
public class ProductServiceImpl {
private final ProductRepository productRepository;
public ProductServiceImpl(
ProductRepository productRepository) {
this.productRepository = productRepository;
}
public void findAllIfAdmin(User user) {
productRepository
.findAll(user)
.forEach(System.out::println);
}
}
public static void main(String[] args) {
var productRepository
= new ProductRepositoryImpl();
var productService
= new ProductServiceImpl(productRepository);
productService
.findAllIfAdmin(new User(1L, "a", true));
}
Jeżeli chcemy z poziomu obiektu pod referencją productService pobrać przykładową listę obiektów rekordu Product tylko wtedy, kiedy użytkownik ma odpowiednie uprawnienia (pole isAdmin obiektu User posiada wartość true), wtedy istnieje konieczność przekazania obiektu rekordu User jako argument do kilku metod. Najpierw obiekt reprezentujący użytkownika przekazujemy jako argument do metody findAllIfAdmin wywołanej na rzecz productService. Następnie w ramach wywołania metody findAllIfAdmin, przekazujemy ten sam obiekt jako argument kolejnej metody, tym razem findAll wywołanej z poziomu obiektu wskazywanego przez referencję productRepository.
W przypadku bardziej złożonej logiki, prawdopodobnie należałoby przekazywać obiekt rekordu User w jeszcze inne miejsca, jeżeli zależałoby nam na wykonywaniu różnych operacji w zależności od uprawnień użytkownika. To oczywiście wpływa na pogorszenie jakości kodu i jest niewygodne dla programisty. Właśnie w takiej sytuacji możemy zastosować Scoped Value. Poniżej umieściłem kod, który wykorzystując Scoped Value, eliminuje niedogodności z poprzedniego przykładu.
public record Product(Long id, String name) {}
public record User (Long id, String username, boolean isAdmin) {}
public interface ProductRepository {
List findAll();
}
public class ProductRepositoryImpl
implements ProductRepository {
private final List products = List.of(
new Product(1L, "PR A"),
new Product(2L, "PR B")
);
@Override
public List findAll() {
return ProductServiceImpl.USER.get().isAdmin() ?
products : List.of();
}
}
public class ProductServiceImpl {
public static final ScopedValue USER
= ScopedValue.newInstance();
private final ProductRepository productRepository;
public ProductServiceImpl(
ProductRepository productRepository) {
this.productRepository = productRepository;
}
public void findAllIfAdmin(User user) throws Exception {
ScopedValue.where(USER, user, () ->
productRepository
.findAll()
.forEach(System.out::println)
);
}
}
public static void main(String[] args) {
try {
var productRepository
= new ProductRepositoryImpl();
var productService
= new ProductServiceImpl(productRepository);
productService
.findAllIfAdmin(
new User(1L, "a", true));
} catch (Exception e) {
e.printStackTrace();
}
}
W klasie ProductServiceImpl umieszczono publiczne statyczne pole składowe USER, które zawiera dane użytkownika.
public static final ScopedValue USER
= ScopedValue.newInstance();
W taki sposób implementuje się obiekt ScopedValue gotowy do przechowania informacji, które za chwilę będą dostępne wszędzie w metodach wywoływanych w ramach konkretnego wątku. W metodzie findAllIfAdmin znajduje się wywołanie metody where:
ScopedValue.where(USER, user, () ->
productRepository
.findAll()
.forEach(System.out::println)
);
Jako pierwszy argument metoda where przyjmuje referencję ScopedValue USER, która będzie ustawiona na obiekt, podany jako drugi argument. W tym przypadku jest to obiekt rekordu User wskazywany przez referencję user. Trzecim argumentem metody where jest implementacja interfejsu Runnable lub Callable. W ten sposób określamy instancję wątku, w ramach którego będzie dostępny przygotowany wcześniej obiekt typu ScopedValue wskazywany przez referencję USER. Dzięki temu metoda findAll wywoływana na rzecz productRepository nie musi dłużej jako argument przyjmować obiektu rekordu User. Teraz można odwołać się do niego bezpośrednio z wnętrza ciała tej metody, wykorzystując Scoped Value:
@Override
public List findAll() {
return ProductServiceImpl.USER.get().isAdmin() ?
products : List.of();
}
Wystarczy na rzecz obiektu USER wywołać metodę get(). Istnieje również możliwość skorzystania z takich metod jak orElse lub orElseThrow. Dostępnych jest również kilka innych metod do zarządzania obiektem typu User w ramach Scoped Value. Na webinarze pokażę kolejne praktyczne przykłady, z których dowiesz się, w jaki sposób prawidłowo zmieniać wskazanie referencji ScopedValue oraz jak wykorzystać mechanizm Scoped Values podczas pracy z Virtual Threads oraz Structured Concurrency.
JDK 20. Podsumowanie
Za nami omówienie kilku nowości, które w ostatnim czasie pojawiły się w Java . Opisane mechanizmy JDK 20 wnoszą wiele ciekawych rozwiązań, zwiększają jakość kodu i pokazują, że twórcy języka Java dokładają wszelkich starań, żeby język był przyjemny w używaniu i podążał za nowymi koncepcjami w świecie programowania.
Pamiętaj, że na webinarze w poniedziałek 24.04.2023 o godzinie 20 rozwinę temat nowości w języku Java. Dokładnie omówię ich przeznaczenie oraz pokażę praktyczne przykłady użycia.
Jeżeli chcesz solidnie i skutecznie nauczyć się języka Java od podstaw po zaawansowane aplikacje komercyjne, zapraszam Cię na Platformę Online KM Programs. Znajdziesz na niej wiele godzin profesjonalnych nagrań w ramach zamieszczonych tam kursów: km-programs.pl/e-learning/ .
Gotowy na ciekawą przygodę z programowaniem w języku Java? Zapraszam!
VIDEO
Zdjęcie główne artykułu pochodzi z unsplash.com .
Programowaniem i nauczaniem programowania zajmuje się już od kilkunastu lat. Znajduje w tym ogromną pasję, dzięki której cały czas rozwija się i czerpie z tego bardzo dużą przyjemność
Sprawdź kursy Java w KM Programs
Ucz się na platformie online lub uczestnicz w spotkaniach z mentorem