Чтобы соответствовать принципу подстановки Барбары Лисков (SOLID) с точки зрения заменяемости класса-родителя классом-наследником, нужны следующие проверки аргументов метода и возвращаемых значений:
Если возвращаемый тип метода предка является
Nonnull
, то переопределенный метод наследника тоже должен бытьNonnull
. Остальное допустимо.Если аргумент метода предка является
Nullable
, то переопределенный метод наследника тоже должен иметьNullable
аннотацию. Остальное допустимо.
Но это не все проверки, которые выполнит за вас Idea после соответствующей настройки. Полный перечень проверок приведен ниже в таблице. Из "коробки" Idea выполняет только две проверки (не те, что выше). Если вы пишете null free код, то статья для вас окажется все равно полезной по причине: 1) наличия стороннего кода; 2) легаси кода; и 3) по причине, что null
может быть использован в критических участках кода.
Зависимости
Для обеспечения таких проверок каждый метод и аргумент метода должны быть обозначены аннотациями @Nullable
и @Nonnull
. Чтобы не утонуть в этих аннотациях можно прийти к соглашению, что аннотацию @Nonnull
не нужно указывать, т.е. что она неявная.
Чтобы научить Idea определять отсутствие аннотации как @Nonnull
, нужно выполнить некоторые манипуляции с кодом. Рассматривалось три подхода, которые умеет обрабатывать Idea. Вариант с аннотацией org.eclipse.jdt.annotation.NonNullByDefault
не рассматриваю.
Подход на основе JSR-305. Требуется создать мета-аннотацию, которая настраивается на классы одного пакета. Действие аннотации не распространяется на классы подпакетов. Не поддерживаемая сообществом технология.
Подход на основе Checker Framework. Мета-аннотация не требуется. Действие применяется на пакеты класса и на все классы подпакетов.Поддерживается Lombok.
Подход с использованием JSR-305
Для реализации подхода, добавляется зависимость
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>3.0.2</version>
<scope>provided</scope>
</dependency>
Scope зависимости можно указать "provided", возможно и вам в Runtime эти аннотации не нужны, JVM никак не импортит классы аннотаций при загрузке классов, если только аннотация не используется в Runtime через вызов Class.getAnnotations()
и обработку класса аннотации. Подход с provided использует и Spring Framework, убедиться в этом можно, если открыть класс org.springframework.lang.NonNull
(если не подключена транзитивная зависимость, то import javax.annotation.Nonnull
будет подсвечен красным, но это не будет мешать работе приложения). Размер jar библиотеки ~20 кБ.
Далее создается класс аннотации @NonnullByDefault
import любимая.реализация.Nonnull
/**
* This annotation can be applied to a package, class or method to indicate that the class fields,
* method return types and parameters in that element are not null by default unless there is:
* The method overrides a method in a superclass (in which
* case the annotation of the corresponding parameter in the superclass applies) there is a
* default parameter annotation applied to a more tightly nested element.
*
* @see <a href="https://youtrack.jetbrains.com/issue/IDEA-125281">Impl</a>
*/
@Documented
@Nonnull
@TypeQualifierDefault({
ElementType.ANNOTATION_TYPE,
ElementType.CONSTRUCTOR,
ElementType.FIELD,
ElementType.LOCAL_VARIABLE,
ElementType.METHOD,
ElementType.PACKAGE,
ElementType.PARAMETER,
ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface NonnullByDefault {
}
Реализацию аннотации Nonnull
можно использовать любую. Рекомендуется либо org.springframework.lang.NonNull
(если проект на Spring), либо javax.annotation.Nonnull
, чтобы не повышать зацепление кода.
Далее в каждом пакете, в классах которого требуется анализ NPE (скорее всего это все пакеты проекта), создается файл package-info.java
со следующим содержанием
@NonnullByDefault
package ru.my.package;
Idea будет отображать сообщения вида: "Method annotated with @Nullable must not override @NonnullByDefault method"
Подход с использованием Checker Framework
Добавляется зависимость (размер jar библиотеки ~200 кБ)
<dependency>
<groupId>org.checkerframework</groupId>
<artifactId>checker-qual</artifactId>
<version>3.25.0</version>
<scope>provided</scope>
</dependency>
Далее в корневом пакете проекта создается файл package-info.java
со следующим содержанием
@DefaultQualifier(Nonnull.class)
package ru.my.package;
import любимая.реализация.Nonnull
Из минусов библиотеки - это, что в сообщение об ошибке идет ссылка не на NonNull
, а DefaultQualifier
: "Method annotated with @Nullable must not override @DefaultQualifier method"
Реализацию Nonnull
можно использовать любую. Также рекомендуется либо org.springframework.lang.NonNull
, либо org.checkerframework.checker.nullness.qual.NonNull
.
Настройка Idea
Считаем, что одним из двух предыдущих способов настроена неявная аннотация @Nonnull
. Можно быть спокойным насчет размера class-файлов, размер не увеличивается, аннотаций @Nonnull
в class-файле не будет).
Проверки в Idea настраиваются в меню Editor → Inspections → Java → Probable bugs , в группах параметров @NotNull/@Nullable problems
, Return of 'null'
и Constant conditions & exceptions
. Сheckbox-ы расписывать не буду, удобнее настройки вычитывать из файла, а файл сохранить в CVS для всех участников команды. Для этого в директории .idea/inspectionProfiles
нужно создать два файла:
Inspections.xml
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Inspections" />
<inspection_tool class="ConstantConditions" enabled="true" level="WARNING" enabled_by_default="true">
<option name="SUGGEST_NULLABLE_ANNOTATIONS" value="true" />
<option name="DONT_REPORT_TRUE_ASSERT_STATEMENTS" value="false" />
</inspection_tool>
<inspection_tool class="NullableProblems" enabled="true" level="WARNING" enabled_by_default="true">
<option name="REPORT_NULLABLE_METHOD_OVERRIDES_NOTNULL" value="true" />
<option name="REPORT_NOT_ANNOTATED_METHOD_OVERRIDES_NOTNULL" value="false" />
<option name="REPORT_NOTNULL_PARAMETER_OVERRIDES_NULLABLE" value="true" />
<option name="REPORT_NOT_ANNOTATED_PARAMETER_OVERRIDES_NOTNULL" value="true" />
<option name="REPORT_NOT_ANNOTATED_GETTER" value="true" />
<option name="REPORT_NOT_ANNOTATED_SETTER_PARAMETER" value="true" />
<option name="REPORT_ANNOTATION_NOT_PROPAGATED_TO_OVERRIDERS" value="false" />
<option name="REPORT_NULLS_PASSED_TO_NON_ANNOTATED_METHOD" value="true" />
</inspection_tool>
<inspection_tool class="ReturnNull" enabled="true" level="WARNING" enabled_by_default="true">
<option name="m_reportObjectMethods" value="true" />
<option name="m_reportArrayMethods" value="true" />
<option name="m_reportCollectionMethods" value="true" />
<option name="m_ignorePrivateMethods" value="false" />
</inspection_tool>
<inspection_tool class="VariableTypeCanBeExplicit" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
</profile>
</component>
profiles_settings.xml
<component name="InspectionProjectProfileManager">
<settings>
<option name="PROJECT_PROFILE" value="Inspections" />
<version value="1.0" />
</settings>
</component>
Если работаете с GIT, то не забудьте добавить файлы в .gitignore
.idea
!.idea/inspectionProfiles
Далее нужно в разделе Constant conditions & exceptions
настроить аннотации для автодополнений, кликнув по кнопке Configure Annotations...
Настройка сохраняется в .idea/misc.xml
. Если файл не добавить в CVS, каждый в группе должен ее настроить так, как настроили остальные участники, чтобы аннотации проставлялись одинаково всеми.
Результат
В таблице указаны проверки (там, где далее упоминается Nonnull
, по соглашению считать, что аннотация на элементе должна отсутствовать; теоретически Nonnull
можно и указывать, на проверки это не повлияет).
Проверка |
По умолчанию |
После конфигурации |
|
1 |
Если возвращаемый тип метода предка является |
❌ |
✅
|
2 |
Если аргумент метода предка является |
❌ |
✅ |
3 |
Проверяется, что аннотации на setter и getter методах соответствуют аннотациям полей класса |
✅ |
✅ |
4 |
Проверяется передача |
❌ |
✅ |
5 |
Проверяется наличие аннотации |
❌ |
✅ |
6 |
Проверяется наличие аннотации |
❌ |
✅ |
7 |
Проверяется, что |
❌ |
✅ |
8 |
Проверяется наличие аннотации |
❌ |
✅ |
9 |
Проверяется отсутствие аннотации |
❌ |
✅ |
10 |
Проверяется возможность получения NPE при работе с объектом, например при вызове метода на объекте, который может принимать значение |
✅ |
✅ |
Так выглядит инспекция в Idea без настройки.
Так будет выглядеть инспекция после настройки.
Код для воспроизведения
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.checkerframework.framework.qual.DefaultQualifier;
@DefaultQualifier(NonNull.class)
@SuppressWarnings({"unused", "FieldMayBeFinal", "ResultOfMethodCallIgnored", "FieldCanBeLocal"})
class Sub extends Base {
private Integer nonNull = 1;
@Nullable private Integer nullable;
@Nullable
@Override
Integer nonNull(Integer i) { return null; } // 1, 2
Integer getNullable() { return nullable; } // 3, 8
void setNullable(Integer nullable) { this.nullable = nullable; }
@Nullable
Integer getNonNull() { return nonNull; }
void setNonNull(@Nullable Integer nonNull) { this.nonNull = nonNull; } // 7
void test() { nonNullArg(1); nonNullArg(null); } // 4
void nonNullArg(Integer i) {} // 5 (works for Checker Framework only; JSR 305 requires @NonNull on arg explicitly)
void test2() { Integer i = null; } // 6
// (configured by "Constraint conditions & exceptions" -> "Report nullable method always return non-null value")
@Nullable // 9? (no warn, idea bug)
Object nonNullResult(Integer i) { return new Object(); }
void testNpe(@Nullable Integer i) { i.longValue(); } // 10
void noTestNpe(Integer i) { i.longValue(); } // this is nonNull by default
}
@DefaultQualifier(NonNull.class)
class Base {
Integer nonNull(@Nullable Integer i) { return 1; }
}
Запуск инспекций из maven
SpotBugs (JSR-305) plugin
Позволяет обнаружить кейсы 4, 7, 10 в схеме с аннотациями JSR-305, в схеме с аннотациями Checker Framework обнаруживает только 10 вариант NPE (есть ряд открытых запросов на SpotBugs). Настраивается maven следующим образом
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.7.2.1</version>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
<phase>compile</phase>
</execution>
</executions>
</plugin>
Checker Framework plugin
Позволяет обнаружить все проверки, кроме 5-ой, которая покрывается 4-ой проверкой. Для Java 11+ настраивается следующим образом
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.10.1</version>
<configuration>
<fork>true</fork> <!-- Must fork or else JVM arguments are ignored. -->
<showDeprecation>true</showDeprecation>
<showWarnings>true</showWarnings>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.checkerframework</groupId>
<artifactId>checker</artifactId>
<version>${checkerframework.version}</version>
</path>
</annotationProcessorPaths>
<annotationProcessors>
<annotationProcessor>
lombok.launch.AnnotationProcessorHider$AnnotationProcessor
</annotationProcessor>
<annotationProcessor>
org.checkerframework.checker.nullness.NullnessChecker
</annotationProcessor>
</annotationProcessors>
<compilerArgs combine.children="append">
<!--arg>-Awarns</arg--> <!-- CI: падать при наличии проблем -->
<arg>-Astubs=jdk.astub</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
</compilerArgs>
</configuration>
</plugin>
Анализатор корректно работает с неаннотированным API JDK, но есть особенность относительно работы Objects.requireNonNull(@NonNull arg)
, аргумент считается @NonNull
, чтобы подсвечивать возможный NPE в точке вызова метода. Для обхода можно использовать механизм astub. В корне CVS репозитария нужно создать файл jdk.astub
import org.checkerframework.checker.nullness.qual.Nullable;
package java.util;
public class Objects {
public static <T> T requireNonNull(@Nullable T obj);
public static <T> T requireNonNull(@Nullable T obj, String message);
}
В файл можно вносить сигнатуры методов из других пакетов, начало классов пакета определяется ключевым словом package
.
Второй вариант - @SuppressWarnings({"nullness", "ConstantConditions"})
- на мой взгляд обхода более правильный. Во-первых, обязывает программиста реагировать на возможный NPE, во-вторых позволяет убрать warning не только для компилятора ("nullness"), но и для инспекций Idea ("ConstantConditions").
Настройка CI
Можно настроить CI, чтобы он не пропускал коммиты с потенциальными NPE, например так это можно сделать на GitHub с maven-плагином Checker Framework
on.pull_request.branches: ['master', 'develop']
jobs:
npecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: '18'
distribution: 'liberica'
cache: maven
- run: mvn --batch-mode clean compile
В заблокированный NPE инспекцией Merge Request легко внести правки, не обращаясь к логу CI, т.к. Idea подсвечивает зафейленные проверки Checker Framework, - это несомненно очень удобно. Idea даже еще на момент коммита покажет все warning (checkbox в окне коммита Analyze code, Choose profile
-> выбрать ранее настроенный "Inspections"), поэтому Merge Request может быть заблокирован только в случае игнорирования предупреждений.
Итоги
Рассмотрен статический анализатор NPE в Idea с использованием аннотаций JSR-305 и Checker Framework. Оба подхода позволяют настроить 10 проверок. JSR-305 имеет меньшего размера библиотеку (не уходит в runtime), однако не развивается, поэтому рекомендуется только для тех проектов, где уже внедрен. Существует проект-преемник JSR-305 - SpotBugs, однако его maven-плагин покрывает лишь от 1 до 3 проверок из 10 (в зависимости от используемой аннотации @Nullable
). Checker Framework покрывает все 10 проверок NPE и в Idea, и из maven-плагина, это позволяет настроить согласованное с Idea поведение проверок при CI.
Комментарии (5)
Akela_wolf
24.10.2022 12:40-3Можно, конечно, настраивать IDEA и разбрасывать аннотации. А можно перейти на Kotlin.
Asapin
26.10.2022 16:29+2Можно ещё подключить ErrorProne от гугла и плагин NullAway для него от Uber и получаем сразу статический анализатор + проверку на null в одном флаконе. Работает на этапе компиляции и довольно быстро, и не надо ничего дополнительно настраивать.
vananiev Автор
26.10.2022 19:57+1Эта схема переносится в maven, чтобы в CI прокинуть? Проверки переносятся согласовано (одинаковый набор ошибок в Idea и CI буду отображаться)?
venum
Подскажите, сработает ли проверка на классы из сторонних зависимостей, подключаемых мавеном, например, которые могут возвращать null?
vananiev Автор
Проверка срабатывает тот код, который компилируется в вашем проекте. Проверяется с учетом контракта сторонней зависимости.
Т.е. анализатор (в Idea или в maven-плагине) ругнется, если в сторонней зависимости есть аннотации Nonnull / Nullable (любой реализации; в стороннем проекте одна из аннотаций может быть настроена через ее отсутствие). Если сторонняя зависимость не использует эти аннотации, провека все еще может быть настроена через механизм astub вашего проекта, либо для статических методов стороннего проекта можно сделать классы хелперы-обертки с нужными аннотациями Nonnull / Nullable.
Вы можете потестить как поведет себя анализатор на примере сторонней зависимости, который можете подключить через maven. В зависимости расставлены аннотации Nonnull / Nullable. Также можно потестить на методах JDK, его контракт поставляется в
org.checkerframework:checker-qual