Wstrzykiwanie zależności to najważniejsza funkcja Springa. To dzięki niemu nie musimy ręcznie tworzyć całego grafu obiektów naszej aplikacji. Wystarczy, że zarejestrujemy nasze beany w kontekście Springa a ten automatycznie zajmie się dostarczeniem ich w potrzebne miejsca. O ile technika ta jest bardzo przydatna i popularna, o tyle nie trudno ustrzec się od błędów. Zapraszam do wpisu, w którym zaprezentuję dobre i złe praktyki z nią związane.
1. Wstrzykiwanie przez pola, czy przez konstruktor? #
Jeśli chodzi o wstrzykiwanie zależności mamy dwa podejścia. Przez pola i przez konstruktor. Wstrzykiwanie przez pola wygląda w następujący sposób.
class EmailSender {
@Autowired
private EmailRepository repository;
@Autowired
private EmailService emailService;
// business methods
void sendEmails() {
// ...
}
}
W tym przypadku przy tworzeniu beana EmailSender
Spring najpierw tworzy go za pomocą domyślnego, pustego konstruktora - new EmailSender()
, a potem za pomocą refleksji ustawia wartości prywatnych pól - repository
i emailService
.
Czy możemy zrobić to lepiej? Zobaczmy, jak wygląda wstrzykiwanie przez konstruktor:
class EmailSender {
private final EmailRepository repository;
private final EmailService emailService;
@Autowired
EmailSender(EmailRepository repository, EmailService emailService) {
this.repository = repository;
this.emailService = emailService;
}
// business methods
void sendEmails() {
// ...
}
}
W tym momencie tworząc nową instację EmailSender-a
Spring od razu tworzy prawidłowy obiekt wołając konstruktor new EmailSender(repository, emailService)
.
Dodatkowo wstrzyknięte zależności oznaczone są słowem kluczowym final
, dzięki któremu po pierwsze mamy pewność, że nikt ich więcej nie zmieni, a po drugie upewniamy się, że EmailSender
zostanie udostępniony innym obiektom wtedy i tylko wtedy, gdy zostanie poprawnie stworzony do końca (klasa nie wycieknie częściowo zainicjalizowana).
W pierwszym przypadku przez pewien moment EmailSender
jest w niepoprawnym stanie i jego zależności są puste. W takiej sytuacji nietrudno o NullPointerException
i niespodziewane błędy.
Drugie podejście gwarantuje nam też sprawdzenie poprawności tworzenia EmailSendera
. Po pierwsze, nie mamy innej możliwości stworzenia go, niż przez konstruktor - który wymaga wszystkich argumentów, a po drugie przy zmianie listy tych zależności, od razu dostaniemy błąd kompilacji.
Dlatego zawsze warto deklarować zależności swoich klas przez konstruktor, a nie przez pola.
2. Wstrzykiwanie wszystkiego co się da #
Poznając Springa i jego możliwości wstrzykiwania zależności wiele osób może mieć tendencję do tworzenia wszystkich komponentów aplikacji za pomocą mechanizmu automatycznego wstrzykiwania. To błąd. Niekoniecznie trzeba wszystkie nasze klasy oddać w ręce frameworka.
W wielu przypadkach okazuje się, że dużo lepiej jest tworzyć obiekty ręcznie. Dzięki temu mamy większą kontrolę nad tym jak obiekty są tworzone i z jakimi parametrami zostały uruchomione. Dla Spring możemy zostawić stworzenie większych agregatów zawierających całe grafy obiektów, które wcześniej stworzyliśmy wołając samodzielnie konstruktory.
Jak to może wyglądać? Na przykład tak.
@Configuration
class EmailConfiguration {
@Bean
public EmailFacade emailFacade(EmailRepository repository) {
EmailService emailService = new MailgunEmailService(mailgun.apiKey(), mailgun.apiSecret());
EmailSender sender = new EmailSender(repository, emailService);
EmailTemplateEngine templateEngine = new ThymeleafEmailTemplateEngine(...);
return new EmailFacade(sender, templateEngine);
}
}
W tym momencie korzystamy ze wstrzyknięcia EmailRepository
przez Springa, a na zewnątrz do kontekstu frameworka zwracamy tylko EmailFacade
. Klasy EmailService
czy EmailSender
w ogóle nie trafiają do Springa i tym samym ograniczamy jego wpływ frameworka na architekturę naszej aplikacji.
3. Wstrzykiwanie konkretnych klas, czy interfejsów? #
Kolejna lekcja dotycząca wstrzykiwania zależności dotyczy odwiecznego problemu: konkretna implementacja vs interfejs. Wielu osobom może wydawać się, że jeśli mamy tylko jedną implementację danej odpowiedzialności biznesowej to nie ma sensu bawić się w interfejsy. To błąd.
Korzystanie z interfejsów pozwoli nam w bardzo wygodny sposób tworzyć obiekty w zależności od kontekstu. Jakiego kontekstu? Bardzo często potrzebujemy innych usług na różnych środowiskach: uruchamianie aplikacji na maszynie dewelopera, uruchamianie testów, uruchamianie na środowisku testowym, uruchamianie na produkcji.
Wyobraź sobie ponownie klasę EmailService
, która służy do wysyłki maili do klientów. Jeśli w tym celu korzystamy z zewnętrznej usługi (np. Mailgun
) moglibyśmy mieć tylko jedną konkretną implementacją, która wysyła maile. Ale o wiele lepiej jest wydzielić interfejs EmailService
i dostarczyć różne implementacje w zależności od kontekstu:
MailgunEmailService
- do faktycznej wysyłki za pomocą serwisu, np. na produkcji, czy środowisku testowym (sandbox, staging),ConsoleEmailService
- do uruchamiania na maszynie deweloperskiej, zamiast wysyłania maili wypisuje ich treść na konsolę,CaptchuringEmailService
- do testów, zamiast wysyłać maile zapisuje je w pamięci i pozwala na weryfikację założeń.
Jeśli zawsze tworząc nowe beany Springa, będziesz stosował się do tej zasady, długoterminowo zobaczysz jak łatwo testuje i modyfikuje się Twoją aplikację. Programowanie w Javie i Springu może być przyjemne nie tylko w pierwszych 3 miesiącach projektu, ale także po 2, 3 czy 5 latach ;)
4. Używanie wielu konstruktorów (najlepszy jest jeden i koniec kropka) #
Zdarza się, że definiując komponenty aplikacji potrzebujemy różnych zachowań. Wtedy - korzystając już z punktu 1 - tworzymy różne konstruktory naszej klasy.
class EmailSender {
private final EmailRepository repository;
private final EmailService emailService;
private EmailBlacklist blacklist;
EmailSender(EmailRepository repository, EmailService emailService) {
this(repository, emailService, null);
}
EmailSender(EmailRepository repository, EmailService emailService, EmailBlacklist blacklist) {
this.repository = repository;
this.emailService = emailService;
this.blacklist = blacklist;
}
}
To niestety kolejny błąd. Po pierwsze nie możemy skorzystać z automatycznego tworzenia obiektu przez Springa - ponieważ nie będzie wiedział, który konstruktor wybrać - a po drugie tworzymy zamieszanie dla programistów, który konstruktor jest właściwy.
Nigdy nie doprowadzaj do sytuacji, w której tworzenie Twojej klasy może prowadzić do dwóch różnych stanów. Powinieneś mieć zawsze jeden konstruktor, który stworzy poprawny obiekt. Jeśli potrzebujesz zmienić zachowanie danego komponentu w zależności od środowiska (np. EmailBlacklist
), to - stosując punkt 3 - dostarcz inną implementację. Ale nigdy nie pozwól stworzyć obiektu w niepoprawny sposób, pozwalając tym samym na tworzenie niepoprawnych (pustych) zależności.
Więcej konstruktorów możesz mieć tylko w momencie, gdy one w rezultacie też tworzą poprawny obiekt. Na przykład jak poniżej.
class EmailSender {
private final EmailRepository repository;
private final EmailService emailService;
private final EmailBlacklist blacklist;
EmailSender(EmailRepository repository, EmailService emailService) {
this(repository, emailService, EmailBlacklist.empty());
}
EmailSender(EmailRepository repository, EmailService emailService, EmailBlacklist blacklist) {
this.repository = repository;
this.emailService = emailService;
this.blacklist = blacklist;
}
}
Zauważ, ze teraz pole EmailBlacklist otrzymało modyfikator final
, a EmailSender nie będzie posiadał pól, które ustawione są na null-e
.
5. Stosowanie Profili #
Ostatnim elementem, który warto zastosować przy definicji swoich komponentów w Springu jest wykorzystanie profili. Dzięki temu, łatwo będziesz mógł wybrać konkretną implementację danej klasy w zależności od kontekstu uruchomienia aplikacji. Na przykład.
@Configuration
class EmailConfiguration {
@Bean
@Profile({"production", "staging", "sandbox"})
public EmailService mailgunEmailService() {
return new MailgunEmailService(mailgun.apiKey(), mailgun.apiSecret());
}
@Bean
@Profile("dev")
public EmailService consoleEmailService() {
return new ConsoleEmailService();
}
@Bean
@Profile("test")
public EmailService testEmailService() {
return new CaptchuringEmailService();
}
}
Teraz podając odpowiednie wartości startowae, np. --spring.profiles.active=dev
, możesz w łatwy sposób wybrać, która klasa zostanie stworzona przez Springa. W tym przypadku będzie to consoleEmailService
.
Podsumowanie #
To wszystko na dzisiaj. Teraz powinieneś wiedzieć już jakie są najlepsze praktyki dotyczące wstrzykiwania zależności w Springu. Jeśli chcesz wiedzieć więcej, to przygotowałem dokument, w którym prezentuję 10 Najlepszych Sztuczek Senior Deweloperów w Springu, w którym znajdziesz więcej takich porad.
Dziękuję Ci za Twój czas i jeśli mogę Ci jakoś pomóc, daj znać w komentarzu poniżej ;)