Nasza strona używa cookies. Dowiedz się więcej o celu ich używania i zmianie ustawień w przeglądarce. Korzystając ze strony, wyrażasz zgodę na używanie cookies, zgodnie z aktualnymi ustawieniami przeglądarki. Rozumiem

Clean architecture z Java 11

Carl-Philipp Harmant Senior Software Engineer / Slalom Build
Sprawdź, jak zastosować clean architecture z Javą 11, jak zorganizować projekt i połączyć go z wybranym fremeworkiem
Clean architecture z Java 11

Architektura oprogramowania jest jednym z najważniejszych tematów dotyczących inżynierii oprogramowania w ostatnich latach. Robert C. Martin (alias Wujek Bob) w swojej książce  głęboko rozwinął wizję czystej architektury. Gorąco polecam ten tytuł. Jednak jeśli chodzi o implementację, sprawy się komplikują i pojawia się wiele pytań. Od czego mam zacząć? Jak zorganizować swój projekt? Jak zastosować zasady wujka Boba do technologii? Którą chcę stosować? Postaram się odpowiedzieć na te pytania z punktu widzenia programisty webowego Java, który chciałby użyć modułów Java 11 i Jigsaw z Java 9. To da Ci bardziej konkretną wizję czystej architektury o której mówi Wujek Bob.


Czysta architektura

Przed zanurzeniem się w implementacji, przyjrzyjmy się architekturze:

  • Encje: To są obiekty biznesowe Twojej aplikacji. Nie powinny na nie wpływać żadne zewnętrzne zmiany i powinien je tworzyć najbardziej stabilny kod w Twojej aplikacji. Mogą to być POJO, obiekty z metodami, a nawet struktury danych.
  • Przypadki użycia: Hermetyzują i implementują całą logikę biznesową.
  • Adaptery interfejsów: Konwersja i prezentacja danych dla warstwy przypadków użycia i encji.
  • Frameworki i sterowniki: Zawierają wszelkie frameworki lub narzędzia potrzebne do uruchomienia aplikacji.


Kluczowe koncepcje tutaj to:

  • Każda warstwa może odnosić się tylko do warstwy pod nią i nie wiedzieć nic o tym, co dzieje się powyżej.
  • Przypadki użycia i encje są sercem Twojej aplikacji i powinny mieć minimalny zestaw zależności od zewnętrznych bibliotek.


Przejdźmy do implementacji.


Konfiguracja projektu

Będziemy używać modułów Gradle multi-project i Java Jigsaw do egzekwowania zależności pomiędzy różnymi warstwami.

Aplikacja, którą zamierzamy zbudować, jest bardzo prosta, a architektura prawdopodobnie okaże się przesadna dla takiego projektu, ale jest to najlepszy sposób, aby zrozumieć, jak to wszystko działa.

Aplikacja będzie pozwalać na:

  • Utworzenie użytkownika.
  • Znajdowanie użytkownika.
  • Listowanie wszystkich użytkowników.
  • Logowanie użytkownika własnym hasłem.


W tym celu zaczniemy od warstw wewnętrznych (encje/przypadki użycia), następnie zajmiemy się warstwą adapterów interfejsów, a skończymy na warstwie zewnętrznej. Pokażemy również elastyczność architektury poprzez zmianę szczegółów implementacji i przełączanie się pomiędzy frameworkami.

Oto, jak wygląda projekt:

Przejdźmy do realizacji.


Warstwy wewnętrzne

Nasze encje i przypadki użycia są rozdzielone na dwa podprojekty, 'domenę' i 'przypadek użycia':

Domena i przypadek użycia

Te dwa podprojekty stanowią serce naszej aplikacji.

Architektura musi być bardzo wyraźna. Przyglądając się tylko przez moment powyższemu przykładowi, od razu wiemy, jakiego rodzaju operacje istnieją i gdzie je znaleźć. Gdyby zamiast tego stworzyć pojedynczą usługę UserService, trudno byłoby określić, jakie operacje istnieją w ramach tej usługi i trzeba byłoby zanurzyć się w implementacji, aby zrozumieć, co ta usługa robi. W naszej czystej architekturze musimy tylko szybko rzucić okiem na pakiet usecase, aby zrozumieć, jakie operacje są wspierane.

Pakiet entity zawiera wszystkie encje. W naszym przypadku będziemy mieli tylko jedną, User:

package com.slalom.example.domain.entity;

public class User {

  private String id;
	private String email;
	private String password;
	private String lastName;
	private String firstName;
        // Builder pattern & Getters
        // ...
}


