Здравствуйте, Хабр.
Иногда попадаются статьи, которые хочется перевести просто за имя. Еще интереснее, когда такая статья может пригодиться специалистам по разным языкам, но содержит примеры на Java. Совсем скоро надеемся поделиться с вами нашей новейшей идеей по поводу издания большой книги о Java, а пока предлагаем ознакомиться с публикацией Мартина Фаулера от декабря 2014, которая до сих пор не была переведена на русский язык. Перевод сделан с небольшими сокращениями.
Если вы выполняете валидацию тех или иных данных, то обычно не стоит использовать исключения в качестве сигнала о том, что валидация не пройдена. Здесь я опишу рефакторинг подобного кода с использованием паттерна «Уведомление».
Недавно мне попался на глаза код, выполнявший простейшую валидацию JSON-сообщений. Он выглядел примерно так:
public void check() { if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null"); if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive"); }
Именно так обычно и выполняется валидация. Вы применяете к данным несколько вариантов проверки (выше приведены лишь несколько полей целого класса). Если хотя бы один этап проверки не пройден, то выбрасывается исключение с сообщением об ошибке.
У меня возникали некоторые проблемы с таким подходом. Во-первых, мне не нравится использовать исключения в подобных случаях. Исключение — это сигнал о некотором экстраординарном событии в рассматриваемом коде. Но если вы подвергаете проверке некий внешний ввод, то допускаете, что вводимые сообщения могут содержать ошибки — то есть, ошибки ожидаемы, и применять в таком случае исключения неправильно.
Вторая проблема с подобным кодом заключается в том, что если он валится после первой же обнаруженной ошибки, то целесообразнее сообщать обо всех ошибках, возникших во вводимых данных, а не только о первой. В таком случае клиент сможет вывести пользователю сразу все ошибки, чтобы тот исправил их за одну операцию, а не заставлять пользователя играть с компьютером в кошки-мышки.
В таких случаях я предпочитаю организовывать отчетность об ошибках валидации при помощи паттерна «Уведомление». Уведомление — это объект, собирающий ошибки, при каждом проваленном акте валидации к уведомлению добавляется очередная ошибка. Метод валидации возвращает уведомление, которое вы затем можете разобрать для выяснения дополнительной информации. Простой пример — следующий код для выполнения проверок.
private void validateNumberOfSeats(Notification note) { if (numberOfSeats < 1) note.addError("number of seats must be positive"); // другие подобные проверки }
Затем можно сделать простой вызов вроде aNotification.hasErrors() для реагирования на ошибки, если таковые найдутся. Другие методы в уведомлении могут докапываться до более подробной информации об ошибках.
Когда применять такой рефакторинг
Здесь отмечу, что я не призываю избавиться от исключений во всей вашей базе кода. Исключения — очень удобный способ обработки нештатных ситуаций и изъятия их из основного логического потока. Предлагаемый рефакторинг уместен только в тех случаях, когда результат, сообщаемый при помощи исключения, на деле исключительным не является, а значит, должен обрабатываться основной логикой программы. Валидация, рассматриваемая здесь — как раз такой случай.
Удобное «железное правило», которым стоит пользоваться при реализации исключений, находим в Pragmatic Programmers:
Мы полагаем, что исключения следует лишь эпизодически использовать в рамках нормального хода программы; к ним нужно прибегать именно при исключительных событиях. Предположите, что неперехваченное исключение завершит вашу программу, и задайтесь вопросом: «продолжит ли этот код функционировать, если убрать из него обработчики исключений»? Если ответ отрицательный, то, вероятно, исключения применяются в неисключительных ситуациях.
— Дэйв Томас и Энди Хант
Отсюда важное следствие: решение о том, использовать ли исключения для конкретной задачи, зависит от ее контекста. Так, продолжают Дэйв и Энди, считывание из ненайденного файла может в разных контекстах быть или не быть исключительной ситуацией. Если вы пытаетесь считать файл из хорошо известного местоположения, например /etc/hosts в системе Unix, вполне логично предположить, что файл должен там оказаться, и в противном случае целесообразно выдать исключение. С другой стороны, если вы пытаетесь считать файл, расположенный по пути, введенному пользователем в командную строку, то должны допускать, что файла там не окажется, и использовать иной механизм — такой, который сигнализирует о неисключительной природе ошибки.
Есть случай, в котором было бы разумно использовать исключения при ошибке валидации. Подразумевается ситуация: есть данные, которые уже должны были пройти валидацию на более раннем этапе обработки, но вы хотите вновь сделать такую проверку, чтобы перестраховаться от программных ошибок, из-за которых могли бы проскользнуть какие-то недопустимые данные.
Эта статья рассказывает о замене исключений на уведомления в контексте валидации необработанного ввода. Такая техника пригодится вам и в тех случаях, когда уведомление — более целесообразный вариант, чем исключение, но здесь мы сосредоточимся на варианте с валидацией как наиболее распространенном.
Начало
Пока я не упоминал предметную область, поскольку описывал лишь самую общую структуру кода. Но далее при развитии этого примера потребуется точнее очертить предметную область. Речь пойдет о коде, принимающем в формате JSON сообщения о бронировании мест в театре. Код представляет собой класс запроса о бронировании, заполняемый на основе информации JSON при помощи библиотеки gson.
gson.fromJson(jsonString, BookingRequest.class)
Gson принимает класс, ищет любые поля, удовлетворяющие ключу в JSON-документе, а затем заполняет такие поля.
Данный запрос о бронировании содержит всего два элемента, которые мы будем валидировать: дату мероприятия и количество бронируемых мест
class BookingRequest…
private Integer numberOfSeats; private String date; Валидационные операции — такие, которые уже упоминались выше class BookingRequest… public void check() { if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null"); if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive"); }
Создание уведомления
Чтобы использовать уведомление, для него нужно создать специальный объект. Уведомление может быть очень простым, порой оно состоит всего лишь из списка строк.
Уведомление аккумулирует ошибки
List<String> notification = new ArrayList<>(); if (numberOfSeats < 5) notification.add("number of seats too small"); // сделать еще несколько проверок // затем… if ( ! notification.isEmpty()) // обработать условие ошибки
Хотя простая идиома списка обеспечивает легковесную реализацию паттерна, я предпочитаю этим не ограничиваться и пишу простой класс.
public class Notification { private List<String> errors = new ArrayList<>(); public void addError(String message) { errors.add(message); } public boolean hasErrors() { return ! errors.isEmpty(); } …
Применяя реальный класс, я четче выражаю мое намерение — читателю кода не приходится ментально соотносить идиому и ее полное значение.
Раскладываем проверочный метод на части
Первым делом я разделю проверочный метод на две части. Внутренняя часть в итоге будет работать только с уведомлениями и не выдавать никаких исключений. Внешняя часть сохранит актуальное поведение проверочного метода — то есть, будет выдавать исключение при провале валидации.
Для этого я первым делом использую выделение метода необычным образом: вынесу все тело проверочного метода в валидационный метод.
class BookingRequest…
public void check() { validation(); } public void validation() { if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null"); if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive"); }
Затем откорректирую валидационный метод так, чтобы он создавал и возвращал уведомление.
class BookingRequest…
public Notification validation() { Notification note = new Notification(); if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null"); if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive"); return note; }
Теперь могу проверить уведомление и выдать исключение, если оно содержит ошибки.
class BookingRequest…
public void check() { if (validation().hasErrors()) throw new IllegalArgumentException(validation().errorMessage()); }
Я сделал валидационный метод публичным, поскольку ожидаю, что большинство вызывающих в перспективе предпочтут использовать именно этот метод, а не проверочный.
Разбивая исходный метод на две части, я отграничиваю валидационную проверку от решения о том, как отреагировать на ошибку.
На данном этапе я еще вообще не трогал поведение кода. Уведомление не будет содержать никаких ошибок, а любые проваленные проверки валидации будут и далее выбрасывать исключения, игнорируя любую новую машинерию, которую я сюда добавлю. Но я готовлю почву, чтобы заменить выдачу исключений на работу с уведомлениями.
Прежде чем приступить к этому, нужно кое-что сказать насчет сообщений об ошибках. При рефакторинге существует правило: избегать изменений в наблюдаемом поведении. В таких ситуациях как эта данное правило немедленно ставит перед нами вопрос: какое поведение является наблюдаемым? Очевидно, выдача корректного исключения будет в определенной мере заметна для внешней программы — но в какой степени для нее актуально сообщение об ошибке? В итоге уведомление аккумулирует множество ошибок и сможет обобщить их в единое сообщение, примерно так:
class Notification…
public String errorMessage() { return errors.stream() .collect(Collectors.joining(", ")); }
Но здесь возникнет проблема, если на более высоком уровне выполнение программы завязано на получении сообщения лишь о первой обнаруженной ошибке, и тогда вам потребуется нечто вроде:
class Notification…
public String errorMessage() { return errors.get(0); }
Придется обращать внимание не только на вызывающую функцию, но и на все обработчики исключений, чтобы определить адекватный ответ на данную ситуацию.
Хотя здесь я никоим образом не мог спровоцировать никаких проблем, я обязательно скомпилирую и протестирую этот код, прежде, чем вносить новые изменения.
Валидация числа
Самый очевидный шаг в данном случае — заменить первую валидацию
class BookingRequest…
public Notification validation() { Notification note = new Notification(); if (date == null) note.addError("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null"); if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive"); return note; }
Очевидный шаг, но плохой, поскольку он сломает код. Если передать функции нулевую дату, то код добавит ошибку к уведомлению, но затем сразу же попытается ее разобрать и выдаст исключение нулевого указателя — а нас интересует не это исключение.
Поэтому в данном случае лучше сделать менее прямолинейную, но более эффективную вещь — шаг назад.
class BookingRequest…
public Notification validation() { Notification note = new Notification(); if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null"); if (numberOfSeats < 1) note.addError("number of seats must be positive"); return note; }
Предыдущая проверка — это проверка на нуль, поэтому нам нужна условная конструкция, которая позволила бы не создавать исключение нулевого указателя.
class BookingRequest…
public Notification validation() { Notification note = new Notification(); if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); if (numberOfSeats == null) note.addError("number of seats cannot be null"); else if (numberOfSeats < 1) note.addError("number of seats must be positive"); return note; }
Вижу, что следующая проверка затрагивает иное поле. Мало того, что на предыдущем этапе рефакторинга мне придется ввести условную конструкцию — теперь мне кажется, что валидационный метод чрезмерно усложняется, и его можно было бы разложить. Итак, выделяем части, отвечающие за валидацию чисел.
class BookingRequest…
public Notification validation() { Notification note = new Notification(); if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); validateNumberOfSeats(note); return note; } private void validateNumberOfSeats(Notification note) { if (numberOfSeats == null) note.addError("number of seats cannot be null"); else if (numberOfSeats < 1) note.addError("number of seats must be positive"); }
Смотрю на выделенную валидацию числа, и ее структура мне не нравится. Не люблю использовать при валидации блоки if-then-else, поскольку так легко может получиться код с чрезмерным количеством вложений. Предпочитаю линейный код, который прекращает работать сразу же, как только выполнение программы становится невозможным – такую точку можно определять при помощи граничного условия. Так я реализую замену вложенных условных конструкций граничными условиями.
class BookingRequest…
private void validateNumberOfSeats(Notification note) { if (numberOfSeats == null) { note.addError("number of seats cannot be null"); return; } if (numberOfSeats < 1) note.addError("number of seats must be positive"); }
При рефакторинге всегда следует пытаться делать минимальные шаги, чтобы сохранить имеющееся поведение
Мое решение сделать шаг назад играет при рефакторинге ключевую роль. Суть рефакторинга заключается в изменении структуры кода, но таким образом, чтобы выполняемые преобразования не меняли его поведения. Поэтому рефакторинг всегда нужно делать мелкими шажками. Так мы страхуемся от возникновения ошибок, которые могут настигнуть нас в отладчике.
Валидация даты
При валидации даты вновь начинаю с выделения метода:
class BookingRequest…
public Notification validation() { Notification note = new Notification(); validateDate(note); validateNumberOfSeats(note); return note; } private void validateDate(Notification note) { if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today"); }
Когда я использовал автоматизированное выделение метода в моей IDE, результирующий код не включал аргумента уведомления. Поэтому я попытался добавить его вручную.
Теперь давайте пойдем назад при валидации даты:
class BookingRequest…
private void validateDate(Notification note) { if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid format for date", e); } if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today"); }
На втором этапе возникает осложнение с обработкой ошибки, поскольку в выброшенном исключении есть условное исключение (cause exception). Чтобы его обработать, потребуется изменить уведомление — так, чтобы оно могло принимать такие исключения. Поскольку я как раз на полпути: отказываюсь от выбрасывания исключений и перехожу к работе с уведомлениями — мой код красный. Итак, я откатываюсь назад, чтобы оставить метод validateDate в вышеприведенном виде, и готовлю уведомление к приему условного исключения.
Приступая к изменению уведомления, я добавляю метод addError, принимающий условие, после чего изменяю исходный метод так, чтобы он мог вызывать новый метод.
class Notification…
public void addError(String message) { addError(message, null); } public void addError(String message, Exception e) { errors.add(message); }
Таким образом, мы принимаем условное исключение, но игнорируем его. Чтобы где-нибудь его разместить, мне нужно превратить запись об ошибке из простой строки в чуть более сложный объект.
class Notification…
private static class Error { String message; Exception cause; private Error(String message, Exception cause) { this.message = message; this.cause = cause; } }
Я не люблю неприватные поля в Java, однако поскольку здесь мы имеем дело с приватным внутренним классом, меня все устраивает. Если бы я собирался открывать доступ к этому классу ошибки где-нибудь вне уведомления, то инкапсулировал бы эти поля.
Итак, у меня есть класс. Теперь нужно модифицировать уведомление, чтобы использовать его, а не строку.
class Notification…
private List<Error> errors = new ArrayList<>(); public void addError(String message, Exception e) { errors.add(new Error(message, e)); } public String errorMessage() { return errors.stream() .map(e -> e.message) .collect(Collectors.joining(", ")); }
Имея новое уведомление, могу внести изменения и в запрос о бронировании
class BookingRequest…
private void validateDate(Notification note) { if (date == null) throw new IllegalArgumentException("date is missing"); LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { note.addError("Invalid format for date", e); return; } if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");
Поскольку я уже в выделенном методе, легко отменить оставшуюся валидацию при помощи команды возврата.
Последнее изменение совсем простое
class BookingRequest…
private void validateDate(Notification note) { if (date == null) { note.addError("date is missing"); return; } LocalDate parsedDate; try { parsedDate = LocalDate.parse(date); } catch (DateTimeParseException e) { note.addError("Invalid format for date", e); return; } if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today"); }
Вверх по стеку
Теперь, когда у нас есть новый метод, следующая задача — посмотреть, кто вызывает исходный проверочный метод и откорректировать эти элементы, чтобы в дальнейшем они пользовались новым валидационным методом. Здесь придется рассмотреть в более широком контексте, как такая структура вписывается в общую логику приложения, поэтому данная проблема выходит за рамки рассматриваемого здесь рефакторинга. Но наша среднесрочная задача – избавиться от использования исключений, огульно применяемых во всех случаях возможного непрохождения валидации.
Во многих ситуациях это позволит вообще избавиться от проверочного метода — тогда все связанные с ним тесты нужно будет переписать так, чтобы они работали с методом валидации. Кроме того, коррекция тестов может понадобиться для проверки того, правильно ли аккумулируются ошибки в уведомлении.
Фреймворки
Ряд фреймворков обеспечивает возможность валидации с применением паттерна уведомления. В Java это Java Bean Validation и валидация Spring. Эти фреймворки служат своеобразными интерфейсами, инициирующими валидацию и использующими уведомление для сбора ошибок ( Set<ConstraintViolation>
для валидации и Errors
в случае Spring).
Присмотритесь к вашему языку и платформе и проверьте, есть ли там валидационный механизм, использующий уведомления. Детали этого механизма отразятся на ходе рефакторинга, но в целом должно получиться очень похоже.
.
ссылка на оригинал статьи http://habrahabr.ru/post/270331/
Добавить комментарий