Русский
Русский
English
Статистика
Реклама

Java ee

Вы часто используете null? А он у нас в спецификации

09.02.2021 18:11:49 | Автор: admin

В нынешнее время для большинства разработчиков стали очевидными минусы использования null как возвращаемых типов или передачи их как аргумента.

Младшие разработчики даже если не понимают, то обычно следуют "чистому коду" (прочитав книжку Роберта Мартина). Поэтому код с возможностью возникновения NPE стал встречаться реже, хотя, конечно, они есть.

Не хочу сказать, что любое использование null - это плохо, скорее тут можно сказать "семь раз отмерь, один раз отрежь".

Тем не менее, я долгое время не жаловался насчет NPE, даже когда разработчики с языков у которых строгий контроль на эту тему хвастались своим null-safety. Но из-за одного бага я понял, что просто использование null это не так страшно, даже если вы возвращаете или передаете его. Разумеется, это очень плохо, но есть вещи хуже - null в спецификациях.

Было бы не особо интересно, если рассказ был об одной компании, которая сделала плохую спецификацию. Давайте вместо этого поговорим о более известной спецификации из Java EE - Java Servlet Specification, а конкретно возьмем класс HttpServletRequest и заглянем в метод getCookies()

getCookies

Cookie[] getCookies()

Returns an array containing all of the Cookie objects the client sent with this request. This method returns null if no cookies were sent.

  • Returns:

    an array of all the Cookies included with this request, or null if the request has no cookies

Больше всего следует обратить внимание на это:

This method returns null if no cookies were sent.

То есть, если здесь нет куков, то нужно вернуть null. Посмотрим на это со стороны разработчика:

  • Нам нужно вернуть массив из куки, а если их нет, то что-то, что означало бы, что куков в запросе не было.

В этом плане null выполняет свою роль, ведь кто как не он говорит, что значения там нет. Но тогда что же должен был означать пустой массив? Разве не то, что куков нет?

null vs empty array

В спецификации разработчики предпочли использовать null, что привело к некоторым последствиям, которые можно было бы избежать:

  • Усложнение API, которое заставляет пользователя каждый раз делать null-check

  • Запросы зачастую содержат хоть какой-нибудь куки, поэтому момент когда getCookies() возвратит null может произойти гораздо позже момента первого использования.

  • Усложнение имплементации этого метода. Если контейнер пустой, то нужно вернуть null (далее рассмотрим пример)

Но, конечно, не стоит считать что разработчики приняли такое решение напившись в баре. Использование null можно было оправдать, например, ради перформанса (избегаем аллоцирование пустого контейнера).

Тем не менее, это утверждение тоже можно поставить под сомнение.

  • Во-первых, обычно оптимизации такого уровня не требуются, если он действительно не сильно мешает производительности. Здесь нужно сделать маленькое отступление и сказать, что конкретно в этом случае это не совсем применимо, так как это спецификация и никогда не знаешь какой уровень оптимизации нужен будет разработчикам, которые разрабатывают продукт по этой спецификации.

  • Во-вторых, аллоцирования легко можно избежать просто создав пустой массив и возвращать его (например, какое-нибудь статическое неизменяемое поле)

Давайте посмотрим пару примеров, как использование null может испортить код