Podmoduł usecase zawiera naszą logikę biznesową. Zaczniemy od prostego przypadku użycia, FindUser:

package com.slalom.example.usecase;

import com.slalom.example.domain.entity.User;
import com.slalom.example.domain.port.UserRepository;
import java.util.List;
import java.util.Optional;

public final class FindUser {

	private final UserRepository repository;

	// All args constructor

	public Optional<User> findById(final String id) {
		return repository.findById(id);
	}

	public List<User> findAllUsers() {
		return repository.findAllUsers();
	}
}


Mamy dwie operacje i  wymagają one pobrania użytkowników z repozytorium. Wygląda to dość standardowo w architekturze zorientowanej na usługi. UserRepository jest interfejsem, który NIE jest zaimplementowany w ramach naszego bieżącego podprojektu. Jest to detal w naszej architekturze, a szczegóły są realizowane w warstwach zewnętrznych. Jego implementacja zostanie zapewniona, gdy zostanie stworzona instancja przypadku użycia (np. poprzez Dependency Injection). To daje pewne korzyści:

  • Niezależnie od implementacji, logika biznesowa pozostaje taka sama.
  • Wszelkie zmiany w implementacji nie wpływają na logikę biznesową.
  • Bardzo łatwo jest całkowicie zmienić implementację, ponieważ nie ma ona wpływu na logikę biznesową.


Zauważ, że interfejs znany jest również jako port, ponieważ stanowi on pomost pomiędzy logiką biznesową a światem zewnętrznym.

Zbudujmy teraz pierwszą iterację naszego przypadku użycia CreateUser.

package com.slalom.example.usecase;

import com.slalom.example.domain.entity.User;
import com.slalom.example.domain.port.IdGenerator;
import com.slalom.example.domain.port.PasswordEncoder;
import com.slalom.example.domain.port.UserRepository;

public final class CreateUser {

	private final UserRepository repository;
	private final PasswordEncoder passwordEncoder;
	private final IdGenerator idGenerator;
  
	// All args constructor
  
	public User create(final User user) {
		var userToSave = User.builder()
			.id(idGenerator.generate())
			.email(user.getEmail())
			.password(passwordEncoder.encode(user.getEmail() + user.getPassword()))
			.lastName(user.getLastName())
			.firstName(user.getFirstName())
			.build();
		return repository.create(userToSave);
	}
}


W taki sam sposób jak w przypadku korzystania z FindUser, potrzebujemy repozytorium, sposobu generowania identyfikatora oraz sposobu kodowania hasła. Są to również szczegóły, a nie reguły biznesowe, które będą implementowane później, w warstwach zewnętrznych.

Chcemy również sprawdzić, czy podany użytkownik jest prawidłowy (zawiera prawidłowe dane) i czy już nie istnieje. Prowadzi to do ostatecznej iteracji przypadku użycia:

package com.slalom.example.domain.usecase;

import com.slalom.example.domain.entity.User;
import com.slalom.example.domain.exception.UserAlreadyExistsException;
import com.slalom.example.domain.port.IdGenerator;
import com.slalom.example.domain.port.PasswordEncoder;
import com.slalom.example.domain.port.UserRepository;
import com.slalom.example.domain.usecase.validator.UserValidator;

public final class CreateUser {

	private final UserRepository repository;
	private final PasswordEncoder passwordEncoder;
	private final IdGenerator idGenerator;
  
  	// All args constructor
    
	public User create(final User user) {
    		UserValidator.validateCreateUser(user);
		if (repository.findByEmail(user.getEmail()).isPresent()) {
			throw new UserAlreadyExistsException(user.getEmail());
		}
		var userToSave = User.builder()
			.id(idGenerator.generate())
			.email(user.getEmail())
			.password(passwordEncoder.encode(user.getEmail() + user.getPassword()))
			.lastName(user.getLastName())
			.firstName(user.getFirstName())
			.build();
		return repository.create(userToSave);
	}
}


Jeśli użytkownik nie jest poprawny lub już istnieje, rzucany jest niestandardowy wyjątek runtime. Te niestandardowe wyjątki powinny być obsługiwane przez inne warstwy.

Nasz ostatni przypadek użycia LoginUser jest dość prosty i jest dostępny na GitHub.

Wreszcie aby wymusić granice, oba podprojekty wykorzystują moduły Jigsaw. Moduły Jigsaw pozwalają nam eksponować na zewnątrz tylko to, co musimy, dzięki czemu nie ma wycieku szczegółów implementacji. Np. nie ma powodu, aby eksponować klasę UserValidator:

