Code Conventions
Code Style
За основу разметки в коде берем Google Java Code Style.
Описание Code Style кладем в папку .idea/codeStyle/Project.xml и
там же рядом создаем файл codeStyleConfig.xml, чтобы Code
Style использовался из проекта:
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true"/>
</state>
</component>
Папку .idea убираем из .gitignore добавляем в индекс. В папке .idea есть свой
.gitignore, где прописано, что user-specific файлы не попадали в git.
Если папка .idea/ в .gitignore, то нужно .idea/codeStyles/* и .idea/.gitignore добавить
вручную:
$ git add -f .idea/.gitignore
$ git add -f .idea/codeStyles/codeStyleConfig.xml .idea/codeStyles/Project.xml
Соглашение по коду
Общие требования
- Если Intellij Idea подсвечивает код, то его нужно исправить.
- Переопределенные методы всегда помечаем аннотацией
@Override. - Можно использовать
varдля описания локальных переменных, если тип переменной очевиден из правого выражения.public static void main(String[] args) { var users = List.of("Alex", "Andrew", "Max", "Anton", "Gleb"); for (var user: users) { System.out.println(user); } } - Если
varне используется, то используемdiamond operator <>private Map<String, Pair<Integer, String>> ratingByUsers = new HashMap<>(); - Для создания список используем
List.of(), для создания mapMap.of(). При этом создаются readonly коллекции.private List<String> users = List.of("Alex", "Andrew", "Max", "Anton", "Gleb"); private Map<String, Integer> ratingByUsers = Map.of("Alex", 1, "Max", 2, "Andrew", 3); - Не используем checked-исключения, только наследников от
RuntimeException. - Крайне нежелательно перехватывать обобщенные исключения
Throwable. - Никогда нельзя перехватывать исключения типа
Error. - Использовать пробелы вместо табуляции.
- Не использовать в заголовке класса
@author(отключить в Intellij Idea). Они указывают кто создал файл, но после этого много народу могут менять этот файл. Для понимания, кто автор изменений в файле, использоватьgit annotate. - Метод не должен занимать больше 3 экранов. Если метод больше, то следует разносить его на более мелкие методы.
- Все magic numbers, constant strings выносим в именованные константы. Константа
описывается
(public|private) static final. - Не использовать XML Dom, XPath для навигации по объектам.
- Нельзя использовать хранимые процедуры.
- Используем YAML для всех конфигураций, т.к. на больших файлах это нагляднее.
- Стараемся использовать
@ConfigurationProperties, т.к. это позволяет проверять заполненность данных на этапе запуска приложения. - Строить бизнес-логику на exception можно, например, если в процессе выполнения были получены какие-то данные, работа с которыми невозможна.
- Если метод должен выполнить проверку, то лучше, чтобы он возвращал
true/false, а вышестоящий код мог сам решить что ему делать с этим.void unzip(@NotNull String str) { if (!isAcceptableForUnzip(str)) { throw new RuntimeException("String '" + str + "' not acceptable for unzip (size not multiple by 4)"); } } boolean isAcceptableForUnzip(@Nonnull String str) { return str.length() % 4 == 0; } - Если есть параметризованные сложные
SQL, то для их построения использоватьCriteria API, не клеить их руками через конкатенацию. - Контролеры максимально простые, вся бизнес логика внутри сервисов. Объекты
HttpServletRequest,HttpServletResponse,SecurityContextHolderи т.п. могут использоваться только в слоеweb, а в сервисы пробрасываются уже результат, который мы из них получили. В противном случае мы мешаем логику представления с бизнес-логикой. - Если проверка валидации требует доступ к базе данных, то ее нужно переносить в слой сервисов, иначе реализовывать через аннотации JSR303 или кодом в контроллере.
- Аннотацию
@SneakyThrowsможно использовать в тестах в любом месте. - В коде можно использовать аннотацию
@SneakyThrowsтолько для приватных методов.@SneakyThrowsнельзя использовать в методах, которые оборачиваются другими аннотациями Spring (например,@Transactional), потому что она маскирует checked exception, делая возможным не описывать его в сигнатуре методаthrows. Spring создает Dynamic Proxy ожидая корректную сигнатуру метода. Объяснение
Если требуется использовать@SnekyThrows, то выносим код в private метод:@SneakyThrows private void deleteFile(@NotNull String path) { Files.deleteIfExists(path); } - Не использовать
printStackTrace(), т.к. он не попадает в лог (ELK).logger.error("", e);
Общая структура проекта
В корне проекта лежит главный файл@SpringBootApplication. Пакеты разбиваются по слоям:
web–@Controllers,@Converter,@RestControllerAdviceи т.п.services– бизнес логика и сервисы.entities– доменные сущности, помеченные аннотацией@Entity.repositories–JpaRepositoryи DAO классы (c@PersistenceContext).config–@Configurationклассы, каждая конфигурация по слоям выносится в отдельный класс. Т.е. bean и настройки, относящиеся к web, должно находится в WebConfiguration, но не обязательно только в нем одном. Можно разносить на более специфичные конфигурации. Главное не мешать конфигурации воедино, например конфигурация@EnableJpaAuditingне должна лежать в той же самой конфигурации, что и созданиеWebClient.models– классы моделей.mappings– mapstruct mappers, так же здесь могут находиться утильные сервисы для маппинга.exceptions– исключения.utils– разные утильные классы. Обычно это статические helper или@Componentутилитарного характера.
Если в сервисе выделяется больше одной доменной области, например, User и Wallet, то все
классы (web, mappings,models, entities, repositories, services), связанные с ними,
должны находится в отдельном пакете user и wallet соответственно.
Для того чтобы сервис (@Service) был изолированный, он должен взаимодействовать только с DAO и
репозиториями из своего домена. Т.е. если нам в WalletService нужно получить пользователя, то мы
должны использоватьUserService, а не работать напрямую с UserRepository. Иначе нарушается
Single Responsibility принцип и сильно усложняются unit тесты.
Если есть какие-то общие классы, сервисы, то они выносятся в пакет common.
Правила описания моделей
@Entityне должен использоваться напрямую в контроллере.- Для разных контроллеров описываем свои модели и разделяем их для запросов и ответов.
- В моделях, которые используются непосредственно в контроллерах, можно использовать суффиксы
RequestиResponse. - Модели (не
@Entity), являющиеся проекциями доменных сущностей, именуются как сущности, но с суффиксомRequest,Response,Data,Info,Item. СуффиксDTOне используем. - Фильтрацию отображаемых контроллером полей модели можно провести и с использованием
JsonView, тогда не нужно будет создавать синтетические модели с их mappings. - В именовании моделей можно использовать суффикс
List, только если модель является наследником от коллекции. Но лучше использовать просто множественное число в именовании. - В модели допускается описывать несколько конструкторов, но для корректной сериализации обычно должен быть дефолтный (пустой) конструктор.
- В описании моделей всегда используем
Boolean, т.к. если поле описано какbooleanи в запросе приходитnull, мы получаемNullPointerExceptionс очень странным stacktrace. getterиsetterне должны содержать никакой логики (в том числе ленивая инициализация), т.к. модель является лишь представлением данных при передаче.- В моделях допускается inline инициализация (или в конструкторе) сложных структур данных
private List<String> list = new ArrayList<>(). - В моделях можно использовать
@Accessors(chain = true)для сборки через chaining. - В моделях можно использовать аннотацию
@Dataдля созданияequals,hashCode,toString. Если в эти методы требуется внести изменения (например убрать поле изtoString), то переопределяем их руками. Использование аннотаций@EqualsAndHashCode.Exclude,@ToString.Excludeухудшает читабельность. - Если объект имеет уникальное поле (например
uid), тоequalsиhashCodeнужно делать от него. Не нужно делатьequalsиhashCodeот@Id(если это поле генерируется Hibernate на этапе сохранения). - Если
equals,hashCode,toStringреализуется для@Entity, то в них не должно быть связных сущностей (даже если ониfetch = EAGER).
Комментарии к коду
- Комментарии пишутся с большой буквы.
- Javadoc комментарии на каждый метод не нужны, они не несут полезной нагрузки и в случае изменения поведения метода, можно забыть их исправить, что приведет негативному эффекту: описание метода не соответствует реализации.
- Очевидные комментарии по коду тоже не нужны, в них нет никакого смысла:
public void cancelActiveProcesses(UUID groupUid) { // Ищем process по processesUid final List<Process> processes = processRepository.findByUid(groupUid); // Цикл по списку processes for (var p : processes) { if (p.status == ACTIVE) {} } } - Название метода должно отражать что делает этот метод, при этом название должно оставаться
лаконичным:
createProcess,updateProcessStatus,generateExcelи т.п. Если в названии нельзя однозначно описать, что происходит в методе, то скорее всего нарушается принцип Single Responsibility. Не нужно пытаться запихнуть в название всю информацию о происходящих в нем действиях.@NotNull @Override User createUser(@NotNull CreateUserRequest request) {} @Override void lockTransaction(@NotNull Transaction transaction) {} - Если требуется закомментировать код, то нужно описать
TODOс описанием что делает этот код и четкими критериями когда этот он потребуется. Желательно прикрепить задачу. - Если в коде есть закомментированный код, которому больше 3 недель и к нему не описан
TODO, то этот код можно удалять. - Комментарии обязательно оставлять к сложным кускам кода и неочевидным решениям, например:
@EnableWebMvc @Configuration public class WebConfigurationLocal implements WebMvcConfigurer { @Autowired private ObjectMapper objectMapper; @Override public void configureMessageConverters(List<HttpMessageConverter<?>> converters) { // Задаем message converters для того, чтобы в MappingJackson2HttpMessageConverter использовался // objectMapper, который настраивается Spring application.yaml // StringHttpMessageConverter и ByteArrayHttpMessageConverter используются чтобы корректно отображался // OpenAPI endpoint, без них SwaggerWelcomeWebMvc :: openapiJson возвращает объект, обернутый в строку converters.add(new StringHttpMessageConverter()); converters.add(new MappingJackson2HttpMessageConverter(objectMapper)); converters.add(new ByteArrayHttpMessageConverter()); } }
Если бы здесь не было этого комментария, причины появления этого кода быстро забылись бы и появилось
ощущение, что мы без всякой нужны переопределяем существующее поведение. Удаление этого метода
привело бы к тому, что настройка jackson сериализатора в Spring Boot
spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS: false (и другие) перестала бы работать и
даты стали возвращаться в виде timestamp.
Количество параметров в методе
Если в методе больше 5 параметров, то их нужно оборачивать в объект. Большое количество параметров может свидетельствовать о том, что:
- метод нарушает правило Single Responsibility – выполняет разные задачи.
- в случае методов в 1-2 строки и 5+ параметрами: метод слишком декомпозирован и его вызов лучше заменить inline-кодом.
Использование интерфейсов
Интерфейс – это контракт класса. Он должен содержать только методы, которые описывают его зону ответственности (Responsibility). Вынесение приватных методов на уровень интерфейса нарушает парадигму Инкапсуляции.
Интерфейс не должен содержать методы (default методы), крайне нежелательно описывать в нем
константы.
Для всех классов под управлением Spring, которые помечены аннотацией @Serviceи @Repository (т.е.
слой бизнес логики и DAO через @PersistenceContext), использование интерфейсов обязательно.
Интерфейсы не нужно описывать для утильных классов, контроллеров (@Controller), репозиториев
(наследниковCrudRepository) и т.п.
Если требуется описать какую-то общую логику, то для этого используется абстрактный класс.
Порядок описания методов
Порядок видимости:
public;protected;- package private;
private.
Порядок описания методов:
- constants;
- static initializers;
- final fields;
- fields;
- initializers;
- constructors;
- static methods;
- methods;
- inner class.
Конструкторы объявляются от большего количества параметров (более общего), к меньшему.
public class GreetingPrinterImpl
extends GreetingPrinter {
public static final String HELLO = "Hello";
public static final String WORLD = "world";
private static final String GREETING;
static {
GREETING = HELLO + ", " + WORLD;
}
private final String name;
private final Integer order;
private Printer<String> printer;
private CounterHolder counter;
{
counter = new CounterHolder();
printer = (str, locale) -> {
System.out.println(str);
return str;
};
}
public MyClassImpl(String name) {
this(name, 1);
}
public MyClassImpl(String name, Integer order) {
this.name = name;
this.order = order;
}
@Override
public void sayHello() {
print(GREETING + " from " + name + " with order " + order);
}
protected void updatePrinter(Printer<String> printer) {
this.printer = printer;
}
private void print(String greeting) {
printer.print(greeting, Locale.getDefault());
counter.increment();
}
static class CounterHolder {
private int counter = 0;
void increment() {
counter++;
}
}
}
Использование Optional
java.util.Optional можно использовать как возвращаемое значение, это полезно когда требуется
сделать chaining.
@Override
@Transactional(readOnly = true)
public Product findByUidOrDefault(@NotNull UUID uid) {
return dictionaryClient.findByUid(uid)
.map(ProductMapper::toModel)
.orElseGet(() -> new Product("N/A"));
}
Но для описания параметров метода его использовать не стоит, лучше в явном виде описать, что метод
может принимать @Nullable.
Использование префиксов в именах методов
- Если метод имеет префикс
findилиget, то он возвращает значение:find– метод возвращаетOptional, кроме случаев когда возвращаетсяPageable,List,Mapи т.п. (они должны возвращать пустую коллекцию). Метод всегда помечается@NotNull. Если значение не найдено, то возвращаетсяOptional.empty().get– если значение не найдено, то выбрасывается исключение.
- Аналогичное поведение в методах
getByIdвJpaRepositoryиfindByIdвCrudRepository. - Если метод имеет префикс
build– то обычно это некоторый утильный метод для сборки объекта из других объектов. - Методы, имеющие префикс
get,find,buildникогда не могут вносить изменения в базу данных. - Если это методы на уровне сервисов, то они должны иметь аннотацию
@Transactional(readOnly = true)в случае, если они выполняют обращение к базе данных.
Такое именование метода запрещено (!!!):
@Override
@Transactional
public MyClass findByUid(@NotNull UUID uid) {
return myClassRepository.findByUid(uid)
.orElseGet(() -> {
var myClass = new MyClass("Hello, world", 1);
return myClassRepository.save(myClass);
});
}
Корректное название, для описанного выше метода findOrCreateByUid.
Если метод имеет префикс create, update и т.п., значит он выполняет модификацию данных.
Использование @Nullable, @NotNull, @Contract
В сервисных методах описываем параметры и возвращаемые значения с помощью аннотаций @NotNull и
@Nullable (из пакета org.jetbrains:annotations) – они используются Intellij Idea в статическом
анализе кода (они имеют Retention Policy = CLASS и недоступны в runtime, в отличие от lombok
@NotNull, они не выбрасывают NullPointerException).
Для более сложного инструктирования статического анализатора можно использовать
аннотацию @Contract.
Constructor Injection
- Если класс под управлением Spring, то используем constructor injection. Если в классе только один
конструктор (отличный от конструктора по-умолчанию), то его не надо помечать
@Autowired, Spring поймет что используется constructor injection и будет пытаться подставить нужные beans. - Можно использовать аннотацию
@RequiredArgsConstructorи необходимые поля описывать какprivate final. @RequiredArgsConstructorне умеет работать с аннотациями на поля, т.е. если у нас в классе есть поля с@Value,@Qualifierи т.п., то либо описываем их как обычные поля вне конструктора, либо создаем конструктор руками и там описываем вне нужные аннотации.@AllArgConstructorиспользовать не рекомендуется по описанным выше причинам.
@Service
@AllArgsConstructor
public class MyEntityServiceImpl
implements MyEntityService {
private final MyEntityRepository myEntityRepository;
private final MyEntityMapper myEntityMapper;
private final WebClient webClient;
@Value("${request.timeout}")
private Duration timeout;
}
Использование constructor injection хороший вариант, потому что при написании unit тестов сразу понятно какие параметры нужно передавать в метод. Так же, если мы видим, что для создания класса требуется передать большое количество параметров в конструктор, то возможно класс занимается не только своей задачей и нарушает Single Responsibility.
Правила именования пакетов
В именовании пакетов стараемся использовать одно слово, если это невозможно, то используем lowerCamelCase или разделяем точками. snake_case недопустим.
package ru.romanow.authorization; // OK
package ru.romanow.empty.authorization; // OK
package ru.romanow.emptyauthorization; // OK
package ru.romanow.empty_authorization; // BAD
Правила именования классов, интерфейсов
Именование классов и интерфейсов должно быть в UpperCamelCase.
Интерфейс может называться словосочетанием (например, UserService, DataPreparationService) или
наречием (Closeable, Iterable). Именование наречия означает, что интерфейс маркировочный, т.е.
класс является чем-то (например Cloneable – имеет методы для клонирования, Iterable – можно
использовать в циклеfor).
В именовании классов (и интерфейсов, если применимо) можно указывать суффикс:
Service– для@Service;@Service public class UserServiceImpl extends UserService {}Controller– для@RestController,@Controller;@RestController public class UserController {}ControllerилиAdvice– для@RestControllerAdvice,@ControllerAdvice;@RestControllerAdvice public class ExceptionController {}Repository- для наследников отCrudRepository,JpaRepositoryи т.д.;public class UserRepository extends JpaRepository<User, Long> {}Dao– для классов доступа к данным, работающим с SQL илиEntityManager, а так же помеченных ( но не обязательно)@Repository.@Repository public class UserDaoImpl extends UserDao { @PersistenceContext private final EntityManager entityManager; }- Исключение: если класс является расширением
JpaRepository(Custom Implementations for Spring Data Repositories), то остается суффиксRepository. - Аннотация
@Componentявляется базовой и выносить ее в название класса не нужно. - Если интерфейс называется словосочетанием, то, если реализация единственная, добавляем суффикс
Impl. Если есть несколько наследников, то каждый наследник называется в соответствии с решаемой задачей:UserService->UserServiceImpl;DataPreparationService->XmlDataPreparationService,CsvDataPreparationService.
- Маркировочные интерфейсы в именовании классов не участвуют.
Критерии использования inner классов
Inner классы используем только в случае, если описываемый объект является составной частью общего. Например:
- составной ключ в сущности
@EmbeddedId; - частью конфигурации, например profile-specific настройки или описание контекста в тестах;
- внутренним объектом в сервисе, используемым внутри сервиса для передачи большого количества параметров в методы.
Если модели являются проекцией сущностей в базе данных, то они не могут быть вложенными.
Именование веток
Все commits должны ссылаться на задачу в Jira. Можно настроить Tasks & Context (Tools ->
Tasks & Context-> Configure Servers -> Jira) и управлять задачами через Intellij Idea. Так же
там создаются
Changelist с именем задачи и при commit автоматически подставляется это описание.
Ветки называть в формате Gitflow: (feature | bugfix)/<task-number>. Опционально, пояснение о
решаемой задаче.
Если в рамках ветки решается несколько связанных задач, то можно называть ветку по имени главной задачи (User Story, Technical Task).
Unit тесты
Обязательно пишем тесты на сервисы (бизнес логика). Эти тесты стараемся делать простыми без
использования @SpringBootTest и его производных @DataJpaTest, @WebMvcTest.
Если требуется использование @SpringBootTest, то в самом тесте в inner классе описываем контекст,
а в @SpringBootTest в classes указываем ссылку на этот контекст.
- Тесты не должны быть зависимыми друг от друга или содержать глобальное состояние.
- Тесты должны тестировать только один класс, остальные зависимости заменять
mock. Писать сквозные тесты плохо, т.к. это усложняет их поддержку.
В тестах используем Mockito для создания заглушек и Assertj для проверки результата.
@ExtendWith(MockitoExtension.class)
class PersonServiceTest {
private static final int DEPARTMENT_ID = 200;
private static final int PERSON_ID = 100;
private PersonDao person;
private PersonService personService;
@BeforeEach
void init() {
personDao = Mockito.mock(PersonDao.class);
personService = new PersonServiceImpl(personDao);
}
@Test
void when_findById_then_success() {
// Given
final Person person = buildPerson(PERSON_ID);
final Department department = person.getDepartment();
when(personDao.findById(PERSON_ID)).thenReturn(person);
// When
final PersonFullResponse personResponse = personService.getById(PERSON_ID);
// Then
assertThat(personResponse.getId()).isEqualTo(PERSON_ID);
assertThat(personResponse.getFullName()).isEqualTo(
person.getLastName() + " " + person.getFirstName() + " " + person.getMiddleName());
assertThat(personResponse.getAge()).isEqualTo(person.getAge());
final DepartmentShortResponse departmentResponse = personResponse.getDepartment();
assertThat(departmentResponse.getId()).isEqualTo(department.getId());
assertThat(departmentResponse.getName()).isEqualTo(department.getName());
}
}
Если метод не возвращает значения, то можно проверять его работоспособность по косвенным признакам,
например через verify.
При написании unit тестов разделяем тест на три части:
given– подготовка данных, созданиеmock,spyи задание желаемого поведения для них;when– выполнение тестируемой операции;then– проверка результата.
Тесты именуем в соответствии с конвенцией: when_<method>_then_<expected-result>, например:
@ExtendWith(MockitoExtension.class)
class ProductScenarioServiceTest {
@Test
void when_findProductScenarioByUid_then_success() {
}
@Test
void when_findProductScenarioByUid_then_notFoundException() {
}
}
Чтобы подробнее описать что делает этот тест, можно использовать @DisplayName.
Логирование
Сообщения в логи пишем на английском языке.
Из сообщения логов должно быть понятно, с какой сущностью выполнялась операция или какая возникла
ошибка. Если речь о сущностях, то выводить ее тип и uid, для классов моделей - toString() или
более короткую информацию.
@Slf4j
class Main {
public static void main(String[] args) {
log.debug("Create new Process {}", process);
log.info("Check process status uid '{}' and startDate '{}'", processUid, startDate);
}
}
Для добавления параметров в логи используем {}, конкатенацию использовать не нужно.
Если при записи в лог объекты подготавливаются, например List<Process> -> List<UUID>,
то эту запись заворачиваем в условие проверки включения уровня логгирования.
@Slf4j
class Main {
public static void main(String[] args) {
if (log.isDebugEnabled()) {
log.debug("Remove temporary data for processUids: [{}]",
processes.stream().map(c -> c.getUid()).toList());
}
}
}
По ходу выполнения каждой бизнес операции пишем логи:
debug– информация позволяющая разбираться в ошибках. В проблемных местах подробное описание, что вызывается и с какими параметрами. Логирование целого запроса только на этом уровне. Если требуется вывести полную информацию об объекте, то переопределить для негоtoString()или реализовать метод, который будет возвращать нужную информацию об объекте (например, если в объекте существуют поля, которые мы не можем светить в логах).info– информация о ходе выполнения бизнес-процесса, перехода со стадии на стадию.warn– ошибки или exception, которые не влияют фатально на ход выполнения (например,NumberFormatExceptionпри разбореString, если поле может бытьnull).error– exception, при возникновении которых, невозможно продолжать выполнение операций.
Для логгирования запросов / ответов используем logbook.
Использование профилей
Разделение на профили нужно для прозрачности настройки приложения в разных средах. Разбиение по профилям выполняется только на уровне профиля приложения, используются стандартные механизмы Spring.
Для каждого контура (dev, stage, prod) описывается свой собственный профиль. Credential к
базе данных, сертификаты к Kafka и остальные важные настройки будут задаваться через переменные
среды, которые будут браться из Secret и ConfigMap.
В OpenAPI поддерживать консистентность по ошибкам в @ApiResponse
Используем OpenAPI версии 3, реализация springdoc.
При добавлении новых ошибок при обработке вызова, они должны быть отображены в @ApiResponse. Это
нужно, чтобы декларация метода в OpenAPI была консистентна с кодом, т.е., чтобы при вызове метода не
было не описанных кодов ответов (кроме 500).
Использование WebClient
Используем RestClient или WebClient, причем создаем через WebClient.Builder.
Работу с WebClient убираем в отдельны сервис, который инкапсулирует в себе всю логику сборки
запроса, вызова и обработки результатов запроса. Сервис имеет суффикс Client.
Использование готовых библиотек, а не разработка своих решений
Для решения типовых задач ищем и используем готовые библиотеки с высоким рейтингом. Например, для
проверки наличия
непустых символов в строке можно написать свой метод hasChars:
public boolean hasChars(@Nullable String str) {
return str != null && str.chars().anyMatch(c -> c != 0);
}
Но на этот метод нужно написать тесты и проверить, что реализация оптимальна. Но лучше использовать
готовый метод org.springframework.util.StringUtils.hasText(@Nullable String str). Т.к. библиотеку
Spring поддерживает community, реализация таких методов будет не хуже, чем самописная.
Аналогичная ситуация с самописными реализациями:
- транслитерация rus -> latin: Iuliia;
- сборка SQL для записи скрипта в файл: SqlBuilder.
Использование валидации методов JSR303
Если требуется проверить аргументы метода в контроллере, то на контроллер ставится аннотация
@Validated и на аргументы ставятся необходимые аннотации @NotEmpty, @Positive, @Pattern и
т.д. Аннотации @NotNull в аргументах бессмысленны, т.к. @RequestParam, @QueryParam имеет
параметр required = true по-умолчанию, т.е. при вызове метода эти аргументы уже точно не null.
Если аргумент должен иметь определенный тип (например Double, UUID и т.д.), то нужно сразу
ставить целевой этот тип данных, а не принимать все в String, а потом паттернами проверять
соответствие типу. Т.к. наше API для внутреннего использования, мы можем сразу указывать необходимый
тип данных, и в случае несоответствия аргумента типу просто возвращать 500 ошибку.
@Validated
@RestController
@RequestMapping("/api/v1/process")
public class ProcessController {
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public List<ProcessResponse> search(
@RequestParam @NotEmpty String name,
@RequestParam @Future UUID data
) {
}
}
Для валидации полей в объекте, в контроллере валидируемый объект помечается аннотацией @Valid, а в
самом объекте аннотации ставятся над полями.
@RestController
@RequestMapping("/api/v1/process")
public class ProcessController {
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEnity<Void> create(@Valid @RequestBody ProcessCreateRequest request) {
}
@Data
@Accessors(chain = true)
public static class ProcessCreateRequest {
@NotEmpty
private String name;
private String description;
@NotNull
@Size(min = 0, max = 100)
private Integer percentage;
}
}
Переводы сообщений валидации
Аннотации валидации JSR-303 имеют параметр message, который ссылается на
ValidationMessages.properties внутри модуля org.hibernate.validator:hibernate-validator.
Для описания своих сообщений валидации, создаем файл
resources/ValidationMessages_<locale>.properties и в нем описываем коды и переводы по аналогии с
messages.properties.
При использовании аннотаций указываем параметр @NotEmpty(message = "{field.not.empty}") со ссылкой
на коды. Важно заметить, что код должен быть указан в фигурных скобках, без этого message будет
воспринят как готовый перевод. В сообщениях валидации можно использовать аргументы из аннотации
валидации.
# ValidationMessages_en.properties
field.not.empty=Field must be not empty
field.fixed.size=Field must be between interval {min} to {max}
@Data
@Accessors(chain = true)
public class ObjectCreateRequest {
@NotEmpty(message = "{field.not.empty}")
private String name;
@NotNull
@Size(min = 0, max = 100, message = "{field.fixed.size}")
private Integer percentage;
}
Обработка exceptions
Если нужно сделать переводы сообщений, то можно воспользоваться стандартным механизмом Spring i10n.
Создается файл resources/messages_<locale>.properties, где locale – сокращенное (ru, en) или
полное имя (ru_RU, en_US) локали.
# messages_ru.properties
product.code.not.found=Код продукта не найден
# messages.properties
product.code.not.found=Product code not found
Создаем сервис MessageHelper, если Locale не передается при вызове метода, то
используется LocaleContextHolder.getLocale(), который берет Locale из параметров запроса.
@Component
public class MessageHelper {
private static MessageSource messageSource;
public MessageHelper(MessageSource messageSource) {
MessageHelper.messageSource = messageSource;
}
@NotNull
public static String tr(@NotNull String code) {
return tr(code, null, LocaleContextHolder.getLocale());
}
@NotNull
public static String tr(@NotNull String code, @NotNull Object[] args) {
return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
}
}
Работа с JPA и транзакциями
Подробнее jpa-example.
Использование @Transacational
Если в рамках запроса выполняется модификация нескольких таблиц, то без использования общей транзакции в случае ошибки откат изменений не будет выполнен или будет выполнен частично, что приведет к неконсистентности данных.
Транзакции нужно использовать на уровне service, т.к. именно там находится бизнес-логика приложения и именно этот слой ответственен за корректность (консистентность) работы с данными.
Если в рамках бизнес операции используются только запросы на чтение, то нужно в транзакции указать
@Transactional(readOnly = true).
Аннотацию @Transactional нужно указывать в реализации и лучше аннотировать ей каждый метод, где
это нужно. Помечать@Transactional декларацию методов в интерфейсе не стоит, т.к. это выдает детали
внутренней реализации и, если в реализации этой аннотации не будет, то по факту транзакция
создастся (т.к. Spring увидит @Transactional в интерфейсе), но по коду это будет неочевидно.
При использовании JPA репозиториев транзакция не создается из воздуха, методы интерфейса
делегируются классу SimpleJpaRepository, который помечен аннотацией
@Transactional(readOnly = true).
Выключение параметра spring.jpa.open-in-view=false
Если включен параметр spring.jpa.open-in-view, то класс OpenEntityManagerInViewInterceptor в
методе preHandle открывает EntityManager для текущего запроса, т.е. Spring создает обрамляющую
транзакцию на весь запрос.
Это очень плохой подход, т.к. он может привести к утечке соединений и падению приложения. Из-за
вопросов совместимости, в Spring Boot 3.x этот параметр остался включен. Настройка
spring.jpa.open-in-view всегда должна быть выключена в явном виде, это значит, что в коде нужно
использовать ручное управление транзакциями.
Разбиение по доменным сущностям
Делать подпакеты можно, например request / response в models, но если требуются подпакеты в
service, обычно это значит что требуется разбиение на более мелкие доменные области.
Доменная область обычно 1 к 1 связана с бизнес-процессом, т.е. у вас в одном пакете есть контроллеры (и сервисы), которые выполняют разную функциональность из разных Use Case (работа с пользователем (создание, блокировка) и работа с кошельком (создание, пополнение, закрытие)), то это обычно различные доменные области.
Если у вас есть необходимость в sql / jpa запросе использовать join на таблицы из разных доменных областей, то лучше это делать в java коде, потому что в случае возможного распила сервиса на части, части этого запроса могут относиться к разным доменным областям, а значит join придется распиливать.
К одной доменной сущности могут относиться объекты, которые будут невалидны без основной сущности.
Например, User ->Address, адрес будет невалиден без привязки к пользователю, но Address ->
Country,Address -> City уже не будут в одном домене, т.к. Country и City могут
потребоваться в других процессах.
Если в JpaRepository метод короткий, то его можно использовать без @Query
Если условие поиска содержит одно или два поля, или одно поле и условие сортировки, то такой запрос
можно описать словами без @Query.
public interface UserRepository
extends JpaRepository<User, Long> {
Optional<User> findByUid(UUID uid);
List<User> findByStatusOrderByCreatedDate(String status);
List<User> findByFirstNameAndLastName(String firstName, String lastName);
@Query("select u from User u where u.status = :status and u.firstName = :firstName order by u.createdDate asc")
List<User> findByStatusAndName(
@Param("status") String status,
@Param("firstName") String firstName
);
}
Запросы, содержащие больше одного условия в блоке where или дополнительные условия order by,
group by, having, то для улучшения читабельности, запрос нужно записывать в несколько строк,
каждый блок на новой строке.
Если требуется делать запросы, где условия поиска зависят от входных параметров, то использовать
либо Example<T>, либо репозиторий наследовать от JpaSpecificationExecutor<T> и работать с
Criteria API в findAll(Specification<T> spec).
Описание @Entity
- При описании
@Entityданных всегда описываем@Tableс именем таблицы в базе данных. - Все описания колонок в
@Entityдолжны максимально соответствовать колонкам в базе данных. При описании колонки в аннотации@Columnвсегда указыватьnameи дополнительные аргументы, напримерnullable,length(дляVARCHAR),scaleиpresicion(дляNUMBER). Если для колонки задаетсяDEFAULT, то он описывается вcolumnDefinition = "BOOLEAN NOT NULL DEFAULT false". - Если объект имеет уникальное поле (например
uid), тоequalsиhashCodeнужно делать от него. Не нужно делатьequalsиhashCodeот@Id(если это поле генерируется Hibernate на этапе сохранения). - Если
equals,hashCode,toStringреализуется для@Entity, то в них не должно быть связных сущностей (даже если ониfetch = EAGER). - В
@Entityможно использовать аннотации@Getter,@Setter,@Accessors(chain = true). - Если
@Entityссылается на другую сущность, то в@JoinColumnвсегда описываем имя колонки и имя constraint.@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "process_id", foreignKey = @ForeignKey(name = "fk_process_version_process_id")) private Process process;
Правила работы с @OneToMany, @ManyToOne, @ManyToMany
- По-умолчанию для
@OneToMany,@ManyToOne,@ManyToManyиспользуем тип загрузкиFetchType.LAZY. ОставлятьFetchType.EAGERв@ManyToOneможно только можно в случае, если основная сущность всегда нужна в связке с дочерней. - Для связи
@OneToManyLAZYпо дефолту, его прописывать не нужно. - Для связи
@OneToOneреализоватьLAZYбез костылей нельзя. Но обычно использование@OneToOneозначает, что дочерняя сущность является частью общей сущности, что не противоречит описанному выше правилу. - Изменение типа связи с
LAZYнаEAGERкрайне не рекомендуется, это может очень негативно сказаться на производительности, т.к. при поднятии одной сущности, будут подниматься еще N дополнительных сущностей. - При этом, если связь помечена
LAZY, а обращение к ней выполняется вне транзакции, то будет выброшенLazyInitializationException. Для предотвращения такой ситуации нужно явно использовать транзакции и (или) использоватьjoin fetchи@EntityGraphв случае, когда эти данные нужны в получаемом результате. - Аннотация
@JoinColumnможет быть по обе стороны связи (@OneToMany,@ManyToOne), она определяет главную сущность в отношении.
JPA Auditing
Если требуется использовать поля: дата создания, дата последней модификации, имя пользователя, который создал или изменил запись, то нужно использовать стандартные средства JPA Auditing с помощью аннотаций:
@CreatedDate,@LastModifiedDate@CreatedBy,@LastModifiedBy
Для их использования нужно создать DateTimeProvider и в самом @Entity
прописать использование @EntityListeners(AuditingEntityListener.class)
Форматирование SQL
- Зарезервированные слова SQL пишутся с большой буквы.
- Зарезервированные слова в HQL пишутся с маленькой буквы.
- Имена таблиц и полей в таблице пишутся в snake_case.
- При создании таблицы ограничения
CHECK,CONSTRAINTпо возможности описываются вместе с полем, к которому относятся. - Все названия специальных объектов должны иметь имена:
<prefix>_<table-name>_<field-name>.
CREATE SEQUENCE seq_process_order AS BIGINT START WITH 1 INCREMENT BY 10;
CREATE TABLE process
(
id BIGSERIAL PRIMARY KEY,
uid UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description VARCHAR(255),
"order" INT NOT NULL DEFAULT NEXTVAL('seq_process_priority'),
priority INT NOT NULL CHECK (priority >= 0 AND priority < 10)
);
CREATE INDEX idx_process_name ON process (name);
CREATE UNIQUE INDEX ui_process_uid ON process (uid);
CREATE TABLE process_status_history
(
id BIGSERIAL PRIMARY KEY,
status VARCHAR(80),
process_id INT
CONSTRAINT fk_process_status_history_process_id
REFERENCES process (id) ON DELETE CASCADE
);
CREATE INDEX idx_process_status_history_process_id ON process_status_history (process_id);
Префиксы объектов:
index–idx_;unique index–ux_;sequence–seq_;foreign key–fk_.
Для всех foreign key создаем индексы.
В реляционных базах данных Primary Key и Foreign Key используются для контроля целостности данных. Это позволяет блокировать удаление строки, если на нее ссылаются другие записи.
Менять дефолтное поведение ON UPDATE / ON DELETE на CASCADE нужно делать осмысленно, т.к.
можно неявно грохнуть половину базы данных.
Для Primary Key
индекс создается по-умолчанию. Для
Foreign Key обязательно создавать индекс, иначе JOIN между таблицами будет выполняться как
Sequence Scan.
Использование UUID для взаимодействия между сервисами
Если требуется создать связь сущностей между сервисами, то дополнительно к private key создается
поле uid UUID NOT NULL, которое является внешним ключом между базами данных.
Т.е. если требуется вывести какую-то информацию о сущности на UI или сделать ссылку на нее из
другого сервиса, то в сущности заводится поле uid, и все взаимодействие идет через него. Внутренние
primary key используются только для ссылок внутри одной базы данных.
Т.к. uid должен быть уникальным ключом, на него создается unique index:
CREATE TABLE process
(
id BIGSERIAL PRIMARY KEY,
uid UUID NOT NULL
);
CREATE UNIQUE INDEX ux_process_uid ON process (uid);
Миграции в liquibase
Для миграций используем
liquibase.
Именование файлов миграции YYYYMMDD_HHMI_<task-number>.xml: 20211231_2359_JIRA-1000.xml
Имена шагов миграций внутри liquibase состоит из <NN>-<описание-изменений>.
Для увеличения надежности используем:
preCondition– проверка того, что скрипт нужно выполнять. Это очень полезный инструмент, т.к. он предотвращает повторное создание уже существующих объектов.rollback– скрипт отката, нужен по двум причинам:- если изменения были применены по-ошибке или содержат ошибку;
- при работе тестировщиков на тестовом контуре при взятии в работу задачи с другим набором миграций.
Комментарии к простым миграциям не нужны. Если скрипт сложный или объемный (более 1 экрана), то комментарий обязателен.
Используем описание миграций в xml, если выполняется изменение DDL. Если требуется модификация данных, то используем SQL.
<changeSet id="01-update-currency-code-for-rubles" author="<автор>">
<preConditions onFail="MARK_RUN" expectedResult="1">
SELECT 1 FROM currency WHERE id = 'RUB' and name='Российский рубль'
</preConditions>
<comment>Проставляем правильное имя для RUB</comment>
<sql splitStatements="false">
UPDATE currency SET name='Российский рубль' WHERE id = 'RUB'
</sql>
<rollback>
<sql>
UPDATE currency SET name='Деревянный' WHERE id = 'RUB'
</sql>
</rollback>
</changeSet>
Использование MapStruct
Если в проекте требуется делать преобразования entity -> response model, request model ->
entity, то нужно использовать
MapStruct.
Задача мапперов просто переложить готовые данные из одного объекта в другой. Мапперы следует делать
быть максимально простыми, в идеальном случае они должны заниматься только перекладыванием плоских
полей (String, Integer, BigDecimal) из объекта в объект.
Если объект является составным, то для каждой доменной сущности должен быть написан свой mapper. Структрурная зависимость мапперов должна повторять структуру доменной области для простоты понимания, поддержки и соблюдения Single Responsibility.
Для сложных составных объектов нужно разделять операции создания и редактирования.
- Для связи мапперов использовать
uses:@Mapper(uses = { AddressMapper.class }. - Если ваше приложение написано на Spring Boot, то мапперы тоже должны быть под управлением
Spring:
@Mapper(componentModel = "spring"). - Для создания мапперов использовать
@Mapper(componentModel = "spring", injectionStrategy = InjectionStrategy.CONSTRUCTOR), что позволит создавать маппер в тестах без создания контекста Spring. - Нужно избегать внедрения других сервисов в мапперы, это упростит поддержку. Лучше сделать что маппер сможет, а остальное уже доставить в сервисе вызова и туда внедрить зависимости.
- Не рекомендуется использовать
@BeforeMapping, так как входная (source) сущность может находиться вPersistence Contextи её изменения в рамках метода@BeforeMappingмогут быть неявно сохранены. - Не использовать
expression="java()"для методов с бизнес-логикой – это очень сложно тестировать. - Полезно использовать
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR), а все неиспользуемые поля в mapping явно указывать через@Mapper(ignore = true, target = "...""). Это позволит не пропустить поля в итоговом объекте.