Po krótkiej przerwie wracam do pisania wpisów na bloga. Muszę przyznać, że bardzo mi tego brakowało. Dzisiaj wziąłem sobie na tapet narzędzie, o którym słyszałem, ale nie miałem okazji zastosować go w praktyce. Wraz z rozpoczęciem pracy u nowego pracodawcy udało mi się z nim w końcu zapoznać. O jakie narzędzie dokładnie chodzi? O bibliotekę MapStruct. Już po miesiącu korzystania z niej napotkałem kilka ciekawych przypadków wykorzystania, którymi chcę się z Tobą podzielić. Jednak w tym wpisie na start chciałbym poruszyć same podstawy tej biblioteki.
W kilku słowach o MapStruct
MapStruct to generator kodu, który upraszcza implementację mapującą/konwertującą obiekty pomiędzy sobą. Wykorzystanie uzyskanego kodu sprowadza się tylko do wywołania prostej metody Javowowej. Kiedy warto skorzystać z tej biblioteki? W przypadku, gdy nasza aplikacja składa się z wielu warstw i nie chcemy, żeby niektóre obiekty przenikały pomiędzy nimi wszystkimi. Wtedy wprowadza się proste klasy będące strukturami danych nazywane DTO. Jednak tworzenie własnego mappera jest pracochłonne, czasochłonne oraz błędogenne. Łatwo o pomyłkę, gdy np. operujemy na klasach zawierających w sobie 10 tych samych typów danych. Z tego powodu warto delegować taką pracę do dedykowanej biblioteki jaką jest np. MapStruct. Oczywiście czasem możesz spotkać się z oporem korzystania z zewnętrznych rozwiązań jaki ja napotkałem w jednej z firm, w której pracowałem (syndrom Not invented here, ale to nie temat dzisiejszego wpisu). Przejdźmy zatem do tego co misie lubią najbardziej, czyli do kodu.
Jak korzystać z MapStruct?
Dodanie niezbędnych zależności
MapStruct to tzw. annotation processor, który jest podłączany do kompilatora Javy. Co to oznacza? Korzystając z odpowiednich adnotacji jak np. @Mapper
wskazujemy bibliotece miejsca, dla których ma zostać wygenerowany kod podczas kompilacji programu. Aby przekonać się o tym na własnej skórze utwórzmy nowy projekt oparty o Maven oraz Javę 17 i dodajmy następujące zależności do pliku POM. Jeśli chcesz przy okazji dowiedzieć się więcej o Maven to zapraszam do tego artykułu.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<project>
...
<properties>
...
<org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
</properties>
<dependencies>
...
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.22.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
...
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>17</source>
<target>17</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
Jak widzisz do pluginu kompilacyjnego musieliśmy podpiąć procesor adnotacji MapStruct o czym wspomniałem wcześniej. Dodatkowo zamieściliśmy również biblioteki testowe, aby sprawdzić naocznie działanie omawianej biblioteki.
Jako dygresja dodam, że nie wydaje się, aby testowanie zewnętrznego rozwiązania było opłacalne w komercyjnym projekcie. Musimy po prostu obdarzyć dane narzędzie zaufaniem, głównie ludzi, którzy jest stworzyli i wierzyć, że dobrze je przetestowali. To trochę jak z młotkiem. Korzystamy z niego niekoniecznie sprawdzając czy spełnia on wszystkie warunki graniczne zadeklarowane przez producentów. Oczywiście ktoś może stwierdzić, że przy wytwarzaniu oprogramowaniu sytuacja ma się inaczej i pewnie ma rację, bo jak zawsze w świecie IT można powiedzieć, że “to zależy”. Jednak w większości przypadków takie podejście nie ma zastosowania.
Na GitHub możemy zweryfikować jakie pokrycie testami ma dana biblioteka. Może nie jest to najlepsza metryka, ale zawsze to jakiś punkt wyjścia. Dla przykładu w MapStruct na ten moment wynik utrzymuje się na poziomie 88%.
Tworzymy klasy do mapowania
Stwórzmy teraz dwie klasy, dla których chcielibyśmy dokonać konwersji. Będzie to klasa Computer
oraz ComputerDto
, które zawierają te same pola processor
, graphicCard
oraz ramGb
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package pl.devcezz.mapstruct.computer;
public class Computer {
private final ProcessorProducer processor;
private final String graphicCard;
private final int ramGb;
public Computer(ProcessorProducer processor, String graphicCard, int ramGb) {
this.processor = processor;
this.graphicCard = graphicCard;
this.ramGb = ramGb;
}
public ProcessorProducer getProcessor() {
return processor;
}
public String getGraphicCard() {
return graphicCard;
}
public int getRamGb() {
return ramGb;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package pl.devcezz.mapstruct.computer;
public class ComputerDto {
private ProcessorProducer processor;
private String graphicCard;
private int ramGb;
public void setProcessor(ProcessorProducer processor) {
this.processor = processor;
}
public void setGraphicCard(String graphicCard) {
this.graphicCard = graphicCard;
}
public void setRamGb(int ramGb) {
this.ramGb = ramGb;
}
public ProcessorProducer getProcessor() {
return processor;
}
public String getGraphicCard() {
return graphicCard;
}
public int getRamGb() {
return ramGb;
}
}
Następnie weźmy się za mapper. Będzie to interfejs oznaczony adnotacją @Mapper
, zawierający statyczne pole INSTANCE
wskazujące na ten mapper oraz dwie metody do konwersji z Computer
na ComputerDto
oraz z ComputerDto
na Computer
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package pl.devcezz.mapstruct.computer;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
@Mapper
public interface ComputerMapper {
ComputerMapper INSTANCE = Mappers.getMapper(ComputerMapper.class);
ComputerDto computerToComputerDto(Computer computer);
Computer computerDtoToComputer(ComputerDto computer);
}
I to tyle! Reszta magii będzie działa się za naszymi plecami. Sprawdźmy jak to wygląda w praktyce i napiszmy testy.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package pl.devcezz.mapstruct.computer;
import org.junit.jupiter.api.Test;
import pl.devcezz.mapstruct.Computer;
import pl.devcezz.mapstruct.ComputerDto;
import pl.devcezz.mapstruct.ComputerMapper;
import pl.devcezz.mapstruct.ProcessorProducer;
import static org.assertj.core.api.Assertions.assertThat;
public class ComputerMapperTest {
@Test
void should_map_computer_to_computer_dto() {
Computer computer = new Computer(
ProcessorProducer.INTEL,
"PNY Quadro RTX 5000 16GB GDDR6",
32
);
ComputerDto computerDto = ComputerMapper.INSTANCE.computerToComputerDto(computer);
assertThat(computerDto.getProcessor()).isEqualTo(ProcessorProducer.INTEL);
assertThat(computerDto.getGraphicCard()).isEqualTo("PNY Quadro RTX 5000 16GB GDDR6");
assertThat(computerDto.getRamGb()).isEqualTo(32);
}
@Test
void should_map_computer_dto_to_computer() {
ComputerDto computerDto = new ComputerDto();
computerDto.setProcessor(ProcessorProducer.AMD);
computerDto.setGraphicCard("GIGABYTE GeForce RTX 3080 Turbo LHR 10GB");
computerDto.setRamGb(64);
Computer computer = ComputerMapper.INSTANCE.computerDtoToComputer(computerDto);
assertThat(computer.getProcessor()).isEqualTo(ProcessorProducer.AMD);
assertThat(computer.getGraphicCard()).isEqualTo("GIGABYTE GeForce RTX 3080 Turbo LHR 10GB");
assertThat(computer.getRamGb()).isEqualTo(64);
}
}
Są zielone, więc biblioteka zadziałała tak jak chcieliśmy. Jednak co to się tak naprawdę wydarzyło? Cała sztuka polega na tym, że MapStuct wygenerował za nas implementację tego interfejsu. Znajduje się ona w katalogu target\generated-sources\annotations\pl\devcezz\mapstruct\ComputerMapperImpl
i ma następującą strukturę.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package pl.devcezz.mapstruct.computer;
import javax.annotation.processing.Generated;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2022-03-05T15:37:15+0100",
comments = "version: 1.4.2.Final, compiler: javac, environment: Java 17 (Oracle Corporation)"
)
public class ComputerMapperImpl implements ComputerMapper {
@Override
public ComputerDto computerToComputerDto(Computer computer) {
if ( computer == null ) {
return null;
}
ComputerDto computerDto = new ComputerDto();
computerDto.setProcessor( computer.getProcessor() );
computerDto.setGraphicCard( computer.getGraphicCard() );
computerDto.setRamGb( computer.getRamGb() );
return computerDto;
}
@Override
public Computer computerDtoToComputer(ComputerDto computer) {
if ( computer == null ) {
return null;
}
ProcessorProducer processor = null;
String graphicCard = null;
int ramGb = 0;
processor = computer.getProcessor();
graphicCard = computer.getGraphicCard();
ramGb = computer.getRamGb();
Computer computer1 = new Computer( processor, graphicCard, ramGb );
return computer1;
}
}
To właśnie robi za nas annotation processor MapStructa. Wygenerował cały boilerplate code, który musielibyśmy przeklikać sami. Warto zauważyć, że niezależnie od tego czy mamy bezargumentowy konstruktor domyślny z setterami czy konstruktor ze wszystkimi polami, ale bez setterów to i tak MapStruct wie co ma zrobić. Możesz również sprawdzić co się stanie, gdy dasz publiczne finalne pola bez getterów i setterów, ale z pełnym konstruktorem.
Co, gdy pola się różnią od siebie nazwami?
No dobra, ale nie zawsze jest tak prosto, że pola dwóch klas są odwzorowane jeden do jednego. Zdarza się, że niektóre z nich przedstawiają ten sam koncept, ale posiadają inną nazwę. Aby lepiej zobrazować tą sytuację stwórzmy klasę reprezentującą amerykańską oraz brytyjską garderobę. Jak wiemy ci pierwsi na spodnie mówią pants a drudzy trousers. Zadeklarujmy, więc odpowiadające im pola w nowopowstałych klasach. Po tak zdefiniowanym problemie co możemy zrobić z tym fantem? Warto spojrzeć do dokumentacji i zapoznać się z działaniem adnotacji @Mapping
. Sprawdźmy jednak na początku co się stanie, gdy tego nie zrobimy.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package pl.devcezz.mapstruct.wardrobe;
import java.util.List;
import java.util.Map;
public class AmericanWardrobe {
private final int numberOfPants;
private final List<String> shirts;
private final Map<Color, Integer> colorToNumberOfSocks;
public AmericanWardrobe(int numberOfPants, List<String> shirts, Map<Color, Integer> colorToNumberOfSocks) {
this.numberOfPants = numberOfPants;
this.shirts = shirts;
this.colorToNumberOfSocks = colorToNumberOfSocks;
}
public int getNumberOfPants() {
return numberOfPants;
}
public List<String> getShirts() {
return shirts;
}
public Map<Color, Integer> getColorToNumberOfSocks() {
return colorToNumberOfSocks;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package pl.devcezz.mapstruct.wardrobe;
import java.util.List;
import java.util.Map;
public class BritishWardrobe {
private final int numberOfTrousers;
private final List<String> shirts;
private final Map<Color, Integer> colorToNumberOfSocks;
public BritishWardrobe(int numberOfTrousers, List<String> shirts, Map<Color, Integer> colorToNumberOfSocks) {
this.numberOfTrousers = numberOfTrousers;
this.shirts = shirts;
this.colorToNumberOfSocks = colorToNumberOfSocks;
}
public int getNumberOfTrousers() {
return numberOfTrousers;
}
public List<String> getShirts() {
return shirts;
}
public Map<Color, Integer> getColorToNumberOfSocks() {
return colorToNumberOfSocks;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package pl.devcezz.mapstruct.wardrobe;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
@Mapper
public interface WardrobeMapper {
WardrobeMapper INSTANCE = Mappers.getMapper(WardrobeMapper.class);
AmericanWardrobe mapToAmericanWardrobe(BritishWardrobe wardrobe);
BritishWardrobe mapToBritishWardrobe(AmericanWardrobe wardrobe);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package pl.devcezz.mapstruct.wardrobe;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
public class WardrobeMapperTest {
@Test
void should_map_british_wardrobe_to_american() {
BritishWardrobe britishWardrobe = new BritishWardrobe(
10,
List.of("blouse", "T-shirt", "tank top"),
Map.of(
Color.RED, 10,
Color.BLUE, 15,
Color.GREEN, 27));
AmericanWardrobe americanWardrobe = WardrobeMapper.INSTANCE.mapToAmericanWardrobe(britishWardrobe);
assertThat(americanWardrobe.getNumberOfPants()).isEqualTo(10);
assertThat(americanWardrobe.getShirts()).containsExactly("blouse", "T-shirt", "tank top");
assertThat(americanWardrobe.getColorToNumberOfSocks())
.containsEntry(Color.RED, 10)
.containsEntry(Color.BLUE, 15)
.containsEntry(Color.GREEN, 27);
}
@Test
void should_map_american_wardrobe_to_british() {
AmericanWardrobe americanWardrobe = new AmericanWardrobe(
14,
List.of("smock", "tunic", "sweatshirt"),
Map.of(
Color.RED, 12,
Color.BLUE, 8,
Color.GREEN, 45));
BritishWardrobe britishWardrobe = WardrobeMapper.INSTANCE.mapToBritishWardrobe(americanWardrobe);
assertThat(britishWardrobe.getNumberOfTrousers()).isEqualTo(14);
assertThat(britishWardrobe.getShirts()).containsExactly("smock", "tunic", "sweatshirt");
assertThat(britishWardrobe.getColorToNumberOfSocks())
.containsEntry(Color.RED, 12)
.containsEntry(Color.BLUE, 8)
.containsEntry(Color.GREEN, 45);
}
}
Wszystko jest identyczne jak we wcześniejszym przykładzie, czyli zdefiniowaliśmy mapper z dwoma metodami oraz statycznym polem INSTANCE
. Napisaliśmy dwa testy, które weryfikują czy konwersja w obie strony działa poprawnie. Niestety po ich uruchomieniu już pierwsza asercja nie przechodzi. Zamiast oczekiwanego wyniku otrzymujemy domyślne 0 dla int. Nasz mapper po prostu nie wie jak ma zamienić numberOfPants
na numberOfTrousers
i odwrotnie.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package pl.devcezz.mapstruct.wardrobe;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.processing.Generated;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2022-03-05T23:25:49+0100",
comments = "version: 1.4.2.Final, compiler: javac, environment: Java 17 (Oracle Corporation)"
)
public class WardrobeMapperImpl implements WardrobeMapper {
@Override
public AmericanWardrobe mapToAmericanWardrobe(BritishWardrobe wardrobe) {
if ( wardrobe == null ) {
return null;
}
List<String> shirts = null;
Map<Color, Integer> colorToNumberOfSocks = null;
List<String> list = wardrobe.getShirts();
if ( list != null ) {
shirts = new ArrayList<String>( list );
}
Map<Color, Integer> map = wardrobe.getColorToNumberOfSocks();
if ( map != null ) {
colorToNumberOfSocks = new HashMap<Color, Integer>( map );
}
int numberOfPants = 0;
AmericanWardrobe americanWardrobe = new AmericanWardrobe( numberOfPants, shirts, colorToNumberOfSocks );
return americanWardrobe;
}
@Override
public BritishWardrobe mapToBritishWardrobe(AmericanWardrobe wardrobe) {
if ( wardrobe == null ) {
return null;
}
List<String> shirts = null;
Map<Color, Integer> colorToNumberOfSocks = null;
List<String> list = wardrobe.getShirts();
if ( list != null ) {
shirts = new ArrayList<String>( list );
}
Map<Color, Integer> map = wardrobe.getColorToNumberOfSocks();
if ( map != null ) {
colorToNumberOfSocks = new HashMap<Color, Integer>( map );
}
int numberOfTrousers = 0;
BritishWardrobe britishWardrobe = new BritishWardrobe( numberOfTrousers, shirts, colorToNumberOfSocks );
return britishWardrobe;
}
}
Przy okazji możemy zobaczyć w jaki sposób mapowane są listy oraz mapy przy pomocy MapStruct. Wracając do tematu, musimy nauczyć nasz mapper jak ma się obchodzić z problematycznym polem. Służy do tego wcześniej wspomniana adnotacja @Mapping
. W przypadku, gdyby była potrzeba stworzenia większej ilości takich instrukcji to możemy je umieścić w dodatkowej, opakowującej adnotacji @Mappings
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package pl.devcezz.mapstruct.wardrobe;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;
import org.mapstruct.factory.Mappers;
@Mapper
public interface WardrobeMapper {
WardrobeMapper INSTANCE = Mappers.getMapper(WardrobeMapper.class);
@Mappings(
@Mapping(source = "numberOfTrousers", target = "numberOfPants")
)
AmericanWardrobe mapToAmericanWardrobe(BritishWardrobe wardrobe);
@Mappings(
@Mapping(source = "numberOfPants", target = "numberOfTrousers")
)
BritishWardrobe mapToBritishWardrobe(AmericanWardrobe wardrobe);
}
W tym momencie uruchamiając testy wszystkie zapalą się na zielono. Oto cała sztuka jaką trzeba było wykonać na potrzeby tego zadania. Oczywiście warto dodać, że jeśli popełnimy jakąś literówkę w nazwie pola (przy wypełnianiu source albo target) i spróbujemy uruchomić test to na etapie kompilacji dostaniemy informację, że taka własność nie istnieje w danej klasie oraz procesor MapStruct wskaże nam nazwę o jaką mogło nam prawdopodobnie chodzić.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package pl.devcezz.mapstruct.wardrobe;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.processing.Generated;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2022-03-05T23:31:48+0100",
comments = "version: 1.4.2.Final, compiler: javac, environment: Java 17 (Oracle Corporation)"
)
public class WardrobeMapperImpl implements WardrobeMapper {
@Override
public AmericanWardrobe mapToAmericanWardrobe(BritishWardrobe wardrobe) {
if ( wardrobe == null ) {
return null;
}
int numberOfPants = 0;
List<String> shirts = null;
Map<Color, Integer> colorToNumberOfSocks = null;
numberOfPants = wardrobe.getNumberOfTrousers();
List<String> list = wardrobe.getShirts();
if ( list != null ) {
shirts = new ArrayList<String>( list );
}
Map<Color, Integer> map = wardrobe.getColorToNumberOfSocks();
if ( map != null ) {
colorToNumberOfSocks = new HashMap<Color, Integer>( map );
}
AmericanWardrobe americanWardrobe = new AmericanWardrobe( numberOfPants, shirts, colorToNumberOfSocks );
return americanWardrobe;
}
@Override
public BritishWardrobe mapToBritishWardrobe(AmericanWardrobe wardrobe) {
if ( wardrobe == null ) {
return null;
}
int numberOfTrousers = 0;
List<String> shirts = null;
Map<Color, Integer> colorToNumberOfSocks = null;
numberOfTrousers = wardrobe.getNumberOfPants();
List<String> list = wardrobe.getShirts();
if ( list != null ) {
shirts = new ArrayList<String>( list );
}
Map<Color, Integer> map = wardrobe.getColorToNumberOfSocks();
if ( map != null ) {
colorToNumberOfSocks = new HashMap<Color, Integer>( map );
}
BritishWardrobe britishWardrobe = new BritishWardrobe( numberOfTrousers, shirts, colorToNumberOfSocks );
return britishWardrobe;
}
}
Plugin do IDE IntelliJ
Tak jak wspomniałem wcześniej, trzeba uważać przy wypełnianiu literałów niezbędnych adnotacji @Mapping
. Błąd zostanie co prawda wyłapany naprawdę szybko, ale po co w sumie narażać się na dodatkowy stres. Jeśli korzystasz tak jak ja z IntelliJ to mam dla Ciebie dobrą informację. Istnieje plugin do MapStructa, który pomaga w takich sytuacjach. Dzięki niemu będąc w pustym literale source
czy target
możemy wcisnąć kombinację Ctrl + Space, aby uzyskać nazwy wszystkich dostępnych pól w danej klasie.
Skrót Ctrl + Space wyświetli listę dostępnych pól w danej klasie
Natomiast trzymając Ctrl i klikając lewym przyciskiem na wybrany polu w adnotacji @Mapping
zostaniemy przeniesieni do pliku klasy go posiadającej. Oczywiście działa to też w drugą stronę i jeśli wybierzemy w ten sam sposób pole klasy to zobaczymy jej wykorzystanie w adnotacji @Mapping
.
Podsumowanie
Mam nadzieję, że ten wpis chociaż trochę przybliżył Ci możliwości jakie daje biblioteka MapStruct. Oczywiście w jednym z kolejnych wpisów postaram się przybliżyć jeszcze inne zagadnienia kryjące się za tym narzędziem oraz jaki problem napotkałem podczas pracy. Naprawdę dobrze jest wrócić po takiej przerwie do pisania i mam nadzieję, że do usłyszenia niebawem!