// Domain module-info
module slalom.example.domain {
	exports com.slalom.example.domain.entity;
	exports com.slalom.example.domain.port;
	exports com.slalom.example.domain.exception;
}

// Use case module-info
module slalom.example.usecase {
	exports com.slalom.example.usecase;
	requires slalom.example.domain;
	requires org.apache.commons.lang3;
}


Podsumowując rolę warstw wewnętrznych:

  • Warstwy wewnętrzne zawierają obiekty domenowe i reguły biznesowe. To powinna być najbardziej stabilna i przetestowana część aplikacji.
  • Jakakolwiek interakcja ze światem zewnętrznym (jak baza danych lub usługa zewnętrzna) nie jest realizowana w warstwach wewnętrznych. Wykorzystujemy porty (interfejsy) do ich reprezentowania.
  • Nie stosuje się żadnych frameworków, a zależności są minimalne.
  • Moduły Jigsaw pozwalają nam ukryć szczegóły implementacji.

 

Adaptery

Teraz, gdy mamy swoje encje i przypadki użycia, możemy implementować szczegóły. Aby zademonstrować, że architektura jest bardzo elastyczna, stworzymy kilka implementacji i wykorzystamy je w różnych kontekstach.

Adaptery

Zacznijmy od repozytorium.

Repozytorium

Implementacja UserRepository z prostą HashMap:

package com.slalom.example.db;

import com.slalom.example.domain.entity.User;
import com.slalom.example.domain.port.UserRepository;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public class InMemoryUserRepository implements UserRepository {
	
	private final Map<String, User> inMemoryDb = new HashMap<>();
	
  	@Override
	public User create(final User user) {
		inMemoryDb.put(user.getId(), user);
		return user;
	}
  
	@Override
	public Optional<User> findById(final String id) {
		return Optional.ofNullable(inMemoryDb.get(id));
	}
  
	@Override
	public Optional<User> findByEmail(final String email) {
		return inMemoryDb.values().stream()
			.filter(user -> user.getEmail().equals(email))
			.findAny();
	}
  
	@Override
	public List<User> findAllUsers() {
		return new ArrayList<>(inMemoryDb.values());
	}
}


Kolejne implementacje z Hazelcast można znaleźć na GitHub.


Inne adaptery

Inne adaptery stworzymy w ten sam sposób poprzez implementację zadeklarowanego w domenie interfejsu. Można je znaleźć na GitGub:


Złożenie wszystkiego do kupy

Teraz, gdy dysponujemy szczegółami implementacji, musimy złożyć je w całość. W tym celu należy utworzyć folder konfiguracyjny, zawierający konfigurację aplikacji oraz folder aplikacji zawierający kod do uruchomienia aplikacji.

Oto jedna z konfiguracji:

public class ManualConfig {

	private final UserRepository userRepository = new InMemoryUserRepository();
	private final IdGenerator idGenerator = new JugIdGenerator();
	private final PasswordEncoder passwordEncoder = new Sha256PasswordEncoder();

	public CreateUser createUser() {
		return new CreateUser(userRepository, passwordEncoder, idGenerator);
	}
	
  	public FindUser findUser() {
		return new FindUser(userRepository);
	}
	
	public LoginUser loginUser() {
		return new LoginUser(userRepository, passwordEncoder);
	}
}


Ta konfiguracja inicjuje przypadki użycia z odpowiednimi adapterami. Jeśli chcesz zmienić implementację, możesz łatwo przełączyć się z jednej implementacji adaptera na drugą, bez konieczności modyfikowania kodu przypadku użycia.

Poniżej znajduje się klasa, która uruchamia aplikację:

public class Main {
	public static void main(String[] args) {
		// Setup
		var config = new ManualConfig();
		var createUser = config.createUser();
		var findUser = config.findUser();
		var loginUser = config.loginUser();
		var user = User.builder()
			.email("john.doe@gmail.com")
			.password("mypassword")
			.lastName("doe")
			.firstName("john")
			.build();
                        
		// Create a user
		var actualCreateUser = createUser.create(user);
		System.out.println("User created with id " + actualCreateUser.getId());
                
		// Find a user by id
		var actualFindUser = findUser.findById(actualCreateUser.getId());
		System.out.println("Found user with id " + actualFindUser.get().getId());
                
		// List all users
		var users = findUser.findAllUsers();
		System.out.println("List all users: " + users);
                
		// Login
		loginUser.login("john.doe@gmail.com", "mypassword");
		System.out.println("Allowed to login with email 'john.doe@gmail.com' and password 'mypassword'");
	}
}


Frameworki webowe