for (Cookie cookie : httpServletRequest.getCookies()) {   // NPE!  // }
int cookiesSize = httpServletRequest.getCookies().length    // NPE!

Добавляем null-check:

if (httpServletRequest.getCookies() != null)for (Cookie cookie : httpServletRequest.getCookies()) {    // }
Cookie[] cookies = httpServletRequest.getCookies();int cookiesSize = cookies == null ? 0 : cookies.length

Как и говорилось ранее, самое обидное, что этот NPE может и не появиться сразу. И разумеется, может быть сложным случайно не забыть сделать эту проверку.

Но усложнения касаются не только API, но и его имплементации. Рассмотрим как пример Jetty

Я не зря выбрал его, так как изначально он имел по сути неправильную реализацию или же лучше будет сказать имплементацию, которая не соответствует спецификации.

Коммит, который исправил ошибку

До:

return cookies == null?null:cookies.getCookies();

После:

if (cookies == null || cookies.getCookies().length == 0)  return null;return _cookies.getCookies();

При этом в реализации была не одна точка возвращения, поэтому разработчикам везде пришлось делать такую проверку.

Справедливости ради стоит сказать, что реализация была неправильной как с точки зрения спецификации, так и со стороны нормального кода. Так как null все равно мог вернуться и просто не было проверки на длину массива, которая если он пустой, то должен вернуть null. Тем не менее, он хорошо показывает усложнение реализации.

Мы против!

Хотя люди, конечно же, возмущались новым изменениям, но не идти же против спецификации. Или же есть смельчаки, которые сделали это? Разумеется есть, хотя я и не знаю нарочно ли они это или по нутру хорошего кода изменили спецификацию?

Например, проект classpathx

The GNU Classpath Extensions project, aka classpathx builds free versions of Oracle's Java extension libraries, the packages in the javax namespace. It is a companion project of the GNU Classpath project.

У них есть скажем так "своя спецификация"

Cookie[] getCookies()

Gets all the Cookies present in the request.

  • Returns:

    an array containing all the Cookies or an empty array if there are no cookies

  • Since:

    2.0

Статические анализаторы

Не обойдем стороной и статические анализаторы. Они также считают что возвращать null не лучшее решение. Например, тот же Sonar, SEI CERT Oracle Coding Standart for Java

Заключение

На этом примере мы можем увидеть как важно правильно составлять спецификации. Нужно думать как о разработчиках, которые будут его имплементировать, так и пользователях, которые будут их использовать.

В этом случае как разработчикам как и клиентам пришлось усложнять свой код.

Кто-то может сказать что в те времена, когда писалась спецификация не были так уж очевидны последствия использования null и это было обычной практикой. Да, так и есть, нам гораздо легче говорить об этом, так как мы имеем опыт предыдущих разработчиков, которые попались на этот капкан и которые нас и научили не попадаться на них. Это они были первопроходцами и поделились с нами знаниями и было бы не очень хорошо так уж сильно их осуждать, вместо благодарностей.

Тем не менее, на то это и спецификация, что она должна быть очень хорошо спроектирована, учитывая что она должна иметь обратную совместимость. Ведь проблемы с null существовали и до осознания того, что они есть.

Все что мы можем сейчас сделать - это вынести уроки из предыдущих ошибок (и своих и чужих):

  • Проблемы каких-то решений могут существовать и до того, как вы их осознаете

  • То что сейчас является обычной практикой в будущем может оказаться плохим кодом

  • Нужно делиться своими знаниями и опытом. Даже не ради кого-то другого, а ради себя, ведь возможно именно вашу статью прочитает будущий разработчик самого популярного фреймворка.

Язык и сообщество не стоит на месте и мы замечаем ошибки прошлого и это очень хорошо, ведь это означает, что мы стали лучше.


Speaking at a software conference in 2009, Tony Hoare apologized for inventing the null reference:[25]

I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object oriented language (ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn't resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years

Подробнее..

DI из ада

20.06.2020 10:15:54 | Автор: admin

Все мы любим Spring. Или не любим. Но по крайней мере знаем. Если вы Java-программист, то вероятно используете этот фреймворк каждый день в своей работе. Spring это огромная платформа, которая предоставляет большой функционал. Тем не менее во главе угла стоят две вещи это DI (Dependency Injection) и IoC (Inversion of Control). Концепции, которые были призваны, чтобы сделать наш код более читаемым и поддерживаемым. Но к несчастью, все оказалось не так радужно. Именно это мы сегодня и обсудим.


Дальнейшие обсуждения применимы не только к Spring, но и к стеку Jakarta EE.

Триггером к написанию этой статьи послужил вот этот вопрос на StackOverflow. Суть заключалась в том, чтобы понять порядок вызовов @PostConstruct и @Autowired, если один компонент зависит от другого. Исходя из этого нужно было определить, что и в каком порядке будет выведено на экран. Мы вернемся к этой задаче в конце статьи, а пока давайте посмотрим на код. Для наглядности я его немного видоизменил, но смысл вопроса сохранен.


@Servicepublic class Parent {    @Autowired    private Child child;    public int getNine() {        return child.sum(6, 3);    }    @PostConstruct    private void init() {        System.out.println("Parent is called");    }}@Servicepublic class Child {    @Autowired    private Parent parent;    public int sum(int a, int b) {        return a + b;    }    @PostConstruct    private void init(){        System.out.println("Child is called");    }}

Люди оставляли довольно подробные ответы с пояснениями. Мол, сначала Spring сделает это, потом то и так далее. Однако я считаю, что здесь стоило несколько сместить акцент. На мой взгляд в этом коде присутствует ряд архитектурных ошибок, после исправления которых вопросы о порядке инстанцирования и вызовов @PostConstruct отпали бы сами собой. Что конкретно здесь плохо? Это мы разберем чуть далее.


Истоки зла


В начале было слово, и слово было @Autowired. Благодаря этой аннотации совершается магия внедрения зависимостей в наш код. Давайте взглянем на ее декларацию.


@Target({    ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PARAMETER,     ElementType.FIELD, ElementType.ANNOTATION_TYPE})@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface Autowired {    boolean required() default true;}

@Target определяет возможные варианты размещения аннотации. Мы не будет брать в расчет ANNOTATION_TYPE и PARAMETER. Так как первый применяется для хитрого механизма наследования аннотаций в Spring, а второй для тестовых сред (по крайней мере, так написано в JavaDoc, но лично я ни разу не видел реального примера).
Отсюда можно сделать вывод, что нас интересует три способа применения @Autowired:


  • Поля класса
  • Сеттеры
  • Конструкторы

Рассмотрим каждый из них подробнее.


DI через поля


Я думаю, что это худший из всех предствавленных вариантов. Он нарушает принципы ООП и логику управления потоком данных. Нет ни конструктора, ни сеттера, но зависимость каким-то образом просто появляется внутри объекта. Spring использует Reflection API, поэтому ему совершено безразличны область видимости и отсутствие публичного доступа. Однако нас, программистов, это еще как касается.


Во-первых, на такой класс невозможно написать хороший unit-тест. Чтобы убедиться, давайте попробуем сделать это. Напишем тест на вышеприведенный Parent.


class ParentTest {    @Test    void testGetNine() {        Parent parent = new Parent();        assertEquals(9, parent.getNine());    }}

Здесь мы ожидаемо получим NullPointerException, так как зависимость на Child отсутствует. Но мы не сдаемся. Воспользуемся упомянутым Reflection API.


class ParentATest {    @Test    void testGetNine() throws NoSuchFieldException, IllegalAccessException {        Parent parent = new Parent();        Class<? extends Parent> clazz = parent.getClass();        Field field = clazz.getDeclaredField("child");        field.setAccessible(true);        field.set(parent, new Child());        assertEquals(9, parent.getNine());        field.setAccessible(false);    }}

Теперь тест работает так, как ожидается. Насколько плохим является такой подход? Я бы сказал, что на 10 хрустальных ваз из 10. Если мы поменяем имя поля или, не дай бог, тип переменной, тест тут же упадет. Но мы не узнаем об этом до тех пор, пока не запустим его. Особенно неприятно, когда подобные ошибки дают о себе знать после пятнадцатиминутного ожидания сборки на Jenkins.


Но тесты это не единственная проблема (да и будем честны, не всегда мы их пишем). Предположим, что теперь необходимо производить инициализацию через BeanFactory.


@Configurationpublic class ParentFactory {    @Bean    @Autowired    public Parent parent(Child child) {        // дополнительные проверки        ...        return new Parent();    }}

К сожалению, свойство child опять останется null, потому что нет законного способа передать для него значение. Конечно, мы могли бы вновь использовать всевидящее око. Но хуже рефлексии в тестах может быть только неоправданная рефлексия в коде продукта.


Как видим, DI через поля порождает гору проблем, при этом не давая ничего взамен, кроме сомнительной пользы от уменьшения кода класса на несколько строчек.


DI через сеттеры


Давайте перепишем компоненты, используя внедрение зависимостей через сеттеры.


@Servicepublic class Parent {    private Child child;    @Autowired    public void setChild(Child child) {        this.child = child;    }    public int getNine() {        return child.sum(6, 3);    }    @PostConstruct    private void init() {        System.out.println("Parent is called");        System.out.println(child.sum(6, 3));    }}@Servicepublic class Child {    private Parent parent;    @Autowired    public void setParent(Parent parent) {        this.parent = parent;    }    public int sum(int a, int b) {        return a + b;    }    @PostConstruct    private void init() {        System.out.println("Child is called");    }}

Стало гораздо лучше. Более того, теперь и тесты можно писать без проблем, и при необходимости инстанцировать объект через BeanFactory.


class ParentTest {    @Test    void testGetNine() {        Parent parent = new Parent();        parent.setChild(new Child());        assertEquals(9, parent.getNine());    }}@Configurationpublic class ParentFactory {    @Bean    @Autowired    public Parent parent(Child child) {        // дополнительные проверки        ...        Parent parent = new Parent();        parent.setChild(child);        return parent;    }}

Казалось бы, живи и радуйся. Но и тут есть ложка дегтя. Наличие публичного сеттера дает нам возможность проворачивать нехорошие манипуляции над бизнес-логикой. К примеру, вот такие.


@Servicepublic class Outsider {    private Parent parent;    @Autowired    public void setParent(Parent parent) {        this.parent = parent;    }    public void prank() {        parent.setChild(null);    }}

Вскоре у нас возникнут проблемы. А может быть и не возникнут. Мы этого не знаем. Возможно эта мина подорвется через 5 минут, а возможно через 5 лет. Все зависит от частоты использования подпорченного нами функционала.


Кто-то может заметить, что такого никогда не произойдет. Зачем так писать? В этом нет никакого смысла. Я думаю, что вы будете правы. Если бы такой код упал ко мне код-ревью, аппрувать его я бы точно не стал. Но вот пример, где это имело бы смысл.


Предположим, что Parent был объявлен со @Scope(SCOPE_PROTOTYPE). Это означает, что при каждом запросе к ApplicationContext.getBean(Parent.class) будет возвращаться новый инстанс класса. В одном из участков программы потребовалось поменять стандартное поведение компонента. Поэтому через parent.setChild была передана другая имплементация (в данном случае наследник). Все работало как часы. Ведь мы всего лишь поменяли поле у только что созданного объекта. Но в какой-то момент аннотация @Scope была убрана, что означает, что теперь ApplicationContext возвращает синглтон. А тот самый сеттер поменял поведения объекта во всей программе.


Кроме того, у DI через сеттеры и поля есть общая проблема циклические зависимости. Я бы разделил их на два типа: явные и неявные.


image


Такое невозможно провернуть через конструктор, так как в процессе инициализации бинов мы получим исключение. В нашем случае имеет место быть явная циклическая зависимость.


Что в этом плохого? На мой взгляд они нарушают уровни взаимодействия абстракций. По заветам Дядюшки Боба мы знаем, что потоки данных должны распространятся от более высоких к более низким слоям абстракций. При этом нижние уровни ничего не знают о вышестоящих. Звучит разумно. В конце концов, было бы странно, если драйвер для принтера мог позвонить кому-нибудь в Skype. Однако при наличии возможности внедрить циклические зависимости этот принцип очень легко нарушить. Самым обидным является то, что далеко не всегда такая циклическая связь очевидна.


Считаю ли я сеттеры абсолютным злом? Не совсем. Я думаю, что их нужно избегать, но в некоторых ситуациях без них не обойтись. Отличный пример привел Евгений Борисов в своем докладе Spring-потрошитель, часть 1 (34:56). В рантайме с помощью JMX в Java-приложении переключался флаг профилирования функций. Очевидно, что без сеттера этого не сделать.


Я написал довольно подробную статью на Medium, в которой высказываю свою позицию насчет сеттеров. Желающие могут ознакомиться по этой ссылке.


DI через конструкторы


Давайте заменим сеттеры конструкторами.


@Servicepublic class Parent {    private final Child child;    @Autowired    public Parent(Child child) {        this.child = child;    }    public int getNine() {        return child.sum(6, 3);    }    @PostConstruct    private void init() {        System.out.println("Parent is called");        System.out.println(child.sum(6, 3));    }@Servicepublic class Child {    public int sum(int a, int b) {        return a + b;    }    @PostContruct    private void init() {        System.out.println("Child is called");    }}

Для того чтобы приложение корректно запустилось, необходимо было избавиться от циклической зависмости Parent Child Parent. Благо здесь это не составило труда.


Я думаю, что этот вариант является лучшим из всех представленных. Во-первых, классы тестируемы. Во-вторых, при необходимости мы можем легко поменять тип инициализации бина. В-третьих, все зависимости инкапсулированы внутри компонентов и не могут быть изменены ни снаружи, ни внутри, так как поля final.


Однако еще не все. Осталось последнее @PostConstruct. Подробно свое мнение по поводу этой аннотации я высказал в этой статье, но вкратце скажу, что здесь в ней нет никакой необходимости и мы можем просто вызвать функцию init внутри конструктора. Приятным бонусом является то, что, начиная со Spring 4.3, @Autowired использовать необязательно, если в классе присутствует единственный конструктор, через который внедряются все зависимости.


Исходя из всех вышеописанных выводов, перепишем Parent и Child.


@Servicepublic class Parent {    private final Child child;    public Parent(Child child) {        this.child = child;        init();    }    public int getNine() {        return child.sum(6, 3);    }    private void init() {        System.out.println("Parent is called");        System.out.println(child.sum(6, 3));    }}@Servicepublic class Child {    public Child() {        init();    }    public int sum(int a, int b) {        return a + b;    }    private void init() {        System.out.println("Child is called");    }}

А теперь давайте вернемся к изначальному вопросу. В каком порядке будут инстанцированы объекты и выведены надписи на экран? Теперь решение прозрачно. Parent зависит от Child, поэтому он не может быть создан первым. Следовательно, порядок вызовов конструкторов Child Parent. Значит, сначала мы увидим "Child is called", потом "Parent is called", а потом 9. Это поведение детерменировано. Мы можем запускать код сколько угодно раз, но результат всегда будет один и тот же.


Выводы


Несмотря на то, что Spring позволяет нам внедрять зависимости как через сеттеры, так и через поля, следует этого избегать. Я думаю, многие со мной согласятся, что вариант классов Parent и Child с DI через конструкторы намного понятнее и чище. Скорее всего, это не вызовет у ваших коллег никаких вопросов, в отличии от запутанного flow с полями и @PostConstruct. Если вы у вас есть вопросы или замечания, прошу оставить комментарии. Спасибо за чтение!


Ссылки


Подробнее..
Категории: Ооп , Java , Spring , Java ee , Dependency injection

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru