Pull to refresh

Пишем код без NPE. Настройка Intellij Idea и CI

Level of difficultyMedium
Reading time11 min
Views7.6K

Чтобы соответствовать принципу подстановки Барбары Лисков (SOLID) с точки зрения заменяемости класса-родителя классом-наследником, нужны следующие проверки аргументов метода и возвращаемых значений:

  1. Если возвращаемый тип метода предка является Nonnull, то переопределенный метод наследника тоже должен быть Nonnull. Остальное допустимо.

  2. Если аргумент метода предка является Nullable, то переопределенный метод наследника тоже должен иметь Nullable аннотацию. Остальное допустимо.

Но это не все проверки, которые выполнит за вас Idea после соответствующей настройки. Полный перечень проверок приведен ниже в таблице. Из "коробки" Idea выполняет только две проверки (не те, что выше). Если вы пишете null free код, то статья для вас окажется все равно полезной по причине: 1) наличия стороннего кода; 2) легаси кода; и 3) по причине, что null может быть использован в критических участках кода.

Зависимости

Для обеспечения таких проверок каждый метод и аргумент метода должны быть обозначены аннотациями @Nullable и @Nonnull. Чтобы не утонуть в этих аннотациях можно прийти к соглашению, что аннотацию @Nonnull не нужно указывать, т.е. что она неявная.

Чтобы научить Idea определять отсутствие аннотации как @Nonnull, нужно выполнить некоторые манипуляции с кодом. Рассматривалось три подхода, которые умеет обрабатывать Idea. Вариант с аннотацией org.eclipse.jdt.annotation.NonNullByDefault не рассматриваю.

  1. Подход на основе JSR-305. Требуется создать мета-аннотацию, которая настраивается на классы одного пакета. Действие аннотации не распространяется на классы подпакетов. Не поддерживаемая сообществом технология.

  2. Подход на основе 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 problemsReturn 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, каждый в группе должен ее настроить так, как настроили остальные участники, чтобы аннотации проставлялись одинаково всеми.

Инспекции в Idea
Инспекции в Idea
Настройка аннотаций для автодополнения
Настройка аннотаций для автодополнения

Результат

В таблице указаны проверки (там, где далее упоминается Nonnull, по соглашению считать, что аннотация на элементе должна отсутствовать; теоретически Nonnull можно и указывать, на проверки это не повлияет).

Проверка

По умолчанию

После конфигурации

1

Если возвращаемый тип метода предка является Nonnull, то переопределенный метод наследника тоже должен иметьNonnull аннотацию. Остальное допустимо

2

Если аргумент метода предка является Nullable, то переопределенный метод наследника тоже должен быть Nullable. Остальное допустимо

3

Проверяется, что аннотации на setter и getter методах соответствуют аннотациям полей класса

4

Проверяется передача null в аргумент метода, который объявлен Nonnull

5

Проверяется наличие аннотации Nullable на аргументе метода, который принимает null откуда-либо (можно исправлять либо этот warn, либо предыдущий)

6

Проверяется наличие аннотации Nullable, если полю присвоено null

7

Проверяется, что Nonnull полю не присваивается null (можно править этот или предыдущий warn)

8

Проверяется наличие аннотации Nullable, если метод может вернуть null

9

Проверяется отсутствие аннотации Nullable, если метод всегда возвращает не null

10

Проверяется возможность получения NPE при работе с объектом, например при вызове метода на объекте, который может принимать значение null

Так выглядит инспекция в Idea Ultimate 2022.2.4 без настройки.

Инспекции Idea по умолчанию
Инспекции Idea по умолчанию

Так будет выглядеть инспекция после настройки.

Инспекции Idea после конфигурации
Инспекции 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

Позволяет обнаружить 9 NPE из 10 (кроме 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-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
            <arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=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").

Nullaway plugin

По наводке @Asapin проверены анализаторы Errorprone + NullAway.

<properties>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
    <javac.version>9+181-r4173-1</javac.version>
</properties>

<plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.8.0</version>
    <configuration>
        <showWarnings>true</showWarnings>
        <compilerArgs>
            <arg>-XDcompilePolicy=simple</arg>
            <arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=org</arg>
        </compilerArgs>
        <annotationProcessorPaths>
            <path>
                <groupId>com.google.errorprone</groupId>
                <artifactId>error_prone_core</artifactId>
                <version>2.4.0</version>
            </path>
            <path>
                <groupId>com.uber.nullaway</groupId>
                <artifactId>nullaway</artifactId>
                <version>0.8.0</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

Nullaway находит 5 NPE из 10 (проверки 1, 4, 7, 8, 10 из таблицы выше). Подробности в комментарии.

Настройка 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 проверок (из коробки Idea выполняет только 2 проверки). JSR-305 имеет меньшего размера библиотеку (не уходит в runtime), однако не развивается, поэтому рекомендуется только для тех проектов, где уже внедрен. Существует проект-преемник JSR-305 - SpotBugs, однако его maven-плагин покрывает лишь от 1 до 3 проверок из 10 (в зависимости от используемой аннотации @Nullable). Плагин Nullaway покрывает 5 NPE проверок. Checker Framework покрывает все 10 проверок NPE, причем набор проверки и в Idea, и из maven-плагина получается одинаковый, это позволяет настроить согласованное с Idea поведение проверок при CI.

Tags:
Hubs:
Total votes 15: ↑13 and ↓2+11
Comments13

Articles