Co zrobić, jeśli chcesz użyć frameworka webowego, takiego jak Spring Boot lub Vert.x? To całkiem proste - po prostu musimy:

  • Utworzyć nową konfigurację aplikacji internetowej.
  • Utworzyć nowy program uruchamiający aplikację.
  • Dodać kontrolery w folderze adaptera. Kontrolery będą odpowiedzialne za komunikację z warstwami wewnętrznymi.


Oto, jak wygląda kontroler Spring:

package com.slalom.example.spring.controller;

@RestController
public class UserController {
	private final CreateUser createUser;
	private final FindUser findUser;
	private final LoginUser loginUser;
  
	// All args constructor with @Autowired
  
	@RequestMapping(value = "/users", method = RequestMethod.POST)
	public UserWeb createUser(@RequestBody final UserWeb userWeb) {
		var user = userWeb.toUser();
		return UserWeb.toUserWeb(createUser.create(user));
	}
	
	@RequestMapping(value = "/login", method = RequestMethod.GET)
	public UserWeb login(@RequestParam("email") final String email, @RequestParam("password") final String password) {
		return UserWeb.toUserWeb(loginUser.login(email, password));
	}
	
 	@RequestMapping(value = "/users/{userId}", method = RequestMethod.GET)
	public UserWeb getUser(@PathVariable("userId") final String userId) {
		return UserWeb.toUserWeb(findUser.findById(userId).orElseThrow(() -> new RuntimeException("user not found")));
	}
	
 	@RequestMapping(value = "/users", method = RequestMethod.GET)
	public List<UserWeb> allUsers() {
		return findUser.findAllUsers()
			.stream()
			.map(UserWeb::toUserWeb)
			.collect(Collectors.toList());
	}
}


Pełny przykład tej aplikacji można znaleźć na GitHub używając zarówno Spring Boot jak i Vert.x.


Podsumowanie

W tym artykule próbowaliśmy pokazać, jak potężna jest czysta architektura wujka Boba. Mam nadzieję, że jest to dla Ciebie trochę bardziej zrozumiałe.

Zalety:

  • Moc: Twoja logika biznesowa jest chroniona i nic z zewnątrz nie może spowodować, że przestanie działać. Twój kod nie zależy od żadnego zewnętrznego frameworka "kontrolowanego" przez kogoś innego.
  • Elastyczność: Każdy adapter może zostać zastąpiony w dowolnym momencie przez dowolną inną implementację wybraną przez użytkownika. Przełączenie ze Spring boot na Vert.x lub Dropwizard może być wykonane bardzo szybko.
  • Odroczenie decyzji: Jakiej bazy danych potrzebuję? Jakich webowych frameworków potrzebuję? Możesz zbudować swoją logikę biznesową, nie znając tych szczegółów.
  • Łatwość utrzymania: Łatwo jest zidentyfikować, który komponent ulegnie awarii.
  • Szybsza implementacja: Ponieważ architektura rozdziela odpowiedzialności, możesz skoncentrować się na jednym zadaniu na raz i szybciej się rozwijać. Powinno to również zmniejszyć dług techniczny.
  • Testy: Testowanie jednostkowe jest łatwiejsze, ponieważ zależności są dobrze zdefiniowane, łatwo można mockować lub stubować.
  • Testy integracyjne: Możesz stworzyć specyficzną implementację dowolnej usługi zewnętrznej, do której chcesz uderzać podczas testów integracyjnych. Np. jeśli nie chcesz odpytywać DB hostowaną w chmurze, ponieważ płacisz za każde żądanie, wystarczy użyć adaptera do implementacji in-memory.


Wady:

  • Krzywa uczenia się: Na początku architektura może być przytłaczająca, szczególnie dla młodszych programistów.
  • Więcej klas, więcej pakietów, więcej podprojektów. O ile mi wiadomo, nic nie można z tym zrobić. Jako programista poliglota, zachęcam programistów Java do odkrywania innych języków, takich jak Kotlin. Może on w tym przypadku bardzo pomóc w zmniejszeniu ilości utworzonych plików.
  • Złożoność projektu jest większa.
  • W przypadku małych projektów może to być po prostu przekombinowane.


Projekt na GitHub dostarcza więcej szczegółów na temat tego, jak postępować z frameworkami webowymi. Zachęcam do sprawdzenia kodu i pobawienia się nim, jeśli jesteś zainteresowany.

Github:


Odniesienia:



Oryginał tekstu w języku angielskim przeczytasz tutaj.

Lubisz dzielić się wiedzą i chcesz zostać autorem?

Podziel się wiedzą z 130 tysiącami naszych czytelników

Dowiedz się więcej