В этой заметке хочу поделится наблюдениями о некоторых антипаттернах, встречающихся в коде приложений, работающих на Спринге. Все из них так или иначе всплывали в живом коде: либо я натыкался на них в уже существующих классах, либо вылавливал во время вычитки творчества коллег.
Надеюсь, вам будет интересно, а если после прочтения вы осознаете свои "грехи" и встанете на путь исправления, то я буду доволен вдвойне. Также призываю вас поделится своими собственными примерами в комментария, наиболее любопытные и необычные добавим в запись.
Autowired
Великий и ужасный @Autowired
— это целая эпоха в Спринге. Без него до сих пор не обойтись при написании тестов, но в основном коде он (ПМСМ) явно лишний. В нескольких моих последних проектах его не было вовсе. Долгое время мы писали вот так:
@Component
public class MyService {
@Autowired
private ServiceDependency;
@Autowired
private AnotherServiceDependency;
//...
}
Причины, по которым внедрение зависимостей через поля и сеттеры подвергаются критике, уже подробно описывались, в частности здесь. Альтернатива — внедрение через конструктор. По приведённой ссылке, описан такой пример:
private DependencyA dependencyA;
private DependencyB dependencyB;
private DependencyC dependencyC;
@Autowired
public DI(DependencyA dependencyA, DependencyB dependencyB, DependencyC dependencyC) {
this.dependencyA = dependencyA;
this.dependencyB = dependencyB;
this.dependencyC = dependencyC;
}
Он выглядит более-менее прилично, но представьте, что зависимостей у нас штук 10 (да-да, я знаю, что в этом случае их нужно группировать по отдельным классам, но сейчас речь не об этом). Картинка получается уже не столь привлекательной:
private DependencyA dependencyA;
private DependencyB dependencyB;
private DependencyC dependencyC;
private DependencyD dependencyD;
private DependencyE dependencyE;
private DependencyF dependencyF;
private DependencyG dependencyG;
private DependencyH dependencyH;
private DependencyI dependencyI;
private DependencyJ dependencyJ;
@Autowired
public DI(/* ... */) {
this.dependencyA = dependencyA;
this.dependencyB = dependencyB;
this.dependencyC = dependencyC;
this.dependencyD = dependencyD;
this.dependencyE = dependencyE;
this.dependencyF = dependencyF;
this.dependencyG = dependencyG;
this.dependencyH = dependencyH;
this.dependencyI = dependencyI;
this.dependencyJ = dependencyJ;
}
Код прямо скажем, выглядит монструозненько.
И вот тут многие забывают, что и здесь скрипач @Autowired
не нужен! Если у класса только один конструктор, то Спринг (>= 4) поймёт, что зависимости нужно внедрять через этот конструктор. А значит мы можем и его выбросить, заменив его на ломбоковский @AllArgsContructor
. Или ещё лучше — на @RequiredArgsContructor
, не забыв при этом объявить все нужные поля final
и получив на сдачу безопасную инициализацию объекта в многопоточной среде (при условии, что все зависимости также безопасно инициализируются):
@RequiredArgsConstructor
public class DI {
private final DependencyA dependencyA;
private final DependencyB dependencyB;
private final DependencyC dependencyC;
private final DependencyD dependencyD;
private final DependencyE dependencyE;
private final DependencyF dependencyF;
private final DependencyG dependencyG;
private final DependencyH dependencyH;
private final DependencyI dependencyI;
private final DependencyJ dependencyJ;
}
Статические методы в утилитных классах и enum-функции
В кровавом Э часто стоит задача преобразования объектов-носителей данных из одного слоя приложения, в подобные объекты другого слоя. Для этого до сих пор (напоминаю, на дворе 2019 год) используются утилитные классы со статическими методами вроде такого:
@UtilityClass
public class ЧтоТоТамUtils {
public static UserDto entityToDto(UserEntity user) {
//...
}
}
Более продвинутые пользователи, читавшие умные книги, знают про волшебные свойства перечислений:
enum ЧтоТоТамFunction implements Function<UserEntity, UserDto> {
INST;
@Override
public UserDto apply(UserEntity user) {
//...
}
}
Правда, и в этом случае обращение всё равно происходит к объекту-одиночке, а не к компоненту, управляемому Спрингом.
Ещё более продвинутые парни (и девушки) знают про MapStruct, позволяющий описать всё в виде одного-единственного интерфейса:
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CriminalRecommendationMapper {
UserDto map(UserEntity user);
}
Вот теперь мы получает спринговый компонент. Вроде победа. Но дьявол в мелочах, и бывает, что победа становится перемогой. Во-первых, именования полей должны быть одинаковы (иначе начинается геморрой), что не всегда удобно, во-вторых, при наличии каких-либо сложных преобразований полей обрабатываемых объектов возникают дополнительные сложности. Ну и сам mapstruct нужно добавлять в зависимости.
И мало кто вспоминает дедовский, но тем не менее простой и рабочий способ получить управляемый спрингом преобразователь:
import org.springframework.core.convert.converter.Converter;
@Component
public class UserEntityToDto implements Converter<UserEntity, UserDto> {
@Override
public UserDto convert(UserEntity user) {
//...
}
}
Преимущество здесь в том, что в другом классе мне достаточно написать
@Component
@RequiredArgsConstructor
public class DI {
private final Converter<UserEntity, UserDto> userEnityToDto;
}
и Спринг самостоятельно всё разрулит!
Ненужный Qualifier
Случай из жизни: приложение работает с двумя БД. Соответственно, есть два источника данных (java.sql.DataSource
), два менеджера транзакций, две группы репозиториев и т.д. Всё это удобно описать в двух отдельных настройках. Вот это для Постгре:
@Configuration
public class PsqlDatasourceConfig {
@Bean
@Primary
@ConfigurationProperties(prefix = "spring.datasource.psql")
public DataSource psqlDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public SpringLiquibase primaryLiquibase(
ProfileChecker checker,
@Qualifier("psqlDataSource") DataSource dataSource
) {
boolean isTest = checker.isTest();
SpringLiquibase liquibase = new SpringLiquibase();
liquibase.setDataSource(dataSource);
liquibase.setChangeLog("classpath:liquibase/schema-postgre.xml");
liquibase.setShouldRun(isTest);
return liquibase;
}
}
А это для ДБ2:
@Configuration
public class Db2DatasourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.db2")
public DataSource db2DataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public SpringLiquibase liquibase(
ProfileChecker checker,
@Qualifier("db2DataSource") DataSource dataSource
) {
boolean isTest = checker.isTest();
SpringLiquibase liquibase = new SpringLiquibase();
liquibase.setDataSource(dataSource);
liquibase.setChangeLog("classpath:liquibase/schema-db2.xml");
liquibase.setShouldRun(isTest);
return liquibase;
}
}
Поскольку у меня есть две базы, то для тестов я хочу накатить на них два отдельных DDL/DML-я. Поскольку обе конфигурации загружаются одновременно при поднятии приложения, то если я уберу @Qualifier
, то Спринг лишится целеуказания и в лучшем случае свалится с ошибкой. Получается, что @Qualifier
-ы громоздки и подвержены очепяткам, а без них не работает. Чтобы выйти из тупика нужно сообразить, что зависимость можно получать не только в виде аргумента, но и в виде возвращаемого значения, переписав код вот так:
@Configuration
public class PsqlDatasourceConfig {
@Bean
@Primary
@ConfigurationProperties(prefix = "spring.datasource.psql")
public DataSource psqlDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public SpringLiquibase primaryLiquibase(ProfileChecker checker) {
boolean isTest = checker.isTest();
SpringLiquibase liquibase = new SpringLiquibase();
liquibase.setDataSource(psqlDataSource()); // <-----
liquibase.setChangeLog("classpath:liquibase/schema-postgre.xml");
liquibase.setShouldRun(isTest);
return liquibase;
}
}
//...
@Configuration
public class Db2DatasourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.db2")
public DataSource db2DataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public SpringLiquibase liquibase(ProfileChecker checker) {
boolean isTest = checker.isTest();
SpringLiquibase liquibase = new SpringLiquibase();
liquibase.setDataSource(db2DataSource()); // <-----
liquibase.setChangeLog("classpath:liquibase/schema-db2.xml");
liquibase.setShouldRun(isTest);
return liquibase;
}
}
javax.inject.Provider
Как получить бин с областью действия prototype? Часто мне попадалось вот это
@Component
@Scope(SCOPE_PROTOTYPE)
@RequiredArgsConstructor
public class ProjectBuilder {
private final ProjectFileConverter converter;
private final ProjectRepository projectRepository;
//...
}
@Component
@RequiredArgsConstructor
public class PrototypeUtilizer {
private final Provider<ProjectBuilder> projectBuilderProvider;
void method() {
ProjectBuilder freshBuilder = projectBuilderProvider.get();
}
}
Казалось бы, всё хорошо, код работает. Однако, в этой бочке мёда есть ложка дёгтя. Нам нужно тащить за собой ещё одну зависимость javax.inject:javax.inject:1
, которая была добавлена в Мавен Централ ровно 10 лет назад и с тех пор ни разу не обновлялась.
А ведь Спринг давным давно умеет делать то же самое без сторонних зависимостей! Достаточно заменить javax.inject.Provider::get
на org.springframework.beans.factory.ObjectFactory::getObject
и всё работает точно так же.
@Component
@RequiredArgsConstructor
public class PrototypeUtilizer {
private final ObjectFactory<ProjectBuilder> projectBuilderFactory;
void method() {
ProjectBuilder freshBuilder = projectBuilderFactory.getObject();
}
}
Теперь мы можем с чистой совестью выпилить javax.inject
из списка зависимостей.
Использование строк вместо классов в настройках
Часто встречающийся пример подключения Spring Data -репозиториев к проекту:
@Configuration
@EnableJpaRepositories("com.smth.repository")
public class JpaConfig {
//...
}
Здесь мы явно прописываем пакет, который будет просматриваться Спрингом. Если мы допустим очепятку в именовании, то приложение упадёт при запуске. Хочется выявлять подобные глупые ошибки на ранних этапах, в пределе — прямо во время правки кода. Фреймворк идёт нам навстречу, поэтому код выше можно переписать:
@Configuration
@EnableJpaRepositories(basePackageClasses = AuditRepository.class)
public class JpaConfig {
//...
}
Здесь AuditRepository
— один из репозиториев пакета, который мы будем просматривать. Т. к. мы указали класс, то нам потребуется подключить этот класс к нашей конфигурации, и теперь опечатки будут выявляться прямо в редакторе или на худой конец при сборке проекта.
Этот подход можно применить во многих похожих случаях, например:
@ComponentScan(basePackages = "com.smth")
превращается в
import com.smth.Smth;
@ComponentScan(basePackageClasses = Smth.class)
Если нам нужно добавить некоторый класс в словарь вида Map<String, Object>
, то это можно сделать вот так:
void config(LocalContainerEntityManagerFactoryBean bean) {
String property = "hibernate.session_factory.session_scoped_interceptor";
bean.getJpaPropertyMap().put(property, "com.smth.interceptor.AuditInterceptor");
}
но лучше использовать явный тип:
import com.smth.interceptor.AuditInterceptor;
void config(LocalContainerEntityManagerFactoryBean bean) {
String property = "hibernate.session_factory.session_scoped_interceptor";
bean.getJpaPropertyMap().put(property, AuditInterceptor.class);
}
А когда есть нечто вроде
LocalContainerEntityManagerFactoryBean bean = builder
.dataSource(dataSource)
.packages(
//... пакеты с сущностями хибернейта
)
.persistenceUnit("psql")
.build();
то стоит обратить внимание, что метод packages()
перегружен и использовать классы:
Не кладите все бины в один пакет
Думаю, во многих проектах на Спринге/Спринг Буте вы видели подобную структуру:
root-package
|
\ repository/
entity/
service/
Application.java
Здесь Application.java
— это корневой класс приложения:
@SpringBootApplication
@EnableJpaRepositories(basePackageClasses = SomeRepository.class)
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Это классический код микросервиса: составляющие разложены по папочкам согласно назначению, в корне лежит класс с настройками. Пока проект небольшой, то всё хорошо. По мере разрастания проекта возникают жирненькие пакеты с десятками репозиториев/сервисов. И если проект так и останется монолитом, то б-г с ними. А вот если встанет задача разделить раскабаневшее приложение на части, тут-то и начинаются вопросы. Один раз испытав эту боль я решил применить другой подход, а именно группировать классы по их домену. В итоге получается что-то вроде
root-package/
|
\ user/
|
\ repository/
domain/
service/
controller/
UserConfig.java
billing/
|
\ repository/
domain/
service/
BillingConfig.java
//...
Application.java
Здесь пакет user
включает в себя подпакеты с классами, ответственными за логику пользователей:
user/
|
\ repository/
UserRepository.java
domain/
UserEntity.java
service/
UserService.java
controller/
UserController.java
UserConfig.java
Теперь в UserConfig
можно описать все настройки, связанные с данным функционалом:
@Configuration
@ComponentScan(basePackageClasses = UserServiceImpl.class)
@EnableJpaRepositories(basePackageClasses = UserRepository.class)
class UserConfig {
}
Преимущество такого подхода в том, что модули можно будет при необходимости легче выделять в отдельные сервисы/приложения. Также это полезно в том случае, если вы собираетесь модуляризировать ваш проект, добавив module-info.java
, скрыв от внешнего мира служебные классы.
На этом всё, я надеюсь, моё творчество оказалось для вас полезным. Описывайте в комментариях ваши антипаттерны, будем разбираться вместе :)